From 16e490e30ebba815a813fa049ceda0c34607df85 Mon Sep 17 00:00:00 2001 From: mroczect Date: Tue, 8 Sep 2026 00:35:02 +0700 Subject: [PATCH 01/48] feractor(clean): remove 0.5.0 source --- demo/.gitignore | 4 - demo/Cargo.lock | 889 --- demo/Cargo.toml | 9 - demo/config.yaml | 43 - demo/content/about.md | 6 - demo/content/blog/post1.md | 7 - demo/content/blog/post2.md | 7 - demo/content/index.md | 6 - demo/src/main.rs | 50 - demo/static/style.css | 17 - demo/templates/base.html | 34 - demo/templates/blog_list.html | 11 - demo/templates/blog_post.html | 7 - demo/templates/rss.xml | 16 - demo/templates/sitemap.xml | 11 - docs/.gitignore | 6 - docs/Cargo.lock | 780 --- docs/Cargo.toml | 7 - docs/config.yml | 43 - docs/content/code_of_conduct.md | 131 - docs/content/configuration.md | 423 -- docs/content/contributing.md | 655 -- docs/content/index.md | 48 - docs/content/installation.md | 243 - docs/content/license.md | 28 - docs/content/references.md | 538 -- docs/src/main.rs | 49 - docs/static/normalize.css | 351 -- docs/static/sidebar.css | 88 - docs/static/style.css | 6838 --------------------- docs/templates/base.html | 87 - docs/templates/rss.xml | 19 - docs/templates/sitemap.xml | 13 - tests/common/mod.rs | 227 - tests/common_mocks.rs | 43 - tests/config_tests.rs | 89 - tests/edge_cases_tests.rs | 54 - tests/error_tests.rs | 16 - tests/frontmatter_tests.rs | 79 - tests/fs_mock_tests.rs | 76 - tests/fs_real_tests.rs | 43 - tests/integration_advanced_tests.rs | 99 - tests/integration_test.rs | 162 - tests/markdown_tests.rs | 35 - tests/property_tests.proptest-regressions | 7 - tests/property_tests.rs | 32 - tests/serve_tests.rs | 74 - tests/site_builder_tests.rs | 148 - tests/site_page_tests.rs | 107 - tests/util_property_tests.rs | 19 - tests/util_tests.rs | 81 - tests/watcher_tests.rs | 20 - 52 files changed, 12875 deletions(-) delete mode 100644 demo/.gitignore delete mode 100644 demo/Cargo.lock delete mode 100644 demo/Cargo.toml delete mode 100644 demo/config.yaml delete mode 100644 demo/content/about.md delete mode 100644 demo/content/blog/post1.md delete mode 100644 demo/content/blog/post2.md delete mode 100644 demo/content/index.md delete mode 100644 demo/src/main.rs delete mode 100644 demo/static/style.css delete mode 100644 demo/templates/base.html delete mode 100644 demo/templates/blog_list.html delete mode 100644 demo/templates/blog_post.html delete mode 100644 demo/templates/rss.xml delete mode 100644 demo/templates/sitemap.xml delete mode 100644 docs/.gitignore delete mode 100644 docs/Cargo.lock delete mode 100644 docs/Cargo.toml delete mode 100644 docs/config.yml delete mode 100644 docs/content/code_of_conduct.md delete mode 100644 docs/content/configuration.md delete mode 100644 docs/content/contributing.md delete mode 100644 docs/content/index.md delete mode 100644 docs/content/installation.md delete mode 100644 docs/content/license.md delete mode 100644 docs/content/references.md delete mode 100644 docs/src/main.rs delete mode 100644 docs/static/normalize.css delete mode 100644 docs/static/sidebar.css delete mode 100644 docs/static/style.css delete mode 100644 docs/templates/base.html delete mode 100644 docs/templates/rss.xml delete mode 100644 docs/templates/sitemap.xml delete mode 100644 tests/common/mod.rs delete mode 100644 tests/common_mocks.rs delete mode 100644 tests/config_tests.rs delete mode 100644 tests/edge_cases_tests.rs delete mode 100644 tests/error_tests.rs delete mode 100644 tests/frontmatter_tests.rs delete mode 100644 tests/fs_mock_tests.rs delete mode 100644 tests/fs_real_tests.rs delete mode 100644 tests/integration_advanced_tests.rs delete mode 100644 tests/integration_test.rs delete mode 100644 tests/markdown_tests.rs delete mode 100644 tests/property_tests.proptest-regressions delete mode 100644 tests/property_tests.rs delete mode 100644 tests/serve_tests.rs delete mode 100644 tests/site_builder_tests.rs delete mode 100644 tests/site_page_tests.rs delete mode 100644 tests/util_property_tests.rs delete mode 100644 tests/util_tests.rs delete mode 100644 tests/watcher_tests.rs diff --git a/demo/.gitignore b/demo/.gitignore deleted file mode 100644 index 1d96de9..0000000 --- a/demo/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -/target -/dev -.velodiff_dist.diff -.snapcat.md \ No newline at end of file diff --git a/demo/Cargo.lock b/demo/Cargo.lock deleted file mode 100644 index 7ac70d1..0000000 --- a/demo/Cargo.lock +++ /dev/null @@ -1,889 +0,0 @@ -# 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 = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - -[[package]] -name = "autocfg" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" - -[[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 = "bitflags" -version = "2.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" - -[[package]] -name = "bumpalo" -version = "3.20.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" - -[[package]] -name = "cc" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" -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.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "serde", - "wasm-bindgen", - "windows-link", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[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", -] - -[[package]] -name = "fastrand" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" - -[[package]] -name = "find-msvc-tools" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" - -[[package]] -name = "futures-core" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" - -[[package]] -name = "futures-task" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" - -[[package]] -name = "futures-util" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" -dependencies = [ - "futures-core", - "futures-task", - "pin-project-lite", - "slab", -] - -[[package]] -name = "getopts" -version = "0.2.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" -dependencies = [ - "unicode-width 0.2.2", -] - -[[package]] -name = "getrandom" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" -dependencies = [ - "cfg-if", - "libc", - "r-efi", -] - -[[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.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" - -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" - -[[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 = "indexmap" -version = "2.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" -dependencies = [ - "equivalent", - "hashbrown", -] - -[[package]] -name = "is_ci" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "js-sys" -version = "0.3.103" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" -dependencies = [ - "cfg-if", - "futures-util", - "wasm-bindgen", -] - -[[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.189" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" - -[[package]] -name = "librawssg" -version = "0.5.0" -dependencies = [ - "chrono", - "glob", - "miette", - "pulldown-cmark", - "serde", - "serde_yaml", - "tera", - "thiserror", - "tracing", - "walkdir", -] - -[[package]] -name = "librawssg-demo" -version = "0.1.0" -dependencies = [ - "librawssg", - "tempfile", - "tracing-subscriber", -] - -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - -[[package]] -name = "log" -version = "0.4.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" - -[[package]] -name = "memchr" -version = "2.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" - -[[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 2.0.119", -] - -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", -] - -[[package]] -name = "nu-ansi-term" -version = "0.50.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" -dependencies = [ - "windows-sys", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[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.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "owo-colors" -version = "4.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "proc-macro2" -version = "1.0.107" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "pulldown-cmark" -version = "0.13.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" -dependencies = [ - "bitflags", - "getopts", - "memchr", - "pulldown-cmark-escape", - "unicase", -] - -[[package]] -name = "pulldown-cmark-escape" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" - -[[package]] -name = "quote" -version = "1.0.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - -[[package]] -name = "rustc-demangle" -version = "0.1.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" - -[[package]] -name = "rustix" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys", -] - -[[package]] -name = "rustversion" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" - -[[package]] -name = "ryu" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" - -[[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 = "serde" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "serde_yaml" -version = "0.9.34+deprecated" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" -dependencies = [ - "indexmap", - "itoa", - "ryu", - "serde", - "unsafe-libyaml", -] - -[[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 = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "smallvec" -version = "1.15.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" - -[[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 = "syn" -version = "2.0.119" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "3.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "tempfile" -version = "3.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand", - "getrandom", - "once_cell", - "rustix", - "windows-sys", -] - -[[package]] -name = "tera" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "511f07fd91a70e92efbe4793d111aaa9035f8474dd157aaa1e31e7c27f5051da" -dependencies = [ - "serde", -] - -[[package]] -name = "terminal_size" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" -dependencies = [ - "rustix", - "windows-sys", -] - -[[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 = "2.0.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "thread_local" -version = "1.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "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 2.0.119", -] - -[[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.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" -dependencies = [ - "nu-ansi-term", - "sharded-slab", - "smallvec", - "thread_local", - "tracing-core", - "tracing-log", -] - -[[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.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "unicode-linebreak" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" - -[[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 = "unsafe-libyaml" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" - -[[package]] -name = "valuable" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" - -[[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 = "wasm-bindgen" -version = "0.2.126" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.126" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.126" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn 2.0.119", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.126" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys", -] - -[[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 2.0.119", -] - -[[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 2.0.119", -] - -[[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.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] diff --git a/demo/Cargo.toml b/demo/Cargo.toml deleted file mode 100644 index 5b05d0f..0000000 --- a/demo/Cargo.toml +++ /dev/null @@ -1,9 +0,0 @@ -[package] -name = "librawssg-demo" -version = "0.1.0" -edition = "2024" - -[dependencies] -librawssg = { path = "..", features = ["tera", "pulldown"] } -tempfile = "3" -tracing-subscriber = "0.3.23" diff --git a/demo/config.yaml b/demo/config.yaml deleted file mode 100644 index 5a6ef1d..0000000 --- a/demo/config.yaml +++ /dev/null @@ -1,43 +0,0 @@ -site: - site_name: "My librawssg Site" - description: "A static site built with librawssg" - language: "id" - base_url: "https://example.com" - author: "Your Name" - navbar: - - label: "Beranda" - url: "/" - - label: "Tentang" - url: "/about.html" - - label: "Blog" - url: "/blog/index.html" - sidebar: - - label: "RSS" - url: "/rss.xml" - -build: - content_dir: "content" - output_dir: "./dist" - templates_dir: "templates" - static_dir: "static" - -content_types: - - name: "page" - pattern: "*.md" - template: "base.html" - list_enabled: false - - name: "blog" - pattern: "blog/*.md" - template: "blog_post.html" - list_template: "blog_list.html" - list_enabled: true - -generators: - rss: - enabled: false - path: "rss.xml" - template: "rss.xml" - sitemap: - enabled: false - path: "sitemap.xml" - template: "sitemap.xml" diff --git a/demo/content/about.md b/demo/content/about.md deleted file mode 100644 index 1502dd0..0000000 --- a/demo/content/about.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -title: About -desc: About this site ---- - -This site is built with librawssg, a super stable static site generator kernel. diff --git a/demo/content/blog/post1.md b/demo/content/blog/post1.md deleted file mode 100644 index e2aad31..0000000 --- a/demo/content/blog/post1.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -title: First Blog Post -desc: The very first post -date: 2025-01-01 ---- - -This is my first blog post. librawssg handles Markdown perfectly. diff --git a/demo/content/blog/post2.md b/demo/content/blog/post2.md deleted file mode 100644 index 8bdc823..0000000 --- a/demo/content/blog/post2.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -title: Second Blog Post -desc: Another interesting post -date: 2025-02-15 ---- - -Second post with **bold text** and [a link](https://github.com/mroczect/librawssg). diff --git a/demo/content/index.md b/demo/content/index.md deleted file mode 100644 index e71f16e..0000000 --- a/demo/content/index.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -title: Home -desc: Welcome to my static site ---- - -This is the home page, generated by **librawssg**. diff --git a/demo/src/main.rs b/demo/src/main.rs deleted file mode 100644 index a8fbb73..0000000 --- a/demo/src/main.rs +++ /dev/null @@ -1,50 +0,0 @@ -use librawssg::markdown::PulldownMarkdown; -use librawssg::site::context::{TeraFeedContextBuilder, TeraSitemapContextBuilder}; -use librawssg::site::TeraRenderer; -use librawssg::SiteBuilder; -use std::path::Path; - -fn main() -> Result<(), Box> { - tracing_subscriber::fmt::init(); - - let builder = SiteBuilder::new() - .load_config("config.yaml") - .expect("Gagal memuat config.yaml"); - - let mut tera = TeraRenderer::new(); - let templates_dir = Path::new("templates"); - if templates_dir.exists() { - for entry in std::fs::read_dir(templates_dir)? { - let entry = entry?; - let path = entry.path(); - if path.is_file() { - let name = path.file_name().unwrap().to_string_lossy().into_owned(); - let content = std::fs::read_to_string(&path)?; - tera.add_raw_template(&name, &content)?; - } - } - } - - let md = PulldownMarkdown; - let content_dir = Path::new("content"); - let output_dir = Path::new("./dist"); - - let site = builder - .content_dir(content_dir) - .output_dir(output_dir) - .with_template_renderer(Box::new(tera)) - .with_markdown_renderer(Box::new(md)) - .with_feed_context_builder(Box::new(TeraFeedContextBuilder)) - .with_sitemap_context_builder(Box::new(TeraSitemapContextBuilder)) - .build()?; - - println!("Jumlah halaman: {}", site.pages().len()); - for page in site.pages() { - println!(" {} -> {}", page.file_path, page.url); - } - - site.generate()?; - println!("Situs berhasil dibuat di {}", output_dir.display()); - - Ok(()) -} diff --git a/demo/static/style.css b/demo/static/style.css deleted file mode 100644 index c85f439..0000000 --- a/demo/static/style.css +++ /dev/null @@ -1,17 +0,0 @@ -body { - font-family: sans-serif; - max-width: 800px; - margin: 0 auto; - padding: 1rem; -} -header { - border-bottom: 1px solid #ccc; - margin-bottom: 1rem; -} -footer { - margin-top: 2rem; - padding-top: 1rem; - border-top: 1px solid #ccc; - font-size: 0.8rem; - color: #666; -} diff --git a/demo/templates/base.html b/demo/templates/base.html deleted file mode 100644 index 67feb70..0000000 --- a/demo/templates/base.html +++ /dev/null @@ -1,34 +0,0 @@ - - - - - {{ page_title }} - {{ site.site_name }} - - - - -
-

{{ site.site_name }}

- -
- {% if site.sidebar %} - - {% endif %} -
- {% block content %} -

{{ page_title }}

- {{ page_content | safe }} {% endblock %} -
-
- Dibuat dengan librawssg -
- - diff --git a/demo/templates/blog_list.html b/demo/templates/blog_list.html deleted file mode 100644 index 6bdd8d6..0000000 --- a/demo/templates/blog_list.html +++ /dev/null @@ -1,11 +0,0 @@ -{% extends "base.html" %} {% block content %} -

{{ page_title }}

- -{% endblock %} diff --git a/demo/templates/blog_post.html b/demo/templates/blog_post.html deleted file mode 100644 index 6837922..0000000 --- a/demo/templates/blog_post.html +++ /dev/null @@ -1,7 +0,0 @@ -{% extends "base.html" %} {% block content %} -
-

{{ page_title }}

-

{{ page_pub_date }}

- {{ page_content | safe }} -
-{% endblock %} diff --git a/demo/templates/rss.xml b/demo/templates/rss.xml deleted file mode 100644 index 4915208..0000000 --- a/demo/templates/rss.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - {{ site.site_name }} - {{ site.description }} - {{ base_url }} - {% for post in posts %} - - {{ post.frontmatter.title }} - {{ base_url }}/{{ post.url }} - {{ post.frontmatter.desc }} - {{ post.pub_date }} - - {% endfor %} - - diff --git a/demo/templates/sitemap.xml b/demo/templates/sitemap.xml deleted file mode 100644 index 282c0f4..0000000 --- a/demo/templates/sitemap.xml +++ /dev/null @@ -1,11 +0,0 @@ - - -{% for page in pages %} - - {{ base_url }}/{{ page.url }} - {% if page.pub_date %} - {{ page.pub_date }} - {% endif %} - -{% endfor %} - \ No newline at end of file diff --git a/docs/.gitignore b/docs/.gitignore deleted file mode 100644 index 5b5ef38..0000000 --- a/docs/.gitignore +++ /dev/null @@ -1,6 +0,0 @@ -/target -/dev -.velodiff_dist.diff -.snapcat.md -/dist -/dist.tmp \ No newline at end of file diff --git a/docs/Cargo.lock b/docs/Cargo.lock deleted file mode 100644 index dcf8a13..0000000 --- a/docs/Cargo.lock +++ /dev/null @@ -1,780 +0,0 @@ -# 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 = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - -[[package]] -name = "autocfg" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" - -[[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 = "bitflags" -version = "2.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" - -[[package]] -name = "bumpalo" -version = "3.20.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" - -[[package]] -name = "cc" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" -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.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "serde", - "wasm-bindgen", - "windows-link", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[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", -] - -[[package]] -name = "find-msvc-tools" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" - -[[package]] -name = "futures-core" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" - -[[package]] -name = "futures-task" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" - -[[package]] -name = "futures-util" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" -dependencies = [ - "futures-core", - "futures-task", - "pin-project-lite", - "slab", -] - -[[package]] -name = "getopts" -version = "0.2.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" -dependencies = [ - "unicode-width 0.2.2", -] - -[[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.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" - -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" - -[[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 = "indexmap" -version = "2.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" -dependencies = [ - "equivalent", - "hashbrown", -] - -[[package]] -name = "is_ci" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "js-sys" -version = "0.3.103" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" -dependencies = [ - "cfg-if", - "futures-util", - "wasm-bindgen", -] - -[[package]] -name = "libc" -version = "0.2.189" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" - -[[package]] -name = "librawssg" -version = "0.5.0" -dependencies = [ - "chrono", - "glob", - "miette", - "pulldown-cmark", - "serde", - "serde_yaml", - "tera", - "thiserror", - "tracing", - "walkdir", -] - -[[package]] -name = "librawssg-docs" -version = "0.1.0" -dependencies = [ - "librawssg", -] - -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - -[[package]] -name = "log" -version = "0.4.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" - -[[package]] -name = "memchr" -version = "2.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" - -[[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 2.0.119", -] - -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[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.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "owo-colors" -version = "4.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "proc-macro2" -version = "1.0.107" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "pulldown-cmark" -version = "0.13.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" -dependencies = [ - "bitflags", - "getopts", - "memchr", - "pulldown-cmark-escape", - "unicase", -] - -[[package]] -name = "pulldown-cmark-escape" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" - -[[package]] -name = "quote" -version = "1.0.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "rustc-demangle" -version = "0.1.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" - -[[package]] -name = "rustix" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys", -] - -[[package]] -name = "rustversion" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" - -[[package]] -name = "ryu" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" - -[[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 = "serde" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "serde_yaml" -version = "0.9.34+deprecated" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" -dependencies = [ - "indexmap", - "itoa", - "ryu", - "serde", - "unsafe-libyaml", -] - -[[package]] -name = "shlex" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[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 = "syn" -version = "2.0.119" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "3.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "tera" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "511f07fd91a70e92efbe4793d111aaa9035f8474dd157aaa1e31e7c27f5051da" -dependencies = [ - "serde", -] - -[[package]] -name = "terminal_size" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" -dependencies = [ - "rustix", - "windows-sys", -] - -[[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 = "2.0.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "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 2.0.119", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", -] - -[[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.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "unicode-linebreak" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" - -[[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 = "unsafe-libyaml" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" - -[[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 = "wasm-bindgen" -version = "0.2.126" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.126" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.126" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn 2.0.119", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.126" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys", -] - -[[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 2.0.119", -] - -[[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 2.0.119", -] - -[[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.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] diff --git a/docs/Cargo.toml b/docs/Cargo.toml deleted file mode 100644 index ef4f6b3..0000000 --- a/docs/Cargo.toml +++ /dev/null @@ -1,7 +0,0 @@ -[package] -name = "librawssg-docs" -version = "0.1.0" -edition = "2024" - -[dependencies] -librawssg = { path = "..", features = ["tera", "pulldown"] } diff --git a/docs/config.yml b/docs/config.yml deleted file mode 100644 index e6bae1e..0000000 --- a/docs/config.yml +++ /dev/null @@ -1,43 +0,0 @@ -site: - site_name: "librawssg" - description: "Safety-first kernel for static site generation in Rust" - language: "en" - base_url: "https://mroczect.biz.id/librawssg" - author: "mroczect" - repo_url: "https://github.com/mroczect/librawssg" - license: "MIT" - - # Sidebar digunakan untuk daftar isi di dalam halaman - sidebar: - - label: "Home" - url: "index.html" - - label: "Installation" - url: "installation.html" - - label: "Configuration" - url: "configuration.html" - - label: "API Reference" - url: "references.html" - - label: "Contributing" - url: "contributing.html" - - label: "Code of Conduct" - url: "code_of_conduct.html" - - label: "License" - url: "license.html" - -build: - content_dir: "content" - output_dir: "dist" - templates_dir: "templates" - static_dir: "static" - -content_types: - - name: "page" - pattern: "*.md" - template: "base.html" - list_enabled: false - -generators: - rss: - enabled: false - sitemap: - enabled: false diff --git a/docs/content/code_of_conduct.md b/docs/content/code_of_conduct.md deleted file mode 100644 index 711f8f0..0000000 --- a/docs/content/code_of_conduct.md +++ /dev/null @@ -1,131 +0,0 @@ ---- -title: Contributor Covenant Code of Conduct -desc: Community guidelines for librawssg ---- - -## Our Pledge - -We as members, contributors, and leaders pledge to make participation in our -community a harassment-free experience for everyone, regardless of age, body -size, visible or invisible disability, ethnicity, sex characteristics, gender -identity and expression, level of experience, education, socio-economic status, -nationality, personal appearance, race, religion, or sexual identity -and orientation. - -We pledge to act and interact in ways that contribute to an open, welcoming, -diverse, inclusive, and healthy community. - -## Our Standards - -Examples of behavior that contributes to a positive environment for our -community include: - -- Demonstrating empathy and kindness toward other people -- Being respectful of differing opinions, viewpoints, and experiences -- Giving and gracefully accepting constructive feedback -- Accepting responsibility and apologizing to those affected by our mistakes, - and learning from the experience -- Focusing on what is best not just for us as individuals, but for the - overall community - -Examples of unacceptable behavior include: - -- The use of sexualized language or imagery, and sexual attention or - advances of any kind -- Trolling, insulting or derogatory comments, and personal or political attacks -- Public or private harassment -- Publishing others' private information, such as a physical or email - address, without their explicit permission -- Other conduct which could reasonably be considered inappropriate in a - professional setting - -## Enforcement Responsibilities - -Community leaders are responsible for clarifying and enforcing our standards of -acceptable behavior and will take appropriate and fair corrective action in -response to any behavior that they deem inappropriate, threatening, offensive, -or harmful. - -Community leaders have the right and responsibility to remove, edit, or reject -comments, commits, code, wiki edits, issues, and other contributions that are -not aligned to this Code of Conduct, and will communicate reasons for moderation -decisions when appropriate. - -## Scope - -This Code of Conduct applies within all community spaces, and also applies when -an individual is officially representing the community in public spaces. -Examples of representing our community include using an official e-mail address, -posting via an official social media account, or acting as an appointed -representative at an online or offline event. - -## Enforcement - -Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported to the community leaders responsible for enforcement at -mroczect@proton.me. -All complaints will be reviewed and investigated promptly and fairly. - -All community leaders are obligated to respect the privacy and security of the -reporter of any incident. - -## Enforcement Guidelines - -Community leaders will follow these Community Impact Guidelines in determining -the consequences for any action they deem in violation of this Code of Conduct: - -### 1. Correction - -**Community Impact**: Use of inappropriate language or other behavior deemed -unprofessional or unwelcome in the community. - -**Consequence**: A private, written warning from community leaders, providing -clarity around the nature of the violation and an explanation of why the -behavior was inappropriate. A public apology may be requested. - -### 2. Warning - -**Community Impact**: A violation through a single incident or series -of actions. - -**Consequence**: A warning with consequences for continued behavior. No -interaction with the people involved, including unsolicited interaction with -those enforcing the Code of Conduct, for a specified period of time. This -includes avoiding interactions in community spaces as well as external channels -like social media. Violating these terms may lead to a temporary or -permanent ban. - -### 3. Temporary Ban - -**Community Impact**: A serious violation of community standards, including -sustained inappropriate behavior. - -**Consequence**: A temporary ban from any sort of interaction or public -communication with the community for a specified period of time. No public or -private interaction with the people involved, including unsolicited interaction -with those enforcing the Code of Conduct, is allowed during this period. -Violating these terms may lead to a permanent ban. - -### 4. Permanent Ban - -**Community Impact**: Demonstrating a pattern of violation of community -standards, including sustained inappropriate behavior, harassment of an -individual, or aggression toward or disparagement of classes of individuals. - -**Consequence**: A permanent ban from any sort of public interaction within -the community. - -## Attribution - -This Code of Conduct is adapted from the [Contributor Covenant][homepage], -version 2.0, available at -https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. - -Community Impact Guidelines were inspired by [Mozilla's code of conduct -enforcement ladder](https://github.com/mozilla/diversity). - -[homepage]: https://www.contributor-covenant.org - -For answers to common questions about this code of conduct, see the FAQ at -https://www.contributor-covenant.org/faq. Translations are available at -https://www.contributor-covenant.org/translations. diff --git a/docs/content/configuration.md b/docs/content/configuration.md deleted file mode 100644 index 8145ab3..0000000 --- a/docs/content/configuration.md +++ /dev/null @@ -1,423 +0,0 @@ ---- -title: Configuration -desc: Complete YAML configuration reference for librawssg ---- - -librawssg is configured through a single YAML file. Every aspect of the site – metadata, directory layout, content types, and generators – is defined here. - -The library ships with sensible defaults, so you only need to specify what changes from the default behaviour. - ---- - -## Loading Configuration - -There are two ways to provide configuration: - -### From a YAML file (recommended) - -```rust -use librawssg::site::builder::SiteBuilder; - -let site = SiteBuilder::new() - .load_config("config.yml")? - .with_template_renderer(Box::new(tera)) - .with_markdown_renderer(Box::new(md)) - .build()?; -``` - -`load_config` reads, parses, and **validates** the file before returning. Any error (missing file, invalid YAML, failed validation) is returned immediately. - -### Programmatically - -```rust -use librawssg::types::RawssgConfig; - -let mut config = RawssgConfig::default(); -config.site.site_name = "My Site".into(); -config.build.content_dir = "pages".into(); -config.content_types.push(ContentTypeDef { - name: "page".into(), - pattern: "**/*.md".into(), - template: "base.html".into(), - list_template: None, - list_enabled: false, -}); - -let site = SiteBuilder::new() - .config(config) - .with_template_renderer(Box::new(tera)) - .with_markdown_renderer(Box::new(md)) - .build()?; -``` - -Use this approach when you need to generate configuration dynamically or don't want a separate YAML file. - ---- - -## Validation - -When you call `load_config` or `build`, the configuration is automatically validated. The following rules are enforced: - -| Rule | Error message | -| ------------------------------------------------------------------------------ | ----------------------------------------------------- | -| `site.site_name` must not be empty | `"site.name cannot be empty"` | -| At least one `content_type` must be defined | `"at least one content_type must be defined"` | -| Every content type must have a non‑empty `name` | `"content_type name cannot be empty"` | -| Every content type must have a non‑empty `template` | `"content_type '...' must have a template"` | -| Every content type `pattern` must be a valid glob | `"Invalid glob pattern '...'"` | -| If RSS is enabled, `rss.template` and `rss.path` must be non‑empty | `"rss.template is required when RSS enabled"` | -| If sitemap is enabled, `sitemap.template` and `sitemap.path` must be non‑empty | `"sitemap.template is required when sitemap enabled"` | - -Validation happens **before** any file processing, saving you from errors later in the build. - ---- - -## Full YAML Structure - -```yaml -site: # Site-wide metadata (available in templates as `site.xxx`) - site_name: # Required. Must not be empty. - description: # Optional. - language: # Optional. Default: "en" - base_url: # Optional. Used in RSS/sitemap. - author: # Optional. - repo_url: # Optional. - license: # Optional. - navbar: # Optional. List of NavItem. - sidebar: # Optional. List of NavItem. - -build: # Directory layout - content_dir: # Default: "content" - output_dir: # Default: "dist" - templates_dir: # Default: "templates" - static_dir: # Default: "static" - -content_types: # List of content type definitions (at least one required) - - name: # Required. Identifier (e.g. "blog", "page") - pattern: # Required. Glob pattern (e.g. "blog/*.md") - template: # Required. Template name (e.g. "post.html") - list_template: # Optional. Template for list pages - list_enabled: # Optional. Default: false - -generators: # Automatic RSS and sitemap generation - rss: - enabled: # Default: true - path: # Required if enabled. Output path (e.g. "rss.xml") - template: # Required if enabled. Template name (e.g. "rss.xml") - sitemap: - enabled: # Default: true - path: # Required if enabled. Output path (e.g. "sitemap.xml") - template: # Required if enabled. Template name (e.g. "sitemap.xml") -``` - ---- - -## `site` – Site Metadata - -All fields under `site` are accessible in templates under the `site` object. For example, `site.site_name`, `site.author`, `site.navbar`. - -### Fields - -| Field | Type | Required | Default | Description | -| ------------- | -------- | -------- | ---------- | ---------------------------------------------- | -| `site_name` | `string` | **Yes** | `"rawssg"` | Name of the site. Used in ``, RSS, etc. | -| `description` | `string` | No | `None` | Short description. Used in meta tags and RSS. | -| `language` | `string` | No | `"en"` | HTML language code (e.g. `"id"`, `"fr"`). | -| `base_url` | `string` | No | `None` | Full public URL. Used in RSS and sitemap. | -| `author` | `string` | No | `None` | Author name. Available as `site.author`. | -| `repo_url` | `string` | No | `None` | Repository URL. Available as `site.repo_url`. | -| `license` | `string` | No | `None` | License name. Available as `site.license`. | -| `navbar` | `list` | No | `[]` | Navigation bar items. See [NavItem](#navitem). | -| `sidebar` | `list` | No | `[]` | Sidebar items. See [NavItem](#navitem). | - -### Example - -```yaml -site: - site_name: "My Awesome Blog" - description: "Thoughts about Rust and static sites" - language: "en" - base_url: "https://example.com" - author: "Jane Doe" - repo_url: "https://github.com/janedoe/my-blog" - license: "MIT" - navbar: - - label: "Home" - url: "/" - - label: "About" - url: "/about.html" - - label: "Blog" - url: "/blog/index.html" - sidebar: - - label: "RSS" - url: "/rss.xml" - - label: "GitHub" - url: "https://github.com/janedoe/my-blog" -``` - -### NavItem - -| Field | Type | Description | -| ------- | -------- | ---------------------------------- | -| `label` | `string` | Display text for the link | -| `url` | `string` | Link target (relative or absolute) | - ---- - -## `build` – Directory Layout - -Controls where the library looks for input files and where it writes output. - -### Fields - -| Field | Type | Default | Description | -| --------------- | -------- | ------------- | ---------------------------------------------------------- | -| `content_dir` | `string` | `"content"` | Directory containing Markdown files | -| `output_dir` | `string` | `"dist"` | Directory where generated HTML is written | -| `templates_dir` | `string` | `"templates"` | Directory containing template files (if loading from disk) | -| `static_dir` | `string` | `"static"` | Directory with static assets (copied as-is) | - -### Example - -```yaml -build: - content_dir: "my-content" - output_dir: "public" - templates_dir: "my-templates" - static_dir: "my-static" -``` - -### Notes - -- All paths are relative to the **current working directory** when the binary runs. -- The `templates_dir` is **not used** by the library directly. You must load templates yourself (see the demo or docs site for an example). The field is kept for reference and future use. -- The `static_dir` is processed automatically: every file inside is copied to the output directory. -- Non‑Markdown files in `content_dir` (images, CSS, JS) are also copied automatically. - ---- - -## `content_types` – Content Type Definitions - -This is the heart of the content pipeline. Every Markdown file must be matched by at least one content type. - -### Fields - -| Field | Type | Required | Default | Description | -| --------------- | -------- | -------- | ------- | -------------------------------------------------------------------------------------------- | -| `name` | `string` | **Yes** | – | Identifier for this type (e.g. `"blog"`, `"page"`). Used as `content_type` in `PageContext`. | -| `pattern` | `string` | **Yes** | – | Glob pattern relative to `content_dir`. Supports `*` (one segment) and `**` (any segments). | -| `template` | `string` | **Yes** | – | Name of the template used for single pages of this type. | -| `list_template` | `string` | No | `None` | Template for list pages (e.g. `blog/index.html`). | -| `list_enabled` | `bool` | No | `false` | If `true`, a list page is generated automatically. | - -### How patterns work - -| Pattern | Matches | Does not match | -| -------------- | ----------------------------------- | ------------------------------- | -| `*.md` | `index.md`, `about.md` | `blog/post.md` | -| `blog/*.md` | `blog/post.md` | `blog/2024/post.md`, `index.md` | -| `blog/**/*.md` | `blog/post.md`, `blog/2024/post.md` | `index.md` | -| `**/*.md` | Everything ending in `.md` | – | - -Patterns are relative to `content_dir`. They use a custom glob engine that supports `*` and `**` wildcards. - -### How list pages work - -When `list_enabled` is `true` and `list_template` is set: - -1. All pages of that type are collected. -2. A `PageContext` with `is_list = true` is created. -3. The list of items is available in the template as the `pages` variable. - -Example template (`blog_list.html`): - -```html -{% extends "base.html" %} {% block content %} -<h2>All Posts</h2> -<ul> - {% for post in pages %} - <li> - <a href="{{ base_path }}{{ post.url }}">{{ post.frontmatter.title }}</a> - <span>({{ post.pub_date }})</span> - </li> - {% endfor %} -</ul> -{% endblock %} -``` - -The list page is generated at `{name}/index.html` (e.g. `blog/index.html`). - -### Example - -```yaml -content_types: - - name: "page" - pattern: "*.md" - template: "base.html" - # No list for regular pages - - - name: "blog" - pattern: "blog/*.md" - template: "blog_post.html" - list_template: "blog_list.html" - list_enabled: true - - - name: "tutorial" - pattern: "tutorials/**/*.md" - template: "tutorial.html" - list_template: "tutorial_list.html" - list_enabled: true -``` - ---- - -## `generators` – RSS and Sitemap - -Automatically generate RSS and sitemap files for your site. - -### RSS - -| Field | Type | Default | Description | -| ---------- | -------- | ------- | ------------------------------------------------------- | -| `enabled` | `bool` | `true` | Whether to generate an RSS feed | -| `path` | `string` | – | Output path relative to `output_dir` (e.g. `"rss.xml"`) | -| `template` | `string` | – | Template name for the RSS XML | - -The RSS feed collects all pages with `content_type == "blog"` (sorted by date, newest first) and passes them as the `posts` variable to the template. - -Example RSS template: - -```xml -<?xml version="1.0" encoding="UTF-8"?> -<rss version="2.0"> - <channel> - <title>{{ site.site_name }} - {{ site.description }} - {{ base_url }} - {% for post in posts %} - - {{ post.frontmatter.title }} - {{ base_url }}/{{ post.url }} - {{ post.frontmatter.desc }} - {{ post.pub_date }} - - {% endfor %} - - -``` - -### Sitemap - -| Field | Type | Default | Description | -| ---------- | -------- | ------- | ----------------------------------------------------------- | -| `enabled` | `bool` | `true` | Whether to generate a sitemap | -| `path` | `string` | – | Output path relative to `output_dir` (e.g. `"sitemap.xml"`) | -| `template` | `string` | – | Template name for the sitemap XML | - -All pages (including list pages) are passed as the `pages` variable. - -Example sitemap template: - -```xml - - -{% for page in pages %} - - {{ base_url }}/{{ page.url }} - {% if page.pub_date %} - {{ page.pub_date }} - {% endif %} - -{% endfor %} - -``` - -### Example configuration - -```yaml -generators: - rss: - enabled: true - path: "feed.xml" - template: "rss.xml" - sitemap: - enabled: true - path: "sitemap.xml" - template: "sitemap.xml" -``` - -To disable a generator entirely: - -```yaml -generators: - rss: - enabled: false - sitemap: - enabled: false -``` - ---- - -## Complete Example - -Here is a full, annotated configuration file that you can use as a starting point: - -```yaml -# ============================================================================= -# librawssg Configuration -# ============================================================================= - -# Site metadata – available in templates as `site.xxx` -site: - site_name: "My Site" - description: "A blog about Rust" - language: "en" - base_url: "https://example.com" - author: "Your Name" - repo_url: "https://github.com/you/your-site" - license: "MIT" - - navbar: - - label: "Home" - url: "/" - - label: "About" - url: "/about.html" - - label: "Blog" - url: "/blog/index.html" - - sidebar: - - label: "RSS" - url: "/rss.xml" - -# Directory paths (all relative to working directory) -build: - content_dir: "content" - output_dir: "dist" - templates_dir: "templates" - static_dir: "static" - -# Content type definitions -content_types: - - name: "page" - pattern: "*.md" - template: "base.html" - - - name: "blog" - pattern: "blog/*.md" - template: "blog_post.html" - list_template: "blog_list.html" - list_enabled: true - -# RSS and Sitemap -generators: - rss: - enabled: true - path: "rss.xml" - template: "rss.xml" - sitemap: - enabled: true - path: "sitemap.xml" - template: "sitemap.xml" -``` - -Save this as `config.yml` in your project root, then load it with `SiteBuilder::new().load_config("config.yml")`. diff --git a/docs/content/contributing.md b/docs/content/contributing.md deleted file mode 100644 index 0924741..0000000 --- a/docs/content/contributing.md +++ /dev/null @@ -1,655 +0,0 @@ ---- -title: Contributing -desc: How to contribute to librawssg ---- - -Thank you for your interest in contributing to **librawssg**. -We welcome contributions of all kinds: bug reports, feature requests, documentation improvements, code patches, and design discussions. - -This guide outlines the entire contribution process so that everyone can work together effectively. - ---- - -## Table of Contents - -- [Code of Conduct](#code-of-conduct) -- [Ways to Contribute](#ways-to-contribute) -- [Getting Started](#getting-started) -- [Development Environment](#development-environment) -- [Project Structure](#project-structure) -- [Building and Testing](#building-and-testing) -- [Coding Style](#coding-style) -- [Commit Messages](#commit-messages) -- [Pull Request Process](#pull-request-process) -- [Reporting Bugs](#reporting-bugs) -- [Feature Requests](#feature-requests) -- [Documentation](#documentation) -- [Community](#community) -- [Recognition](#recognition) - ---- - -## Code of Conduct - -This project adheres to a minimal set of social rules: - -- **Be respectful** – treat others as you would like to be treated. -- **Be constructive** – focus on the issue, not the person. -- **Be inclusive** – welcome people of all backgrounds and experience levels. - -Harassment, discrimination, or hostile behaviour is not tolerated. -If you experience or witness such conduct, please contact the maintainers immediately. - -Read the full [Code of Conduct](code_of_conduct.html). - ---- - -## Ways to Contribute - -You don't have to write code to make a difference. Here are some ways you can help: - -| Contribution type | Impact | -| -------------------- | --------------------------------------------------- | -| **Bug reports** | Help us find and fix problems | -| **Feature requests** | Shape the future of the library | -| **Documentation** | Make the library easier to learn | -| **Code patches** | Fix bugs, add features, improve performance | -| **Code review** | Help maintain quality by reviewing PRs | -| **Testing** | Write tests, report edge cases | -| **Spread the word** | Write blog posts, give talks, share on social media | - ---- - -## Getting Started - -### 1. Fork the repository - -Click the **Fork** button at the top of [github.com/mroczect/librawssg](https://github.com/mroczect/librawssg). - -### 2. Clone your fork - -```bash -git clone https://github.com/YOUR_USERNAME/librawssg.git -cd librawssg -``` - -### 3. Add the upstream remote - -This keeps your fork in sync with the main repository. - -```bash -git remote add upstream https://github.com/mroczect/librawssg.git -``` - -### 4. Create a branch - -Use a descriptive name that reflects what you're working on. - -```bash -git checkout -b feat/my-awesome-feature -``` - -Branch naming conventions: - -| Prefix | Purpose | Example | -| ----------- | ------------------ | ---------------------- | -| `feat/` | New feature | `feat/custom-handlers` | -| `fix/` | Bug fix | `fix/path-traversal` | -| `docs/` | Documentation | `docs/api-reference` | -| `test/` | Adding tests | `test/site-builder` | -| `refactor/` | Code restructuring | `refactor/error-types` | -| `chore/` | Maintenance tasks | `chore/update-deps` | - -### 5. Keep your branch up to date - -```bash -git fetch upstream -git rebase upstream/master -``` - -Rebase instead of merge to keep the history clean. -If you're uncomfortable with rebasing, merging is also acceptable. - ---- - -## Development Environment - -### Required tools - -- **Rust** – Install via [rustup](https://rustup.rs). The latest stable version is recommended. - ```bash - rustup update stable - ``` -- **Cargo** – Comes with Rust. No additional setup needed. -- **Git** – For version control. - -### Recommended tools - -- **cargo-edit** – Easily manage dependencies from the command line. - ```bash - cargo install cargo-edit - ``` -- **cargo-watch** – Automatically rebuild and test on file changes. - ```bash - cargo install cargo-watch - ``` -- **cargo-audit** – Check for security vulnerabilities in dependencies. - ```bash - cargo install cargo-audit - ``` - -### Editor setup - -Any text editor works, but we recommend one with Rust support: - -- **VS Code** with the `rust-analyzer` extension -- **IntelliJ IDEA** with the Rust plugin -- **Helix** or **Neovim** with built-in LSP support - -Configure your editor to: - -- Run `rustfmt` on save -- Show Clippy warnings inline -- Enable `rust-analyzer` for autocompletion and type hints - ---- - -## Project Structure - -Understanding the codebase helps you make better contributions. - -``` -librawssg/ -├── src/ # Library source code -│ ├── config/ # Configuration loading (YAML, defaults) -│ │ ├── mod.rs # ConfigLoader trait -│ │ └── loader.rs # YamlConfigLoader implementation -│ ├── error.rs # RawssgError enum (thiserror + miette) -│ ├── frontmatter.rs # YAML frontmatter extraction -│ ├── fs/ # Filesystem abstraction -│ │ ├── mod.rs # FileSystem trait -│ │ └── real.rs # RealFs implementation -│ ├── markdown.rs # MarkdownRenderer trait (+ optional pulldown) -│ ├── serve/ # Optional dev server -│ │ ├── mod.rs # Static file server -│ │ └── watcher.rs # File watcher (notify) -│ ├── site/ # Core site building logic -│ │ ├── mod.rs # TemplateRenderer trait, ContentHandler trait -│ │ ├── builder.rs # SiteBuilder and Site -│ │ ├── page.rs # Page context building -│ │ ├── feed.rs # RSS feed generation -│ │ └── sitemap.rs # Sitemap generation -│ ├── types.rs # All data types (config, frontmatter, context) -│ ├── util.rs # Path safety, slugify, glob matching -│ └── lib.rs # Crate root, re-exports -├── tests/ # Integration tests -│ ├── common/ # Mock implementations (MockFs, MockRenderers) -│ │ └── mod.rs -│ ├── config_tests.rs -│ ├── error_tests.rs -│ ├── frontmatter_tests.rs -│ ├── fs_mock_tests.rs -│ ├── fs_real_tests.rs -│ ├── integration_test.rs -│ ├── markdown_tests.rs -│ ├── site_builder_tests.rs -│ ├── site_page_tests.rs -│ └── util_tests.rs -├── demo/ # Full working example -├── docs/ # Documentation site source -└── Cargo.toml # Project manifest -``` - -### Key design principles - -1. **Everything is a trait** – Filesystem, rendering, content processing are all behind traits. This makes the library testable and extensible. -2. **Minimal dependencies** – Only essential crates are required. Everything else is optional. -3. **Safety first** – Path traversal is prevented at every level. Output is atomic. -4. **Validate early** – Configuration errors are caught at build time, not generation time. - ---- - -## Building and Testing - -All commands are run from the repository root. - -### Build - -```bash -# Basic build (no optional features) -cargo build - -# Build with all features -cargo build --all-features - -# Release build (optimised) -cargo build --release -``` - -### Run tests - -```bash -# Run all tests (only tests that don't require features) -cargo test - -# Run tests with Tera and pulldown features -cargo test --features tera,pulldown - -# Run tests with dev server feature -cargo test --features serve - -# Run all tests with every feature -cargo test --all-features - -# Run a specific test file -cargo test --test config_tests - -# Run a specific test function -cargo test test_name -``` - -### Format and lint - -```bash -# Check formatting (CI will fail if this isn't clean) -cargo fmt --all -- --check - -# Auto-format all code -cargo fmt --all - -# Run Clippy with strict warnings -cargo clippy --all-targets --all-features -- -D warnings -``` - -**These checks are enforced in CI.** Run them locally before pushing. - -### Watch mode (auto-test on changes) - -```bash -cargo watch -x "test --all-features" -x "clippy --all-targets --all-features -- -D warnings" -``` - ---- - -## Coding Style - -We follow the standard Rust style guide with a few additional conventions. - -### General - -- Follow what `cargo fmt` produces. No arguments. -- All Clippy warnings are errors. If Clippy complains, fix it. -- Keep functions small – aim for under 50 lines. -- One responsibility per function. -- Use meaningful variable names. - -### Error handling - -- Use `Result` for all fallible operations. -- Use the `?` operator liberally. -- Add context to errors where helpful: - ```rust - fs.read_to_string(path) - .map_err(|e| RawssgError::Config(format!("cannot read config: {}", e)))?; - ``` -- Don't use `.unwrap()` or `.expect()` in library code unless you can prove the invariant. - -### Documentation - -- Every public item must have a `///` doc comment. -- Include a short description, not just a repetition of the name. -- Add a code example when the usage isn't obvious. - -Example: - -````rust -/// Loads a site configuration from a YAML file. -/// -/// The file is validated after loading. Returns an error if the file -/// cannot be read or contains invalid YAML. -/// -/// # Example -/// ```rust -/// let builder = SiteBuilder::new() -/// .load_config("config.yml") -/// .expect("failed to load config"); -/// ``` -pub fn load_config>(mut self, path: P) -> Result { - // ... -} -```` - -### Testing - -- Write tests for all new functionality. -- Place unit tests in the same file as the code they test, inside a `#[cfg(test)]` module. -- Place integration tests in the `tests/` directory. -- Use the mock implementations in `tests/common/mod.rs` for testing without real files. - -### Performance - -- Don't optimise prematurely. -- If you're making a performance claim, include benchmarks. -- Use `cargo bench` if benchmarks are available. - ---- - -## Commit Messages - -We use the [Conventional Commits](https://www.conventionalcommits.org/) format. - -### Format - -``` -type(scope): short description - -Optional longer explanation of the change, including motivation -and any breaking changes. -``` - -### Types - -| Type | When to use | -| ---------- | ----------------------------------------------------- | -| `feat` | A new feature | -| `fix` | A bug fix | -| `docs` | Documentation changes | -| `test` | Adding or updating tests | -| `refactor` | Code changes that neither fix a bug nor add a feature | -| `chore` | Maintenance tasks (dependencies, CI, etc.) | -| `style` | Formatting, missing semicolons, etc. (no code change) | -| `perf` | Performance improvements | - -### Scope - -The scope is usually `librawssg`, but can be more specific: - -| Scope | When to use | -| ----------- | --------------------------- | -| `librawssg` | Changes to the core library | -| `ci` | CI pipeline changes | -| `docs` | Documentation site changes | -| `demo` | Demo project changes | - -### Examples - -```text -feat(librawssg): add support for custom content handlers - -Introduce the ContentHandler trait that allows users to define their own -file processing logic. Includes default MarkdownPageHandler. - -BREAKING CHANGE: SiteBuilder::new() no longer sets up default handlers. -``` - -```text -fix(librawssg): prevent path traversal in safe_path - -safe_path now normalises paths before checking boundaries, preventing -attacks using ../ sequences on not-yet-existing files. -``` - -```text -docs(librawssg): add API reference for PageContext - -Document all fields of PageContext with examples showing how they are -populated from Markdown frontmatter. -``` - ---- - -## Pull Request Process - -### Before you submit - -1. **Update your branch** – Rebase onto the latest `master`. - - ```bash - git fetch upstream - git rebase upstream/master - ``` - -2. **Run all checks** – Make sure everything passes. - - ```bash - cargo fmt --all -- --check - cargo clippy --all-targets --all-features -- -D warnings - cargo test --all-features - ``` - -3. **Check your commits** – Squash trivial commits, ensure messages follow the convention. - -### Submitting - -1. Push your branch to your fork. -2. Open a pull request against `mroczect/librawssg:master`. -3. Fill out the PR template completely. - -### PR description - -Your PR description should answer these questions: - -- **What** does this change do? -- **Why** is this change needed? -- **How** was it implemented (brief overview)? -- **Breaking changes** – List any changes that break existing code. -- **Testing** – Describe how you tested the change. -- **Related issues** – Link any issues this PR addresses. - -Example: - -```markdown -## What - -Add a `ContentHandler` trait that allows users to define custom file -processing logic. - -## Why - -Currently, only Markdown files can be processed. Users with other file -types (AsciiDoc, reStructuredText) have no way to integrate them. - -## How - -- Defined the `ContentHandler` trait in `site/mod.rs` -- Added `add_handler()` method to `SiteBuilder` -- Converted existing Markdown handling into `MarkdownPageHandler` -- Added `StaticFileHandler` as a catch-all - -## Breaking changes - -- `SiteBuilder::new()` no longer sets up default handlers. - Users must call `.add_handler()` if they remove defaults. - -## Testing - -- Added `test_custom_handler()` to `tests/site_builder_tests.rs` -- All existing tests pass with the updated builder - -## Related issues - -Closes #42 -``` - -### Review process - -1. A maintainer will review your code within a few days. -2. They may ask questions or request changes. -3. Make the requested changes in new commits, then push. -4. Once approved, the maintainer will merge your PR via **squash merge** to keep the history linear. - -### After merge - -- Delete your branch. -- Update your fork's `master` branch. - -```bash -git checkout master -git pull upstream master -git push origin master -git branch -d feat/my-feature -``` - ---- - -## Reporting Bugs - -Found a bug? We appreciate you taking the time to report it. - -### Before reporting - -1. **Search existing issues** – Someone might have already reported it. -2. **Check the version** – Make sure you're using the latest release. - -### Writing a good bug report - -Include as much of the following as possible: - -```markdown -## Description - -A clear and concise description of the bug. - -## Steps to reproduce - -1. Create a project with this configuration: ... -2. Add this content file: ... -3. Run `cargo run` -4. See error: ... - -## Expected behaviour - -The site should generate with ... - -## Actual behaviour - -Instead, the following error occurs: ... - -## Environment - -- OS: Ubuntu 24.04 -- Rust version: rustc 1.80.0 -- librawssg version: v0.3.0 -- Features enabled: tera, pulldown - -## Minimal reproduction - -Link to a repository or paste minimal code that reproduces the issue. -``` - -The more detail you provide, the faster we can fix the bug. - ---- - -## Feature Requests - -Have an idea to make librawssg better? We want to hear it. - -### Writing a good feature request - -```markdown -## The problem - -I want to use custom template engines, but currently I have to -implement TemplateRenderer which is not well documented. - -## Proposed solution - -Add a guide showing how to implement TemplateRenderer for a -custom engine, with Handlebars as an example. - -## Alternatives considered - -I could use Tera, but my existing templates are in Handlebars. - -## Additional context - -Handlebars is widely used in the JavaScript ecosystem, so -supporting it would attract more users. -``` - -### What makes a good feature request - -- **Describes a real problem** – Not just "it would be cool if…" -- **Fits the project scope** – librawssg is a kernel, not a full website builder. -- **Is specific** – Vague requests are hard to act on. -- **Considers alternatives** – Shows you've thought about the problem. - -For large features, consider opening a discussion issue first to gather feedback before writing code. - ---- - -## Documentation - -Documentation is just as important as code. We follow these principles: - -### What to document - -- Every public API item (types, traits, functions) -- Configuration options -- How to get started (tutorials) -- How to contribute (this guide) - -### Where documentation lives - -| Documentation type | Location | -| ------------------ | ------------------------------- | -| API docs | `///` comments in source code | -| User guide | `docs/content/*.md` (this site) | -| README | `README.md` in the root | -| Release notes | GitHub releases | - -### Writing good documentation - -- Write in plain English. -- Use short sentences and paragraphs. -- Include code examples that can be copied and run. -- Use consistent terminology. -- Update documentation when you change behaviour. - -### Building the documentation site - -```bash -cd docs -cargo run -``` - -The generated site appears in `docs/dist/`. - ---- - -## Community - -### Communication channels - -- **GitHub Issues** – Bug reports and feature requests -- **GitHub Discussions** – Questions and general conversation -- **Pull Requests** – Code contributions and review - -### Getting help - -If you're stuck or have questions: - -1. Check the [documentation](/). -2. Search [existing issues](https://github.com/mroczect/librawssg/issues). -3. Open a new discussion or issue if you can't find an answer. - -### Etiquette - -- Be patient – maintainers are volunteers. -- Be clear – the more context you provide, the better the answer. -- Be kind – everyone was a beginner once. - ---- - -## Recognition - -All contributors are recognised in the project. When your PR is merged: - -- Your name appears in the Git history. -- You may be listed in the README (ask if you'd like to be added). -- You get the satisfaction of knowing you helped build something useful. - ---- - -Thank you for contributing to **librawssg**. Your effort makes this project better for everyone. diff --git a/docs/content/index.md b/docs/content/index.md deleted file mode 100644 index b190caf..0000000 --- a/docs/content/index.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -title: librawssg -desc: A safety‑first, engine‑agnostic static site generator kernel for Rust ---- - -**librawssg** is the core engine that powers `rawssg`. -It gives you complete freedom to build your own static site generator with the tools you already know. - -**Engine‑agnostic** – Bring your own template engine (Tera, Handlebars, …) and Markdown renderer (pulldown‑cmark, comrak, …) through simple Rust traits. - -**Safety‑first** – Built‑in path‑traversal protection, atomic output generation, and strict configuration validation mean you never have to worry about broken builds or security holes. - -**Batteries optional** – The library ships with zero mandatory dependencies. Enable the built‑in Tera and pulldown‑cmark implementations with a single feature flag, or plug in your own. - ---- - -## Why librawssg? - -- **You control the pipeline** – Write your own `main.rs` and compose exactly the pieces you need. -- **Test everything** – Every component is abstracted behind a trait; mock the filesystem and renderers for fast, reliable tests. -- **No magic** – Configuration is validated up‑front. Errors are typed and described with `miette` diagnostics. - ---- - -## Quick example - -```rust -use librawssg::site::builder::SiteBuilder; -use librawssg::site::TeraRenderer; -use librawssg::markdown::PulldownMarkdown; - -let site = SiteBuilder::new() - .load_config("config.yml")? - .with_template_renderer(Box::new(tera)) - .with_markdown_renderer(Box::new(md)) - .build()?; - -site.generate()?; -``` - ---- - -## Explore the documentation - -- [Installation](installation.html) – Add librawssg to your project -- [Configuration](configuration.html) – Full YAML reference -- [API Reference](references.html) – All public types and traits -- [Contributing](contributing.html) – Help us improve diff --git a/docs/content/installation.md b/docs/content/installation.md deleted file mode 100644 index c434f1d..0000000 --- a/docs/content/installation.md +++ /dev/null @@ -1,243 +0,0 @@ ---- -title: Installation -desc: Add librawssg to your Rust project in under a minute ---- - -librawssg is **not yet published on [crates.io](https://crates.io)**, but you can still use it today directly from the Git repository. -All methods below take less than a minute – pick the one that fits your workflow. - ---- - -## Prerequisites - -- **Rust** `1.96` or later (install via [rustup](https://rustup.rs)) -- A Cargo project with `edition = "2024"` (or `2021` if you prefer) - ---- - -## Method 1: `cargo add` (Git dependency – recommended) - -If you have `cargo-edit` installed (`cargo install cargo-edit`), the fastest way is: - -```bash -cargo add --git https://github.com/mroczect/librawssg.git --tag v0.4.0 librawssg -``` - -To also enable the built‑in Tera and pulldown‑cmark implementations (most common): - -```bash -cargo add --git https://github.com/mroczect/librawssg.git --tag v0.4.0 librawssg --features tera,pulldown -``` - -This will add the dependency to your `Cargo.toml` automatically. - ---- - -## Method 2: Manual `Cargo.toml` entry - -Add this to your `Cargo.toml`: - -```toml -[dependencies] -librawssg = { git = "https://github.com/mroczect/librawssg.git", tag = "v0.4.0" } -``` - -**Always pin a specific tag** to avoid breaking changes. To enable Tera and pulldown-cmark: - -```toml -librawssg = { git = "https://github.com/mroczect/librawssg.git", tag = "v0.4.0", features = ["tera", "pulldown"] } -``` - ---- - -## Method 3: Path dependency (local development) - -If you cloned the repository and want to hack on the library: - -```bash -git clone https://github.com/mroczect/librawssg.git -cd librawssg -``` - -Then in your project’s `Cargo.toml`: - -```toml -[dependencies] -librawssg = { path = "../librawssg", features = ["tera", "pulldown"] } -``` - ---- - -## Method 4: Full clone and build - -```bash -git clone https://github.com/mroczect/librawssg.git -cd librawssg -cargo build --release -``` - -Then reference it as a path dependency (see Method 3). - ---- - -## Feature Flags - -librawssg is modular – you only pay for what you use. - -| Flag | What it enables | Adds crate(s) | -| ---------- | --------------------------- | --------------------- | -| `tera` | Built‑in `TeraRenderer` | `tera` | -| `pulldown` | Built‑in `PulldownMarkdown` | `pulldown-cmark` | -| `serve` | Dev server with live reload | `tiny_http`, `notify` | - -All features are **disabled by default**. -If you don't enable `tera` or `pulldown`, you must provide your own implementations of `TemplateRenderer` and `MarkdownRenderer`. - ---- - -## Verify your installation - -Create a small test binary to confirm everything compiles: - -```rust -// src/main.rs -use librawssg::site::builder::SiteBuilder; - -fn main() { - let _ = SiteBuilder::new(); - println!("librawssg is ready!"); -} -``` - -```bash -cargo run -``` - -If you see `"librawssg is ready!"`, the library is correctly linked. - ---- - -## Minimal working example - -Here's a complete, copy‑paste ready project that generates a real site. - -**1. Create a new binary project** - -```bash -cargo new my-site -cd my-site -``` - -**2. Add the dependency (choose one method)** - -Using `cargo add`: - -```bash -cargo add --git https://github.com/mroczect/librawssg.git --tag v0.4.0 librawssg --features tera,pulldown -``` - -Or edit `Cargo.toml` manually (see Method 2). - -**3. Create folders and a content file** - -```bash -mkdir content templates static -echo '--- -title: Hello -desc: My first page ---- -# Welcome!' > content/index.md - -echo '{{ page_content | safe }}' > templates/base.html -``` - -**4. Create `config.yml`** - -```yaml -site: - site_name: "My Site" - -build: - content_dir: "content" - output_dir: "dist" - templates_dir: "templates" - static_dir: "static" - -content_types: - - name: "page" - pattern: "*.md" - template: "base.html" - list_enabled: false -``` - -**5. Replace `src/main.rs`** - -```rust -use librawssg::markdown::PulldownMarkdown; -use librawssg::site::builder::SiteBuilder; -use librawssg::site::TeraRenderer; -use std::path::Path; - -fn main() -> Result<(), Box> { - let mut tera = TeraRenderer::new(); - let templates_dir = Path::new("templates"); - for entry in std::fs::read_dir(templates_dir)? { - let entry = entry?; - let path = entry.path(); - if path.is_file() { - let name = path.file_name().unwrap().to_string_lossy().into_owned(); - let content = std::fs::read_to_string(&path)?; - tera.add_raw_template(&name, &content)?; - } - } - - let site = SiteBuilder::new() - .load_config("config.yml")? - .content_dir("content") - .output_dir("dist") - .with_template_renderer(Box::new(tera)) - .with_markdown_renderer(Box::new(PulldownMarkdown)) - .build()?; - - site.generate()?; - println!("Site generated in dist/"); - Ok(()) -} -``` - -**6. Run it** - -```bash -cargo run -open dist/index.html -``` - -You should see a simple HTML page with "Welcome!" rendered from Markdown. - ---- - -## Troubleshooting - -### `error: failed to parse manifest` when using a tag - -Make sure the tag exists on GitHub (e.g., `v0.4.0`). You can also use `branch = "master"` temporarily, but **prefer a tag for stability**. - -### `cannot find trait implementation` for Tera or Pulldown - -You forgot to enable the `tera` and `pulldown` features. Add them to your `Cargo.toml` or `cargo add` command. - -### `No context implementation available` error at runtime - -You tried to build a site without a template renderer. Make sure you call `.with_template_renderer(...)` before `.build()`. - -### `File not found` when running from a different directory - -The `content_dir`, `templates_dir`, and `static_dir` paths are relative to the current working directory. Run the binary from the project root, or use absolute paths. - ---- - -## Next steps - -- [Configuration](configuration.html) – Learn every YAML option -- [API Reference](references.html) – Discover all traits and types -- [Demo project](https://github.com/mroczect/librawssg/tree/master/demo) – A full‑featured example with blog, RSS, and sitemap \ No newline at end of file diff --git a/docs/content/license.md b/docs/content/license.md deleted file mode 100644 index 067c86e..0000000 --- a/docs/content/license.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -title: License -desc: MIT License ---- - -```txt -The MIT License (MIT) - -Copyright (c) 2026 mroczect - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. -``` diff --git a/docs/content/references.md b/docs/content/references.md deleted file mode 100644 index abb1346..0000000 --- a/docs/content/references.md +++ /dev/null @@ -1,538 +0,0 @@ ---- -title: API Reference -desc: Complete reference of all public types, traits, functions, and modules in librawssg v0.5.0 ---- - -This document lists every public item exported by `librawssg`. -Items gated behind Cargo features are clearly marked. - ---- - -## `config` module - -Configuration loading traits and implementations. - -### `ConfigLoader` trait - -```rust -pub trait ConfigLoader: Send + Sync { - fn load(&self) -> Result; - fn load_or_default(&self) -> RawssgConfig { - self.load().unwrap_or_else(|e| { - tracing::error!("Failed to load config, using defaults: {}", e); - RawssgConfig::default() - }) - } -} -``` - -Implementors produce a `RawssgConfig`. `load_or_default()` logs the error and returns the default configuration if loading fails. - -### `YamlConfigLoader

` - -```rust -pub struct YamlConfigLoader + Send + Sync> { /* path */ } -``` - -Reads a YAML file and deserialises it into a `RawssgConfig`. -**Constructor**: - -```rust -pub fn new(path: P) -> Self; -``` - -**Trait implementation**: `ConfigLoader`. - -### `DefaultConfig` - -```rust -pub struct DefaultConfig; -``` - -A `ConfigLoader` that always returns `RawssgConfig::default()` (which now includes a default `page` content type). - ---- - -## `error` module - -### `RawssgError` enum - -```rust -pub enum RawssgError { - Io(std::io::Error), - Config(String), - Frontmatter { path: PathBuf, source: Box }, - Template(String), - PathTraversal(String), - MissingConfig(String), - Markdown(String), - SiteGeneration(String), - NotFound(String), - Internal(String), -} -``` - -All errors implement `Display` and `Diagnostic` (via `miette`). -Variants are used throughout the library for specific failure conditions. - ---- - -## `frontmatter` module - -### `parse_frontmatter_and_render` - -```rust -pub fn parse_frontmatter_and_render( - raw: &str, - path: &Path, - renderer: &dyn MarkdownRenderer, -) -> Result<(PageFrontMatter, String), RawssgError>; -``` - -Extracts YAML frontmatter from a string. Returns a `Frontmatter` error if the opening or closing `---` is missing, or if the YAML is invalid. -Renders the remaining Markdown to HTML using the provided renderer. Returns the frontmatter and the rendered HTML. - ---- - -## `fs` module - -### `FileSystem` trait - -```rust -pub trait FileSystem: Send + Sync { - fn read_to_string(&self, path: &Path) -> io::Result; - fn read_bytes(&self, path: &Path) -> io::Result>; - fn write(&self, path: &Path, content: &[u8]) -> io::Result<()>; - fn create_dir_all(&self, path: &Path) -> io::Result<()>; - fn remove_dir_all(&self, path: &Path) -> io::Result<()>; - fn exists(&self, path: &Path) -> bool; - fn is_dir(&self, path: &Path) -> bool; - fn is_file(&self, path: &Path) -> bool; - fn read_dir(&self, path: &Path) -> io::Result>; - fn copy_file(&self, from: &Path, to: &Path) -> io::Result; - fn walk_dir(&self, root: &Path) -> io::Result>; - fn canonicalize(&self, path: &Path) -> io::Result; - fn rename(&self, from: &Path, to: &Path) -> io::Result<()>; // new in v0.5.0 -} -``` - -Abstraction over all filesystem operations. Implement this trait to support in‑memory files, network storage, or any custom backend. -The `rename` method is used by `Site::generate` for atomic output; a cross‑device fallback is triggered if `rename` returns `CrossesDevices`. - -### `RealFs` - -```rust -pub struct RealFs; -``` - -Implements `FileSystem` by delegating to `std::fs` and `walkdir`. All methods are instrumented with `tracing`. - ---- - -## `markdown` module - -### `MarkdownRenderer` trait - -```rust -pub trait MarkdownRenderer: Send + Sync { - fn render(&self, markdown: &str) -> String; -} -``` - -Converts Markdown text to HTML. - -### `PulldownMarkdown` [![feature: pulldown](https://img.shields.io/badge/feature-pulldown-blue)] - -```rust -pub struct PulldownMarkdown; -``` - -Available with the `pulldown` feature. -Implements `MarkdownRenderer` using `pulldown‑cmark` with tables, strikethrough, and tasklists enabled. - ---- - -## `serve` module [![feature: serve](https://img.shields.io/badge/feature-serve-blue)] - -### `start_dev_server` - -```rust -pub fn start_dev_server(output_dir: &Path, port: u16) -> Result<(), RawssgError>; -``` - -Starts a simple HTTP server that serves static files from `output_dir`. -Requests are handled in separate threads. MIME types are detected from file extensions (including `text/plain` for `.txt`, `.md`, `.yaml`, `.yml`, `.log`). Uses `safe_path` to prevent directory traversal. - -### `watch_dirs` - -```rust -pub fn watch_dirs(dirs: &[PathBuf], on_change: F) -> notify::Result> -where - F: Fn() + Send + 'static; -``` - -Watches one or more directories for changes (modify, create, remove) and calls `on_change` on each event. Returns a watcher that must be kept alive. - ---- - -## `site` module - -### `TemplateRenderer` trait - -```rust -pub trait TemplateRenderer: Send + Sync { - fn render(&self, template_name: &str, context: &dyn Context) -> Result; -} -``` - -Renders a named template with a given context. The context is engine‑specific and accessed via the `Context` trait. - -### `Context` trait - -```rust -pub trait Context: Send + Sync { - fn as_any(&self) -> &dyn std::any::Any; - fn as_mut_any(&mut self) -> &mut dyn std::any::Any; -} -``` - -Enables type‑erased access to the underlying template context. Engine implementations must implement this trait for their context type. - -### `TeraRenderer` [![feature: tera](https://img.shields.io/badge/feature-tera-blue)] - -```rust -pub struct TeraRenderer { /* contains a tera::Tera */ } -``` - -Available with the `tera` feature. -**Constructor**: - -```rust -pub fn new() -> Self; -``` - -**Methods**: - -```rust -pub fn add_raw_template(&mut self, name: &str, content: &str) -> Result<(), RawssgError>; -``` - -Adds a template to the engine. Call this before passing the renderer to `SiteBuilder`. -`TeraRenderer` implements `TemplateRenderer` and `Default`. - -### `ContentHandler` trait - -```rust -pub trait ContentHandler: Send + Sync { - fn can_handle(&self, relative_path: &Path, original_path: &Path) -> bool; - fn process( - &self, - fs: &dyn FileSystem, - md_renderer: &dyn MarkdownRenderer, - file_path: &Path, - content_dir: &Path, - ) -> Result, RawssgError>; -} -``` - -Processes a file into a `PageContext`. Return `None` to skip the file (e.g., drafts). - -### `MarkdownPageHandler` - -```rust -pub struct MarkdownPageHandler; -``` - -Handles `.md` files by parsing frontmatter and rendering Markdown. Implements `ContentHandler`. - -### `StaticFileHandler` - -```rust -pub struct StaticFileHandler; -``` - -A catch‑all handler that always returns `None`. It is included by default to prevent warnings for non‑Markdown files (which are copied as assets separately). Implements `ContentHandler`. - -### `build_page_context` - -```rust -pub fn build_page_context( - fs: &dyn FileSystem, - markdown_renderer: &dyn MarkdownRenderer, - file_path: &Path, - content_dir: &Path, -) -> Result, RawssgError>; -``` - -Validates the path, reads the file, extracts frontmatter, renders Markdown, and returns a `PageContext`. Drafts are skipped automatically. -This function is used internally but is public for custom handler implementations. - ---- - -### `SiteBuilder` - -```rust -pub struct SiteBuilder { /* private fields */ } -``` - -The entry point for constructing a site. - -**Location**: `librawssg::site::builders::site_builder` (re‑exported as `librawssg::SiteBuilder`). - -**Methods**: - -| Method | Description | -| ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | -| `pub fn new() -> Self` | Creates a new builder with default values and `RealFs`. | -| `pub fn config(self, config: RawssgConfig) -> Self` | Sets the entire configuration. | -| `pub fn load_config + Send + Sync>(self, path: P) -> Result` | Loads configuration from a YAML file. | -| `pub fn content_dir(self, dir: impl Into) -> Self` | Sets the content directory (default `"content"`). | -| `pub fn output_dir(self, dir: impl Into) -> Self` | Sets the output directory (default `"dist"`). | -| `pub fn with_fs(self, fs: Box) -> Self` | Sets the filesystem backend. | -| `pub fn with_markdown_renderer(self, md: Box) -> Self` | Sets the Markdown renderer (**required**). | -| `pub fn with_template_renderer(self, tr: Box) -> Self` | Sets the template renderer (**required**). | -| `pub fn add_handler(self, handler: Box) -> Self` | Adds a content handler (defaults: `MarkdownPageHandler`, `StaticFileHandler`). | -| `pub fn with_feed_context_builder(self, b: Box) -> Self` | Sets the feed context builder. **Only required if `generators.rss.enabled` is `true`.** [![feature: tera](https://img.shields.io/badge/feature-tera-blue)] | -| `pub fn with_sitemap_context_builder(self, b: Box) -> Self` | Sets the sitemap context builder. **Only required if `generators.sitemap.enabled` is `true`.** [![feature: tera](https://img.shields.io/badge/feature-tera-blue)] | -| `pub fn build(self) -> Result` | Validates config, canonicalizes directories, processes all content, and returns a ready‑to‑generate `Site`. | - -### `Site` - -```rust -pub struct Site { /* private fields */ } -``` - -A fully processed site ready for output generation. -**Location**: `librawssg::site::builders::site` (re‑exported as `librawssg::Site`). - -**Methods**: - -| Method | Description | -| -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `pub fn pages(&self) -> &[PageContext]` | Returns all pages (including list pages). | -| `pub fn generate(self) -> Result<(), RawssgError>` | Writes HTML files, copies static assets and non‑Markdown files, optionally generates RSS and sitemap. Uses atomic output via `FileSystem::rename` with a recursive copy fallback if the rename fails due to `CrossesDevices`. | - ---- - -### `generate_feed` [![feature: tera](https://img.shields.io/badge/feature-tera-blue)] - -```rust -pub fn generate_feed( - renderer: &dyn TemplateRenderer, - config: &RawssgConfig, - posts: &[&PageContext], - base_url: &str, - context_builder: &dyn FeedContextBuilder, -) -> Result; -``` - -Renders an RSS feed from a list of blog posts using the configured RSS template and the provided context builder. - -### `generate_sitemap` [![feature: tera](https://img.shields.io/badge/feature-tera-blue)] - -```rust -pub fn generate_sitemap( - renderer: &dyn TemplateRenderer, - config: &RawssgConfig, - pages: &[PageContext], - base_url: &str, - context_builder: &dyn SitemapContextBuilder, -) -> Result; -``` - -Renders a sitemap XML from all pages using the configured sitemap template and the provided context builder. - -### Context builders (in `site::context`) [![feature: tera](https://img.shields.io/badge/feature-tera-blue)] - -```rust -pub trait FeedContextBuilder: Send + Sync { - fn build_feed_context(&self, config: &RawssgConfig, posts: &[&PageContext], base_url: &str) - -> Result, RawssgError>; -} - -pub trait SitemapContextBuilder: Send + Sync { - fn build_sitemap_context(&self, config: &RawssgConfig, pages: &[PageContext], base_url: &str) - -> Result, RawssgError>; -} -``` - -Default Tera implementations (`TeraFeedContextBuilder`, `TeraSitemapContextBuilder`) insert `site`, `posts`/`pages`, and `base_url`. Custom builders can inject additional variables. - ---- - -## `types` module - -### `RawssgConfig` - -```rust -pub struct RawssgConfig { - pub site: GlobalConfig, - pub build: BuildConfig, - pub content_types: Vec, - pub generators: GeneratorsConfig, -} -``` - -Top‑level configuration. -**Default**: includes a `page` content type (`**/*.md` → `base.html`). -**Method**: - -```rust -pub fn validate(&self) -> Result<(), RawssgError>; -``` - -Validates that `site_name` is non‑empty, `content_types` is non‑empty, each content type has a valid glob pattern and template, and required generator fields are present when enabled. - -### `GlobalConfig` - -```rust -pub struct GlobalConfig { - pub navbar: Vec, - pub sidebar: Vec, - pub site_name: String, // default "rawssg" - pub description: Option, - pub language: Option, // default Some("en") - pub base_url: Option, - pub author: Option, - pub repo_url: Option, - pub license: Option, -} -``` - -Site‑wide metadata available in templates as `site.`. - -### `BuildConfig` - -```rust -pub struct BuildConfig { - pub content_dir: String, // default "content" - pub output_dir: String, // default "dist" - pub templates_dir: String, // default "templates" - pub static_dir: String, // default "static" -} -``` - -### `ContentTypeDef` - -```rust -pub struct ContentTypeDef { - pub name: String, - pub pattern: String, - pub template: String, - pub list_template: Option, - pub list_enabled: bool, -} -``` - -Defines a content type. `pattern` is a glob relative to `content_dir`. If `list_enabled` is `true`, a list page (`{name}/index.html`) is generated using `list_template`. - -### `GeneratorsConfig` - -```rust -pub struct GeneratorsConfig { - pub rss: GeneratorDef, - pub sitemap: GeneratorDef, -} -``` - -### `GeneratorDef` - -```rust -pub struct GeneratorDef { - pub enabled: bool, // default false (changed in v0.5.0) - pub path: String, - pub template: String, -} -``` - -Configuration for RSS and sitemap generators. Both default to **disabled**. When enabled, `path` and `template` must be provided. - -### `NavItem` - -```rust -pub struct NavItem { - pub label: String, - pub url: String, -} -``` - -Used in `navbar` and `sidebar`. - -### `PageFrontMatter` - -```rust -pub struct PageFrontMatter { - pub title: String, - pub desc: String, - pub author: Option, - pub repo_url: Option, - pub license: Option, - pub date: Option, - pub tags: Vec, - pub draft: bool, -} -``` - -Parsed from the YAML frontmatter block. `title` and `desc` are required; everything else is optional. - -### `PageContext` - -```rust -pub struct PageContext { - pub frontmatter: PageFrontMatter, - pub content_html: String, - pub url: String, - pub file_path: String, - pub depth: usize, - pub pub_date: Option, - pub content_type: String, - pub is_list: bool, - pub list_items: Option>, -} -``` - -Fully processed representation of a page. `pub_date` is an RFC 2822 string if `date` was present in the frontmatter. `list_items` is populated only for list pages. - ---- - -## `util` module - -### `safe_path` - -```rust -pub fn safe_path( - fs: &dyn FileSystem, - base: &Path, - candidate: &Path, -) -> Result; -``` - -Validates that `candidate` is inside `base`. -- If the candidate exists, it canonicalises the path and checks the prefix. -- If the candidate does not exist (used for output files), it canonicalises the parent directory and verifies it is still within `base`. -Prevents directory traversal and symlink escapes. Returns the safe canonical path. - -### `slugify` - -```rust -pub fn slugify(title: &str) -> String; -``` - -Converts a string to a URL‑friendly slug (lowercase, only alphanumerics and hyphens). - -### `relative_prefix` - -```rust -pub fn relative_prefix(depth: usize) -> String; -``` - -Returns a relative path prefix based on directory depth: `"./"` for depth 0, `"../"` for depth 1, etc. - -### `match_pattern` - -```rust -pub fn match_pattern(pattern: &str, path: &Path) -> bool; -``` - -Matches a path against a glob pattern with `*` (single segment) and `**` (any number of segments). -The algorithm correctly handles patterns like `blog/**/*.html` (will not match `.md`). Used internally for content type matching. \ No newline at end of file diff --git a/docs/src/main.rs b/docs/src/main.rs deleted file mode 100644 index ee4c776..0000000 --- a/docs/src/main.rs +++ /dev/null @@ -1,49 +0,0 @@ -use librawssg::markdown::PulldownMarkdown; -use librawssg::site::context::{TeraFeedContextBuilder, TeraSitemapContextBuilder}; -use librawssg::site::TeraRenderer; -use librawssg::SiteBuilder; -use std::path::Path; - -fn main() -> Result<(), Box> { - let builder = SiteBuilder::new() - .load_config("config.yml") - .expect("Failed to load config.yml"); - - let mut tera = TeraRenderer::new(); - let templates_dir = Path::new("templates"); - if templates_dir.exists() { - for entry in std::fs::read_dir(templates_dir)? { - let entry = entry?; - let path = entry.path(); - if path.is_file() { - let name = path.file_name().unwrap().to_string_lossy().into_owned(); - let content = std::fs::read_to_string(&path)?; - tera.add_raw_template(&name, &content)?; - } - } - } - - let md = PulldownMarkdown; - - let content_dir = Path::new("content"); - let output_dir = Path::new("dist"); - - let site = builder - .content_dir(content_dir) - .output_dir(output_dir) - .with_template_renderer(Box::new(tera)) - .with_markdown_renderer(Box::new(md)) - .with_feed_context_builder(Box::new(TeraFeedContextBuilder)) - .with_sitemap_context_builder(Box::new(TeraSitemapContextBuilder)) - .build()?; - - println!("Generated {} pages:", site.pages().len()); - for page in site.pages() { - println!(" {} -> {}", page.file_path, page.url); - } - - site.generate()?; - println!("\nDocumentation built successfully in {}", output_dir.display()); - - Ok(()) -} \ No newline at end of file diff --git a/docs/static/normalize.css b/docs/static/normalize.css deleted file mode 100644 index bb6e2a7..0000000 --- a/docs/static/normalize.css +++ /dev/null @@ -1,351 +0,0 @@ -/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */ - -/* Document - ========================================================================== */ - -/** - * 1. Correct the line height in all browsers. - * 2. Prevent adjustments of font size after orientation changes in iOS. - */ - -html { - line-height: 1.15; /* 1 */ - -webkit-text-size-adjust: 100%; /* 2 */ -} - -/* Sections - ========================================================================== */ - -/** - * Remove the margin in all browsers. - */ - -body { - margin: 0; -} - -/** - * Render the `main` element consistently in IE. - */ - -main { - display: block; -} - -/** - * Correct the font size and margin on `h1` elements within `section` and - * `article` contexts in Chrome, Firefox, and Safari. - */ - -h1 { - font-size: 2em; - margin: 0.67em 0; -} - -/* Grouping content - ========================================================================== */ - -/** - * 1. Add the correct box sizing in Firefox. - * 2. Show the overflow in Edge and IE. - */ - -hr { - box-sizing: content-box; /* 1 */ - height: 0; /* 1 */ - overflow: visible; /* 2 */ -} - -/** - * 1. Correct the inheritance and scaling of font size in all browsers. - * 2. Correct the odd `em` font sizing in all browsers. - */ - -pre { - font-family: monospace, monospace; /* 1 */ - font-size: 1em; /* 2 */ -} - -/* Text-level semantics - ========================================================================== */ - -/** - * Remove the gray background on active links in IE 10. - */ - -a { - background-color: transparent; -} - -/** - * 1. Remove the bottom border in Chrome 57- - * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari. - */ - -abbr[title] { - border-bottom: none; /* 1 */ - text-decoration: underline; /* 2 */ - text-decoration: underline dotted; /* 2 */ -} - -/** - * Add the correct font weight in Chrome, Edge, and Safari. - */ - -b, -strong { - font-weight: bolder; -} - -/** - * 1. Correct the inheritance and scaling of font size in all browsers. - * 2. Correct the odd `em` font sizing in all browsers. - */ - -code, -kbd, -samp { - font-family: monospace, monospace; /* 1 */ - font-size: 1em; /* 2 */ -} - -/** - * Add the correct font size in all browsers. - */ - -small { - font-size: 80%; -} - -/** - * Prevent `sub` and `sup` elements from affecting the line height in - * all browsers. - */ - -sub, -sup { - font-size: 75%; - line-height: 0; - position: relative; - vertical-align: baseline; -} - -sub { - bottom: -0.25em; -} - -sup { - top: -0.5em; -} - -/* Embedded content - ========================================================================== */ - -/** - * Remove the border on images inside links in IE 10. - */ - -img { - border-style: none; -} - -/* Forms - ========================================================================== */ - -/** - * 1. Change the font styles in all browsers. - * 2. Remove the margin in Firefox and Safari. - */ - -button, -input, -optgroup, -select, -textarea { - font-family: inherit; /* 1 */ - font-size: 100%; /* 1 */ - line-height: 1.15; /* 1 */ - margin: 0; /* 2 */ -} - -/** - * Show the overflow in IE. - * 1. Show the overflow in Edge. - */ - -button, -input { - /* 1 */ - overflow: visible; -} - -/** - * Remove the inheritance of text transform in Edge, Firefox, and IE. - * 1. Remove the inheritance of text transform in Firefox. - */ - -button, -select { - /* 1 */ - text-transform: none; -} - -/** - * Correct the inability to style clickable types in iOS and Safari. - */ - -button, -[type="button"], -[type="reset"], -[type="submit"] { - -webkit-appearance: button; -} - -/** - * Remove the inner border and padding in Firefox. - */ - -button::-moz-focus-inner, -[type="button"]::-moz-focus-inner, -[type="reset"]::-moz-focus-inner, -[type="submit"]::-moz-focus-inner { - border-style: none; - padding: 0; -} - -/** - * Restore the focus styles unset by the previous rule. - */ - -button:-moz-focusring, -[type="button"]:-moz-focusring, -[type="reset"]:-moz-focusring, -[type="submit"]:-moz-focusring { - outline: 1px dotted ButtonText; -} - -/** - * Correct the padding in Firefox. - */ - -fieldset { - padding: 0.35em 0.75em 0.625em; -} - -/** - * 1. Correct the text wrapping in Edge and IE. - * 2. Correct the color inheritance from `fieldset` elements in IE. - * 3. Remove the padding so developers are not caught out when they zero out - * `fieldset` elements in all browsers. - */ - -legend { - box-sizing: border-box; /* 1 */ - color: inherit; /* 2 */ - display: table; /* 1 */ - max-width: 100%; /* 1 */ - padding: 0; /* 3 */ - white-space: normal; /* 1 */ -} - -/** - * Add the correct vertical alignment in Chrome, Firefox, and Opera. - */ - -progress { - vertical-align: baseline; -} - -/** - * Remove the default vertical scrollbar in IE 10+. - */ - -textarea { - overflow: auto; -} - -/** - * 1. Add the correct box sizing in IE 10. - * 2. Remove the padding in IE 10. - */ - -[type="checkbox"], -[type="radio"] { - box-sizing: border-box; /* 1 */ - padding: 0; /* 2 */ -} - -/** - * Correct the cursor style of increment and decrement buttons in Chrome. - */ - -[type="number"]::-webkit-inner-spin-button, -[type="number"]::-webkit-outer-spin-button { - height: auto; -} - -/** - * 1. Correct the odd appearance in Chrome and Safari. - * 2. Correct the outline style in Safari. - */ - -[type="search"] { - -webkit-appearance: textfield; /* 1 */ - outline-offset: -2px; /* 2 */ -} - -/** - * Remove the inner padding in Chrome and Safari on macOS. - */ - -[type="search"]::-webkit-search-decoration { - -webkit-appearance: none; -} - -/** - * 1. Correct the inability to style clickable types in iOS and Safari. - * 2. Change font properties to `inherit` in Safari. - */ - -::-webkit-file-upload-button { - -webkit-appearance: button; /* 1 */ - font: inherit; /* 2 */ -} - -/* Interactive - ========================================================================== */ - -/* - * Add the correct display in Edge, IE 10+, and Firefox. - */ - -details { - display: block; -} - -/* - * Add the correct display in all browsers. - */ - -summary { - display: list-item; -} - -/* Misc - ========================================================================== */ - -/** - * Add the correct display in IE 10+. - */ - -template { - display: none; -} - -/** - * Add the correct display in IE 10. - */ - -[hidden] { - display: none; -} diff --git a/docs/static/sidebar.css b/docs/static/sidebar.css deleted file mode 100644 index 2f58bd9..0000000 --- a/docs/static/sidebar.css +++ /dev/null @@ -1,88 +0,0 @@ -/* Layout dasar */ -.wrapper { - display: flex; - min-height: 100vh; -} - -/* Sidebar – sangat sederhana */ -.sidebar { - width: 220px; - flex-shrink: 0; - position: sticky; - top: 0; - height: 100vh; - padding: 1.5rem 1rem; - background: #fff; - border-right: 1px solid #ccc; - overflow-y: auto; -} - -.sidebar h2 { - font-size: 1rem; - font-weight: 600; - margin: 0 0 1rem; - padding-bottom: 0.5rem; - border-bottom: 1px solid #ddd; -} - -.sidebar h2 a { - color: #000; - text-decoration: none; -} - -.sidebar ul { - list-style: none; - padding: 0; - margin: 0; -} - -.sidebar li { - margin-bottom: 0.15rem; -} - -.sidebar a { - display: block; - padding: 0.3rem 0.5rem; - color: #333; - text-decoration: none; - font-size: 0.9rem; -} - -/* Tidak ada hover background, hanya underline */ -.sidebar a:hover { - text-decoration: underline; - color: #000; -} - -.sidebar a.active { - font-weight: 700; - color: #000; - text-decoration: underline; -} - -/* Konten utama – polos */ -.content { - flex: 1; - padding: 1.5rem 2rem; - background: #fff; -} - -/* Responsif – tetap berfungsi */ -@media (max-width: 768px) { - .wrapper { - flex-direction: column; - } - - .sidebar { - width: 100%; - height: auto; - position: static; - border-right: none; - border-bottom: 1px solid #ccc; - padding: 1rem; - } - - .content { - padding: 1rem; - } -} \ No newline at end of file diff --git a/docs/static/style.css b/docs/static/style.css deleted file mode 100644 index a964b5e..0000000 --- a/docs/static/style.css +++ /dev/null @@ -1,6838 +0,0 @@ -/*! normalize.css v4.1.1 | MIT License | github.com/necolas/normalize.css */ -html { - font-family: sans-serif; - -ms-text-size-adjust: 100%; - -webkit-text-size-adjust: 100%; -} - -body { - margin: 0; -} - -article, -aside, -details, -figcaption, -figure, -footer, -header, -main, -menu, -nav, -section { - display: block; -} - -summary { - display: list-item; -} - -audio, -canvas, -progress, -video { - display: inline-block; -} - -audio:not([controls]) { - display: none; - height: 0; -} - -progress { - vertical-align: baseline; -} - -template, -[hidden] { - display: none !important; -} - -a { - background-color: transparent; -} - -a:active, -a:hover { - outline-width: 0; -} - -abbr[title] { - border-bottom: none; - text-decoration: underline; - text-decoration: underline dotted; -} - -b, -strong { - font-weight: inherit; -} - -b, -strong { - font-weight: bolder; -} - -dfn { - font-style: italic; -} - -h1 { - font-size: 2em; - margin: 0.67em 0; -} - -mark { - background-color: #ff0; - color: #000; -} - -small { - font-size: 80%; -} - -sub, -sup { - font-size: 75%; - line-height: 0; - position: relative; - vertical-align: baseline; -} - -sub { - bottom: -0.25em; -} - -sup { - top: -0.5em; -} - -img { - border-style: none; -} - -svg:not(:root) { - overflow: hidden; -} - -code, -kbd, -pre, -samp { - font-family: monospace, monospace; - font-size: 1em; -} - -figure { - margin: 1em 40px; -} - -hr { - box-sizing: content-box; - height: 0; - overflow: visible; -} - -button, -input, -select, -textarea { - font: inherit; - margin: 0; -} - -optgroup { - font-weight: bold; -} - -button, -input { - overflow: visible; -} - -button, -select { - text-transform: none; -} - -button, -html [type="button"], -[type="reset"], -[type="submit"] { - -webkit-appearance: button; -} - -button::-moz-focus-inner, -[type="button"]::-moz-focus-inner, -[type="reset"]::-moz-focus-inner, -[type="submit"]::-moz-focus-inner { - border-style: none; - padding: 0; -} - -button:-moz-focusring, -[type="button"]:-moz-focusring, -[type="reset"]:-moz-focusring, -[type="submit"]:-moz-focusring { - outline: 1px dotted ButtonText; -} - -fieldset { - border: 1px solid #c0c0c0; - margin: 0 2px; - padding: 0.35em 0.625em 0.75em; -} - -legend { - box-sizing: border-box; - color: inherit; - display: table; - max-width: 100%; - padding: 0; - white-space: normal; -} - -textarea { - overflow: auto; -} - -[type="checkbox"], -[type="radio"] { - box-sizing: border-box; - padding: 0; -} - -[type="number"]::-webkit-inner-spin-button, -[type="number"]::-webkit-outer-spin-button { - height: auto; -} - -[type="search"] { - -webkit-appearance: textfield; - outline-offset: -2px; -} - -[type="search"]::-webkit-search-cancel-button, -[type="search"]::-webkit-search-decoration { - -webkit-appearance: none; -} - -::-webkit-input-placeholder { - color: inherit; - opacity: 0.54; -} - -::-webkit-file-upload-button { - -webkit-appearance: button; - font: inherit; -} - -* { - box-sizing: border-box; -} - -input, -select, -textarea, -button { - font-family: inherit; - font-size: inherit; - line-height: inherit; -} - -body { - font-family: - -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, - "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; - font-size: 14px; - line-height: 1.5; - color: #24292e; - background-color: #fff; -} - -a { - color: #0366d6; - text-decoration: none; -} - -a:hover { - text-decoration: underline; -} - -b, -strong { - font-weight: 600; -} - -hr, -.rule { - height: 0; - margin: 15px 0; - overflow: hidden; - background: transparent; - border: 0; - border-bottom: 1px solid #dfe2e5; -} - -hr::before, -.rule::before { - display: table; - content: ""; -} - -hr::after, -.rule::after { - display: table; - clear: both; - content: ""; -} - -table { - border-spacing: 0; - border-collapse: collapse; -} - -td, -th { - padding: 0; -} - -button { - cursor: pointer; - border-radius: 0; -} - -[hidden][hidden] { - display: none !important; -} - -details summary { - cursor: pointer; -} - -details:not([open]) > *:not(summary) { - display: none !important; -} - -h1, -h2, -h3, -h4, -h5, -h6 { - margin-top: 0; - margin-bottom: 0; -} - -h1 { - font-size: 32px; - font-weight: 600; -} - -h2 { - font-size: 24px; - font-weight: 600; -} - -h3 { - font-size: 20px; - font-weight: 600; -} - -h4 { - font-size: 16px; - font-weight: 600; -} - -h5 { - font-size: 14px; - font-weight: 600; -} - -h6 { - font-size: 12px; - font-weight: 600; -} - -p { - margin-top: 0; - margin-bottom: 10px; -} - -small { - font-size: 90%; -} - -blockquote { - margin: 0; -} - -ul, -ol { - padding-left: 0; - margin-top: 0; - margin-bottom: 0; -} - -ol ol, -ul ol { - list-style-type: lower-roman; -} - -ul ul ol, -ul ol ol, -ol ul ol, -ol ol ol { - list-style-type: lower-alpha; -} - -dd { - margin-left: 0; -} - -tt, -code { - font-family: - "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace; - font-size: 12px; -} - -pre { - margin-top: 0; - margin-bottom: 0; - font-family: - "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace; - font-size: 12px; -} - -.octicon { - vertical-align: text-bottom; -} - -.anim-fade-in { - animation-name: fade-in; - animation-duration: 1s; - animation-timing-function: ease-in-out; -} - -.anim-fade-in.fast { - animation-duration: 300ms; -} - -@keyframes fade-in { - 0% { - opacity: 0; - } - - 100% { - opacity: 1; - } -} - -.anim-fade-out { - animation-name: fade-out; - animation-duration: 1s; - animation-timing-function: ease-out; -} - -.anim-fade-out.fast { - animation-duration: 0.3s; -} - -@keyframes fade-out { - 0% { - opacity: 1; - } - - 100% { - opacity: 0; - } -} - -.anim-fade-up { - opacity: 0; - animation-name: fade-up; - animation-duration: 0.3s; - animation-fill-mode: forwards; - animation-timing-function: ease-out; - animation-delay: 1s; -} - -@keyframes fade-up { - 0% { - opacity: 0.8; - transform: translateY(100%); - } - - 100% { - opacity: 1; - transform: translateY(0); - } -} - -.anim-fade-down { - animation-name: fade-down; - animation-duration: 0.3s; - animation-fill-mode: forwards; - animation-timing-function: ease-in; -} - -@keyframes fade-down { - 0% { - opacity: 1; - transform: translateY(0); - } - - 100% { - opacity: 0.5; - transform: translateY(100%); - } -} - -.anim-grow-x { - width: 0%; - animation-name: grow-x; - animation-duration: 0.3s; - animation-fill-mode: forwards; - animation-timing-function: ease; - animation-delay: 0.5s; -} - -@keyframes grow-x { - to { - width: 100%; - } -} - -.anim-shrink-x { - animation-name: shrink-x; - animation-duration: 0.3s; - animation-fill-mode: forwards; - animation-timing-function: ease-in-out; - animation-delay: 0.5s; -} - -@keyframes shrink-x { - to { - width: 0%; - } -} - -.anim-scale-in { - animation-name: scale-in; - animation-duration: 0.15s; - animation-timing-function: cubic-bezier(0.2, 0, 0.13, 1.5); -} - -@keyframes scale-in { - 0% { - opacity: 0; - transform: scale(0.5); - } - - 100% { - opacity: 1; - transform: scale(1); - } -} - -.anim-pulse { - animation-name: pulse; - animation-duration: 2s; - animation-timing-function: linear; - animation-iteration-count: infinite; -} - -@keyframes pulse { - 0% { - opacity: 0.3; - } - - 10% { - opacity: 1; - } - - 100% { - opacity: 0.3; - } -} - -.anim-pulse-in { - animation-name: pulse-in; - animation-duration: 0.5s; -} - -@keyframes pulse-in { - 0% { - transform: scale3d(1, 1, 1); - } - - 50% { - transform: scale3d(1.1, 1.1, 1.1); - } - - 100% { - transform: scale3d(1, 1, 1); - } -} - -.hover-grow { - transition: transform 0.3s; - backface-visibility: hidden; -} - -.hover-grow:hover { - transform: scale(1.025); -} - -.border { - border: 1px #e1e4e8 solid !important; -} - -.border-y { - border-top: 1px #e1e4e8 solid !important; - border-bottom: 1px #e1e4e8 solid !important; -} - -.border-0 { - border: 0 !important; -} - -.border-dashed { - border-style: dashed !important; -} - -.border-blue { - border-color: #0366d6 !important; -} - -.border-blue-light { - border-color: #c8e1ff !important; -} - -.border-green { - border-color: #34d058 !important; -} - -.border-green-light { - border-color: #a2cbac !important; -} - -.border-red { - border-color: #d73a49 !important; -} - -.border-red-light { - border-color: #cea0a5 !important; -} - -.border-purple { - border-color: #6f42c1 !important; -} - -.border-yellow { - border-color: #d9d0a5 !important; -} - -.border-gray-light { - border-color: #eaecef !important; -} - -.border-gray-dark { - border-color: #d1d5da !important; -} - -.border-black-fade { - border-color: rgba(27, 31, 35, 0.15) !important; -} - -.border-top { - border-top: 1px #e1e4e8 solid !important; -} - -.border-right { - border-right: 1px #e1e4e8 solid !important; -} - -.border-bottom { - border-bottom: 1px #e1e4e8 solid !important; -} - -.border-left { - border-left: 1px #e1e4e8 solid !important; -} - -.border-top-0 { - border-top: 0 !important; -} - -.border-right-0 { - border-right: 0 !important; -} - -.border-bottom-0 { - border-bottom: 0 !important; -} - -.border-left-0 { - border-left: 0 !important; -} - -.rounded-0 { - border-radius: 0 !important; -} - -.rounded-1 { - border-radius: 3px !important; -} - -.rounded-2 { - border-radius: 6px !important; -} - -.rounded-top-0 { - border-top-left-radius: 0 !important; - border-top-right-radius: 0 !important; -} - -.rounded-top-1 { - border-top-left-radius: 3px !important; - border-top-right-radius: 3px !important; -} - -.rounded-top-2 { - border-top-left-radius: 6px !important; - border-top-right-radius: 6px !important; -} - -.rounded-right-0 { - border-top-right-radius: 0 !important; - border-bottom-right-radius: 0 !important; -} - -.rounded-right-1 { - border-top-right-radius: 3px !important; - border-bottom-right-radius: 3px !important; -} - -.rounded-right-2 { - border-top-right-radius: 6px !important; - border-bottom-right-radius: 6px !important; -} - -.rounded-bottom-0 { - border-bottom-right-radius: 0 !important; - border-bottom-left-radius: 0 !important; -} - -.rounded-bottom-1 { - border-bottom-right-radius: 3px !important; - border-bottom-left-radius: 3px !important; -} - -.rounded-bottom-2 { - border-bottom-right-radius: 6px !important; - border-bottom-left-radius: 6px !important; -} - -.rounded-left-0 { - border-bottom-left-radius: 0 !important; - border-top-left-radius: 0 !important; -} - -.rounded-left-1 { - border-bottom-left-radius: 3px !important; - border-top-left-radius: 3px !important; -} - -.rounded-left-2 { - border-bottom-left-radius: 6px !important; - border-top-left-radius: 6px !important; -} - -@media (min-width: 544px) { - .border-sm-top { - border-top: 1px #e1e4e8 solid !important; - } - - .border-sm-right { - border-right: 1px #e1e4e8 solid !important; - } - - .border-sm-bottom { - border-bottom: 1px #e1e4e8 solid !important; - } - - .border-sm-left { - border-left: 1px #e1e4e8 solid !important; - } - - .border-sm-top-0 { - border-top: 0 !important; - } - - .border-sm-right-0 { - border-right: 0 !important; - } - - .border-sm-bottom-0 { - border-bottom: 0 !important; - } - - .border-sm-left-0 { - border-left: 0 !important; - } - - .rounded-sm-0 { - border-radius: 0 !important; - } - - .rounded-sm-1 { - border-radius: 3px !important; - } - - .rounded-sm-2 { - border-radius: 6px !important; - } - - .rounded-sm-top-0 { - border-top-left-radius: 0 !important; - border-top-right-radius: 0 !important; - } - - .rounded-sm-top-1 { - border-top-left-radius: 3px !important; - border-top-right-radius: 3px !important; - } - - .rounded-sm-top-2 { - border-top-left-radius: 6px !important; - border-top-right-radius: 6px !important; - } - - .rounded-sm-right-0 { - border-top-right-radius: 0 !important; - border-bottom-right-radius: 0 !important; - } - - .rounded-sm-right-1 { - border-top-right-radius: 3px !important; - border-bottom-right-radius: 3px !important; - } - - .rounded-sm-right-2 { - border-top-right-radius: 6px !important; - border-bottom-right-radius: 6px !important; - } - - .rounded-sm-bottom-0 { - border-bottom-right-radius: 0 !important; - border-bottom-left-radius: 0 !important; - } - - .rounded-sm-bottom-1 { - border-bottom-right-radius: 3px !important; - border-bottom-left-radius: 3px !important; - } - - .rounded-sm-bottom-2 { - border-bottom-right-radius: 6px !important; - border-bottom-left-radius: 6px !important; - } - - .rounded-sm-left-0 { - border-bottom-left-radius: 0 !important; - border-top-left-radius: 0 !important; - } - - .rounded-sm-left-1 { - border-bottom-left-radius: 3px !important; - border-top-left-radius: 3px !important; - } - - .rounded-sm-left-2 { - border-bottom-left-radius: 6px !important; - border-top-left-radius: 6px !important; - } -} - -@media (min-width: 768px) { - .border-md-top { - border-top: 1px #e1e4e8 solid !important; - } - - .border-md-right { - border-right: 1px #e1e4e8 solid !important; - } - - .border-md-bottom { - border-bottom: 1px #e1e4e8 solid !important; - } - - .border-md-left { - border-left: 1px #e1e4e8 solid !important; - } - - .border-md-top-0 { - border-top: 0 !important; - } - - .border-md-right-0 { - border-right: 0 !important; - } - - .border-md-bottom-0 { - border-bottom: 0 !important; - } - - .border-md-left-0 { - border-left: 0 !important; - } - - .rounded-md-0 { - border-radius: 0 !important; - } - - .rounded-md-1 { - border-radius: 3px !important; - } - - .rounded-md-2 { - border-radius: 6px !important; - } - - .rounded-md-top-0 { - border-top-left-radius: 0 !important; - border-top-right-radius: 0 !important; - } - - .rounded-md-top-1 { - border-top-left-radius: 3px !important; - border-top-right-radius: 3px !important; - } - - .rounded-md-top-2 { - border-top-left-radius: 6px !important; - border-top-right-radius: 6px !important; - } - - .rounded-md-right-0 { - border-top-right-radius: 0 !important; - border-bottom-right-radius: 0 !important; - } - - .rounded-md-right-1 { - border-top-right-radius: 3px !important; - border-bottom-right-radius: 3px !important; - } - - .rounded-md-right-2 { - border-top-right-radius: 6px !important; - border-bottom-right-radius: 6px !important; - } - - .rounded-md-bottom-0 { - border-bottom-right-radius: 0 !important; - border-bottom-left-radius: 0 !important; - } - - .rounded-md-bottom-1 { - border-bottom-right-radius: 3px !important; - border-bottom-left-radius: 3px !important; - } - - .rounded-md-bottom-2 { - border-bottom-right-radius: 6px !important; - border-bottom-left-radius: 6px !important; - } - - .rounded-md-left-0 { - border-bottom-left-radius: 0 !important; - border-top-left-radius: 0 !important; - } - - .rounded-md-left-1 { - border-bottom-left-radius: 3px !important; - border-top-left-radius: 3px !important; - } - - .rounded-md-left-2 { - border-bottom-left-radius: 6px !important; - border-top-left-radius: 6px !important; - } -} - -@media (min-width: 1012px) { - .border-lg-top { - border-top: 1px #e1e4e8 solid !important; - } - - .border-lg-right { - border-right: 1px #e1e4e8 solid !important; - } - - .border-lg-bottom { - border-bottom: 1px #e1e4e8 solid !important; - } - - .border-lg-left { - border-left: 1px #e1e4e8 solid !important; - } - - .border-lg-top-0 { - border-top: 0 !important; - } - - .border-lg-right-0 { - border-right: 0 !important; - } - - .border-lg-bottom-0 { - border-bottom: 0 !important; - } - - .border-lg-left-0 { - border-left: 0 !important; - } - - .rounded-lg-0 { - border-radius: 0 !important; - } - - .rounded-lg-1 { - border-radius: 3px !important; - } - - .rounded-lg-2 { - border-radius: 6px !important; - } - - .rounded-lg-top-0 { - border-top-left-radius: 0 !important; - border-top-right-radius: 0 !important; - } - - .rounded-lg-top-1 { - border-top-left-radius: 3px !important; - border-top-right-radius: 3px !important; - } - - .rounded-lg-top-2 { - border-top-left-radius: 6px !important; - border-top-right-radius: 6px !important; - } - - .rounded-lg-right-0 { - border-top-right-radius: 0 !important; - border-bottom-right-radius: 0 !important; - } - - .rounded-lg-right-1 { - border-top-right-radius: 3px !important; - border-bottom-right-radius: 3px !important; - } - - .rounded-lg-right-2 { - border-top-right-radius: 6px !important; - border-bottom-right-radius: 6px !important; - } - - .rounded-lg-bottom-0 { - border-bottom-right-radius: 0 !important; - border-bottom-left-radius: 0 !important; - } - - .rounded-lg-bottom-1 { - border-bottom-right-radius: 3px !important; - border-bottom-left-radius: 3px !important; - } - - .rounded-lg-bottom-2 { - border-bottom-right-radius: 6px !important; - border-bottom-left-radius: 6px !important; - } - - .rounded-lg-left-0 { - border-bottom-left-radius: 0 !important; - border-top-left-radius: 0 !important; - } - - .rounded-lg-left-1 { - border-bottom-left-radius: 3px !important; - border-top-left-radius: 3px !important; - } - - .rounded-lg-left-2 { - border-bottom-left-radius: 6px !important; - border-top-left-radius: 6px !important; - } -} - -@media (min-width: 1280px) { - .border-xl-top { - border-top: 1px #e1e4e8 solid !important; - } - - .border-xl-right { - border-right: 1px #e1e4e8 solid !important; - } - - .border-xl-bottom { - border-bottom: 1px #e1e4e8 solid !important; - } - - .border-xl-left { - border-left: 1px #e1e4e8 solid !important; - } - - .border-xl-top-0 { - border-top: 0 !important; - } - - .border-xl-right-0 { - border-right: 0 !important; - } - - .border-xl-bottom-0 { - border-bottom: 0 !important; - } - - .border-xl-left-0 { - border-left: 0 !important; - } - - .rounded-xl-0 { - border-radius: 0 !important; - } - - .rounded-xl-1 { - border-radius: 3px !important; - } - - .rounded-xl-2 { - border-radius: 6px !important; - } - - .rounded-xl-top-0 { - border-top-left-radius: 0 !important; - border-top-right-radius: 0 !important; - } - - .rounded-xl-top-1 { - border-top-left-radius: 3px !important; - border-top-right-radius: 3px !important; - } - - .rounded-xl-top-2 { - border-top-left-radius: 6px !important; - border-top-right-radius: 6px !important; - } - - .rounded-xl-right-0 { - border-top-right-radius: 0 !important; - border-bottom-right-radius: 0 !important; - } - - .rounded-xl-right-1 { - border-top-right-radius: 3px !important; - border-bottom-right-radius: 3px !important; - } - - .rounded-xl-right-2 { - border-top-right-radius: 6px !important; - border-bottom-right-radius: 6px !important; - } - - .rounded-xl-bottom-0 { - border-bottom-right-radius: 0 !important; - border-bottom-left-radius: 0 !important; - } - - .rounded-xl-bottom-1 { - border-bottom-right-radius: 3px !important; - border-bottom-left-radius: 3px !important; - } - - .rounded-xl-bottom-2 { - border-bottom-right-radius: 6px !important; - border-bottom-left-radius: 6px !important; - } - - .rounded-xl-left-0 { - border-bottom-left-radius: 0 !important; - border-top-left-radius: 0 !important; - } - - .rounded-xl-left-1 { - border-bottom-left-radius: 3px !important; - border-top-left-radius: 3px !important; - } - - .rounded-xl-left-2 { - border-bottom-left-radius: 6px !important; - border-top-left-radius: 6px !important; - } -} - -.circle { - border-radius: 50% !important; -} - -.box-shadow { - box-shadow: 0 1px 1px rgba(27, 31, 35, 0.1) !important; -} - -.box-shadow-medium { - box-shadow: 0 1px 5px rgba(27, 31, 35, 0.15) !important; -} - -.box-shadow-large { - box-shadow: 0 1px 15px rgba(27, 31, 35, 0.15) !important; -} - -.box-shadow-extra-large { - box-shadow: 0 10px 50px rgba(27, 31, 35, 0.07) !important; -} - -.box-shadow-none { - box-shadow: none !important; -} - -.bg-white { - background-color: #fff !important; -} - -.bg-blue { - background-color: #0366d6 !important; -} - -.bg-blue-light { - background-color: #f1f8ff !important; -} - -.bg-gray-dark { - background-color: #24292e !important; -} - -.bg-gray { - background-color: #f6f8fa !important; -} - -.bg-gray-light { - background-color: #fafbfc !important; -} - -.bg-green { - background-color: #28a745 !important; -} - -.bg-green-light { - background-color: #dcffe4 !important; -} - -.bg-red { - background-color: #d73a49 !important; -} - -.bg-red-light { - background-color: #ffdce0 !important; -} - -.bg-yellow { - background-color: #ffd33d !important; -} - -.bg-yellow-light { - background-color: #fff5b1 !important; -} - -.bg-purple { - background-color: #6f42c1 !important; -} - -.bg-purple-light { - background-color: #f5f0ff !important; -} - -.bg-shade-gradient { - background-image: linear-gradient( - 180deg, - rgba(27, 31, 35, 0.065), - rgba(27, 31, 35, 0) - ) !important; - background-repeat: no-repeat !important; - background-size: 100% 200px !important; -} - -.text-blue { - color: #0366d6 !important; -} - -.text-red { - color: #cb2431 !important; -} - -.text-gray-light { - color: #6a737d !important; -} - -.text-gray { - color: #586069 !important; -} - -.text-gray-dark { - color: #24292e !important; -} - -.text-green { - color: #28a745 !important; -} - -.text-orange { - color: #a04100 !important; -} - -.text-orange-light { - color: #e36209 !important; -} - -.text-purple { - color: #6f42c1 !important; -} - -.text-white { - color: #fff !important; -} - -.text-inherit { - color: inherit !important; -} - -.text-pending { - color: #b08800 !important; -} - -.bg-pending { - color: #dbab09 !important; -} - -.link-gray { - color: #586069 !important; -} - -.link-gray:hover { - color: #0366d6 !important; -} - -.link-gray-dark { - color: #24292e !important; -} - -.link-gray-dark:hover { - color: #0366d6 !important; -} - -.link-hover-blue:hover { - color: #0366d6 !important; -} - -.muted-link { - color: #586069 !important; -} - -.muted-link:hover { - color: #0366d6 !important; - text-decoration: none; -} - -.details-overlay[open] > summary::before { - position: fixed; - top: 0; - right: 0; - bottom: 0; - left: 0; - z-index: 80; - display: block; - cursor: default; - content: " "; - background: transparent; -} - -.details-overlay-dark[open] > summary::before { - z-index: 99; - background: rgba(27, 31, 35, 0.5); -} - -.flex-row { - flex-direction: row !important; -} - -.flex-row-reverse { - flex-direction: row-reverse !important; -} - -.flex-column { - flex-direction: column !important; -} - -.flex-wrap { - flex-wrap: wrap !important; -} - -.flex-nowrap { - flex-wrap: nowrap !important; -} - -.flex-justify-start { - justify-content: flex-start !important; -} - -.flex-justify-end { - justify-content: flex-end !important; -} - -.flex-justify-center { - justify-content: center !important; -} - -.flex-justify-between { - justify-content: space-between !important; -} - -.flex-justify-around { - justify-content: space-around !important; -} - -.flex-items-start { - align-items: flex-start !important; -} - -.flex-items-end { - align-items: flex-end !important; -} - -.flex-items-center { - align-items: center !important; -} - -.flex-items-baseline { - align-items: baseline !important; -} - -.flex-items-stretch { - align-items: stretch !important; -} - -.flex-content-start { - align-content: flex-start !important; -} - -.flex-content-end { - align-content: flex-end !important; -} - -.flex-content-center { - align-content: center !important; -} - -.flex-content-between { - align-content: space-between !important; -} - -.flex-content-around { - align-content: space-around !important; -} - -.flex-content-stretch { - align-content: stretch !important; -} - -.flex-auto { - flex: 1 1 auto !important; -} - -.flex-shrink-0 { - flex-shrink: 0 !important; -} - -.flex-self-auto { - align-self: auto !important; -} - -.flex-self-start { - align-self: flex-start !important; -} - -.flex-self-end { - align-self: flex-end !important; -} - -.flex-self-center { - align-self: center !important; -} - -.flex-self-baseline { - align-self: baseline !important; -} - -.flex-self-stretch { - align-self: stretch !important; -} - -.flex-item-equal { - flex-grow: 1; - flex-basis: 0; -} - -@media (min-width: 544px) { - .flex-sm-row { - flex-direction: row !important; - } - - .flex-sm-row-reverse { - flex-direction: row-reverse !important; - } - - .flex-sm-column { - flex-direction: column !important; - } - - .flex-sm-wrap { - flex-wrap: wrap !important; - } - - .flex-sm-nowrap { - flex-wrap: nowrap !important; - } - - .flex-sm-justify-start { - justify-content: flex-start !important; - } - - .flex-sm-justify-end { - justify-content: flex-end !important; - } - - .flex-sm-justify-center { - justify-content: center !important; - } - - .flex-sm-justify-between { - justify-content: space-between !important; - } - - .flex-sm-justify-around { - justify-content: space-around !important; - } - - .flex-sm-items-start { - align-items: flex-start !important; - } - - .flex-sm-items-end { - align-items: flex-end !important; - } - - .flex-sm-items-center { - align-items: center !important; - } - - .flex-sm-items-baseline { - align-items: baseline !important; - } - - .flex-sm-items-stretch { - align-items: stretch !important; - } - - .flex-sm-content-start { - align-content: flex-start !important; - } - - .flex-sm-content-end { - align-content: flex-end !important; - } - - .flex-sm-content-center { - align-content: center !important; - } - - .flex-sm-content-between { - align-content: space-between !important; - } - - .flex-sm-content-around { - align-content: space-around !important; - } - - .flex-sm-content-stretch { - align-content: stretch !important; - } - - .flex-sm-auto { - flex: 1 1 auto !important; - } - - .flex-sm-shrink-0 { - flex-shrink: 0 !important; - } - - .flex-sm-self-auto { - align-self: auto !important; - } - - .flex-sm-self-start { - align-self: flex-start !important; - } - - .flex-sm-self-end { - align-self: flex-end !important; - } - - .flex-sm-self-center { - align-self: center !important; - } - - .flex-sm-self-baseline { - align-self: baseline !important; - } - - .flex-sm-self-stretch { - align-self: stretch !important; - } - - .flex-sm-item-equal { - flex-grow: 1; - flex-basis: 0; - } -} - -@media (min-width: 768px) { - .flex-md-row { - flex-direction: row !important; - } - - .flex-md-row-reverse { - flex-direction: row-reverse !important; - } - - .flex-md-column { - flex-direction: column !important; - } - - .flex-md-wrap { - flex-wrap: wrap !important; - } - - .flex-md-nowrap { - flex-wrap: nowrap !important; - } - - .flex-md-justify-start { - justify-content: flex-start !important; - } - - .flex-md-justify-end { - justify-content: flex-end !important; - } - - .flex-md-justify-center { - justify-content: center !important; - } - - .flex-md-justify-between { - justify-content: space-between !important; - } - - .flex-md-justify-around { - justify-content: space-around !important; - } - - .flex-md-items-start { - align-items: flex-start !important; - } - - .flex-md-items-end { - align-items: flex-end !important; - } - - .flex-md-items-center { - align-items: center !important; - } - - .flex-md-items-baseline { - align-items: baseline !important; - } - - .flex-md-items-stretch { - align-items: stretch !important; - } - - .flex-md-content-start { - align-content: flex-start !important; - } - - .flex-md-content-end { - align-content: flex-end !important; - } - - .flex-md-content-center { - align-content: center !important; - } - - .flex-md-content-between { - align-content: space-between !important; - } - - .flex-md-content-around { - align-content: space-around !important; - } - - .flex-md-content-stretch { - align-content: stretch !important; - } - - .flex-md-auto { - flex: 1 1 auto !important; - } - - .flex-md-shrink-0 { - flex-shrink: 0 !important; - } - - .flex-md-self-auto { - align-self: auto !important; - } - - .flex-md-self-start { - align-self: flex-start !important; - } - - .flex-md-self-end { - align-self: flex-end !important; - } - - .flex-md-self-center { - align-self: center !important; - } - - .flex-md-self-baseline { - align-self: baseline !important; - } - - .flex-md-self-stretch { - align-self: stretch !important; - } - - .flex-md-item-equal { - flex-grow: 1; - flex-basis: 0; - } -} - -@media (min-width: 1012px) { - .flex-lg-row { - flex-direction: row !important; - } - - .flex-lg-row-reverse { - flex-direction: row-reverse !important; - } - - .flex-lg-column { - flex-direction: column !important; - } - - .flex-lg-wrap { - flex-wrap: wrap !important; - } - - .flex-lg-nowrap { - flex-wrap: nowrap !important; - } - - .flex-lg-justify-start { - justify-content: flex-start !important; - } - - .flex-lg-justify-end { - justify-content: flex-end !important; - } - - .flex-lg-justify-center { - justify-content: center !important; - } - - .flex-lg-justify-between { - justify-content: space-between !important; - } - - .flex-lg-justify-around { - justify-content: space-around !important; - } - - .flex-lg-items-start { - align-items: flex-start !important; - } - - .flex-lg-items-end { - align-items: flex-end !important; - } - - .flex-lg-items-center { - align-items: center !important; - } - - .flex-lg-items-baseline { - align-items: baseline !important; - } - - .flex-lg-items-stretch { - align-items: stretch !important; - } - - .flex-lg-content-start { - align-content: flex-start !important; - } - - .flex-lg-content-end { - align-content: flex-end !important; - } - - .flex-lg-content-center { - align-content: center !important; - } - - .flex-lg-content-between { - align-content: space-between !important; - } - - .flex-lg-content-around { - align-content: space-around !important; - } - - .flex-lg-content-stretch { - align-content: stretch !important; - } - - .flex-lg-auto { - flex: 1 1 auto !important; - } - - .flex-lg-shrink-0 { - flex-shrink: 0 !important; - } - - .flex-lg-self-auto { - align-self: auto !important; - } - - .flex-lg-self-start { - align-self: flex-start !important; - } - - .flex-lg-self-end { - align-self: flex-end !important; - } - - .flex-lg-self-center { - align-self: center !important; - } - - .flex-lg-self-baseline { - align-self: baseline !important; - } - - .flex-lg-self-stretch { - align-self: stretch !important; - } - - .flex-lg-item-equal { - flex-grow: 1; - flex-basis: 0; - } -} - -@media (min-width: 1280px) { - .flex-xl-row { - flex-direction: row !important; - } - - .flex-xl-row-reverse { - flex-direction: row-reverse !important; - } - - .flex-xl-column { - flex-direction: column !important; - } - - .flex-xl-wrap { - flex-wrap: wrap !important; - } - - .flex-xl-nowrap { - flex-wrap: nowrap !important; - } - - .flex-xl-justify-start { - justify-content: flex-start !important; - } - - .flex-xl-justify-end { - justify-content: flex-end !important; - } - - .flex-xl-justify-center { - justify-content: center !important; - } - - .flex-xl-justify-between { - justify-content: space-between !important; - } - - .flex-xl-justify-around { - justify-content: space-around !important; - } - - .flex-xl-items-start { - align-items: flex-start !important; - } - - .flex-xl-items-end { - align-items: flex-end !important; - } - - .flex-xl-items-center { - align-items: center !important; - } - - .flex-xl-items-baseline { - align-items: baseline !important; - } - - .flex-xl-items-stretch { - align-items: stretch !important; - } - - .flex-xl-content-start { - align-content: flex-start !important; - } - - .flex-xl-content-end { - align-content: flex-end !important; - } - - .flex-xl-content-center { - align-content: center !important; - } - - .flex-xl-content-between { - align-content: space-between !important; - } - - .flex-xl-content-around { - align-content: space-around !important; - } - - .flex-xl-content-stretch { - align-content: stretch !important; - } - - .flex-xl-auto { - flex: 1 1 auto !important; - } - - .flex-xl-shrink-0 { - flex-shrink: 0 !important; - } - - .flex-xl-self-auto { - align-self: auto !important; - } - - .flex-xl-self-start { - align-self: flex-start !important; - } - - .flex-xl-self-end { - align-self: flex-end !important; - } - - .flex-xl-self-center { - align-self: center !important; - } - - .flex-xl-self-baseline { - align-self: baseline !important; - } - - .flex-xl-self-stretch { - align-self: stretch !important; - } - - .flex-xl-item-equal { - flex-grow: 1; - flex-basis: 0; - } -} - -.position-static { - position: static !important; -} - -.position-relative { - position: relative !important; -} - -.position-absolute { - position: absolute !important; -} - -.position-fixed { - position: fixed !important; -} - -.top-0 { - top: 0 !important; -} - -.right-0 { - right: 0 !important; -} - -.bottom-0 { - bottom: 0 !important; -} - -.left-0 { - left: 0 !important; -} - -.v-align-middle { - vertical-align: middle !important; -} - -.v-align-top { - vertical-align: top !important; -} - -.v-align-bottom { - vertical-align: bottom !important; -} - -.v-align-text-top { - vertical-align: text-top !important; -} - -.v-align-text-bottom { - vertical-align: text-bottom !important; -} - -.v-align-baseline { - vertical-align: baseline !important; -} - -.overflow-hidden { - overflow: hidden !important; -} - -.overflow-scroll { - overflow: scroll !important; -} - -.overflow-auto { - overflow: auto !important; -} - -.clearfix::before { - display: table; - content: ""; -} - -.clearfix::after { - display: table; - clear: both; - content: ""; -} - -.float-left { - float: left !important; -} - -.float-right { - float: right !important; -} - -.float-none { - float: none !important; -} - -@media (min-width: 544px) { - .float-sm-left { - float: left !important; - } - - .float-sm-right { - float: right !important; - } - - .float-sm-none { - float: none !important; - } -} - -@media (min-width: 768px) { - .float-md-left { - float: left !important; - } - - .float-md-right { - float: right !important; - } - - .float-md-none { - float: none !important; - } -} - -@media (min-width: 1012px) { - .float-lg-left { - float: left !important; - } - - .float-lg-right { - float: right !important; - } - - .float-lg-none { - float: none !important; - } -} - -@media (min-width: 1280px) { - .float-xl-left { - float: left !important; - } - - .float-xl-right { - float: right !important; - } - - .float-xl-none { - float: none !important; - } -} - -.width-fit { - max-width: 100% !important; -} - -.width-full { - width: 100% !important; -} - -.height-fit { - max-height: 100% !important; -} - -.height-full { - height: 100% !important; -} - -.min-width-0 { - min-width: 0 !important; -} - -.direction-rtl { - direction: rtl !important; -} - -.direction-ltr { - direction: ltr !important; -} - -@media (min-width: 544px) { - .direction-sm-rtl { - direction: rtl !important; - } - - .direction-sm-ltr { - direction: ltr !important; - } -} - -@media (min-width: 768px) { - .direction-md-rtl { - direction: rtl !important; - } - - .direction-md-ltr { - direction: ltr !important; - } -} - -@media (min-width: 1012px) { - .direction-lg-rtl { - direction: rtl !important; - } - - .direction-lg-ltr { - direction: ltr !important; - } -} - -@media (min-width: 1280px) { - .direction-xl-rtl { - direction: rtl !important; - } - - .direction-xl-ltr { - direction: ltr !important; - } -} - -.m-0 { - margin: 0 !important; -} - -.mt-0 { - margin-top: 0 !important; -} - -.mr-0 { - margin-right: 0 !important; -} - -.mb-0 { - margin-bottom: 0 !important; -} - -.ml-0 { - margin-left: 0 !important; -} - -.mx-0 { - margin-right: 0 !important; - margin-left: 0 !important; -} - -.my-0 { - margin-top: 0 !important; - margin-bottom: 0 !important; -} - -.m-1 { - margin: 4px !important; -} - -.mt-1 { - margin-top: 4px !important; -} - -.mr-1 { - margin-right: 4px !important; -} - -.mb-1 { - margin-bottom: 4px !important; -} - -.ml-1 { - margin-left: 4px !important; -} - -.mt-n1 { - margin-top: -4px !important; -} - -.mr-n1 { - margin-right: -4px !important; -} - -.mb-n1 { - margin-bottom: -4px !important; -} - -.ml-n1 { - margin-left: -4px !important; -} - -.mx-1 { - margin-right: 4px !important; - margin-left: 4px !important; -} - -.my-1 { - margin-top: 4px !important; - margin-bottom: 4px !important; -} - -.m-2 { - margin: 8px !important; -} - -.mt-2 { - margin-top: 8px !important; -} - -.mr-2 { - margin-right: 8px !important; -} - -.mb-2 { - margin-bottom: 8px !important; -} - -.ml-2 { - margin-left: 8px !important; -} - -.mt-n2 { - margin-top: -8px !important; -} - -.mr-n2 { - margin-right: -8px !important; -} - -.mb-n2 { - margin-bottom: -8px !important; -} - -.ml-n2 { - margin-left: -8px !important; -} - -.mx-2 { - margin-right: 8px !important; - margin-left: 8px !important; -} - -.my-2 { - margin-top: 8px !important; - margin-bottom: 8px !important; -} - -.m-3 { - margin: 16px !important; -} - -.mt-3 { - margin-top: 16px !important; -} - -.mr-3 { - margin-right: 16px !important; -} - -.mb-3 { - margin-bottom: 16px !important; -} - -.ml-3 { - margin-left: 16px !important; -} - -.mt-n3 { - margin-top: -16px !important; -} - -.mr-n3 { - margin-right: -16px !important; -} - -.mb-n3 { - margin-bottom: -16px !important; -} - -.ml-n3 { - margin-left: -16px !important; -} - -.mx-3 { - margin-right: 16px !important; - margin-left: 16px !important; -} - -.my-3 { - margin-top: 16px !important; - margin-bottom: 16px !important; -} - -.m-4 { - margin: 24px !important; -} - -.mt-4 { - margin-top: 24px !important; -} - -.mr-4 { - margin-right: 24px !important; -} - -.mb-4 { - margin-bottom: 24px !important; -} - -.ml-4 { - margin-left: 24px !important; -} - -.mt-n4 { - margin-top: -24px !important; -} - -.mr-n4 { - margin-right: -24px !important; -} - -.mb-n4 { - margin-bottom: -24px !important; -} - -.ml-n4 { - margin-left: -24px !important; -} - -.mx-4 { - margin-right: 24px !important; - margin-left: 24px !important; -} - -.my-4 { - margin-top: 24px !important; - margin-bottom: 24px !important; -} - -.m-5 { - margin: 32px !important; -} - -.mt-5 { - margin-top: 32px !important; -} - -.mr-5 { - margin-right: 32px !important; -} - -.mb-5 { - margin-bottom: 32px !important; -} - -.ml-5 { - margin-left: 32px !important; -} - -.mt-n5 { - margin-top: -32px !important; -} - -.mr-n5 { - margin-right: -32px !important; -} - -.mb-n5 { - margin-bottom: -32px !important; -} - -.ml-n5 { - margin-left: -32px !important; -} - -.mx-5 { - margin-right: 32px !important; - margin-left: 32px !important; -} - -.my-5 { - margin-top: 32px !important; - margin-bottom: 32px !important; -} - -.m-6 { - margin: 40px !important; -} - -.mt-6 { - margin-top: 40px !important; -} - -.mr-6 { - margin-right: 40px !important; -} - -.mb-6 { - margin-bottom: 40px !important; -} - -.ml-6 { - margin-left: 40px !important; -} - -.mt-n6 { - margin-top: -40px !important; -} - -.mr-n6 { - margin-right: -40px !important; -} - -.mb-n6 { - margin-bottom: -40px !important; -} - -.ml-n6 { - margin-left: -40px !important; -} - -.mx-6 { - margin-right: 40px !important; - margin-left: 40px !important; -} - -.my-6 { - margin-top: 40px !important; - margin-bottom: 40px !important; -} - -.mx-auto { - margin-right: auto !important; - margin-left: auto !important; -} - -@media (min-width: 544px) { - .m-sm-0 { - margin: 0 !important; - } - - .mt-sm-0 { - margin-top: 0 !important; - } - - .mr-sm-0 { - margin-right: 0 !important; - } - - .mb-sm-0 { - margin-bottom: 0 !important; - } - - .ml-sm-0 { - margin-left: 0 !important; - } - - .mx-sm-0 { - margin-right: 0 !important; - margin-left: 0 !important; - } - - .my-sm-0 { - margin-top: 0 !important; - margin-bottom: 0 !important; - } - - .m-sm-1 { - margin: 4px !important; - } - - .mt-sm-1 { - margin-top: 4px !important; - } - - .mr-sm-1 { - margin-right: 4px !important; - } - - .mb-sm-1 { - margin-bottom: 4px !important; - } - - .ml-sm-1 { - margin-left: 4px !important; - } - - .mt-sm-n1 { - margin-top: -4px !important; - } - - .mr-sm-n1 { - margin-right: -4px !important; - } - - .mb-sm-n1 { - margin-bottom: -4px !important; - } - - .ml-sm-n1 { - margin-left: -4px !important; - } - - .mx-sm-1 { - margin-right: 4px !important; - margin-left: 4px !important; - } - - .my-sm-1 { - margin-top: 4px !important; - margin-bottom: 4px !important; - } - - .m-sm-2 { - margin: 8px !important; - } - - .mt-sm-2 { - margin-top: 8px !important; - } - - .mr-sm-2 { - margin-right: 8px !important; - } - - .mb-sm-2 { - margin-bottom: 8px !important; - } - - .ml-sm-2 { - margin-left: 8px !important; - } - - .mt-sm-n2 { - margin-top: -8px !important; - } - - .mr-sm-n2 { - margin-right: -8px !important; - } - - .mb-sm-n2 { - margin-bottom: -8px !important; - } - - .ml-sm-n2 { - margin-left: -8px !important; - } - - .mx-sm-2 { - margin-right: 8px !important; - margin-left: 8px !important; - } - - .my-sm-2 { - margin-top: 8px !important; - margin-bottom: 8px !important; - } - - .m-sm-3 { - margin: 16px !important; - } - - .mt-sm-3 { - margin-top: 16px !important; - } - - .mr-sm-3 { - margin-right: 16px !important; - } - - .mb-sm-3 { - margin-bottom: 16px !important; - } - - .ml-sm-3 { - margin-left: 16px !important; - } - - .mt-sm-n3 { - margin-top: -16px !important; - } - - .mr-sm-n3 { - margin-right: -16px !important; - } - - .mb-sm-n3 { - margin-bottom: -16px !important; - } - - .ml-sm-n3 { - margin-left: -16px !important; - } - - .mx-sm-3 { - margin-right: 16px !important; - margin-left: 16px !important; - } - - .my-sm-3 { - margin-top: 16px !important; - margin-bottom: 16px !important; - } - - .m-sm-4 { - margin: 24px !important; - } - - .mt-sm-4 { - margin-top: 24px !important; - } - - .mr-sm-4 { - margin-right: 24px !important; - } - - .mb-sm-4 { - margin-bottom: 24px !important; - } - - .ml-sm-4 { - margin-left: 24px !important; - } - - .mt-sm-n4 { - margin-top: -24px !important; - } - - .mr-sm-n4 { - margin-right: -24px !important; - } - - .mb-sm-n4 { - margin-bottom: -24px !important; - } - - .ml-sm-n4 { - margin-left: -24px !important; - } - - .mx-sm-4 { - margin-right: 24px !important; - margin-left: 24px !important; - } - - .my-sm-4 { - margin-top: 24px !important; - margin-bottom: 24px !important; - } - - .m-sm-5 { - margin: 32px !important; - } - - .mt-sm-5 { - margin-top: 32px !important; - } - - .mr-sm-5 { - margin-right: 32px !important; - } - - .mb-sm-5 { - margin-bottom: 32px !important; - } - - .ml-sm-5 { - margin-left: 32px !important; - } - - .mt-sm-n5 { - margin-top: -32px !important; - } - - .mr-sm-n5 { - margin-right: -32px !important; - } - - .mb-sm-n5 { - margin-bottom: -32px !important; - } - - .ml-sm-n5 { - margin-left: -32px !important; - } - - .mx-sm-5 { - margin-right: 32px !important; - margin-left: 32px !important; - } - - .my-sm-5 { - margin-top: 32px !important; - margin-bottom: 32px !important; - } - - .m-sm-6 { - margin: 40px !important; - } - - .mt-sm-6 { - margin-top: 40px !important; - } - - .mr-sm-6 { - margin-right: 40px !important; - } - - .mb-sm-6 { - margin-bottom: 40px !important; - } - - .ml-sm-6 { - margin-left: 40px !important; - } - - .mt-sm-n6 { - margin-top: -40px !important; - } - - .mr-sm-n6 { - margin-right: -40px !important; - } - - .mb-sm-n6 { - margin-bottom: -40px !important; - } - - .ml-sm-n6 { - margin-left: -40px !important; - } - - .mx-sm-6 { - margin-right: 40px !important; - margin-left: 40px !important; - } - - .my-sm-6 { - margin-top: 40px !important; - margin-bottom: 40px !important; - } - - .mx-sm-auto { - margin-right: auto !important; - margin-left: auto !important; - } -} - -@media (min-width: 768px) { - .m-md-0 { - margin: 0 !important; - } - - .mt-md-0 { - margin-top: 0 !important; - } - - .mr-md-0 { - margin-right: 0 !important; - } - - .mb-md-0 { - margin-bottom: 0 !important; - } - - .ml-md-0 { - margin-left: 0 !important; - } - - .mx-md-0 { - margin-right: 0 !important; - margin-left: 0 !important; - } - - .my-md-0 { - margin-top: 0 !important; - margin-bottom: 0 !important; - } - - .m-md-1 { - margin: 4px !important; - } - - .mt-md-1 { - margin-top: 4px !important; - } - - .mr-md-1 { - margin-right: 4px !important; - } - - .mb-md-1 { - margin-bottom: 4px !important; - } - - .ml-md-1 { - margin-left: 4px !important; - } - - .mt-md-n1 { - margin-top: -4px !important; - } - - .mr-md-n1 { - margin-right: -4px !important; - } - - .mb-md-n1 { - margin-bottom: -4px !important; - } - - .ml-md-n1 { - margin-left: -4px !important; - } - - .mx-md-1 { - margin-right: 4px !important; - margin-left: 4px !important; - } - - .my-md-1 { - margin-top: 4px !important; - margin-bottom: 4px !important; - } - - .m-md-2 { - margin: 8px !important; - } - - .mt-md-2 { - margin-top: 8px !important; - } - - .mr-md-2 { - margin-right: 8px !important; - } - - .mb-md-2 { - margin-bottom: 8px !important; - } - - .ml-md-2 { - margin-left: 8px !important; - } - - .mt-md-n2 { - margin-top: -8px !important; - } - - .mr-md-n2 { - margin-right: -8px !important; - } - - .mb-md-n2 { - margin-bottom: -8px !important; - } - - .ml-md-n2 { - margin-left: -8px !important; - } - - .mx-md-2 { - margin-right: 8px !important; - margin-left: 8px !important; - } - - .my-md-2 { - margin-top: 8px !important; - margin-bottom: 8px !important; - } - - .m-md-3 { - margin: 16px !important; - } - - .mt-md-3 { - margin-top: 16px !important; - } - - .mr-md-3 { - margin-right: 16px !important; - } - - .mb-md-3 { - margin-bottom: 16px !important; - } - - .ml-md-3 { - margin-left: 16px !important; - } - - .mt-md-n3 { - margin-top: -16px !important; - } - - .mr-md-n3 { - margin-right: -16px !important; - } - - .mb-md-n3 { - margin-bottom: -16px !important; - } - - .ml-md-n3 { - margin-left: -16px !important; - } - - .mx-md-3 { - margin-right: 16px !important; - margin-left: 16px !important; - } - - .my-md-3 { - margin-top: 16px !important; - margin-bottom: 16px !important; - } - - .m-md-4 { - margin: 24px !important; - } - - .mt-md-4 { - margin-top: 24px !important; - } - - .mr-md-4 { - margin-right: 24px !important; - } - - .mb-md-4 { - margin-bottom: 24px !important; - } - - .ml-md-4 { - margin-left: 24px !important; - } - - .mt-md-n4 { - margin-top: -24px !important; - } - - .mr-md-n4 { - margin-right: -24px !important; - } - - .mb-md-n4 { - margin-bottom: -24px !important; - } - - .ml-md-n4 { - margin-left: -24px !important; - } - - .mx-md-4 { - margin-right: 24px !important; - margin-left: 24px !important; - } - - .my-md-4 { - margin-top: 24px !important; - margin-bottom: 24px !important; - } - - .m-md-5 { - margin: 32px !important; - } - - .mt-md-5 { - margin-top: 32px !important; - } - - .mr-md-5 { - margin-right: 32px !important; - } - - .mb-md-5 { - margin-bottom: 32px !important; - } - - .ml-md-5 { - margin-left: 32px !important; - } - - .mt-md-n5 { - margin-top: -32px !important; - } - - .mr-md-n5 { - margin-right: -32px !important; - } - - .mb-md-n5 { - margin-bottom: -32px !important; - } - - .ml-md-n5 { - margin-left: -32px !important; - } - - .mx-md-5 { - margin-right: 32px !important; - margin-left: 32px !important; - } - - .my-md-5 { - margin-top: 32px !important; - margin-bottom: 32px !important; - } - - .m-md-6 { - margin: 40px !important; - } - - .mt-md-6 { - margin-top: 40px !important; - } - - .mr-md-6 { - margin-right: 40px !important; - } - - .mb-md-6 { - margin-bottom: 40px !important; - } - - .ml-md-6 { - margin-left: 40px !important; - } - - .mt-md-n6 { - margin-top: -40px !important; - } - - .mr-md-n6 { - margin-right: -40px !important; - } - - .mb-md-n6 { - margin-bottom: -40px !important; - } - - .ml-md-n6 { - margin-left: -40px !important; - } - - .mx-md-6 { - margin-right: 40px !important; - margin-left: 40px !important; - } - - .my-md-6 { - margin-top: 40px !important; - margin-bottom: 40px !important; - } - - .mx-md-auto { - margin-right: auto !important; - margin-left: auto !important; - } -} - -@media (min-width: 1012px) { - .m-lg-0 { - margin: 0 !important; - } - - .mt-lg-0 { - margin-top: 0 !important; - } - - .mr-lg-0 { - margin-right: 0 !important; - } - - .mb-lg-0 { - margin-bottom: 0 !important; - } - - .ml-lg-0 { - margin-left: 0 !important; - } - - .mx-lg-0 { - margin-right: 0 !important; - margin-left: 0 !important; - } - - .my-lg-0 { - margin-top: 0 !important; - margin-bottom: 0 !important; - } - - .m-lg-1 { - margin: 4px !important; - } - - .mt-lg-1 { - margin-top: 4px !important; - } - - .mr-lg-1 { - margin-right: 4px !important; - } - - .mb-lg-1 { - margin-bottom: 4px !important; - } - - .ml-lg-1 { - margin-left: 4px !important; - } - - .mt-lg-n1 { - margin-top: -4px !important; - } - - .mr-lg-n1 { - margin-right: -4px !important; - } - - .mb-lg-n1 { - margin-bottom: -4px !important; - } - - .ml-lg-n1 { - margin-left: -4px !important; - } - - .mx-lg-1 { - margin-right: 4px !important; - margin-left: 4px !important; - } - - .my-lg-1 { - margin-top: 4px !important; - margin-bottom: 4px !important; - } - - .m-lg-2 { - margin: 8px !important; - } - - .mt-lg-2 { - margin-top: 8px !important; - } - - .mr-lg-2 { - margin-right: 8px !important; - } - - .mb-lg-2 { - margin-bottom: 8px !important; - } - - .ml-lg-2 { - margin-left: 8px !important; - } - - .mt-lg-n2 { - margin-top: -8px !important; - } - - .mr-lg-n2 { - margin-right: -8px !important; - } - - .mb-lg-n2 { - margin-bottom: -8px !important; - } - - .ml-lg-n2 { - margin-left: -8px !important; - } - - .mx-lg-2 { - margin-right: 8px !important; - margin-left: 8px !important; - } - - .my-lg-2 { - margin-top: 8px !important; - margin-bottom: 8px !important; - } - - .m-lg-3 { - margin: 16px !important; - } - - .mt-lg-3 { - margin-top: 16px !important; - } - - .mr-lg-3 { - margin-right: 16px !important; - } - - .mb-lg-3 { - margin-bottom: 16px !important; - } - - .ml-lg-3 { - margin-left: 16px !important; - } - - .mt-lg-n3 { - margin-top: -16px !important; - } - - .mr-lg-n3 { - margin-right: -16px !important; - } - - .mb-lg-n3 { - margin-bottom: -16px !important; - } - - .ml-lg-n3 { - margin-left: -16px !important; - } - - .mx-lg-3 { - margin-right: 16px !important; - margin-left: 16px !important; - } - - .my-lg-3 { - margin-top: 16px !important; - margin-bottom: 16px !important; - } - - .m-lg-4 { - margin: 24px !important; - } - - .mt-lg-4 { - margin-top: 24px !important; - } - - .mr-lg-4 { - margin-right: 24px !important; - } - - .mb-lg-4 { - margin-bottom: 24px !important; - } - - .ml-lg-4 { - margin-left: 24px !important; - } - - .mt-lg-n4 { - margin-top: -24px !important; - } - - .mr-lg-n4 { - margin-right: -24px !important; - } - - .mb-lg-n4 { - margin-bottom: -24px !important; - } - - .ml-lg-n4 { - margin-left: -24px !important; - } - - .mx-lg-4 { - margin-right: 24px !important; - margin-left: 24px !important; - } - - .my-lg-4 { - margin-top: 24px !important; - margin-bottom: 24px !important; - } - - .m-lg-5 { - margin: 32px !important; - } - - .mt-lg-5 { - margin-top: 32px !important; - } - - .mr-lg-5 { - margin-right: 32px !important; - } - - .mb-lg-5 { - margin-bottom: 32px !important; - } - - .ml-lg-5 { - margin-left: 32px !important; - } - - .mt-lg-n5 { - margin-top: -32px !important; - } - - .mr-lg-n5 { - margin-right: -32px !important; - } - - .mb-lg-n5 { - margin-bottom: -32px !important; - } - - .ml-lg-n5 { - margin-left: -32px !important; - } - - .mx-lg-5 { - margin-right: 32px !important; - margin-left: 32px !important; - } - - .my-lg-5 { - margin-top: 32px !important; - margin-bottom: 32px !important; - } - - .m-lg-6 { - margin: 40px !important; - } - - .mt-lg-6 { - margin-top: 40px !important; - } - - .mr-lg-6 { - margin-right: 40px !important; - } - - .mb-lg-6 { - margin-bottom: 40px !important; - } - - .ml-lg-6 { - margin-left: 40px !important; - } - - .mt-lg-n6 { - margin-top: -40px !important; - } - - .mr-lg-n6 { - margin-right: -40px !important; - } - - .mb-lg-n6 { - margin-bottom: -40px !important; - } - - .ml-lg-n6 { - margin-left: -40px !important; - } - - .mx-lg-6 { - margin-right: 40px !important; - margin-left: 40px !important; - } - - .my-lg-6 { - margin-top: 40px !important; - margin-bottom: 40px !important; - } - - .mx-lg-auto { - margin-right: auto !important; - margin-left: auto !important; - } -} - -@media (min-width: 1280px) { - .m-xl-0 { - margin: 0 !important; - } - - .mt-xl-0 { - margin-top: 0 !important; - } - - .mr-xl-0 { - margin-right: 0 !important; - } - - .mb-xl-0 { - margin-bottom: 0 !important; - } - - .ml-xl-0 { - margin-left: 0 !important; - } - - .mx-xl-0 { - margin-right: 0 !important; - margin-left: 0 !important; - } - - .my-xl-0 { - margin-top: 0 !important; - margin-bottom: 0 !important; - } - - .m-xl-1 { - margin: 4px !important; - } - - .mt-xl-1 { - margin-top: 4px !important; - } - - .mr-xl-1 { - margin-right: 4px !important; - } - - .mb-xl-1 { - margin-bottom: 4px !important; - } - - .ml-xl-1 { - margin-left: 4px !important; - } - - .mt-xl-n1 { - margin-top: -4px !important; - } - - .mr-xl-n1 { - margin-right: -4px !important; - } - - .mb-xl-n1 { - margin-bottom: -4px !important; - } - - .ml-xl-n1 { - margin-left: -4px !important; - } - - .mx-xl-1 { - margin-right: 4px !important; - margin-left: 4px !important; - } - - .my-xl-1 { - margin-top: 4px !important; - margin-bottom: 4px !important; - } - - .m-xl-2 { - margin: 8px !important; - } - - .mt-xl-2 { - margin-top: 8px !important; - } - - .mr-xl-2 { - margin-right: 8px !important; - } - - .mb-xl-2 { - margin-bottom: 8px !important; - } - - .ml-xl-2 { - margin-left: 8px !important; - } - - .mt-xl-n2 { - margin-top: -8px !important; - } - - .mr-xl-n2 { - margin-right: -8px !important; - } - - .mb-xl-n2 { - margin-bottom: -8px !important; - } - - .ml-xl-n2 { - margin-left: -8px !important; - } - - .mx-xl-2 { - margin-right: 8px !important; - margin-left: 8px !important; - } - - .my-xl-2 { - margin-top: 8px !important; - margin-bottom: 8px !important; - } - - .m-xl-3 { - margin: 16px !important; - } - - .mt-xl-3 { - margin-top: 16px !important; - } - - .mr-xl-3 { - margin-right: 16px !important; - } - - .mb-xl-3 { - margin-bottom: 16px !important; - } - - .ml-xl-3 { - margin-left: 16px !important; - } - - .mt-xl-n3 { - margin-top: -16px !important; - } - - .mr-xl-n3 { - margin-right: -16px !important; - } - - .mb-xl-n3 { - margin-bottom: -16px !important; - } - - .ml-xl-n3 { - margin-left: -16px !important; - } - - .mx-xl-3 { - margin-right: 16px !important; - margin-left: 16px !important; - } - - .my-xl-3 { - margin-top: 16px !important; - margin-bottom: 16px !important; - } - - .m-xl-4 { - margin: 24px !important; - } - - .mt-xl-4 { - margin-top: 24px !important; - } - - .mr-xl-4 { - margin-right: 24px !important; - } - - .mb-xl-4 { - margin-bottom: 24px !important; - } - - .ml-xl-4 { - margin-left: 24px !important; - } - - .mt-xl-n4 { - margin-top: -24px !important; - } - - .mr-xl-n4 { - margin-right: -24px !important; - } - - .mb-xl-n4 { - margin-bottom: -24px !important; - } - - .ml-xl-n4 { - margin-left: -24px !important; - } - - .mx-xl-4 { - margin-right: 24px !important; - margin-left: 24px !important; - } - - .my-xl-4 { - margin-top: 24px !important; - margin-bottom: 24px !important; - } - - .m-xl-5 { - margin: 32px !important; - } - - .mt-xl-5 { - margin-top: 32px !important; - } - - .mr-xl-5 { - margin-right: 32px !important; - } - - .mb-xl-5 { - margin-bottom: 32px !important; - } - - .ml-xl-5 { - margin-left: 32px !important; - } - - .mt-xl-n5 { - margin-top: -32px !important; - } - - .mr-xl-n5 { - margin-right: -32px !important; - } - - .mb-xl-n5 { - margin-bottom: -32px !important; - } - - .ml-xl-n5 { - margin-left: -32px !important; - } - - .mx-xl-5 { - margin-right: 32px !important; - margin-left: 32px !important; - } - - .my-xl-5 { - margin-top: 32px !important; - margin-bottom: 32px !important; - } - - .m-xl-6 { - margin: 40px !important; - } - - .mt-xl-6 { - margin-top: 40px !important; - } - - .mr-xl-6 { - margin-right: 40px !important; - } - - .mb-xl-6 { - margin-bottom: 40px !important; - } - - .ml-xl-6 { - margin-left: 40px !important; - } - - .mt-xl-n6 { - margin-top: -40px !important; - } - - .mr-xl-n6 { - margin-right: -40px !important; - } - - .mb-xl-n6 { - margin-bottom: -40px !important; - } - - .ml-xl-n6 { - margin-left: -40px !important; - } - - .mx-xl-6 { - margin-right: 40px !important; - margin-left: 40px !important; - } - - .my-xl-6 { - margin-top: 40px !important; - margin-bottom: 40px !important; - } - - .mx-xl-auto { - margin-right: auto !important; - margin-left: auto !important; - } -} - -.p-0 { - padding: 0 !important; -} - -.pt-0 { - padding-top: 0 !important; -} - -.pr-0 { - padding-right: 0 !important; -} - -.pb-0 { - padding-bottom: 0 !important; -} - -.pl-0 { - padding-left: 0 !important; -} - -.px-0 { - padding-right: 0 !important; - padding-left: 0 !important; -} - -.py-0 { - padding-top: 0 !important; - padding-bottom: 0 !important; -} - -.p-1 { - padding: 4px !important; -} - -.pt-1 { - padding-top: 4px !important; -} - -.pr-1 { - padding-right: 4px !important; -} - -.pb-1 { - padding-bottom: 4px !important; -} - -.pl-1 { - padding-left: 4px !important; -} - -.px-1 { - padding-right: 4px !important; - padding-left: 4px !important; -} - -.py-1 { - padding-top: 4px !important; - padding-bottom: 4px !important; -} - -.p-2 { - padding: 8px !important; -} - -.pt-2 { - padding-top: 8px !important; -} - -.pr-2 { - padding-right: 8px !important; -} - -.pb-2 { - padding-bottom: 8px !important; -} - -.pl-2 { - padding-left: 8px !important; -} - -.px-2 { - padding-right: 8px !important; - padding-left: 8px !important; -} - -.py-2 { - padding-top: 8px !important; - padding-bottom: 8px !important; -} - -.p-3 { - padding: 16px !important; -} - -.pt-3 { - padding-top: 16px !important; -} - -.pr-3 { - padding-right: 16px !important; -} - -.pb-3 { - padding-bottom: 16px !important; -} - -.pl-3 { - padding-left: 16px !important; -} - -.px-3 { - padding-right: 16px !important; - padding-left: 16px !important; -} - -.py-3 { - padding-top: 16px !important; - padding-bottom: 16px !important; -} - -.p-4 { - padding: 24px !important; -} - -.pt-4 { - padding-top: 24px !important; -} - -.pr-4 { - padding-right: 24px !important; -} - -.pb-4 { - padding-bottom: 24px !important; -} - -.pl-4 { - padding-left: 24px !important; -} - -.px-4 { - padding-right: 24px !important; - padding-left: 24px !important; -} - -.py-4 { - padding-top: 24px !important; - padding-bottom: 24px !important; -} - -.p-5 { - padding: 32px !important; -} - -.pt-5 { - padding-top: 32px !important; -} - -.pr-5 { - padding-right: 32px !important; -} - -.pb-5 { - padding-bottom: 32px !important; -} - -.pl-5 { - padding-left: 32px !important; -} - -.px-5 { - padding-right: 32px !important; - padding-left: 32px !important; -} - -.py-5 { - padding-top: 32px !important; - padding-bottom: 32px !important; -} - -.p-6 { - padding: 40px !important; -} - -.pt-6 { - padding-top: 40px !important; -} - -.pr-6 { - padding-right: 40px !important; -} - -.pb-6 { - padding-bottom: 40px !important; -} - -.pl-6 { - padding-left: 40px !important; -} - -.px-6 { - padding-right: 40px !important; - padding-left: 40px !important; -} - -.py-6 { - padding-top: 40px !important; - padding-bottom: 40px !important; -} - -@media (min-width: 544px) { - .p-sm-0 { - padding: 0 !important; - } - - .pt-sm-0 { - padding-top: 0 !important; - } - - .pr-sm-0 { - padding-right: 0 !important; - } - - .pb-sm-0 { - padding-bottom: 0 !important; - } - - .pl-sm-0 { - padding-left: 0 !important; - } - - .px-sm-0 { - padding-right: 0 !important; - padding-left: 0 !important; - } - - .py-sm-0 { - padding-top: 0 !important; - padding-bottom: 0 !important; - } - - .p-sm-1 { - padding: 4px !important; - } - - .pt-sm-1 { - padding-top: 4px !important; - } - - .pr-sm-1 { - padding-right: 4px !important; - } - - .pb-sm-1 { - padding-bottom: 4px !important; - } - - .pl-sm-1 { - padding-left: 4px !important; - } - - .px-sm-1 { - padding-right: 4px !important; - padding-left: 4px !important; - } - - .py-sm-1 { - padding-top: 4px !important; - padding-bottom: 4px !important; - } - - .p-sm-2 { - padding: 8px !important; - } - - .pt-sm-2 { - padding-top: 8px !important; - } - - .pr-sm-2 { - padding-right: 8px !important; - } - - .pb-sm-2 { - padding-bottom: 8px !important; - } - - .pl-sm-2 { - padding-left: 8px !important; - } - - .px-sm-2 { - padding-right: 8px !important; - padding-left: 8px !important; - } - - .py-sm-2 { - padding-top: 8px !important; - padding-bottom: 8px !important; - } - - .p-sm-3 { - padding: 16px !important; - } - - .pt-sm-3 { - padding-top: 16px !important; - } - - .pr-sm-3 { - padding-right: 16px !important; - } - - .pb-sm-3 { - padding-bottom: 16px !important; - } - - .pl-sm-3 { - padding-left: 16px !important; - } - - .px-sm-3 { - padding-right: 16px !important; - padding-left: 16px !important; - } - - .py-sm-3 { - padding-top: 16px !important; - padding-bottom: 16px !important; - } - - .p-sm-4 { - padding: 24px !important; - } - - .pt-sm-4 { - padding-top: 24px !important; - } - - .pr-sm-4 { - padding-right: 24px !important; - } - - .pb-sm-4 { - padding-bottom: 24px !important; - } - - .pl-sm-4 { - padding-left: 24px !important; - } - - .px-sm-4 { - padding-right: 24px !important; - padding-left: 24px !important; - } - - .py-sm-4 { - padding-top: 24px !important; - padding-bottom: 24px !important; - } - - .p-sm-5 { - padding: 32px !important; - } - - .pt-sm-5 { - padding-top: 32px !important; - } - - .pr-sm-5 { - padding-right: 32px !important; - } - - .pb-sm-5 { - padding-bottom: 32px !important; - } - - .pl-sm-5 { - padding-left: 32px !important; - } - - .px-sm-5 { - padding-right: 32px !important; - padding-left: 32px !important; - } - - .py-sm-5 { - padding-top: 32px !important; - padding-bottom: 32px !important; - } - - .p-sm-6 { - padding: 40px !important; - } - - .pt-sm-6 { - padding-top: 40px !important; - } - - .pr-sm-6 { - padding-right: 40px !important; - } - - .pb-sm-6 { - padding-bottom: 40px !important; - } - - .pl-sm-6 { - padding-left: 40px !important; - } - - .px-sm-6 { - padding-right: 40px !important; - padding-left: 40px !important; - } - - .py-sm-6 { - padding-top: 40px !important; - padding-bottom: 40px !important; - } -} - -@media (min-width: 768px) { - .p-md-0 { - padding: 0 !important; - } - - .pt-md-0 { - padding-top: 0 !important; - } - - .pr-md-0 { - padding-right: 0 !important; - } - - .pb-md-0 { - padding-bottom: 0 !important; - } - - .pl-md-0 { - padding-left: 0 !important; - } - - .px-md-0 { - padding-right: 0 !important; - padding-left: 0 !important; - } - - .py-md-0 { - padding-top: 0 !important; - padding-bottom: 0 !important; - } - - .p-md-1 { - padding: 4px !important; - } - - .pt-md-1 { - padding-top: 4px !important; - } - - .pr-md-1 { - padding-right: 4px !important; - } - - .pb-md-1 { - padding-bottom: 4px !important; - } - - .pl-md-1 { - padding-left: 4px !important; - } - - .px-md-1 { - padding-right: 4px !important; - padding-left: 4px !important; - } - - .py-md-1 { - padding-top: 4px !important; - padding-bottom: 4px !important; - } - - .p-md-2 { - padding: 8px !important; - } - - .pt-md-2 { - padding-top: 8px !important; - } - - .pr-md-2 { - padding-right: 8px !important; - } - - .pb-md-2 { - padding-bottom: 8px !important; - } - - .pl-md-2 { - padding-left: 8px !important; - } - - .px-md-2 { - padding-right: 8px !important; - padding-left: 8px !important; - } - - .py-md-2 { - padding-top: 8px !important; - padding-bottom: 8px !important; - } - - .p-md-3 { - padding: 16px !important; - } - - .pt-md-3 { - padding-top: 16px !important; - } - - .pr-md-3 { - padding-right: 16px !important; - } - - .pb-md-3 { - padding-bottom: 16px !important; - } - - .pl-md-3 { - padding-left: 16px !important; - } - - .px-md-3 { - padding-right: 16px !important; - padding-left: 16px !important; - } - - .py-md-3 { - padding-top: 16px !important; - padding-bottom: 16px !important; - } - - .p-md-4 { - padding: 24px !important; - } - - .pt-md-4 { - padding-top: 24px !important; - } - - .pr-md-4 { - padding-right: 24px !important; - } - - .pb-md-4 { - padding-bottom: 24px !important; - } - - .pl-md-4 { - padding-left: 24px !important; - } - - .px-md-4 { - padding-right: 24px !important; - padding-left: 24px !important; - } - - .py-md-4 { - padding-top: 24px !important; - padding-bottom: 24px !important; - } - - .p-md-5 { - padding: 32px !important; - } - - .pt-md-5 { - padding-top: 32px !important; - } - - .pr-md-5 { - padding-right: 32px !important; - } - - .pb-md-5 { - padding-bottom: 32px !important; - } - - .pl-md-5 { - padding-left: 32px !important; - } - - .px-md-5 { - padding-right: 32px !important; - padding-left: 32px !important; - } - - .py-md-5 { - padding-top: 32px !important; - padding-bottom: 32px !important; - } - - .p-md-6 { - padding: 40px !important; - } - - .pt-md-6 { - padding-top: 40px !important; - } - - .pr-md-6 { - padding-right: 40px !important; - } - - .pb-md-6 { - padding-bottom: 40px !important; - } - - .pl-md-6 { - padding-left: 40px !important; - } - - .px-md-6 { - padding-right: 40px !important; - padding-left: 40px !important; - } - - .py-md-6 { - padding-top: 40px !important; - padding-bottom: 40px !important; - } -} - -@media (min-width: 1012px) { - .p-lg-0 { - padding: 0 !important; - } - - .pt-lg-0 { - padding-top: 0 !important; - } - - .pr-lg-0 { - padding-right: 0 !important; - } - - .pb-lg-0 { - padding-bottom: 0 !important; - } - - .pl-lg-0 { - padding-left: 0 !important; - } - - .px-lg-0 { - padding-right: 0 !important; - padding-left: 0 !important; - } - - .py-lg-0 { - padding-top: 0 !important; - padding-bottom: 0 !important; - } - - .p-lg-1 { - padding: 4px !important; - } - - .pt-lg-1 { - padding-top: 4px !important; - } - - .pr-lg-1 { - padding-right: 4px !important; - } - - .pb-lg-1 { - padding-bottom: 4px !important; - } - - .pl-lg-1 { - padding-left: 4px !important; - } - - .px-lg-1 { - padding-right: 4px !important; - padding-left: 4px !important; - } - - .py-lg-1 { - padding-top: 4px !important; - padding-bottom: 4px !important; - } - - .p-lg-2 { - padding: 8px !important; - } - - .pt-lg-2 { - padding-top: 8px !important; - } - - .pr-lg-2 { - padding-right: 8px !important; - } - - .pb-lg-2 { - padding-bottom: 8px !important; - } - - .pl-lg-2 { - padding-left: 8px !important; - } - - .px-lg-2 { - padding-right: 8px !important; - padding-left: 8px !important; - } - - .py-lg-2 { - padding-top: 8px !important; - padding-bottom: 8px !important; - } - - .p-lg-3 { - padding: 16px !important; - } - - .pt-lg-3 { - padding-top: 16px !important; - } - - .pr-lg-3 { - padding-right: 16px !important; - } - - .pb-lg-3 { - padding-bottom: 16px !important; - } - - .pl-lg-3 { - padding-left: 16px !important; - } - - .px-lg-3 { - padding-right: 16px !important; - padding-left: 16px !important; - } - - .py-lg-3 { - padding-top: 16px !important; - padding-bottom: 16px !important; - } - - .p-lg-4 { - padding: 24px !important; - } - - .pt-lg-4 { - padding-top: 24px !important; - } - - .pr-lg-4 { - padding-right: 24px !important; - } - - .pb-lg-4 { - padding-bottom: 24px !important; - } - - .pl-lg-4 { - padding-left: 24px !important; - } - - .px-lg-4 { - padding-right: 24px !important; - padding-left: 24px !important; - } - - .py-lg-4 { - padding-top: 24px !important; - padding-bottom: 24px !important; - } - - .p-lg-5 { - padding: 32px !important; - } - - .pt-lg-5 { - padding-top: 32px !important; - } - - .pr-lg-5 { - padding-right: 32px !important; - } - - .pb-lg-5 { - padding-bottom: 32px !important; - } - - .pl-lg-5 { - padding-left: 32px !important; - } - - .px-lg-5 { - padding-right: 32px !important; - padding-left: 32px !important; - } - - .py-lg-5 { - padding-top: 32px !important; - padding-bottom: 32px !important; - } - - .p-lg-6 { - padding: 40px !important; - } - - .pt-lg-6 { - padding-top: 40px !important; - } - - .pr-lg-6 { - padding-right: 40px !important; - } - - .pb-lg-6 { - padding-bottom: 40px !important; - } - - .pl-lg-6 { - padding-left: 40px !important; - } - - .px-lg-6 { - padding-right: 40px !important; - padding-left: 40px !important; - } - - .py-lg-6 { - padding-top: 40px !important; - padding-bottom: 40px !important; - } -} - -@media (min-width: 1280px) { - .p-xl-0 { - padding: 0 !important; - } - - .pt-xl-0 { - padding-top: 0 !important; - } - - .pr-xl-0 { - padding-right: 0 !important; - } - - .pb-xl-0 { - padding-bottom: 0 !important; - } - - .pl-xl-0 { - padding-left: 0 !important; - } - - .px-xl-0 { - padding-right: 0 !important; - padding-left: 0 !important; - } - - .py-xl-0 { - padding-top: 0 !important; - padding-bottom: 0 !important; - } - - .p-xl-1 { - padding: 4px !important; - } - - .pt-xl-1 { - padding-top: 4px !important; - } - - .pr-xl-1 { - padding-right: 4px !important; - } - - .pb-xl-1 { - padding-bottom: 4px !important; - } - - .pl-xl-1 { - padding-left: 4px !important; - } - - .px-xl-1 { - padding-right: 4px !important; - padding-left: 4px !important; - } - - .py-xl-1 { - padding-top: 4px !important; - padding-bottom: 4px !important; - } - - .p-xl-2 { - padding: 8px !important; - } - - .pt-xl-2 { - padding-top: 8px !important; - } - - .pr-xl-2 { - padding-right: 8px !important; - } - - .pb-xl-2 { - padding-bottom: 8px !important; - } - - .pl-xl-2 { - padding-left: 8px !important; - } - - .px-xl-2 { - padding-right: 8px !important; - padding-left: 8px !important; - } - - .py-xl-2 { - padding-top: 8px !important; - padding-bottom: 8px !important; - } - - .p-xl-3 { - padding: 16px !important; - } - - .pt-xl-3 { - padding-top: 16px !important; - } - - .pr-xl-3 { - padding-right: 16px !important; - } - - .pb-xl-3 { - padding-bottom: 16px !important; - } - - .pl-xl-3 { - padding-left: 16px !important; - } - - .px-xl-3 { - padding-right: 16px !important; - padding-left: 16px !important; - } - - .py-xl-3 { - padding-top: 16px !important; - padding-bottom: 16px !important; - } - - .p-xl-4 { - padding: 24px !important; - } - - .pt-xl-4 { - padding-top: 24px !important; - } - - .pr-xl-4 { - padding-right: 24px !important; - } - - .pb-xl-4 { - padding-bottom: 24px !important; - } - - .pl-xl-4 { - padding-left: 24px !important; - } - - .px-xl-4 { - padding-right: 24px !important; - padding-left: 24px !important; - } - - .py-xl-4 { - padding-top: 24px !important; - padding-bottom: 24px !important; - } - - .p-xl-5 { - padding: 32px !important; - } - - .pt-xl-5 { - padding-top: 32px !important; - } - - .pr-xl-5 { - padding-right: 32px !important; - } - - .pb-xl-5 { - padding-bottom: 32px !important; - } - - .pl-xl-5 { - padding-left: 32px !important; - } - - .px-xl-5 { - padding-right: 32px !important; - padding-left: 32px !important; - } - - .py-xl-5 { - padding-top: 32px !important; - padding-bottom: 32px !important; - } - - .p-xl-6 { - padding: 40px !important; - } - - .pt-xl-6 { - padding-top: 40px !important; - } - - .pr-xl-6 { - padding-right: 40px !important; - } - - .pb-xl-6 { - padding-bottom: 40px !important; - } - - .pl-xl-6 { - padding-left: 40px !important; - } - - .px-xl-6 { - padding-right: 40px !important; - padding-left: 40px !important; - } - - .py-xl-6 { - padding-top: 40px !important; - padding-bottom: 40px !important; - } -} - -.p-responsive { - padding-right: 16px !important; - padding-left: 16px !important; -} - -@media (min-width: 544px) { - .p-responsive { - padding-right: 40px !important; - padding-left: 40px !important; - } -} - -@media (min-width: 1012px) { - .p-responsive { - padding-right: 16px !important; - padding-left: 16px !important; - } -} - -.h1 { - font-size: 26px !important; -} - -@media (min-width: 768px) { - .h1 { - font-size: 32px !important; - } -} - -.h2 { - font-size: 22px !important; -} - -@media (min-width: 768px) { - .h2 { - font-size: 24px !important; - } -} - -.h3 { - font-size: 18px !important; -} - -@media (min-width: 768px) { - .h3 { - font-size: 20px !important; - } -} - -.h4 { - font-size: 16px !important; -} - -.h5 { - font-size: 14px !important; -} - -.h6 { - font-size: 12px !important; -} - -.h1, -.h2, -.h3, -.h4, -.h5, -.h6 { - font-weight: 600 !important; -} - -.f1 { - font-size: 26px !important; -} - -@media (min-width: 768px) { - .f1 { - font-size: 32px !important; - } -} - -.f2 { - font-size: 22px !important; -} - -@media (min-width: 768px) { - .f2 { - font-size: 24px !important; - } -} - -.f3 { - font-size: 18px !important; -} - -@media (min-width: 768px) { - .f3 { - font-size: 20px !important; - } -} - -.f4 { - font-size: 16px !important; -} - -@media (min-width: 768px) { - .f4 { - font-size: 16px !important; - } -} - -.f5 { - font-size: 14px !important; -} - -.f6 { - font-size: 12px !important; -} - -.f00-light { - font-size: 40px !important; - font-weight: 300 !important; -} - -@media (min-width: 768px) { - .f00-light { - font-size: 48px !important; - } -} - -.f0-light { - font-size: 32px !important; - font-weight: 300 !important; -} - -@media (min-width: 768px) { - .f0-light { - font-size: 40px !important; - } -} - -.f1-light { - font-size: 26px !important; - font-weight: 300 !important; -} - -@media (min-width: 768px) { - .f1-light { - font-size: 32px !important; - } -} - -.f2-light { - font-size: 22px !important; - font-weight: 300 !important; -} - -@media (min-width: 768px) { - .f2-light { - font-size: 24px !important; - } -} - -.f3-light { - font-size: 18px !important; - font-weight: 300 !important; -} - -@media (min-width: 768px) { - .f3-light { - font-size: 20px !important; - } -} - -.text-small { - font-size: 12px !important; -} - -.lead { - margin-bottom: 30px; - font-size: 20px; - font-weight: 300; - color: #586069; -} - -.lh-condensed-ultra { - line-height: 1 !important; -} - -.lh-condensed { - line-height: 1.25 !important; -} - -.lh-default { - line-height: 1.5 !important; -} - -.lh-0 { - line-height: 0 !important; -} - -.text-right { - text-align: right !important; -} - -.text-left { - text-align: left !important; -} - -.text-center { - text-align: center !important; -} - -@media (min-width: 544px) { - .text-sm-right { - text-align: right !important; - } - - .text-sm-left { - text-align: left !important; - } - - .text-sm-center { - text-align: center !important; - } -} - -@media (min-width: 768px) { - .text-md-right { - text-align: right !important; - } - - .text-md-left { - text-align: left !important; - } - - .text-md-center { - text-align: center !important; - } -} - -@media (min-width: 1012px) { - .text-lg-right { - text-align: right !important; - } - - .text-lg-left { - text-align: left !important; - } - - .text-lg-center { - text-align: center !important; - } -} - -@media (min-width: 1280px) { - .text-xl-right { - text-align: right !important; - } - - .text-xl-left { - text-align: left !important; - } - - .text-xl-center { - text-align: center !important; - } -} - -.text-normal { - font-weight: 400 !important; -} - -.text-bold { - font-weight: 600 !important; -} - -.text-italic { - font-style: italic !important; -} - -.text-uppercase { - text-transform: uppercase !important; -} - -.text-underline { - text-decoration: underline !important; -} - -.no-underline { - text-decoration: none !important; -} - -.no-wrap { - white-space: nowrap !important; -} - -.ws-normal { - white-space: normal !important; -} - -.wb-break-all { - word-break: break-all !important; -} - -.text-emphasized { - font-weight: 600; - color: #24292e; -} - -.list-style-none { - list-style: none !important; -} - -.text-shadow-dark { - text-shadow: - 0 1px 1px rgba(27, 31, 35, 0.25), - 0 1px 25px rgba(27, 31, 35, 0.75); -} - -.text-shadow-light { - text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5); -} - -.text-mono { - font-family: - "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace; -} - -.user-select-none { - user-select: none !important; -} - -.d-block { - display: block !important; -} - -.d-flex { - display: flex !important; -} - -.d-inline { - display: inline !important; -} - -.d-inline-block { - display: inline-block !important; -} - -.d-inline-flex { - display: inline-flex !important; -} - -.d-none { - display: none !important; -} - -.d-table { - display: table !important; -} - -.d-table-cell { - display: table-cell !important; -} - -@media (min-width: 544px) { - .d-sm-block { - display: block !important; - } - - .d-sm-flex { - display: flex !important; - } - - .d-sm-inline { - display: inline !important; - } - - .d-sm-inline-block { - display: inline-block !important; - } - - .d-sm-inline-flex { - display: inline-flex !important; - } - - .d-sm-none { - display: none !important; - } - - .d-sm-table { - display: table !important; - } - - .d-sm-table-cell { - display: table-cell !important; - } -} - -@media (min-width: 768px) { - .d-md-block { - display: block !important; - } - - .d-md-flex { - display: flex !important; - } - - .d-md-inline { - display: inline !important; - } - - .d-md-inline-block { - display: inline-block !important; - } - - .d-md-inline-flex { - display: inline-flex !important; - } - - .d-md-none { - display: none !important; - } - - .d-md-table { - display: table !important; - } - - .d-md-table-cell { - display: table-cell !important; - } -} - -@media (min-width: 1012px) { - .d-lg-block { - display: block !important; - } - - .d-lg-flex { - display: flex !important; - } - - .d-lg-inline { - display: inline !important; - } - - .d-lg-inline-block { - display: inline-block !important; - } - - .d-lg-inline-flex { - display: inline-flex !important; - } - - .d-lg-none { - display: none !important; - } - - .d-lg-table { - display: table !important; - } - - .d-lg-table-cell { - display: table-cell !important; - } -} - -@media (min-width: 1280px) { - .d-xl-block { - display: block !important; - } - - .d-xl-flex { - display: flex !important; - } - - .d-xl-inline { - display: inline !important; - } - - .d-xl-inline-block { - display: inline-block !important; - } - - .d-xl-inline-flex { - display: inline-flex !important; - } - - .d-xl-none { - display: none !important; - } - - .d-xl-table { - display: table !important; - } - - .d-xl-table-cell { - display: table-cell !important; - } -} - -.v-hidden { - visibility: hidden !important; -} - -.v-visible { - visibility: visible !important; -} - -@media (max-width: 544px) { - .hide-sm { - display: none !important; - } -} - -@media (min-width: 544px) and (max-width: 768px) { - .hide-md { - display: none !important; - } -} - -@media (min-width: 768px) and (max-width: 1012px) { - .hide-lg { - display: none !important; - } -} - -@media (min-width: 1012px) { - .hide-xl { - display: none !important; - } -} - -.table-fixed { - table-layout: fixed !important; -} - -.sr-only { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - overflow: hidden; - clip: rect(0, 0, 0, 0); - word-wrap: normal; - border: 0; -} - -.show-on-focus { - position: absolute; - width: 1px; - height: 1px; - margin: 0; - overflow: hidden; - clip: rect(1px, 1px, 1px, 1px); -} - -.show-on-focus:focus { - z-index: 20; - width: auto; - height: auto; - clip: auto; -} - -.container { - width: 980px; - margin-right: auto; - margin-left: auto; -} - -.container::before { - display: table; - content: ""; -} - -.container::after { - display: table; - clear: both; - content: ""; -} - -.container-md { - max-width: 768px; - margin-right: auto; - margin-left: auto; -} - -.container-lg { - max-width: 1012px; - margin-right: auto; - margin-left: auto; -} - -.container-xl { - max-width: 1280px; - margin-right: auto; - margin-left: auto; -} - -.columns { - margin-right: -10px; - margin-left: -10px; -} - -.columns::before { - display: table; - content: ""; -} - -.columns::after { - display: table; - clear: both; - content: ""; -} - -.column { - float: left; - padding-right: 10px; - padding-left: 10px; -} - -.one-third { - width: 33.333333%; -} - -.two-thirds { - width: 66.666667%; -} - -.one-fourth { - width: 25%; -} - -.one-half { - width: 50%; -} - -.three-fourths { - width: 75%; -} - -.one-fifth { - width: 20%; -} - -.four-fifths { - width: 80%; -} - -.centered { - display: block; - float: none; - margin-right: auto; - margin-left: auto; -} - -.col-1 { - width: 8.3333333333%; -} - -.col-2 { - width: 16.6666666667%; -} - -.col-3 { - width: 25%; -} - -.col-4 { - width: 33.3333333333%; -} - -.col-5 { - width: 41.6666666667%; -} - -.col-6 { - width: 50%; -} - -.col-7 { - width: 58.3333333333%; -} - -.col-8 { - width: 66.6666666667%; -} - -.col-9 { - width: 75%; -} - -.col-10 { - width: 83.3333333333%; -} - -.col-11 { - width: 91.6666666667%; -} - -.col-12 { - width: 100%; -} - -@media (min-width: 544px) { - .col-sm-1 { - width: 8.3333333333%; - } - - .col-sm-2 { - width: 16.6666666667%; - } - - .col-sm-3 { - width: 25%; - } - - .col-sm-4 { - width: 33.3333333333%; - } - - .col-sm-5 { - width: 41.6666666667%; - } - - .col-sm-6 { - width: 50%; - } - - .col-sm-7 { - width: 58.3333333333%; - } - - .col-sm-8 { - width: 66.6666666667%; - } - - .col-sm-9 { - width: 75%; - } - - .col-sm-10 { - width: 83.3333333333%; - } - - .col-sm-11 { - width: 91.6666666667%; - } - - .col-sm-12 { - width: 100%; - } -} - -@media (min-width: 768px) { - .col-md-1 { - width: 8.3333333333%; - } - - .col-md-2 { - width: 16.6666666667%; - } - - .col-md-3 { - width: 25%; - } - - .col-md-4 { - width: 33.3333333333%; - } - - .col-md-5 { - width: 41.6666666667%; - } - - .col-md-6 { - width: 50%; - } - - .col-md-7 { - width: 58.3333333333%; - } - - .col-md-8 { - width: 66.6666666667%; - } - - .col-md-9 { - width: 75%; - } - - .col-md-10 { - width: 83.3333333333%; - } - - .col-md-11 { - width: 91.6666666667%; - } - - .col-md-12 { - width: 100%; - } -} - -@media (min-width: 1012px) { - .col-lg-1 { - width: 8.3333333333%; - } - - .col-lg-2 { - width: 16.6666666667%; - } - - .col-lg-3 { - width: 25%; - } - - .col-lg-4 { - width: 33.3333333333%; - } - - .col-lg-5 { - width: 41.6666666667%; - } - - .col-lg-6 { - width: 50%; - } - - .col-lg-7 { - width: 58.3333333333%; - } - - .col-lg-8 { - width: 66.6666666667%; - } - - .col-lg-9 { - width: 75%; - } - - .col-lg-10 { - width: 83.3333333333%; - } - - .col-lg-11 { - width: 91.6666666667%; - } - - .col-lg-12 { - width: 100%; - } -} - -@media (min-width: 1280px) { - .col-xl-1 { - width: 8.3333333333%; - } - - .col-xl-2 { - width: 16.6666666667%; - } - - .col-xl-3 { - width: 25%; - } - - .col-xl-4 { - width: 33.3333333333%; - } - - .col-xl-5 { - width: 41.6666666667%; - } - - .col-xl-6 { - width: 50%; - } - - .col-xl-7 { - width: 58.3333333333%; - } - - .col-xl-8 { - width: 66.6666666667%; - } - - .col-xl-9 { - width: 75%; - } - - .col-xl-10 { - width: 83.3333333333%; - } - - .col-xl-11 { - width: 91.6666666667%; - } - - .col-xl-12 { - width: 100%; - } -} - -.gutter { - margin-right: -16px; - margin-left: -16px; -} - -.gutter > [class*="col-"] { - padding-right: 16px !important; - padding-left: 16px !important; -} - -.gutter-condensed { - margin-right: -8px; - margin-left: -8px; -} - -.gutter-condensed > [class*="col-"] { - padding-right: 8px !important; - padding-left: 8px !important; -} - -.gutter-spacious { - margin-right: -24px; - margin-left: -24px; -} - -.gutter-spacious > [class*="col-"] { - padding-right: 24px !important; - padding-left: 24px !important; -} - -@media (min-width: 544px) { - .gutter-sm { - margin-right: -16px; - margin-left: -16px; - } - - .gutter-sm > [class*="col-"] { - padding-right: 16px !important; - padding-left: 16px !important; - } - - .gutter-sm-condensed { - margin-right: -8px; - margin-left: -8px; - } - - .gutter-sm-condensed > [class*="col-"] { - padding-right: 8px !important; - padding-left: 8px !important; - } - - .gutter-sm-spacious { - margin-right: -24px; - margin-left: -24px; - } - - .gutter-sm-spacious > [class*="col-"] { - padding-right: 24px !important; - padding-left: 24px !important; - } -} - -@media (min-width: 768px) { - .gutter-md { - margin-right: -16px; - margin-left: -16px; - } - - .gutter-md > [class*="col-"] { - padding-right: 16px !important; - padding-left: 16px !important; - } - - .gutter-md-condensed { - margin-right: -8px; - margin-left: -8px; - } - - .gutter-md-condensed > [class*="col-"] { - padding-right: 8px !important; - padding-left: 8px !important; - } - - .gutter-md-spacious { - margin-right: -24px; - margin-left: -24px; - } - - .gutter-md-spacious > [class*="col-"] { - padding-right: 24px !important; - padding-left: 24px !important; - } -} - -@media (min-width: 1012px) { - .gutter-lg { - margin-right: -16px; - margin-left: -16px; - } - - .gutter-lg > [class*="col-"] { - padding-right: 16px !important; - padding-left: 16px !important; - } - - .gutter-lg-condensed { - margin-right: -8px; - margin-left: -8px; - } - - .gutter-lg-condensed > [class*="col-"] { - padding-right: 8px !important; - padding-left: 8px !important; - } - - .gutter-lg-spacious { - margin-right: -24px; - margin-left: -24px; - } - - .gutter-lg-spacious > [class*="col-"] { - padding-right: 24px !important; - padding-left: 24px !important; - } -} - -@media (min-width: 1280px) { - .gutter-xl { - margin-right: -16px; - margin-left: -16px; - } - - .gutter-xl > [class*="col-"] { - padding-right: 16px !important; - padding-left: 16px !important; - } - - .gutter-xl-condensed { - margin-right: -8px; - margin-left: -8px; - } - - .gutter-xl-condensed > [class*="col-"] { - padding-right: 8px !important; - padding-left: 8px !important; - } - - .gutter-xl-spacious { - margin-right: -24px; - margin-left: -24px; - } - - .gutter-xl-spacious > [class*="col-"] { - padding-right: 24px !important; - padding-left: 24px !important; - } -} - -.offset-1 { - margin-left: 8.3333333333% !important; -} - -.offset-2 { - margin-left: 16.6666666667% !important; -} - -.offset-3 { - margin-left: 25% !important; -} - -.offset-4 { - margin-left: 33.3333333333% !important; -} - -.offset-5 { - margin-left: 41.6666666667% !important; -} - -.offset-6 { - margin-left: 50% !important; -} - -.offset-7 { - margin-left: 58.3333333333% !important; -} - -.offset-8 { - margin-left: 66.6666666667% !important; -} - -.offset-9 { - margin-left: 75% !important; -} - -.offset-10 { - margin-left: 83.3333333333% !important; -} - -.offset-11 { - margin-left: 91.6666666667% !important; -} - -@media (min-width: 544px) { - .offset-sm-1 { - margin-left: 8.3333333333% !important; - } - - .offset-sm-2 { - margin-left: 16.6666666667% !important; - } - - .offset-sm-3 { - margin-left: 25% !important; - } - - .offset-sm-4 { - margin-left: 33.3333333333% !important; - } - - .offset-sm-5 { - margin-left: 41.6666666667% !important; - } - - .offset-sm-6 { - margin-left: 50% !important; - } - - .offset-sm-7 { - margin-left: 58.3333333333% !important; - } - - .offset-sm-8 { - margin-left: 66.6666666667% !important; - } - - .offset-sm-9 { - margin-left: 75% !important; - } - - .offset-sm-10 { - margin-left: 83.3333333333% !important; - } - - .offset-sm-11 { - margin-left: 91.6666666667% !important; - } -} - -@media (min-width: 768px) { - .offset-md-1 { - margin-left: 8.3333333333% !important; - } - - .offset-md-2 { - margin-left: 16.6666666667% !important; - } - - .offset-md-3 { - margin-left: 25% !important; - } - - .offset-md-4 { - margin-left: 33.3333333333% !important; - } - - .offset-md-5 { - margin-left: 41.6666666667% !important; - } - - .offset-md-6 { - margin-left: 50% !important; - } - - .offset-md-7 { - margin-left: 58.3333333333% !important; - } - - .offset-md-8 { - margin-left: 66.6666666667% !important; - } - - .offset-md-9 { - margin-left: 75% !important; - } - - .offset-md-10 { - margin-left: 83.3333333333% !important; - } - - .offset-md-11 { - margin-left: 91.6666666667% !important; - } -} - -@media (min-width: 1012px) { - .offset-lg-1 { - margin-left: 8.3333333333% !important; - } - - .offset-lg-2 { - margin-left: 16.6666666667% !important; - } - - .offset-lg-3 { - margin-left: 25% !important; - } - - .offset-lg-4 { - margin-left: 33.3333333333% !important; - } - - .offset-lg-5 { - margin-left: 41.6666666667% !important; - } - - .offset-lg-6 { - margin-left: 50% !important; - } - - .offset-lg-7 { - margin-left: 58.3333333333% !important; - } - - .offset-lg-8 { - margin-left: 66.6666666667% !important; - } - - .offset-lg-9 { - margin-left: 75% !important; - } - - .offset-lg-10 { - margin-left: 83.3333333333% !important; - } - - .offset-lg-11 { - margin-left: 91.6666666667% !important; - } -} - -@media (min-width: 1280px) { - .offset-xl-1 { - margin-left: 8.3333333333% !important; - } - - .offset-xl-2 { - margin-left: 16.6666666667% !important; - } - - .offset-xl-3 { - margin-left: 25% !important; - } - - .offset-xl-4 { - margin-left: 33.3333333333% !important; - } - - .offset-xl-5 { - margin-left: 41.6666666667% !important; - } - - .offset-xl-6 { - margin-left: 50% !important; - } - - .offset-xl-7 { - margin-left: 58.3333333333% !important; - } - - .offset-xl-8 { - margin-left: 66.6666666667% !important; - } - - .offset-xl-9 { - margin-left: 75% !important; - } - - .offset-xl-10 { - margin-left: 83.3333333333% !important; - } - - .offset-xl-11 { - margin-left: 91.6666666667% !important; - } -} - -.markdown-body { - font-family: - -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, - "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; - font-size: 16px; - line-height: 1.5; - word-wrap: break-word; -} - -.markdown-body::before { - display: table; - content: ""; -} - -.markdown-body::after { - display: table; - clear: both; - content: ""; -} - -.markdown-body > *:first-child { - margin-top: 0 !important; -} - -.markdown-body > *:last-child { - margin-bottom: 0 !important; -} - -.markdown-body a:not([href]) { - color: inherit; - text-decoration: none; -} - -.markdown-body .absent { - color: #cb2431; -} - -.markdown-body .anchor { - float: left; - padding-right: 4px; - margin-left: -20px; - line-height: 1; -} - -.markdown-body .anchor:focus { - outline: none; -} - -.markdown-body p, -.markdown-body blockquote, -.markdown-body ul, -.markdown-body ol, -.markdown-body dl, -.markdown-body table, -.markdown-body pre { - margin-top: 0; - margin-bottom: 16px; -} - -.markdown-body hr { - height: 0.25em; - padding: 0; - margin: 24px 0; - background-color: #e1e4e8; - border: 0; -} - -.markdown-body blockquote { - padding: 0 1em; - color: #6a737d; - border-left: 0.25em solid #dfe2e5; -} - -.markdown-body blockquote > :first-child { - margin-top: 0; -} - -.markdown-body blockquote > :last-child { - margin-bottom: 0; -} - -.markdown-body kbd { - display: inline-block; - padding: 3px 5px; - font-size: 11px; - line-height: 10px; - color: #444d56; - vertical-align: middle; - background-color: #fafbfc; - border: solid 1px #c6cbd1; - border-bottom-color: #959da5; - border-radius: 3px; - box-shadow: inset 0 -1px 0 #959da5; -} - -.markdown-body h1, -.markdown-body h2, -.markdown-body h3, -.markdown-body h4, -.markdown-body h5, -.markdown-body h6 { - margin-top: 24px; - margin-bottom: 16px; - font-weight: 600; - line-height: 1.25; -} - -.markdown-body h1 .octicon-link, -.markdown-body h2 .octicon-link, -.markdown-body h3 .octicon-link, -.markdown-body h4 .octicon-link, -.markdown-body h5 .octicon-link, -.markdown-body h6 .octicon-link { - color: #1b1f23; - vertical-align: middle; - visibility: hidden; -} - -.markdown-body h1:hover .anchor, -.markdown-body h2:hover .anchor, -.markdown-body h3:hover .anchor, -.markdown-body h4:hover .anchor, -.markdown-body h5:hover .anchor, -.markdown-body h6:hover .anchor { - text-decoration: none; -} - -.markdown-body h1:hover .anchor .octicon-link, -.markdown-body h2:hover .anchor .octicon-link, -.markdown-body h3:hover .anchor .octicon-link, -.markdown-body h4:hover .anchor .octicon-link, -.markdown-body h5:hover .anchor .octicon-link, -.markdown-body h6:hover .anchor .octicon-link { - visibility: visible; -} - -.markdown-body h1 tt, -.markdown-body h1 code, -.markdown-body h2 tt, -.markdown-body h2 code, -.markdown-body h3 tt, -.markdown-body h3 code, -.markdown-body h4 tt, -.markdown-body h4 code, -.markdown-body h5 tt, -.markdown-body h5 code, -.markdown-body h6 tt, -.markdown-body h6 code { - font-size: inherit; -} - -.markdown-body h1 { - padding-bottom: 0.3em; - font-size: 2em; - border-bottom: 1px solid #eaecef; -} - -.markdown-body h2 { - padding-bottom: 0.3em; - font-size: 1.5em; - border-bottom: 1px solid #eaecef; -} - -.markdown-body h3 { - font-size: 1.25em; -} - -.markdown-body h4 { - font-size: 1em; -} - -.markdown-body h5 { - font-size: 0.875em; -} - -.markdown-body h6 { - font-size: 0.85em; - color: #6a737d; -} - -.markdown-body ul, -.markdown-body ol { - padding-left: 2em; -} - -.markdown-body ul.no-list, -.markdown-body ol.no-list { - padding: 0; - list-style-type: none; -} - -.markdown-body ul ul, -.markdown-body ul ol, -.markdown-body ol ol, -.markdown-body ol ul { - margin-top: 0; - margin-bottom: 0; -} - -.markdown-body li { - word-wrap: break-all; -} - -.markdown-body li > p { - margin-top: 16px; -} - -.markdown-body li + li { - margin-top: 0.25em; -} - -.markdown-body dl { - padding: 0; -} - -.markdown-body dl dt { - padding: 0; - margin-top: 16px; - font-size: 1em; - font-style: italic; - font-weight: 600; -} - -.markdown-body dl dd { - padding: 0 16px; - margin-bottom: 16px; -} - -.markdown-body table { - display: block; - width: 100%; - overflow: auto; -} - -.markdown-body table th { - font-weight: 600; -} - -.markdown-body table th, -.markdown-body table td { - padding: 6px 13px; - border: 1px solid #dfe2e5; -} - -.markdown-body table tr { - background-color: #fff; - border-top: 1px solid #c6cbd1; -} - -.markdown-body table tr:nth-child(2n) { - background-color: #f6f8fa; -} - -.markdown-body table img { - background-color: transparent; -} - -.markdown-body img { - max-width: 100%; - box-sizing: content-box; - background-color: #fff; -} - -.markdown-body img[align="right"] { - padding-left: 20px; -} - -.markdown-body img[align="left"] { - padding-right: 20px; -} - -.markdown-body .emoji { - max-width: none; - vertical-align: text-top; - background-color: transparent; -} - -.markdown-body span.frame { - display: block; - overflow: hidden; -} - -.markdown-body span.frame > span { - display: block; - float: left; - width: auto; - padding: 7px; - margin: 13px 0 0; - overflow: hidden; - border: 1px solid #dfe2e5; -} - -.markdown-body span.frame span img { - display: block; - float: left; -} - -.markdown-body span.frame span span { - display: block; - padding: 5px 0 0; - clear: both; - color: #24292e; -} - -.markdown-body span.align-center { - display: block; - overflow: hidden; - clear: both; -} - -.markdown-body span.align-center > span { - display: block; - margin: 13px auto 0; - overflow: hidden; - text-align: center; -} - -.markdown-body span.align-center span img { - margin: 0 auto; - text-align: center; -} - -.markdown-body span.align-right { - display: block; - overflow: hidden; - clear: both; -} - -.markdown-body span.align-right > span { - display: block; - margin: 13px 0 0; - overflow: hidden; - text-align: right; -} - -.markdown-body span.align-right span img { - margin: 0; - text-align: right; -} - -.markdown-body span.float-left { - display: block; - float: left; - margin-right: 13px; - overflow: hidden; -} - -.markdown-body span.float-left span { - margin: 13px 0 0; -} - -.markdown-body span.float-right { - display: block; - float: right; - margin-left: 13px; - overflow: hidden; -} - -.markdown-body span.float-right > span { - display: block; - margin: 13px auto 0; - overflow: hidden; - text-align: right; -} - -.markdown-body code, -.markdown-body tt { - padding: 0.2em 0.4em; - margin: 0; - font-size: 85%; - background-color: rgba(27, 31, 35, 0.05); - border-radius: 3px; -} - -.markdown-body code br, -.markdown-body tt br { - display: none; -} - -.markdown-body del code { - text-decoration: inherit; -} - -.markdown-body pre { - word-wrap: normal; -} - -.markdown-body pre > code { - padding: 0; - margin: 0; - font-size: 100%; - word-break: normal; - white-space: pre; - background: transparent; - border: 0; -} - -.markdown-body .highlight { - margin-bottom: 16px; -} - -.markdown-body .highlight pre { - margin-bottom: 0; - word-break: normal; -} - -.markdown-body .highlight pre, -.markdown-body pre { - padding: 16px; - overflow: auto; - font-size: 85%; - line-height: 1.45; - background-color: #f6f8fa; - border-radius: 3px; -} - -.markdown-body pre code, -.markdown-body pre tt { - display: inline; - max-width: auto; - padding: 0; - margin: 0; - overflow: visible; - line-height: inherit; - word-wrap: normal; - background-color: transparent; - border: 0; -} - -.markdown-body .csv-data td, -.markdown-body .csv-data th { - padding: 5px; - overflow: hidden; - font-size: 12px; - line-height: 1; - text-align: left; - white-space: nowrap; -} - -.markdown-body .csv-data .blob-num { - padding: 10px 8px 9px; - text-align: right; - background: #fff; - border: 0; -} - -.markdown-body .csv-data tr { - border-top: 0; -} - -.markdown-body .csv-data th { - font-weight: 600; - background: #f6f8fa; - border-top: 0; -} - -.highlight table td { - padding: 5px; -} - -.highlight table pre { - margin: 0; -} - -.highlight .cm { - color: #999988; - font-style: italic; -} - -.highlight .cp { - color: #999999; - font-weight: bold; -} - -.highlight .c1 { - color: #999988; - font-style: italic; -} - -.highlight .cs { - color: #999999; - font-weight: bold; - font-style: italic; -} - -.highlight .c, -.highlight .cd { - color: #999988; - font-style: italic; -} - -.highlight .err { - color: #a61717; - background-color: #e3d2d2; -} - -.highlight .gd { - color: #000000; - background-color: #ffdddd; -} - -.highlight .ge { - color: #000000; - font-style: italic; -} - -.highlight .gr { - color: #aa0000; -} - -.highlight .gh { - color: #999999; -} - -.highlight .gi { - color: #000000; - background-color: #ddffdd; -} - -.highlight .go { - color: #888888; -} - -.highlight .gp { - color: #555555; -} - -.highlight .gs { - font-weight: bold; -} - -.highlight .gu { - color: #aaaaaa; -} - -.highlight .gt { - color: #aa0000; -} - -.highlight .kc { - color: #000000; - font-weight: bold; -} - -.highlight .kd { - color: #000000; - font-weight: bold; -} - -.highlight .kn { - color: #000000; - font-weight: bold; -} - -.highlight .kp { - color: #000000; - font-weight: bold; -} - -.highlight .kr { - color: #000000; - font-weight: bold; -} - -.highlight .kt { - color: #445588; - font-weight: bold; -} - -.highlight .k, -.highlight .kv { - color: #000000; - font-weight: bold; -} - -.highlight .mf { - color: #009999; -} - -.highlight .mh { - color: #009999; -} - -.highlight .il { - color: #009999; -} - -.highlight .mi { - color: #009999; -} - -.highlight .mo { - color: #009999; -} - -.highlight .m, -.highlight .mb, -.highlight .mx { - color: #009999; -} - -.highlight .sb { - color: #d14; -} - -.highlight .sc { - color: #d14; -} - -.highlight .sd { - color: #d14; -} - -.highlight .s2 { - color: #d14; -} - -.highlight .se { - color: #d14; -} - -.highlight .sh { - color: #d14; -} - -.highlight .si { - color: #d14; -} - -.highlight .sx { - color: #d14; -} - -.highlight .sr { - color: #009926; -} - -.highlight .s1 { - color: #d14; -} - -.highlight .ss { - color: #990073; -} - -.highlight .s { - color: #d14; -} - -.highlight .na { - color: #008080; -} - -.highlight .bp { - color: #999999; -} - -.highlight .nb { - color: #0086b3; -} - -.highlight .nc { - color: #445588; - font-weight: bold; -} - -.highlight .no { - color: #008080; -} - -.highlight .nd { - color: #3c5d5d; - font-weight: bold; -} - -.highlight .ni { - color: #800080; -} - -.highlight .ne { - color: #990000; - font-weight: bold; -} - -.highlight .nf { - color: #990000; - font-weight: bold; -} - -.highlight .nl { - color: #990000; - font-weight: bold; -} - -.highlight .nn { - color: #555555; -} - -.highlight .nt { - color: #000080; -} - -.highlight .vc { - color: #008080; -} - -.highlight .vg { - color: #008080; -} - -.highlight .vi { - color: #008080; -} - -.highlight .nv { - color: #008080; -} - -.highlight .ow { - color: #000000; - font-weight: bold; -} - -.highlight .o { - color: #000000; - font-weight: bold; -} - -.highlight .w { - color: #bbbbbb; -} - -.highlight { - background-color: #f8f8f8; -} diff --git a/docs/templates/base.html b/docs/templates/base.html deleted file mode 100644 index 848168c..0000000 --- a/docs/templates/base.html +++ /dev/null @@ -1,87 +0,0 @@ - - - - - - {{ page_title }} – {{ site.site_name }} - - - - - - - - - - -

- - -
-
-

{{ page_title }}

- {{ page_content | safe }} -
- -
- - Built with librawssg - · Licensed under {{ site.license | default(value='MIT') }} - -
-
-
- - - - - diff --git a/docs/templates/rss.xml b/docs/templates/rss.xml deleted file mode 100644 index 92c45bf..0000000 --- a/docs/templates/rss.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - {{ site.site_name }} - {{ site.description }} - {{ site.base_url }} - - {{ site.language | default(value='en') }} - {% for post in posts %} - - {{ post.frontmatter.title }} - {{ site.base_url }}/{{ post.url }} - {{ post.frontmatter.desc }} - {{ post.pub_date }} - {{ site.base_url }}/{{ post.url }} - - {% endfor %} - - diff --git a/docs/templates/sitemap.xml b/docs/templates/sitemap.xml deleted file mode 100644 index ae3b004..0000000 --- a/docs/templates/sitemap.xml +++ /dev/null @@ -1,13 +0,0 @@ - - -{% for page in pages %} - - {{ site.base_url }}/{{ page.url }} - {% if page.pub_date %} - {{ page.pub_date }} - {% endif %} - monthly - 0.8 - -{% endfor %} - diff --git a/tests/common/mod.rs b/tests/common/mod.rs deleted file mode 100644 index da58718..0000000 --- a/tests/common/mod.rs +++ /dev/null @@ -1,227 +0,0 @@ -#![allow(dead_code, unused_imports)] - -use librawssg::config::ConfigLoader; -use librawssg::error::RawssgError; -use librawssg::fs::FileSystem; -use librawssg::markdown::MarkdownRenderer; -use librawssg::site::{Context, TemplateRenderer}; -use librawssg::types::RawssgConfig; -use std::collections::HashMap; -use std::io; -use std::path::{Path, PathBuf}; - -pub struct MockFs { - pub files: HashMap>, - pub dirs: Vec, - pub read_error: Option, -} - -impl MockFs { - pub fn new() -> Self { - Self { - files: HashMap::new(), - dirs: vec![], - read_error: None, - } - } - - pub fn add_file(&mut self, path: &str, content: &str) { - self.files - .insert(PathBuf::from(path), content.as_bytes().to_vec()); - if let Some(parent) = Path::new(path).parent() { - let mut p = PathBuf::new(); - for comp in parent.components() { - p.push(comp); - if !self.dirs.contains(&p) { - self.dirs.push(p.clone()); - } - } - } - } -} - -impl FileSystem for MockFs { - fn read_to_string(&self, path: &Path) -> io::Result { - if let Some(err_path) = &self.read_error - && err_path == path - { - return Err(io::Error::new(io::ErrorKind::NotFound, "mock error")); - } - self.files - .get(path) - .map(|b| String::from_utf8_lossy(b).to_string()) - .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "file not found")) - } - - fn read_bytes(&self, path: &Path) -> io::Result> { - self.files - .get(path) - .cloned() - .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "file not found")) - } - - fn write(&self, _path: &Path, _content: &[u8]) -> io::Result<()> { - Ok(()) - } - fn create_dir_all(&self, _path: &Path) -> io::Result<()> { - Ok(()) - } - fn remove_dir_all(&self, _path: &Path) -> io::Result<()> { - Ok(()) - } - - fn exists(&self, path: &Path) -> bool { - self.files.contains_key(path) || self.dirs.contains(&path.to_path_buf()) - } - - fn is_dir(&self, path: &Path) -> bool { - self.dirs.contains(&path.to_path_buf()) - } - fn is_file(&self, path: &Path) -> bool { - self.files.contains_key(path) - } - - fn read_dir(&self, path: &Path) -> io::Result> { - let mut entries = vec![]; - for f in self.files.keys() { - if let Some(parent) = f.parent() - && parent == path - { - entries.push(f.clone()); - } - } - for d in &self.dirs { - if let Some(parent) = d.parent() - && parent == path - { - entries.push(d.clone()); - } - } - Ok(entries) - } - - fn copy_file(&self, _from: &Path, _to: &Path) -> io::Result { - Ok(0) - } - - fn walk_dir(&self, root: &Path) -> io::Result> { - Ok(self - .files - .keys() - .filter(|p| p.starts_with(root)) - .cloned() - .collect()) - } - - fn canonicalize(&self, path: &Path) -> io::Result { - let absolute = if path.is_absolute() { - path.to_path_buf() - } else { - Path::new("/").join(path) - }; - - let mut components = Vec::new(); - for comp in absolute.components() { - match comp { - std::path::Component::ParentDir => { - components.pop(); - } - std::path::Component::CurDir => {} - other => components.push(other), - } - } - let canonical: PathBuf = components.iter().collect(); - - if self.files.contains_key(&canonical) || self.dirs.contains(&canonical) { - Ok(canonical) - } else { - Err(io::Error::new( - io::ErrorKind::NotFound, - "mock: path not found", - )) - } - } - - fn rename(&self, _from: &Path, _to: &Path) -> io::Result<()> { - Ok(()) - } -} - -pub struct MockMarkdownRenderer { - pub render_fn: Box String + Send + Sync>, -} - -impl MockMarkdownRenderer { - pub fn new String + Send + Sync + 'static>(f: F) -> Self { - Self { - render_fn: Box::new(f), - } - } - pub fn identity() -> Self { - Self::new(|s| s.to_string()) - } -} - -impl MarkdownRenderer for MockMarkdownRenderer { - fn render(&self, markdown: &str) -> String { - (self.render_fn)(markdown) - } -} - -#[allow(clippy::type_complexity)] -pub struct MockTemplateRenderer { - pub templates: HashMap, - pub render_fn: Box Result + Send + Sync>, -} - -impl MockTemplateRenderer { - pub fn new() -> Self { - Self { - templates: HashMap::new(), - render_fn: Box::new(|name, _ctx| Ok(format!("rendered:{}", name))), - } - } - - pub fn with_fn< - F: Fn(&str, &dyn Context) -> Result + Send + Sync + 'static, - >( - f: F, - ) -> Self { - Self { - templates: HashMap::new(), - render_fn: Box::new(f), - } - } - - pub fn add_raw_template(&mut self, name: &str, content: &str) { - self.templates.insert(name.to_string(), content.to_string()); - } -} - -impl TemplateRenderer for MockTemplateRenderer { - fn render(&self, template: &str, context: &dyn Context) -> Result { - (self.render_fn)(template, context) - } -} - -pub struct MockConfigLoader { - pub config_fn: Box Result + Send + Sync>, -} - -impl MockConfigLoader { - pub fn new Result + Send + Sync + 'static>(f: F) -> Self { - Self { - config_fn: Box::new(f), - } - } -} - -impl ConfigLoader for MockConfigLoader { - fn load(&self) -> Result { - (self.config_fn)() - } -} - -pub fn default_test_config() -> RawssgConfig { - RawssgConfig::default() -} diff --git a/tests/common_mocks.rs b/tests/common_mocks.rs deleted file mode 100644 index 8e088a9..0000000 --- a/tests/common_mocks.rs +++ /dev/null @@ -1,43 +0,0 @@ -#![allow(dead_code)] - -#[cfg(feature = "tera")] -pub mod context_mocks { - use librawssg::error::RawssgError; - use librawssg::site::Context; - use librawssg::site::context::{FeedContextBuilder, SitemapContextBuilder}; - use librawssg::types::{PageContext, RawssgConfig}; - - pub struct MockFeedContextBuilder; - - impl FeedContextBuilder for MockFeedContextBuilder { - fn build_feed_context( - &self, - config: &RawssgConfig, - posts: &[&PageContext], - base_url: &str, - ) -> Result, RawssgError> { - let mut ctx = tera::Context::new(); - ctx.insert("site", &config.site); - ctx.insert("posts", posts); - ctx.insert("base_url", base_url); - Ok(Box::new(ctx)) - } - } - - pub struct MockSitemapContextBuilder; - - impl SitemapContextBuilder for MockSitemapContextBuilder { - fn build_sitemap_context( - &self, - config: &RawssgConfig, - pages: &[PageContext], - base_url: &str, - ) -> Result, RawssgError> { - let mut ctx = tera::Context::new(); - ctx.insert("site", &config.site); - ctx.insert("pages", pages); - ctx.insert("base_url", base_url); - Ok(Box::new(ctx)) - } - } -} diff --git a/tests/config_tests.rs b/tests/config_tests.rs deleted file mode 100644 index 93d77f1..0000000 --- a/tests/config_tests.rs +++ /dev/null @@ -1,89 +0,0 @@ -use librawssg::config::{ConfigLoader, loader::YamlConfigLoader}; -use librawssg::error::RawssgError; -use librawssg::types::RawssgConfig; -use std::io::Write; -use tempfile::NamedTempFile; - -#[test] -fn default_config_returns_valid_default() { - let loader = librawssg::config::loader::DefaultConfig; - let config = loader.load().expect("default config should load"); - assert_eq!(config.site.site_name, "rawssg"); - assert_eq!(config.build.content_dir, "content"); - assert!( - !config.content_types.is_empty(), - "default config must have at least one content type" - ); - assert_eq!(config.content_types.len(), 1); - assert_eq!(config.content_types[0].name, "page"); - assert_eq!(config.content_types[0].pattern, "**/*.md"); - assert_eq!(config.content_types[0].template, "base.html"); -} - -#[test] -fn yaml_loader_loads_full_config() { - let yaml = r#" -site: - site_name: "Test Site" - description: "A test" -build: - content_dir: "my_content" - output_dir: "public" -content_types: - - name: blog - pattern: "blog/*.md" - template: "post.html" -"#; - let mut file = NamedTempFile::new().unwrap(); - file.write_all(yaml.as_bytes()).unwrap(); - let loader = YamlConfigLoader::new(file.path()); - let config = loader.load().unwrap(); - assert_eq!(config.site.site_name, "Test Site"); - assert_eq!(config.build.content_dir, "my_content"); - assert_eq!(config.content_types.len(), 1); - assert_eq!(config.content_types[0].name, "blog"); -} - -#[test] -fn yaml_loader_partial_defaults() { - let yaml = "site:\n site_name: \"OnlyName\""; - let mut file = NamedTempFile::new().unwrap(); - file.write_all(yaml.as_bytes()).unwrap(); - let loader = YamlConfigLoader::new(file.path()); - let config = loader.load().unwrap(); - assert_eq!(config.site.site_name, "OnlyName"); - assert_eq!(config.build.content_dir, "content"); -} - -#[test] -fn yaml_loader_invalid_syntax_error() { - let yaml = "site: [unclosed"; - let mut file = NamedTempFile::new().unwrap(); - file.write_all(yaml.as_bytes()).unwrap(); - let loader = YamlConfigLoader::new(file.path()); - match loader.load() { - Err(RawssgError::Config(_)) => {} - _ => panic!("expected Config error"), - } -} - -#[test] -fn yaml_loader_file_not_found() { - let loader = YamlConfigLoader::new("nonexistent.yaml"); - match loader.load() { - Err(RawssgError::Config(_)) => {} - _ => panic!("expected Config error"), - } -} - -#[test] -fn config_loader_trait_load_or_default_on_error() { - struct FailingLoader; - impl ConfigLoader for FailingLoader { - fn load(&self) -> Result { - Err(RawssgError::Config("fail".into())) - } - } - let config = FailingLoader.load_or_default(); - assert_eq!(config.site.site_name, "rawssg"); -} diff --git a/tests/edge_cases_tests.rs b/tests/edge_cases_tests.rs deleted file mode 100644 index 9f76711..0000000 --- a/tests/edge_cases_tests.rs +++ /dev/null @@ -1,54 +0,0 @@ -use librawssg::config::{ConfigLoader, loader::YamlConfigLoader}; -use librawssg::error::RawssgError; -use librawssg::types::RawssgConfig; -use std::io::Write; -use tempfile::NamedTempFile; - -#[test] -fn load_or_default_uses_default_on_error() { - struct BadLoader; - impl ConfigLoader for BadLoader { - fn load(&self) -> Result { - Err(RawssgError::Internal("boom".into())) - } - } - let config = BadLoader.load_or_default(); - assert_eq!(config.site.site_name, "rawssg"); -} - -#[test] -fn load_or_default_returns_loaded_config_when_ok() { - struct GoodLoader; - impl ConfigLoader for GoodLoader { - fn load(&self) -> Result { - let mut cfg = RawssgConfig::default(); - cfg.site.site_name = "custom".into(); - Ok(cfg) - } - } - let config = GoodLoader.load_or_default(); - assert_eq!(config.site.site_name, "custom"); -} - -#[test] -fn yaml_loader_invalid_yaml_error() { - let yaml = "site:\n site_name: [invalid"; - let mut file = NamedTempFile::new().unwrap(); - file.write_all(yaml.as_bytes()).unwrap(); - let loader = YamlConfigLoader::new(file.path()); - let err = loader.load().unwrap_err(); - match err { - RawssgError::Config(msg) => assert!(msg.contains("invalid config YAML")), - _ => panic!("Expected Config error"), - } -} - -#[test] -fn yaml_loader_file_not_found_error() { - let loader = YamlConfigLoader::new("/nonexistent/path.yaml"); - let err = loader.load().unwrap_err(); - match err { - RawssgError::Config(msg) => assert!(msg.contains("cannot read config")), - _ => panic!("Expected Config error"), - } -} diff --git a/tests/error_tests.rs b/tests/error_tests.rs deleted file mode 100644 index 8db8364..0000000 --- a/tests/error_tests.rs +++ /dev/null @@ -1,16 +0,0 @@ -use librawssg::error::RawssgError; -use std::path::PathBuf; - -#[test] -fn error_display_and_diagnostic_code() { - let err = RawssgError::Frontmatter { - path: PathBuf::from("test.md"), - source: Box::new(std::io::Error::other("oops")), - }; - let msg = format!("{}", err); - assert!(msg.contains("Failed to parse frontmatter in test.md")); - assert_eq!( - format!("{:?}", err), - "Frontmatter { path: \"test.md\", source: Custom { kind: Other, error: \"oops\" } }" - ); -} diff --git a/tests/frontmatter_tests.rs b/tests/frontmatter_tests.rs deleted file mode 100644 index cae9f7c..0000000 --- a/tests/frontmatter_tests.rs +++ /dev/null @@ -1,79 +0,0 @@ -mod common; -use common::MockMarkdownRenderer; -use librawssg::error::RawssgError; -use librawssg::frontmatter::parse_frontmatter_and_render; -use std::path::Path; - -#[test] -fn valid_frontmatter_parsed() { - let input = "---\ntitle: Test\ndesc: Desc\n---\n# Content"; - let renderer = MockMarkdownRenderer::identity(); - let (fm, html) = parse_frontmatter_and_render(input, Path::new("file.md"), &renderer).unwrap(); - assert_eq!(fm.title, "Test"); - assert_eq!(fm.desc, "Desc"); - assert_eq!(html, "# Content"); -} - -#[test] -fn missing_opening_dashes_error() { - let input = "# No frontmatter"; - let renderer = MockMarkdownRenderer::identity(); - let err = parse_frontmatter_and_render(input, Path::new("file.md"), &renderer).unwrap_err(); - match err { - RawssgError::Frontmatter { path, source } => { - assert!(source.to_string().contains("missing opening")); - assert_eq!(path, Path::new("file.md")); - } - _ => panic!("wrong error"), - } -} - -#[test] -fn invalid_yaml_in_frontmatter() { - let input = "---\ntitle: [broken\ndesc: x\n---\ncontent"; - let renderer = MockMarkdownRenderer::identity(); - assert!(parse_frontmatter_and_render(input, Path::new("f.md"), &renderer).is_err()); -} - -#[test] -fn missing_closing_dashes_error() { - let input = "---\ntitle: Test\ndesc: Test desc\n"; - let renderer = MockMarkdownRenderer::identity(); - let err = parse_frontmatter_and_render(input, Path::new("f.md"), &renderer).unwrap_err(); - match err { - RawssgError::Frontmatter { source, .. } => { - assert!(source.to_string().contains("missing closing '---'")); - } - _ => panic!("expected Frontmatter error about missing closing dashes"), - } -} - -#[test] -fn windows_line_endings() { - let input = "---\r\ntitle: Win\r\ndesc: Win desc\r\n---\r\n# Content"; - let renderer = MockMarkdownRenderer::identity(); - let (fm, html) = parse_frontmatter_and_render(input, Path::new("f.md"), &renderer).unwrap(); - assert_eq!(fm.title, "Win"); - assert_eq!(fm.desc, "Win desc"); - assert_eq!(html, "# Content"); -} - -#[test] -fn only_frontmatter_no_content() { - let input = "---\ntitle: Only\ndesc: Only desc\n---"; - let renderer = MockMarkdownRenderer::identity(); - let (fm, html) = parse_frontmatter_and_render(input, Path::new("f.md"), &renderer).unwrap(); - assert_eq!(fm.title, "Only"); - assert_eq!(fm.desc, "Only desc"); - assert!(html.is_empty()); -} - -#[test] -fn empty_yaml_values_still_valid() { - let input = "---\ntitle: \"\"\ndesc: \"\"\n---\n# Content after FM"; - let renderer = MockMarkdownRenderer::identity(); - let (fm, html) = parse_frontmatter_and_render(input, Path::new("f.md"), &renderer).unwrap(); - assert_eq!(fm.title, ""); - assert_eq!(fm.desc, ""); - assert_eq!(html, "# Content after FM"); -} diff --git a/tests/fs_mock_tests.rs b/tests/fs_mock_tests.rs deleted file mode 100644 index b605b8c..0000000 --- a/tests/fs_mock_tests.rs +++ /dev/null @@ -1,76 +0,0 @@ -mod common; -use common::MockFs; -use librawssg::fs::FileSystem; -use std::path::Path; - -#[test] -fn read_to_string_existing_file() { - let mut fs = MockFs::new(); - fs.add_file("/test.txt", "hello"); - let content = fs.read_to_string(Path::new("/test.txt")).unwrap(); - assert_eq!(content, "hello"); -} - -#[test] -fn read_to_string_not_found() { - let fs = MockFs::new(); - assert!(fs.read_to_string(Path::new("/missing.txt")).is_err()); -} - -#[test] -fn read_bytes_existing() { - let mut fs = MockFs::new(); - fs.add_file("/data.bin", "\x00\x01"); - let bytes = fs.read_bytes(Path::new("/data.bin")).unwrap(); - assert_eq!(bytes, vec![0, 1]); -} - -#[test] -fn exists_true() { - let mut fs = MockFs::new(); - fs.add_file("/file.txt", "x"); - assert!(fs.exists(Path::new("/file.txt"))); -} - -#[test] -fn exists_false() { - let fs = MockFs::new(); - assert!(!fs.exists(Path::new("/ghost.txt"))); -} - -#[test] -fn is_dir_and_is_file() { - let mut fs = MockFs::new(); - fs.add_file("/dir/file.txt", "data"); - assert!(fs.is_dir(Path::new("/dir"))); - assert!(fs.is_file(Path::new("/dir/file.txt"))); - assert!(!fs.is_dir(Path::new("/dir/file.txt"))); -} - -#[test] -fn read_dir_returns_entries() { - let mut fs = MockFs::new(); - fs.add_file("/root/a.md", "a"); - fs.add_file("/root/b.md", "b"); - fs.dirs.push(Path::new("/root/sub").to_path_buf()); - let entries = fs.read_dir(Path::new("/root")).unwrap(); - assert_eq!(entries.len(), 3); -} - -#[test] -fn walk_dir_recursive() { - let mut fs = MockFs::new(); - fs.add_file("/root/a.md", "a"); - fs.add_file("/root/sub/b.md", "b"); - let files = fs.walk_dir(Path::new("/root")).unwrap(); - assert!(files.contains(&Path::new("/root/a.md").to_path_buf())); - assert!(files.contains(&Path::new("/root/sub/b.md").to_path_buf())); -} - -#[test] -fn read_to_string_mock_error() { - let mut fs = MockFs::new(); - fs.add_file("/faulty.txt", "data"); - fs.read_error = Some(Path::new("/faulty.txt").to_path_buf()); - assert!(fs.read_to_string(Path::new("/faulty.txt")).is_err()); -} diff --git a/tests/fs_real_tests.rs b/tests/fs_real_tests.rs deleted file mode 100644 index 7c51c06..0000000 --- a/tests/fs_real_tests.rs +++ /dev/null @@ -1,43 +0,0 @@ -use librawssg::fs::{FileSystem, real::RealFs}; -use tempfile::tempdir; - -#[test] -fn real_fs_read_write_cycle() { - let dir = tempdir().unwrap(); - let file_path = dir.path().join("test.txt"); - RealFs.write(&file_path, b"hello").unwrap(); - assert!(RealFs.exists(&file_path)); - let content = RealFs.read_to_string(&file_path).unwrap(); - assert_eq!(content, "hello"); -} - -#[test] -fn real_fs_create_and_remove_dir() { - let dir = tempdir().unwrap(); - let sub = dir.path().join("sub"); - RealFs.create_dir_all(&sub).unwrap(); - assert!(RealFs.is_dir(&sub)); - RealFs.remove_dir_all(&sub).unwrap(); - assert!(!RealFs.exists(&sub)); -} - -#[test] -fn real_fs_walk_dir() { - let dir = tempdir().unwrap(); - RealFs.write(&dir.path().join("a.txt"), b"a").unwrap(); - std::fs::create_dir(dir.path().join("sub")).unwrap(); - RealFs.write(&dir.path().join("sub/b.txt"), b"b").unwrap(); - let files = RealFs.walk_dir(dir.path()).unwrap(); - assert!(files.contains(&dir.path().join("a.txt"))); - assert!(files.contains(&dir.path().join("sub/b.txt"))); -} - -#[test] -fn real_fs_copy_file() { - let dir = tempdir().unwrap(); - let src = dir.path().join("src.txt"); - let dst = dir.path().join("dst.txt"); - RealFs.write(&src, b"copy").unwrap(); - RealFs.copy_file(&src, &dst).unwrap(); - assert_eq!(RealFs.read_to_string(&dst).unwrap(), "copy"); -} diff --git a/tests/integration_advanced_tests.rs b/tests/integration_advanced_tests.rs deleted file mode 100644 index 64884c2..0000000 --- a/tests/integration_advanced_tests.rs +++ /dev/null @@ -1,99 +0,0 @@ -mod common; -mod common_mocks; - -#[cfg(all(feature = "tera", feature = "pulldown"))] -mod advanced { - use librawssg::SiteBuilder; - use librawssg::error::RawssgError; - use librawssg::markdown::PulldownMarkdown; - use librawssg::site::TeraRenderer; - use librawssg::types::{ContentTypeDef, RawssgConfig}; - - #[test] - fn builder_missing_markdown_renderer() { - let mut config = RawssgConfig::default(); - config.content_types.push(ContentTypeDef { - name: "page".into(), - pattern: "*.md".into(), - template: "base.html".into(), - list_template: None, - list_enabled: false, - }); - let result = SiteBuilder::new() - .config(config) - .with_template_renderer(Box::new(TeraRenderer::new())) - .build(); - match result { - Err(RawssgError::Config(msg)) => assert!(msg.contains("markdown renderer")), - _ => panic!("Expected config error about missing markdown renderer"), - } - } - - #[test] - fn builder_missing_template_renderer() { - let mut config = RawssgConfig::default(); - config.content_types.push(ContentTypeDef { - name: "page".into(), - pattern: "*.md".into(), - template: "base.html".into(), - list_template: None, - list_enabled: false, - }); - let result = SiteBuilder::new() - .config(config) - .with_markdown_renderer(Box::new(PulldownMarkdown)) - .build(); - match result { - Err(RawssgError::Config(msg)) => assert!(msg.contains("template renderer")), - _ => panic!("Expected config error about missing template renderer"), - } - } - - #[test] - fn rss_enabled_without_context_builder_error() { - let mut config = RawssgConfig::default(); - config.generators.rss.enabled = true; - config.generators.rss.template = "rss.xml".into(); - config.generators.rss.path = "rss.xml".into(); - config.content_types.push(ContentTypeDef { - name: "page".into(), - pattern: "*.md".into(), - template: "base.html".into(), - list_template: None, - list_enabled: false, - }); - let result = SiteBuilder::new() - .config(config) - .with_template_renderer(Box::new(TeraRenderer::new())) - .with_markdown_renderer(Box::new(PulldownMarkdown)) - .build(); - match result { - Err(RawssgError::Config(msg)) => assert!(msg.contains("feed context builder")), - _ => panic!("Expected error about missing feed context builder"), - } - } - - #[test] - fn sitemap_enabled_without_context_builder_error() { - let mut config = RawssgConfig::default(); - config.generators.sitemap.enabled = true; - config.generators.sitemap.template = "sitemap.xml".into(); - config.generators.sitemap.path = "sitemap.xml".into(); - config.content_types.push(ContentTypeDef { - name: "page".into(), - pattern: "*.md".into(), - template: "base.html".into(), - list_template: None, - list_enabled: false, - }); - let result = SiteBuilder::new() - .config(config) - .with_template_renderer(Box::new(TeraRenderer::new())) - .with_markdown_renderer(Box::new(PulldownMarkdown)) - .build(); - match result { - Err(RawssgError::Config(msg)) => assert!(msg.contains("sitemap context builder")), - _ => panic!("Expected error about missing sitemap context builder"), - } - } -} diff --git a/tests/integration_test.rs b/tests/integration_test.rs deleted file mode 100644 index b9ce158..0000000 --- a/tests/integration_test.rs +++ /dev/null @@ -1,162 +0,0 @@ -mod common; -mod common_mocks; - -#[cfg(all(feature = "tera", feature = "pulldown"))] -mod all_tests { - use crate::common_mocks::context_mocks::{MockFeedContextBuilder, MockSitemapContextBuilder}; - use librawssg::SiteBuilder; - use librawssg::markdown::PulldownMarkdown; - use librawssg::site::TeraRenderer; - use librawssg::types::{ContentTypeDef, RawssgConfig}; - use std::fs; - use tempfile::tempdir; - - #[test] - fn full_site_generation() { - let dir = tempdir().unwrap(); - let content = dir.path().join("content"); - let templates = dir.path().join("templates"); - let output = dir.path().join("dist"); - fs::create_dir(&content).unwrap(); - fs::create_dir(&templates).unwrap(); - - fs::write( - content.join("index.md"), - "---\ntitle: Home\ndesc: Home page\n---\nWelcome!", - ) - .unwrap(); - fs::write( - content.join("about.md"), - "---\ntitle: About\ndesc: About us\n---\nAbout us", - ) - .unwrap(); - - let mut tera = TeraRenderer::new(); - tera.add_raw_template( - "base.html", - "{{ page_title }}{{ page_content }}", - ).unwrap(); - let md = PulldownMarkdown; - - let mut config = RawssgConfig::default(); - config.build.content_dir = content.to_string_lossy().into(); - config.build.output_dir = output.to_string_lossy().into(); - config.build.templates_dir = templates.to_string_lossy().into(); - - config.content_types.push(ContentTypeDef { - name: "page".into(), - pattern: "**/*.md".into(), - template: "base.html".into(), - list_template: None, - list_enabled: false, - }); - - let site = SiteBuilder::new() - .config(config) - .with_template_renderer(Box::new(tera)) - .with_markdown_renderer(Box::new(md)) - .with_feed_context_builder(Box::new(MockFeedContextBuilder)) - .with_sitemap_context_builder(Box::new(MockSitemapContextBuilder)) - .build() - .expect("build failed"); - site.generate().expect("generate failed"); - - assert!(output.join("index.html").exists()); - assert!(output.join("about.html").exists()); - let home_html = fs::read_to_string(output.join("index.html")).unwrap(); - assert!(home_html.contains("Welcome!")); - assert!(home_html.contains("Home")); - } - - #[test] - fn draft_not_in_output() { - let dir = tempdir().unwrap(); - let content = dir.path().join("content"); - let templates = dir.path().join("templates"); - let output = dir.path().join("dist"); - fs::create_dir(&content).unwrap(); - fs::create_dir(&templates).unwrap(); - - fs::write( - content.join("draft.md"), - "---\ntitle: Secret\ndesc: x\ndraft: true\n---\nShh", - ) - .unwrap(); - - let mut tera = TeraRenderer::new(); - tera.add_raw_template("base.html", "{{ page_content }}") - .unwrap(); - let md = PulldownMarkdown; - - let mut config = RawssgConfig::default(); - config.build.content_dir = content.to_string_lossy().into(); - config.build.output_dir = output.to_string_lossy().into(); - config.build.templates_dir = templates.to_string_lossy().into(); - - config.content_types.push(ContentTypeDef { - name: "page".into(), - pattern: "**/*.md".into(), - template: "base.html".into(), - list_template: None, - list_enabled: false, - }); - - let site = SiteBuilder::new() - .config(config) - .with_template_renderer(Box::new(tera)) - .with_markdown_renderer(Box::new(md)) - .with_feed_context_builder(Box::new(MockFeedContextBuilder)) - .with_sitemap_context_builder(Box::new(MockSitemapContextBuilder)) - .build() - .unwrap(); - site.generate().unwrap(); - - assert!(!output.join("draft.html").exists()); - } - - #[test] - fn generate_output_files_basic() { - let dir = tempdir().unwrap(); - let content = dir.path().join("content"); - let templates = dir.path().join("templates"); - let output = dir.path().join("dist"); - fs::create_dir(&content).unwrap(); - fs::create_dir(&templates).unwrap(); - - fs::write( - content.join("about.md"), - "---\ntitle: About\ndesc: x\n---\nAbout content", - ) - .unwrap(); - - let mut tera = TeraRenderer::new(); - tera.add_raw_template("base.html", "{{ page_content }}") - .unwrap(); - let md = PulldownMarkdown; - - let mut config = RawssgConfig::default(); - config.build.content_dir = content.to_string_lossy().into(); - config.build.output_dir = output.to_string_lossy().into(); - config.build.templates_dir = templates.to_string_lossy().into(); - - config.content_types.push(ContentTypeDef { - name: "page".into(), - pattern: "**/*.md".into(), - template: "base.html".into(), - list_template: None, - list_enabled: false, - }); - - let site = SiteBuilder::new() - .config(config) - .with_template_renderer(Box::new(tera)) - .with_markdown_renderer(Box::new(md)) - .with_feed_context_builder(Box::new(MockFeedContextBuilder)) - .with_sitemap_context_builder(Box::new(MockSitemapContextBuilder)) - .build() - .unwrap(); - site.generate().expect("generate failed"); - - assert!(output.join("about.html").exists()); - } -} diff --git a/tests/markdown_tests.rs b/tests/markdown_tests.rs deleted file mode 100644 index e384e56..0000000 --- a/tests/markdown_tests.rs +++ /dev/null @@ -1,35 +0,0 @@ -#[cfg(feature = "pulldown")] -mod markdown_tests { - use librawssg::markdown::{MarkdownRenderer, PulldownMarkdown}; - - #[test] - fn basic_markdown() { - let html = PulldownMarkdown.render("# Hello\nWorld"); - assert!(html.contains("

")); - assert!(html.contains("World")); - } - - #[test] - fn tables_rendered() { - let input = "| A | B |\n|---|---|\n| 1 | 2 |"; - let html = PulldownMarkdown.render(input); - assert!(html.contains("")); - } - - #[test] - fn strikethrough() { - let html = PulldownMarkdown.render("~~deleted~~"); - assert!(html.contains("")); - } - - #[test] - fn task_list() { - let html = PulldownMarkdown.render("- [ ] task\n- [x] done"); - assert!(html.contains("checkbox")); - } - - #[test] - fn empty_input() { - assert_eq!(PulldownMarkdown.render(""), ""); - } -} diff --git a/tests/property_tests.proptest-regressions b/tests/property_tests.proptest-regressions deleted file mode 100644 index 07ff2da..0000000 --- a/tests/property_tests.proptest-regressions +++ /dev/null @@ -1,7 +0,0 @@ -# Seeds for failure cases proptest has generated in the past. It is -# automatically read and these particular cases re-run before any -# novel cases are generated. -# -# It is recommended to check this file in to source control so that -# everyone who runs the test benefits from these saved cases. -cc 3af87c54bbb988799deb6c5d4d4a7821893da1be3436e1b14b6ecffb0b13c787 # shrinks to s = "𝔖" diff --git a/tests/property_tests.rs b/tests/property_tests.rs deleted file mode 100644 index f25c356..0000000 --- a/tests/property_tests.rs +++ /dev/null @@ -1,32 +0,0 @@ -use librawssg::util::{match_pattern, relative_prefix, slugify}; -use proptest::prelude::*; - -proptest! { - #[test] - fn slugify_does_not_panic(s in "\\PC*") { - let _ = slugify(&s); - } - - #[test] - fn relative_prefix_never_empty_for_any_depth(depth in 0usize..100) { - let prefix = relative_prefix(depth); - assert!(!prefix.is_empty()); - assert!(prefix.ends_with('/')); - assert_eq!(prefix.matches("../").count(), depth); - } - - #[test] - fn match_pattern_exact_self(path in "[a-zA-Z0-9_/]+\\.md") { - assert!(match_pattern(&path, std::path::Path::new(&path))); - } - - #[test] - fn match_pattern_double_star_matches_everything(path in "[a-zA-Z0-9_/]+\\.md") { - assert!(match_pattern("**", std::path::Path::new(&path))); - } - - #[test] - fn match_pattern_wildcard_segment(path in "blog/[a-z]+\\.md") { - assert!(match_pattern("blog/*.md", std::path::Path::new(&path))); - } -} diff --git a/tests/serve_tests.rs b/tests/serve_tests.rs deleted file mode 100644 index 3567fdb..0000000 --- a/tests/serve_tests.rs +++ /dev/null @@ -1,74 +0,0 @@ -#![cfg(feature = "serve")] -use librawssg::fs::{FileSystem, real::RealFs}; -use librawssg::serve::start_dev_server; -use std::io::{Read, Write}; -use std::net::{TcpListener, TcpStream}; -use std::thread; -use std::time::Duration; -use tempfile::tempdir; - -fn http_get(port: u16, path: &str) -> String { - let mut stream = TcpStream::connect(("127.0.0.1", port)).expect("cannot connect"); - let request = format!("GET {} HTTP/1.0\r\nHost: localhost\r\n\r\n", path); - stream.write_all(request.as_bytes()).unwrap(); - stream.flush().unwrap(); - let mut response = String::new(); - stream.read_to_string(&mut response).unwrap(); - response -} - -fn find_available_port() -> u16 { - TcpListener::bind("127.0.0.1:0") - .expect("failed to bind") - .local_addr() - .unwrap() - .port() -} - -#[test] -fn server_serves_index_html() { - let dir = tempdir().unwrap(); - let index_path = dir.path().join("index.html"); - RealFs.write(&index_path, b"

Hello

").unwrap(); - - let port = find_available_port(); - let dist = dir.path().to_path_buf(); - let server_handle = thread::spawn(move || { - let _ = start_dev_server(&dist, port); - }); - - thread::sleep(Duration::from_millis(500)); - - let response = http_get(port, "/"); - assert!(response.contains("200 OK") || response.contains("HTTP/1.0 200")); - assert!(response.contains("

Hello

")); - - drop(server_handle); -} - -#[test] -fn server_returns_500_for_missing_file() { - let dir = tempdir().unwrap(); - let port = find_available_port(); - let dist = dir.path().to_path_buf(); - let server_handle = thread::spawn(move || { - let _ = start_dev_server(&dist, port); - }); - - let mut response = String::new(); - for _ in 0..10 { - thread::sleep(Duration::from_millis(100)); - if let Ok(res) = std::panic::catch_unwind(|| http_get(port, "/nope.html")) { - response = res; - break; - } - } - - assert!( - response.contains("500"), - "Expected 500 status, got: {}", - response - ); - - drop(server_handle); -} diff --git a/tests/site_builder_tests.rs b/tests/site_builder_tests.rs deleted file mode 100644 index 6753f62..0000000 --- a/tests/site_builder_tests.rs +++ /dev/null @@ -1,148 +0,0 @@ -mod common; -mod common_mocks; - -#[cfg(feature = "tera")] -mod tera_tests { - use crate::common::{MockMarkdownRenderer, MockTemplateRenderer}; - use crate::common_mocks::context_mocks::{MockFeedContextBuilder, MockSitemapContextBuilder}; - use librawssg::SiteBuilder; - use librawssg::types::{ContentTypeDef, RawssgConfig}; - use std::fs; - use tempfile::tempdir; - - fn make_config() -> RawssgConfig { - let mut cfg = RawssgConfig::default(); - cfg.content_types.clear(); - cfg.build.content_dir = "content".into(); - cfg.build.output_dir = "dist".into(); - cfg.build.templates_dir = "templates".into(); - cfg.build.static_dir = "static".into(); - cfg.content_types.push(ContentTypeDef { - name: "blog".into(), - pattern: "blog/*".into(), - template: "post.html".into(), - list_template: Some("blog_list.html".into()), - list_enabled: true, - }); - cfg - } - - #[test] - fn build_simple_site() { - let dir = tempdir().unwrap(); - let content = dir.path().join("content"); - let templates = dir.path().join("templates"); - let output = dir.path().join("dist"); - fs::create_dir(&content).unwrap(); - fs::create_dir(&templates).unwrap(); - - fs::write( - content.join("index.md"), - "---\ntitle: Home\ndesc: Home page\n---\nHello", - ) - .unwrap(); - fs::write(templates.join("base.html"), "{{ page_title }}").unwrap(); - - let mut config = make_config(); - config.build.content_dir = content.to_string_lossy().into(); - config.build.output_dir = output.to_string_lossy().into(); - config.build.templates_dir = templates.to_string_lossy().into(); - - let site = SiteBuilder::new() - .config(config) - .with_template_renderer(Box::new(MockTemplateRenderer::new())) - .with_markdown_renderer(Box::new(MockMarkdownRenderer::identity())) - .with_feed_context_builder(Box::new(MockFeedContextBuilder)) - .with_sitemap_context_builder(Box::new(MockSitemapContextBuilder)) - .build() - .expect("build should succeed"); - - assert_eq!(site.pages().len(), 1); - assert_eq!(site.pages()[0].url, "index.html"); - } - - #[test] - fn draft_skipped_during_build() { - let dir = tempdir().unwrap(); - let content = dir.path().join("content"); - let templates = dir.path().join("templates"); - fs::create_dir(&content).unwrap(); - fs::create_dir(&templates).unwrap(); - - fs::write( - content.join("draft.md"), - "---\ntitle: Draft\ndesc: x\ndraft: true\n---\n...", - ) - .unwrap(); - fs::write(templates.join("base.html"), "base").unwrap(); - - let mut config = make_config(); - config.build.content_dir = content.to_string_lossy().into(); - config.build.output_dir = dir.path().join("dist").to_string_lossy().into(); - config.build.templates_dir = templates.to_string_lossy().into(); - - let site = SiteBuilder::new() - .config(config) - .with_template_renderer(Box::new(MockTemplateRenderer::new())) - .with_markdown_renderer(Box::new(MockMarkdownRenderer::identity())) - .with_feed_context_builder(Box::new(MockFeedContextBuilder)) - .with_sitemap_context_builder(Box::new(MockSitemapContextBuilder)) - .build() - .unwrap(); - assert!(site.pages().is_empty()); - } - - #[test] - fn blog_list_page_generated() { - let dir = tempdir().unwrap(); - let content = dir.path().join("content"); - let templates = dir.path().join("templates"); - fs::create_dir(&content).unwrap(); - fs::create_dir(&templates).unwrap(); - - fs::create_dir(content.join("blog")).unwrap(); - fs::write( - content.join("blog/post1.md"), - "---\ntitle: P1\ndesc: x\ndate: 2025-01-01\n---\nOne", - ) - .unwrap(); - fs::write( - content.join("blog/post2.md"), - "---\ntitle: P2\ndesc: x\ndate: 2025-01-02\n---\nTwo", - ) - .unwrap(); - fs::write(templates.join("base.html"), "{{ page_title }}").unwrap(); - fs::write(templates.join("post.html"), "post").unwrap(); - fs::write(templates.join("blog_list.html"), "list").unwrap(); - - let mut config = make_config(); - config.build.content_dir = content.to_string_lossy().into(); - config.build.output_dir = dir.path().join("dist").to_string_lossy().into(); - config.build.templates_dir = templates.to_string_lossy().into(); - - let site = SiteBuilder::new() - .config(config) - .with_template_renderer(Box::new(MockTemplateRenderer::new())) - .with_markdown_renderer(Box::new(MockMarkdownRenderer::identity())) - .with_feed_context_builder(Box::new(MockFeedContextBuilder)) - .with_sitemap_context_builder(Box::new(MockSitemapContextBuilder)) - .build() - .unwrap(); - - assert_eq!(site.pages().len(), 3); - let list = site.pages().iter().find(|p| p.is_list).unwrap(); - assert_eq!(list.url, "blog/index.html"); - assert_eq!(list.list_items.as_ref().unwrap().len(), 2); - } -} - -#[test] -fn builder_new_works() { - let _builder = librawssg::SiteBuilder::new(); -} - -#[test] -fn builder_load_config_file_not_found() { - let result = librawssg::SiteBuilder::new().load_config("nonexistent.yaml"); - assert!(result.is_err()); -} diff --git a/tests/site_page_tests.rs b/tests/site_page_tests.rs deleted file mode 100644 index 6e07e4f..0000000 --- a/tests/site_page_tests.rs +++ /dev/null @@ -1,107 +0,0 @@ -mod common; -use common::{MockFs, MockMarkdownRenderer}; -use librawssg::error::RawssgError; -use librawssg::site::page::build_page_context; -use std::path::Path; - -#[test] -fn successful_page_context() { - let mut fs = MockFs::new(); - fs.add_file( - "/content/about.md", - "---\ntitle: About\ndesc: About page\n---\nHello", - ); - let renderer = MockMarkdownRenderer::identity(); - let ctx = build_page_context( - &fs, - &renderer, - Path::new("/content/about.md"), - Path::new("/content"), - ) - .unwrap() - .expect("should return some"); - assert_eq!(ctx.frontmatter.title, "About"); - assert_eq!(ctx.content_html, "Hello"); - assert_eq!(ctx.url, "about.html"); - assert_eq!(ctx.depth, 0); - assert!(ctx.pub_date.is_none()); -} - -#[test] -fn draft_page_returns_none() { - let mut fs = MockFs::new(); - fs.add_file( - "/content/draft.md", - "---\ntitle: Draft\ndesc: x\ndraft: true\n---\nContent", - ); - let renderer = MockMarkdownRenderer::identity(); - let result = build_page_context( - &fs, - &renderer, - Path::new("/content/draft.md"), - Path::new("/content"), - ) - .unwrap(); - assert!(result.is_none()); -} - -#[test] -fn date_formatting() { - let mut fs = MockFs::new(); - fs.add_file( - "/content/post.md", - "---\ntitle: Post\ndesc: x\ndate: 2025-01-01\n---\nBody", - ); - let renderer = MockMarkdownRenderer::identity(); - let ctx = build_page_context( - &fs, - &renderer, - Path::new("/content/post.md"), - Path::new("/content"), - ) - .unwrap() - .unwrap(); - assert_eq!( - ctx.pub_date, - Some("Wed, 01 Jan 2025 00:00:00 +0000".to_string()) - ); -} - -#[test] -fn nested_depth_calculation() { - let mut fs = MockFs::new(); - fs.add_file( - "/content/blog/post.md", - "---\ntitle: Nested\ndesc: x\n---\n", - ); - let renderer = MockMarkdownRenderer::identity(); - let ctx = build_page_context( - &fs, - &renderer, - Path::new("/content/blog/post.md"), - Path::new("/content"), - ) - .unwrap() - .unwrap(); - assert_eq!(ctx.url, "blog/post.html"); - assert_eq!(ctx.depth, 1); -} - -#[test] -fn read_error_propagates() { - let mut fs = MockFs::new(); - fs.add_file("/content/fault.md", "anything"); - fs.read_error = Some(Path::new("/content/fault.md").to_path_buf()); - let renderer = MockMarkdownRenderer::identity(); - let err = build_page_context( - &fs, - &renderer, - Path::new("/content/fault.md"), - Path::new("/content"), - ) - .unwrap_err(); - match err { - RawssgError::Io(_) => {} - _ => panic!("expected Io error"), - } -} diff --git a/tests/util_property_tests.rs b/tests/util_property_tests.rs deleted file mode 100644 index 928d627..0000000 --- a/tests/util_property_tests.rs +++ /dev/null @@ -1,19 +0,0 @@ -use librawssg::util::slugify; -use proptest::prelude::*; - -proptest! { - #[test] - fn slugify_no_consecutive_dashes(s in "\\PC*") { - let result = slugify(&s); - assert!(!result.contains("--")); - } - - #[test] - fn slugify_no_leading_or_trailing_dash(s in "\\PC*") { - let result = slugify(&s); - if !result.is_empty() { - assert!(!result.starts_with('-')); - assert!(!result.ends_with('-')); - } - } -} diff --git a/tests/util_tests.rs b/tests/util_tests.rs deleted file mode 100644 index 7e3d1c1..0000000 --- a/tests/util_tests.rs +++ /dev/null @@ -1,81 +0,0 @@ -use librawssg::error::RawssgError; -use librawssg::fs::real::RealFs; -use librawssg::util::{match_pattern, relative_prefix, safe_path, slugify}; -use std::path::Path; -use tempfile::tempdir; - -#[test] -fn safe_path_inside_base() { - let dir = tempdir().unwrap(); - let base = dir.path().canonicalize().unwrap(); - let candidate = base.join("file.txt"); - std::fs::write(&candidate, "").unwrap(); - let result = safe_path(&RealFs, &base, &candidate).unwrap(); - assert_eq!(result, candidate.canonicalize().unwrap()); -} - -#[test] -fn safe_path_outside_base() { - let dir = tempdir().unwrap(); - let base = dir.path().join("sub"); - std::fs::create_dir(&base).unwrap(); - let base = base.canonicalize().unwrap(); - - let outside = dir.path().join("outside.txt"); - std::fs::write(&outside, "test").unwrap(); - let outside = outside.canonicalize().unwrap(); - - match safe_path(&RealFs, &base, &outside) { - Err(RawssgError::PathTraversal(_)) => {} - _ => panic!("expected PathTraversal error"), - } -} - -#[test] -fn slugify_simple() { - assert_eq!(slugify("Hello World"), "hello-world"); -} - -#[test] -fn slugify_special_chars() { - assert_eq!(slugify("Rust & SSG!"), "rust-ssg"); -} - -#[test] -fn slugify_multiple_dashes() { - assert_eq!(slugify("A--B"), "a-b"); -} - -#[test] -fn relative_prefix_depths() { - assert_eq!(relative_prefix(0), "./"); - assert_eq!(relative_prefix(1), "../"); - assert_eq!(relative_prefix(3), "../../../"); -} - -#[test] -fn match_pattern_exact() { - assert!(match_pattern("blog/post.md", Path::new("blog/post.md"))); -} - -#[test] -fn match_pattern_wildcard_single() { - assert!(match_pattern("blog/*.md", Path::new("blog/hello.md"))); - assert!(!match_pattern("blog/*.md", Path::new("blog/sub/hello.md"))); -} - -#[test] -fn match_pattern_double_wildcard() { - assert!(match_pattern("blog/**", Path::new("blog/a/b/c.md"))); - assert!(match_pattern("blog/**", Path::new("blog/file.md"))); -} - -#[test] -fn match_pattern_too_long() { - assert!(!match_pattern("a/b/c", Path::new("a/b"))); -} - -#[test] -fn match_pattern_empty_pattern() { - assert!(!match_pattern("", Path::new("anything"))); -} diff --git a/tests/watcher_tests.rs b/tests/watcher_tests.rs deleted file mode 100644 index 0dfc83b..0000000 --- a/tests/watcher_tests.rs +++ /dev/null @@ -1,20 +0,0 @@ -#![cfg(feature = "serve")] -use librawssg::serve::watch_dirs; -use std::sync::mpsc; -use std::time::Duration; -use tempfile::tempdir; - -#[test] -fn watcher_triggers_on_file_create() { - let dir = tempdir().unwrap(); - let (tx, rx) = mpsc::channel(); - let on_change = move || { - tx.send(()).ok(); - }; - let _watcher = watch_dirs(&[dir.path().to_path_buf()], on_change).expect("watcher start"); - - std::fs::write(dir.path().join("new.md"), b"content").unwrap(); - - let received = rx.recv_timeout(Duration::from_secs(3)); - assert!(received.is_ok(), "watcher should have triggered"); -} From 19f1bdc0a1eef51cf8eff0156fd128e421ac55a8 Mon Sep 17 00:00:00 2001 From: mroczect Date: Tue, 8 Sep 2026 00:40:59 +0700 Subject: [PATCH 02/48] refactor: split into workspace crates (#25) * chore: update Cargo.lock for workspace split Remove dependency entries for the old single-crate project and retain only entries for the three new workspace crates. This reflects the transition to a workspace layout. * refactor: convert to Cargo workspace with three crates Replace the single `librawssg` package with a workspace containing `librawssg_compiler`, `librawssg_handler`, and `librawssg_templates`. Add strict workspace-level lints for clippy and rustc. Remove all old dependencies and features. * refactor: remove src/config/loader.rs Delete the old YAML config loader implementation. Configuration handling will be reimplemented in the new workspace crates. * refactor: remove src/config/mod.rs Delete the old configuration module. Configuration functionality will be split across the new workspace crates. * refactor: remove src/error.rs Delete the old error type definitions. Error types will be redefined in the new workspace crates. * refactor: remove src/frontmatter.rs Delete the frontmatter parser. This logic will be moved to the appropriate new crate. * refactor: remove src/fs/mod.rs Delete the filesystem abstraction trait. Filesystem operations will be handled by the new crates. * refactor: remove src/fs/real.rs Delete the real filesystem implementation. It will be replaced by functionality in the new crates. * refactor: remove src/lib.rs Delete the old crate root. The library is now a workspace of multiple crates. * refactor: remove src/markdown.rs Delete the Markdown rendering trait and pulldown implementation. Markdown handling will be moved to the compiler crate. * refactor: remove src/serve/mod.rs Delete the development server module. Serving functionality will be reimplemented in the handler crate. * refactor: remove src/serve/watcher.rs Delete the file watcher module. Watch functionality will be part of the handler crate. * refactor: remove src/site/builders/mod.rs Delete the builders module declaration. Site building logic will be relocated. * refactor: remove src/site/builders/site.rs Delete the Site builder implementation. This core logic will be moved to the compiler crate. * refactor: remove src/site/builders/site_builder.rs Delete the SiteBuilder implementation. It will be reimplemented in the compiler crate. * refactor: remove src/site/context.rs Delete the context builder traits and Tera implementations. Context handling moves to the templates crate. * refactor: remove src/site/feed.rs Delete the RSS feed generation module. Feed generation will be part of the compiler crate. * refactor: remove src/site/mod.rs Delete the main site module that defined traits and handlers. These will be split across new crates. * refactor: remove src/site/page.rs Delete the page building logic. Page processing will be implemented in the compiler crate. * refactor: remove src/site/sitemap.rs Delete the sitemap generation module. This functionality moves to the compiler crate. * refactor: remove src/types.rs Delete the old configuration and page types. New type definitions will live in the appropriate workspace crates. * refactor: remove src/util.rs Delete utility functions including safe_path, slugify, and pattern matching. These will be reimplemented in the new crates. * feat: add librawssg_compiler crate manifest Create Cargo.toml for the new compiler crate, using workspace lints and no dependencies. * feat: add librawssg_compiler placeholder lib Add a placeholder library file with a simple add function and test to bootstrap the compiler crate. * feat: add librawssg_handler crate manifest Create Cargo.toml for the new handler crate, using workspace lints and no dependencies. * feat: add librawssg_handler placeholder lib Add a placeholder library file with a simple add function and test to bootstrap the handler crate. * feat: add librawssg_templates crate manifest Create Cargo.toml for the new templates crate, using workspace lints and no dependencies. * feat: add librawssg_templates placeholder lib Add a placeholder library file with a simple add function and test to bootstrap the templates crate. --- Cargo.lock | 1187 +---------------------------- Cargo.toml | 138 +++- librawssg_compiler/Cargo.toml | 9 + librawssg_compiler/src/lib.rs | 14 + librawssg_handler/Cargo.toml | 9 + librawssg_handler/src/lib.rs | 14 + librawssg_templates/Cargo.toml | 9 + librawssg_templates/src/lib.rs | 14 + src/config/loader.rs | 32 - src/config/mod.rs | 14 - src/error.rs | 55 -- src/frontmatter.rs | 44 -- src/fs/mod.rs | 20 - src/fs/real.rs | 84 -- src/lib.rs | 17 - src/markdown.rs | 21 - src/serve/mod.rs | 98 --- src/serve/watcher.rs | 41 - src/site/builders/mod.rs | 2 - src/site/builders/site.rs | 314 -------- src/site/builders/site_builder.rs | 242 ------ src/site/context.rs | 59 -- src/site/feed.rs | 21 - src/site/mod.rs | 116 --- src/site/page.rs | 58 -- src/site/sitemap.rs | 21 - src/types.rs | 246 ------ src/util.rs | 179 ----- 28 files changed, 173 insertions(+), 2905 deletions(-) create mode 100644 librawssg_compiler/Cargo.toml create mode 100644 librawssg_compiler/src/lib.rs create mode 100644 librawssg_handler/Cargo.toml create mode 100644 librawssg_handler/src/lib.rs create mode 100644 librawssg_templates/Cargo.toml create mode 100644 librawssg_templates/src/lib.rs delete mode 100644 src/config/loader.rs delete mode 100644 src/config/mod.rs delete mode 100644 src/error.rs delete mode 100644 src/frontmatter.rs delete mode 100644 src/fs/mod.rs delete mode 100644 src/fs/real.rs delete mode 100644 src/lib.rs delete mode 100644 src/markdown.rs delete mode 100644 src/serve/mod.rs delete mode 100644 src/serve/watcher.rs delete mode 100644 src/site/builders/mod.rs delete mode 100644 src/site/builders/site.rs delete mode 100644 src/site/builders/site_builder.rs delete mode 100644 src/site/context.rs delete mode 100644 src/site/feed.rs delete mode 100644 src/site/mod.rs delete mode 100644 src/site/page.rs delete mode 100644 src/site/sitemap.rs delete mode 100644 src/types.rs delete mode 100644 src/util.rs diff --git a/Cargo.lock b/Cargo.lock index 75d8594..cb330d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3,1188 +3,13 @@ version = 4 [[package]] -name = "addr2line" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" -dependencies = [ - "gimli", -] +name = "librawssg_compiler" +version = "0.1.0" [[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +name = "librawssg_handler" +version = "0.1.0" [[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 = "ascii" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" - -[[package]] -name = "autocfg" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" - -[[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 = "bit-set" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" -dependencies = [ - "bit-vec", -] - -[[package]] -name = "bit-vec" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" - -[[package]] -name = "bitflags" -version = "2.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" - -[[package]] -name = "bumpalo" -version = "3.20.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" - -[[package]] -name = "cc" -version = "1.2.67" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" -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.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "serde", - "wasm-bindgen", - "windows-link", -] - -[[package]] -name = "chunked_transfer" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[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 = "fastrand" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" - -[[package]] -name = "find-msvc-tools" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[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-core" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" - -[[package]] -name = "futures-task" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" - -[[package]] -name = "futures-util" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" -dependencies = [ - "futures-core", - "futures-task", - "pin-project-lite", - "slab", -] - -[[package]] -name = "getopts" -version = "0.2.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" -dependencies = [ - "unicode-width 0.2.2", -] - -[[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 5.3.0", - "wasip2", -] - -[[package]] -name = "getrandom" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" -dependencies = [ - "cfg-if", - "libc", - "r-efi 6.0.0", -] - -[[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.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" - -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" - -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - -[[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 = "indexmap" -version = "2.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" -dependencies = [ - "equivalent", - "hashbrown", -] - -[[package]] -name = "inotify" -version = "0.11.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "153be1941a183ec9ccd095ddbe17a8b8d435ef6c76e9e02451b933c3999af2c8" -dependencies = [ - "bitflags", - "inotify-sys", - "libc", -] - -[[package]] -name = "inotify-sys" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" -dependencies = [ - "libc", -] - -[[package]] -name = "is_ci" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "js-sys" -version = "0.3.103" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" -dependencies = [ - "cfg-if", - "futures-util", - "wasm-bindgen", -] - -[[package]] -name = "kqueue" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" -dependencies = [ - "kqueue-sys", - "libc", -] - -[[package]] -name = "kqueue-sys" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" -dependencies = [ - "bitflags", - "libc", -] - -[[package]] -name = "libc" -version = "0.2.189" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" - -[[package]] -name = "librawssg" -version = "0.5.0" -dependencies = [ - "chrono", - "glob", - "miette", - "notify", - "proptest", - "pulldown-cmark", - "serde", - "serde_yaml", - "tempfile", - "tera", - "thiserror", - "tiny_http", - "tracing", - "walkdir", -] - -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - -[[package]] -name = "log" -version = "0.4.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" - -[[package]] -name = "memchr" -version = "2.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" - -[[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 2.0.119", -] - -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", -] - -[[package]] -name = "mio" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" -dependencies = [ - "libc", - "log", - "wasi", - "windows-sys 0.61.2", -] - -[[package]] -name = "notify" -version = "8.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" -dependencies = [ - "bitflags", - "fsevent-sys", - "inotify", - "kqueue", - "libc", - "log", - "mio", - "notify-types", - "walkdir", - "windows-sys 0.60.2", -] - -[[package]] -name = "notify-types" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" -dependencies = [ - "bitflags", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[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.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "owo-colors" -version = "4.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "proc-macro2" -version = "1.0.107" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "proptest" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" -dependencies = [ - "bit-set", - "bit-vec", - "bitflags", - "num-traits", - "rand", - "rand_chacha", - "rand_xorshift", - "regex-syntax", - "rusty-fork", - "tempfile", - "unarray", -] - -[[package]] -name = "pulldown-cmark" -version = "0.13.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" -dependencies = [ - "bitflags", - "getopts", - "memchr", - "pulldown-cmark-escape", - "unicase", -] - -[[package]] -name = "pulldown-cmark-escape" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" - -[[package]] -name = "quick-error" -version = "1.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" - -[[package]] -name = "quote" -version = "1.0.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" -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 = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - -[[package]] -name = "rand" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" -dependencies = [ - "rand_chacha", - "rand_core", -] - -[[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", -] - -[[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 = "rand_xorshift" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" -dependencies = [ - "rand_core", -] - -[[package]] -name = "regex-syntax" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" - -[[package]] -name = "rustc-demangle" -version = "0.1.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" - -[[package]] -name = "rustix" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustversion" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" - -[[package]] -name = "rusty-fork" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" -dependencies = [ - "fnv", - "quick-error", - "tempfile", - "wait-timeout", -] - -[[package]] -name = "ryu" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" - -[[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 = "serde" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "serde_yaml" -version = "0.9.34+deprecated" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" -dependencies = [ - "indexmap", - "itoa", - "ryu", - "serde", - "unsafe-libyaml", -] - -[[package]] -name = "shlex" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[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 = "syn" -version = "2.0.119" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "3.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "tempfile" -version = "3.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand", - "getrandom 0.4.3", - "once_cell", - "rustix", - "windows-sys 0.61.2", -] - -[[package]] -name = "tera" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "511f07fd91a70e92efbe4793d111aaa9035f8474dd157aaa1e31e7c27f5051da" -dependencies = [ - "serde", -] - -[[package]] -name = "terminal_size" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" -dependencies = [ - "rustix", - "windows-sys 0.61.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 = "2.0.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "tiny_http" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" -dependencies = [ - "ascii", - "chunked_transfer", - "httpdate", - "log", -] - -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "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 2.0.119", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", -] - -[[package]] -name = "unarray" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" - -[[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.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "unicode-linebreak" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" - -[[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 = "unsafe-libyaml" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" - -[[package]] -name = "wait-timeout" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" -dependencies = [ - "libc", -] - -[[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.4+wasi-0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.126" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.126" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.126" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn 2.0.119", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.126" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" -dependencies = [ - "unicode-ident", -] - -[[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 2.0.119", -] - -[[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 2.0.119", -] - -[[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.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets", -] - -[[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.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] - -[[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.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - -[[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.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - -[[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.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - -[[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.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - -[[package]] -name = "zerocopy" -version = "0.8.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] +name = "librawssg_templates" +version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 722a3df..bb365b8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,42 +1,100 @@ -[package] -name = "librawssg" -version = "0.5.0" -edition = "2024" -description = "Engine-agnostic, safety-first kernel for building static site generators in Rust" -license = "MIT" -repository = "https://github.com/mroczect/librawssg" -homepage = "https://github.com/mroczect/librawssg" -documentation = "https://docs.rs/librawssg" -readme = "README.md" -keywords = ["static-site", "ssg", "libary", "kernel", "rawssg"] -categories = ["web-programming", "template-engine", "development-tools"] +[workspace] +resolver = "3" +members = ["librawssg_compiler","librawssg_handler", "librawssg_templates"] -[dependencies] -serde = { version = "1", features = ["derive"] } -serde_yaml = "0.9" -thiserror = "2" -miette = { version = "7", features = ["fancy"] } -tracing = "0.1" -walkdir = "2" -chrono = { version = "0.4", features = ["serde"] } -glob = "0.3" +[workspace.lints.clippy] +all = { level = "deny", priority = -1 } +alloc_instead_of_core = "deny" +allow_attributes = "allow" +allow_attributes_without_reason = "allow" +arithmetic_side_effects = "deny" +cargo = { level = "deny", priority = -1 } +complexity = { level = "deny", priority = -1 } +correctness = { level = "deny", priority = -1 } +doc_lazy_continuation = "allow" +doc_markdown = "allow" +empty_docs = "allow" +expect_used = "deny" +implicit_hasher = "allow" +indexing_slicing = "deny" +map_err_ignore = "deny" +match_same_arms = "allow" +missing_docs_in_private_items = "allow" +missing_errors_doc = "allow" +missing_panics_doc = "allow" +missing_safety_doc = "allow" +module_name_repetitions = "allow" +needless_doctest_main = "allow" +needless_return = "allow" +nursery = { level = "deny", priority = -1 } +panic = "deny" +pedantic = { level = "deny", priority = -1 } +perf = { level = "deny", priority = -1 } +std_instead_of_alloc = "deny" +std_instead_of_core = "deny" +style = { level = "deny", priority = -1 } +suspicious = { level = "deny", priority = -1 } +uninlined_format_args = "allow" +unwrap_used = "deny" +wildcard_enum_match_arm = "deny" -tera = { version = "2", optional = true } -pulldown-cmark = { version = "0.13", optional = true } - -# Serve feature -tiny_http = { version = "0.12", optional = true } -notify = { version = "8", optional = true } - -[features] -default = [] -tera = ["dep:tera"] -pulldown = ["dep:pulldown-cmark"] -serve = ["dep:tiny_http", "dep:notify"] - -[dev-dependencies] -pulldown-cmark = "0.13" -tempfile = "3.27.0" -tera = "2" - -proptest = "1.11.0" \ No newline at end of file +[workspace.lints.rust] +deprecated = "deny" +elided_lifetimes_in_paths = "deny" +explicit_outlives_requirements = "deny" +future_incompatible = { level = "deny", priority = -1 } +invalid_reference_casting = "deny" +macro_use_extern_crate = "deny" +missing_copy_implementations = "deny" +missing_debug_implementations = "deny" +missing_docs = "allow" +no_mangle_generic_items = "deny" +non_ascii_idents = "deny" +non_camel_case_types = "deny" +non_snake_case = "deny" +non_upper_case_globals = "deny" +noop_method_call = "deny" +overlapping_range_endpoints = "deny" +private_bounds = "deny" +private_interfaces = "deny" +redundant_lifetimes = "deny" +renamed_and_removed_lints = "deny" +rust_2018_idioms = { level = "deny", priority = -1 } +rust_2021_compatibility = { level = "deny", priority = -1 } +rust_2024_compatibility = { level = "deny", priority = -1 } +single_use_lifetimes = "deny" +trivial_bounds = "deny" +trivial_casts = "deny" +trivial_numeric_casts = "deny" +unexpected_cfgs = "deny" +uninhabited_static = "deny" +unit_bindings = "deny" +unknown_lints = "deny" +unnameable_types = "deny" +unreachable_code = "deny" +unreachable_patterns = "deny" +unreachable_pub = "deny" +unsafe_code = "forbid" +unsafe_op_in_unsafe_fn = "deny" +unused = { level = "deny", priority = -1 } +unused_allocation = "deny" +unused_assignments = "deny" +unused_braces = "deny" +unused_comparisons = "deny" +unused_crate_dependencies = "deny" +unused_doc_comments = "allow" +unused_extern_crates = "deny" +unused_features = "deny" +unused_imports = "deny" +unused_labels = "deny" +unused_lifetimes = "deny" +unused_macro_rules = "deny" +unused_macros = "deny" +unused_must_use = "deny" +unused_mut = "deny" +unused_parens = "deny" +unused_qualifications = "deny" +unused_results = "deny" +unused_unsafe = "deny" +unused_variables = "deny" +warnings = "deny" diff --git a/librawssg_compiler/Cargo.toml b/librawssg_compiler/Cargo.toml new file mode 100644 index 0000000..d31f713 --- /dev/null +++ b/librawssg_compiler/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "librawssg_compiler" +version = "0.1.0" +edition = "2024" + +[dependencies] + +[lints] +workspace = true diff --git a/librawssg_compiler/src/lib.rs b/librawssg_compiler/src/lib.rs new file mode 100644 index 0000000..b93cf3f --- /dev/null +++ b/librawssg_compiler/src/lib.rs @@ -0,0 +1,14 @@ +pub fn add(left: u64, right: u64) -> u64 { + left + right +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_works() { + let result = add(2, 2); + assert_eq!(result, 4); + } +} diff --git a/librawssg_handler/Cargo.toml b/librawssg_handler/Cargo.toml new file mode 100644 index 0000000..ca51fb3 --- /dev/null +++ b/librawssg_handler/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "librawssg_handler" +version = "0.1.0" +edition = "2024" + +[dependencies] + +[lints] +workspace = true diff --git a/librawssg_handler/src/lib.rs b/librawssg_handler/src/lib.rs new file mode 100644 index 0000000..b93cf3f --- /dev/null +++ b/librawssg_handler/src/lib.rs @@ -0,0 +1,14 @@ +pub fn add(left: u64, right: u64) -> u64 { + left + right +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_works() { + let result = add(2, 2); + assert_eq!(result, 4); + } +} diff --git a/librawssg_templates/Cargo.toml b/librawssg_templates/Cargo.toml new file mode 100644 index 0000000..6900f48 --- /dev/null +++ b/librawssg_templates/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "librawssg_templates" +version = "0.1.0" +edition = "2024" + +[dependencies] + +[lints] +workspace = true diff --git a/librawssg_templates/src/lib.rs b/librawssg_templates/src/lib.rs new file mode 100644 index 0000000..b93cf3f --- /dev/null +++ b/librawssg_templates/src/lib.rs @@ -0,0 +1,14 @@ +pub fn add(left: u64, right: u64) -> u64 { + left + right +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_works() { + let result = add(2, 2); + assert_eq!(result, 4); + } +} diff --git a/src/config/loader.rs b/src/config/loader.rs deleted file mode 100644 index 86dc6e3..0000000 --- a/src/config/loader.rs +++ /dev/null @@ -1,32 +0,0 @@ -use super::ConfigLoader; -use crate::error::RawssgError; -use crate::types::RawssgConfig; -use std::path::Path; - -pub struct YamlConfigLoader + Send + Sync> { - path: P, -} - -impl + Send + Sync> YamlConfigLoader

{ - pub fn new(path: P) -> Self { - Self { path } - } -} - -impl + Send + Sync> ConfigLoader for YamlConfigLoader

{ - #[tracing::instrument(skip(self))] - fn load(&self) -> Result { - let content = std::fs::read_to_string(self.path.as_ref()) - .map_err(|e| RawssgError::Config(format!("cannot read config: {}", e)))?; - serde_yaml::from_str(&content) - .map_err(|e| RawssgError::Config(format!("invalid config YAML: {}", e))) - } -} - -pub struct DefaultConfig; - -impl ConfigLoader for DefaultConfig { - fn load(&self) -> Result { - Ok(RawssgConfig::default()) - } -} diff --git a/src/config/mod.rs b/src/config/mod.rs deleted file mode 100644 index 95728ae..0000000 --- a/src/config/mod.rs +++ /dev/null @@ -1,14 +0,0 @@ -pub mod loader; - -use crate::error::RawssgError; -use crate::types::RawssgConfig; - -pub trait ConfigLoader: Send + Sync { - fn load(&self) -> Result; - fn load_or_default(&self) -> RawssgConfig { - self.load().unwrap_or_else(|e| { - tracing::error!("Failed to load config, using defaults: {}", e); - RawssgConfig::default() - }) - } -} diff --git a/src/error.rs b/src/error.rs deleted file mode 100644 index dd08fb0..0000000 --- a/src/error.rs +++ /dev/null @@ -1,55 +0,0 @@ -use miette::Diagnostic; -use thiserror::Error; - -#[derive(Error, Debug, Diagnostic)] -pub enum RawssgError { - #[error("I/O error")] - #[diagnostic(code(rawssg::io))] - Io(#[from] std::io::Error), - - #[error("Configuration error: {0}")] - #[diagnostic(code(rawssg::config))] - Config(String), - - #[error("Failed to parse frontmatter in {path}")] - #[diagnostic( - code(rawssg::frontmatter), - help("Check the YAML frontmatter syntax and ensure the file starts with '---'") - )] - Frontmatter { - path: std::path::PathBuf, - #[source] - source: Box, - }, - - #[error("Template rendering error: {0}")] - #[diagnostic(code(rawssg::template))] - Template(String), - - #[error("Path traversal attempt detected: {0}")] - #[diagnostic( - code(rawssg::path_traversal), - help("All file paths must stay within the project directory") - )] - PathTraversal(String), - - #[error("Missing configuration key: {0}")] - #[diagnostic(code(rawssg::missing_config))] - MissingConfig(String), - - #[error("Markdown processing error: {0}")] - #[diagnostic(code(rawssg::markdown))] - Markdown(String), - - #[error("Site generation error: {0}")] - #[diagnostic(code(rawssg::site))] - SiteGeneration(String), - - #[error("Resource not found: {0}")] - #[diagnostic(code(rawssg::not_found))] - NotFound(String), - - #[error("Internal error: {0}")] - #[diagnostic(code(rawssg::internal))] - Internal(String), -} diff --git a/src/frontmatter.rs b/src/frontmatter.rs deleted file mode 100644 index f4129ef..0000000 --- a/src/frontmatter.rs +++ /dev/null @@ -1,44 +0,0 @@ -use crate::error::RawssgError; -use crate::markdown::MarkdownRenderer; -use crate::types::PageFrontMatter; -use std::path::Path; - -#[tracing::instrument(skip(raw, renderer))] -pub fn parse_frontmatter_and_render( - raw: &str, - path: &Path, - renderer: &dyn MarkdownRenderer, -) -> Result<(PageFrontMatter, String), RawssgError> { - let trimmed = raw.trim_start(); - if !trimmed.starts_with("---") { - return Err(RawssgError::Frontmatter { - path: path.to_path_buf(), - source: "missing opening '---'".into(), - }); - } - let without_first = trimmed.trim_start_matches("---").trim_start(); - let end = without_first - .find("\n---") - .or_else(|| without_first.find("\r\n---")); - let end = match end { - Some(pos) => pos, - None => { - return Err(RawssgError::Frontmatter { - path: path.to_path_buf(), - source: "missing closing '---'".into(), - }); - } - }; - let yaml_str = &without_first[..end]; - let markdown_str = without_first[end..] - .trim_start_matches("\n---") - .trim_start_matches("\r\n---") - .trim(); - let fm: PageFrontMatter = - serde_yaml::from_str(yaml_str).map_err(|e| RawssgError::Frontmatter { - path: path.to_path_buf(), - source: Box::new(e), - })?; - let html = renderer.render(markdown_str); - Ok((fm, html)) -} diff --git a/src/fs/mod.rs b/src/fs/mod.rs deleted file mode 100644 index bad9b8a..0000000 --- a/src/fs/mod.rs +++ /dev/null @@ -1,20 +0,0 @@ -use std::io; -use std::path::{Path, PathBuf}; - -pub trait FileSystem: Send + Sync { - fn read_to_string(&self, path: &Path) -> io::Result; - fn read_bytes(&self, path: &Path) -> io::Result>; - fn write(&self, path: &Path, content: &[u8]) -> io::Result<()>; - fn create_dir_all(&self, path: &Path) -> io::Result<()>; - fn remove_dir_all(&self, path: &Path) -> io::Result<()>; - fn exists(&self, path: &Path) -> bool; - fn is_dir(&self, path: &Path) -> bool; - fn is_file(&self, path: &Path) -> bool; - fn read_dir(&self, path: &Path) -> io::Result>; - fn copy_file(&self, from: &Path, to: &Path) -> io::Result; - fn walk_dir(&self, root: &Path) -> io::Result>; - fn canonicalize(&self, path: &Path) -> io::Result; - fn rename(&self, from: &Path, to: &Path) -> io::Result<()>; -} - -pub mod real; diff --git a/src/fs/real.rs b/src/fs/real.rs deleted file mode 100644 index 120798c..0000000 --- a/src/fs/real.rs +++ /dev/null @@ -1,84 +0,0 @@ -use super::FileSystem; -use std::fs; -use std::io; -use std::path::{Path, PathBuf}; -use walkdir::WalkDir; - -pub struct RealFs; - -impl FileSystem for RealFs { - #[tracing::instrument(skip(self))] - fn read_to_string(&self, path: &Path) -> io::Result { - fs::read_to_string(path) - } - - #[tracing::instrument(skip(self))] - fn read_bytes(&self, path: &Path) -> io::Result> { - fs::read(path) - } - - #[tracing::instrument(skip(self, content))] - fn write(&self, path: &Path, content: &[u8]) -> io::Result<()> { - if let Some(parent) = path.parent() { - self.create_dir_all(parent)?; - } - fs::write(path, content) - } - - #[tracing::instrument(skip(self))] - fn create_dir_all(&self, path: &Path) -> io::Result<()> { - fs::create_dir_all(path) - } - - #[tracing::instrument(skip(self))] - fn remove_dir_all(&self, path: &Path) -> io::Result<()> { - fs::remove_dir_all(path) - } - - #[tracing::instrument(skip(self))] - fn exists(&self, path: &Path) -> bool { - path.exists() - } - - #[tracing::instrument(skip(self))] - fn is_dir(&self, path: &Path) -> bool { - path.is_dir() - } - - #[tracing::instrument(skip(self))] - fn is_file(&self, path: &Path) -> bool { - path.is_file() - } - - #[tracing::instrument(skip(self))] - fn read_dir(&self, path: &Path) -> io::Result> { - let mut entries = Vec::new(); - for entry in fs::read_dir(path)? { - entries.push(entry?.path()); - } - Ok(entries) - } - - #[tracing::instrument(skip(self))] - fn copy_file(&self, from: &Path, to: &Path) -> io::Result { - fs::copy(from, to) - } - - #[tracing::instrument(skip(self))] - fn walk_dir(&self, root: &Path) -> io::Result> { - let mut files = Vec::new(); - for entry in WalkDir::new(root) { - let entry = entry?; - if entry.file_type().is_file() { - files.push(entry.into_path()); - } - } - Ok(files) - } - fn canonicalize(&self, path: &Path) -> io::Result { - path.canonicalize() - } - fn rename(&self, from: &Path, to: &Path) -> io::Result<()> { - std::fs::rename(from, to) - } -} diff --git a/src/lib.rs b/src/lib.rs deleted file mode 100644 index 13849e4..0000000 --- a/src/lib.rs +++ /dev/null @@ -1,17 +0,0 @@ -pub mod config; -pub mod error; -pub mod frontmatter; -pub mod fs; -pub mod markdown; -pub mod site; -pub mod types; -pub mod util; - -#[cfg(feature = "serve")] -pub mod serve; - -pub use error::RawssgError; -pub use site::TemplateRenderer; -pub use site::builders::site::Site; -pub use site::builders::site_builder::SiteBuilder; -pub use types::RawssgConfig; diff --git a/src/markdown.rs b/src/markdown.rs deleted file mode 100644 index 024f01b..0000000 --- a/src/markdown.rs +++ /dev/null @@ -1,21 +0,0 @@ -pub trait MarkdownRenderer: Send + Sync { - fn render(&self, markdown: &str) -> String; -} - -#[cfg(feature = "pulldown")] -pub struct PulldownMarkdown; - -#[cfg(feature = "pulldown")] -impl MarkdownRenderer for PulldownMarkdown { - fn render(&self, md: &str) -> String { - use pulldown_cmark::{Options, Parser, html}; - let mut options = Options::empty(); - options.insert(Options::ENABLE_TABLES); - options.insert(Options::ENABLE_STRIKETHROUGH); - options.insert(Options::ENABLE_TASKLISTS); - let parser = Parser::new_ext(md, options); - let mut html_out = String::new(); - html::push_html(&mut html_out, parser); - html_out - } -} diff --git a/src/serve/mod.rs b/src/serve/mod.rs deleted file mode 100644 index 19468b1..0000000 --- a/src/serve/mod.rs +++ /dev/null @@ -1,98 +0,0 @@ -#[cfg(feature = "serve")] -mod server { - use crate::error::RawssgError; - use crate::fs::FileSystem; - use crate::fs::real::RealFs; - use crate::util::safe_path; - use std::path::{Path, PathBuf}; - use std::thread; - use tiny_http::{Header, Response, Server}; - - fn mime_type(path: &Path) -> &'static str { - match path.extension().and_then(|e| e.to_str()) { - Some("html") => "text/html; charset=utf-8", - Some("css") => "text/css", - Some("js") => "application/javascript", - Some("json") => "application/json", - Some("xml") => "application/xml", - Some("svg") => "image/svg+xml", - Some("png") => "image/png", - Some("jpg") | Some("jpeg") => "image/jpeg", - Some("gif") => "image/gif", - Some("ico") => "image/x-icon", - Some("woff") => "font/woff", - Some("woff2") => "font/woff2", - Some("ttf") => "font/ttf", - Some("txt") | Some("md") | Some("yaml") | Some("yml") | Some("log") => { - "text/plain; charset=utf-8" - } - _ => "application/octet-stream", - } - } - - fn handle_request(request: tiny_http::Request, dist_path: &Path) -> Result<(), RawssgError> { - let url = request.url().to_string(); - let requested_path = if url == "/" { - "index.html" - } else { - url.trim_start_matches('/') - }; - - let candidate = Path::new(requested_path); - - match safe_path(&RealFs, dist_path, candidate) { - Ok(safe_path) => { - let fs = RealFs; - match fs.read_bytes(&safe_path) { - Ok(content) => { - let mime = mime_type(&safe_path); - let header = Header::from_bytes("Content-Type", mime.as_bytes()) - .unwrap_or_else(|_| { - Header::from_bytes("Content-Type", b"application/octet-stream") - .unwrap() - }); - let response = Response::from_data(content).with_header(header); - request.respond(response).ok(); - } - Err(_) => { - let response = Response::from_string("500 Internal Server Error") - .with_status_code(500); - request.respond(response).ok(); - } - } - } - Err(_) => { - let response = Response::from_string("404 Not Found").with_status_code(404); - request.respond(response).ok(); - } - } - Ok(()) - } - - pub fn start_dev_server(output_dir: &Path, port: u16) -> Result<(), RawssgError> { - let dist: PathBuf = output_dir - .canonicalize() - .unwrap_or_else(|_| output_dir.to_path_buf()); - - let server = Server::http(format!("0.0.0.0:{}", port)) - .map_err(|e| RawssgError::Io(std::io::Error::other(e)))?; - - tracing::info!("dev server listening on http://localhost:{}", port); - for request in server.incoming_requests() { - let dist = dist.clone(); - thread::spawn(move || { - if let Err(e) = handle_request(request, &dist) { - tracing::error!("request error: {:?}", e); - } - }); - } - Ok(()) - } -} - -#[cfg(feature = "serve")] -pub use server::start_dev_server; -#[cfg(feature = "serve")] -pub mod watcher; -#[cfg(feature = "serve")] -pub use watcher::watch_dirs; diff --git a/src/serve/watcher.rs b/src/serve/watcher.rs deleted file mode 100644 index 042e154..0000000 --- a/src/serve/watcher.rs +++ /dev/null @@ -1,41 +0,0 @@ -#[cfg(feature = "serve")] -use notify::{RecursiveMode, Watcher}; -#[cfg(feature = "serve")] -use std::path::PathBuf; -#[cfg(feature = "serve")] -use std::sync::mpsc; - -#[cfg(feature = "serve")] -pub fn watch_dirs(dirs: &[PathBuf], on_change: F) -> notify::Result> -where - F: Fn() + Send + 'static, -{ - let (tx, rx) = mpsc::channel(); - let watcher_tx = tx.clone(); - let mut watcher = notify::recommended_watcher(move |res: notify::Result| { - if let Ok(event) = res - && matches!( - event.kind, - notify::EventKind::Modify(_) - | notify::EventKind::Create(_) - | notify::EventKind::Remove(_) - ) - { - let _ = watcher_tx.send(()); - } - })?; - - for dir in dirs { - if dir.exists() { - watcher.watch(dir, RecursiveMode::Recursive)?; - } - } - - std::thread::spawn(move || { - while rx.recv().is_ok() { - on_change(); - } - }); - - Ok(Box::new(watcher)) -} diff --git a/src/site/builders/mod.rs b/src/site/builders/mod.rs deleted file mode 100644 index ea7a0cd..0000000 --- a/src/site/builders/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod site; -pub mod site_builder; diff --git a/src/site/builders/site.rs b/src/site/builders/site.rs deleted file mode 100644 index b7b1524..0000000 --- a/src/site/builders/site.rs +++ /dev/null @@ -1,314 +0,0 @@ -use crate::error::RawssgError; -use crate::fs::FileSystem; -#[cfg(feature = "tera")] -use crate::site::context::{FeedContextBuilder, SitemapContextBuilder}; -use crate::site::{Context, TemplateRenderer}; -use crate::types::{PageContext, RawssgConfig}; -#[cfg(feature = "tera")] -use crate::util::relative_prefix; -use crate::util::safe_path; -use std::io; -use std::path::{Path, PathBuf}; - -pub struct Site { - pub(crate) config: RawssgConfig, - pub(crate) pages: Vec, - pub(crate) output_dir: PathBuf, - #[cfg_attr(not(feature = "tera"), allow(dead_code))] - pub(crate) base_url: String, - pub(crate) fs: Box, - pub(crate) renderer: Box, - pub(crate) content_dir: PathBuf, - #[cfg(feature = "tera")] - pub(crate) feed_context_builder: Option>, - #[cfg(feature = "tera")] - pub(crate) sitemap_context_builder: Option>, -} - -impl Site { - pub fn pages(&self) -> &[PageContext] { - &self.pages - } - - #[tracing::instrument(skip(self))] - pub fn generate(self) -> Result<(), RawssgError> { - let tmp_dir = self.output_dir.with_extension("tmp"); - if self.fs.exists(&tmp_dir) { - self.fs.remove_dir_all(&tmp_dir)?; - } - self.fs.create_dir_all(&tmp_dir)?; - - self.generate_to(&tmp_dir)?; - - if self.fs.exists(&self.output_dir) { - self.fs.remove_dir_all(&self.output_dir)?; - } - self.fs.rename(&tmp_dir, &self.output_dir).or_else(|e| { - if e.kind() == io::ErrorKind::CrossesDevices { - self.copy_dir_all(&tmp_dir, &self.output_dir)?; - self.fs.remove_dir_all(&tmp_dir)?; - Ok(()) - } else { - Err(RawssgError::SiteGeneration(format!( - "Atomic rename failed: {}", - e - ))) - } - })?; - - Ok(()) - } - - pub(crate) fn generate_to(&self, output_base: &Path) -> Result<(), RawssgError> { - self.fs.create_dir_all(output_base)?; - - let static_dir = self.try_canonicalize_or_skip(Path::new(&self.config.build.static_dir))?; - let content_dir = self.try_canonicalize_or_skip(&self.content_dir)?; - - for page in &self.pages { - if page.is_list { - continue; - } - let ctx = self.build_context(page)?; - let template = self.template_for_page(page); - let html = self.renderer.render(&template, &*ctx)?; - - self.write_page_output(output_base, page, &html)?; - } - - for page in &self.pages { - if !page.is_list { - continue; - } - #[allow(unused_mut)] - let mut ctx = self.build_context(page)?; - if let Some(_items) = &page.list_items { - #[cfg(feature = "tera")] - if let Some(tera_ctx) = ctx.as_mut_any().downcast_mut::() { - tera_ctx.insert("pages", _items); - } - } - let template = self.template_for_page(page); - let html = self.renderer.render(&template, &*ctx)?; - - self.write_page_output(output_base, page, &html)?; - } - - if let Some(ref dir) = static_dir - && self.fs.exists(dir) - { - self.copy_static_assets(dir, output_base)?; - } - if let Some(ref dir) = content_dir { - self.copy_content_assets(dir, output_base)?; - } - - #[cfg(feature = "tera")] - { - if self.config.generators.rss.enabled { - if let Some(ref feed_builder) = self.feed_context_builder { - let blog_posts: Vec<&PageContext> = self - .pages - .iter() - .filter(|p| p.content_type == "blog" && !p.is_list) - .collect(); - if !blog_posts.is_empty() { - let rss = crate::site::feed::generate_feed( - &*self.renderer, - &self.config, - &blog_posts, - &self.base_url, - &**feed_builder, - )?; - let rss_path = Path::new(&self.config.generators.rss.path); - self.write_generated_file(output_base, rss_path, rss.as_bytes())?; - } - } else { - return Err(RawssgError::Internal( - "RSS enabled but no feed context builder provided".into(), - )); - } - } - - if self.config.generators.sitemap.enabled { - if let Some(ref sitemap_builder) = self.sitemap_context_builder { - let sitemap = crate::site::sitemap::generate_sitemap( - &*self.renderer, - &self.config, - &self.pages, - &self.base_url, - &**sitemap_builder, - )?; - let sitemap_path = Path::new(&self.config.generators.sitemap.path); - self.write_generated_file(output_base, sitemap_path, sitemap.as_bytes())?; - } else { - return Err(RawssgError::Internal( - "Sitemap enabled but no sitemap context builder provided".into(), - )); - } - } - } - - Ok(()) - } - - fn write_page_output( - &self, - output_base: &Path, - page: &PageContext, - html: &str, - ) -> Result<(), RawssgError> { - let candidate = Path::new(&page.url); - if let Some(parent_rel) = candidate.parent() { - let parent_out = output_base.join(parent_rel); - self.fs.create_dir_all(&parent_out)?; - } - let out_path = safe_path(self.fs.as_ref(), output_base, candidate)?; - self.fs.write(&out_path, html.as_bytes())?; - Ok(()) - } - #[cfg(feature = "tera")] - fn write_generated_file( - &self, - output_base: &Path, - rel_path: &Path, - content: &[u8], - ) -> Result<(), RawssgError> { - if let Some(parent_rel) = rel_path.parent() { - let parent_out = output_base.join(parent_rel); - self.fs.create_dir_all(&parent_out)?; - } - let out_path = safe_path(self.fs.as_ref(), output_base, rel_path)?; - self.fs.write(&out_path, content)?; - Ok(()) - } - - fn try_canonicalize_or_skip(&self, path: &Path) -> Result, RawssgError> { - match self.fs.canonicalize(path) { - Ok(p) if self.fs.exists(&p) => Ok(Some(p)), - Ok(_) => Ok(None), - Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None), - Err(e) => Err(RawssgError::SiteGeneration(format!( - "Cannot access directory '{}': {}", - path.display(), - e - ))), - } - } - - #[cfg(feature = "tera")] - fn build_context(&self, page: &PageContext) -> Result, RawssgError> { - let mut ctx = tera::Context::new(); - self.fill_tera_context(page, &mut ctx); - Ok(Box::new(ctx)) - } - - #[cfg(not(feature = "tera"))] - fn build_context(&self, _page: &PageContext) -> Result, RawssgError> { - Err(RawssgError::Internal( - "Context builder not available without 'tera' feature".into(), - )) - } - - #[cfg(feature = "tera")] - fn fill_tera_context(&self, page: &PageContext, ctx: &mut tera::Context) { - ctx.insert("site", &self.config.site); - ctx.insert("base_url", &self.base_url); - ctx.insert("base_path", &relative_prefix(page.depth)); - ctx.insert("page_title", &page.frontmatter.title); - ctx.insert("page_desc", &page.frontmatter.desc); - ctx.insert("page_content", &page.content_html); - ctx.insert( - "page_author", - &page.frontmatter.author.as_deref().unwrap_or(""), - ); - ctx.insert( - "page_repo_url", - &page.frontmatter.repo_url.as_deref().unwrap_or(""), - ); - ctx.insert( - "page_license", - &page.frontmatter.license.as_deref().unwrap_or(""), - ); - ctx.insert("page_url", &page.url); - ctx.insert("page_pub_date", &page.pub_date.as_deref().unwrap_or("")); - } - - fn template_for_page(&self, page: &PageContext) -> String { - for ct in &self.config.content_types { - if ct.name == page.content_type { - if page.is_list - && let Some(ref list_tpl) = ct.list_template - { - return list_tpl.clone(); - } - return ct.template.clone(); - } - } - "base.html".into() - } - - fn copy_static_assets(&self, static_dir: &Path, output_base: &Path) -> Result<(), RawssgError> { - for entry in self.fs.walk_dir(static_dir)? { - let rel = entry - .strip_prefix(static_dir) - .map_err(|e| RawssgError::SiteGeneration(e.to_string()))?; - self.copy_asset_to_output(output_base, rel, &entry)?; - } - Ok(()) - } - - fn copy_content_assets( - &self, - content_dir: &Path, - output_base: &Path, - ) -> Result<(), RawssgError> { - if !self.fs.exists(content_dir) { - return Ok(()); - } - for entry in self.fs.walk_dir(content_dir)? { - if entry.extension().map(|e| e == "md").unwrap_or(false) { - continue; - } - let rel = entry - .strip_prefix(content_dir) - .map_err(|e| RawssgError::SiteGeneration(e.to_string()))?; - self.copy_asset_to_output(output_base, rel, &entry)?; - } - Ok(()) - } - - fn copy_asset_to_output( - &self, - output_base: &Path, - rel: &Path, - source: &Path, - ) -> Result<(), RawssgError> { - if let Some(parent_rel) = rel.parent() { - let parent_out = output_base.join(parent_rel); - self.fs.create_dir_all(&parent_out)?; - } - let dest = safe_path(self.fs.as_ref(), output_base, rel)?; - self.fs.copy_file(source, &dest)?; - Ok(()) - } - - fn copy_dir_all(&self, from: &Path, to: &Path) -> Result<(), RawssgError> { - self.fs.create_dir_all(to)?; - for entry in self.fs.walk_dir(from)? { - let rel = entry - .strip_prefix(from) - .map_err(|e| RawssgError::SiteGeneration(e.to_string()))?; - let dest = to.join(rel); - if self.fs.is_dir(&entry) { - self.fs.create_dir_all(&dest)?; - } else { - if let Some(parent) = dest.parent() { - self.fs.create_dir_all(parent)?; - } - self.fs.copy_file(&entry, &dest)?; - } - } - Ok(()) - } -} diff --git a/src/site/builders/site_builder.rs b/src/site/builders/site_builder.rs deleted file mode 100644 index 206c9b3..0000000 --- a/src/site/builders/site_builder.rs +++ /dev/null @@ -1,242 +0,0 @@ -use crate::config::ConfigLoader; -use crate::error::RawssgError; -use crate::fs::FileSystem; -use crate::markdown::MarkdownRenderer; -#[cfg(feature = "tera")] -use crate::site::context::{FeedContextBuilder, SitemapContextBuilder}; -use crate::site::{ContentHandler, MarkdownPageHandler, StaticFileHandler, TemplateRenderer}; -use crate::types::{PageContext, RawssgConfig}; -use std::path::{Path, PathBuf}; - -use super::site::Site; - -pub struct SiteBuilder { - config: RawssgConfig, - content_dir: PathBuf, - output_dir: PathBuf, - fs: Box, - md_renderer: Option>, - renderer: Option>, - handlers: Vec>, - #[cfg(feature = "tera")] - feed_context_builder: Option>, - #[cfg(feature = "tera")] - sitemap_context_builder: Option>, -} - -impl SiteBuilder { - pub fn new() -> Self { - Self { - config: RawssgConfig::default(), - content_dir: PathBuf::from("content"), - output_dir: PathBuf::from("dist"), - fs: Box::new(crate::fs::real::RealFs), - md_renderer: None, - renderer: None, - handlers: vec![Box::new(MarkdownPageHandler), Box::new(StaticFileHandler)], - #[cfg(feature = "tera")] - feed_context_builder: None, - #[cfg(feature = "tera")] - sitemap_context_builder: None, - } - } - - pub fn config(mut self, config: RawssgConfig) -> Self { - self.config = config; - self - } - - pub fn load_config + Send + Sync>( - mut self, - path: P, - ) -> Result { - let loader = crate::config::loader::YamlConfigLoader::new(path); - self.config = loader.load()?; - Ok(self) - } - - pub fn content_dir(mut self, dir: impl Into) -> Self { - self.content_dir = dir.into(); - self - } - - pub fn output_dir(mut self, dir: impl Into) -> Self { - self.output_dir = dir.into(); - self - } - - pub fn with_fs(mut self, fs: Box) -> Self { - self.fs = fs; - self - } - - pub fn with_markdown_renderer(mut self, md: Box) -> Self { - self.md_renderer = Some(md); - self - } - - pub fn with_template_renderer(mut self, tr: Box) -> Self { - self.renderer = Some(tr); - self - } - - pub fn add_handler(mut self, handler: Box) -> Self { - self.handlers.push(handler); - self - } - - #[cfg(feature = "tera")] - pub fn with_feed_context_builder(mut self, b: Box) -> Self { - self.feed_context_builder = Some(b); - self - } - - #[cfg(feature = "tera")] - pub fn with_sitemap_context_builder(mut self, b: Box) -> Self { - self.sitemap_context_builder = Some(b); - self - } - - #[tracing::instrument(skip(self))] - pub fn build(mut self) -> Result { - self.config.validate()?; - - let md_renderer = self - .md_renderer - .take() - .ok_or_else(|| RawssgError::Config("markdown renderer not set".into()))?; - let renderer = self - .renderer - .take() - .ok_or_else(|| RawssgError::Config("template renderer not set".into()))?; - - #[cfg(feature = "tera")] - let feed_context_builder = if self.config.generators.rss.enabled { - Some( - self.feed_context_builder - .take() - .ok_or_else(|| RawssgError::Config("feed context builder not set".into()))?, - ) - } else { - None - }; - - #[cfg(feature = "tera")] - let sitemap_context_builder = if self.config.generators.sitemap.enabled { - Some( - self.sitemap_context_builder - .take() - .ok_or_else(|| RawssgError::Config("sitemap context builder not set".into()))?, - ) - } else { - None - }; - - if self.content_dir == Path::new("content") { - self.content_dir = PathBuf::from(&self.config.build.content_dir); - } - if self.output_dir == Path::new("dist") { - self.output_dir = PathBuf::from(&self.config.build.output_dir); - } - - let base_url = self - .config - .site - .base_url - .clone() - .unwrap_or_else(|| "http://localhost:3000".to_string()); - - let mut pages = Vec::new(); - let mut blog_posts = Vec::new(); - - let all_files = self.fs.walk_dir(&self.content_dir)?; - for file_path in &all_files { - let rel = match file_path.strip_prefix(&self.content_dir) { - Ok(r) => r.to_path_buf(), - Err(_) => continue, - }; - for handler in &self.handlers { - if handler.can_handle(&rel, file_path) { - match handler.process(&*self.fs, &*md_renderer, &rel, &self.content_dir) { - Ok(Some(ctx)) => { - let mut ctx = ctx; - ctx.content_type = self.determine_content_type(&rel); - if ctx.content_type == "blog" && !ctx.is_list { - blog_posts.push(ctx.clone()); - } - pages.push(ctx); - } - Ok(None) => {} - Err(e) => { - tracing::error!("Failed to process {}: {}", file_path.display(), e); - } - } - break; - } - } - } - - blog_posts.sort_by(|a, b| { - b.frontmatter - .date - .cmp(&a.frontmatter.date) - .then_with(|| a.frontmatter.title.cmp(&b.frontmatter.title)) - }); - - for ct in &self.config.content_types { - if ct.list_enabled && ct.list_template.is_some() { - let items: Vec = pages - .iter() - .filter(|p| p.content_type == ct.name && !p.is_list) - .cloned() - .collect(); - if !items.is_empty() { - let list_url = format!("{}/index.html", ct.name); - pages.push(PageContext { - url: list_url, - file_path: String::new(), - depth: 1, - pub_date: None, - frontmatter: crate::types::PageFrontMatter { - title: ct.name.clone(), - ..Default::default() - }, - content_html: String::new(), - content_type: ct.name.clone(), - is_list: true, - list_items: Some(items), - }); - } - } - } - - Ok(Site { - config: self.config, - pages, - output_dir: self.output_dir, - base_url, - fs: self.fs, - renderer, - content_dir: self.content_dir, - #[cfg(feature = "tera")] - feed_context_builder, - #[cfg(feature = "tera")] - sitemap_context_builder, - }) - } - - fn determine_content_type(&self, relative_path: &Path) -> String { - for ct in &self.config.content_types { - if crate::util::match_pattern(&ct.pattern, relative_path) { - return ct.name.clone(); - } - } - "page".into() - } -} - -impl Default for SiteBuilder { - fn default() -> Self { - Self::new() - } -} diff --git a/src/site/context.rs b/src/site/context.rs deleted file mode 100644 index 595b95e..0000000 --- a/src/site/context.rs +++ /dev/null @@ -1,59 +0,0 @@ -use crate::error::RawssgError; -use crate::site::Context; -use crate::types::{PageContext, RawssgConfig}; - -pub trait FeedContextBuilder: Send + Sync { - fn build_feed_context( - &self, - config: &RawssgConfig, - posts: &[&PageContext], - base_url: &str, - ) -> Result, RawssgError>; -} - -pub trait SitemapContextBuilder: Send + Sync { - fn build_sitemap_context( - &self, - config: &RawssgConfig, - pages: &[PageContext], - base_url: &str, - ) -> Result, RawssgError>; -} - -#[cfg(feature = "tera")] -pub struct TeraFeedContextBuilder; - -#[cfg(feature = "tera")] -impl FeedContextBuilder for TeraFeedContextBuilder { - fn build_feed_context( - &self, - config: &RawssgConfig, - posts: &[&PageContext], - base_url: &str, - ) -> Result, RawssgError> { - let mut ctx = tera::Context::new(); - ctx.insert("site", &config.site); - ctx.insert("posts", posts); - ctx.insert("base_url", base_url); - Ok(Box::new(ctx)) - } -} - -#[cfg(feature = "tera")] -pub struct TeraSitemapContextBuilder; - -#[cfg(feature = "tera")] -impl SitemapContextBuilder for TeraSitemapContextBuilder { - fn build_sitemap_context( - &self, - config: &RawssgConfig, - pages: &[PageContext], - base_url: &str, - ) -> Result, RawssgError> { - let mut ctx = tera::Context::new(); - ctx.insert("site", &config.site); - ctx.insert("pages", pages); - ctx.insert("base_url", base_url); - Ok(Box::new(ctx)) - } -} diff --git a/src/site/feed.rs b/src/site/feed.rs deleted file mode 100644 index 27b7b71..0000000 --- a/src/site/feed.rs +++ /dev/null @@ -1,21 +0,0 @@ -#[cfg(feature = "tera")] -use super::context::FeedContextBuilder; -#[cfg(feature = "tera")] -use crate::error::RawssgError; -#[cfg(feature = "tera")] -use crate::site::TemplateRenderer; -#[cfg(feature = "tera")] -use crate::types::{PageContext, RawssgConfig}; - -#[cfg(feature = "tera")] -#[tracing::instrument(skip(renderer, context_builder))] -pub fn generate_feed( - renderer: &dyn TemplateRenderer, - config: &RawssgConfig, - posts: &[&PageContext], - base_url: &str, - context_builder: &dyn FeedContextBuilder, -) -> Result { - let ctx = context_builder.build_feed_context(config, posts, base_url)?; - renderer.render(&config.generators.rss.template, &*ctx) -} diff --git a/src/site/mod.rs b/src/site/mod.rs deleted file mode 100644 index 1bf6ab5..0000000 --- a/src/site/mod.rs +++ /dev/null @@ -1,116 +0,0 @@ -pub mod builders; -pub mod feed; -pub mod page; -pub mod sitemap; - -use crate::error::RawssgError; -use crate::fs::FileSystem; -use crate::markdown::MarkdownRenderer; -use crate::types::PageContext; -use std::path::Path; - -pub trait TemplateRenderer: Send + Sync { - fn render(&self, template_name: &str, context: &dyn Context) -> Result; -} - -pub trait Context: Send + Sync { - fn as_any(&self) -> &dyn std::any::Any; - fn as_mut_any(&mut self) -> &mut dyn std::any::Any; -} - -#[cfg(feature = "tera")] -pub mod context; - -#[cfg(feature = "tera")] -pub struct TeraRenderer { - tera: tera::Tera, -} - -#[cfg(feature = "tera")] -impl TeraRenderer { - pub fn new() -> Self { - Self { - tera: tera::Tera::default(), - } - } - - pub fn add_raw_template(&mut self, name: &str, content: &str) -> Result<(), RawssgError> { - self.tera - .add_raw_template(name, content) - .map_err(|e| RawssgError::Template(e.to_string())) - } -} - -#[cfg(feature = "tera")] -impl Default for TeraRenderer { - fn default() -> Self { - Self::new() - } -} - -#[cfg(feature = "tera")] -impl TemplateRenderer for TeraRenderer { - fn render(&self, template: &str, context: &dyn Context) -> Result { - let ctx = context - .as_any() - .downcast_ref::() - .ok_or_else(|| RawssgError::Template("Invalid context type for Tera".into()))?; - self.tera - .render(template, ctx) - .map_err(|e| RawssgError::Template(e.to_string())) - } -} - -#[cfg(feature = "tera")] -impl Context for tera::Context { - fn as_any(&self) -> &dyn std::any::Any { - self - } - fn as_mut_any(&mut self) -> &mut dyn std::any::Any { - self - } -} - -pub trait ContentHandler: Send + Sync { - fn can_handle(&self, relative_path: &Path, original_path: &Path) -> bool; - fn process( - &self, - fs: &dyn FileSystem, - md_renderer: &dyn MarkdownRenderer, - file_path: &Path, - content_dir: &Path, - ) -> Result, RawssgError>; -} - -pub struct MarkdownPageHandler; -impl ContentHandler for MarkdownPageHandler { - fn can_handle(&self, _rel: &Path, orig: &Path) -> bool { - orig.extension().is_some_and(|e| e == "md") - } - - fn process( - &self, - fs: &dyn FileSystem, - md_renderer: &dyn MarkdownRenderer, - file_path: &Path, - content_dir: &Path, - ) -> Result, RawssgError> { - crate::site::page::build_page_context(fs, md_renderer, file_path, content_dir) - } -} - -pub struct StaticFileHandler; -impl ContentHandler for StaticFileHandler { - fn can_handle(&self, _rel: &Path, _orig: &Path) -> bool { - true - } - fn process( - &self, - _fs: &dyn FileSystem, - _md_renderer: &dyn MarkdownRenderer, - _file_path: &Path, - _content_dir: &Path, - ) -> Result, RawssgError> { - Ok(None) - } -} diff --git a/src/site/page.rs b/src/site/page.rs deleted file mode 100644 index 3ef5478..0000000 --- a/src/site/page.rs +++ /dev/null @@ -1,58 +0,0 @@ -use crate::error::RawssgError; -use crate::frontmatter::parse_frontmatter_and_render; -use crate::fs::FileSystem; -use crate::markdown::MarkdownRenderer; -use crate::types::PageContext; -use crate::util::safe_path; -use chrono::{NaiveTime, TimeZone, Utc}; -use std::path::Path; - -#[tracing::instrument(skip(fs, markdown_renderer))] -pub fn build_page_context( - fs: &dyn FileSystem, - markdown_renderer: &dyn MarkdownRenderer, - file_path: &Path, - content_dir: &Path, -) -> Result, RawssgError> { - let safe_file_path = safe_path(fs, content_dir, file_path)?; - let raw = fs.read_to_string(&safe_file_path)?; - - let (fm, content_html) = - parse_frontmatter_and_render(&raw, &safe_file_path, markdown_renderer)?; - - if fm.draft { - tracing::warn!("Skipping draft: {}", safe_file_path.display()); - return Ok(None); - } - - let base_canon = fs - .canonicalize(content_dir) - .map_err(|e| RawssgError::SiteGeneration(format!("canonicalize base: {}", e)))?; - let rel_path = safe_file_path - .strip_prefix(&base_canon) - .map_err(|_| RawssgError::SiteGeneration("prefix not found".into()))?; - - let url = rel_path - .with_extension("html") - .to_string_lossy() - .to_string(); - let depth = rel_path.components().count().saturating_sub(1); - - let pub_date = fm.date.map(|d| { - Utc.from_utc_datetime(&d.and_time(NaiveTime::from_hms_opt(0, 0, 0).unwrap())) - .format("%a, %d %b %Y %H:%M:%S %z") - .to_string() - }); - - Ok(Some(PageContext { - frontmatter: fm, - content_html, - url, - file_path: safe_file_path.to_string_lossy().to_string(), - depth, - pub_date, - content_type: "page".into(), - is_list: false, - list_items: None, - })) -} diff --git a/src/site/sitemap.rs b/src/site/sitemap.rs deleted file mode 100644 index 116b378..0000000 --- a/src/site/sitemap.rs +++ /dev/null @@ -1,21 +0,0 @@ -#[cfg(feature = "tera")] -use super::context::SitemapContextBuilder; -#[cfg(feature = "tera")] -use crate::error::RawssgError; -#[cfg(feature = "tera")] -use crate::site::TemplateRenderer; -#[cfg(feature = "tera")] -use crate::types::{PageContext, RawssgConfig}; - -#[cfg(feature = "tera")] -#[tracing::instrument(skip(renderer, context_builder))] -pub fn generate_sitemap( - renderer: &dyn TemplateRenderer, - config: &RawssgConfig, - pages: &[PageContext], - base_url: &str, - context_builder: &dyn SitemapContextBuilder, -) -> Result { - let ctx = context_builder.build_sitemap_context(config, pages, base_url)?; - renderer.render(&config.generators.sitemap.template, &*ctx) -} diff --git a/src/types.rs b/src/types.rs deleted file mode 100644 index 9ba35dc..0000000 --- a/src/types.rs +++ /dev/null @@ -1,246 +0,0 @@ -use chrono::NaiveDate; -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct RawssgConfig { - #[serde(default)] - pub site: GlobalConfig, - #[serde(default)] - pub build: BuildConfig, - #[serde(default)] - pub content_types: Vec, - #[serde(default)] - pub generators: GeneratorsConfig, -} - -impl RawssgConfig { - pub fn validate(&self) -> Result<(), crate::error::RawssgError> { - if self.site.site_name.trim().is_empty() { - return Err(crate::error::RawssgError::Config( - "site.name cannot be empty".into(), - )); - } - - if self.content_types.is_empty() { - return Err(crate::error::RawssgError::Config( - "at least one content_type must be defined".into(), - )); - } - - for ct in &self.content_types { - if ct.name.trim().is_empty() { - return Err(crate::error::RawssgError::Config(format!( - "content_type name cannot be empty (pattern: {})", - ct.pattern - ))); - } - if ct.template.trim().is_empty() { - return Err(crate::error::RawssgError::Config(format!( - "content_type '{}' must have a template", - ct.name - ))); - } - if let Err(e) = glob::Pattern::new(&ct.pattern) { - return Err(crate::error::RawssgError::Config(format!( - "Invalid glob pattern '{}' for content_type '{}': {}", - ct.pattern, ct.name, e - ))); - } - } - - if self.generators.rss.enabled { - if self.generators.rss.template.trim().is_empty() { - return Err(crate::error::RawssgError::Config( - "rss.template is required when RSS enabled".into(), - )); - } - if self.generators.rss.path.trim().is_empty() { - return Err(crate::error::RawssgError::Config( - "rss.path is required when RSS enabled".into(), - )); - } - } - - if self.generators.sitemap.enabled { - if self.generators.sitemap.template.trim().is_empty() { - return Err(crate::error::RawssgError::Config( - "sitemap.template is required when sitemap enabled".into(), - )); - } - if self.generators.sitemap.path.trim().is_empty() { - return Err(crate::error::RawssgError::Config( - "sitemap.path is required when sitemap enabled".into(), - )); - } - } - - Ok(()) - } -} - -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct GlobalConfig { - #[serde(default)] - pub navbar: Vec, - #[serde(default)] - pub sidebar: Vec, - #[serde(default = "default_site_name")] - pub site_name: String, - #[serde(default)] - pub description: Option, - #[serde(default)] - pub language: Option, - #[serde(default)] - pub base_url: Option, - #[serde(default)] - pub author: Option, - #[serde(default)] - pub repo_url: Option, - #[serde(default)] - pub license: Option, -} - -impl Default for GlobalConfig { - fn default() -> Self { - Self { - navbar: vec![], - sidebar: vec![], - site_name: default_site_name(), - description: None, - language: Some("en".into()), - base_url: None, - author: None, - repo_url: None, - license: None, - } - } -} - -fn default_site_name() -> String { - "rawssg".into() -} - -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct BuildConfig { - #[serde(default = "default_content_dir")] - pub content_dir: String, - #[serde(default = "default_output_dir")] - pub output_dir: String, - #[serde(default = "default_templates_dir")] - pub templates_dir: String, - #[serde(default = "default_static_dir")] - pub static_dir: String, -} - -fn default_content_dir() -> String { - "content".into() -} -fn default_output_dir() -> String { - "dist".into() -} -fn default_templates_dir() -> String { - "templates".into() -} -fn default_static_dir() -> String { - "static".into() -} - -impl Default for BuildConfig { - fn default() -> Self { - Self { - content_dir: default_content_dir(), - output_dir: default_output_dir(), - templates_dir: default_templates_dir(), - static_dir: default_static_dir(), - } - } -} - -impl Default for RawssgConfig { - fn default() -> Self { - Self { - site: GlobalConfig::default(), - build: BuildConfig::default(), - content_types: vec![ContentTypeDef { - name: "page".into(), - pattern: "**/*.md".into(), - template: "base.html".into(), - list_template: None, - list_enabled: false, - }], - generators: GeneratorsConfig::default(), - } - } -} - -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct ContentTypeDef { - pub name: String, - pub pattern: String, - pub template: String, - #[serde(default)] - pub list_template: Option, - #[serde(default)] - pub list_enabled: bool, -} - -#[derive(Debug, Clone, Deserialize, Serialize, Default)] -pub struct GeneratorsConfig { - #[serde(default)] - pub rss: GeneratorDef, - #[serde(default)] - pub sitemap: GeneratorDef, -} - -#[derive(Debug, Clone, Deserialize, Serialize, Default)] -pub struct GeneratorDef { - #[serde(default = "default_false")] - pub enabled: bool, - #[serde(default)] - pub path: String, - #[serde(default)] - pub template: String, -} - -fn default_false() -> bool { - false -} - -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct NavItem { - pub label: String, - pub url: String, - #[serde(default)] - pub children: Vec, -} - -#[derive(Debug, Deserialize, Serialize, Clone, Default)] -pub struct PageFrontMatter { - pub title: String, - pub desc: String, - #[serde(default)] - pub author: Option, - #[serde(default)] - pub repo_url: Option, - #[serde(default)] - pub license: Option, - #[serde(default)] - pub date: Option, - #[serde(default)] - pub tags: Vec, - #[serde(default)] - pub draft: bool, -} - -#[derive(Debug, Serialize, Clone)] -pub struct PageContext { - pub frontmatter: PageFrontMatter, - pub content_html: String, - pub url: String, - pub file_path: String, - pub depth: usize, - pub pub_date: Option, - pub content_type: String, - pub is_list: bool, - pub list_items: Option>, -} diff --git a/src/util.rs b/src/util.rs deleted file mode 100644 index a56ff8b..0000000 --- a/src/util.rs +++ /dev/null @@ -1,179 +0,0 @@ -use crate::error::RawssgError; -use crate::fs::FileSystem; -use std::path::{Component, Path, PathBuf}; - -fn normalize_path(path: &Path) -> PathBuf { - let mut components = Vec::new(); - for comp in path.components() { - match comp { - Component::ParentDir => { - if components - .last() - .is_some_and(|c: &Component| c != &Component::RootDir) - { - components.pop(); - } - } - Component::CurDir => {} - other => components.push(other), - } - } - components.into_iter().collect() -} - -pub fn safe_path( - fs: &dyn FileSystem, - base: &Path, - candidate: &Path, -) -> Result { - let base_canon = fs.canonicalize(base).map_err(|e| { - RawssgError::Io(std::io::Error::other(format!( - "Cannot canonicalize base '{}': {}", - base.display(), - e - ))) - })?; - - let joined = if candidate.is_relative() { - base_canon.join(candidate) - } else { - candidate.to_path_buf() - }; - let normalized = normalize_path(&joined); - - if fs.exists(&normalized) { - let resolved = fs.canonicalize(&normalized).map_err(|e| { - RawssgError::Io(std::io::Error::other(format!( - "Cannot resolve path '{}': {}", - normalized.display(), - e - ))) - })?; - if !resolved.starts_with(&base_canon) { - return Err(RawssgError::PathTraversal(format!( - "Path traversal detected: {}", - resolved.display() - ))); - } - Ok(resolved) - } else { - let parent = normalized.parent().ok_or_else(|| { - RawssgError::PathTraversal(format!( - "Cannot determine parent of output path: {}", - normalized.display() - )) - })?; - let parent_canon = fs.canonicalize(parent).map_err(|e| { - RawssgError::Io(std::io::Error::other(format!( - "Cannot resolve parent '{}': {}", - parent.display(), - e - ))) - })?; - if !parent_canon.starts_with(&base_canon) { - return Err(RawssgError::PathTraversal(format!( - "Path escapes base: {}", - normalized.display() - ))); - } - let file_name = normalized.file_name().ok_or_else(|| { - RawssgError::PathTraversal(format!( - "Output path has no file name: {}", - normalized.display() - )) - })?; - Ok(parent_canon.join(file_name)) - } -} -pub fn slugify(title: &str) -> String { - title - .to_lowercase() - .chars() - .map(|c| { - if c.is_alphanumeric() || c == '-' { - c - } else { - '-' - } - }) - .collect::() - .split('-') - .filter(|s| !s.is_empty()) - .collect::>() - .join("-") -} - -pub fn relative_prefix(depth: usize) -> String { - if depth == 0 { - "./".into() - } else { - "../".repeat(depth) - } -} - -pub fn match_pattern(pattern: &str, path: &Path) -> bool { - let path_str = path.to_string_lossy(); - let segments: Vec<&str> = path_str.split('/').collect(); - let pattern_segments: Vec<&str> = pattern.split('/').collect(); - - match_pattern_slice(&pattern_segments, &segments) -} - -fn match_pattern_slice(pattern: &[&str], segments: &[&str]) -> bool { - if pattern.is_empty() { - return segments.is_empty(); - } - if segments.is_empty() { - return pattern.iter().all(|&p| p == "**"); - } - - match pattern[0] { - "**" => { - if pattern.len() == 1 { - return true; - } - for i in 0..segments.len() { - if match_pattern_slice(&pattern[1..], &segments[i..]) { - return true; - } - } - false - } - pat => { - if !segment_matches(pat, segments[0]) { - return false; - } - match_pattern_slice(&pattern[1..], &segments[1..]) - } - } -} - -fn segment_matches(pattern: &str, segment: &str) -> bool { - let mut pattern_chars = pattern.chars(); - let mut segment_chars = segment.chars(); - - loop { - match pattern_chars.next() { - Some('*') => { - let rest_of_pattern: String = pattern_chars.clone().collect(); - if rest_of_pattern.is_empty() { - return true; - } - let mut remaining_segment: String = segment_chars.clone().collect(); - while !remaining_segment.is_empty() { - if segment_matches(&rest_of_pattern, &remaining_segment) { - return true; - } - segment_chars.next(); - remaining_segment = segment_chars.clone().collect(); - } - return false; - } - Some(pc) => match segment_chars.next() { - Some(sc) if pc == sc => continue, - _ => return false, - }, - None => return segment_chars.next().is_none(), - } - } -} From f17e65ada331c01b365555c4030d5bf18ea51448 Mon Sep 17 00:00:00 2001 From: mroczect Date: Tue, 8 Sep 2026 01:07:06 +0700 Subject: [PATCH 03/48] feat(workspace): add config, error, fs crates (#26) * chore(lock): update lockfile for new workspace crates Add entries for librawssg_config, librawssg_error, librawssg_fs and adjust existing crate versions to 1.0.0. Include thiserror and its transitive dependencies. * feat(workspace): add config, error, fs crates to workspace Expand workspace members to include librawssg_config, librawssg_error, and librawssg_fs. Keep existing members. * chore(compiler): bump version to 1.0.0 and add metadata Set version to 1.0.0 and add description, license, repository, readme, keywords, and categories. * docs(compiler): add README for compiler crate Add a short description of the build pipeline orchestration role. * feat(config): add config crate manifest Create Cargo.toml for librawssg_config with description, license, repository, readme, keywords, and categories. Include workspace lints. * docs(config): add README for config crate Describe the purpose of the config crate. * feat(config): add placeholder library for config crate Add a simple placeholder lib.rs with add function and test to bootstrap the crate. * feat(error): add error crate manifest Create Cargo.toml for librawssg_error with thiserror dependency and metadata. Use workspace lints. * docs(error): add README for error crate Describe the unified error type and Result alias. * feat(error): define unified error enum and Result alias Add a non-exhaustive Error enum with variants for common SSG failures. Export Result type using thiserror. * test(error): add placeholder integration test file Create empty integration_tests.rs for future tests. * test(error): add placeholder property test file Create empty property_tests.rs for future tests. * test(error): add placeholder unit test file Create empty unit_tests.rs for future tests. * feat(fs): add fs crate manifest Create Cargo.toml for librawssg_fs with description, license, repository, readme, keywords, and categories. Use workspace lints. * docs(fs): add README for fs crate Describe the filesystem abstraction layer. * feat(fs): add placeholder library for fs crate Add a simple placeholder lib.rs with add function and test. * chore(handler): bump version to 1.0.0 and add metadata Set version to 1.0.0 and add description, license, repository, readme, keywords, and categories. * docs(handler): add README for handler crate Describe the content processing contracts. * chore(templates): bump version to 1.0.0 and add metadata Set version to 1.0.0 and add description, license, repository, readme, keywords, and categories. * docs(templates): add README for templates crate Describe the template rendering contracts. --- Cargo.lock | 76 +++++++++++++++++++++- Cargo.toml | 2 +- librawssg_compiler/Cargo.toml | 8 ++- librawssg_compiler/README.md | 4 ++ librawssg_config/Cargo.toml | 15 +++++ librawssg_config/README.md | 4 ++ librawssg_config/src/lib.rs | 14 ++++ librawssg_error/Cargo.toml | 16 +++++ librawssg_error/README.md | 4 ++ librawssg_error/src/lib.rs | 58 +++++++++++++++++ librawssg_error/tests/integration_tests.rs | 1 + librawssg_error/tests/property_tests.rs | 1 + librawssg_error/tests/unit_tests.rs | 1 + librawssg_fs/Cargo.toml | 15 +++++ librawssg_fs/README.md | 4 ++ librawssg_fs/src/lib.rs | 14 ++++ librawssg_handler/Cargo.toml | 8 ++- librawssg_handler/README.md | 4 ++ librawssg_templates/Cargo.toml | 8 ++- librawssg_templates/README.md | 4 ++ 20 files changed, 254 insertions(+), 7 deletions(-) create mode 100644 librawssg_compiler/README.md create mode 100644 librawssg_config/Cargo.toml create mode 100644 librawssg_config/README.md create mode 100644 librawssg_config/src/lib.rs create mode 100644 librawssg_error/Cargo.toml create mode 100644 librawssg_error/README.md create mode 100644 librawssg_error/src/lib.rs create mode 100644 librawssg_error/tests/integration_tests.rs create mode 100644 librawssg_error/tests/property_tests.rs create mode 100644 librawssg_error/tests/unit_tests.rs create mode 100644 librawssg_fs/Cargo.toml create mode 100644 librawssg_fs/README.md create mode 100644 librawssg_fs/src/lib.rs create mode 100644 librawssg_handler/README.md create mode 100644 librawssg_templates/README.md diff --git a/Cargo.lock b/Cargo.lock index cb330d0..84f6ee0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,12 +4,82 @@ version = 4 [[package]] name = "librawssg_compiler" -version = "0.1.0" +version = "1.0.0" + +[[package]] +name = "librawssg_config" +version = "1.0.0" + +[[package]] +name = "librawssg_error" +version = "1.0.0" +dependencies = [ + "thiserror", +] + +[[package]] +name = "librawssg_fs" +version = "1.0.0" [[package]] name = "librawssg_handler" -version = "0.1.0" +version = "1.0.0" [[package]] name = "librawssg_templates" -version = "0.1.0" +version = "1.0.0" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" diff --git a/Cargo.toml b/Cargo.toml index bb365b8..9fcbab3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "3" -members = ["librawssg_compiler","librawssg_handler", "librawssg_templates"] +members = ["librawssg_compiler", "librawssg_config", "librawssg_error", "librawssg_fs","librawssg_handler", "librawssg_templates"] [workspace.lints.clippy] all = { level = "deny", priority = -1 } diff --git a/librawssg_compiler/Cargo.toml b/librawssg_compiler/Cargo.toml index d31f713..8590eaf 100644 --- a/librawssg_compiler/Cargo.toml +++ b/librawssg_compiler/Cargo.toml @@ -1,7 +1,13 @@ [package] name = "librawssg_compiler" -version = "0.1.0" +version = "1.0.0" edition = "2024" +description = "Build pipeline orchestration for the librawssg static site generator" +license = "MIT" +repository = "https://github.com/mroczect/librawssg" +readme = "README.md" +keywords = ["ssg", "static-site-generator", "pipeline", "builder", "compiler"] +categories = ["development-tools::build-utils"] [dependencies] diff --git a/librawssg_compiler/README.md b/librawssg_compiler/README.md new file mode 100644 index 0000000..656345f --- /dev/null +++ b/librawssg_compiler/README.md @@ -0,0 +1,4 @@ +# librawssg_compiler + +Orchestrates the build pipeline for librawssg. +Combines filesystem, processors, renderers, and generators to produce a static site. diff --git a/librawssg_config/Cargo.toml b/librawssg_config/Cargo.toml new file mode 100644 index 0000000..9585972 --- /dev/null +++ b/librawssg_config/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "librawssg_config" +version = "1.0.0" +edition = "2024" +description = "Configuration types and validation for the librawssg static site generator" +license = "MIT" +repository = "https://github.com/mroczect/librawssg" +readme = "README.md" +keywords = ["ssg", "static-site-generator", "config", "yaml", "validation"] +categories = ["development-tools::build-utils"] + +[dependencies] + +[lints] +workspace = true diff --git a/librawssg_config/README.md b/librawssg_config/README.md new file mode 100644 index 0000000..466003a --- /dev/null +++ b/librawssg_config/README.md @@ -0,0 +1,4 @@ +# librawssg_config + +Configuration types and validation for librawssg. +Provides `Config`, site metadata, build settings, and content rule definitions. diff --git a/librawssg_config/src/lib.rs b/librawssg_config/src/lib.rs new file mode 100644 index 0000000..b93cf3f --- /dev/null +++ b/librawssg_config/src/lib.rs @@ -0,0 +1,14 @@ +pub fn add(left: u64, right: u64) -> u64 { + left + right +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_works() { + let result = add(2, 2); + assert_eq!(result, 4); + } +} diff --git a/librawssg_error/Cargo.toml b/librawssg_error/Cargo.toml new file mode 100644 index 0000000..4af6213 --- /dev/null +++ b/librawssg_error/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "librawssg_error" +version = "1.0.0" +edition = "2024" +description = "Unified error types for the librawssg static site generator" +license = "MIT" +repository = "https://github.com/mroczect/librawssg" +readme = "README.md" +keywords = ["ssg", "static-site-generator", "error", "thiserror"] +categories = ["development-tools::build-utils"] + +[dependencies] +thiserror = "2.0.20" + +[lints] +workspace = true diff --git a/librawssg_error/README.md b/librawssg_error/README.md new file mode 100644 index 0000000..07aee98 --- /dev/null +++ b/librawssg_error/README.md @@ -0,0 +1,4 @@ +# librawssg_error + +Unified error types for the librawssg static site generator ecosystem. +Provides a single `Error` enum and `Result` alias used by all other crates. diff --git a/librawssg_error/src/lib.rs b/librawssg_error/src/lib.rs new file mode 100644 index 0000000..2373b44 --- /dev/null +++ b/librawssg_error/src/lib.rs @@ -0,0 +1,58 @@ +use core::error::Error as CoreError; +use std::path::PathBuf; +use thiserror::Error; + +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum Error { + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), + + #[error("Configuration error: {0}")] + Config(String), + + #[error("Failed to parse metadata in {path}")] + Metadata { + path: PathBuf, + #[source] + source: Box, + }, + + #[error("Template rendering error: {0}")] + Render(String), + + #[error("Content processor error: {0}")] + Processor(String), + + #[error("Generator error: {0}")] + Generator(String), + + #[error("Path traversal attempt detected: {0}")] + PathTraversal(String), + + #[error("Missing configuration key: {0}")] + MissingConfig(String), + + #[error("Site generation error: {0}")] + Generation(String), + + #[error("Resource not found: {0}")] + NotFound(String), + + #[error("Serialization error: {0}")] + Serialization(String), + + #[error("Validation error: {0}")] + Validation(String), + + #[error("Duplicate value: {0}")] + Duplicate(String), + + #[error("Invalid state: {0}")] + InvalidState(String), + + #[error("Internal error: {0}")] + Internal(String), +} + +pub type Result = core::result::Result; diff --git a/librawssg_error/tests/integration_tests.rs b/librawssg_error/tests/integration_tests.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/librawssg_error/tests/integration_tests.rs @@ -0,0 +1 @@ + diff --git a/librawssg_error/tests/property_tests.rs b/librawssg_error/tests/property_tests.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/librawssg_error/tests/property_tests.rs @@ -0,0 +1 @@ + diff --git a/librawssg_error/tests/unit_tests.rs b/librawssg_error/tests/unit_tests.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/librawssg_error/tests/unit_tests.rs @@ -0,0 +1 @@ + diff --git a/librawssg_fs/Cargo.toml b/librawssg_fs/Cargo.toml new file mode 100644 index 0000000..05b1145 --- /dev/null +++ b/librawssg_fs/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "librawssg_fs" +version = "1.0.0" +edition = "2024" +description = "Filesystem abstraction for the librawssg static site generator" +license = "MIT" +repository = "https://github.com/mroczect/librawssg" +readme = "README.md" +keywords = ["ssg", "static-site-generator", "filesystem", "io"] +categories = ["development-tools::build-utils"] + +[dependencies] + +[lints] +workspace = true diff --git a/librawssg_fs/README.md b/librawssg_fs/README.md new file mode 100644 index 0000000..3ca0a9b --- /dev/null +++ b/librawssg_fs/README.md @@ -0,0 +1,4 @@ +# librawssg_fs + +Filesystem abstraction layer for librawssg. +Defines the `FileSystem` trait and provides a real implementation. diff --git a/librawssg_fs/src/lib.rs b/librawssg_fs/src/lib.rs new file mode 100644 index 0000000..b93cf3f --- /dev/null +++ b/librawssg_fs/src/lib.rs @@ -0,0 +1,14 @@ +pub fn add(left: u64, right: u64) -> u64 { + left + right +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_works() { + let result = add(2, 2); + assert_eq!(result, 4); + } +} diff --git a/librawssg_handler/Cargo.toml b/librawssg_handler/Cargo.toml index ca51fb3..c4700d7 100644 --- a/librawssg_handler/Cargo.toml +++ b/librawssg_handler/Cargo.toml @@ -1,7 +1,13 @@ [package] name = "librawssg_handler" -version = "0.1.0" +version = "1.0.0" edition = "2024" +description = "Content processing contracts for the librawssg static site generator" +license = "MIT" +repository = "https://github.com/mroczect/librawssg" +readme = "README.md" +keywords = ["ssg", "static-site-generator", "content", "processor", "traits"] +categories = ["development-tools::build-utils"] [dependencies] diff --git a/librawssg_handler/README.md b/librawssg_handler/README.md new file mode 100644 index 0000000..581174e --- /dev/null +++ b/librawssg_handler/README.md @@ -0,0 +1,4 @@ +# librawssg_handler + +Content processing contracts for librawssg. +Defines the `Processor` trait and related types for turning source files into documents. diff --git a/librawssg_templates/Cargo.toml b/librawssg_templates/Cargo.toml index 6900f48..6fecaf7 100644 --- a/librawssg_templates/Cargo.toml +++ b/librawssg_templates/Cargo.toml @@ -1,7 +1,13 @@ [package] name = "librawssg_templates" -version = "0.1.0" +version = "1.0.0" edition = "2024" +description = "Template rendering contracts for the librawssg static site generator" +license = "MIT" +repository = "https://github.com/mroczect/librawssg" +readme = "README.md" +keywords = ["ssg", "static-site-generator", "template", "renderer", "traits"] +categories = ["development-tools::build-utils"] [dependencies] diff --git a/librawssg_templates/README.md b/librawssg_templates/README.md new file mode 100644 index 0000000..81b90c1 --- /dev/null +++ b/librawssg_templates/README.md @@ -0,0 +1,4 @@ +# librawssg_templates + +Template rendering contracts for librawssg. +Defines the `Renderer` and `RenderContext` traits for pluggable template engines. From cc2d16a3fc2d885ce2af25e9331d753b2fa9572a Mon Sep 17 00:00:00 2001 From: mroczect Date: Tue, 8 Sep 2026 01:17:02 +0700 Subject: [PATCH 04/48] ci: update workflows and makefile, add error tests (#27) * ci(workflows): improve CI pipeline and add build steps Update CI workflow with explicit job names and timeouts. Pin Rust toolchain to 1.96.0. Add build steps for debug and release, ensure formatting check runs in both jobs, and use workspace-wide clippy and test commands. * ci(workflows): remove obsolete docs deployment workflow Delete the GitHub Pages documentation deployment workflow. It is no longer needed since the workspace is being restructured and documentation deployment will be handled separately. * chore(gitignore): ignore generated PR body files Add pull_request_body.md to .gitignore to prevent temporary PR body files from being tracked accidentally. * refactor(makefile): expand build automation with per-crate targets Replace simple Makefile with comprehensive workspace targets. Add per-crate shortcuts, publish helpers, clippy flags variable, and improved help. This improves developer workflow across the new workspace. * test(error): add integration tests for error propagation Add tests verifying Io error conversion via From and Metadata error source accessibility using thiserror. * test(error): add property tests for error messages Add property tests ensuring Config, PathTraversal, and Render error messages preserve their input strings. * test(error): add unit tests for error enum Add comprehensive unit tests covering Display, source, From conversion, and Debug output for all error variants. --- .github/workflows/ci.yml | 46 +++- .github/workflows/docs.yml | 65 ----- .gitignore | 3 +- Makefile | 274 ++++++++++++++++++--- librawssg_error/tests/integration_tests.rs | 32 +++ librawssg_error/tests/property_tests.rs | 51 ++++ librawssg_error/tests/unit_tests.rs | 155 ++++++++++++ 7 files changed, 518 insertions(+), 108 deletions(-) delete mode 100644 .github/workflows/docs.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ec4f3f9..30311a7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,13 +11,18 @@ env: jobs: base: + name: Base checks (no features) runs-on: ubuntu-latest + timeout-minutes: 30 + steps: - - uses: actions/checkout@v4 + - name: Checkout repository + uses: actions/checkout@v4 - name: Install Rust stable uses: dtolnay/rust-toolchain@stable with: + toolchain: "1.96.0" components: rustfmt, clippy - name: Cache Cargo artifacts @@ -31,24 +36,32 @@ jobs: restore-keys: | ${{ runner.os }}-cargo-base- - - name: Check formatting + - name: Check code formatting run: cargo fmt --all -- --check - - name: Lint (no features) - run: cargo clippy --all-targets -- -D warnings + - name: Run Clippy (no features) + run: cargo clippy --workspace --all-targets -- -D warnings + + - name: Build (debug, no features) + run: cargo build --workspace --all-targets - - name: Test (no features) - run: cargo test --workspace --verbose + - name: Run tests (no features) + run: cargo test --workspace --all-targets --verbose features: + name: Full feature checks runs-on: ubuntu-latest + timeout-minutes: 30 + steps: - - uses: actions/checkout@v4 + - name: Checkout repository + uses: actions/checkout@v4 - name: Install Rust stable uses: dtolnay/rust-toolchain@stable with: - components: clippy + toolchain: "1.96.0" + components: rustfmt, clippy - name: Cache Cargo artifacts uses: actions/cache@v4 @@ -61,8 +74,17 @@ jobs: restore-keys: | ${{ runner.os }}-cargo-features- - - name: Lint (all features) - run: cargo clippy --all-targets --all-features -- -D warnings + - name: Check code formatting + run: cargo fmt --all -- --check + + - name: Run Clippy (all features) + run: cargo clippy --workspace --all-targets --all-features -- -D warnings + + - name: Build (debug, all features) + run: cargo build --workspace --all-targets --all-features + + - name: Run tests (all features) + run: cargo test --workspace --all-targets --all-features --verbose - - name: Test (all features) - run: cargo test --workspace --verbose --all-features + - name: Build (release, all features) + run: cargo build --workspace --all-targets --all-features --release diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml deleted file mode 100644 index 3398b4c..0000000 --- a/.github/workflows/docs.yml +++ /dev/null @@ -1,65 +0,0 @@ -name: Deploy Docs to GitHub Pages - -on: - push: - branches: ["master"] - paths: - - "docs/**" - workflow_dispatch: - -permissions: - contents: read - pages: write - id-token: write - -concurrency: - group: "pages" - cancel-in-progress: false - -jobs: - build: - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Setup Rust - uses: actions-rs/toolchain@v1 - with: - toolchain: stable - override: true - - - name: Cache Cargo - uses: actions/cache@v3 - with: - path: | - ~/.cargo/bin/ - ~/.cargo/registry/index/ - ~/.cargo/registry/cache/ - ~/.cargo/git/db/ - target/ - key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} - - - name: Build documentation site - run: | - cd docs - cargo run - - - name: Setup Pages - uses: actions/configure-pages@v5 - - - name: Upload Pages artifact - uses: actions/upload-pages-artifact@v3 - with: - path: "docs/dist" - - deploy: - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - runs-on: ubuntu-latest - needs: build - steps: - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index 5eee747..1468f0e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ /dev .velodiff_dist.diff .snapcat.md -/dist \ No newline at end of file +/dist +pull_request_body.md \ No newline at end of file diff --git a/Makefile b/Makefile index 4972479..c3ba25c 100644 --- a/Makefile +++ b/Makefile @@ -1,49 +1,263 @@ -.PHONY: all build release check test lint fmt clippy clean run install ci +SHELL = /bin/bash +.SHELLFLAGS = -euo pipefail -c +CARGO = cargo +MEMBERS = librawssg_error librawssg_fs librawssg_config librawssg_handler librawssg_templates librawssg_compiler +PUBLISH_ORDER = librawssg_error librawssg_fs librawssg_config librawssg_handler librawssg_templates librawssg_compiler + +PKG ?= librawssg_handler + +CLIPPY_FLAGS ?= -- -D warnings + +.DEFAULT_GOAL := help + +.PHONY: help +help: + @echo "Usage: make [PKG=] [CLIPPY_FLAGS=]" + @echo "" + @echo "Targets:" + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \ + | awk 'BEGIN {FS = ":.*?## "}; {printf " %-20s %s\n", $$1, $$2}' + @echo "" + @echo "Contoh:" + @echo " make ci" + @echo " make clippy CLIPPY_FLAGS=''" + @echo " make test-pkg PKG=librawssg_core" + +.PHONY: all all: build -build: - cargo build +.PHONY: build +build: + $(CARGO) build --workspace + +.PHONY: release +release: + $(CARGO) build --release --workspace + +.PHONY: check +check: + $(CARGO) check --workspace + +.PHONY: check-all +check-all: + $(CARGO) check --workspace --all-targets --all-features + +.PHONY: test +test: + $(CARGO) test --workspace + +.PHONY: test-all +test-all: test + +.PHONY: test-verbose +test-verbose: + RUST_BACKTRACE=1 $(CARGO) test --workspace -- --nocapture -release: - cargo build --release +.PHONY: watch-test +watch-test: + $(CARGO) watch -x 'test --workspace' -check: - cargo check --workspace +.PHONY: watch-build +watch-build: + $(CARGO) watch -x 'check --workspace' -test: - cargo test --workspace +.PHONY: fmt +fmt: + $(CARGO) fmt --all -test-verbose: - cargo test --workspace -- --nocapture +.PHONY: fmt-check +fmt-check: + $(CARGO) fmt --all -- --check -fmt: - cargo fmt --all +.PHONY: clippy +clippy: + $(CARGO) clippy --workspace --all-targets --all-features $(CLIPPY_FLAGS) -fmt-check: - cargo fmt --all -- --check +.PHONY: clippy-all +clippy-all: clippy -clippy: - cargo clippy -- -D warnings +.PHONY: clippy-strict +clippy-strict: + $(CARGO) clippy --workspace --all-targets --all-features -- -D warnings +.PHONY: lint lint: fmt clippy -clean: - cargo clean +.PHONY: ci +ci: fmt-check clippy test-verbose + +.PHONY: ci-fast +ci-fast: fmt-check clippy test + +.PHONY: clean +clean: + $(CARGO) clean + +.PHONY: doc +doc: + $(CARGO) doc --workspace --no-deps + +.PHONY: doc-open +doc-open: doc + $(CARGO) doc --workspace --no-deps --open + +.PHONY: bench +bench: + $(CARGO) bench --workspace + +.PHONY: coverage +coverage: + $(CARGO) llvm-cov --workspace --html + @echo "Coverage report: target/llvm-cov/html/index.html" + +.PHONY: update +update: + $(CARGO) update + +.PHONY: audit +audit: + @if command -v cargo-audit >/dev/null 2>&1; then \ + $(CARGO) audit; \ + else \ + echo "cargo-audit not installed. Run: cargo install cargo-audit"; \ + fi + +.PHONY: publish-check +publish-check: + @for crate in $(MEMBERS); do \ + echo "🔍 Memeriksa packaging $$crate"; \ + $(CARGO) package -p "$$crate" || exit 1; \ + done + @echo "✅ Semua crate siap publish." + +.PHONY: publish-all +publish-all: + @for crate in $(PUBLISH_ORDER); do \ + echo "📦 Publishing $$crate ..."; \ + $(CARGO) publish -p $$crate || exit 1; \ + sleep 5; \ + done + @echo "✅ Semua crate berhasil dipublish." + +.PHONY: version +version: + @if [ -z "$(V)" ]; then \ + echo "Usage: make version V="; \ + exit 1; \ + fi + @if [ ! -x "dev/version_bump.sh" ]; then \ + echo "ERROR: dev/version_bump.sh not found or not executable"; \ + exit 1; \ + fi + dev/version_bump.sh $(V) + +.PHONY: snap +snap: + mkdir -p dev + @for crate in $(MEMBERS); do \ + if [ -d "$$crate" ]; then \ + echo "Snapping $$crate/src"; \ + snapcat "$$crate/src" -f markdown -o "dev/$$crate.src.snapcat.md" || true; \ + fi; \ + if [ -d "$$crate/tests" ]; then \ + echo "Snapping $$crate/tests"; \ + snapcat "$$crate/tests" -f markdown -o "dev/$$crate.tests.snapcat.md" || true; \ + fi; \ + done + @echo "Merging all snapshots into dev/root.md" + cat dev/*.snapcat.md > dev/root.md 2>/dev/null || true + @echo "Done. See dev/root.md" + +.PHONY: run +run: + $(CARGO) run -p $(PKG) + +.PHONY: install +install: + $(CARGO) install --path . + +.PHONY: uninstall +uninstall: + $(CARGO) uninstall librawssg || true + +.PHONY: rebuild +rebuild: release install + +.PHONY: build-pkg +build-pkg: + $(CARGO) build -p $(PKG) + +.PHONY: release-pkg +release-pkg: + $(CARGO) build --release -p $(PKG) + +.PHONY: check-pkg +check-pkg: + $(CARGO) check -p $(PKG) + +.PHONY: test-pkg +test-pkg: + $(CARGO) test -p $(PKG) + +.PHONY: test-verbose-pkg +test-verbose-pkg: + RUST_BACKTRACE=1 $(CARGO) test -p $(PKG) -- --nocapture + +.PHONY: fmt-pkg +fmt-pkg: + $(CARGO) fmt -p $(PKG) + +.PHONY: fmt-check-pkg +fmt-check-pkg: + $(CARGO) fmt -p $(PKG) -- --check + +.PHONY: clippy-pkg +clippy-pkg: + $(CARGO) clippy -p $(PKG) --all-targets --all-features $(CLIPPY_FLAGS) + +.PHONY: clippy-pkg-strict +clippy-pkg-strict: + $(CARGO) clippy -p $(PKG) --all-targets --all-features -- -D warnings + +.PHONY: ci-pkg +ci-pkg: fmt-check-pkg clippy-pkg test-verbose-pkg + +.PHONY: ci-pkg-strict +ci-pkg-strict: fmt-check-pkg clippy-pkg-strict test-verbose-pkg + +.PHONY: doc-pkg +doc-pkg: + $(CARGO) doc -p $(PKG) --no-deps + +.PHONY: watch-test-pkg +watch-test-pkg: + $(CARGO) watch -x 'test -p $(PKG)' + +.PHONY: watch-build-pkg +watch-build-pkg: + $(CARGO) watch -x 'check -p $(PKG)' -run: - cargo run +.PHONY: error +error: PKG=librawssg_error +error: ci-pkg -install: - cargo install --path . +.PHONY: fs +fs: PKG=librawssg_fs +fs: ci-pkg -uninstall: - cargo uninstall jsscli +.PHONY: config +config: PKG=librawssg_config +config: ci-pkg -ci: fmt-check clippy test +.PHONY: handler +handler: PKG=librawssg_handler +handler: ci-pkg -rebuild: - make release && make install +.PHONY: templates +templates: PKG=librawssg_templates +templates: ci-pkg -snap: - snapcat tests -f markdown -o dev/tests.snapcat.md && snapcat src -f markdown -o dev/src.snapcat.md \ No newline at end of file +.PHONY: compiler +compiler: PKG=librawssg_compiler +compiler: ci-pkg diff --git a/librawssg_error/tests/integration_tests.rs b/librawssg_error/tests/integration_tests.rs index 8b13789..fa0ff54 100644 --- a/librawssg_error/tests/integration_tests.rs +++ b/librawssg_error/tests/integration_tests.rs @@ -1 +1,33 @@ +use librawssg_error::{Error, Result}; +use std::path::PathBuf; +use thiserror as _; +fn read_file(path: &str) -> Result { + let content = std::fs::read_to_string(path)?; + Ok(content) +} + +#[test] +fn io_error_propagates_via_question_mark() { + let result = read_file("definitely_not_exists.txt"); + assert!(result.is_err()); + assert!(matches!(result, Err(Error::Io(_)))); +} + +#[test] +fn metadata_error_can_hold_boxed_dyn_error() { + let source: Box = + Box::new(std::io::Error::other("bad yaml")); + let err = Error::Metadata { + path: PathBuf::from("content/post.md"), + source, + }; + + assert!(err.to_string().contains("content/post.md")); + let as_core_error: &dyn core::error::Error = &err; + let source_ref = as_core_error.source(); + assert!(source_ref.is_some(), "source should exist"); + if let Some(source_ref) = source_ref { + assert_eq!(source_ref.to_string(), "bad yaml"); + } +} diff --git a/librawssg_error/tests/property_tests.rs b/librawssg_error/tests/property_tests.rs index 8b13789..b78848a 100644 --- a/librawssg_error/tests/property_tests.rs +++ b/librawssg_error/tests/property_tests.rs @@ -1 +1,52 @@ +use librawssg_error::Error; +use thiserror as _; +#[test] +fn config_error_message_preserves_input() { + let samples = [ + "", + "short", + "a very long error message with symbols !@#$%^&*()", + "line1\nline2", + ]; + + for sample in samples { + let err = Error::Config(sample.to_string()); + assert_eq!(err.to_string(), format!("Configuration error: {sample}")); + } +} + +#[test] +fn path_traversal_error_message_preserves_input() { + let samples = [ + "../", + "..\\..\\windows", + "/absolute/path", + "sub/../../escape", + ]; + + for sample in samples { + let err = Error::PathTraversal(sample.to_string()); + assert_eq!( + err.to_string(), + format!("Path traversal attempt detected: {sample}") + ); + } +} + +#[test] +fn render_error_message_preserves_input() { + let samples = [ + "missing variable `title`", + "template not found: base.html", + "unclosed tag", + ]; + + for sample in samples { + let err = Error::Render(sample.to_string()); + assert_eq!( + err.to_string(), + format!("Template rendering error: {sample}") + ); + } +} diff --git a/librawssg_error/tests/unit_tests.rs b/librawssg_error/tests/unit_tests.rs index 8b13789..5ee39a4 100644 --- a/librawssg_error/tests/unit_tests.rs +++ b/librawssg_error/tests/unit_tests.rs @@ -1 +1,156 @@ +use core::error::Error as CoreError; +use librawssg_error::Error; +use std::path::PathBuf; +use thiserror as _; +#[test] +fn display_for_config_error() { + let err = Error::Config("invalid YAML".to_string()); + assert_eq!(err.to_string(), "Configuration error: invalid YAML"); +} + +#[test] +fn display_for_io_error() { + let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file missing"); + let err = Error::Io(io_err); + assert_eq!(err.to_string(), "I/O error: file missing"); +} + +#[test] +fn display_for_metadata_error() { + let source = std::io::Error::new(std::io::ErrorKind::InvalidData, "bad front matter"); + let err = Error::Metadata { + path: PathBuf::from("content/page.md"), + source: Box::new(source), + }; + assert_eq!( + err.to_string(), + "Failed to parse metadata in content/page.md" + ); +} + +#[test] +fn display_for_render_error() { + let err = Error::Render("template not found".to_string()); + assert_eq!( + err.to_string(), + "Template rendering error: template not found" + ); +} + +#[test] +fn display_for_processor_error() { + let err = Error::Processor("custom processor failed".to_string()); + assert_eq!( + err.to_string(), + "Content processor error: custom processor failed" + ); +} + +#[test] +fn display_for_generator_error() { + let err = Error::Generator("RSS generation failed".to_string()); + assert_eq!(err.to_string(), "Generator error: RSS generation failed"); +} + +#[test] +fn display_for_path_traversal_error() { + let err = Error::PathTraversal("../escape".to_string()); + assert_eq!( + err.to_string(), + "Path traversal attempt detected: ../escape" + ); +} + +#[test] +fn display_for_missing_config_error() { + let err = Error::MissingConfig("base_url".to_string()); + assert_eq!(err.to_string(), "Missing configuration key: base_url"); +} + +#[test] +fn display_for_generation_error() { + let err = Error::Generation("output write failed".to_string()); + assert_eq!( + err.to_string(), + "Site generation error: output write failed" + ); +} + +#[test] +fn display_for_not_found_error() { + let err = Error::NotFound("asset.css".to_string()); + assert_eq!(err.to_string(), "Resource not found: asset.css"); +} + +#[test] +fn display_for_serialization_error() { + let err = Error::Serialization("invalid JSON".to_string()); + assert_eq!(err.to_string(), "Serialization error: invalid JSON"); +} + +#[test] +fn display_for_validation_error() { + let err = Error::Validation("name too long".to_string()); + assert_eq!(err.to_string(), "Validation error: name too long"); +} + +#[test] +fn display_for_duplicate_error() { + let err = Error::Duplicate("duplicate key".to_string()); + assert_eq!(err.to_string(), "Duplicate value: duplicate key"); +} + +#[test] +fn display_for_invalid_state_error() { + let err = Error::InvalidState("unexpected null".to_string()); + assert_eq!(err.to_string(), "Invalid state: unexpected null"); +} + +#[test] +fn display_for_internal_error() { + let err = Error::Internal("bug in code".to_string()); + assert_eq!(err.to_string(), "Internal error: bug in code"); +} + +#[test] +fn implements_std_error_trait() { + let err = Error::Config("test".to_string()); + let as_core_error: &dyn CoreError = &err; + assert!(as_core_error.source().is_none()); +} + +#[test] +fn metadata_source_is_accessible() { + let source = std::io::Error::other("source err"); + let err = Error::Metadata { + path: PathBuf::from("test.txt"), + source: Box::new(source), + }; + let as_core_error: &dyn CoreError = &err; + let source_ref = as_core_error.source(); + assert!(source_ref.is_some(), "source should exist"); + if let Some(source_ref) = source_ref { + assert_eq!(source_ref.to_string(), "source err"); + } +} + +#[test] +fn io_error_from_conversion() { + let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied"); + let err: Error = io_err.into(); + assert!(matches!(err, Error::Io(_))); +} + +#[test] +fn result_alias_works() { + let res: librawssg_error::Result<()> = Ok(()); + assert!(res.is_ok()); +} + +#[test] +fn debug_output_contains_variant_name() { + let err = Error::Generator("boom".to_string()); + let debug_str = format!("{err:?}"); + assert!(debug_str.contains("Generator")); +} From a5dfde25d166eb0d79752756f469b67d2fbd7976 Mon Sep 17 00:00:00 2001 From: mroczect Date: Tue, 8 Sep 2026 01:18:25 +0700 Subject: [PATCH 05/48] ci(workflows): run CI on all branches (#28) Change push and pull_request trigger branches from "master" to "*" so CI runs on every branch. This supports the new branching strategy and ensures early feedback for all feature and refactor branches. --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 30311a7..47ed0c5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: ["master"] + branches: ["*"] pull_request: - branches: ["master"] + branches: ["*"] env: CARGO_TERM_COLOR: always From 2726dd735f3d5e5a885a18828032a3453ce02ba7 Mon Sep 17 00:00:00 2001 From: mroczect Date: Tue, 8 Sep 2026 01:39:43 +0700 Subject: [PATCH 06/48] feat(workspace): implement fs and handler core (#29) * chore: update Cargo.lock for new dependencies Add entries for chrono, serde, tempfile, tracing, walkdir and their transitive dependencies. Update thiserror and syn versions to reflect new dependency graph. * feat(fs): add dependencies and dev-dependencies Add tracing and walkdir as runtime dependencies. Add tempfile as a dev dependency for tests. * feat(fs): define FileSystem trait and RealFs export Replace placeholder with a comprehensive FileSystem trait covering file, directory, symlink, and permission operations. Provide default implementations for path safety and fallback copy methods. Export RealFs. * feat(handler): add handler dependencies Add librawssg_error, librawssg_fs, chrono, and serde as dependencies. Chrono is configured without default features, enabling serde support. * feat(handler): expose Document, Metadata, and Processor Replace placeholder with module declarations for document, metadata, and processor. Re-export the public types. * feat(fs): implement RealFs for all FileSystem operations Add a concrete RealFs struct implementing the FileSystem trait. Use std::fs and walkdir to handle I/O, directory traversal, symlinks, permissions, atomic writes, and more. * test(fs): add comprehensive filesystem tests Add a test suite covering read/write, directories, copy, rename, atomic writes, symlinks, permissions, and path safety for the RealFs implementation. * feat(handler): define Document structure Add Document struct with metadata, body, URL, source path, depth, content type, list flag, and optional child documents. * feat(handler): define Metadata struct Add Metadata struct with title, description, author, repo URL, license, date, tags, and draft flag. Derives Serialize, Deserialize, and Default. * feat(handler): define Processor trait Add Processor trait with can_process and process methods. The process method accepts FileSystem and paths, returning Result>. --- Cargo.lock | 248 +++++++++++++- librawssg_fs/Cargo.toml | 5 + librawssg_fs/src/lib.rs | 124 ++++++- librawssg_fs/src/real.rs | 165 +++++++++ librawssg_fs/tests/filesystem.rs | 527 +++++++++++++++++++++++++++++ librawssg_handler/Cargo.toml | 4 + librawssg_handler/src/document.rs | 14 + librawssg_handler/src/lib.rs | 19 +- librawssg_handler/src/metadata.rs | 14 + librawssg_handler/src/processor.rs | 15 + 10 files changed, 1111 insertions(+), 24 deletions(-) create mode 100644 librawssg_fs/src/real.rs create mode 100644 librawssg_fs/tests/filesystem.rs create mode 100644 librawssg_handler/src/document.rs create mode 100644 librawssg_handler/src/metadata.rs create mode 100644 librawssg_handler/src/processor.rs diff --git a/Cargo.lock b/Cargo.lock index 84f6ee0..8734180 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,67 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[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.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "num-traits", + "serde", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + [[package]] name = "librawssg_compiler" version = "1.0.0" @@ -20,15 +81,53 @@ dependencies = [ [[package]] name = "librawssg_fs" version = "1.0.0" +dependencies = [ + "tempfile", + "tracing", + "walkdir", +] [[package]] name = "librawssg_handler" version = "1.0.0" +dependencies = [ + "chrono", + "librawssg_error", + "librawssg_fs", + "serde", +] [[package]] name = "librawssg_templates" version = "1.0.0" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + [[package]] name = "proc-macro2" version = "1.0.107" @@ -47,6 +146,75 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[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 = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "3.0.5" @@ -58,6 +226,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "thiserror" version = "2.0.20" @@ -75,7 +256,38 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.5", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "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 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", ] [[package]] @@ -83,3 +295,37 @@ name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[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 = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] diff --git a/librawssg_fs/Cargo.toml b/librawssg_fs/Cargo.toml index 05b1145..f9beb47 100644 --- a/librawssg_fs/Cargo.toml +++ b/librawssg_fs/Cargo.toml @@ -10,6 +10,11 @@ keywords = ["ssg", "static-site-generator", "filesystem", "io"] categories = ["development-tools::build-utils"] [dependencies] +tracing = "0.1.44" +walkdir = "2.5.0" [lints] workspace = true + +[dev-dependencies] +tempfile = "3.27.0" diff --git a/librawssg_fs/src/lib.rs b/librawssg_fs/src/lib.rs index b93cf3f..69040cf 100644 --- a/librawssg_fs/src/lib.rs +++ b/librawssg_fs/src/lib.rs @@ -1,14 +1,118 @@ -pub fn add(left: u64, right: u64) -> u64 { - left + right -} +pub mod real; -#[cfg(test)] -mod tests { - use super::*; +use std::io; +use std::path::{Path, PathBuf}; + +use tracing as _; +use walkdir as _; + +pub trait FileSystem: Send + Sync { + fn read_to_string(&self, path: &Path) -> io::Result; + fn read_bytes(&self, path: &Path) -> io::Result>; + fn write(&self, path: &Path, content: &[u8]) -> io::Result<()>; + fn create_dir_all(&self, path: &Path) -> io::Result<()>; + fn remove_dir_all(&self, path: &Path) -> io::Result<()>; + fn remove_file(&self, path: &Path) -> io::Result<()>; + fn create_dir(&self, path: &Path) -> io::Result<()>; + fn exists(&self, path: &Path) -> bool; + fn is_dir(&self, path: &Path) -> bool; + fn is_file(&self, path: &Path) -> bool; + fn read_dir(&self, path: &Path) -> io::Result>; + fn copy_file(&self, from: &Path, to: &Path) -> io::Result; + fn copy_dir_all(&self, from: &Path, to: &Path) -> io::Result<()>; + fn walk_dir(&self, root: &Path) -> io::Result>; + fn canonicalize(&self, path: &Path) -> io::Result; + fn rename(&self, from: &Path, to: &Path) -> io::Result<()>; + fn atomic_write(&self, path: &Path, content: &[u8]) -> io::Result<()>; + fn touch(&self, path: &Path) -> io::Result<()>; + fn metadata(&self, path: &Path) -> io::Result; + fn symlink_metadata(&self, path: &Path) -> io::Result; + fn permissions(&self, path: &Path) -> io::Result; + fn set_permissions(&self, path: &Path, permissions: std::fs::Permissions) -> io::Result<()>; + fn read_link(&self, path: &Path) -> io::Result; + fn hard_link(&self, from: &Path, to: &Path) -> io::Result<()>; + + fn is_symlink(&self, path: &Path) -> bool { + self.symlink_metadata(path) + .is_ok_and(|meta| meta.file_type().is_symlink()) + } + + fn canonicalize_or_join(&self, base: &Path, candidate: &Path) -> io::Result { + let mut components: Vec> = Vec::new(); + for comp in candidate.components() { + match comp { + std::path::Component::CurDir => {} + std::path::Component::ParentDir => { + if components.is_empty() { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "path traversal detected", + )); + } + if matches!(components.last(), Some(std::path::Component::Normal(_))) { + let _ = components.pop(); + } else { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "path traversal detected", + )); + } + } + std::path::Component::Prefix(_) + | std::path::Component::RootDir + | std::path::Component::Normal(_) => components.push(comp), + } + } + let normalized_candidate: PathBuf = components.into_iter().collect(); + let joined = base.join(normalized_candidate); + + if self.exists(&joined) { + self.canonicalize(&joined) + } else { + let parent = joined + .parent() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "path has no parent"))?; + let parent_canon = self.canonicalize(parent)?; + let file_name = joined.file_name().ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "path has no file name") + })?; + Ok(parent_canon.join(file_name)) + } + } + + fn safe_join(&self, base: &Path, candidate: &Path) -> io::Result { + let base_canon = self.canonicalize(base)?; + let joined = self.canonicalize_or_join(&base_canon, candidate)?; + if !joined.starts_with(&base_canon) { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "path traversal detected", + )); + } + Ok(joined) + } + + fn copy(&self, from: &Path, to: &Path) -> io::Result<()> { + if self.is_dir(from) { + self.copy_dir_all(from, to) + } else { + self.copy_file(from, to).map(|_| ()) + } + } - #[test] - fn it_works() { - let result = add(2, 2); - assert_eq!(result, 4); + fn rename_or_copy(&self, from: &Path, to: &Path) -> io::Result<()> { + match self.rename(from, to) { + Ok(()) => Ok(()), + Err(e) if e.kind() == io::ErrorKind::CrossesDevices => { + self.copy_dir_all(from, to)?; + self.remove_dir_all(from) + } + Err(e) => Err(e), + } } } + +pub use real::RealFs; + +#[cfg(test)] +use tempfile as _; diff --git a/librawssg_fs/src/real.rs b/librawssg_fs/src/real.rs new file mode 100644 index 0000000..8d73142 --- /dev/null +++ b/librawssg_fs/src/real.rs @@ -0,0 +1,165 @@ +use super::FileSystem; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; +use walkdir::WalkDir; + +#[derive(Debug, Default, Clone, Copy)] +pub struct RealFs; + +impl FileSystem for RealFs { + #[tracing::instrument(skip(self))] + fn read_to_string(&self, path: &Path) -> io::Result { + fs::read_to_string(path) + } + + #[tracing::instrument(skip(self))] + fn read_bytes(&self, path: &Path) -> io::Result> { + fs::read(path) + } + + #[tracing::instrument(skip(self, content))] + fn write(&self, path: &Path, content: &[u8]) -> io::Result<()> { + if let Some(parent) = path.parent() { + self.create_dir_all(parent)?; + } + fs::write(path, content) + } + + #[tracing::instrument(skip(self))] + fn create_dir_all(&self, path: &Path) -> io::Result<()> { + fs::create_dir_all(path) + } + + #[tracing::instrument(skip(self))] + fn remove_dir_all(&self, path: &Path) -> io::Result<()> { + fs::remove_dir_all(path) + } + + #[tracing::instrument(skip(self))] + fn remove_file(&self, path: &Path) -> io::Result<()> { + fs::remove_file(path) + } + + #[tracing::instrument(skip(self))] + fn create_dir(&self, path: &Path) -> io::Result<()> { + fs::create_dir(path) + } + + #[tracing::instrument(skip(self))] + fn exists(&self, path: &Path) -> bool { + path.exists() + } + + #[tracing::instrument(skip(self))] + fn is_dir(&self, path: &Path) -> bool { + path.is_dir() + } + + #[tracing::instrument(skip(self))] + fn is_file(&self, path: &Path) -> bool { + path.is_file() + } + + #[tracing::instrument(skip(self))] + fn read_dir(&self, path: &Path) -> io::Result> { + let mut entries = Vec::new(); + for entry in fs::read_dir(path)? { + entries.push(entry?.path()); + } + Ok(entries) + } + + #[tracing::instrument(skip(self))] + fn copy_file(&self, from: &Path, to: &Path) -> io::Result { + fs::copy(from, to) + } + + #[tracing::instrument(skip(self))] + fn copy_dir_all(&self, from: &Path, to: &Path) -> io::Result<()> { + self.create_dir_all(to)?; + for entry in self.walk_dir(from)? { + let rel = entry + .strip_prefix(from) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?; + let dest = to.join(rel); + if self.is_dir(&entry) { + self.create_dir_all(&dest)?; + } else { + if let Some(parent) = dest.parent() { + self.create_dir_all(parent)?; + } + let _ = self.copy_file(&entry, &dest)?; + } + } + Ok(()) + } + + #[tracing::instrument(skip(self))] + fn walk_dir(&self, root: &Path) -> io::Result> { + let mut files = Vec::new(); + for entry in WalkDir::new(root) { + let entry = entry?; + if entry.file_type().is_file() { + files.push(entry.into_path()); + } + } + Ok(files) + } + + fn canonicalize(&self, path: &Path) -> io::Result { + path.canonicalize() + } + + fn rename(&self, from: &Path, to: &Path) -> io::Result<()> { + fs::rename(from, to) + } + + fn atomic_write(&self, path: &Path, content: &[u8]) -> io::Result<()> { + let tmp = path.with_extension("tmp"); + self.write(&tmp, content)?; + match self.rename(&tmp, path) { + Ok(()) => Ok(()), + Err(e) => { + let _ = self.remove_file(&tmp); + Err(e) + } + } + } + + fn touch(&self, path: &Path) -> io::Result<()> { + if let Some(parent) = path.parent() { + self.create_dir_all(parent)?; + } + let file = fs::OpenOptions::new() + .create(true) + .append(true) + .open(path)?; + file.sync_all()?; + Ok(()) + } + + fn metadata(&self, path: &Path) -> io::Result { + fs::metadata(path) + } + + fn symlink_metadata(&self, path: &Path) -> io::Result { + fs::symlink_metadata(path) + } + + fn permissions(&self, path: &Path) -> io::Result { + fs::metadata(path).map(|m| m.permissions()) + } + + fn set_permissions(&self, path: &Path, permissions: fs::Permissions) -> io::Result<()> { + fs::set_permissions(path, permissions) + } + + fn read_link(&self, path: &Path) -> io::Result { + fs::read_link(path) + } + + fn hard_link(&self, from: &Path, to: &Path) -> io::Result<()> { + fs::hard_link(from, to) + } +} diff --git a/librawssg_fs/tests/filesystem.rs b/librawssg_fs/tests/filesystem.rs new file mode 100644 index 0000000..561ad4b --- /dev/null +++ b/librawssg_fs/tests/filesystem.rs @@ -0,0 +1,527 @@ +use librawssg_fs::{FileSystem, RealFs}; +use std::path::Path; +use tempfile::TempDir; +use tracing as _; +use walkdir as _; + +macro_rules! must { + ($result:expr, $context:expr) => { + match $result { + Ok(value) => value, + Err(err) => { + eprintln!("{} failed: {}", $context, err); + std::process::exit(1); + } + } + }; +} + +#[test] +fn write_and_read_string() { + let tmp = must!(TempDir::new(), "TempDir::new"); + let fs = RealFs; + let file_path = tmp.path().join("hello.txt"); + + must!(fs.write(&file_path, b"world"), "write"); + let content = must!(fs.read_to_string(&file_path), "read_to_string"); + assert_eq!(content, "world"); +} + +#[test] +fn write_and_read_bytes() { + let tmp = must!(TempDir::new(), "TempDir::new"); + let fs = RealFs; + let file_path = tmp.path().join("data.bin"); + let data = vec![0, 1, 2, 3]; + + must!(fs.write(&file_path, &data), "write"); + let read = must!(fs.read_bytes(&file_path), "read_bytes"); + assert_eq!(read, data); +} + +#[test] +fn read_nonexistent_file_errors() { + let tmp = must!(TempDir::new(), "TempDir::new"); + let fs = RealFs; + let result = fs.read_to_string(&tmp.path().join("missing.txt")); + assert!(result.is_err()); +} + +#[test] +fn write_creates_parent_directories() { + let tmp = must!(TempDir::new(), "TempDir::new"); + let fs = RealFs; + let nested = tmp.path().join("a/b/c/file.txt"); + + must!(fs.write(&nested, b"deep"), "write nested"); + assert!(fs.exists(&nested)); + let content = must!(fs.read_to_string(&nested), "read nested"); + assert_eq!(content, "deep"); +} + +#[test] +fn create_dir_and_check_exists() { + let tmp = must!(TempDir::new(), "TempDir::new"); + let fs = RealFs; + let dir_path = tmp.path().join("subdir"); + + must!(fs.create_dir(&dir_path), "create_dir"); + assert!(fs.exists(&dir_path)); + assert!(fs.is_dir(&dir_path)); + assert!(!fs.is_file(&dir_path)); +} + +#[test] +fn create_dir_already_exists_errors() { + let tmp = must!(TempDir::new(), "TempDir::new"); + let fs = RealFs; + let dir = tmp.path().join("existing_dir"); + must!(fs.create_dir(&dir), "create_dir first"); + + let result = fs.create_dir(&dir); + assert!(result.is_err(), "creating existing dir should error"); +} + +#[test] +fn create_dir_all_recursive() { + let tmp = must!(TempDir::new(), "TempDir::new"); + let fs = RealFs; + let nested = tmp.path().join("a/b/c"); + + must!(fs.create_dir_all(&nested), "create_dir_all"); + assert!(fs.exists(&nested)); + assert!(fs.is_dir(&nested)); +} + +#[test] +fn read_dir_lists_entries() { + let tmp = must!(TempDir::new(), "TempDir::new"); + let fs = RealFs; + let dir = tmp.path().join("dir"); + must!(fs.create_dir_all(&dir), "create_dir_all"); + must!(fs.write(&dir.join("a.txt"), b"a"), "write a"); + must!(fs.write(&dir.join("b.txt"), b"b"), "write b"); + + let entries = must!(fs.read_dir(&dir), "read_dir"); + assert_eq!(entries.len(), 2); + assert!(entries.iter().any(|p| p.ends_with("a.txt"))); + assert!(entries.iter().any(|p| p.ends_with("b.txt"))); +} + +#[test] +fn read_dir_empty_returns_empty_vec() { + let tmp = must!(TempDir::new(), "TempDir::new"); + let fs = RealFs; + let dir = tmp.path().join("empty"); + must!(fs.create_dir(&dir), "create_dir"); + + let entries = must!(fs.read_dir(&dir), "read_dir"); + assert!(entries.is_empty()); +} + +#[test] +fn remove_dir_all_works() { + let tmp = must!(TempDir::new(), "TempDir::new"); + let fs = RealFs; + let dir = tmp.path().join("dir"); + must!(fs.create_dir_all(&dir.join("sub")), "create_dir_all"); + must!(fs.write(&dir.join("file.txt"), b"data"), "write"); + + must!(fs.remove_dir_all(&dir), "remove_dir_all"); + assert!(!fs.exists(&dir)); +} + +#[test] +fn remove_dir_all_nonexistent_errors() { + let tmp = must!(TempDir::new(), "TempDir::new"); + let fs = RealFs; + let result = fs.remove_dir_all(&tmp.path().join("ghost")); + assert!(result.is_err()); +} + +#[test] +fn copy_file_works() { + let tmp = must!(TempDir::new(), "TempDir::new"); + let fs = RealFs; + let src = tmp.path().join("src.txt"); + let dst = tmp.path().join("dst.txt"); + must!(fs.write(&src, b"data"), "write"); + + let copied = must!(fs.copy_file(&src, &dst), "copy_file"); + assert!(copied > 0); + let content = must!(fs.read_to_string(&dst), "read dst"); + assert_eq!(content, "data"); +} + +#[test] +fn copy_file_nonexistent_source_errors() { + let tmp = must!(TempDir::new(), "TempDir::new"); + let fs = RealFs; + let result = fs.copy_file(&tmp.path().join("missing"), &tmp.path().join("dest")); + assert!(result.is_err()); +} + +#[test] +fn rename_works() { + let tmp = must!(TempDir::new(), "TempDir::new"); + let fs = RealFs; + let old = tmp.path().join("old.txt"); + let new = tmp.path().join("new.txt"); + must!(fs.write(&old, b"content"), "write"); + + must!(fs.rename(&old, &new), "rename"); + assert!(!fs.exists(&old)); + assert!(fs.exists(&new)); + let content = must!(fs.read_to_string(&new), "read new"); + assert_eq!(content, "content"); +} + +#[test] +fn rename_nonexistent_errors() { + let tmp = must!(TempDir::new(), "TempDir::new"); + let fs = RealFs; + let result = fs.rename(&tmp.path().join("missing"), &tmp.path().join("dest")); + assert!(result.is_err()); +} + +#[test] +fn remove_file_works() { + let tmp = must!(TempDir::new(), "TempDir::new"); + let fs = RealFs; + let file = tmp.path().join("delete.txt"); + must!(fs.write(&file, b"to delete"), "write"); + + must!(fs.remove_file(&file), "remove_file"); + assert!(!fs.exists(&file)); +} + +#[test] +fn remove_file_nonexistent_errors() { + let tmp = must!(TempDir::new(), "TempDir::new"); + let fs = RealFs; + let result = fs.remove_file(&tmp.path().join("missing.txt")); + assert!(result.is_err()); +} + +#[test] +fn atomic_write_works() { + let tmp = must!(TempDir::new(), "TempDir::new"); + let fs = RealFs; + let file = tmp.path().join("atomic.txt"); + + must!(fs.atomic_write(&file, b"first"), "atomic_write first"); + let content = must!(fs.read_to_string(&file), "read first"); + assert_eq!(content, "first"); + + must!(fs.atomic_write(&file, b"second"), "atomic_write second"); + let content = must!(fs.read_to_string(&file), "read second"); + assert_eq!(content, "second"); +} + +#[test] +fn atomic_write_creates_parents() { + let tmp = must!(TempDir::new(), "TempDir::new"); + let fs = RealFs; + let file = tmp.path().join("parent/child/atomic.txt"); + + must!(fs.atomic_write(&file, b"nested"), "atomic_write nested"); + assert!(fs.exists(&file)); + let content = must!(fs.read_to_string(&file), "read nested"); + assert_eq!(content, "nested"); +} + +#[test] +fn touch_creates_file() { + let tmp = must!(TempDir::new(), "TempDir::new"); + let fs = RealFs; + let file = tmp.path().join("new_file.txt"); + + must!(fs.touch(&file), "touch"); + assert!(fs.exists(&file)); + let content = must!(fs.read_to_string(&file), "read touched"); + assert_eq!(content, ""); +} + +#[test] +fn touch_existing_file_keeps_content() { + let tmp = must!(TempDir::new(), "TempDir::new"); + let fs = RealFs; + let file = tmp.path().join("existing.txt"); + must!(fs.write(&file, b"keep"), "write"); + + must!(fs.touch(&file), "touch"); + let content = must!(fs.read_to_string(&file), "read after touch"); + assert_eq!(content, "keep"); +} + +#[test] +fn copy_dir_all_works() { + let tmp = must!(TempDir::new(), "TempDir::new"); + let fs = RealFs; + let src_dir = tmp.path().join("src_dir"); + let dst_dir = tmp.path().join("dst_dir"); + must!(fs.create_dir_all(&src_dir.join("nested")), "create_dir_all"); + must!(fs.write(&src_dir.join("file1.txt"), b"one"), "write file1"); + must!( + fs.write(&src_dir.join("nested").join("file2.txt"), b"two"), + "write file2" + ); + + must!(fs.copy_dir_all(&src_dir, &dst_dir), "copy_dir_all"); + assert!(fs.exists(&dst_dir.join("file1.txt"))); + assert!(fs.exists(&dst_dir.join("nested").join("file2.txt"))); + let content = must!(fs.read_to_string(&dst_dir.join("file1.txt")), "read file1"); + assert_eq!(content, "one"); +} + +#[test] +fn copy_file_via_copy_method() { + let tmp = must!(TempDir::new(), "TempDir::new"); + let fs = RealFs; + let src = tmp.path().join("src.txt"); + let dst = tmp.path().join("dst.txt"); + must!(fs.write(&src, b"copy me"), "write src"); + + must!(fs.copy(&src, &dst), "copy file"); + assert!(fs.exists(&dst)); + let content = must!(fs.read_to_string(&dst), "read dst"); + assert_eq!(content, "copy me"); +} + +#[test] +fn copy_dir_via_copy_method() { + let tmp = must!(TempDir::new(), "TempDir::new"); + let fs = RealFs; + let src = tmp.path().join("src_dir"); + let dst = tmp.path().join("dst_dir"); + must!(fs.create_dir_all(&src), "create_dir_all"); + must!(fs.write(&src.join("file.txt"), b"dir copy"), "write file"); + + must!(fs.copy(&src, &dst), "copy dir"); + assert!(fs.exists(&dst.join("file.txt"))); + let content = must!(fs.read_to_string(&dst.join("file.txt")), "read dst file"); + assert_eq!(content, "dir copy"); +} + +#[test] +fn walk_dir_collects_files_recursively() { + let tmp = must!(TempDir::new(), "TempDir::new"); + let fs = RealFs; + let root = tmp.path().join("root"); + must!(fs.create_dir_all(&root.join("sub")), "create_dir_all"); + must!(fs.write(&root.join("root.txt"), b"root"), "write root"); + must!( + fs.write(&root.join("sub").join("sub.txt"), b"sub"), + "write sub" + ); + + let files = must!(fs.walk_dir(&root), "walk_dir"); + assert_eq!(files.len(), 2); + assert!(files.iter().any(|p| p.ends_with("root.txt"))); + assert!(files.iter().any(|p| p.ends_with("sub.txt"))); +} + +#[test] +fn walk_dir_empty_directory_returns_empty() { + let tmp = must!(TempDir::new(), "TempDir::new"); + let fs = RealFs; + let dir = tmp.path().join("empty"); + must!(fs.create_dir(&dir), "create_dir"); + + let files = must!(fs.walk_dir(&dir), "walk_dir"); + assert!(files.is_empty()); +} + +#[test] +fn canonicalize_or_join_works_for_existing_and_missing() { + let tmp = must!(TempDir::new(), "TempDir::new"); + let fs = RealFs; + let base = tmp.path(); + let existing = base.join("existing.txt"); + must!(fs.write(&existing, b"data"), "write"); + + let canon_existing = must!( + fs.canonicalize_or_join(base, Path::new("existing.txt")), + "canonicalize existing" + ); + let canon_direct = must!(fs.canonicalize(&existing), "canonicalize direct"); + assert_eq!(canon_existing, canon_direct); + + let missing = Path::new("missing.txt"); + let canon_missing = must!( + fs.canonicalize_or_join(base, missing), + "canonicalize missing" + ); + let canon_base = must!(fs.canonicalize(base), "canonicalize base"); + assert_eq!(canon_missing, canon_base.join(missing)); +} + +#[test] +fn safe_join_blocks_traversal() { + let tmp = must!(TempDir::new(), "TempDir::new"); + let fs = RealFs; + let base = tmp.path().join("base"); + must!(fs.create_dir_all(&base), "create base"); + + let safe = must!( + fs.safe_join(&base, Path::new("inside.txt")), + "safe join inside" + ); + assert!(safe.starts_with(&base)); + + let traversal = Path::new("../escape.txt"); + let result = fs.safe_join(&base, traversal); + assert!(result.is_err(), "traversal should be rejected"); +} + +#[test] +fn safe_join_allows_dot_segments_inside() { + let tmp = must!(TempDir::new(), "TempDir::new"); + let fs = RealFs; + let base = tmp.path().join("base"); + must!(fs.create_dir_all(&base), "create base"); + + let candidate = Path::new("sub/../file.txt"); + let result = must!( + fs.safe_join(&base, candidate), + "safe join with dot segments" + ); + let expected = must!(fs.canonicalize(&base), "canonicalize base").join("file.txt"); + assert_eq!(result, expected); +} + +#[test] +fn metadata_works() { + let tmp = must!(TempDir::new(), "TempDir::new"); + let fs = RealFs; + let file = tmp.path().join("meta.txt"); + must!(fs.write(&file, b"hello"), "write"); + + let meta = must!(fs.metadata(&file), "metadata"); + assert_eq!(meta.len(), 5); + assert!(meta.is_file()); +} + +#[test] +fn metadata_nonexistent_errors() { + let tmp = must!(TempDir::new(), "TempDir::new"); + let fs = RealFs; + let result = fs.metadata(&tmp.path().join("missing.txt")); + assert!(result.is_err()); +} + +#[test] +fn symlink_metadata_works() { + let tmp = must!(TempDir::new(), "TempDir::new"); + let fs = RealFs; + let file = tmp.path().join("file.txt"); + must!(fs.write(&file, b"data"), "write"); + + let meta = must!(fs.symlink_metadata(&file), "symlink_metadata"); + assert!(meta.is_file()); +} + +#[test] +fn permissions_roundtrip() { + use std::os::unix::fs::PermissionsExt; + + let tmp = must!(TempDir::new(), "TempDir::new"); + let fs = RealFs; + let file = tmp.path().join("perm.txt"); + must!(fs.write(&file, b"data"), "write"); + + let mut perms = must!(fs.permissions(&file), "permissions"); + perms.set_mode(0o600); + must!(fs.set_permissions(&file, perms.clone()), "set_permissions"); + + let read_perms = must!(fs.permissions(&file), "permissions after set"); + assert_eq!(read_perms.mode() & 0o777, 0o600); +} + +#[test] +fn permissions_nonexistent_errors() { + let tmp = must!(TempDir::new(), "TempDir::new"); + let fs = RealFs; + let result = fs.permissions(&tmp.path().join("missing.txt")); + assert!(result.is_err()); +} +#[test] +fn set_permissions_nonexistent_errors() { + use std::os::unix::fs::PermissionsExt; + + let tmp = must!(TempDir::new(), "TempDir::new"); + let fs = RealFs; + let perms = std::fs::Permissions::from_mode(0o644); + let result = fs.set_permissions(&tmp.path().join("missing.txt"), perms); + assert!(result.is_err()); +} + +#[cfg(unix)] +#[test] +fn symlink_works() { + let tmp = must!(TempDir::new(), "TempDir::new"); + let fs = RealFs; + let target = tmp.path().join("target.txt"); + let link = tmp.path().join("link.txt"); + must!(fs.write(&target, b"target"), "write target"); + + must!(std::os::unix::fs::symlink(&target, &link), "symlink"); + assert!(fs.is_symlink(&link)); + let link_target = must!(fs.read_link(&link), "read_link"); + assert_eq!(link_target, target); + let content = must!(fs.read_to_string(&link), "read through symlink"); + assert_eq!(content, "target"); +} + +#[cfg(unix)] +#[test] +fn is_symlink_false_for_regular_file() { + let tmp = must!(TempDir::new(), "TempDir::new"); + let fs = RealFs; + let file = tmp.path().join("regular.txt"); + must!(fs.write(&file, b"data"), "write"); + assert!(!fs.is_symlink(&file)); +} + +#[cfg(unix)] +#[test] +fn hard_link_works() { + let tmp = must!(TempDir::new(), "TempDir::new"); + let fs = RealFs; + let original = tmp.path().join("original.txt"); + let hard = tmp.path().join("hard.txt"); + must!(fs.write(&original, b"shared"), "write original"); + + must!(fs.hard_link(&original, &hard), "hard_link"); + assert!(fs.exists(&hard)); + let content = must!(fs.read_to_string(&hard), "read hard"); + assert_eq!(content, "shared"); +} + +#[cfg(unix)] +#[test] +fn hard_link_nonexistent_source_errors() { + let tmp = must!(TempDir::new(), "TempDir::new"); + let fs = RealFs; + let result = fs.hard_link( + &tmp.path().join("missing.txt"), + &tmp.path().join("hard.txt"), + ); + assert!(result.is_err()); +} + +#[test] +fn rename_or_copy_fallback_renames_on_same_device() { + let tmp = must!(TempDir::new(), "TempDir::new"); + let fs = RealFs; + let src = tmp.path().join("src_dir"); + let dst = tmp.path().join("dst_dir"); + must!(fs.create_dir_all(&src), "create src"); + must!(fs.write(&src.join("file.txt"), b"data"), "write file"); + + must!(fs.rename_or_copy(&src, &dst), "rename_or_copy"); + assert!(!fs.exists(&src)); + assert!(fs.exists(&dst)); + let content = must!(fs.read_to_string(&dst.join("file.txt")), "read dst"); + assert_eq!(content, "data"); +} diff --git a/librawssg_handler/Cargo.toml b/librawssg_handler/Cargo.toml index c4700d7..3ed2e16 100644 --- a/librawssg_handler/Cargo.toml +++ b/librawssg_handler/Cargo.toml @@ -10,6 +10,10 @@ keywords = ["ssg", "static-site-generator", "content", "processor", "traits"] categories = ["development-tools::build-utils"] [dependencies] +librawssg_error = { path = "../librawssg_error" } +librawssg_fs = { path = "../librawssg_fs" } +chrono = { version = "0.4", default-features = false, features = ["serde"] } +serde = { version = "1.0.229", features = ["derive"] } [lints] workspace = true diff --git a/librawssg_handler/src/document.rs b/librawssg_handler/src/document.rs new file mode 100644 index 0000000..0bba99f --- /dev/null +++ b/librawssg_handler/src/document.rs @@ -0,0 +1,14 @@ +use crate::Metadata; +use std::path::PathBuf; + +#[derive(Debug, Clone, PartialEq)] +pub struct Document { + pub metadata: Metadata, + pub body: String, + pub url: String, + pub source_path: PathBuf, + pub depth: usize, + pub content_type: String, + pub is_list: bool, + pub list_items: Option>, +} diff --git a/librawssg_handler/src/lib.rs b/librawssg_handler/src/lib.rs index b93cf3f..ed443b7 100644 --- a/librawssg_handler/src/lib.rs +++ b/librawssg_handler/src/lib.rs @@ -1,14 +1,7 @@ -pub fn add(left: u64, right: u64) -> u64 { - left + right -} +pub mod document; +pub mod metadata; +pub mod processor; -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn it_works() { - let result = add(2, 2); - assert_eq!(result, 4); - } -} +pub use document::Document; +pub use metadata::Metadata; +pub use processor::Processor; diff --git a/librawssg_handler/src/metadata.rs b/librawssg_handler/src/metadata.rs new file mode 100644 index 0000000..e3f31bc --- /dev/null +++ b/librawssg_handler/src/metadata.rs @@ -0,0 +1,14 @@ +use chrono::NaiveDate; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct Metadata { + pub title: String, + pub description: String, + pub author: Option, + pub repo_url: Option, + pub license: Option, + pub date: Option, + pub tags: Vec, + pub draft: bool, +} diff --git a/librawssg_handler/src/processor.rs b/librawssg_handler/src/processor.rs new file mode 100644 index 0000000..e6b7e2c --- /dev/null +++ b/librawssg_handler/src/processor.rs @@ -0,0 +1,15 @@ +use crate::Document; +use librawssg_error::Result; +use librawssg_fs::FileSystem; +use std::path::Path; + +pub trait Processor: Send + Sync { + fn can_process(&self, relative_path: &Path, original_path: &Path) -> bool; + + fn process( + &self, + fs: &dyn FileSystem, + relative_path: &Path, + content_dir: &Path, + ) -> Result>; +} From 87806dcd1c617f00c1fbbfd95ed6b4a50e747336 Mon Sep 17 00:00:00 2001 From: mroczect Date: Tue, 8 Sep 2026 01:50:23 +0700 Subject: [PATCH 07/48] feat(handler): add core data structures and processor extensions (#30) * chore(handler): update lockfile for new dependencies Add serde_json and its transitive dependencies to Cargo.lock. This reflects the new serde_json dependency in librawssg_handler and keeps the lockfile in sync with Cargo.toml. * feat(handler): add serde_json dependency Add serde_json version 1.0.151 to librawssg_handler dependencies. This crate will be used for storing and manipulating extra metadata as JSON values. * feat(handler): enhance Document with validation and taxonomies Add `output_path`, `taxonomies`, and derive `PartialEq`. Make the struct non-exhaustive. Implement a constructor `new` with validation for URL, output_path, source_path, and depth. Add helper methods `relative_url`, `add_taxonomy`, and `depth`. * refactor(handler): allow multiple crate versions lint Add `#![allow(clippy::multiple_crate_versions)]` at crate root to suppress warnings about multiple versions of dependencies across the workspace. This keeps clippy output clean without affecting functionality. * feat(handler): extend Metadata with extra fields and methods Add `updated: Option` and `extra: HashMap` fields. Make the struct non-exhaustive. Implement a constructor `new` that validates a non-empty title. Add helper methods `is_draft`, `insert_extra`, and `get_extra`. * feat(handler): add name and priority to Processor trait Extend the `Processor` trait with a required `name()` method and a default `priority()` method. These methods allow processors to be identified and ordered during content processing. * test(handler): add tests for Document Add comprehensive tests covering Document construction, validation errors, relative_url, add_taxonomy, depth, and default fields. * test(handler): add tests for Metadata Add tests for Metadata construction, validation, extra fields, draft flag, serialization, and deserialization from JSON. * test(handler): add tests for Processor trait Add tests using a mock Processor to verify name, priority, can_process, and process behavior. Includes a test for error propagation. * test(handler): add basic reexport availability test Add a test that ensures reexports of Metadata, Document, and Processor are available. This catches missing or broken public exports. --- Cargo.lock | 32 +++ librawssg_handler/Cargo.toml | 1 + librawssg_handler/src/document.rs | 69 +++++- librawssg_handler/src/lib.rs | 2 + librawssg_handler/src/metadata.rs | 37 +++ librawssg_handler/src/processor.rs | 6 + librawssg_handler/tests/document_tests.rs | 163 +++++++++++++ librawssg_handler/tests/metadata_tests.rs | 128 ++++++++++ librawssg_handler/tests/processor_tests.rs | 259 +++++++++++++++++++++ librawssg_handler/tests/unit_tests.rs | 20 ++ 10 files changed, 716 insertions(+), 1 deletion(-) create mode 100644 librawssg_handler/tests/document_tests.rs create mode 100644 librawssg_handler/tests/metadata_tests.rs create mode 100644 librawssg_handler/tests/processor_tests.rs create mode 100644 librawssg_handler/tests/unit_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 8734180..315859d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -57,6 +57,12 @@ dependencies = [ "r-efi", ] +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + [[package]] name = "libc" version = "0.2.189" @@ -95,6 +101,7 @@ dependencies = [ "librawssg_error", "librawssg_fs", "serde", + "serde_json", ] [[package]] @@ -107,6 +114,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + [[package]] name = "num-traits" version = "0.2.19" @@ -204,6 +217,19 @@ dependencies = [ "syn 3.0.5", ] +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "syn" version = "2.0.119" @@ -329,3 +355,9 @@ checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ "windows-link", ] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/librawssg_handler/Cargo.toml b/librawssg_handler/Cargo.toml index 3ed2e16..829e237 100644 --- a/librawssg_handler/Cargo.toml +++ b/librawssg_handler/Cargo.toml @@ -14,6 +14,7 @@ librawssg_error = { path = "../librawssg_error" } librawssg_fs = { path = "../librawssg_fs" } chrono = { version = "0.4", default-features = false, features = ["serde"] } serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0.151" [lints] workspace = true diff --git a/librawssg_handler/src/document.rs b/librawssg_handler/src/document.rs index 0bba99f..b9cad17 100644 --- a/librawssg_handler/src/document.rs +++ b/librawssg_handler/src/document.rs @@ -1,14 +1,81 @@ use crate::Metadata; +use librawssg_error::{Error, Result}; +use std::collections::HashMap; use std::path::PathBuf; #[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] pub struct Document { pub metadata: Metadata, pub body: String, pub url: String, + pub output_path: PathBuf, pub source_path: PathBuf, pub depth: usize, pub content_type: String, pub is_list: bool, - pub list_items: Option>, + pub list_items: Option>, + pub taxonomies: HashMap>, +} + +impl Document { + #[allow(clippy::too_many_arguments)] + pub fn new( + metadata: Metadata, + body: impl Into, + url: impl Into, + output_path: impl Into, + source_path: impl Into, + depth: usize, + content_type: impl Into, + is_list: bool, + ) -> Result { + let url = url.into(); + if url.trim().is_empty() { + return Err(Error::Validation("document url cannot be empty".into())); + } + let output_path = output_path.into(); + if output_path.as_os_str().is_empty() { + return Err(Error::Validation( + "document output_path cannot be empty".into(), + )); + } + let source_path = source_path.into(); + if source_path.file_name().is_none() { + return Err(Error::Validation( + "document source_path must have a file name".into(), + )); + } + if depth > 1000 { + return Err(Error::Validation( + "document depth is unreasonably large".into(), + )); + } + Ok(Self { + metadata, + body: body.into(), + url, + output_path, + source_path, + depth, + content_type: content_type.into(), + is_list, + list_items: None, + taxonomies: HashMap::new(), + }) + } + + #[must_use] + pub fn relative_url(&self) -> &str { + &self.url + } + + pub fn add_taxonomy(&mut self, name: impl Into, items: Vec) { + let _ = self.taxonomies.insert(name.into(), items); + } + + #[must_use] + pub const fn depth(&self) -> usize { + self.depth + } } diff --git a/librawssg_handler/src/lib.rs b/librawssg_handler/src/lib.rs index ed443b7..6751f36 100644 --- a/librawssg_handler/src/lib.rs +++ b/librawssg_handler/src/lib.rs @@ -1,3 +1,5 @@ +#![allow(clippy::multiple_crate_versions)] + pub mod document; pub mod metadata; pub mod processor; diff --git a/librawssg_handler/src/metadata.rs b/librawssg_handler/src/metadata.rs index e3f31bc..7d6b60e 100644 --- a/librawssg_handler/src/metadata.rs +++ b/librawssg_handler/src/metadata.rs @@ -1,7 +1,9 @@ use chrono::NaiveDate; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[non_exhaustive] pub struct Metadata { pub title: String, pub description: String, @@ -9,6 +11,41 @@ pub struct Metadata { pub repo_url: Option, pub license: Option, pub date: Option, + pub updated: Option, pub tags: Vec, pub draft: bool, + pub extra: HashMap, +} + +impl Metadata { + pub fn new( + title: impl Into, + description: impl Into, + ) -> librawssg_error::Result { + let title = title.into(); + if title.trim().is_empty() { + return Err(librawssg_error::Error::Validation( + "metadata title cannot be empty".into(), + )); + } + Ok(Self { + title, + description: description.into(), + ..Default::default() + }) + } + + #[must_use] + pub const fn is_draft(&self) -> bool { + self.draft + } + + pub fn insert_extra(&mut self, key: impl Into, value: impl Into) { + let _ = self.extra.insert(key.into(), value.into()); + } + + #[must_use] + pub fn get_extra(&self, key: &str) -> Option<&serde_json::Value> { + self.extra.get(key) + } } diff --git a/librawssg_handler/src/processor.rs b/librawssg_handler/src/processor.rs index e6b7e2c..5ff7b2d 100644 --- a/librawssg_handler/src/processor.rs +++ b/librawssg_handler/src/processor.rs @@ -4,6 +4,12 @@ use librawssg_fs::FileSystem; use std::path::Path; pub trait Processor: Send + Sync { + fn name(&self) -> &str; + + fn priority(&self) -> i32 { + 0 + } + fn can_process(&self, relative_path: &Path, original_path: &Path) -> bool; fn process( diff --git a/librawssg_handler/tests/document_tests.rs b/librawssg_handler/tests/document_tests.rs new file mode 100644 index 0000000..9d4a38c --- /dev/null +++ b/librawssg_handler/tests/document_tests.rs @@ -0,0 +1,163 @@ +use chrono as _; +use librawssg_fs as _; +use librawssg_handler::{Document, Metadata}; +use serde as _; +use serde_json as _; +use std::path::PathBuf; + +macro_rules! must { + ($result:expr, $context:expr) => { + match $result { + Ok(value) => value, + Err(err) => { + eprintln!("{} failed: {}", $context, err); + std::process::exit(1); + } + } + }; +} + +fn valid_metadata() -> Metadata { + must!(Metadata::new("Title", "Description"), "Metadata::new") +} + +fn valid_document() -> Document { + must!( + Document::new( + valid_metadata(), + "

Body

", + "blog/my-post.html", + "blog/my-post/index.html", + "content/blog/my-post.md", + 1, + "blog", + false, + ), + "Document::new" + ) +} + +#[test] +fn new_valid_document_ok() { + let doc = valid_document(); + assert_eq!(doc.metadata.title, "Title"); + assert_eq!(doc.body, "

Body

"); + assert_eq!(doc.url, "blog/my-post.html"); + assert_eq!(doc.output_path, PathBuf::from("blog/my-post/index.html")); + assert_eq!(doc.source_path, PathBuf::from("content/blog/my-post.md")); + assert_eq!(doc.depth, 1); + assert_eq!(doc.content_type, "blog"); + assert!(!doc.is_list); + assert!(doc.list_items.is_none()); + assert!(doc.taxonomies.is_empty()); +} + +#[test] +fn new_empty_url_errors() { + let result = Document::new( + valid_metadata(), + "body", + "", + "out", + "src.md", + 0, + "page", + false, + ); + assert!(result.is_err()); + if let Err(err) = result { + assert!(matches!( + err, + librawssg_error::Error::Validation(ref msg) if msg == "document url cannot be empty" + )); + } +} + +#[test] +fn new_empty_output_path_errors() { + let result = Document::new( + valid_metadata(), + "body", + "url", + "", + "src.md", + 0, + "page", + false, + ); + assert!(result.is_err()); + if let Err(err) = result { + assert!(matches!( + err, + librawssg_error::Error::Validation(ref msg) if msg == "document output_path cannot be empty" + )); + } +} + +#[test] +fn new_source_path_without_filename_errors() { + let result = Document::new(valid_metadata(), "body", "url", "out", "", 0, "page", false); + assert!(result.is_err()); + if let Err(err) = result { + assert!(matches!( + err, + librawssg_error::Error::Validation(ref msg) if msg == "document source_path must have a file name" + )); + } +} + +#[test] +fn new_depth_too_large_errors() { + let result = Document::new( + valid_metadata(), + "body", + "url", + "out", + "src.md", + 1001, + "page", + false, + ); + assert!(result.is_err()); + if let Err(err) = result { + assert!(matches!( + err, + librawssg_error::Error::Validation(ref msg) if msg == "document depth is unreasonably large" + )); + } +} + +#[test] +fn relative_url_returns_url() { + let doc = valid_document(); + assert_eq!(doc.relative_url(), "blog/my-post.html"); +} + +#[test] +fn add_taxonomy_inserts() { + let mut doc = valid_document(); + doc.add_taxonomy("categories", vec!["rust".to_string(), "ssg".to_string()]); + let tax = doc.taxonomies.get("categories"); + assert!(tax.is_some()); + if let Some(items) = tax { + assert_eq!(items, &vec!["rust".to_string(), "ssg".to_string()]); + } +} + +#[test] +fn depth_returns_depth() { + let doc = valid_document(); + assert_eq!(doc.depth(), 1); +} + +#[test] +fn list_items_default_none() { + let doc = valid_document(); + assert!(doc.list_items.is_none()); +} + +#[test] +fn taxonomies_default_empty() { + let doc = valid_document(); + assert!(doc.taxonomies.is_empty()); +} diff --git a/librawssg_handler/tests/metadata_tests.rs b/librawssg_handler/tests/metadata_tests.rs new file mode 100644 index 0000000..0ff7476 --- /dev/null +++ b/librawssg_handler/tests/metadata_tests.rs @@ -0,0 +1,128 @@ +use librawssg_fs as _; +use librawssg_handler::Metadata; +use serde as _; +use serde_json::json; + +macro_rules! must { + ($result:expr, $context:expr) => { + match $result { + Ok(value) => value, + Err(err) => { + eprintln!("{} failed: {}", $context, err); + std::process::exit(1); + } + } + }; +} + +#[test] +fn new_with_valid_title_ok() { + let meta = must!(Metadata::new("My Title", "My Description"), "Metadata::new"); + assert_eq!(meta.title, "My Title"); + assert_eq!(meta.description, "My Description"); + assert!(!meta.draft); + assert!(meta.tags.is_empty()); + assert!(meta.extra.is_empty()); +} + +#[test] +fn new_with_empty_title_errors() { + let result = Metadata::new("", "Desc"); + assert!(result.is_err()); + if let Err(err) = result { + assert!(matches!( + err, + librawssg_error::Error::Validation(ref msg) if msg == "metadata title cannot be empty" + )); + } +} + +#[test] +fn new_with_whitespace_title_errors() { + let result = Metadata::new(" ", "Desc"); + assert!(result.is_err()); +} + +#[test] +fn is_draft_reflects_field() { + let mut meta = must!(Metadata::new("Title", "Desc"), "Metadata::new"); + assert!(!meta.is_draft()); + meta.draft = true; + assert!(meta.is_draft()); +} + +#[test] +fn insert_extra_and_get() { + let mut meta = must!(Metadata::new("Title", "Desc"), "Metadata::new"); + meta.insert_extra("key", "value"); + let val = meta.get_extra("key"); + assert!(val.is_some()); + if let Some(v) = val { + assert_eq!(v, &json!("value")); + } +} + +#[test] +fn insert_extra_overwrites_existing() { + let mut meta = must!(Metadata::new("Title", "Desc"), "Metadata::new"); + meta.insert_extra("key", "first"); + meta.insert_extra("key", "second"); + let val = meta.get_extra("key"); + assert!(val.is_some()); + if let Some(v) = val { + assert_eq!(v, &json!("second")); + } +} + +#[test] +fn get_extra_missing_returns_none() { + let meta = must!(Metadata::new("Title", "Desc"), "Metadata::new"); + assert!(meta.get_extra("nonexistent").is_none()); +} + +#[test] +fn default_metadata_has_empty_fields() { + let meta = Metadata::default(); + assert_eq!(meta.title, ""); + assert_eq!(meta.description, ""); + assert!(!meta.draft); + assert!(meta.tags.is_empty()); + assert!(meta.extra.is_empty()); + assert!(meta.author.is_none()); + assert!(meta.date.is_none()); + assert!(meta.updated.is_none()); +} + +#[test] +fn serialization_roundtrip() { + let meta = must!(Metadata::new("Title", "Desc"), "Metadata::new"); + let json_str = must!(serde_json::to_string(&meta), "serialize"); + let deserialized: Metadata = must!(serde_json::from_str(&json_str), "deserialize"); + assert_eq!(meta, deserialized); +} + +#[test] +fn deserialize_from_json_with_extra() { + let json_str = r#"{ + "title": "Hello", + "description": "World", + "author": "Alice", + "date": "2026-09-08", + "tags": ["rust", "ssg"], + "draft": false, + "extra": {"foo": "bar"} + }"#; + let meta: Metadata = must!(serde_json::from_str(json_str), "deserialize"); + assert_eq!(meta.title, "Hello"); + assert_eq!(meta.description, "World"); + assert_eq!(meta.author.as_deref(), Some("Alice")); + let expected_date = chrono::NaiveDate::from_ymd_opt(2026, 9, 8); + assert_eq!(meta.date, expected_date); + assert_eq!(meta.tags, vec!["rust", "ssg"]); + assert!(!meta.draft); + let extra_val = meta.get_extra("foo"); + assert!(extra_val.is_some()); + if let Some(v) = extra_val { + assert_eq!(v, &json!("bar")); + } +} diff --git a/librawssg_handler/tests/processor_tests.rs b/librawssg_handler/tests/processor_tests.rs new file mode 100644 index 0000000..3a1145d --- /dev/null +++ b/librawssg_handler/tests/processor_tests.rs @@ -0,0 +1,259 @@ +use chrono as _; +use librawssg_error::Result; +use librawssg_fs::FileSystem; +use librawssg_handler::{Document, Metadata, Processor}; +use serde as _; +use serde_json as _; +use std::io; +use std::path::{Path, PathBuf}; + +macro_rules! must { + ($result:expr, $context:expr) => { + match $result { + Ok(value) => value, + Err(err) => { + eprintln!("{} failed: {}", $context, err); + std::process::exit(1); + } + } + }; +} + +struct DummyFs; +impl FileSystem for DummyFs { + fn read_to_string(&self, _path: &Path) -> io::Result { + Err(io::Error::other("not implemented")) + } + fn read_bytes(&self, _path: &Path) -> io::Result> { + Err(io::Error::other("not implemented")) + } + fn write(&self, _path: &Path, _content: &[u8]) -> io::Result<()> { + Err(io::Error::other("not implemented")) + } + fn create_dir_all(&self, _path: &Path) -> io::Result<()> { + Err(io::Error::other("not implemented")) + } + fn remove_dir_all(&self, _path: &Path) -> io::Result<()> { + Err(io::Error::other("not implemented")) + } + fn remove_file(&self, _path: &Path) -> io::Result<()> { + Err(io::Error::other("not implemented")) + } + fn create_dir(&self, _path: &Path) -> io::Result<()> { + Err(io::Error::other("not implemented")) + } + fn exists(&self, _path: &Path) -> bool { + false + } + fn is_dir(&self, _path: &Path) -> bool { + false + } + fn is_file(&self, _path: &Path) -> bool { + false + } + fn read_dir(&self, _path: &Path) -> io::Result> { + Err(io::Error::other("not implemented")) + } + fn copy_file(&self, _from: &Path, _to: &Path) -> io::Result { + Err(io::Error::other("not implemented")) + } + fn copy_dir_all(&self, _from: &Path, _to: &Path) -> io::Result<()> { + Err(io::Error::other("not implemented")) + } + fn walk_dir(&self, _root: &Path) -> io::Result> { + Err(io::Error::other("not implemented")) + } + fn canonicalize(&self, _path: &Path) -> io::Result { + Err(io::Error::other("not implemented")) + } + fn rename(&self, _from: &Path, _to: &Path) -> io::Result<()> { + Err(io::Error::other("not implemented")) + } + fn atomic_write(&self, _path: &Path, _content: &[u8]) -> io::Result<()> { + Err(io::Error::other("not implemented")) + } + fn touch(&self, _path: &Path) -> io::Result<()> { + Err(io::Error::other("not implemented")) + } + fn metadata(&self, _path: &Path) -> io::Result { + Err(io::Error::other("not implemented")) + } + fn symlink_metadata(&self, _path: &Path) -> io::Result { + Err(io::Error::other("not implemented")) + } + fn permissions(&self, _path: &Path) -> io::Result { + Err(io::Error::other("not implemented")) + } + fn set_permissions(&self, _path: &Path, _permissions: std::fs::Permissions) -> io::Result<()> { + Err(io::Error::other("not implemented")) + } + fn read_link(&self, _path: &Path) -> io::Result { + Err(io::Error::other("not implemented")) + } + fn hard_link(&self, _from: &Path, _to: &Path) -> io::Result<()> { + Err(io::Error::other("not implemented")) + } +} + +struct MockProcessor { + name: String, + priority: i32, + can_process_result: bool, + process_output: Option, +} + +impl Processor for MockProcessor { + fn name(&self) -> &str { + &self.name + } + fn priority(&self) -> i32 { + self.priority + } + fn can_process(&self, _relative_path: &Path, _original_path: &Path) -> bool { + self.can_process_result + } + fn process( + &self, + _fs: &dyn FileSystem, + _relative_path: &Path, + _content_dir: &Path, + ) -> Result> { + Ok(self.process_output.clone()) + } +} + +fn sample_document() -> Document { + let metadata = must!(Metadata::new("Sample", "Desc"), "Metadata::new"); + must!( + Document::new( + metadata, + "

Body

", + "sample.html", + "sample/index.html", + "content/sample.md", + 0, + "page", + false, + ), + "Document::new" + ) +} + +#[test] +fn name_returns_set_name() { + let p = MockProcessor { + name: "test".to_string(), + priority: 0, + can_process_result: false, + process_output: None, + }; + assert_eq!(p.name(), "test"); +} + +#[test] +fn priority_default_zero() { + let p = MockProcessor { + name: "default".to_string(), + priority: 0, + can_process_result: false, + process_output: None, + }; + assert_eq!(p.priority(), 0); +} + +#[test] +fn priority_custom() { + let p = MockProcessor { + name: "custom".to_string(), + priority: 5, + can_process_result: false, + process_output: None, + }; + assert_eq!(p.priority(), 5); +} + +#[test] +fn can_process_returns_true() { + let p = MockProcessor { + name: "true".to_string(), + priority: 0, + can_process_result: true, + process_output: None, + }; + assert!(p.can_process(Path::new("a.md"), Path::new("content/a.md"))); +} + +#[test] +fn can_process_returns_false() { + let p = MockProcessor { + name: "false".to_string(), + priority: 0, + can_process_result: false, + process_output: None, + }; + assert!(!p.can_process(Path::new("a.md"), Path::new("content/a.md"))); +} + +#[test] +fn process_returns_document() { + let doc = sample_document(); + let p = MockProcessor { + name: "doc".to_string(), + priority: 0, + can_process_result: true, + process_output: Some(doc.clone()), + }; + let result = must!( + p.process(&DummyFs, Path::new("a.md"), Path::new("content")), + "process" + ); + assert!(result.is_some()); + if let Some(processed) = result { + assert_eq!(processed, doc); + } +} + +#[test] +fn process_returns_none() { + let p = MockProcessor { + name: "none".to_string(), + priority: 0, + can_process_result: true, + process_output: None, + }; + let result = must!( + p.process(&DummyFs, Path::new("a.md"), Path::new("content")), + "process" + ); + assert!(result.is_none()); +} + +#[test] +fn process_error_case() { + struct ErrorProcessor; + impl Processor for ErrorProcessor { + fn name(&self) -> &'static str { + "error" + } + fn can_process(&self, _relative_path: &Path, _original_path: &Path) -> bool { + true + } + fn process( + &self, + _fs: &dyn FileSystem, + _relative_path: &Path, + _content_dir: &Path, + ) -> Result> { + Err(librawssg_error::Error::Processor("test error".into())) + } + } + let p = ErrorProcessor; + let result = p.process(&DummyFs, Path::new("a.md"), Path::new("content")); + assert!(result.is_err()); + if let Err(err) = result { + assert!(matches!( + err, + librawssg_error::Error::Processor(ref msg) if msg == "test error" + )); + } +} diff --git a/librawssg_handler/tests/unit_tests.rs b/librawssg_handler/tests/unit_tests.rs new file mode 100644 index 0000000..3f6b732 --- /dev/null +++ b/librawssg_handler/tests/unit_tests.rs @@ -0,0 +1,20 @@ +use chrono as _; +use librawssg_error as _; +use librawssg_fs as _; +use serde as _; +use serde_json as _; +#[test] +fn reexports_are_available() { + let _ = librawssg_handler::Metadata::default(); + let _ = librawssg_handler::Document::new( + librawssg_handler::Metadata::default(), + "", + "", + "", + "", + 0, + "", + false, + ); + let _: Option<&dyn librawssg_handler::Processor> = None; +} From acbdf439abb3c6cb2069d904cc7883f59d1ad202 Mon Sep 17 00:00:00 2001 From: mroczect Date: Tue, 8 Sep 2026 02:09:29 +0700 Subject: [PATCH 08/48] feat(config): implement configuration crate (#31) * chore(lock): update lockfile for config crate deps Add entries for serde_yaml and its dependencies, plus indexmap and unsafe-libyaml. Update existing entries to reflect new dependency graph. * feat(config): add dependencies for config crate Add librawssg_error, serde, serde_json, serde_yaml as dependencies and tempfile as dev-dependency. This enables config parsing, serialization, and testing. * feat(config): expose config modules and types Replace placeholder with module declarations for build, config, content_rule, nav, and site. Re-export public types and add crate-level lint allowance. * chore(handler): specify version for path dependencies Add version = "1.0.0" to librawssg_error and librawssg_fs path dependencies. This clarifies compatibility requirements and aligns with workspace versioning. * feat(config): define BuildConfig struct Add BuildConfig with content_dir, output_dir, templates_dir, static_dir and serde defaults. Implement Default, new, and derive traits for serialization. * feat(config): implement Config with validation and parsing Add Config struct with site, build, content_rules, extra. Provide methods for rule management, validation, YAML/JSON conversion. Validation checks site name, rules, patterns, and base_url. * feat(config): define ContentRule struct Add ContentRule with name, pattern, template, optional list fields, extra. Derive serde traits and provide a constructor. * feat(config): define NavItem struct Add NavItem with label, url, children and serde support. Provide a constructor and Default. * feat(config): define SiteConfig struct Add SiteConfig with nav, sidebar, site_name, description, language, base_url, author, repo_url, license, extra. Implement Default and a constructor. * test(config): add BuildConfig tests Add tests for default values, new equality, YAML roundtrip, and deserialization with defaults. * test(config): add Config tests Add comprehensive tests for Config construction, rule management, validation, YAML/JSON roundtrip, and error cases. * test(config): add ContentRule tests Add tests for constructor, default, and serialization roundtrip. * test(config): add NavItem tests Add tests for constructor, default, child addition, and serialization roundtrip. * test(config): add SiteConfig tests Add tests for constructor, default values, and serialization roundtrip. --- Cargo.lock | 54 ++++ librawssg_config/Cargo.toml | 7 + librawssg_config/src/build.rs | 45 ++++ librawssg_config/src/config.rs | 122 +++++++++ librawssg_config/src/content_rule.rs | 34 +++ librawssg_config/src/lib.rs | 26 +- librawssg_config/src/nav.rs | 20 ++ librawssg_config/src/site.rs | 63 +++++ librawssg_config/tests/build_tests.rs | 50 ++++ librawssg_config/tests/config_tests.rs | 255 +++++++++++++++++++ librawssg_config/tests/content_rule_tests.rs | 51 ++++ librawssg_config/tests/nav_tests.rs | 52 ++++ librawssg_config/tests/site_tests.rs | 51 ++++ librawssg_handler/Cargo.toml | 4 +- 14 files changed, 820 insertions(+), 14 deletions(-) create mode 100644 librawssg_config/src/build.rs create mode 100644 librawssg_config/src/config.rs create mode 100644 librawssg_config/src/content_rule.rs create mode 100644 librawssg_config/src/nav.rs create mode 100644 librawssg_config/src/site.rs create mode 100644 librawssg_config/tests/build_tests.rs create mode 100644 librawssg_config/tests/config_tests.rs create mode 100644 librawssg_config/tests/content_rule_tests.rs create mode 100644 librawssg_config/tests/nav_tests.rs create mode 100644 librawssg_config/tests/site_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 315859d..00f2773 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -30,6 +30,12 @@ dependencies = [ "serde", ] +[[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" @@ -57,6 +63,22 @@ dependencies = [ "r-efi", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "itoa" version = "1.0.18" @@ -76,6 +98,13 @@ version = "1.0.0" [[package]] name = "librawssg_config" version = "1.0.0" +dependencies = [ + "librawssg_error", + "serde", + "serde_json", + "serde_yaml", + "tempfile", +] [[package]] name = "librawssg_error" @@ -178,6 +207,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -230,6 +265,19 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "syn" version = "2.0.119" @@ -322,6 +370,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "walkdir" version = "2.5.0" diff --git a/librawssg_config/Cargo.toml b/librawssg_config/Cargo.toml index 9585972..8e43b21 100644 --- a/librawssg_config/Cargo.toml +++ b/librawssg_config/Cargo.toml @@ -10,6 +10,13 @@ keywords = ["ssg", "static-site-generator", "config", "yaml", "validation"] categories = ["development-tools::build-utils"] [dependencies] +librawssg_error = { version = "1.0.0", path = "../librawssg_error" } +serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0.151" +serde_yaml = "0.9.34" + +[dev-dependencies] +tempfile = "3" [lints] workspace = true diff --git a/librawssg_config/src/build.rs b/librawssg_config/src/build.rs new file mode 100644 index 0000000..aaaabd2 --- /dev/null +++ b/librawssg_config/src/build.rs @@ -0,0 +1,45 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[non_exhaustive] +pub struct BuildConfig { + #[serde(default = "default_content_dir")] + pub content_dir: String, + #[serde(default = "default_output_dir")] + pub output_dir: String, + #[serde(default = "default_templates_dir")] + pub templates_dir: String, + #[serde(default = "default_static_dir")] + pub static_dir: String, +} + +fn default_content_dir() -> String { + "content".into() +} +fn default_output_dir() -> String { + "dist".into() +} +fn default_templates_dir() -> String { + "templates".into() +} +fn default_static_dir() -> String { + "static".into() +} + +impl BuildConfig { + #[must_use] + pub fn new() -> Self { + Self::default() + } +} + +impl Default for BuildConfig { + fn default() -> Self { + Self { + content_dir: default_content_dir(), + output_dir: default_output_dir(), + templates_dir: default_templates_dir(), + static_dir: default_static_dir(), + } + } +} diff --git a/librawssg_config/src/config.rs b/librawssg_config/src/config.rs new file mode 100644 index 0000000..874b83d --- /dev/null +++ b/librawssg_config/src/config.rs @@ -0,0 +1,122 @@ +use librawssg_error::{Error, Result}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +use super::{BuildConfig, ContentRule, SiteConfig}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[non_exhaustive] +pub struct Config { + pub site: SiteConfig, + pub build: BuildConfig, + pub content_rules: Vec, + #[serde(default)] + pub extra: HashMap, +} + +impl Config { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + #[must_use] + pub fn with_site_name(mut self, name: impl Into) -> Self { + self.site.site_name = name.into(); + self + } + + pub fn add_content_rule(&mut self, rule: ContentRule) { + self.content_rules.push(rule); + } + + #[must_use] + pub fn find_rule_by_name(&self, name: &str) -> Option<&ContentRule> { + self.content_rules.iter().find(|rule| rule.name == name) + } + + pub fn remove_rule_by_name(&mut self, name: &str) -> Option { + let pos = self.content_rules.iter().position(|rule| rule.name == name); + pos.map(|idx| self.content_rules.remove(idx)) + } + + #[must_use] + pub fn has_duplicate_rule_names(&self) -> bool { + let mut seen = std::collections::HashSet::new(); + self.content_rules + .iter() + .any(|rule| !seen.insert(rule.name.clone())) + } + + pub fn validate(&self) -> Result<()> { + if self.site.site_name.trim().is_empty() { + return Err(Error::Validation("site_name cannot be empty".into())); + } + + if self.content_rules.is_empty() { + return Err(Error::Validation( + "at least one content rule must be defined".into(), + )); + } + + if self.has_duplicate_rule_names() { + return Err(Error::Validation( + "duplicate content rule names are not allowed".into(), + )); + } + + for (idx, rule) in self.content_rules.iter().enumerate() { + if rule.name.trim().is_empty() { + return Err(Error::Validation(format!( + "content rule #{idx}: name cannot be empty" + ))); + } + if rule.pattern.trim().is_empty() { + return Err(Error::Validation(format!( + "content rule '{}': pattern cannot be empty", + rule.name + ))); + } + if rule.pattern.contains("..") { + return Err(Error::Validation(format!( + "content rule '{}': pattern contains invalid '..'", + rule.name + ))); + } + if rule.template.trim().is_empty() { + return Err(Error::Validation(format!( + "content rule '{}': template cannot be empty", + rule.name + ))); + } + } + + if let Some(base_url) = &self.site.base_url + && !(base_url.starts_with("http://") || base_url.starts_with("https://")) + { + return Err(Error::Validation( + "site.base_url must start with http:// or https://".into(), + )); + } + + Ok(()) + } + + pub fn from_yaml_str(yaml: &str) -> Result { + serde_yaml::from_str(yaml).map_err(|e| Error::Config(format!("invalid YAML config: {e}"))) + } + + pub fn to_yaml_string(&self) -> Result { + serde_yaml::to_string(self) + .map_err(|e| Error::Serialization(format!("failed to serialize config: {e}"))) + } + + pub fn from_json_str(json: &str) -> Result { + serde_json::from_str(json).map_err(|e| Error::Config(format!("invalid JSON config: {e}"))) + } + + pub fn to_json_string(&self) -> Result { + serde_json::to_string(self) + .map_err(|e| Error::Serialization(format!("failed to serialize config: {e}"))) + } +} diff --git a/librawssg_config/src/content_rule.rs b/librawssg_config/src/content_rule.rs new file mode 100644 index 0000000..e772eaf --- /dev/null +++ b/librawssg_config/src/content_rule.rs @@ -0,0 +1,34 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[non_exhaustive] +pub struct ContentRule { + pub name: String, + pub pattern: String, + pub template: String, + #[serde(default)] + pub list_template: Option, + #[serde(default)] + pub list_enabled: bool, + #[serde(default)] + pub extra: HashMap, +} + +impl ContentRule { + #[must_use] + pub fn new( + name: impl Into, + pattern: impl Into, + template: impl Into, + ) -> Self { + Self { + name: name.into(), + pattern: pattern.into(), + template: template.into(), + list_template: None, + list_enabled: false, + extra: HashMap::new(), + } + } +} diff --git a/librawssg_config/src/lib.rs b/librawssg_config/src/lib.rs index b93cf3f..6d74594 100644 --- a/librawssg_config/src/lib.rs +++ b/librawssg_config/src/lib.rs @@ -1,14 +1,16 @@ -pub fn add(left: u64, right: u64) -> u64 { - left + right -} +#![allow(clippy::multiple_crate_versions)] -#[cfg(test)] -mod tests { - use super::*; +pub mod build; +pub mod config; +pub mod content_rule; +pub mod nav; +pub mod site; + +pub use build::BuildConfig; +pub use config::Config; +pub use content_rule::ContentRule; +pub use nav::NavItem; +pub use site::SiteConfig; - #[test] - fn it_works() { - let result = add(2, 2); - assert_eq!(result, 4); - } -} +#[cfg(test)] +use tempfile as _; diff --git a/librawssg_config/src/nav.rs b/librawssg_config/src/nav.rs new file mode 100644 index 0000000..980f701 --- /dev/null +++ b/librawssg_config/src/nav.rs @@ -0,0 +1,20 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[non_exhaustive] +pub struct NavItem { + pub label: String, + pub url: String, + pub children: Vec, +} + +impl NavItem { + #[must_use] + pub fn new(label: impl Into, url: impl Into) -> Self { + Self { + label: label.into(), + url: url.into(), + children: Vec::new(), + } + } +} diff --git a/librawssg_config/src/site.rs b/librawssg_config/src/site.rs new file mode 100644 index 0000000..4682663 --- /dev/null +++ b/librawssg_config/src/site.rs @@ -0,0 +1,63 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[non_exhaustive] +pub struct SiteConfig { + #[serde(default)] + pub navbar: Vec, + #[serde(default)] + pub sidebar: Vec, + #[serde(default = "default_site_name")] + pub site_name: String, + #[serde(default)] + pub description: Option, + #[serde(default = "default_language")] + pub language: Option, + #[serde(default)] + pub base_url: Option, + #[serde(default)] + pub author: Option, + #[serde(default)] + pub repo_url: Option, + #[serde(default)] + pub license: Option, + #[serde(default)] + pub extra: HashMap, +} + +fn default_site_name() -> String { + "librawssg".into() +} + +#[allow(clippy::unnecessary_wraps)] +fn default_language() -> Option { + Some("en".into()) +} + +impl SiteConfig { + #[must_use] + pub fn new(site_name: impl Into) -> Self { + Self { + site_name: site_name.into(), + ..Self::default() + } + } +} + +impl Default for SiteConfig { + fn default() -> Self { + Self { + navbar: Vec::new(), + sidebar: Vec::new(), + site_name: default_site_name(), + description: None, + language: default_language(), + base_url: None, + author: None, + repo_url: None, + license: None, + extra: HashMap::new(), + } + } +} diff --git a/librawssg_config/tests/build_tests.rs b/librawssg_config/tests/build_tests.rs new file mode 100644 index 0000000..745b49c --- /dev/null +++ b/librawssg_config/tests/build_tests.rs @@ -0,0 +1,50 @@ +use librawssg_config::BuildConfig; +use librawssg_error as _; +use serde as _; +use serde_json as _; +use tempfile as _; + +macro_rules! must { + ($result:expr, $context:expr) => { + match $result { + Ok(value) => value, + Err(err) => { + eprintln!("{} failed: {}", $context, err); + std::process::exit(1); + } + } + }; +} + +#[test] +fn default_values_are_correct() { + let build = BuildConfig::default(); + assert_eq!(build.content_dir, "content"); + assert_eq!(build.output_dir, "dist"); + assert_eq!(build.templates_dir, "templates"); + assert_eq!(build.static_dir, "static"); +} + +#[test] +fn new_equals_default() { + let build = BuildConfig::new(); + assert_eq!(build, BuildConfig::default()); +} + +#[test] +fn serialize_deserialize_roundtrip_yaml() { + let build = BuildConfig::default(); + let yaml = must!(serde_yaml::to_string(&build), "serialize yaml"); + let parsed: BuildConfig = must!(serde_yaml::from_str(&yaml), "deserialize yaml"); + assert_eq!(build, parsed); +} + +#[test] +fn deserialize_from_yaml_with_defaults() { + let yaml = "content_dir: custom_content\noutput_dir: public\n"; + let build: BuildConfig = must!(serde_yaml::from_str(yaml), "deserialize yaml"); + assert_eq!(build.content_dir, "custom_content"); + assert_eq!(build.output_dir, "public"); + assert_eq!(build.templates_dir, "templates"); + assert_eq!(build.static_dir, "static"); +} diff --git a/librawssg_config/tests/config_tests.rs b/librawssg_config/tests/config_tests.rs new file mode 100644 index 0000000..38ba498 --- /dev/null +++ b/librawssg_config/tests/config_tests.rs @@ -0,0 +1,255 @@ +use librawssg_config::{Config, ContentRule}; +use librawssg_error as _; +use serde as _; +use serde_json::json; +use serde_yaml as _; +use tempfile as _; + +macro_rules! must { + ($result:expr, $context:expr) => { + match $result { + Ok(value) => value, + Err(err) => { + eprintln!("{} failed: {}", $context, err); + std::process::exit(1); + } + } + }; +} + +fn valid_config() -> Config { + let mut config = Config::new().with_site_name("My Site"); + config.add_content_rule(ContentRule::new("page", "**/*.html", "base")); + config +} + +#[test] +fn new_creates_default_with_empty_rules() { + let config = Config::new(); + assert_eq!(config.site.site_name, "librawssg"); + assert!(config.content_rules.is_empty()); + assert!(config.extra.is_empty()); +} + +#[test] +fn with_site_name_updates_name() { + let config = Config::new().with_site_name("Awesome"); + assert_eq!(config.site.site_name, "Awesome"); +} + +#[test] +fn add_and_find_rule() { + let mut config = Config::new(); + config.add_content_rule(ContentRule::new("blog", "**/*.md", "post")); + assert!(config.find_rule_by_name("blog").is_some()); + assert!(config.find_rule_by_name("missing").is_none()); +} + +#[test] +fn remove_rule_by_name_returns_removed() { + let mut config = Config::new(); + config.add_content_rule(ContentRule::new("blog", "**/*.md", "post")); + let removed = config.remove_rule_by_name("blog"); + assert!(removed.is_some()); + assert!(config.find_rule_by_name("blog").is_none()); +} + +#[test] +fn has_duplicate_rule_names_detects_duplicates() { + let mut config = Config::new(); + config.add_content_rule(ContentRule::new("page", "**/*.html", "base")); + config.add_content_rule(ContentRule::new("page", "**/*.raw", "raw")); + assert!(config.has_duplicate_rule_names()); +} + +#[test] +fn valid_config_passes_validation() { + let config = valid_config(); + assert!(config.validate().is_ok()); +} + +#[test] +fn empty_site_name_fails_validation() { + let mut config = valid_config(); + config.site.site_name = " ".to_string(); + let result = config.validate(); + assert!(result.is_err()); + if let Err(err) = result { + assert!( + matches!(err, librawssg_error::Error::Validation(ref msg) if msg == "site_name cannot be empty") + ); + } +} + +#[test] +fn no_content_rules_fails_validation() { + let mut config = valid_config(); + config.content_rules.clear(); + let result = config.validate(); + assert!(result.is_err()); + if let Err(err) = result { + assert!( + matches!(err, librawssg_error::Error::Validation(ref msg) if msg == "at least one content rule must be defined") + ); + } +} + +#[test] +fn duplicate_rule_names_fails_validation() { + let mut config = valid_config(); + config.add_content_rule(ContentRule::new("page", "**/*.raw", "raw")); + let result = config.validate(); + assert!(result.is_err()); + if let Err(err) = result { + assert!( + matches!(err, librawssg_error::Error::Validation(ref msg) if msg == "duplicate content rule names are not allowed") + ); + } +} + +#[test] +fn content_rule_empty_name_fails_validation() { + let mut config = valid_config(); + assert!( + config.content_rules.first_mut().is_some(), + "rule should exist" + ); + if let Some(rule) = config.content_rules.first_mut() { + rule.name = String::new(); + } + let result = config.validate(); + assert!(result.is_err()); +} + +#[test] +fn content_rule_empty_pattern_fails_validation() { + let mut config = valid_config(); + assert!( + config.content_rules.first_mut().is_some(), + "rule should exist" + ); + if let Some(rule) = config.content_rules.first_mut() { + rule.pattern = String::new(); + } + let result = config.validate(); + assert!(result.is_err()); +} + +#[test] +fn content_rule_pattern_with_dotdot_fails_validation() { + let mut config = valid_config(); + assert!( + config.content_rules.first_mut().is_some(), + "rule should exist" + ); + if let Some(rule) = config.content_rules.first_mut() { + rule.pattern = "**/../*.html".to_string(); + } + let result = config.validate(); + assert!(result.is_err()); + if let Err(err) = result { + assert!( + matches!(err, librawssg_error::Error::Validation(ref msg) if msg.contains("contains invalid '..'")) + ); + } +} + +#[test] +fn content_rule_empty_template_fails_validation() { + let mut config = valid_config(); + assert!( + config.content_rules.first_mut().is_some(), + "rule should exist" + ); + if let Some(rule) = config.content_rules.first_mut() { + rule.template = String::new(); + } + let result = config.validate(); + assert!(result.is_err()); +} + +#[test] +fn invalid_base_url_fails_validation() { + let mut config = valid_config(); + config.site.base_url = Some("ftp://example.com".to_string()); + let result = config.validate(); + assert!(result.is_err()); + if let Err(err) = result { + assert!( + matches!(err, librawssg_error::Error::Validation(ref msg) if msg == "site.base_url must start with http:// or https://") + ); + } +} + +#[test] +fn valid_base_url_passes_validation() { + let mut config = valid_config(); + config.site.base_url = Some("https://example.com".to_string()); + assert!(config.validate().is_ok()); +} + +#[test] +fn yaml_roundtrip() { + let config = valid_config(); + let yaml = must!(config.to_yaml_string(), "to_yaml_string"); + let parsed = must!(Config::from_yaml_str(&yaml), "from_yaml_str"); + assert_eq!(config, parsed); +} + +#[test] +fn json_roundtrip() { + let config = valid_config(); + let json = must!(config.to_json_string(), "to_json_string"); + let parsed = must!(Config::from_json_str(&json), "from_json_str"); + assert_eq!(config, parsed); +} + +#[test] +fn deserialize_from_yaml_with_extra_fields() { + let yaml = r#" +site: + site_name: Test Site + description: A test + extra: + foo: bar +build: + content_dir: src +content_rules: + - name: page + pattern: "**/*.raw" + template: "main" + list_enabled: true +"#; + let config = must!(Config::from_yaml_str(yaml), "from_yaml_str"); + assert_eq!(config.site.site_name, "Test Site"); + assert_eq!(config.build.content_dir, "src"); + assert_eq!(config.content_rules.len(), 1); + assert!(!config.content_rules.is_empty(), "rule should exist"); + if let Some(rule) = config.content_rules.first() { + assert_eq!(rule.name, "page"); + assert!(rule.list_enabled); + } + let extra = config.site.extra.get("foo"); + assert!(extra.is_some()); + if let Some(value) = extra { + assert_eq!(value, &json!("bar")); + } +} + +#[test] +fn deserialize_invalid_yaml_returns_config_error() { + let result = Config::from_yaml_str("invalid_yaml: ["); + assert!(result.is_err()); + if let Err(err) = result { + assert!(matches!(err, librawssg_error::Error::Config(_))); + } +} + +#[test] +fn deserialize_invalid_json_returns_config_error() { + let result = Config::from_json_str("{invalid json"); + assert!(result.is_err()); + if let Err(err) = result { + assert!(matches!(err, librawssg_error::Error::Config(_))); + } +} diff --git a/librawssg_config/tests/content_rule_tests.rs b/librawssg_config/tests/content_rule_tests.rs new file mode 100644 index 0000000..527105e --- /dev/null +++ b/librawssg_config/tests/content_rule_tests.rs @@ -0,0 +1,51 @@ +use librawssg_config::ContentRule; +use librawssg_error as _; +use serde as _; +use serde_json::json; +use serde_yaml as _; +use tempfile as _; + +macro_rules! must { + ($result:expr, $context:expr) => { + match $result { + Ok(value) => value, + Err(err) => { + eprintln!("{} failed: {}", $context, err); + std::process::exit(1); + } + } + }; +} + +#[test] +fn new_sets_required_fields() { + let rule = ContentRule::new("blog", "**/*.md", "post"); + assert_eq!(rule.name, "blog"); + assert_eq!(rule.pattern, "**/*.md"); + assert_eq!(rule.template, "post"); + assert!(!rule.list_enabled); + assert!(rule.list_template.is_none()); + assert!(rule.extra.is_empty()); +} + +#[test] +fn default_is_empty() { + let rule = ContentRule::default(); + assert_eq!(rule.name, ""); + assert_eq!(rule.pattern, ""); + assert_eq!(rule.template, ""); + assert!(!rule.list_enabled); + assert!(rule.list_template.is_none()); + assert!(rule.extra.is_empty()); +} + +#[test] +fn serialize_deserialize_roundtrip() { + let mut rule = ContentRule::new("page", "**/*.html", "base"); + rule.list_enabled = true; + rule.list_template = Some("list".into()); + let _ = rule.extra.insert("key".into(), json!("value")); + let yaml = must!(serde_yaml::to_string(&rule), "serialize"); + let parsed: ContentRule = must!(serde_yaml::from_str(&yaml), "deserialize"); + assert_eq!(rule, parsed); +} diff --git a/librawssg_config/tests/nav_tests.rs b/librawssg_config/tests/nav_tests.rs new file mode 100644 index 0000000..2b2e67e --- /dev/null +++ b/librawssg_config/tests/nav_tests.rs @@ -0,0 +1,52 @@ +use librawssg_config::NavItem; +use librawssg_error as _; +use serde as _; +use serde_yaml as _; +use tempfile as _; + +macro_rules! must { + ($result:expr, $context:expr) => { + match $result { + Ok(value) => value, + Err(err) => { + eprintln!("{} failed: {}", $context, err); + std::process::exit(1); + } + } + }; +} + +#[test] +fn new_sets_label_and_url() { + let item = NavItem::new("Home", "/"); + assert_eq!(item.label, "Home"); + assert_eq!(item.url, "/"); + assert!(item.children.is_empty()); +} + +#[test] +fn default_is_empty() { + let item = NavItem::default(); + assert_eq!(item.label, ""); + assert_eq!(item.url, ""); + assert!(item.children.is_empty()); +} + +#[test] +fn children_can_be_added() { + let mut parent = NavItem::new("Docs", "/docs"); + parent.children.push(NavItem::new("API", "/docs/api")); + assert_eq!(parent.children.len(), 1); + assert!(!parent.children.is_empty(), "child should exist"); + if let Some(child) = parent.children.first() { + assert_eq!(child.label, "API"); + } +} + +#[test] +fn serialize_deserialize_roundtrip() { + let item = NavItem::new("Home", "/"); + let json = must!(serde_json::to_string(&item), "serialize"); + let parsed: NavItem = must!(serde_json::from_str(&json), "deserialize"); + assert_eq!(item, parsed); +} diff --git a/librawssg_config/tests/site_tests.rs b/librawssg_config/tests/site_tests.rs new file mode 100644 index 0000000..b910db3 --- /dev/null +++ b/librawssg_config/tests/site_tests.rs @@ -0,0 +1,51 @@ +use librawssg_config::SiteConfig; +use librawssg_error as _; +use serde as _; +use serde_json::json; +use serde_yaml as _; +use tempfile as _; + +macro_rules! must { + ($result:expr, $context:expr) => { + match $result { + Ok(value) => value, + Err(err) => { + eprintln!("{} failed: {}", $context, err); + std::process::exit(1); + } + } + }; +} + +#[test] +fn new_sets_site_name_only() { + let site = SiteConfig::new("My Site"); + assert_eq!(site.site_name, "My Site"); + assert_eq!(site.language.as_deref(), Some("en")); + assert!(site.navbar.is_empty()); + assert!(site.sidebar.is_empty()); +} + +#[test] +fn default_has_expected_values() { + let site = SiteConfig::default(); + assert_eq!(site.site_name, "librawssg"); + assert_eq!(site.language.as_deref(), Some("en")); + assert!(site.navbar.is_empty()); + assert!(site.sidebar.is_empty()); + assert!(site.description.is_none()); + assert!(site.base_url.is_none()); + assert!(site.author.is_none()); + assert!(site.repo_url.is_none()); + assert!(site.license.is_none()); + assert!(site.extra.is_empty()); +} + +#[test] +fn serialize_deserialize_roundtrip() { + let mut site = SiteConfig::new("Test"); + let _ = site.extra.insert("foo".into(), json!("bar")); + let yaml = must!(serde_yaml::to_string(&site), "serialize"); + let parsed: SiteConfig = must!(serde_yaml::from_str(&yaml), "deserialize"); + assert_eq!(site, parsed); +} diff --git a/librawssg_handler/Cargo.toml b/librawssg_handler/Cargo.toml index 829e237..c77e3ad 100644 --- a/librawssg_handler/Cargo.toml +++ b/librawssg_handler/Cargo.toml @@ -10,8 +10,8 @@ keywords = ["ssg", "static-site-generator", "content", "processor", "traits"] categories = ["development-tools::build-utils"] [dependencies] -librawssg_error = { path = "../librawssg_error" } -librawssg_fs = { path = "../librawssg_fs" } +librawssg_error = { version = "1.0.0", path = "../librawssg_error" } +librawssg_fs = { version = "1.0.0", path = "../librawssg_fs" } chrono = { version = "0.4", default-features = false, features = ["serde"] } serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" From 2707a185ba2941008bdc22b586c9998161b3f968 Mon Sep 17 00:00:00 2001 From: mroczect Date: Tue, 8 Sep 2026 02:24:09 +0700 Subject: [PATCH 09/48] feat(templates): add renderer traits and Tera implementation (#32) * chore(lock): update lockfile for templates crate dependencies Add dependencies for tera and its transitive crates (chrono-tz, globwalk, pest, slug, etc.) to Cargo.lock. This reflects the new optional tera feature in librawssg_templates and keeps the lockfile in sync with Cargo.toml changes. * feat(templates): add tera feature and dependencies Add optional tera feature with default enabled. Add librawssg_error, walkdir, and tera dependencies. Add tempfile as dev-dependency. Update keywords to include tera. * feat(templates): expose renderer and tera modules Replace placeholder with module declarations for renderer and optional tera_renderer. Re-export public types and add crate-level lint allowance for multiple crate versions. * feat(templates): define RenderContext and Renderer traits Add RenderContext trait with as_any and as_mut_any methods. Add Renderer trait with render method returning Result. * feat(templates): implement TeraRenderer Add TeraRenderer struct with methods for adding raw templates, loading from files/directories, enabling autoescape, and rendering. Implement Renderer and RenderContext for tera::Context. * test(templates): add TeraRenderer integration tests Add comprehensive tests for TeraRenderer including raw templates, loops, filters, conditions, file loading, recursion, autoescape, inheritance, macros, includes, and mutable access. * test(templates): add basic trait and reexport tests Add unit tests verifying Renderer and RenderContext traits can be mocked and reexports are available. --- Cargo.lock | 611 ++++++++++++++++++++++- librawssg_templates/Cargo.toml | 15 +- librawssg_templates/src/lib.rs | 22 +- librawssg_templates/src/renderer.rs | 12 + librawssg_templates/src/tera_renderer.rs | 140 ++++++ librawssg_templates/tests/tera_tests.rs | 282 +++++++++++ librawssg_templates/tests/unit_tests.rs | 55 ++ 7 files changed, 1123 insertions(+), 14 deletions(-) create mode 100644 librawssg_templates/src/renderer.rs create mode 100644 librawssg_templates/src/tera_renderer.rs create mode 100644 librawssg_templates/tests/tera_tests.rs create mode 100644 librawssg_templates/tests/unit_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 00f2773..71a5a0d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,24 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + [[package]] name = "autocfg" version = "1.5.1" @@ -14,6 +32,32 @@ version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "serde_core", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cc" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -26,10 +70,71 @@ version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ + "iana-time-zone", "num-traits", "serde", + "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 = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", ] +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "deunicode" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" + [[package]] name = "equivalent" version = "1.0.2" @@ -52,6 +157,47 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "find-msvc-tools" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[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.4.3" @@ -63,12 +209,85 @@ dependencies = [ "r-efi", ] +[[package]] +name = "globset" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "globwalk" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757" +dependencies = [ + "bitflags", + "ignore", + "walkdir", +] + [[package]] name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "humansize" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7" +dependencies = [ + "libm", +] + +[[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 = "ignore" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b69833ed729dc5aa7d19541d96d6cf8e9137194207a04916d658e43168402f" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + [[package]] name = "indexmap" version = "2.14.2" @@ -85,12 +304,35 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[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.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "librawssg_compiler" version = "1.0.0" @@ -136,6 +378,12 @@ dependencies = [ [[package]] name = "librawssg_templates" version = "1.0.0" +dependencies = [ + "librawssg_error", + "tempfile", + "tera", + "walkdir", +] [[package]] name = "linux-raw-sys" @@ -143,6 +391,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + [[package]] name = "memchr" version = "2.8.3" @@ -164,12 +418,116 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[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.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d45aeb61b4bf818e12d4205f2466f8c4748f85f4fce0146d1c03d69d753f0ad" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89cc5a242e25ed4e7704d0be240f2cfbe20a8c27e7e252d94835be93d92dc39f" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7abf21475cc3820fe4b2ca2dc2142902f67a02189f3b5b3a229f4febc01a43e5" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pest_meta" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adba4db388f687393c18c51348d44a41d870ca9df71a2c98172ea3035dc6936e" +dependencies = [ + "pest", +] + +[[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", +] + +[[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.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -194,6 +552,65 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[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", +] + +[[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 = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "rustix" version = "1.1.4" @@ -207,6 +624,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + [[package]] name = "ryu" version = "1.0.23" @@ -278,6 +701,34 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[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 = "syn" version = "2.0.119" @@ -307,12 +758,34 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys", ] +[[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", + "regex", + "serde", + "serde_json", + "slug", + "unicode-segmentation", +] + [[package]] name = "thiserror" version = "2.0.20" @@ -364,12 +837,24 @@ dependencies = [ "once_cell", ] +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -386,6 +871,57 @@ dependencies = [ "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 = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.5", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + [[package]] name = "winapi-util" version = "0.1.11" @@ -395,12 +931,65 @@ dependencies = [ "windows-sys", ] +[[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 2.0.119", +] + +[[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 2.0.119", +] + [[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.61.2" @@ -410,6 +999,26 @@ dependencies = [ "windows-link", ] +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "zmij" version = "1.0.23" diff --git a/librawssg_templates/Cargo.toml b/librawssg_templates/Cargo.toml index 6fecaf7..28629ed 100644 --- a/librawssg_templates/Cargo.toml +++ b/librawssg_templates/Cargo.toml @@ -6,10 +6,23 @@ description = "Template rendering contracts for the librawssg static site genera license = "MIT" repository = "https://github.com/mroczect/librawssg" readme = "README.md" -keywords = ["ssg", "static-site-generator", "template", "renderer", "traits"] +keywords = ["ssg", "static-site-generator", "template", "renderer", "tera"] categories = ["development-tools::build-utils"] +[features] +default = ["tera"] +tera = ["dep:tera"] + [dependencies] +librawssg_error = { version = "1.0.0", path = "../librawssg_error" } +walkdir = "2.5.0" + +[dependencies.tera] +version = "1.20.0" +optional = true + +[dev-dependencies] +tempfile = "3" [lints] workspace = true diff --git a/librawssg_templates/src/lib.rs b/librawssg_templates/src/lib.rs index b93cf3f..7c32251 100644 --- a/librawssg_templates/src/lib.rs +++ b/librawssg_templates/src/lib.rs @@ -1,14 +1,12 @@ -pub fn add(left: u64, right: u64) -> u64 { - left + right -} +#![allow(clippy::multiple_crate_versions)] -#[cfg(test)] -mod tests { - use super::*; +pub mod renderer; +#[cfg(feature = "tera")] +pub mod tera_renderer; + +pub use renderer::{RenderContext, Renderer}; +#[cfg(feature = "tera")] +pub use tera_renderer::TeraRenderer; - #[test] - fn it_works() { - let result = add(2, 2); - assert_eq!(result, 4); - } -} +#[cfg(test)] +use tempfile as _; diff --git a/librawssg_templates/src/renderer.rs b/librawssg_templates/src/renderer.rs new file mode 100644 index 0000000..661367c --- /dev/null +++ b/librawssg_templates/src/renderer.rs @@ -0,0 +1,12 @@ +use core::any::Any; +use librawssg_error::Result; + +pub trait RenderContext: Send + Sync { + fn as_any(&self) -> &dyn Any; + + fn as_mut_any(&mut self) -> &mut dyn Any; +} + +pub trait Renderer: Send + Sync { + fn render(&self, template_name: &str, context: &dyn RenderContext) -> Result; +} diff --git a/librawssg_templates/src/tera_renderer.rs b/librawssg_templates/src/tera_renderer.rs new file mode 100644 index 0000000..b90d8c9 --- /dev/null +++ b/librawssg_templates/src/tera_renderer.rs @@ -0,0 +1,140 @@ +use crate::renderer::{RenderContext, Renderer}; +use librawssg_error::{Error, Result}; +use std::path::Path; + +#[derive(Debug)] +pub struct TeraRenderer { + tera: tera::Tera, +} + +impl TeraRenderer { + #[must_use] + pub fn new() -> Self { + Self { + tera: tera::Tera::default(), + } + } + + pub fn add_raw_template(&mut self, name: &str, content: &str) -> Result<()> { + self.tera + .add_raw_template(name, content) + .map_err(|e| Error::Render(e.to_string())) + } + + pub fn add_template_file(&mut self, path: &Path) -> Result<()> { + let content = std::fs::read_to_string(path) + .map_err(|e| Error::Io(std::io::Error::other(format!("{e}"))))?; + let name = path + .file_name() + .and_then(|s| s.to_str()) + .ok_or_else(|| Error::Render("template file has no valid file name".into()))?; + self.add_raw_template(name, &content) + } + + pub fn add_template_files_from_dir(&mut self, dir: &Path) -> Result<()> { + let entries = + std::fs::read_dir(dir).map_err(|e| Error::Io(std::io::Error::other(format!("{e}"))))?; + for entry in entries { + let entry = entry.map_err(|e| Error::Io(std::io::Error::other(format!("{e}"))))?; + let path = entry.path(); + if path.is_file() { + self.add_template_file(&path)?; + } + } + Ok(()) + } + + pub fn load_templates_dir(&mut self, dir: &Path) -> Result<()> { + let dir_canon = dir + .canonicalize() + .map_err(|e| Error::Io(std::io::Error::other(format!("{e}"))))?; + for entry in walkdir::WalkDir::new(&dir_canon) { + let entry = entry.map_err(|e| Error::Io(std::io::Error::other(format!("{e}"))))?; + if entry.file_type().is_file() { + let abs_path = entry.path(); + let rel_path = abs_path + .strip_prefix(&dir_canon) + .map_err(|e| Error::Io(std::io::Error::other(format!("{e}"))))?; + let template_name = rel_path_to_template_name(rel_path)?; + let content = std::fs::read_to_string(abs_path) + .map_err(|e| Error::Io(std::io::Error::other(format!("{e}"))))?; + self.add_raw_template(&template_name, &content)?; + } + } + Ok(()) + } + + pub fn enable_autoescape(&mut self) { + self.tera.autoescape_on(vec!["html", "htm", "xml"]); + } + + pub fn render_str(&self, template_str: &str, context: &dyn RenderContext) -> Result { + let tera_ctx = context + .as_any() + .downcast_ref::() + .ok_or_else(|| Error::Render("invalid context type for Tera".into()))?; + tera::Tera::one_off(template_str, tera_ctx, true).map_err(|e| Error::Render(e.to_string())) + } + + #[must_use] + pub const fn as_tera(&self) -> &tera::Tera { + &self.tera + } + + #[must_use] + pub const fn as_tera_mut(&mut self) -> &mut tera::Tera { + &mut self.tera + } +} + +fn rel_path_to_template_name(rel_path: &Path) -> Result { + let mut parts: Vec<&str> = Vec::new(); + for component in rel_path.components() { + match component { + std::path::Component::Normal(os_str) => parts.push( + os_str + .to_str() + .ok_or_else(|| Error::Render("non-UTF-8 path".into()))?, + ), + std::path::Component::Prefix(_) + | std::path::Component::RootDir + | std::path::Component::CurDir + | std::path::Component::ParentDir => { + return Err(Error::Render( + "unexpected path component in template path".into(), + )); + } + } + } + if parts.is_empty() { + return Err(Error::Render("empty template name".into())); + } + Ok(parts.join("/")) +} + +impl Default for TeraRenderer { + fn default() -> Self { + Self::new() + } +} + +impl Renderer for TeraRenderer { + fn render(&self, template_name: &str, context: &dyn RenderContext) -> Result { + let tera_ctx = context + .as_any() + .downcast_ref::() + .ok_or_else(|| Error::Render("invalid context type for Tera".into()))?; + self.tera + .render(template_name, tera_ctx) + .map_err(|e| Error::Render(e.to_string())) + } +} + +impl RenderContext for tera::Context { + fn as_any(&self) -> &dyn core::any::Any { + self + } + fn as_mut_any(&mut self) -> &mut dyn core::any::Any { + self + } +} diff --git a/librawssg_templates/tests/tera_tests.rs b/librawssg_templates/tests/tera_tests.rs new file mode 100644 index 0000000..c6c78a5 --- /dev/null +++ b/librawssg_templates/tests/tera_tests.rs @@ -0,0 +1,282 @@ +#![cfg(feature = "tera")] + +use librawssg_templates::{RenderContext, Renderer, TeraRenderer}; +use tempfile as _; +use walkdir as _; + +macro_rules! must { + ($result:expr, $context:expr) => { + match $result { + Ok(value) => value, + Err(err) => { + eprintln!("{} failed: {}", $context, err); + std::process::exit(1); + } + } + }; +} + +fn sample_context() -> tera::Context { + let mut ctx = tera::Context::new(); + ctx.insert("title", "Hello"); + ctx.insert("items", &vec!["a", "b", "c"]); + ctx.insert("number", &42); + ctx +} + +#[test] +fn render_raw_template_with_simple_variable() { + let mut renderer = TeraRenderer::new(); + must!( + renderer.add_raw_template("simple", "{{ title }}"), + "add_raw_template" + ); + + let mut ctx = tera::Context::new(); + ctx.insert("title", "Hello World"); + let output = must!(renderer.render("simple", &ctx), "render"); + assert_eq!(output, "Hello World"); +} + +#[test] +fn render_raw_template_with_loop() { + let mut renderer = TeraRenderer::new(); + must!( + renderer.add_raw_template( + "loop", + "{% for item in items %}{{ item }}{% if not loop.last %},{% endif %}{% endfor %}" + ), + "add_raw_template" + ); + + let ctx = sample_context(); + let output = must!(renderer.render("loop", &ctx), "render"); + assert_eq!(output, "a,b,c"); +} + +#[test] +fn render_raw_template_with_filter() { + let mut renderer = TeraRenderer::new(); + must!( + renderer.add_raw_template("filter", "{{ title | upper }}"), + "add_raw_template" + ); + + let ctx = sample_context(); + let output = must!(renderer.render("filter", &ctx), "render"); + assert_eq!(output, "HELLO"); +} + +#[test] +fn render_raw_template_with_condition() { + let mut renderer = TeraRenderer::new(); + must!( + renderer.add_raw_template( + "condition", + "{% if number > 40 %}high{% else %}low{% endif %}" + ), + "add_raw_template" + ); + + let ctx = sample_context(); + let output = must!(renderer.render("condition", &ctx), "render"); + assert_eq!(output, "high"); +} + +#[test] +fn render_missing_template_errors() { + let renderer = TeraRenderer::new(); + let ctx = tera::Context::new(); + let result = renderer.render("missing", &ctx); + assert!(result.is_err()); + if let Err(err) = result { + assert!(matches!(err, librawssg_error::Error::Render(_))); + } +} + +#[test] +fn render_with_missing_variable_errors() { + let mut renderer = TeraRenderer::new(); + must!( + renderer.add_raw_template("missing_var", "{{ does_not_exist }}"), + "add_raw_template" + ); + let ctx = tera::Context::new(); + let result = renderer.render("missing_var", &ctx); + assert!(result.is_err()); +} + +#[test] +fn add_invalid_template_errors() { + let mut renderer = TeraRenderer::new(); + let result = renderer.add_raw_template("invalid", "{% if %}"); + assert!(result.is_err()); + if let Err(err) = result { + assert!(matches!(err, librawssg_error::Error::Render(_))); + } +} + +#[test] +fn add_template_file_works() { + let dir = must!(tempfile::TempDir::new(), "TempDir::new"); + let file_path = dir.path().join("hello.tera"); + must!(std::fs::write(&file_path, "{{ name }}"), "write template"); + + let mut renderer = TeraRenderer::new(); + must!(renderer.add_template_file(&file_path), "add_template_file"); + + let mut ctx = tera::Context::new(); + ctx.insert("name", "World"); + let output = must!(renderer.render("hello.tera", &ctx), "render"); + assert_eq!(output, "World"); +} + +#[test] +fn add_template_files_from_dir_works() { + let dir = must!(tempfile::TempDir::new(), "TempDir::new"); + must!(std::fs::write(dir.path().join("a.tera"), "A"), "write a"); + must!(std::fs::write(dir.path().join("b.tera"), "B"), "write b"); + + let mut renderer = TeraRenderer::new(); + must!( + renderer.add_template_files_from_dir(dir.path()), + "add from dir" + ); + + let ctx = tera::Context::new(); + let out_a = must!(renderer.render("a.tera", &ctx), "render a"); + let out_b = must!(renderer.render("b.tera", &ctx), "render b"); + assert_eq!(out_a, "A"); + assert_eq!(out_b, "B"); +} + +#[test] +fn load_templates_dir_recursive() { + let dir = must!(tempfile::TempDir::new(), "TempDir::new"); + let sub_dir = dir.path().join("sub"); + must!(std::fs::create_dir(&sub_dir), "create subdir"); + must!( + std::fs::write(sub_dir.join("nested.tera"), "Nested"), + "write nested" + ); + + let mut renderer = TeraRenderer::new(); + must!( + renderer.load_templates_dir(dir.path()), + "load templates dir" + ); + + let ctx = tera::Context::new(); + let output = must!(renderer.render("sub/nested.tera", &ctx), "render nested"); + assert_eq!(output, "Nested"); +} + +#[test] +fn autoescape_escapes_html() { + let mut renderer = TeraRenderer::new(); + renderer.enable_autoescape(); + must!( + renderer.add_raw_template("esc.html", "{{ content }}"), + "add_raw_template" + ); + let mut ctx = tera::Context::new(); + ctx.insert("content", ""); + let output = must!(renderer.render("esc.html", &ctx), "render"); + assert_eq!(output, "<script>alert(1)</script>"); +} + +#[test] +fn render_str_autoescapes_by_default() { + let renderer = TeraRenderer::new(); + let mut ctx = tera::Context::new(); + ctx.insert("content", "bold"); + let output = must!(renderer.render_str("{{ content }}", &ctx), "render_str"); + assert_eq!(output, "<b>bold</b>"); +} + +#[test] +fn inheritance_and_blocks() { + let mut renderer = TeraRenderer::new(); + must!( + renderer.add_raw_template( + "base", + "{% block content %}Default{% endblock %}" + ), + "add base" + ); + must!( + renderer.add_raw_template( + "child", + "{% extends \"base\" %}{% block content %}Child content{% endblock %}" + ), + "add child" + ); + + let ctx = tera::Context::new(); + let output = must!(renderer.render("child", &ctx), "render child"); + assert_eq!(output, "Child content"); +} + +#[test] +fn macros_work() { + let mut renderer = TeraRenderer::new(); + must!( + renderer.add_raw_template( + "macro", + "{% macro hello(name) %}Hello, {{ name }}{% endmacro hello %}{{ self::hello(name=\"World\") }}" + ), + "add macro template" + ); + let ctx = tera::Context::new(); + let output = must!(renderer.render("macro", &ctx), "render macro"); + assert_eq!(output, "Hello, World"); +} + +#[test] +fn include_partial_works() { + let mut renderer = TeraRenderer::new(); + must!( + renderer.add_raw_template("partial", "Partial content"), + "add partial" + ); + must!( + renderer.add_raw_template("main", "{% include \"partial\" %}"), + "add main" + ); + let ctx = tera::Context::new(); + let output = must!(renderer.render("main", &ctx), "render main"); + assert_eq!(output, "Partial content"); +} + +#[test] +fn context_downcast_works() { + let ctx = sample_context(); + let dyn_ctx: &dyn RenderContext = &ctx; + assert!(dyn_ctx.as_any().is::()); +} + +#[test] +fn context_mutable_downcast_works() { + let mut ctx = sample_context(); + let dyn_ctx: &mut dyn RenderContext = &mut ctx; + assert!(dyn_ctx.as_mut_any().is::()); +} + +#[test] +fn as_tera_returns_underlying_engine() { + let renderer = TeraRenderer::new(); + let tera = renderer.as_tera(); + let _ = tera; +} +#[test] +fn as_tera_mut_allows_mutation() { + let mut renderer = TeraRenderer::new(); + let tera_mut = renderer.as_tera_mut(); + let add_result = tera_mut + .add_raw_template("via_mut", "ok") + .map_err(|e| librawssg_error::Error::Render(e.to_string())); + must!(add_result, "add raw template via mutable access"); + let ctx = tera::Context::new(); + let output = must!(renderer.render("via_mut", &ctx), "render via mut"); + assert_eq!(output, "ok"); +} diff --git a/librawssg_templates/tests/unit_tests.rs b/librawssg_templates/tests/unit_tests.rs new file mode 100644 index 0000000..5984a33 --- /dev/null +++ b/librawssg_templates/tests/unit_tests.rs @@ -0,0 +1,55 @@ +use core::any::Any; +use librawssg_error::Result; +use librawssg_templates::{RenderContext, Renderer}; +use tempfile as _; +use tera as _; +use walkdir as _; +struct MockRenderer { + output: String, +} + +impl Renderer for MockRenderer { + fn render(&self, _template_name: &str, _context: &dyn RenderContext) -> Result { + Ok(self.output.clone()) + } +} + +struct MockContext; + +impl RenderContext for MockContext { + fn as_any(&self) -> &dyn Any { + self + } + fn as_mut_any(&mut self) -> &mut dyn Any { + self + } +} + +#[test] +fn renderer_trait_can_be_mocked() { + let renderer = MockRenderer { + output: "mock".to_string(), + }; + let ctx = MockContext; + let output = match renderer.render("whatever", &ctx) { + Ok(value) => value, + Err(e) => { + eprintln!("render failed: {}", e); + std::process::exit(1); + } + }; + assert_eq!(output, "mock"); +} + +#[test] +fn render_context_trait_can_be_mocked() { + let ctx = MockContext; + let dyn_ctx: &dyn RenderContext = &ctx; + assert!(dyn_ctx.as_any().is::()); +} + +#[test] +fn reexports_available() { + let _: Option<&dyn Renderer> = None; + let _: Option<&dyn RenderContext> = None; +} From 20e5d354526b832ef9caeacf014c57d3f12a9286 Mon Sep 17 00:00:00 2001 From: mroczect Date: Tue, 8 Sep 2026 02:30:05 +0700 Subject: [PATCH 10/48] chore(lock): update lockfile for compiler dependencies Add librawssg_config, librawssg_handler, librawssg_templates, and tempfile dependencies to compiler crate entry. This reflects new compiler Cargo.toml dependencies and keeps the lockfile synchronized. --- Cargo.lock | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 71a5a0d..47f876e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -336,6 +336,14 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "librawssg_compiler" version = "1.0.0" +dependencies = [ + "librawssg_config", + "librawssg_error", + "librawssg_fs", + "librawssg_handler", + "librawssg_templates", + "tempfile", +] [[package]] name = "librawssg_config" From 9effd0b6e1e4352b7580328423f124ce996f08e6 Mon Sep 17 00:00:00 2001 From: mroczect Date: Tue, 8 Sep 2026 02:30:06 +0700 Subject: [PATCH 11/48] feat(compiler): add dependencies for compiler crate Add librawssg_error, librawssg_fs, librawssg_config, librawssg_handler, and librawssg_templates as runtime dependencies. Add tempfile as dev-dependency. This prepares the compiler crate for building the pipeline. --- librawssg_compiler/Cargo.toml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/librawssg_compiler/Cargo.toml b/librawssg_compiler/Cargo.toml index 8590eaf..8bd2882 100644 --- a/librawssg_compiler/Cargo.toml +++ b/librawssg_compiler/Cargo.toml @@ -10,6 +10,14 @@ keywords = ["ssg", "static-site-generator", "pipeline", "builder", "compiler"] categories = ["development-tools::build-utils"] [dependencies] +librawssg_error = { version = "1.0.0", path = "../librawssg_error" } +librawssg_fs = { version = "1.0.0", path = "../librawssg_fs" } +librawssg_config = { version = "1.0.0", path = "../librawssg_config" } +librawssg_handler = { version = "1.0.0", path = "../librawssg_handler" } +librawssg_templates = { version = "1.0.0", path = "../librawssg_templates" } + +[dev-dependencies] +tempfile = "3" [lints] workspace = true From 1c52455c5cfc089cdc0d6dd170935fbb0628a089 Mon Sep 17 00:00:00 2001 From: mroczect Date: Tue, 8 Sep 2026 02:30:06 +0700 Subject: [PATCH 12/48] feat(compiler): expose public modules and types Replace placeholder with module declarations for builder, context, generator, pattern, and pipeline. Re-export PipelineBuilder, ContextBuilder, Generator, Pipeline, and TeraContextBuilder under tera feature. --- librawssg_compiler/src/lib.rs | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/librawssg_compiler/src/lib.rs b/librawssg_compiler/src/lib.rs index b93cf3f..9f8007d 100644 --- a/librawssg_compiler/src/lib.rs +++ b/librawssg_compiler/src/lib.rs @@ -1,14 +1,15 @@ -pub fn add(left: u64, right: u64) -> u64 { - left + right -} +#![allow(clippy::multiple_crate_versions)] -#[cfg(test)] -mod tests { - use super::*; +pub mod builder; +pub mod context; +pub mod generator; +pub mod pattern; +pub mod pipeline; - #[test] - fn it_works() { - let result = add(2, 2); - assert_eq!(result, 4); - } -} +pub use builder::PipelineBuilder; +pub use context::ContextBuilder; +pub use generator::Generator; +pub use pipeline::Pipeline; + +#[cfg(feature = "tera")] +pub use context::TeraContextBuilder; From 42f57e7a0afc8646495673291c30cfc26d353402 Mon Sep 17 00:00:00 2001 From: mroczect Date: Tue, 8 Sep 2026 02:30:06 +0700 Subject: [PATCH 13/48] feat(compiler): implement PipelineBuilder Add PipelineBuilder struct with config, content/output directories, filesystem, renderer, processors, context builder, and generators. Implement builder methods and `build` method that validates config and constructs a Pipeline. --- librawssg_compiler/src/builder.rs | 129 ++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 librawssg_compiler/src/builder.rs diff --git a/librawssg_compiler/src/builder.rs b/librawssg_compiler/src/builder.rs new file mode 100644 index 0000000..bdb7853 --- /dev/null +++ b/librawssg_compiler/src/builder.rs @@ -0,0 +1,129 @@ +use super::ContextBuilder; +use super::processor::Processor; +use crate::generator::Generator; +use crate::pipeline::Pipeline; +use librawssg_config::Config; +use librawssg_error::Result; +use librawssg_fs::FileSystem; +use librawssg_fs::RealFs; +use librawssg_templates::Renderer; +use std::path::PathBuf; + +pub struct PipelineBuilder { + config: Config, + content_dir: PathBuf, + output_dir: PathBuf, + fs: Box, + renderer: Option>, + processors: Vec>, + context_builder: Option>, + generators: Vec>, +} + +impl PipelineBuilder { + #[must_use] + pub fn new() -> Self { + Self { + config: Config::default(), + content_dir: PathBuf::from("content"), + output_dir: PathBuf::from("dist"), + fs: Box::new(RealFs), + renderer: None, + processors: Vec::new(), + context_builder: None, + generators: Vec::new(), + } + } + + #[must_use] + pub fn config(mut self, config: Config) -> Self { + self.config = config; + self + } + + pub fn load_config + Send + Sync>(mut self, path: P) -> Result { + let content = std::fs::read_to_string(path.as_ref()) + .map_err(|e| librawssg_error::Error::Config(e.to_string()))?; + self.config = Config::from_yaml_str(&content)?; + Ok(self) + } + + #[must_use] + pub fn content_dir(mut self, dir: impl Into) -> Self { + self.content_dir = dir.into(); + self + } + + #[must_use] + pub fn output_dir(mut self, dir: impl Into) -> Self { + self.output_dir = dir.into(); + self + } + + #[must_use] + pub fn with_fs(mut self, fs: Box) -> Self { + self.fs = fs; + self + } + + #[must_use] + pub fn with_renderer(mut self, renderer: Box) -> Self { + self.renderer = Some(renderer); + self + } + + #[must_use] + pub fn add_processor(mut self, processor: Box) -> Self { + self.processors.push(processor); + self + } + + #[must_use] + pub fn with_context_builder(mut self, builder: Box) -> Self { + self.context_builder = Some(builder); + self + } + + #[must_use] + pub fn add_generator(mut self, generator: Box) -> Self { + self.generators.push(generator); + self + } + + pub fn build(mut self) -> Result { + self.config.validate()?; + + if self.content_dir == PathBuf::from("content") { + self.content_dir = PathBuf::from(&self.config.build.content_dir); + } + if self.output_dir == PathBuf::from("dist") { + self.output_dir = PathBuf::from(&self.config.build.output_dir); + } + + let renderer = self + .renderer + .take() + .ok_or_else(|| librawssg_error::Error::Config("template renderer not set".into()))?; + let context_builder = self + .context_builder + .take() + .ok_or_else(|| librawssg_error::Error::Config("context builder not set".into()))?; + + Ok(Pipeline { + config: self.config, + fs: self.fs, + renderer, + processors: self.processors, + context_builder, + generators: self.generators, + content_dir: self.content_dir, + output_dir: self.output_dir, + }) + } +} + +impl Default for PipelineBuilder { + fn default() -> Self { + Self::new() + } +} From 5713c747755f034ad2ee39568d9513f9e79394ee Mon Sep 17 00:00:00 2001 From: mroczect Date: Tue, 8 Sep 2026 02:30:06 +0700 Subject: [PATCH 14/48] feat(compiler): define ContextBuilder trait and Tera implementation Add ContextBuilder trait that builds a RenderContext from Config and Document. Implement TeraContextBuilder under the `tera` feature, populating a tera::Context with site and page fields. --- librawssg_compiler/src/context.rs | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 librawssg_compiler/src/context.rs diff --git a/librawssg_compiler/src/context.rs b/librawssg_compiler/src/context.rs new file mode 100644 index 0000000..c63174b --- /dev/null +++ b/librawssg_compiler/src/context.rs @@ -0,0 +1,31 @@ +use librawssg_config::Config; +use librawssg_error::Result; +use librawssg_handler::Document; +use librawssg_templates::RenderContext; + +pub trait ContextBuilder: Send + Sync { + fn build_context(&self, config: &Config, doc: &Document) -> Result>; +} + +#[cfg(feature = "tera")] +pub struct TeraContextBuilder; + +#[cfg(feature = "tera")] +impl ContextBuilder for TeraContextBuilder { + fn build_context(&self, config: &Config, doc: &Document) -> Result> { + let mut ctx = tera::Context::new(); + ctx.insert("site", &config.site); + ctx.insert("page_title", &doc.metadata.title); + ctx.insert("page_description", &doc.metadata.description); + ctx.insert("page_author", &doc.metadata.author); + ctx.insert("page_date", &doc.metadata.date); + ctx.insert("page_tags", &doc.metadata.tags); + ctx.insert("page_content", &doc.body); + ctx.insert("page_url", &doc.url); + ctx.insert("page_depth", &doc.depth); + ctx.insert("page_type", &doc.content_type); + ctx.insert("page_is_list", &doc.is_list); + ctx.insert("page_list_items", &doc.list_items); + Ok(Box::new(ctx)) + } +} From 78d33e24dfd2acb3d7925a214b62d9ce000488bf Mon Sep 17 00:00:00 2001 From: mroczect Date: Tue, 8 Sep 2026 02:30:06 +0700 Subject: [PATCH 15/48] feat(compiler): define Generator trait Add Generator trait with a generate method that receives a Pipeline reference and returns Result. This allows post-processing generators to be plugged into the pipeline. --- librawssg_compiler/src/generator.rs | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 librawssg_compiler/src/generator.rs diff --git a/librawssg_compiler/src/generator.rs b/librawssg_compiler/src/generator.rs new file mode 100644 index 0000000..8d7578d --- /dev/null +++ b/librawssg_compiler/src/generator.rs @@ -0,0 +1,6 @@ +use crate::pipeline::Pipeline; +use librawssg_error::Result; + +pub trait Generator: Send + Sync { + fn generate(&self, pipeline: &Pipeline) -> Result<()>; +} From 7124ae62ea2a820855553b0061132855d28fb77c Mon Sep 17 00:00:00 2001 From: mroczect Date: Tue, 8 Sep 2026 02:30:06 +0700 Subject: [PATCH 16/48] feat(compiler): add glob pattern matching utility Implement match_pattern function and helper functions for matching path segments against glob patterns, including `**` and `*` wildcards. This is used for content type detection. --- librawssg_compiler/src/pattern.rs | 68 +++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 librawssg_compiler/src/pattern.rs diff --git a/librawssg_compiler/src/pattern.rs b/librawssg_compiler/src/pattern.rs new file mode 100644 index 0000000..0362a16 --- /dev/null +++ b/librawssg_compiler/src/pattern.rs @@ -0,0 +1,68 @@ +use std::path::Path; + +#[must_use] +pub fn match_pattern(pattern: &str, path: &Path) -> bool { + let path_str = path.to_string_lossy(); + let segments: Vec<&str> = path_str.split('/').collect(); + let pattern_segments: Vec<&str> = pattern.split('/').collect(); + match_pattern_slice(&pattern_segments, &segments) +} + +fn match_pattern_slice(pattern: &[&str], segments: &[&str]) -> bool { + if pattern.is_empty() { + return segments.is_empty(); + } + if segments.is_empty() { + return pattern.iter().all(|&p| p == "**"); + } + + match pattern[0] { + "**" => { + if pattern.len() == 1 { + return true; + } + for i in 0..segments.len() { + if match_pattern_slice(&pattern[1..], &segments[i..]) { + return true; + } + } + false + } + pat => { + if !segment_matches(pat, segments[0]) { + return false; + } + match_pattern_slice(&pattern[1..], &segments[1..]) + } + } +} + +fn segment_matches(pattern: &str, segment: &str) -> bool { + let mut pattern_chars = pattern.chars(); + let mut segment_chars = segment.chars(); + + loop { + match pattern_chars.next() { + Some('*') => { + let rest_of_pattern: String = pattern_chars.clone().collect(); + if rest_of_pattern.is_empty() { + return true; + } + let mut remaining_segment: String = segment_chars.clone().collect(); + while !remaining_segment.is_empty() { + if segment_matches(&rest_of_pattern, &remaining_segment) { + return true; + } + segment_chars.next(); + remaining_segment = segment_chars.clone().collect(); + } + return false; + } + Some(pc) => match segment_chars.next() { + Some(sc) if pc == sc => continue, + _ => return false, + }, + None => return segment_chars.next().is_none(), + } + } +} From 60a2537f903ddb14f4ab2b9d44ade4bdd71e5597 Mon Sep 17 00:00:00 2001 From: mroczect Date: Tue, 8 Sep 2026 02:30:06 +0700 Subject: [PATCH 17/48] feat(compiler): implement Pipeline Add Pipeline struct with config, fs, renderer, processors, context builder, generators, content/output directories. Implement run method that generates to a temporary directory and atomically renames. Implement document processing, rendering, output writing, and static asset copying. --- librawssg_compiler/src/pipeline.rs | 216 +++++++++++++++++++++++++++++ 1 file changed, 216 insertions(+) create mode 100644 librawssg_compiler/src/pipeline.rs diff --git a/librawssg_compiler/src/pipeline.rs b/librawssg_compiler/src/pipeline.rs new file mode 100644 index 0000000..1bef3ad --- /dev/null +++ b/librawssg_compiler/src/pipeline.rs @@ -0,0 +1,216 @@ +use crate::ContextBuilder; +use crate::pattern::match_pattern; +use librawssg_config::Config; +use librawssg_error::{Error, Result}; +use librawssg_fs::FileSystem; +use librawssg_handler::Document; +use librawssg_templates::Renderer; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +pub struct Pipeline { + pub(crate) config: Config, + pub(crate) fs: Box, + pub(crate) renderer: Box, + pub(crate) processors: Vec>, + pub(crate) context_builder: Box, + pub(crate) generators: Vec>, + pub(crate) content_dir: PathBuf, + pub(crate) output_dir: PathBuf, +} + +impl Pipeline { + #[must_use] + pub const fn config(&self) -> &Config { + &self.config + } + + pub fn run(&self) -> Result<()> { + let tmp_dir = self.output_dir.with_extension("tmp"); + if self.fs.exists(&tmp_dir) { + self.fs.remove_dir_all(&tmp_dir)?; + } + self.fs.create_dir_all(&tmp_dir)?; + + self.generate_to(&tmp_dir)?; + + if self.fs.exists(&self.output_dir) { + self.fs.remove_dir_all(&self.output_dir)?; + } + match self.fs.rename(&tmp_dir, &self.output_dir) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::CrossesDevices => { + self.copy_dir_all(&tmp_dir, &self.output_dir)?; + self.fs.remove_dir_all(&tmp_dir)?; + Ok(()) + } + Err(e) => Err(Error::Generation(format!("atomic rename failed: {e}"))), + } + } + + fn generate_to(&self, output_base: &Path) -> Result<()> { + self.fs.create_dir_all(output_base)?; + + let documents = self.process_documents()?; + + let mut docs_by_type: HashMap> = HashMap::new(); + for doc in &documents { + docs_by_type + .entry(doc.content_type.clone()) + .or_default() + .push(doc.clone()); + } + + for doc in &documents { + if doc.is_list { + continue; + } + self.render_document(output_base, doc)?; + } + + for (content_type, docs) in &docs_by_type { + if let Some(rule) = self + .config + .content_rules + .iter() + .find(|r| r.name == *content_type) + { + if rule.list_enabled && !docs.is_empty() { + if let Some(list_template) = &rule.list_template { + let list_doc = Document { + metadata: librawssg_handler::Metadata { + title: content_type.clone(), + ..Default::default() + }, + body: String::new(), + url: format!("{}/index.html", content_type), + output_path: PathBuf::from(format!("{}/index.html", content_type)), + source_path: PathBuf::new(), + depth: 1, + content_type: content_type.clone(), + is_list: true, + list_items: Some(docs.clone()), + taxonomies: HashMap::new(), + }; + self.render_document_with_template(output_base, &list_doc, list_template)?; + } + } + } + } + + if self.fs.exists(Path::new(&self.config.build.static_dir)) { + self.copy_dir_all( + Path::new(&self.config.build.static_dir), + &output_base.join(&self.config.build.static_dir), + )?; + } + + for generator in &self.generators { + generator.generate(self)?; + } + + Ok(()) + } + + fn process_documents(&self) -> Result> { + let mut docs = Vec::new(); + if !self.fs.exists(&self.content_dir) { + return Ok(docs); + } + let files = self.fs.walk_dir(&self.content_dir)?; + for file_path in files { + let rel = file_path + .strip_prefix(&self.content_dir) + .map_err(|e| Error::Generation(e.to_string()))?; + for processor in &self.processors { + if processor.can_process(rel, &file_path) { + if let Some(mut doc) = processor.process(&*self.fs, rel, &self.content_dir)? { + doc.content_type = self.determine_content_type(rel); + doc.depth = rel.components().count().saturating_sub(1); + docs.push(doc); + } + break; + } + } + } + Ok(docs) + } + + fn determine_content_type(&self, rel: &Path) -> String { + for rule in &self.config.content_rules { + if match_pattern(&rule.pattern, rel) { + return rule.name.clone(); + } + } + "page".into() + } + + fn render_document(&self, output_base: &Path, doc: &Document) -> Result<()> { + let template = self.template_for_document(doc)?; + self.render_document_with_template(output_base, doc, &template) + } + + fn render_document_with_template( + &self, + output_base: &Path, + doc: &Document, + template: &str, + ) -> Result<()> { + let ctx = self.context_builder.build_context(&self.config, doc)?; + let html = self.renderer.render(template, &*ctx)?; + self.write_output(output_base, doc, html.as_bytes()) + } + + fn template_for_document(&self, doc: &Document) -> Result { + for rule in &self.config.content_rules { + if rule.name == doc.content_type { + if doc.is_list + && let Some(ref list_template) = rule.list_template + { + return Ok(list_template.clone()); + } + return Ok(rule.template.clone()); + } + } + Err(Error::Generation(format!( + "no content rule found for type '{}'", + doc.content_type + ))) + } + + fn write_output(&self, output_base: &Path, doc: &Document, content: &[u8]) -> Result<()> { + let rel_out = Path::new(&doc.output_path); + if let Some(parent) = rel_out.parent() { + self.fs.create_dir_all(&output_base.join(parent))?; + } + let dest = self + .fs + .safe_join(output_base, rel_out) + .map_err(|e| Error::Generation(format!("unsafe output path: {e}")))?; + self.fs + .write(&dest, content) + .map_err(|e| Error::Generation(format!("write output failed: {e}"))) + } + + fn copy_dir_all(&self, from: &Path, to: &Path) -> Result<()> { + if !self.fs.exists(from) { + return Ok(()); + } + self.fs.create_dir_all(to)?; + for entry in self.fs.walk_dir(from)? { + let rel = entry + .strip_prefix(from) + .map_err(|e| Error::Generation(e.to_string()))?; + let dest = to.join(rel); + if self.fs.is_dir(&entry) { + self.fs.create_dir_all(&dest)?; + } else { + if let Some(parent) = dest.parent() { + self.fs.create_dir_all(parent)?; + } + let _ = self.fs.copy_file(&entry, &dest)?; + } + } + Ok(()) + } +} From b45c797bb7af7ec291e47ffc25eb06f501f57e12 Mon Sep 17 00:00:00 2001 From: mroczect Date: Tue, 8 Sep 2026 02:53:32 +0700 Subject: [PATCH 18/48] feat(compiler): add full compiler tests and refine pipeline (#33) * chore(lock): update lockfile for compiler tera dependency Add tera to librawssg_compiler dependencies in Cargo.lock. This reflects the new direct dependency and keeps the lockfile consistent with Cargo.toml. * feat(compiler): add tera dependency Add tera version 1.20.0 as a direct dependency to librawssg_compiler. This enables TeraContextBuilder without relying on optional feature and simplifies imports. * refactor(builder): fix path comparisons and imports Use `as_path() == Path::new("content")` instead of comparing PathBuf directly. Import `librawssg_handler::Processor` and add missing_debug_implementations attribute to PipelineBuilder. Improve code clarity and avoid type mismatches. * refactor(context): remove feature gate from TeraContextBuilder Derive Debug, Default, Clone, Copy for TeraContextBuilder and remove cfg(feature = "tera") attributes. Tera is now a required dependency, so the feature gate is no longer necessary. * feat(compiler): update Generator trait signature Add `output_base: &Path` parameter to `generate` method. This allows generators to write directly to the output directory during pipeline execution. * refactor(compiler): remove feature gate and add test import Remove `#[cfg(feature = "tera")]` from TeraContextBuilder re-export. Add `#[cfg(test)] use tempfile as _;` to suppress unused import warning in test builds. * refactor(pattern): rewrite glob matching with safer indexing Replace recursive slice indexing with match on pattern and segment options. Avoid potential panics by using get() and first(). Improve readability and maintainability of the pattern matching logic. * refactor(pipeline): simplify list doc creation and static copy Use Document::new and with_list_items to construct list documents instead of manual struct instantiation. Replace repeated path comparisons with let-else block for static directory copying. Add missing_debug_implementations attribute to Pipeline. Reverse content rule iteration to give later rules priority. * feat(handler): add Serialize derive and with_list_items method Derive Serialize for Document to support serialization in templates. Add a `with_list_items` builder method to set the list_items field fluently. This improves API ergonomics and enables future serialization. * test(compiler): add comprehensive full compiler integration tests Add full_compiler_test.rs covering pipeline generation of single pages, content type rules, list pages, static asset copying, generators, atomic replacement, empty content dir, skipped files, and error cases for missing renderer/context builder/invalid config. Uses MockRenderer and RawHtmlProcessor to test end-to-end behavior. --- Cargo.lock | 1 + librawssg_compiler/Cargo.toml | 1 + librawssg_compiler/src/builder.rs | 11 +- librawssg_compiler/src/context.rs | 3 +- librawssg_compiler/src/generator.rs | 3 +- librawssg_compiler/src/lib.rs | 4 +- librawssg_compiler/src/pattern.rs | 77 ++-- librawssg_compiler/src/pipeline.rs | 58 ++- .../tests/full_compiler_test.rs | 433 ++++++++++++++++++ librawssg_handler/src/document.rs | 9 +- 10 files changed, 526 insertions(+), 74 deletions(-) create mode 100644 librawssg_compiler/tests/full_compiler_test.rs diff --git a/Cargo.lock b/Cargo.lock index 47f876e..cbfe261 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -343,6 +343,7 @@ dependencies = [ "librawssg_handler", "librawssg_templates", "tempfile", + "tera", ] [[package]] diff --git a/librawssg_compiler/Cargo.toml b/librawssg_compiler/Cargo.toml index 8bd2882..fa6f5f0 100644 --- a/librawssg_compiler/Cargo.toml +++ b/librawssg_compiler/Cargo.toml @@ -15,6 +15,7 @@ librawssg_fs = { version = "1.0.0", path = "../librawssg_fs" } librawssg_config = { version = "1.0.0", path = "../librawssg_config" } librawssg_handler = { version = "1.0.0", path = "../librawssg_handler" } librawssg_templates = { version = "1.0.0", path = "../librawssg_templates" } +tera = "1.20.0" [dev-dependencies] tempfile = "3" diff --git a/librawssg_compiler/src/builder.rs b/librawssg_compiler/src/builder.rs index bdb7853..0adae23 100644 --- a/librawssg_compiler/src/builder.rs +++ b/librawssg_compiler/src/builder.rs @@ -1,14 +1,15 @@ use super::ContextBuilder; -use super::processor::Processor; use crate::generator::Generator; use crate::pipeline::Pipeline; use librawssg_config::Config; use librawssg_error::Result; use librawssg_fs::FileSystem; use librawssg_fs::RealFs; +use librawssg_handler::Processor; use librawssg_templates::Renderer; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; +#[allow(missing_debug_implementations)] pub struct PipelineBuilder { config: Config, content_dir: PathBuf, @@ -41,7 +42,7 @@ impl PipelineBuilder { self } - pub fn load_config + Send + Sync>(mut self, path: P) -> Result { + pub fn load_config + Send + Sync>(mut self, path: P) -> Result { let content = std::fs::read_to_string(path.as_ref()) .map_err(|e| librawssg_error::Error::Config(e.to_string()))?; self.config = Config::from_yaml_str(&content)?; @@ -93,10 +94,10 @@ impl PipelineBuilder { pub fn build(mut self) -> Result { self.config.validate()?; - if self.content_dir == PathBuf::from("content") { + if self.content_dir.as_path() == Path::new("content") { self.content_dir = PathBuf::from(&self.config.build.content_dir); } - if self.output_dir == PathBuf::from("dist") { + if self.output_dir.as_path() == Path::new("dist") { self.output_dir = PathBuf::from(&self.config.build.output_dir); } diff --git a/librawssg_compiler/src/context.rs b/librawssg_compiler/src/context.rs index c63174b..d15df15 100644 --- a/librawssg_compiler/src/context.rs +++ b/librawssg_compiler/src/context.rs @@ -7,10 +7,9 @@ pub trait ContextBuilder: Send + Sync { fn build_context(&self, config: &Config, doc: &Document) -> Result>; } -#[cfg(feature = "tera")] +#[derive(Debug, Default, Clone, Copy)] pub struct TeraContextBuilder; -#[cfg(feature = "tera")] impl ContextBuilder for TeraContextBuilder { fn build_context(&self, config: &Config, doc: &Document) -> Result> { let mut ctx = tera::Context::new(); diff --git a/librawssg_compiler/src/generator.rs b/librawssg_compiler/src/generator.rs index 8d7578d..5c79349 100644 --- a/librawssg_compiler/src/generator.rs +++ b/librawssg_compiler/src/generator.rs @@ -1,6 +1,7 @@ use crate::pipeline::Pipeline; use librawssg_error::Result; +use std::path::Path; pub trait Generator: Send + Sync { - fn generate(&self, pipeline: &Pipeline) -> Result<()>; + fn generate(&self, pipeline: &Pipeline, output_base: &Path) -> Result<()>; } diff --git a/librawssg_compiler/src/lib.rs b/librawssg_compiler/src/lib.rs index 9f8007d..a2225bd 100644 --- a/librawssg_compiler/src/lib.rs +++ b/librawssg_compiler/src/lib.rs @@ -11,5 +11,7 @@ pub use context::ContextBuilder; pub use generator::Generator; pub use pipeline::Pipeline; -#[cfg(feature = "tera")] pub use context::TeraContextBuilder; + +#[cfg(test)] +use tempfile as _; diff --git a/librawssg_compiler/src/pattern.rs b/librawssg_compiler/src/pattern.rs index 0362a16..45037fd 100644 --- a/librawssg_compiler/src/pattern.rs +++ b/librawssg_compiler/src/pattern.rs @@ -9,60 +9,69 @@ pub fn match_pattern(pattern: &str, path: &Path) -> bool { } fn match_pattern_slice(pattern: &[&str], segments: &[&str]) -> bool { - if pattern.is_empty() { - return segments.is_empty(); - } - if segments.is_empty() { - return pattern.iter().all(|&p| p == "**"); - } - - match pattern[0] { - "**" => { - if pattern.len() == 1 { - return true; - } - for i in 0..segments.len() { - if match_pattern_slice(&pattern[1..], &segments[i..]) { + match (pattern.first(), segments.first()) { + (None, None) => true, + (Some(_), None) => pattern.iter().all(|&p| p == "**"), + (None, Some(_)) => false, + (Some(&first_pat), Some(&first_seg)) => { + if first_pat == "**" { + if pattern.len() == 1 { return true; } + let Some(rest_pattern) = pattern.get(1..) else { + return false; + }; + for i in 0..segments.len() { + let Some(rest_segments) = segments.get(i..) else { + continue; + }; + if match_pattern_slice(rest_pattern, rest_segments) { + return true; + } + } + false + } else if segment_matches(first_pat, first_seg) { + match (pattern.get(1..), segments.get(1..)) { + (Some(next_pattern), Some(next_segments)) => { + match_pattern_slice(next_pattern, next_segments) + } + _ => false, + } + } else { + false } - false - } - pat => { - if !segment_matches(pat, segments[0]) { - return false; - } - match_pattern_slice(&pattern[1..], &segments[1..]) } } } fn segment_matches(pattern: &str, segment: &str) -> bool { - let mut pattern_chars = pattern.chars(); - let mut segment_chars = segment.chars(); + let mut pattern_iter = pattern.chars(); + let mut segment_iter = segment.chars(); loop { - match pattern_chars.next() { + match pattern_iter.next() { Some('*') => { - let rest_of_pattern: String = pattern_chars.clone().collect(); - if rest_of_pattern.is_empty() { + let rest: String = pattern_iter.clone().collect(); + if rest.is_empty() { return true; } - let mut remaining_segment: String = segment_chars.clone().collect(); - while !remaining_segment.is_empty() { - if segment_matches(&rest_of_pattern, &remaining_segment) { + let mut remaining: String = segment_iter.clone().collect(); + while !remaining.is_empty() { + if segment_matches(&rest, &remaining) { return true; } - segment_chars.next(); - remaining_segment = segment_chars.clone().collect(); + if segment_iter.next().is_none() { + break; + } + remaining = segment_iter.clone().collect(); } return false; } - Some(pc) => match segment_chars.next() { - Some(sc) if pc == sc => continue, + Some(pc) => match segment_iter.next() { + Some(sc) if pc == sc => {} _ => return false, }, - None => return segment_chars.next().is_none(), + None => return segment_iter.next().is_none(), } } } diff --git a/librawssg_compiler/src/pipeline.rs b/librawssg_compiler/src/pipeline.rs index 1bef3ad..1235e83 100644 --- a/librawssg_compiler/src/pipeline.rs +++ b/librawssg_compiler/src/pipeline.rs @@ -3,16 +3,17 @@ use crate::pattern::match_pattern; use librawssg_config::Config; use librawssg_error::{Error, Result}; use librawssg_fs::FileSystem; -use librawssg_handler::Document; +use librawssg_handler::{Document, Metadata, Processor}; use librawssg_templates::Renderer; use std::collections::HashMap; use std::path::{Path, PathBuf}; +#[allow(missing_debug_implementations)] pub struct Pipeline { pub(crate) config: Config, pub(crate) fs: Box, pub(crate) renderer: Box, - pub(crate) processors: Vec>, + pub(crate) processors: Vec>, pub(crate) context_builder: Box, pub(crate) generators: Vec>, pub(crate) content_dir: PathBuf, @@ -74,41 +75,38 @@ impl Pipeline { .content_rules .iter() .find(|r| r.name == *content_type) + && rule.list_enabled + && !docs.is_empty() + && let Some(list_template) = &rule.list_template { - if rule.list_enabled && !docs.is_empty() { - if let Some(list_template) = &rule.list_template { - let list_doc = Document { - metadata: librawssg_handler::Metadata { - title: content_type.clone(), - ..Default::default() - }, - body: String::new(), - url: format!("{}/index.html", content_type), - output_path: PathBuf::from(format!("{}/index.html", content_type)), - source_path: PathBuf::new(), - depth: 1, - content_type: content_type.clone(), - is_list: true, - list_items: Some(docs.clone()), - taxonomies: HashMap::new(), - }; - self.render_document_with_template(output_base, &list_doc, list_template)?; - } - } + let metadata = Metadata::new(content_type.clone(), String::new())?; + let list_doc = Document::new( + metadata, + String::new(), + format!("{}/index.html", content_type), + PathBuf::from(format!("{}/index.html", content_type)), + PathBuf::from("__list__"), + 1, + content_type.clone(), + true, + )? + .with_list_items(docs.clone()); + self.render_document_with_template(output_base, &list_doc, list_template)?; } } - if self.fs.exists(Path::new(&self.config.build.static_dir)) { - self.copy_dir_all( - Path::new(&self.config.build.static_dir), - &output_base.join(&self.config.build.static_dir), - )?; + let static_src = Path::new(&self.config.build.static_dir); + if self.fs.exists(static_src) { + let static_name = static_src + .file_name() + .unwrap_or_else(|| std::ffi::OsStr::new("static")); + let static_dest = output_base.join(static_name); + self.copy_dir_all(static_src, &static_dest)?; } for generator in &self.generators { - generator.generate(self)?; + generator.generate(self, output_base)?; } - Ok(()) } @@ -137,7 +135,7 @@ impl Pipeline { } fn determine_content_type(&self, rel: &Path) -> String { - for rule in &self.config.content_rules { + for rule in self.config.content_rules.iter().rev() { if match_pattern(&rule.pattern, rel) { return rule.name.clone(); } diff --git a/librawssg_compiler/tests/full_compiler_test.rs b/librawssg_compiler/tests/full_compiler_test.rs new file mode 100644 index 0000000..3835829 --- /dev/null +++ b/librawssg_compiler/tests/full_compiler_test.rs @@ -0,0 +1,433 @@ +use librawssg_compiler::{ContextBuilder, Generator, Pipeline, PipelineBuilder}; +use librawssg_config::{Config, ContentRule}; +use librawssg_fs::{FileSystem, RealFs}; +use librawssg_handler::{Document, Metadata, Processor}; +use librawssg_templates::{RenderContext, Renderer}; +use std::path::{Path, PathBuf}; +use tempfile as _; +use tera as _; + +macro_rules! must { + ($result:expr, $context:expr) => { + match $result { + Ok(value) => value, + Err(err) => { + eprintln!("{} failed: {}", $context, err); + std::process::exit(1); + } + } + }; +} + +struct MockRenderer; + +impl Renderer for MockRenderer { + fn render( + &self, + template_name: &str, + _context: &dyn RenderContext, + ) -> librawssg_error::Result { + Ok(format!("rendered:{template_name}")) + } +} + +struct MockContext; + +impl RenderContext for MockContext { + fn as_any(&self) -> &dyn core::any::Any { + self + } + fn as_mut_any(&mut self) -> &mut dyn core::any::Any { + self + } +} + +struct MockContextBuilder; + +impl ContextBuilder for MockContextBuilder { + fn build_context( + &self, + _config: &Config, + _doc: &Document, + ) -> librawssg_error::Result> { + Ok(Box::new(MockContext)) + } +} + +struct RawHtmlProcessor; + +impl Processor for RawHtmlProcessor { + fn name(&self) -> &'static str { + "raw-html" + } + + fn can_process(&self, relative_path: &Path, _original_path: &Path) -> bool { + relative_path.extension().is_some_and(|ext| ext == "html") + } + + fn process( + &self, + fs: &dyn FileSystem, + relative_path: &Path, + content_dir: &Path, + ) -> librawssg_error::Result> { + let full_path = content_dir.join(relative_path); + let content = fs.read_to_string(&full_path)?; + let url = relative_path + .with_extension("html") + .to_string_lossy() + .to_string(); + let output_path = PathBuf::from(&url); + let metadata = Metadata::new("Test", "Description")?; + let doc = Document::new( + metadata, + content, + url, + output_path, + relative_path.to_path_buf(), + 0, + "page".to_string(), + false, + )?; + Ok(Some(doc)) + } +} + +struct DummyGenerator; + +impl DummyGenerator { + #[must_use] + const fn new() -> Self { + Self + } +} + +impl Generator for DummyGenerator { + fn generate(&self, _pipeline: &Pipeline, output_base: &Path) -> librawssg_error::Result<()> { + let path = output_base.join("generated.txt"); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| librawssg_error::Error::Generation(e.to_string()))?; + } + std::fs::write(&path, b"generated") + .map_err(|e| librawssg_error::Error::Generation(e.to_string())) + } +} +fn setup_config() -> Config { + let mut config = Config::new().with_site_name("Compiler Test"); + config.add_content_rule(ContentRule::new("page", "**/*.html", "base")); + config +} + +fn build_basic_pipeline( + content_dir: &Path, + output_dir: &Path, + config: Config, +) -> librawssg_error::Result { + PipelineBuilder::new() + .config(config) + .content_dir(content_dir) + .output_dir(output_dir) + .with_fs(Box::new(RealFs)) + .with_renderer(Box::new(MockRenderer)) + .with_context_builder(Box::new(MockContextBuilder)) + .add_processor(Box::new(RawHtmlProcessor)) + .build() +} + +#[test] +fn pipeline_generates_single_page() { + let tmp = must!(tempfile::TempDir::new(), "TempDir::new"); + let content_dir = tmp.path().join("content"); + let output_dir = tmp.path().join("dist"); + must!(std::fs::create_dir_all(&content_dir), "create content dir"); + must!( + std::fs::write(content_dir.join("index.html"), "

Home

"), + "write index.html" + ); + + let pipeline = must!( + build_basic_pipeline(&content_dir, &output_dir, setup_config()), + "build pipeline" + ); + must!(pipeline.run(), "run pipeline"); + + let output_file = output_dir.join("index.html"); + assert!(output_file.exists()); + let content = must!(std::fs::read_to_string(output_file), "read output"); + assert_eq!(content, "rendered:base"); +} + +#[test] +fn pipeline_respects_content_type_rules() { + let tmp = must!(tempfile::TempDir::new(), "TempDir::new"); + let content_dir = tmp.path().join("content"); + let output_dir = tmp.path().join("dist"); + must!( + std::fs::create_dir_all(content_dir.join("blog")), + "create blog dir" + ); + must!( + std::fs::write(content_dir.join("blog/post.html"), "post"), + "write post.html" + ); + must!( + std::fs::write(content_dir.join("index.html"), "index"), + "write index.html" + ); + + let mut config = setup_config(); + config.add_content_rule(ContentRule::new("blog", "blog/**/*.html", "post_template")); + + let pipeline = must!( + build_basic_pipeline(&content_dir, &output_dir, config), + "build pipeline" + ); + must!(pipeline.run(), "run pipeline"); + + let blog_output = output_dir.join("blog/post.html"); + assert!(blog_output.exists()); + let blog_content = must!(std::fs::read_to_string(blog_output), "read blog output"); + assert_eq!(blog_content, "rendered:post_template"); + + let index_output = output_dir.join("index.html"); + assert!(index_output.exists()); + let index_content = must!(std::fs::read_to_string(index_output), "read index output"); + assert_eq!(index_content, "rendered:base"); +} + +#[test] +fn pipeline_generates_list_pages() { + let tmp = must!(tempfile::TempDir::new(), "TempDir::new"); + let content_dir = tmp.path().join("content"); + let output_dir = tmp.path().join("dist"); + must!( + std::fs::create_dir_all(content_dir.join("blog")), + "create blog dir" + ); + must!( + std::fs::write(content_dir.join("blog/one.html"), "one"), + "write one.html" + ); + must!( + std::fs::write(content_dir.join("blog/two.html"), "two"), + "write two.html" + ); + + let mut config = setup_config(); + let mut blog_rule = ContentRule::new("blog", "blog/**/*.html", "post"); + blog_rule.list_template = Some("list".into()); + blog_rule.list_enabled = true; + config.add_content_rule(blog_rule); + + let pipeline = must!( + build_basic_pipeline(&content_dir, &output_dir, config), + "build pipeline" + ); + must!(pipeline.run(), "run pipeline"); + + let list_output = output_dir.join("blog/index.html"); + assert!(list_output.exists()); + let list_content = must!(std::fs::read_to_string(list_output), "read list output"); + assert_eq!(list_content, "rendered:list"); +} + +#[test] +fn pipeline_copies_static_assets() { + let tmp = must!(tempfile::TempDir::new(), "TempDir::new"); + let content_dir = tmp.path().join("content"); + let output_dir = tmp.path().join("dist"); + let static_dir = tmp.path().join("static"); + must!(std::fs::create_dir_all(&content_dir), "create content dir"); + must!(std::fs::create_dir_all(&static_dir), "create static dir"); + must!( + std::fs::write(content_dir.join("index.html"), "home"), + "write index.html" + ); + must!( + std::fs::write(static_dir.join("style.css"), "body {}"), + "write style.css" + ); + + let mut config = setup_config(); + config.build.static_dir = static_dir.to_string_lossy().to_string(); + + let pipeline = must!( + build_basic_pipeline(&content_dir, &output_dir, config), + "build pipeline" + ); + must!(pipeline.run(), "run pipeline"); + + let style_output = output_dir.join(static_dir).join("style.css"); + assert!(style_output.exists()); + let style_content = must!(std::fs::read_to_string(style_output), "read style"); + assert_eq!(style_content, "body {}"); +} + +#[test] +fn pipeline_runs_generators() { + let tmp = must!(tempfile::TempDir::new(), "TempDir::new"); + let content_dir = tmp.path().join("content"); + let output_dir = tmp.path().join("dist"); + must!(std::fs::create_dir_all(&content_dir), "create content dir"); + must!( + std::fs::write(content_dir.join("index.html"), "hello"), + "write index.html" + ); + + let generator = DummyGenerator::new(); + let pipeline = must!( + PipelineBuilder::new() + .config(setup_config()) + .content_dir(&content_dir) + .output_dir(&output_dir) + .with_fs(Box::new(RealFs)) + .with_renderer(Box::new(MockRenderer)) + .with_context_builder(Box::new(MockContextBuilder)) + .add_processor(Box::new(RawHtmlProcessor)) + .add_generator(Box::new(generator)) + .build(), + "build pipeline" + ); + must!(pipeline.run(), "run pipeline"); + + assert!(output_dir.join("generated.txt").exists()); +} + +#[test] +fn pipeline_atomic_replace_existing_output() { + let tmp = must!(tempfile::TempDir::new(), "TempDir::new"); + let content_dir = tmp.path().join("content"); + let output_dir = tmp.path().join("dist"); + must!(std::fs::create_dir_all(&content_dir), "create content dir"); + must!( + std::fs::write(content_dir.join("index.html"), "new"), + "write index.html" + ); + + must!(std::fs::create_dir_all(&output_dir), "create output dir"); + must!( + std::fs::write(output_dir.join("old.txt"), "old"), + "write old file" + ); + + let pipeline = must!( + build_basic_pipeline(&content_dir, &output_dir, setup_config()), + "build pipeline" + ); + must!(pipeline.run(), "run pipeline"); + + assert!(!output_dir.join("old.txt").exists()); + assert!(output_dir.join("index.html").exists()); + let content = must!( + std::fs::read_to_string(output_dir.join("index.html")), + "read new" + ); + assert_eq!(content, "rendered:base"); +} + +#[test] +fn pipeline_handles_empty_content_dir() { + let tmp = must!(tempfile::TempDir::new(), "TempDir::new"); + let content_dir = tmp.path().join("content"); + let output_dir = tmp.path().join("dist"); + must!(std::fs::create_dir_all(&content_dir), "create content dir"); + + let pipeline = must!( + build_basic_pipeline(&content_dir, &output_dir, setup_config()), + "build pipeline" + ); + must!(pipeline.run(), "run pipeline"); + + assert!(output_dir.exists()); + let entries = must!(std::fs::read_dir(&output_dir), "read output dir"); + assert_eq!(entries.count(), 0); +} + +#[test] +fn pipeline_skips_files_not_matching_processor() { + let tmp = must!(tempfile::TempDir::new(), "TempDir::new"); + let content_dir = tmp.path().join("content"); + let output_dir = tmp.path().join("dist"); + must!(std::fs::create_dir_all(&content_dir), "create content dir"); + must!( + std::fs::write(content_dir.join("index.html"), "home"), + "write index.html" + ); + must!( + std::fs::write(content_dir.join("script.js"), "console.log('hi')"), + "write script.js" + ); + + let pipeline = must!( + build_basic_pipeline(&content_dir, &output_dir, setup_config()), + "build pipeline" + ); + must!(pipeline.run(), "run pipeline"); + + assert!(output_dir.join("index.html").exists()); + assert!(!output_dir.join("script.js").exists()); +} + +#[test] +fn pipeline_errors_when_renderer_missing() { + let tmp = must!(tempfile::TempDir::new(), "TempDir::new"); + let content_dir = tmp.path().join("content"); + let output_dir = tmp.path().join("dist"); + must!(std::fs::create_dir_all(&content_dir), "create content dir"); + + let result = PipelineBuilder::new() + .config(setup_config()) + .content_dir(&content_dir) + .output_dir(&output_dir) + .with_fs(Box::new(RealFs)) + .with_context_builder(Box::new(MockContextBuilder)) + .add_processor(Box::new(RawHtmlProcessor)) + .build(); + assert!(result.is_err()); + if let Err(err) = result { + assert!(matches!(err, librawssg_error::Error::Config(_))); + } +} + +#[test] +fn pipeline_errors_when_context_builder_missing() { + let tmp = must!(tempfile::TempDir::new(), "TempDir::new"); + let content_dir = tmp.path().join("content"); + let output_dir = tmp.path().join("dist"); + must!(std::fs::create_dir_all(&content_dir), "create content dir"); + + let result = PipelineBuilder::new() + .config(setup_config()) + .content_dir(&content_dir) + .output_dir(&output_dir) + .with_fs(Box::new(RealFs)) + .with_renderer(Box::new(MockRenderer)) + .add_processor(Box::new(RawHtmlProcessor)) + .build(); + assert!(result.is_err()); + if let Err(err) = result { + assert!(matches!(err, librawssg_error::Error::Config(_))); + } +} + +#[test] +fn pipeline_errors_on_invalid_config() { + let tmp = must!(tempfile::TempDir::new(), "TempDir::new"); + let content_dir = tmp.path().join("content"); + let output_dir = tmp.path().join("dist"); + + let mut config = setup_config(); + config.site.site_name = " ".to_string(); + + let result = PipelineBuilder::new() + .config(config) + .content_dir(&content_dir) + .output_dir(&output_dir) + .with_fs(Box::new(RealFs)) + .with_renderer(Box::new(MockRenderer)) + .with_context_builder(Box::new(MockContextBuilder)) + .add_processor(Box::new(RawHtmlProcessor)) + .build(); + assert!(result.is_err()); +} diff --git a/librawssg_handler/src/document.rs b/librawssg_handler/src/document.rs index b9cad17..645bfa8 100644 --- a/librawssg_handler/src/document.rs +++ b/librawssg_handler/src/document.rs @@ -1,9 +1,10 @@ use crate::Metadata; use librawssg_error::{Error, Result}; +use serde::Serialize; use std::collections::HashMap; use std::path::PathBuf; -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Serialize)] #[non_exhaustive] pub struct Document { pub metadata: Metadata, @@ -78,4 +79,10 @@ impl Document { pub const fn depth(&self) -> usize { self.depth } + + #[must_use] + pub fn with_list_items(mut self, items: Vec) -> Self { + self.list_items = Some(items); + self + } } From c868488f341fc25b37b81d5d948898090f5cf6e7 Mon Sep 17 00:00:00 2001 From: mroczect Date: Tue, 8 Sep 2026 03:02:21 +0700 Subject: [PATCH 19/48] feat(workspace): add librawssg and demo crates (#34) * chore: update .gitignore for demo output Add an additional `/dist` entry to ignore the generated demo site output. This prevents accidental commits of build artifacts. * chore: update lockfile for librawssg and demo crates Add entries for the new `librawssg` and `librawssg_demo` crates and their workspace dependencies. This keeps the lockfile synchronized with Cargo.toml. * feat(workspace): add librawssg and librawssg_demo to members Include the new `librawssg` placeholder crate and the `librawssg_demo` application in the workspace member list. This enables building and testing them alongside existing crates. * feat(librawssg): add placeholder crate manifest Create Cargo.toml for the top-level `librawssg` crate. This crate currently serves as a placeholder for future public API re-exports or convenience features. * feat(librawssg): add placeholder library Add a minimal lib.rs with a simple `add` function and a test. This bootstraps the top-level crate and allows workspace compilation. * feat(demo): add demo crate manifest Create Cargo.toml for `librawssg_demo` application. Declare dependencies on all librawssg workspace crates and set metadata. * feat(demo): add about page raw content Add a simple HTML about page that demonstrates how raw content files are processed by the demo pipeline. The content is static and will be rendered into a full page. * feat(demo): add index page raw content Add a welcome page with sample HTML. This file is processed by RawFileProcessor and demonstrates generation from a `.raw` source file. * feat(demo): add demo application entry point Implement main.rs with a custom RawFileProcessor and pipeline setup. Load Tera templates, configure content rules, and run the pipeline to generate a static site from `src/content`. * feat(demo): add static stylesheet Add a basic CSS file that styles the demo site. It is copied verbatim to the output directory by the pipeline's static asset handling. * feat(demo): add base Tera template Add a base HTML template that uses Tera placeholders for page title and content. It demonstrates how the compiler populates context values during rendering. --- .gitignore | 3 +- Cargo.lock | 16 +++++ Cargo.toml | 2 +- librawssg/Cargo.toml | 9 +++ librawssg/src/lib.rs | 14 ++++ librawssg_demo/Cargo.toml | 21 ++++++ librawssg_demo/src/content/about.raw | 2 + librawssg_demo/src/content/index.raw | 2 + librawssg_demo/src/main.rs | 95 ++++++++++++++++++++++++++ librawssg_demo/src/static/style.css | 9 +++ librawssg_demo/src/templates/base.tera | 18 +++++ 11 files changed, 189 insertions(+), 2 deletions(-) create mode 100644 librawssg/Cargo.toml create mode 100644 librawssg/src/lib.rs create mode 100644 librawssg_demo/Cargo.toml create mode 100644 librawssg_demo/src/content/about.raw create mode 100644 librawssg_demo/src/content/index.raw create mode 100644 librawssg_demo/src/main.rs create mode 100644 librawssg_demo/src/static/style.css create mode 100644 librawssg_demo/src/templates/base.tera diff --git a/.gitignore b/.gitignore index 1468f0e..b265701 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ .velodiff_dist.diff .snapcat.md /dist -pull_request_body.md \ No newline at end of file +pull_request_body.md +/dist \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index cbfe261..28bd408 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -333,6 +333,10 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "librawssg" +version = "0.1.0" + [[package]] name = "librawssg_compiler" version = "1.0.0" @@ -357,6 +361,18 @@ dependencies = [ "tempfile", ] +[[package]] +name = "librawssg_demo" +version = "0.1.0" +dependencies = [ + "librawssg_compiler", + "librawssg_config", + "librawssg_error", + "librawssg_fs", + "librawssg_handler", + "librawssg_templates", +] + [[package]] name = "librawssg_error" version = "1.0.0" diff --git a/Cargo.toml b/Cargo.toml index 9fcbab3..b4fa58a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "3" -members = ["librawssg_compiler", "librawssg_config", "librawssg_error", "librawssg_fs","librawssg_handler", "librawssg_templates"] +members = ["librawssg","librawssg_compiler", "librawssg_config", "librawssg_demo", "librawssg_error", "librawssg_fs","librawssg_handler", "librawssg_templates"] [workspace.lints.clippy] all = { level = "deny", priority = -1 } diff --git a/librawssg/Cargo.toml b/librawssg/Cargo.toml new file mode 100644 index 0000000..fb8a976 --- /dev/null +++ b/librawssg/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "librawssg" +version = "0.1.0" +edition = "2024" + +[dependencies] + +[lints] +workspace = true diff --git a/librawssg/src/lib.rs b/librawssg/src/lib.rs new file mode 100644 index 0000000..b93cf3f --- /dev/null +++ b/librawssg/src/lib.rs @@ -0,0 +1,14 @@ +pub fn add(left: u64, right: u64) -> u64 { + left + right +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_works() { + let result = add(2, 2); + assert_eq!(result, 4); + } +} diff --git a/librawssg_demo/Cargo.toml b/librawssg_demo/Cargo.toml new file mode 100644 index 0000000..1091b01 --- /dev/null +++ b/librawssg_demo/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "librawssg_demo" +version = "0.1.0" +edition = "2024" +description = "Demo application for librawssg" +license = "MIT" +repository = "https://github.com/mroczect/librawssg" +readme = "README.md" +keywords = ["ssg", "demo", "librawssg"] +categories = ["development-tools::build-utils"] + +[dependencies] +librawssg_error = { version = "1.0.0", path = "../librawssg_error" } +librawssg_compiler = { version = "1.0.0", path = "../librawssg_compiler" } +librawssg_config = { version = "1.0.0", path = "../librawssg_config" } +librawssg_fs = { version = "1.0.0", path = "../librawssg_fs" } +librawssg_handler = { version = "1.0.0", path = "../librawssg_handler" } +librawssg_templates = { version = "1.0.0", path = "../librawssg_templates" } + +[lints] +workspace = true diff --git a/librawssg_demo/src/content/about.raw b/librawssg_demo/src/content/about.raw new file mode 100644 index 0000000..77c0bed --- /dev/null +++ b/librawssg_demo/src/content/about.raw @@ -0,0 +1,2 @@ +

About

+

Learn more about this project.

diff --git a/librawssg_demo/src/content/index.raw b/librawssg_demo/src/content/index.raw new file mode 100644 index 0000000..7e723d2 --- /dev/null +++ b/librawssg_demo/src/content/index.raw @@ -0,0 +1,2 @@ +

Welcome to the Demo

+

This page was generated from a .raw file.

diff --git a/librawssg_demo/src/main.rs b/librawssg_demo/src/main.rs new file mode 100644 index 0000000..87ab3f7 --- /dev/null +++ b/librawssg_demo/src/main.rs @@ -0,0 +1,95 @@ +#![allow(clippy::multiple_crate_versions)] + +use librawssg_compiler::{PipelineBuilder, TeraContextBuilder}; +use librawssg_config::{Config, ContentRule}; +use librawssg_fs::{FileSystem, RealFs}; +use librawssg_handler::{Document, Metadata, Processor}; +use librawssg_templates::TeraRenderer; +use std::path::{Path, PathBuf}; + +struct RawFileProcessor; + +impl Processor for RawFileProcessor { + fn name(&self) -> &'static str { + "raw-file" + } + + fn can_process(&self, relative_path: &Path, _original_path: &Path) -> bool { + relative_path.extension().is_some_and(|ext| ext == "raw") + } + + fn process( + &self, + fs: &dyn FileSystem, + relative_path: &Path, + content_dir: &Path, + ) -> librawssg_error::Result> { + let full_path = content_dir.join(relative_path); + let body = fs.read_to_string(&full_path)?; + + let stem = relative_path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("untitled"); + let title = stem + .split('-') + .map(|word| { + let mut c = word.chars(); + c.next().map_or_else(String::new, |first| { + first.to_uppercase().collect::() + c.as_str() + }) + }) + .collect::>() + .join(" "); + + let metadata = Metadata::new(title, String::new())?; + let url = relative_path + .with_extension("html") + .to_string_lossy() + .to_string(); + let output_path = PathBuf::from(&url); + + let doc = Document::new( + metadata, + body, + url, + output_path, + relative_path.to_path_buf(), + relative_path.components().count().saturating_sub(1), + "page".to_string(), + false, + )?; + Ok(Some(doc)) + } +} + +fn main() -> Result<(), Box> { + let base = Path::new(env!("CARGO_MANIFEST_DIR")); + let content_dir = base.join("src/content"); + let templates_dir = base.join("src/templates"); + let static_dir = base.join("src/static"); + let output_dir = base.join("dist"); + let mut renderer = TeraRenderer::new(); + renderer.load_templates_dir(&templates_dir)?; + + let mut config = Config::new().with_site_name("Demo Site"); + config.add_content_rule(ContentRule::new("page", "**/*.raw", "base.tera")); + config.build.content_dir = content_dir.to_string_lossy().to_string(); + config.build.output_dir = output_dir.to_string_lossy().to_string(); + config.build.static_dir = static_dir.to_string_lossy().to_string(); + + let pipeline = PipelineBuilder::new() + .config(config) + .content_dir(&content_dir) + .output_dir(&output_dir) + .with_fs(Box::new(RealFs)) + .with_renderer(Box::new(renderer)) + .with_context_builder(Box::new(TeraContextBuilder)) + .add_processor(Box::new(RawFileProcessor)) + .build()?; + + pipeline.run()?; + + println!("✅ Site generated in '{}'", output_dir.display()); + Ok(()) +} diff --git a/librawssg_demo/src/static/style.css b/librawssg_demo/src/static/style.css new file mode 100644 index 0000000..458e2aa --- /dev/null +++ b/librawssg_demo/src/static/style.css @@ -0,0 +1,9 @@ +body { + font-family: sans-serif; + max-width: 800px; + margin: 0 auto; + padding: 2rem; +} +nav a { + margin-right: 1rem; +} diff --git a/librawssg_demo/src/templates/base.tera b/librawssg_demo/src/templates/base.tera new file mode 100644 index 0000000..66f5de5 --- /dev/null +++ b/librawssg_demo/src/templates/base.tera @@ -0,0 +1,18 @@ + + + + + + {{ page_title }} + + + + +
+ {{ page_content | safe }} +
+ + From 91064d297ea37c53b4976251d3864b8fba7d0465 Mon Sep 17 00:00:00 2001 From: mroczect Date: Tue, 8 Sep 2026 03:05:45 +0700 Subject: [PATCH 20/48] feat(librawssg): add facade crate with re-exports (#35) * chore(lock): update Cargo.lock for librawssg facade crate Update librawssg entry to version 1.0.0 and add dependencies on all workspace crates: compiler, config, error, fs, handler, templates. This reflects the new facade crate's Cargo.toml changes. * feat(librawssg): turn crate into facade with dependencies Bump version to 1.0.0 and add path dependencies to all internal workspace crates. Add package metadata including description, license, repository, readme, keywords, and categories. This enables the crate to re-export the entire library. * feat(librawssg): re-export all workspace crates and types Replace placeholder `add` function with module re-exports for config, fs, handler, templates, compiler, and error. Re-export common types like Config, Document, PipelineBuilder, TeraRenderer, and Error. Add `#![allow(clippy::multiple_crate_versions)]` to suppress workspace lint warnings. * docs(librawssg): add README for facade crate Add a short README describing the facade crate and listing the re-exported building blocks. This improves crate documentation and discoverability. --- Cargo.lock | 10 +++++++++- librawssg/Cargo.toml | 14 +++++++++++++- librawssg/README.md | 12 ++++++++++++ librawssg/src/lib.rs | 39 +++++++++++++++++++++++++++------------ 4 files changed, 61 insertions(+), 14 deletions(-) create mode 100644 librawssg/README.md diff --git a/Cargo.lock b/Cargo.lock index 28bd408..4e5a8bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -335,7 +335,15 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "librawssg" -version = "0.1.0" +version = "1.0.0" +dependencies = [ + "librawssg_compiler", + "librawssg_config", + "librawssg_error", + "librawssg_fs", + "librawssg_handler", + "librawssg_templates", +] [[package]] name = "librawssg_compiler" diff --git a/librawssg/Cargo.toml b/librawssg/Cargo.toml index fb8a976..1e6e861 100644 --- a/librawssg/Cargo.toml +++ b/librawssg/Cargo.toml @@ -1,9 +1,21 @@ [package] name = "librawssg" -version = "0.1.0" +version = "1.0.0" edition = "2024" +description = "A modular static site generator library" +license = "MIT" +repository = "https://github.com/mroczect/librawssg" +readme = "README.md" +keywords = ["ssg", "static-site-generator", "compiler", "templates"] +categories = ["development-tools::build-utils"] [dependencies] +librawssg_error = { version = "1.0.0", path = "../librawssg_error" } +librawssg_fs = { version = "1.0.0", path = "../librawssg_fs" } +librawssg_config = { version = "1.0.0", path = "../librawssg_config" } +librawssg_handler = { version = "1.0.0", path = "../librawssg_handler" } +librawssg_templates = { version = "1.0.0", path = "../librawssg_templates" } +librawssg_compiler = { version = "1.0.0", path = "../librawssg_compiler" } [lints] workspace = true diff --git a/librawssg/README.md b/librawssg/README.md new file mode 100644 index 0000000..6efdc79 --- /dev/null +++ b/librawssg/README.md @@ -0,0 +1,12 @@ +# librawssg + +A modular static site generator library for Rust. + +This facade crate re-exports the essential building blocks: +- Configuration types +- Filesystem abstraction +- Content processor contracts +- Template rendering (Tera built-in) +- Build pipeline orchestration + +See the crate documentation for usage examples. diff --git a/librawssg/src/lib.rs b/librawssg/src/lib.rs index b93cf3f..095386a 100644 --- a/librawssg/src/lib.rs +++ b/librawssg/src/lib.rs @@ -1,14 +1,29 @@ -pub fn add(left: u64, right: u64) -> u64 { - left + right -} - -#[cfg(test)] -mod tests { - use super::*; +#![allow(clippy::multiple_crate_versions)] - #[test] - fn it_works() { - let result = add(2, 2); - assert_eq!(result, 4); - } +pub mod config { + pub use librawssg_config::*; +} +pub mod fs { + pub use librawssg_fs::*; +} +pub mod handler { + pub use librawssg_handler::*; } +pub mod templates { + pub use librawssg_templates::*; +} +pub mod compiler { + pub use librawssg_compiler::*; +} +pub mod error { + pub use librawssg_error::*; +} + +pub use librawssg_compiler::{ + ContextBuilder, Generator, Pipeline, PipelineBuilder, TeraContextBuilder, +}; +pub use librawssg_config::{BuildConfig, Config, ContentRule, NavItem, SiteConfig}; +pub use librawssg_error::{Error, Result}; +pub use librawssg_fs::{FileSystem, RealFs}; +pub use librawssg_handler::{Document, Metadata, Processor}; +pub use librawssg_templates::{RenderContext, Renderer, TeraRenderer}; From 8eb124e89510dc4dd7f43cf941a03aac8876983b Mon Sep 17 00:00:00 2001 From: mroczect Date: Wed, 9 Sep 2026 00:07:06 +0700 Subject: [PATCH 21/48] docs: add comprehensive API documentation for all crates - Add root README.md with project overview, features, and usage examples - Create detailed API documentation for each workspace crate: - librawssg (facade) - librawssg_config - librawssg_fs - librawssg_handler - librawssg_templates - librawssg_compiler - librawssg_error - librawssg_demo - Include examples, validation rules, and testing details --- README.md | 754 +++++------------------- librawssg/README.md | 447 +++++++++++++- librawssg_compiler/README.md | 759 +++++++++++++++++++++++- librawssg_config/README.md | 762 +++++++++++++++++++++++- librawssg_demo/README.md | 259 +++++++++ librawssg_demo/src/static/style.css | 10 +- librawssg_error/README.md | 685 +++++++++++++++++++++- librawssg_fs/README.md | 867 +++++++++++++++++++++++++++- librawssg_handler/README.md | 775 ++++++++++++++++++++++++- librawssg_templates/README.md | 656 ++++++++++++++++++++- 10 files changed, 5337 insertions(+), 637 deletions(-) create mode 100644 librawssg_demo/README.md diff --git a/README.md b/README.md index 759c51f..d1b33f1 100644 --- a/README.md +++ b/README.md @@ -1,682 +1,212 @@ -# librawssg · [![GitHub tag](https://img.shields.io/github/v/tag/mroczect/librawssg?label=version)](https://github.com/mroczect/librawssg/tags) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![CI](https://github.com/mroczect/librawssg/actions/workflows/ci.yml/badge.svg)](https://github.com/mroczect/librawssg/actions/workflows/ci.yml) - -**librawssg** is the engine‑agnostic, safety‑first kernel for building static site generators in Rust. -It gives you all the primitives you need: filesystem abstraction, frontmatter parsing, Markdown rendering, template rendering, content processing pipelines, feed & sitemap generation, and a secure development server. - -The library does **not** include a CLI – you write your own `main.rs` and compose the parts you need. -Optional built‑in implementations for **Tera** and **pulldown‑cmark** are available behind feature flags. - ---- - -## Table of Contents - -- [What's New in v0.5.0](#whats-new-in-v050) -- [Installation](#installation) -- [Quick Start](#quick-start) -- [Architecture](#architecture) -- [API Reference](#api-reference) - - [Configuration](#configuration) - - [YAML Configuration File](#yaml-configuration-file) - - [ConfigLoader trait](#configloader-trait) - - [RawssgConfig::validate](#rawssgconfigvalidate) - - [RawssgConfig default](#rawssgconfig-default) - - [Error Handling](#error-handling) - - [Filesystem Abstraction](#filesystem-abstraction) - - [FileSystem trait](#filesystem-trait) - - [RealFs](#realfs) - - [Implementing a Custom FileSystem](#implementing-a-custom-filesystem) - - [Markdown Rendering](#markdown-rendering) - - [MarkdownRenderer trait](#markdownrenderer-trait) - - [PulldownMarkdown](#pulldownmarkdown) - - [Implementing a Custom Markdown Renderer](#implementing-a-custom-markdown-renderer) - - [Template Rendering](#template-rendering) - - [TemplateRenderer trait](#templaterenderer-trait) - - [Context trait](#context-trait) - - [TeraRenderer](#terarenderer) - - [Implementing a Custom Template Engine](#implementing-a-custom-template-engine) - - [Content Pipeline](#content-pipeline) - - [ContentHandler trait](#contenthandler-trait) - - [MarkdownPageHandler](#markdownpagehandler) - - [StaticFileHandler](#staticfilehandler) - - [build_page_context](#build_page_context) - - [Adding Custom Handlers](#adding-custom-handlers) - - [Site Builder](#site-builder) - - [SiteBuilder](#sitebuilder) - - [Site](#site) - - [Atomic Generation & Cross-Device Fallback](#atomic-generation--cross-device-fallback) - - [Feed & Sitemap](#feed--sitemap) - - [generate_feed and generate_sitemap](#generate_feed-and-generate_sitemap) - - [Context Builders](#context-builders) - - [Utility Functions](#utility-functions) - - [safe_path](#safe_path) - - [slugify](#slugify) - - [relative_prefix](#relative_prefix) - - [match_pattern](#match_pattern) - - [Type Reference](#type-reference) - - [RawssgConfig](#rawssgconfig) - - [GlobalConfig](#globalconfig) - - [BuildConfig](#buildconfig) - - [ContentTypeDef](#contenttypedef) - - [GeneratorsConfig & GeneratorDef](#generatorsconfig--generatordef) - - [NavItem](#navitem) - - [PageFrontMatter](#pagefrontmatter) - - [PageContext](#pagecontext) - - [Dev Server & Watcher (serve feature)](#dev-server--watcher-serve-feature) -- [Feature Flags](#feature-flags) -- [Security](#security) -- [Full Customisation](#full-customisation) - - [Step‑by‑Step: Building a Fully Custom SSG](#stepbystep-building-a-fully-custom-ssg) -- [Testing](#testing) -- [Contributing](#contributing) -- [License](#license) - ---- - -## What's New in v0.5.0 - -- **Robust path security** – Symlink‑safe output writing via canonicalised parent directories. -- **Correct glob matching** – `**` patterns now match exactly as expected (e.g., `blog/**/*.html` no longer matches `.md`). -- **Completely trait‑based** – Added `rename` to `FileSystem`; all I/O goes through the trait for full mockability. -- **Better defaults** – A default `page` content type (`**/*.md` → `base.html`) is included out‑of‑the‑box. -- **Optional context builders** – Feed and sitemap context builders are now only required when the respective generator is enabled. -- **Clearer error messages** – Missing closing `---` in frontmatter is reported explicitly; config loading failures are logged. -- **Improved testing** – Property‑based tests, dynamic server ports, and a complete mock filesystem. - ---- - -## Installation - -### Method 1: `cargo add` (Git dependency – recommended) +# librawssg -```bash -cargo add --git https://github.com/mroczect/librawssg.git --tag v0.5.0 librawssg -cargo add --git https://github.com/mroczect/librawssg.git --tag v0.5.0 librawssg --features tera,pulldown -``` +A modular static site generator library for Rust. -### Method 2: Manual `Cargo.toml` entry +`librawssg` is a collection of crates that together form a flexible and extensible framework for building static site generators. The project is designed with modularity, testability, and safety in mind, leveraging Rust's type system and trait abstractions. -```toml -[dependencies] -librawssg = { git = "https://github.com/mroczect/librawssg.git", tag = "v0.5.0" } -librawssg = { git = "https://github.com/mroczect/librawssg.git", tag = "v0.5.0", features = ["tera", "pulldown"] } -``` +## Features -### Method 3: Path dependency (local development) +- **Modular architecture** – Each aspect (configuration, filesystem, content processing, templating, compilation) is isolated into its own crate. +- **Pluggable processors** – Define custom content processors via the `Processor` trait. +- **Template engine integration** – Built-in support for Tera templates through the `TeraRenderer` (optional, enabled by default). +- **Strong filesystem abstraction** – Trait-based filesystem with built-in path traversal protection. +- **Atomic output generation** – The build pipeline writes to a temporary directory and atomically replaces the final output. +- **Comprehensive configuration** – YAML/JSON support, validation, and nested site/build settings. +- **Extensible** – Add custom renderers, context builders, and post-processing generators. +- **Strict linting** – Deny-level lints for clippy and rustc ensure high code quality. +- **Demo application** – A complete example showing how to assemble the parts into a working static site. -```bash -git clone https://github.com/mroczect/librawssg.git -cd librawssg -# in your project's Cargo.toml: -librawssg = { path = "../librawssg", features = ["tera", "pulldown"] } -``` +## Repository Structure ---- +The workspace consists of the following crates: -## Quick Start +| Crate | Description | +| --------------------- | -------------------------------------------------------------- | +| `librawssg` | Facade crate that re-exports all other crates for convenience. | +| `librawssg_config` | Configuration data structures and validation. | +| `librawssg_fs` | Filesystem abstraction trait and real implementation. | +| `librawssg_handler` | Core document, metadata, and processor contracts. | +| `librawssg_templates` | Rendering traits and Tera implementation. | +| `librawssg_compiler` | Build pipeline orchestration. | +| `librawssg_error` | Unified error types and result alias. | +| `librawssg_demo` | Example application demonstrating usage of the framework. | -```rust -use librawssg::SiteBuilder; -use librawssg::site::TeraRenderer; -use librawssg::markdown::PulldownMarkdown; +## Getting Started -fn main() -> Result<(), Box> { - let mut tera = TeraRenderer::new(); - tera.add_raw_template("base.html", "{{ page_content }}")?; - let md = PulldownMarkdown; +### Prerequisites - let mut config = librawssg::RawssgConfig::default(); - config.build.content_dir = "content".into(); - config.build.output_dir = "dist".into(); - - let site = SiteBuilder::new() - .config(config) - .with_template_renderer(Box::new(tera)) - .with_markdown_renderer(Box::new(md)) - .build()?; - - site.generate()?; - Ok(()) -} -``` - ---- - -## Architecture - -``` -src/ - config/ ConfigLoader trait, YamlConfigLoader, DefaultConfig - error.rs RawssgError (miette + thiserror) - frontmatter.rs YAML frontmatter extraction and Markdown rendering - fs/ FileSystem trait, RealFs - markdown.rs MarkdownRenderer trait, optional PulldownMarkdown - serve/ Dev server and file watcher (feature "serve") - site/ - builders/ Site and SiteBuilder structs - context.rs FeedContextBuilder, SitemapContextBuilder traits - feed.rs generate_feed - mod.rs Core traits: TemplateRenderer, Context, ContentHandler - page.rs build_page_context - sitemap.rs generate_sitemap - types.rs All configuration and page context types - util.rs safe_path, slugify, relative_prefix, match_pattern -``` +- Rust toolchain (stable, edition 2024) – install via [rustup](https://rustup.rs/) +- Cargo (comes with Rust) ---- +### Building the Project -## API Reference +Clone the repository and build all crates: -### Configuration - -#### YAML Configuration File - -```yaml -site: - site_name: "My Site" - description: "A blog about Rust" - base_url: "https://example.com" - language: "en" - author: "Alice" -build: - content_dir: content - output_dir: dist - templates_dir: templates - static_dir: static -content_types: - - name: blog - pattern: blog/**/*.md - template: post.html - list_template: blog_list.html - list_enabled: true - - name: page - pattern: **/*.md - template: page.html -generators: - rss: - enabled: true - path: feed.xml - template: rss.xml - sitemap: - enabled: true - path: sitemap.xml - template: sitemap.xml -``` - -#### ConfigLoader trait - -```rust -pub trait ConfigLoader: Send + Sync { - fn load(&self) -> Result; - fn load_or_default(&self) -> RawssgConfig; -} -``` - -- `YamlConfigLoader>` – reads a YAML file. -- `DefaultConfig` – returns `RawssgConfig::default()`. - -#### RawssgConfig::validate - -- Checks that `site_name` is non‑empty. -- At least one `content_types` entry must exist. -- Each content type must have a valid glob pattern and a template name. -- If RSS or sitemap is enabled, their `path` and `template` must be set. - -#### RawssgConfig default - -Since v0.5.0, the default configuration includes a single content type: - -```rust -ContentTypeDef { - name: "page".into(), - pattern: "**/*.md".into(), - template: "base.html".into(), - list_template: None, - list_enabled: false, -} -``` - -You can remove it with `config.content_types.clear()` and define your own. - -### Error Handling - -`RawssgError` implements `std::error::Error`, `Display`, and `miette::Diagnostic`. - -```rust -match err { - RawssgError::Frontmatter { path, source } => { /* ... */ } - RawssgError::PathTraversal(msg) => { /* ... */ } - // ... -} -``` - -### Filesystem Abstraction - -#### FileSystem trait - -```rust -pub trait FileSystem: Send + Sync { - fn read_to_string(&self, path: &Path) -> io::Result; - fn read_bytes(&self, path: &Path) -> io::Result>; - fn write(&self, path: &Path, content: &[u8]) -> io::Result<()>; - fn create_dir_all(&self, path: &Path) -> io::Result<()>; - fn remove_dir_all(&self, path: &Path) -> io::Result<()>; - fn exists(&self, path: &Path) -> bool; - fn is_dir(&self, path: &Path) -> bool; - fn is_file(&self, path: &Path) -> bool; - fn read_dir(&self, path: &Path) -> io::Result>; - fn copy_file(&self, from: &Path, to: &Path) -> io::Result; - fn walk_dir(&self, root: &Path) -> io::Result>; - fn canonicalize(&self, path: &Path) -> io::Result; - fn rename(&self, from: &Path, to: &Path) -> io::Result<()>; // new in v0.5.0 -} -``` - -#### RealFs - -Default implementation delegating to `std::fs` and `walkdir`. - -#### Implementing a Custom FileSystem - -```rust -struct MyFs; -impl FileSystem for MyFs { - // implement all methods; e.g., read from database or network - fn read_to_string(&self, path: &Path) -> io::Result { /* ... */ } - // ... etc. -} -let site = SiteBuilder::new().with_fs(Box::new(MyFs)).build()?; +```bash +git clone https://github.com/mroczect/librawssg.git +cd librawssg +cargo build ``` -### Markdown Rendering +### Running the Demo -#### MarkdownRenderer trait +The `librawssg_demo` crate provides a working example. To run it: -```rust -pub trait MarkdownRenderer: Send + Sync { - fn render(&self, markdown: &str) -> String; -} +```bash +cargo run -p librawssg_demo ``` -#### PulldownMarkdown - -Available with `pulldown` feature. Enables tables, strikethrough, task lists. - -#### Implementing a Custom Markdown Renderer - -```rust -struct MyMd; -impl MarkdownRenderer for MyMd { - fn render(&self, md: &str) -> String { my_parser(md) } -} -``` +This will process `.raw` HTML fragment files from `librawssg_demo/src/content`, render them using a Tera template, copy static assets, and output the site into `librawssg_demo/dist`. -### Template Rendering +### Using as a Library -#### TemplateRenderer trait +Add `librawssg` to your `Cargo.toml`: -```rust -pub trait TemplateRenderer: Send + Sync { - fn render(&self, template_name: &str, context: &dyn Context) -> Result; -} +```toml +[dependencies] +librawssg = "1.0.0" ``` -#### Context trait +Then you can import the necessary components. Here is a minimal example that sets up a pipeline: ```rust -pub trait Context: Send + Sync { - fn as_any(&self) -> &dyn Any; - fn as_mut_any(&mut self) -> &mut dyn Any; -} -``` +use librawssg::{ + Config, ContentRule, Document, FileSystem, Metadata, PipelineBuilder, Processor, + RealFs, RenderContext, Renderer, TeraContextBuilder, TeraRenderer, +}; +use std::path::{Path, PathBuf}; -#### TeraRenderer - -- `new()` – creates an empty Tera instance. -- `add_raw_template(name, content)` – registers an inline template. - -#### Implementing a Custom Template Engine - -Implement `TemplateRenderer` and a `Context` wrapper. -Example: MiniJinja. - -```rust -struct MiniJinjaRenderer { env: mini_jinja::Environment<'static> } -impl TemplateRenderer for MiniJinjaRenderer { - fn render(&self, name: &str, ctx: &dyn Context) -> Result { - let tmpl = self.env.get_template(name).map_err(|e| RawssgError::Template(e.to_string()))?; - let data = ctx.as_any().downcast_ref::().unwrap(); - tmpl.render(data).map_err(|e| RawssgError::Template(e.to_string())) +// Implement a custom processor for .txt files +struct TextProcessor; +impl Processor for TextProcessor { + fn name(&self) -> &'static str { "text" } + fn can_process(&self, rel: &Path, _orig: &Path) -> bool { + rel.extension().and_then(|e| e.to_str()) == Some("txt") } -} -impl Context for serde_json::Value { /* as_any downcast */ } -``` - -### Content Pipeline - -#### ContentHandler trait - -```rust -pub trait ContentHandler: Send + Sync { - fn can_handle(&self, relative_path: &Path, original_path: &Path) -> bool; - fn process(&self, fs: &dyn FileSystem, md_renderer: &dyn MarkdownRenderer, - file_path: &Path, content_dir: &Path) -> Result, RawssgError>; -} -``` - -Return `None` to skip a file. - -#### MarkdownPageHandler - -Handles `.md` files; extracts frontmatter, renders Markdown. - -#### StaticFileHandler - -Always returns `None` (catch‑all, non‑Markdown files become static assets). - -#### build_page_context - -```rust -pub fn build_page_context(fs: &dyn FileSystem, md_renderer: &dyn MarkdownRenderer, - file_path: &Path, content_dir: &Path) -> Result, RawssgError>; -``` - -Skips drafts. Returns a `PageContext` with URL, depth, date formatting. - -#### Adding Custom Handlers - -```rust -struct AsciiDocHandler; -impl ContentHandler for AsciiDocHandler { - fn can_handle(&self, _rel: &Path, orig: &Path) -> bool { - orig.extension().map_or(false, |e| e == "adoc") - } - fn process(&self, fs: &dyn FileSystem, _md: &dyn MarkdownRenderer, ...) -> Result, RawssgError> { - let content = fs.read_to_string(file_path)?; - let html = asciidoc_render(&content); - Ok(Some(PageContext { content_html: html, .. })) + fn process( + &self, + fs: &dyn FileSystem, + rel: &Path, + content_dir: &Path, + ) -> librawssg::Result> { + let body = fs.read_to_string(&content_dir.join(rel))?; + let meta = Metadata::new("Page", "Description")?; + let url = rel.with_extension("html").to_string_lossy().to_string(); + let doc = Document::new( + meta, + body, + url.clone(), + PathBuf::from(&url), + rel.to_path_buf(), + 0, + "page".to_string(), + false, + )?; + Ok(Some(doc)) } } -let builder = SiteBuilder::new().add_handler(Box::new(AsciiDocHandler)); -``` - -### Site Builder - -#### SiteBuilder - -```rust -SiteBuilder::new() - .config(config) - .load_config("config.yml")? // alternative to .config() - .content_dir("my_content") - .output_dir("public") - .with_fs(Box::new(RealFs)) - .with_markdown_renderer(Box::new(PulldownMarkdown)) - .with_template_renderer(Box::new(TeraRenderer::new())) - .with_feed_context_builder(Box::new(TeraFeedContextBuilder)) - .with_sitemap_context_builder(Box::new(TeraSitemapContextBuilder)) - .add_handler(Box::new(MyHandler)) - .build()?; -``` - -- `content_dir`, `output_dir` can be overridden by config values if left as default (`"content"`, `"dist"`). -- `feed_context_builder` and `sitemap_context_builder` are only required when the corresponding generator is enabled. - -#### Site - -```rust -let pages: &[PageContext] = site.pages(); -site.generate()?; // atomic write to output_dir -``` - -`generate()`: - -1. Writes all pages (HTML) to `output_dir`. -2. Copies static assets from `static_dir`. -3. Copies non‑Markdown files from `content_dir`. -4. Optionally generates RSS and sitemap (if `tera` feature + enabled). -5. Uses atomic write: temp dir → rename (with cross‑device fallback). - -#### Atomic Generation & Cross-Device Fallback - -If `rename` fails with `CrossesDevices`, the library performs a recursive copy and then deletes the temporary directory. -### Feed & Sitemap - -#### generate_feed and generate_sitemap - -```rust -pub fn generate_feed(renderer: &dyn TemplateRenderer, config: &RawssgConfig, - posts: &[&PageContext], base_url: &str, context_builder: &dyn FeedContextBuilder) -> Result; -pub fn generate_sitemap(renderer: &dyn TemplateRenderer, config: &RawssgConfig, - pages: &[PageContext], base_url: &str, context_builder: &dyn SitemapContextBuilder) -> Result; -``` +fn main() -> Result<(), Box> { + let mut config = Config::new().with_site_name("My Site"); + config.add_content_rule(ContentRule::new("page", "**/*.txt", "base.tera")); + config.build.content_dir = "content".into(); + config.build.output_dir = "dist".into(); + config.build.static_dir = "static".into(); -Callers are responsible for writing the returned string to the output file; `Site::generate` does this automatically. + let mut renderer = TeraRenderer::new(); + renderer.load_templates_dir(Path::new("templates"))?; -#### Context Builders + let pipeline = PipelineBuilder::new() + .config(config) + .content_dir("content") + .output_dir("dist") + .with_fs(Box::new(RealFs)) + .with_renderer(Box::new(renderer)) + .with_context_builder(Box::new(TeraContextBuilder)) + .add_processor(Box::new(TextProcessor)) + .build()?; -```rust -pub trait FeedContextBuilder: Send + Sync { - fn build_feed_context(&self, config: &RawssgConfig, posts: &[&PageContext], base_url: &str) - -> Result, RawssgError>; + pipeline.run()?; + println!("Site generated!"); + Ok(()) } ``` -Default Tera implementations insert `site`, `posts`/`pages`, and `base_url`. Custom builders can add extra variables (e.g., `ctx.insert("custom", &"value")`). - -### Utility Functions - -#### safe_path - -```rust -pub fn safe_path(fs: &dyn FileSystem, base: &Path, candidate: &Path) -> Result; -``` - -- Canonicalises `base`. -- For existing files: canonicalises the candidate, checks it stays inside `base`. -- For non‑existent files (output): canonicalises the parent directory and verifies confinement. -- Returns an absolute, safe path. - -#### slugify - -Converts a string to lowercase, alphanumeric + hyphens. Example: `"Hello World!"` → `"hello-world"`. - -#### relative_prefix - -Returns `"./"` for depth 0, `"../"` repeated for deeper paths. - -#### match_pattern - -Glob matching with `*` (single segment) and `**` (multi‑segment). Correctly handles patterns like `blog/**/*.html`. - -### Type Reference +For a more detailed example, see the `librawssg_demo` source code. -#### RawssgConfig +## Core Concepts -```rust -pub struct RawssgConfig { - pub site: GlobalConfig, - pub build: BuildConfig, - pub content_types: Vec, - pub generators: GeneratorsConfig, -} -``` +### Configuration -- `validate()` ensures invariants. -- `Default` now includes one content type. +The `Config` struct holds all settings required for the build. It includes: -#### GlobalConfig +- `site`: Site-wide metadata (name, description, navigation, etc.) +- `build`: Paths for content, output, templates, and static assets. +- `content_rules`: A list of `ContentRule` objects that map file patterns to templates. +- `extra`: Arbitrary key-value data. -```rust -pub struct GlobalConfig { - pub site_name: String, // default "rawssg" - pub description: Option, - pub language: Option, // default Some("en") - pub base_url: Option, - pub author: Option, - pub repo_url: Option, - pub license: Option, - pub navbar: Vec, - pub sidebar: Vec, -} -``` +Configuration can be loaded from YAML or JSON using `Config::from_yaml_str` / `Config::from_json_str`. -#### BuildConfig +### Content Processing -```rust -pub struct BuildConfig { - pub content_dir: String, // default "content" - pub output_dir: String, // default "dist" - pub templates_dir: String, // default "templates" - pub static_dir: String, // default "static" -} -``` +Content files are processed by implementations of the `Processor` trait. Each processor declares which files it can handle via `can_process()`, and then transforms them into `Document` objects. The pipeline walks the content directory, determines the appropriate processor for each file, and collects the resulting documents. -#### ContentTypeDef +### Rendering -```rust -pub struct ContentTypeDef { - pub name: String, - pub pattern: String, // glob - pub template: String, - pub list_template: Option, - pub list_enabled: bool, -} -``` +The `Renderer` trait abstracts template rendering. The built-in `TeraRenderer` uses the Tera template engine. A `ContextBuilder` creates the render context for each document; the default `TeraContextBuilder` populates it with page and site data. -#### GeneratorsConfig & GeneratorDef +### Build Pipeline -```rust -pub struct GeneratorsConfig { pub rss: GeneratorDef, pub sitemap: GeneratorDef } -pub struct GeneratorDef { - pub enabled: bool, // default false - pub path: String, - pub template: String, -} -``` +The `PipelineBuilder` assembles all components (filesystem, renderer, processors, context builder, generators) and produces a `Pipeline`. Calling `pipeline.run()` performs the following steps: -#### NavItem +1. Processes all content files. +2. Renders non-list documents. +3. Optionally generates list pages (index pages) for content types with list support enabled. +4. Copies static assets. +5. Executes any custom generators. +6. Atomically replaces the output directory. -```rust -pub struct NavItem { pub label: String, pub url: String } -``` +## Customization -#### PageFrontMatter +You can extend the framework by implementing the following traits: -```rust -pub struct PageFrontMatter { - pub title: String, - pub desc: String, - pub author: Option, - pub date: Option, - pub tags: Vec, - pub draft: bool, - // ... -} -``` +- **`Processor`** – For handling new file types or custom transformations. +- **`Renderer`** and **`RenderContext`** – To integrate a different template engine. +- **`ContextBuilder`** – To customize the data passed to templates. +- **`Generator`** – To add extra outputs like RSS feeds, sitemaps, or search indexes. -#### PageContext +All components are passed to the pipeline as boxed trait objects, so they are easily swappable. -```rust -pub struct PageContext { - pub frontmatter: PageFrontMatter, - pub content_html: String, - pub url: String, - pub file_path: String, - pub depth: usize, - pub pub_date: Option, - pub content_type: String, - pub is_list: bool, - pub list_items: Option>, -} -``` +## Development -### Dev Server & Watcher (serve feature) +### Workspace Lints -```rust -use librawssg::serve::start_dev_server; -start_dev_server(Path::new("dist"), 8080)?; -``` +The workspace enforces strict linting via `[workspace.lints]` in the root `Cargo.toml`. Many clippy and rustc lints are set to `deny`, including `unsafe_code = "forbid"`, `unwrap_used = "deny"`, `expect_used = "deny"`, `panic = "deny"`, and many others. This ensures high code quality and safety. When contributing, please ensure your code passes `cargo clippy --all --all-targets --all-features -- -D warnings` and `cargo fmt --check`. -- Serves files with correct MIME types (including `text/plain` for `.txt`, `.md`, `.yaml`). -- 404 for missing, 500 for internal errors. +### Running Tests -```rust -use librawssg::serve::watch_dirs; -let _watcher = watch_dirs(&[content_path, templates_path], || { rebuild(); })?; +```bash +cargo test --workspace ``` -Uses `notify` to trigger on `Modify`, `Create`, `Remove`. - ---- - -## Feature Flags - -| Feature | Deps | Description | -| ---------- | -------------------- | -------------------------------- | -| `tera` | `tera` | `TeraRenderer`, context builders | -| `pulldown` | `pulldown-cmark` | `PulldownMarkdown` | -| `serve` | `tiny_http`,`notify` | Dev server + file watcher | - -All disabled by default. - ---- - -## Security - -- **Path confinement**: `safe_path` prevents directory traversal for both existing and new files. -- **Atomic output**: Temp directory → rename; fallback copy‑and‑delete ensures atomicity across devices. -- **Configuration validation**: All YAML keys are known; glob patterns are validated. -- **Trait‑based I/O**: Every disk access goes through `FileSystem`, allowing sandboxing and auditing. - ---- - -## Full Customisation - -Every core component is a trait. You can: - -- **Filesystem**: Implement `FileSystem` to read from database, in‑memory store, or network. -- **Markdown**: Any parser via `MarkdownRenderer`. -- **Templates**: Any engine via `TemplateRenderer` + `Context`. -- **Content handlers**: Add new file processors (e.g., AsciiDoc, reStructuredText). -- **Feed/Sitemap**: Custom context builders inject arbitrary variables. -- **Site generation**: Use `SiteBuilder` to build `Site`, then replace `generate()` with your own logic. - -### Step‑by‑Step: Building a Fully Custom SSG - -1. **Define your custom types** – implement the required traits. -2. **Load configuration** – use `YamlConfigLoader` or build `RawssgConfig` programmatically. -3. **Instantiate `SiteBuilder`** with your custom implementations. -4. **Call `.build()`** to obtain a `Site`. -5. **Generate** using `site.generate()`, or iterate over `site.pages()` for custom output. - ---- - -## Testing +### Formatting ```bash -cargo fmt --all -- --check -cargo clippy --all-targets --all-features -- -D warnings -cargo test --workspace --all-features +cargo fmt --all ``` -The test suite includes: - -- `MockFs` – full mock filesystem (with `rename`). -- Mock renderers. -- Property‑based tests (`proptest`). -- Integration tests: full generation, drafts, blog lists, RSS/sitemap errors. -- Dynamic port allocation for server tests. +## Contributing ---- +Contributions are welcome! Please read `CONTRIBUTING.md` and `CODE_OF_CONDUCT.md` for guidelines. By participating, you agree to abide by the project's code of conduct. -## Contributing +## License -Please read [`CONTRIBUTING.md`](CONTRIBUTING.md) for guidelines. -All contributions are welcome – issues, PRs, documentation improvements. +This project is licensed under the **MIT License**. See the [LICENSE](LICENSE) file for details. ---- +## Acknowledgements -## License +This project uses the following open-source crates (among others): -MIT. See [`LICENSE`](LICENSE). +- [Tera](https://github.com/Keats/tera) – Template engine +- [Serde](https://serde.rs/) – Serialization framework +- [WalkDir](https://github.com/BurntSushi/walkdir) – Directory traversal diff --git a/librawssg/README.md b/librawssg/README.md index 6efdc79..9388444 100644 --- a/librawssg/README.md +++ b/librawssg/README.md @@ -2,11 +2,442 @@ A modular static site generator library for Rust. -This facade crate re-exports the essential building blocks: -- Configuration types -- Filesystem abstraction -- Content processor contracts -- Template rendering (Tera built-in) -- Build pipeline orchestration - -See the crate documentation for usage examples. +This facade crate re‑exports the essential building blocks from the `librawssg` ecosystem, providing a single convenient entry point for building static site generators. It aggregates configuration management, filesystem abstraction, content processing, template rendering (with Tera built‑in), and build pipeline orchestration. + +--- + +## Table of Contents + +1. [Overview](#overview) +2. [Installation](#installation) +3. [Modules](#modules) +4. [Core Types and Traits](#core-types-and-traits) + - [Configuration](#configuration) + - [Filesystem](#filesystem) + - [Content Handling](#content-handling) + - [Template Rendering](#template-rendering) + - [Compiler Pipeline](#compiler-pipeline) + - [Error Handling](#error-handling) +5. [Usage Example](#usage-example) +6. [Full API Reference](#full-api-reference) + - [Configuration Types](#configuration-types) + - [Filesystem Types](#filesystem-types) + - [Handler Types](#handler-types) + - [Template Types](#template-types) + - [Compiler Types](#compiler-types) + - [Error Types](#error-types) +7. [Feature Flags](#feature-flags) +8. [License](#license) + +--- + +## Overview + +`librawssg` is the top‑level crate that brings together six specialized crates: + +- **`librawssg_config`** – Configuration data structures and validation. +- **`librawssg_fs`** – Trait‑based filesystem abstraction with path traversal protection. +- **`librawssg_handler`** – Core document and metadata types, plus the `Processor` trait. +- **`librawssg_templates`** – Rendering traits (`Renderer`, `RenderContext`) and a Tera implementation. +- **`librawssg_compiler`** – Build pipeline orchestration. +- **`librawssg_error`** – Unified error enum and `Result` alias. + +By depending on `librawssg`, you get all these components without needing to specify each one individually. The facade also re‑exports the most commonly used types at the crate root for ergonomic access. + +--- + +## Installation + +Add `librawssg` to your `Cargo.toml`: + +```toml +[dependencies] +librawssg = "1.0.0" +``` + +If you are working in the same workspace as the `librawssg` source, you can use a path dependency: + +```toml +[dependencies] +librawssg = { path = "../librawssg" } +``` + +The crate is compatible with Rust edition 2024 and later. + +--- + +## Modules + +The crate organises its re‑exports into submodules for clarity: + +- **`librawssg::config`** – Configuration types (`Config`, `SiteConfig`, `BuildConfig`, `ContentRule`, `NavItem`). +- **`librawssg::fs`** – Filesystem trait and `RealFs`. +- **`librawssg::handler`** – Document, metadata, and processor contracts. +- **`librawssg::templates`** – Rendering traits and `TeraRenderer`. +- **`librawssg::compiler`** – Pipeline builder, pipeline, context builders, generators. +- **`librawssg::error`** – Error type and `Result` alias. + +Additionally, the most important types are also re‑exported directly at the crate root for convenience. + +--- + +## Core Types and Traits + +### Configuration + +The configuration system revolves around the `Config` struct, which contains site settings, build paths, and content processing rules. + +- **`Config`** – Top‑level configuration. + - **Fields**: `site: SiteConfig`, `build: BuildConfig`, `content_rules: Vec`, `extra: HashMap`. + - **Constructors**: + - `Config::new() -> Config` + - `Config::default() -> Config` + - **Builder method**: `with_site_name(name: impl Into) -> Self` + - **Rule management**: + - `add_content_rule(&mut self, rule: ContentRule)` + - `find_rule_by_name(&self, name: &str) -> Option<&ContentRule>` + - `remove_rule_by_name(&mut self, name: &str) -> Option` + - `has_duplicate_rule_names(&self) -> bool` + - **Validation**: `validate(&self) -> Result<()>` + - **Serialization**: + - `from_yaml_str(yaml: &str) -> Result` + - `to_yaml_string(&self) -> Result` + - `from_json_str(json: &str) -> Result` + - `to_json_string(&self) -> Result` + +- **`SiteConfig`** – Global site metadata. + - **Fields**: `navbar`, `sidebar`, `site_name`, `description`, `language`, `base_url`, `author`, `repo_url`, `license`, `extra`. + - **Constructors**: `SiteConfig::new(site_name: impl Into) -> Self`, `SiteConfig::default()`. + - **Defaults**: `site_name = "librawssg"`, `language = Some("en")`. + +- **`BuildConfig`** – Filesystem path settings. + - **Fields**: `content_dir`, `output_dir`, `templates_dir`, `static_dir`. + - **Constructors**: `BuildConfig::new()`, `BuildConfig::default()`. + - **Defaults**: `"content"`, `"dist"`, `"templates"`, `"static"`. + +- **`ContentRule`** – Defines how a group of files should be processed. + - **Fields**: `name`, `pattern`, `template`, `list_template: Option`, `list_enabled: bool`, `extra: HashMap`. + - **Constructor**: `ContentRule::new(name, pattern, template) -> Self`. + - **Default**: all strings empty, `list_enabled = false`. + +- **`NavItem`** – Navigation menu entry. + - **Fields**: `label: String`, `url: String`, `children: Vec`. + - **Constructor**: `NavItem::new(label, url) -> Self`. + +### Filesystem + +- **`FileSystem` trait** – Abstract filesystem operations. All methods return `io::Result` or `bool`. + - Required methods: + - `read_to_string`, `read_bytes`, `write`, `create_dir_all`, `remove_dir_all`, `remove_file`, `create_dir`, `exists`, `is_dir`, `is_file`, `read_dir`, `copy_file`, `copy_dir_all`, `walk_dir`, `canonicalize`, `rename`, `atomic_write`, `touch`, `metadata`, `symlink_metadata`, `permissions`, `set_permissions`, `read_link`, `hard_link`. + - Provided methods (with default implementations): + - `is_symlink` + - `canonicalize_or_join` + - `safe_join` – **Important for security**: ensures the resulting path stays within a base directory. + - `copy` + - `rename_or_copy` + +- **`RealFs`** – Zero‑sized struct implementing `FileSystem` using `std::fs` and `walkdir`. + +### Content Handling + +- **`Document`** – Represents a processed content item. + - **Fields**: `metadata: Metadata`, `body: String`, `url: String`, `output_path: PathBuf`, `source_path: PathBuf`, `depth: usize`, `content_type: String`, `is_list: bool`, `list_items: Option>`, `taxonomies: HashMap>`. + - **Constructor**: `Document::new(metadata, body, url, output_path, source_path, depth, content_type, is_list) -> Result`. + - **Methods**: + - `relative_url(&self) -> &str` + - `add_taxonomy(&mut self, name: impl Into, items: Vec)` + - `depth(&self) -> usize` + - `with_list_items(self, items: Vec) -> Self` + +- **`Metadata`** – Front matter data. + - **Fields**: `title`, `description`, `author: Option`, `repo_url`, `license`, `date: Option`, `updated: Option`, `tags: Vec`, `draft: bool`, `extra: HashMap`. + - **Constructor**: `Metadata::new(title, description) -> Result`. + - **Methods**: + - `is_draft(&self) -> bool` + - `insert_extra(&mut self, key, value)` + - `get_extra(&self, key: &str) -> Option<&serde_json::Value>` + +- **`Processor` trait** – Interface for transforming source files into `Document`s. + - Required methods: + - `name(&self) -> &str` + - `can_process(&self, relative_path: &Path, original_path: &Path) -> bool` + - `process(&self, fs: &dyn FileSystem, relative_path: &Path, content_dir: &Path) -> Result>` + - Provided method: `priority(&self) -> i32` (default 0). + +### Template Rendering + +- **`RenderContext` trait** – Type‑erased context for renderers. + - Required methods: `as_any(&self) -> &dyn Any`, `as_mut_any(&mut self) -> &mut dyn Any`. + +- **`Renderer` trait** – Interface for template rendering. + - Required method: `render(&self, template_name: &str, context: &dyn RenderContext) -> Result`. + +- **`TeraRenderer`** – Concrete renderer using the Tera template engine. + - Available when the `tera` feature is enabled (enabled by default). + - **Constructors**: `TeraRenderer::new()`, `Default`. + - **Methods**: + - `add_raw_template(&mut self, name: &str, content: &str) -> Result<()>` + - `add_template_file(&mut self, path: &Path) -> Result<()>` + - `add_template_files_from_dir(&mut self, dir: &Path) -> Result<()>` + - `load_templates_dir(&mut self, dir: &Path) -> Result<()>` + - `enable_autoescape(&mut self)` + - `render_str(&self, template_str: &str, context: &dyn RenderContext) -> Result` + - `as_tera(&self) -> &tera::Tera` + - `as_tera_mut(&mut self) -> &mut tera::Tera` + +### Compiler Pipeline + +- **`PipelineBuilder`** – Builds a `Pipeline`. + - **Constructor**: `PipelineBuilder::new()`. + - **Builder methods**: `config`, `load_config`, `content_dir`, `output_dir`, `with_fs`, `with_renderer`, `add_processor`, `with_context_builder`, `add_generator`. + - **Build**: `build(self) -> Result`. + +- **`Pipeline`** – Executes the site generation. + - **Method**: `run(&self) -> Result<()>`. + - **Accessor**: `config(&self) -> &Config`. + +- **`ContextBuilder` trait** – Creates a `RenderContext` from `Config` and `Document`. + - Required method: `build_context(&self, config: &Config, doc: &Document) -> Result>`. + +- **`TeraContextBuilder`** – Default implementation that produces a `tera::Context` with common page and site variables. + +- **`Generator` trait** – Custom post‑processing step. + - Required method: `generate(&self, pipeline: &Pipeline, output_base: &Path) -> Result<()>`. + +- **`match_pattern` function** (in `compiler::pattern`) – Glob matching for content rule patterns. + +### Error Handling + +- **`Error` enum** – Variants: `Io`, `Config`, `Metadata`, `Render`, `Processor`, `Generator`, `PathTraversal`, `MissingConfig`, `Generation`, `NotFound`, `Serialization`, `Validation`, `Duplicate`, `InvalidState`, `Internal`. +- **`Result`** – Alias for `core::result::Result`. + +--- + +## Usage Example + +Here’s a minimal but complete example that builds a site from raw HTML fragments using a custom processor, Tera templates, and the default context builder: + +```rust +use librawssg::{ + Config, ContentRule, Document, FileSystem, Metadata, PipelineBuilder, Processor, + RealFs, RenderContext, Renderer, TeraContextBuilder, TeraRenderer, +}; +use std::path::{Path, PathBuf}; + +// 1. Define a simple processor for .raw files +struct RawProcessor; +impl Processor for RawProcessor { + fn name(&self) -> &'static str { "raw" } + fn can_process(&self, rel: &Path, _orig: &Path) -> bool { + rel.extension().and_then(|e| e.to_str()) == Some("raw") + } + fn process( + &self, + fs: &dyn FileSystem, + rel: &Path, + content_dir: &Path, + ) -> librawssg::Result> { + let full_path = content_dir.join(rel); + let body = fs.read_to_string(&full_path)?; + let title = rel.file_stem().unwrap_or_default().to_string_lossy().to_string(); + let meta = Metadata::new(title, String::new())?; + let url = rel.with_extension("html").to_string_lossy().to_string(); + let output = PathBuf::from(&url); + let doc = Document::new(meta, body, url, output, rel.to_path_buf(), 0, "page".to_string(), false)?; + Ok(Some(doc)) + } +} + +fn main() -> Result<(), Box> { + // 2. Configure the site + let mut config = Config::new().with_site_name("My Site"); + config.add_content_rule(ContentRule::new("page", "**/*.raw", "base.tera")); + config.build.content_dir = "content".to_string(); + config.build.output_dir = "dist".to_string(); + config.build.static_dir = "static".to_string(); + + // 3. Set up the renderer and load templates + let mut renderer = TeraRenderer::new(); + renderer.load_templates_dir(Path::new("templates"))?; + + // 4. Build the pipeline + let pipeline = PipelineBuilder::new() + .config(config) + .content_dir("content") + .output_dir("dist") + .with_fs(Box::new(RealFs)) + .with_renderer(Box::new(renderer)) + .with_context_builder(Box::new(TeraContextBuilder)) + .add_processor(Box::new(RawProcessor)) + .build()?; + + // 5. Run the generation + pipeline.run()?; + println!("Site generated successfully!"); + Ok(()) +} +``` + +For more detailed examples, see the `librawssg_demo` crate in the repository. + +--- + +## Full API Reference + +This section provides a concise reference for every public item re‑exported by `librawssg`. For deeper details, consult the respective sub‑crate documentation (e.g., `librawssg_config`, `librawssg_compiler`). + +### Configuration Types + +All configuration types are in `librawssg::config` (and re‑exported at root). + +- **`Config`** + - `new() -> Self` + - `default() -> Self` + - `with_site_name(self, name: impl Into) -> Self` + - `add_content_rule(&mut self, rule: ContentRule)` + - `find_rule_by_name(&self, name: &str) -> Option<&ContentRule>` + - `remove_rule_by_name(&mut self, name: &str) -> Option` + - `has_duplicate_rule_names(&self) -> bool` + - `validate(&self) -> Result<()>` + - `from_yaml_str(yaml: &str) -> Result` + - `to_yaml_string(&self) -> Result` + - `from_json_str(json: &str) -> Result` + - `to_json_string(&self) -> Result` + +- **`SiteConfig`** + - `new(site_name: impl Into) -> Self` + - `default() -> Self` + - Fields: `navbar: Vec`, `sidebar: Vec`, `site_name: String`, `description: Option`, `language: Option`, `base_url: Option`, `author: Option`, `repo_url: Option`, `license: Option`, `extra: HashMap` + +- **`BuildConfig`** + - `new() -> Self` + - `default() -> Self` + - Fields: `content_dir: String`, `output_dir: String`, `templates_dir: String`, `static_dir: String` + +- **`ContentRule`** + - `new(name: impl Into, pattern: impl Into, template: impl Into) -> Self` + - `default() -> Self` + - Fields: `name: String`, `pattern: String`, `template: String`, `list_template: Option`, `list_enabled: bool`, `extra: HashMap` + +- **`NavItem`** + - `new(label: impl Into, url: impl Into) -> Self` + - `default() -> Self` + - Fields: `label: String`, `url: String`, `children: Vec` + +### Filesystem Types + +- **`FileSystem` trait** (in `librawssg::fs`, re‑exported at root) + - Required methods (see above). + - Provided methods: `is_symlink`, `canonicalize_or_join`, `safe_join`, `copy`, `rename_or_copy`. + +- **`RealFs`** (in `librawssg::fs`, re‑exported at root) + - Implements `FileSystem` using `std::fs`. + - `RealFs` (unit struct), `RealFs::default()`. + +### Handler Types + +- **`Document`** (in `librawssg::handler`, re‑exported at root) + - `new(metadata, body, url, output_path, source_path, depth, content_type, is_list) -> Result` + - `relative_url(&self) -> &str` + - `add_taxonomy(&mut self, name, items)` + - `depth(&self) -> usize` + - `with_list_items(self, items: Vec) -> Self` + - Fields: as listed earlier. + +- **`Metadata`** + - `new(title, description) -> Result` + - `is_draft(&self) -> bool` + - `insert_extra(&mut self, key, value)` + - `get_extra(&self, key: &str) -> Option<&serde_json::Value>` + - Fields: as listed earlier. + +- **`Processor` trait** + - Required: `name`, `can_process`, `process` + - Provided: `priority` + +### Template Types + +- **`RenderContext` trait** + - `as_any(&self) -> &dyn Any` + - `as_mut_any(&mut self) -> &mut dyn Any` + +- **`Renderer` trait** + - `render(&self, template_name: &str, context: &dyn RenderContext) -> Result` + +- **`TeraRenderer`** (feature `tera`, enabled by default) + - `new() -> Self` + - `add_raw_template(&mut self, name: &str, content: &str) -> Result<()>` + - `add_template_file(&mut self, path: &Path) -> Result<()>` + - `add_template_files_from_dir(&mut self, dir: &Path) -> Result<()>` + - `load_templates_dir(&mut self, dir: &Path) -> Result<()>` + - `enable_autoescape(&mut self)` + - `render_str(&self, template_str: &str, context: &dyn RenderContext) -> Result` + - `as_tera(&self) -> &tera::Tera` + - `as_tera_mut(&mut self) -> &mut tera::Tera` + - Implements `Renderer` and `Default`. + +### Compiler Types + +- **`PipelineBuilder`** + - `new() -> Self` + - `config(self, config: Config) -> Self` + - `load_config + Send + Sync>(self, path: P) -> Result` + - `content_dir(self, dir: impl Into) -> Self` + - `output_dir(self, dir: impl Into) -> Self` + - `with_fs(self, fs: Box) -> Self` + - `with_renderer(self, renderer: Box) -> Self` + - `add_processor(self, processor: Box) -> Self` + - `with_context_builder(self, builder: Box) -> Self` + - `add_generator(self, generator: Box) -> Self` + - `build(self) -> Result` + +- **`Pipeline`** + - `run(&self) -> Result<()>` + - `config(&self) -> &Config` + +- **`ContextBuilder` trait** + - `build_context(&self, config: &Config, doc: &Document) -> Result>` + +- **`TeraContextBuilder`** (unit struct) + - Implements `ContextBuilder`. + - `TeraContextBuilder` (no fields), `Default`. + +- **`Generator` trait** + - `generate(&self, pipeline: &Pipeline, output_base: &Path) -> Result<()>` + +- **`match_pattern` function** (accessible via `librawssg::compiler::pattern::match_pattern`) + - Signature: `pub fn match_pattern(pattern: &str, path: &Path) -> bool` + - Supports glob patterns with `*` and `**`. + +### Error Types + +- **`Error` enum** (in `librawssg::error`, re‑exported at root) + - Variants: as listed above. + - Implements `Display`, `Debug`, `std::error::Error`. + - `From` is implemented. + +- **`Result`** type alias + - `pub type Result = core::result::Result` + +--- + +## Feature Flags + +The `tera` feature is enabled by default and provides the `TeraRenderer` implementation. To disable it (e.g., if you use a different template engine), set `default-features = false` in your `Cargo.toml`: + +```toml +[dependencies] +librawssg = { version = "1.0.0", default-features = false } +``` + +Without this feature, the crate still exports the core traits (`Renderer`, `RenderContext`) and all other functionality, but `TeraRenderer` and `TeraContextBuilder` are unavailable. + +--- + +## License + +This project is licensed under the **MIT License**. See the [LICENSE](LICENSE) file for details. + +--- + +_This documentation is generated from the source code of the `librawssg` facade crate and its sub‑crates._ diff --git a/librawssg_compiler/README.md b/librawssg_compiler/README.md index 656345f..6d3ace7 100644 --- a/librawssg_compiler/README.md +++ b/librawssg_compiler/README.md @@ -1,4 +1,759 @@ # librawssg_compiler -Orchestrates the build pipeline for librawssg. -Combines filesystem, processors, renderers, and generators to produce a static site. +**Version**: 1.0.0 (implied) +**Crate name**: `librawssg_compiler` +**Description**: Provides the core compilation pipeline for the `librawssg` static site generator. This crate orchestrates the entire build process: reading content files, processing them via pluggable processors, rendering templates, copying static assets, running custom generators, and outputting the final site atomically. + +--- + +## Table of Contents + +1. [Overview](#overview) +2. [Modules and Re‑exports](#modules-and-re-exports) +3. [`PipelineBuilder`](#struct-pipelinebuilder) + - [Constructor `new()`](#pipelinebuilder-new) + - [Builder Methods](#pipelinebuilder-builder-methods) + - [`config()`](#pipelinebuilder-config) + - [`load_config()`](#pipelinebuilder-load_config) + - [`content_dir()`](#pipelinebuilder-content_dir) + - [`output_dir()`](#pipelinebuilder-output_dir) + - [`with_fs()`](#pipelinebuilder-with_fs) + - [`with_renderer()`](#pipelinebuilder-with_renderer) + - [`add_processor()`](#pipelinebuilder-add_processor) + - [`with_context_builder()`](#pipelinebuilder-with_context_builder) + - [`add_generator()`](#pipelinebuilder-add_generator) + - [`build()` Method](#pipelinebuilder-build) + - [`Default` Implementation](#pipelinebuilder-default) + - [Example Usage](#pipelinebuilder-example) +4. [`ContextBuilder` Trait](#trait-contextbuilder) + - [Required Method `build_context()`](#contextbuilder-build_context) + - [`TeraContextBuilder`](#struct-teracontextbuilder) + - [Implementation Details](#teracontextbuilder-implementation) +5. [`Generator` Trait](#trait-generator) + - [Required Method `generate()`](#generator-generate) +6. [Pattern Matching Function](#function-match_pattern) + - [Signature](#match_pattern-signature) + - [Supported Glob Syntax](#match_pattern-syntax) + - [Algorithm Overview](#match_pattern-algorithm) + - [Examples](#match_pattern-examples) +7. [`Pipeline` Struct](#struct-pipeline) + - [Accessor `config()`](#pipeline-config) + - [Method `run()`](#pipeline-run) + - [Internal Workflow](#pipeline-internal-workflow) + - [Document Processing](#pipeline-document-processing) + - [Content Type Determination](#pipeline-content-type) + - [Rendering](#pipeline-rendering) + - [List Generation](#pipeline-list-generation) + - [Static Assets](#pipeline-static-assets) + - [Generators](#pipeline-generators) + - [Atomic Output Replacement](#pipeline-atomic-output) +8. [Error Handling](#error-handling) +9. [Complete Example from Tests](#complete-example-from-tests) + - [Setting Up a Pipeline](#example-setup) + - [Running the Pipeline](#example-run) + - [Verifying Output](#example-verify) +10. [Testing Suite Overview](#testing-suite-overview) +11. [Conclusion](#conclusion) + +--- + +## Overview + +`librawssg_compiler` is the orchestration layer that ties together all other components of the static site generator: + +- **`Config`** from `librawssg_config` defines content rules and paths. +- **`Processor`** from `librawssg_handler` transforms source files into `Document` objects. +- **`Renderer`** and **`RenderContext`** from `librawssg_templates` handle template rendering. +- **`FileSystem`** from `librawssg_fs` abstracts all I/O operations. +- **`Generator`** (defined here) allows custom post‑processing steps. +- **`ContextBuilder`** (defined here) constructs the render context from a `Document` and `Config`. + +The main entry point is `PipelineBuilder`, which constructs a `Pipeline` after validating the configuration and ensuring mandatory components are present. Running the pipeline performs the full site generation in an atomic fashion, producing output in the configured output directory. + +--- + +## Modules and Re‑exports + +The crate root (`lib.rs`) declares the following public modules: + +- `builder` – Contains `PipelineBuilder`. +- `context` – Contains `ContextBuilder` trait and `TeraContextBuilder`. +- `generator` – Contains `Generator` trait. +- `pattern` – Contains `match_pattern` function. +- `pipeline` – Contains `Pipeline` struct. + +Re‑exported types at the crate root: + +```rust +pub use builder::PipelineBuilder; +pub use context::ContextBuilder; +pub use context::TeraContextBuilder; +pub use generator::Generator; +pub use pipeline::Pipeline; +``` + +The `match_pattern` function is also re‑exported? Actually it is in `pattern` module and not re‑exported at root, so users must use `librawssg_compiler::pattern::match_pattern`. However, in `pipeline.rs` it is imported via `crate::pattern::match_pattern`, but for external users they need to access it via module path. + +--- + +## Struct `PipelineBuilder` + +`PipelineBuilder` is a builder‑style struct that collects all components needed to run the compilation pipeline and then builds a `Pipeline`. + +```rust +pub struct PipelineBuilder { + config: Config, + content_dir: PathBuf, + output_dir: PathBuf, + fs: Box, + renderer: Option>, + processors: Vec>, + context_builder: Option>, + generators: Vec>, +} +``` + +**Note**: Fields are private; use the builder methods to configure. + +### `PipelineBuilder::new` + +```rust +#[must_use] +pub fn new() -> Self +``` + +**Purpose**: Creates a new builder with default values: + +- `config`: `Config::default()` +- `content_dir`: `"content"` +- `output_dir`: `"dist"` +- `fs`: `Box::new(RealFs)` +- `renderer`: `None` +- `processors`: empty vector +- `context_builder`: `None` +- `generators`: empty vector + +**Returns**: A fresh `PipelineBuilder`. + +**Example**: + +```rust +let builder = PipelineBuilder::new(); +``` + +--- + +### PipelineBuilder Builder Methods + +All builder methods consume `self` and return `Self`, allowing method chaining. + +#### `config` + +```rust +#[must_use] +pub fn config(mut self, config: Config) -> Self +``` + +**Purpose**: Sets the configuration object. + +**Parameters**: + +- `config`: A `Config` instance from `librawssg_config`. + +**Returns**: The builder with the config set. + +#### `load_config` + +```rust +pub fn load_config + Send + Sync>(mut self, path: P) -> Result +``` + +**Purpose**: Reads a YAML config file from disk and parses it into a `Config`. Errors are converted to `Error::Config`. + +**Parameters**: + +- `path`: Path to the YAML file. + +**Returns**: `Ok(Self)` if parsing succeeds, otherwise `Err(Error::Config)`. + +**Note**: The file is read using standard `std::fs::read_to_string`; the error is wrapped in `Error::Config`. + +#### `content_dir` + +```rust +#[must_use] +pub fn content_dir(mut self, dir: impl Into) -> Self +``` + +**Purpose**: Overrides the content directory. + +**Parameters**: + +- `dir`: Any type convertible to `PathBuf`. + +**Default**: `"content"` (but may be overridden by config if not explicitly set; see `build()`). + +#### `output_dir` + +```rust +#[must_use] +pub fn output_dir(mut self, dir: impl Into) -> Self +``` + +**Purpose**: Overrides the output directory. + +**Default**: `"dist"`. + +#### `with_fs` + +```rust +#[must_use] +pub fn with_fs(mut self, fs: Box) -> Self +``` + +**Purpose**: Sets a custom filesystem implementation. Useful for testing or non‑standard backends. + +**Default**: `RealFs`. + +#### `with_renderer` + +```rust +#[must_use] +pub fn with_renderer(mut self, renderer: Box) -> Self +``` + +**Purpose**: Sets the template renderer. **Mandatory**; `build()` will fail if not set. + +#### `add_processor` + +```rust +#[must_use] +pub fn add_processor(mut self, processor: Box) -> Self +``` + +**Purpose**: Adds a content processor to the pipeline. Multiple processors can be added; they are tried in order for each source file. + +#### `with_context_builder` + +```rust +#[must_use] +pub fn with_context_builder(mut self, builder: Box) -> Self +``` + +**Purpose**: Sets the context builder. **Mandatory**; `build()` will fail if not set. + +#### `add_generator` + +```rust +#[must_use] +pub fn add_generator(mut self, generator: Box) -> Self +``` + +**Purpose**: Adds a post‑processing generator. Generators run after all documents are rendered and static assets copied. + +--- + +### PipelineBuilder::build + +```rust +pub fn build(mut self) -> Result +``` + +**Purpose**: Validates the configuration, ensures required components are present, and constructs a `Pipeline`. + +**Behavior**: + +1. Calls `self.config.validate()?` (see `librawssg_config::Config::validate`). +2. If `content_dir` is still the default `"content"` (i.e., not changed by `content_dir()`), it is replaced with `self.config.build.content_dir`. +3. Similarly, if `output_dir` is still `"dist"`, it is replaced with `self.config.build.output_dir`. +4. Takes the renderer from `self.renderer` (using `take()`). If `None`, returns `Error::Config("template renderer not set")`. +5. Takes the context builder from `self.context_builder`. If `None`, returns `Error::Config("context builder not set")`. +6. Moves all remaining fields into a new `Pipeline` and returns `Ok`. + +**Returns**: + +- `Ok(Pipeline)` on success. +- `Err(Error::Validation)` if config invalid. +- `Err(Error::Config)` if renderer or context builder missing. + +--- + +### PipelineBuilder Default + +```rust +impl Default for PipelineBuilder { + fn default() -> Self { + Self::new() + } +} +``` + +Allows creating with `PipelineBuilder::default()`. + +--- + +### PipelineBuilder Example + +```rust +use librawssg_compiler::{PipelineBuilder, TeraContextBuilder}; +use librawssg_config::Config; +use librawssg_fs::RealFs; +use librawssg_handler::Processor; +use librawssg_templates::{Renderer, TeraRenderer}; + +// Assuming custom processor and renderer exist +let config = Config::new().with_site_name("My Site"); +let processor = Box::new(MyProcessor); +let renderer = Box::new(TeraRenderer::new()); // TeraRenderer must be configured with templates beforehand +let context_builder = Box::new(TeraContextBuilder); + +let pipeline = PipelineBuilder::new() + .config(config) + .content_dir("src") + .output_dir("public") + .with_fs(Box::new(RealFs)) + .with_renderer(renderer) + .with_context_builder(context_builder) + .add_processor(processor) + .build()?; +``` + +--- + +## Trait `ContextBuilder` + +```rust +pub trait ContextBuilder: Send + Sync { + fn build_context(&self, config: &Config, doc: &Document) -> Result>; +} +``` + +**Purpose**: Abstract factory that creates a `RenderContext` from the global `Config` and a specific `Document`. The renderer then uses this context to render the document's template. + +**Requirements**: Implementors must be `Send + Sync`. + +### `build_context` + +**Parameters**: + +- `config`: Reference to the site configuration. +- `doc`: Reference to the document being rendered. + +**Returns**: + +- `Ok(Box)` – a boxed trait object holding the render context. +- `Err(librawssg_error::Error)` if context creation fails. + +--- + +## Struct `TeraContextBuilder` + +```rust +#[derive(Debug, Default, Clone, Copy)] +pub struct TeraContextBuilder; +``` + +A concrete implementation of `ContextBuilder` that produces a `tera::Context` populated with common page and site data. + +### Implementation Details + +`TeraContextBuilder` creates a new `tera::Context` and inserts the following keys: + +| Key | Value Source | Description | +| ------------------ | --------------------------- | --------------------------------------------- | +| `site` | `&config.site` | The full `SiteConfig` object. | +| `page_title` | `&doc.metadata.title` | Document title. | +| `page_description` | `&doc.metadata.description` | Document description. | +| `page_author` | `&doc.metadata.author` | Optional author. | +| `page_date` | `&doc.metadata.date` | Optional publication date. | +| `page_tags` | `&doc.metadata.tags` | Vector of tags. | +| `page_content` | `&doc.body` | The rendered body content of the document. | +| `page_url` | `&doc.url` | Relative URL of the document. | +| `page_depth` | `&doc.depth` | Depth in the site hierarchy. | +| `page_type` | `&doc.content_type` | Content type identifier (e.g., `"blog"`). | +| `page_is_list` | `&doc.is_list` | Boolean indicating list page. | +| `page_list_items` | `&doc.list_items` | Optional vector of child documents for lists. | + +**Note**: The `page_list_items` field is inserted as `&doc.list_items` which is `Option>`. In Tera templates, this will be `None` or an array. + +**Example**: + +```rust +use librawssg_compiler::TeraContextBuilder; +let builder = TeraContextBuilder; +let ctx = builder.build_context(&config, &doc)?; +// Pass `ctx` to renderer.render(...) +``` + +--- + +## Trait `Generator` + +```rust +pub trait Generator: Send + Sync { + fn generate(&self, pipeline: &Pipeline, output_base: &Path) -> Result<()>; +} +``` + +**Purpose**: Allows custom post‑processing steps after the main site generation. Generators can write additional files to the output directory (e.g., RSS feed, sitemap, search index). + +**Requirements**: Implementors must be `Send + Sync`. + +### `generate` + +**Parameters**: + +- `pipeline`: Reference to the running `Pipeline`, which provides access to its configuration, filesystem, etc. (though the fields are crate‑private, the `config()` method is available). +- `output_base`: Path to the temporary output directory where generated files should be written. The pipeline's `run()` method later moves this directory atomically to the final output location. + +**Returns**: + +- `Ok(())` on success. +- `Err(librawssg_error::Error)` on failure. + +**Example** (from tests): + +```rust +struct DummyGenerator; + +impl Generator for DummyGenerator { + fn generate(&self, _pipeline: &Pipeline, output_base: &Path) -> Result<()> { + let path = output_base.join("generated.txt"); + std::fs::write(&path, b"generated")?; + Ok(()) + } +} +``` + +--- + +## Function `match_pattern` + +```rust +#[must_use] +pub fn match_pattern(pattern: &str, path: &Path) -> bool +``` + +**Location**: `librawssg_compiler::pattern` + +**Purpose**: Checks whether a file path matches a glob pattern with support for `*` (within a segment) and `**` (across segments). Used by the pipeline to assign content types based on `ContentRule` patterns. + +**Parameters**: + +- `pattern`: A glob‑like pattern string, e.g., `"blog/**/*.html"`. +- `path`: A `Path` to test (usually a relative path from the content directory). + +**Returns**: `true` if the path matches the pattern; `false` otherwise. + +### Supported Glob Syntax + +- `*` – Matches any sequence of characters within a single path segment (i.e., does not cross `/`). +- `**` – Matches any number of path segments, including zero. + +**Limitations**: + +- Only `*` and `**` are supported; no character classes (`[abc]`) or alternation. +- Patterns are split on `/`; backslashes are not treated as separators (path normalization may be needed on Windows). +- The implementation uses a custom recursive algorithm; for complex patterns, behavior may differ from standard glob libraries. + +### Algorithm Overview + +The function first converts the path to a string (lossy) and splits both pattern and path by `/`. It then calls an internal recursive `match_pattern_slice`. The logic: + +- If both pattern and path segments are exhausted → `true`. +- If pattern still has segments but path is empty → only `**` segments are allowed. +- If first pattern segment is `"**"`: + - If it's the only segment → `true`. + - Otherwise, try matching the rest of the pattern against every suffix of the path. +- Otherwise, the first pattern segment must match the first path segment using `segment_matches` (handles `*` wildcards), and recursion continues on the rest. +- `segment_matches` handles `*` by trying to match the remainder of the pattern segment against suffixes of the path segment. + +### Examples + +```rust +use std::path::Path; +use librawssg_compiler::pattern::match_pattern; + +assert!(match_pattern("**/*.html", Path::new("blog/post.html"))); +assert!(match_pattern("blog/**/*.html", Path::new("blog/2024/post.html"))); +assert!(match_pattern("*.html", Path::new("index.html"))); +assert!(!match_pattern("*.html", Path::new("blog/post.html"))); +assert!(match_pattern("**", Path::new("anything/at/all"))); +assert!(match_pattern("**/*.md", Path::new("readme.md"))); // zero segments before .md +``` + +--- + +## Struct `Pipeline` + +The `Pipeline` is the core execution engine. It is created by `PipelineBuilder::build()` and holds all necessary components. + +```rust +pub struct Pipeline { + config: Config, + fs: Box, + renderer: Box, + processors: Vec>, + context_builder: Box, + generators: Vec>, + content_dir: PathBuf, + output_dir: PathBuf, +} +``` + +All fields are private; access to configuration is provided via the `config()` method. + +### Pipeline::config + +```rust +#[must_use] +pub const fn config(&self) -> &Config +``` + +**Purpose**: Returns a reference to the configuration used by this pipeline. + +**Returns**: `&Config`. + +--- + +### Pipeline::run + +```rust +pub fn run(&self) -> Result<()> +``` + +**Purpose**: Executes the full site generation process atomically. + +**Behavior**: + +1. Determines a temporary output directory: `output_dir.with_extension("tmp")`. For example, if `output_dir` is `"dist"`, the temp dir is `"dist.tmp"`. +2. If the temp dir already exists, it is removed. +3. Creates the temp dir. +4. Calls internal `generate_to(&tmp_dir)` to perform the actual generation into the temporary location. +5. If the final output directory exists, it is removed. +6. Attempts to rename the temp dir to the final output dir. + - On success, returns `Ok(())`. + - If rename fails with `CrossesDevices` error (different filesystems), fallback: copy the temp dir contents to the final output dir using `copy_dir_all` (internal method), then remove the temp dir. + - For any other error, returns `Error::Generation("atomic rename failed: ...")`. + +**Returns**: `Ok(())` on success, or `Err(Error)`. + +**Note**: This atomic approach ensures that the final output directory is never left in a partially generated state; either the old output remains untouched (if generation fails) or the new output replaces it atomically (or near‑atomically). + +--- + +### Pipeline Internal Workflow + +The internal method `generate_to(output_base: &Path)` orchestrates the entire generation. The following steps are performed (not public, but described for understanding): + +1. **Create output directory** – `fs.create_dir_all(output_base)`. +2. **Process documents** – Calls `process_documents()` to get a vector of `Document`. +3. **Group documents by content type** – Builds a `HashMap>`. +4. **Render non‑list documents** – For each `Document` where `is_list == false`, calls `render_document(output_base, doc)`. +5. **Generate list pages** – For each content type that has a matching `ContentRule` with `list_enabled == true`, `list_template` set, and at least one document, creates a synthetic list document (`is_list = true`) and renders it using the list template. The list document’s `list_items` are all documents of that content type. The output path is `"{content_type}/index.html"`. +6. **Copy static assets** – If the directory specified by `config.build.static_dir` exists, its entire contents are copied to `output_base/static_dir_name` (using `copy_dir_all`). +7. **Run generators** – Iterates over all `generators` and calls `generate()` for each, passing the output base. + +#### Document Processing + +`process_documents()` walks the content directory (using `fs.walk_dir`). For each file, it: + +- Computes the path relative to `content_dir`. +- Iterates through the processors in order; the first processor whose `can_process(rel, &file_path)` returns `true` is used. +- Calls that processor’s `process(&*fs, rel, &content_dir)`, which returns `Option`. +- If a document is returned, its `content_type` is overridden based on the matching content rule (via `determine_content_type`), and its `depth` is set to the number of path components minus 1. +- The document is added to the result list. + +If no processor matches, the file is ignored. If a processor returns `Ok(None)`, the file is skipped. If any processor returns an error, the whole processing fails. + +#### Content Type Determination + +`determine_content_type(rel)` iterates through the `config.content_rules` in **reverse order** (so later rules take precedence) and returns the `name` of the first rule whose `pattern` matches the relative path. If no rule matches, the content type defaults to `"page"`. + +#### Rendering + +`render_document` calls `template_for_document(doc)` to get the template name: + +- Iterates through content rules, finds the rule whose `name` equals `doc.content_type`. +- If `doc.is_list` and the rule has a `list_template`, that template is used. +- Otherwise, the rule’s `template` is used. +- If no rule is found, returns `Error::Generation("no content rule found for type '...'")`. + +The actual rendering uses: + +- `context_builder.build_context(&config, doc)` to get a `RenderContext`. +- `renderer.render(template, &*ctx)` to produce the HTML string. +- `write_output(output_base, doc, html.as_bytes())` to write the result. + +#### List Generation + +When generating list pages, the pipeline creates a `Metadata` with `title = content_type` and empty description. Then constructs a `Document` with: + +- `body`: empty string (the list template is expected to use `page_list_items`). +- `url`: `"{content_type}/index.html"`. +- `output_path`: `"{content_type}/index.html"`. +- `source_path`: `PathBuf::from("__list__")` (placeholder). +- `depth`: `1`. +- `content_type`: same as the content type. +- `is_list`: `true`. + +Then sets `list_items` to the cloned vector of documents of that type, and renders using the list template. + +#### Static Assets + +The static directory from `config.build.static_dir` is copied verbatim. The destination is `output_base` joined with the static directory’s base name (e.g., if `static_dir = "static"`, files are copied to `output_base/static/`). Directories are recursively copied. + +#### Generators + +After all documents and static files are in place, each registered `Generator` is invoked with `&self` and `output_base`. This allows adding custom files like RSS feeds, sitemaps, etc. + +--- + +## Error Handling + +`librawssg_compiler` uses `librawssg_error::Error` for all fallible operations. The common error variants encountered: + +- `Error::Config` – For configuration issues (e.g., missing renderer, invalid config file). +- `Error::Validation` – From `Config::validate`. +- `Error::Generation` – For errors during pipeline execution (e.g., write failures, unsafe output path). +- `Error::Io` – From filesystem operations (though these may be wrapped in `Error::Generation` in some places). +- `Error::Render` – From template rendering. + +Methods return `Result` (alias for `std::result::Result`). + +--- + +## Complete Example from Tests + +The test file `full_compiler_test.rs` demonstrates a full working pipeline with mock components. Below is a simplified but complete example. + +### Example Setup + +Define mock renderer, context, context builder, and processor: + +```rust +use librawssg_compiler::{PipelineBuilder, TeraContextBuilder}; +use librawssg_config::{Config, ContentRule}; +use librawssg_fs::{FileSystem, RealFs}; +use librawssg_handler::{Document, Metadata, Processor}; +use librawssg_templates::{RenderContext, Renderer}; +use std::path::{Path, PathBuf}; + +// Mock renderer: returns "rendered:{template_name}" +struct MockRenderer; +impl Renderer for MockRenderer { + fn render(&self, template_name: &str, _ctx: &dyn RenderContext) -> Result { + Ok(format!("rendered:{template_name}")) + } +} + +// Mock context +struct MockContext; +impl RenderContext for MockContext { + fn as_any(&self) -> &dyn Any { self } + fn as_mut_any(&mut self) -> &mut dyn Any { self } +} + +// Mock context builder +struct MockContextBuilder; +impl ContextBuilder for MockContextBuilder { + fn build_context(&self, _config: &Config, _doc: &Document) -> Result> { + Ok(Box::new(MockContext)) + } +} + +// Processor that handles .html files +struct RawHtmlProcessor; +impl Processor for RawHtmlProcessor { + fn name(&self) -> &'static str { "raw-html" } + fn can_process(&self, relative_path: &Path, _original_path: &Path) -> bool { + relative_path.extension().is_some_and(|ext| ext == "html") + } + fn process(&self, fs: &dyn FileSystem, relative_path: &Path, content_dir: &Path) -> Result> { + let full_path = content_dir.join(relative_path); + let content = fs.read_to_string(&full_path)?; + let url = relative_path.with_extension("html").to_string_lossy().to_string(); + let output_path = PathBuf::from(&url); + let metadata = Metadata::new("Test", "Description")?; + let doc = Document::new( + metadata, + content, + url, + output_path, + relative_path.to_path_buf(), + 0, + "page".to_string(), + false, + )?; + Ok(Some(doc)) + } +} + +// Config with one rule +fn setup_config() -> Config { + let mut config = Config::new().with_site_name("Compiler Test"); + config.add_content_rule(ContentRule::new("page", "**/*.html", "base")); + config +} + +// Build pipeline +fn build_pipeline(content_dir: &Path, output_dir: &Path) -> Result { + PipelineBuilder::new() + .config(setup_config()) + .content_dir(content_dir) + .output_dir(output_dir) + .with_fs(Box::new(RealFs)) + .with_renderer(Box::new(MockRenderer)) + .with_context_builder(Box::new(MockContextBuilder)) + .add_processor(Box::new(RawHtmlProcessor)) + .build() +} +``` + +### Running the Pipeline + +```rust +let tmp = tempfile::TempDir::new()?; +let content_dir = tmp.path().join("content"); +let output_dir = tmp.path().join("dist"); +std::fs::create_dir_all(&content_dir)?; +std::fs::write(content_dir.join("index.html"), "

Home

")?; + +let pipeline = build_pipeline(&content_dir, &output_dir)?; +pipeline.run()?; +``` + +### Verifying Output + +```rust +let output_file = output_dir.join("index.html"); +assert!(output_file.exists()); +let content = std::fs::read_to_string(output_file)?; +assert_eq!(content, "rendered:base"); +``` + +--- + +## Testing Suite Overview + +The test file `tests/full_compiler_test.rs` contains comprehensive integration tests covering: + +- Generation of a single page. +- Content type rules (different templates for different content types). +- List page generation. +- Static asset copying. +- Custom generators. +- Atomic replacement of existing output directory. +- Handling empty content directory. +- Skipping files that no processor handles. +- Error cases: missing renderer, missing context builder, invalid config. + +Each test uses temporary directories (`tempfile`) and mock implementations to isolate components. The tests serve as executable examples of how to configure and run the pipeline. + +--- + +## Conclusion + +`librawssg_compiler` is the central execution engine of the static site generator. It provides a flexible builder to assemble the necessary components, a robust `Pipeline` that orchestrates all steps, and extension points via `Processor`, `Renderer`, `ContextBuilder`, and `Generator`. The pattern matching function and atomic output generation ensure correctness and safety. The comprehensive test suite demonstrates practical usage and edge cases. + +For further details, refer to the source code and the test file. diff --git a/librawssg_config/README.md b/librawssg_config/README.md index 466003a..c48d4ea 100644 --- a/librawssg_config/README.md +++ b/librawssg_config/README.md @@ -1,4 +1,762 @@ # librawssg_config -Configuration types and validation for librawssg. -Provides `Config`, site metadata, build settings, and content rule definitions. +**Version**: 1.0.0 (implied) +**Crate name**: `librawssg_config` +**Description**: Defines configuration data structures for the `librawssg` static site generator. Provides `Config`, `SiteConfig`, `BuildConfig`, `ContentRule`, and `NavItem` types, along with serialization/deserialization support (YAML and JSON) and validation logic. + +--- + +## Table of Contents + +1. [Overview](#overview) +2. [Modules](#modules) +3. [`BuildConfig`](#struct-buildconfig) + - [Fields](#buildconfig-fields) + - [Constructor `new()`](#buildconfig-new) + - [`Default` Implementation](#buildconfig-default) + - [Serialization & Deserialization](#buildconfig-serialization) +4. [`ContentRule`](#struct-contentrule) + - [Fields](#contentrule-fields) + - [Constructor `new()`](#contentrule-new) + - [`Default` Implementation](#contentrule-default) + - [Serialization & Deserialization](#contentrule-serialization) +5. [`NavItem`](#struct-navitem) + - [Fields](#navitem-fields) + - [Constructor `new()`](#navitem-new) + - [`Default` Implementation](#navitem-default) + - [Serialization & Deserialization](#navitem-serialization) +6. [`SiteConfig`](#struct-siteconfig) + - [Fields](#siteconfig-fields) + - [Constructor `new()`](#siteconfig-new) + - [`Default` Implementation](#siteconfig-default) + - [Serialization & Deserialization](#siteconfig-serialization) +7. [`Config`](#struct-config) + - [Fields](#config-fields) + - [Constructor `new()`](#config-new) + - [Builder Method `with_site_name()`](#config-with_site_name) + - [Rule Management Methods](#config-rule-management) + - [`add_content_rule()`](#config-add_content_rule) + - [`find_rule_by_name()`](#config-find_rule_by_name) + - [`remove_rule_by_name()`](#config-remove_rule_by_name) + - [`has_duplicate_rule_names()`](#config-has_duplicate_rule_names) + - [Validation Method `validate()`](#config-validate) + - [Serialization Methods](#config-serialization) + - [`from_yaml_str()`](#config-from_yaml_str) + - [`to_yaml_string()`](#config-to_yaml_string) + - [`from_json_str()`](#config-from_json_str) + - [`to_json_string()`](#config-to_json_string) +8. [Error Handling](#error-handling) +9. [Serialization Details](#serialization-details) +10. [Examples from Tests](#examples-from-tests) +11. [Testing Suite Overview](#testing-suite-overview) +12. [Conclusion](#conclusion) + +--- + +## Overview + +`librawssg_config` provides the central configuration types used by the static site generator. The main `Config` struct combines site settings (`SiteConfig`), build paths (`BuildConfig`), content processing rules (`ContentRule`), and arbitrary extra data. All types are serializable/deserializable via `serde`, enabling configuration to be read from and written to YAML or JSON files. + +The types are designed with sensible defaults and include a `validate()` method to ensure the configuration is internally consistent and safe (e.g., preventing path traversal in patterns). + +--- + +## Modules + +The crate root (`lib.rs`) declares the following public modules: + +- `build` – Contains `BuildConfig`. +- `config` – Contains `Config`. +- `content_rule` – Contains `ContentRule`. +- `nav` – Contains `NavItem`. +- `site` – Contains `SiteConfig`. + +All public types are re‑exported at the crate root for convenience: + +```rust +pub use build::BuildConfig; +pub use config::Config; +pub use content_rule::ContentRule; +pub use nav::NavItem; +pub use site::SiteConfig; +``` + +--- + +## Struct `BuildConfig` + +Represents filesystem path configuration for the build process. + +```rust +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[non_exhaustive] +pub struct BuildConfig { + #[serde(default = "default_content_dir")] + pub content_dir: String, + #[serde(default = "default_output_dir")] + pub output_dir: String, + #[serde(default = "default_templates_dir")] + pub templates_dir: String, + #[serde(default = "default_static_dir")] + pub static_dir: String, +} +``` + +### BuildConfig Fields + +| Field | Type | Default Value | Description | +| --------------- | -------- | ------------- | ------------------------------------------------------ | +| `content_dir` | `String` | `"content"` | Directory containing source content files. | +| `output_dir` | `String` | `"dist"` | Directory where generated site output will be written. | +| `templates_dir` | `String` | `"templates"` | Directory containing template files. | +| `static_dir` | `String` | `"static"` | Directory containing static assets (copied as-is). | + +**Note**: `#[non_exhaustive]` prevents external crates from exhaustively matching or constructing with a struct literal. Use the provided constructors or update syntax. + +### BuildConfig::new + +```rust +#[must_use] +pub fn new() -> Self +``` + +**Purpose**: Creates a `BuildConfig` with default values (identical to `BuildConfig::default()`). + +**Returns**: A new `BuildConfig` with all fields set to their defaults. + +**Example**: + +```rust +let build = BuildConfig::new(); +assert_eq!(build.content_dir, "content"); +``` + +### BuildConfig Default + +The `Default` trait is implemented with the following values: + +- `content_dir`: `"content"` +- `output_dir`: `"dist"` +- `templates_dir`: `"templates"` +- `static_dir`: `"static"` + +These defaults can be overridden during deserialization; missing fields in serialized data will fall back to these defaults (thanks to `#[serde(default = "...")]`). + +### BuildConfig Serialization + +`BuildConfig` derives `Serialize` and `Deserialize`. When deserializing from YAML/JSON, any omitted fields will use the specified default functions. This allows partial configuration. + +**Example** (from tests): + +```rust +let yaml = "content_dir: custom_content\noutput_dir: public\n"; +let build: BuildConfig = serde_yaml::from_str(yaml)?; +assert_eq!(build.content_dir, "custom_content"); +assert_eq!(build.templates_dir, "templates"); // default +``` + +--- + +## Struct `ContentRule` + +Defines how a certain group of content files should be processed. + +```rust +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[non_exhaustive] +pub struct ContentRule { + pub name: String, + pub pattern: String, + pub template: String, + #[serde(default)] + pub list_template: Option, + #[serde(default)] + pub list_enabled: bool, + #[serde(default)] + pub extra: HashMap, +} +``` + +### ContentRule Fields + +| Field | Type | Default | Description | +| --------------- | ------------------------------------ | --------- | ------------------------------------------------------------------------------- | +| `name` | `String` | `""` | Unique identifier for the rule (e.g., `"blog"`, `"page"`). | +| `pattern` | `String` | `""` | Glob pattern matching content files (e.g., `"**/*.md"`). Must not contain `..`. | +| `template` | `String` | `""` | Name of the template to use for rendering each matched file. | +| `list_template` | `Option` | `None` | Optional template name for rendering list pages (e.g., index pages). | +| `list_enabled` | `bool` | `false` | Whether list generation is enabled for this rule. | +| `extra` | `HashMap` | empty map | Arbitrary extra data associated with the rule. | + +### ContentRule::new + +```rust +#[must_use] +pub fn new( + name: impl Into, + pattern: impl Into, + template: impl Into, +) -> Self +``` + +**Purpose**: Creates a `ContentRule` with the required fields (`name`, `pattern`, `template`). All other fields are set to their defaults. + +**Parameters**: + +- `name`: The rule name (converted to `String`). +- `pattern`: The glob pattern (converted to `String`). +- `template`: The template name (converted to `String`). + +**Returns**: A new `ContentRule` instance. + +**Example**: + +```rust +let rule = ContentRule::new("blog", "**/*.md", "post"); +assert_eq!(rule.name, "blog"); +assert!(!rule.list_enabled); +``` + +### ContentRule Default + +The `Default` implementation (derived) sets: + +- `name`, `pattern`, `template`: empty strings +- `list_template`: `None` +- `list_enabled`: `false` +- `extra`: empty map + +### ContentRule Serialization + +Serializes/deserializes with `serde`. Missing optional fields default as specified. The `extra` map can hold any JSON‑compatible values. + +**Example**: + +```rust +let mut rule = ContentRule::new("page", "**/*.html", "base"); +rule.list_enabled = true; +rule.list_template = Some("list".into()); +rule.extra.insert("key".into(), json!("value")); +let yaml = serde_yaml::to_string(&rule)?; +let parsed: ContentRule = serde_yaml::from_str(&yaml)?; +assert_eq!(rule, parsed); +``` + +--- + +## Struct `NavItem` + +Represents an item in a navigation menu (navbar or sidebar). + +```rust +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[non_exhaustive] +pub struct NavItem { + pub label: String, + pub url: String, + pub children: Vec, +} +``` + +### NavItem Fields + +| Field | Type | Default | Description | +| ---------- | -------------- | ------- | ---------------------------------------------- | +| `label` | `String` | `""` | Display text for the navigation link. | +| `url` | `String` | `""` | URL the link points to (relative or absolute). | +| `children` | `Vec` | empty | Nested sub‑items, enabling hierarchical menus. | + +### NavItem::new + +```rust +#[must_use] +pub fn new(label: impl Into, url: impl Into) -> Self +``` + +**Purpose**: Creates a `NavItem` with a label and URL. The `children` vector starts empty. + +**Parameters**: + +- `label`: Display label. +- `url`: Target URL. + +**Returns**: A new `NavItem`. + +**Example**: + +```rust +let item = NavItem::new("Home", "/"); +assert_eq!(item.label, "Home"); +assert!(item.children.is_empty()); +``` + +### NavItem Default + +`NavItem::default()` creates an item with empty label, empty URL, and no children. + +### NavItem Serialization + +Supports serialization and deserialization via `serde`. Nested children are handled recursively. + +**Example**: + +```rust +let parent = NavItem::new("Docs", "/docs"); +parent.children.push(NavItem::new("API", "/docs/api")); +let json = serde_json::to_string(&parent)?; +let parsed: NavItem = serde_json::from_str(&json)?; +assert_eq!(parent, parsed); +``` + +--- + +## Struct `SiteConfig` + +Holds global site metadata and navigation structures. + +```rust +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[non_exhaustive] +pub struct SiteConfig { + #[serde(default)] + pub navbar: Vec, + #[serde(default)] + pub sidebar: Vec, + #[serde(default = "default_site_name")] + pub site_name: String, + #[serde(default)] + pub description: Option, + #[serde(default = "default_language")] + pub language: Option, + #[serde(default)] + pub base_url: Option, + #[serde(default)] + pub author: Option, + #[serde(default)] + pub repo_url: Option, + #[serde(default)] + pub license: Option, + #[serde(default)] + pub extra: HashMap, +} +``` + +### SiteConfig Fields + +| Field | Type | Default | Description | +| ------------- | ------------------------------------ | ------------- | ---------------------------------------------------------------------- | +| `navbar` | `Vec` | empty | Navigation items for the top bar. | +| `sidebar` | `Vec` | empty | Navigation items for the sidebar. | +| `site_name` | `String` | `"librawssg"` | The name of the website. | +| `description` | `Option` | `None` | Short site description. | +| `language` | `Option` | `Some("en")` | Site language code (e.g., `"en"`, `"id"`). | +| `base_url` | `Option` | `None` | Base URL for the site; must start with `http://` or `https://` if set. | +| `author` | `Option` | `None` | Default author name. | +| `repo_url` | `Option` | `None` | URL to the source repository. | +| `license` | `Option` | `None` | License identifier (e.g., `"MIT"`). | +| `extra` | `HashMap` | empty map | Arbitrary extra site‑wide metadata. | + +### SiteConfig::new + +```rust +#[must_use] +pub fn new(site_name: impl Into) -> Self +``` + +**Purpose**: Creates a `SiteConfig` with a custom site name. All other fields are set to their defaults (navbar/sidebar empty, language `Some("en")`, etc.). + +**Parameters**: + +- `site_name`: The site name (converted to `String`). + +**Returns**: A new `SiteConfig`. + +**Example**: + +```rust +let site = SiteConfig::new("My Site"); +assert_eq!(site.site_name, "My Site"); +assert_eq!(site.language.as_deref(), Some("en")); +``` + +### SiteConfig Default + +`SiteConfig::default()` sets: + +- `site_name`: `"librawssg"` +- `language`: `Some("en")` +- All `Option` fields: `None` +- Vectors and map: empty + +### SiteConfig Serialization + +Supports YAML/JSON. Missing fields during deserialization use defaults. The `language` default is provided by a custom function. + +**Example**: + +```rust +let mut site = SiteConfig::new("Test"); +site.extra.insert("foo".into(), json!("bar")); +let yaml = serde_yaml::to_string(&site)?; +let parsed: SiteConfig = serde_yaml::from_str(&yaml)?; +assert_eq!(site, parsed); +``` + +--- + +## Struct `Config` + +The top‑level configuration combining all other components. + +```rust +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[non_exhaustive] +pub struct Config { + pub site: SiteConfig, + pub build: BuildConfig, + pub content_rules: Vec, + #[serde(default)] + pub extra: HashMap, +} +``` + +### Config Fields + +| Field | Type | Default | Description | +| --------------- | ------------------------------------ | ----------------------------------------------- | --------------------------------- | +| `site` | `SiteConfig` | `SiteConfig::default()` (site name "librawssg") | Global site configuration. | +| `build` | `BuildConfig` | `BuildConfig::default()` | Build path settings. | +| `content_rules` | `Vec` | empty | List of content processing rules. | +| `extra` | `HashMap` | empty map | Arbitrary top‑level extra data. | + +### Config::new + +```rust +#[must_use] +pub fn new() -> Self +``` + +**Purpose**: Creates a `Config` with all fields defaulted. Equivalent to `Config::default()`. + +**Returns**: A new `Config`. + +**Example**: + +```rust +let config = Config::new(); +assert!(config.content_rules.is_empty()); +``` + +### Config::with_site_name + +```rust +#[must_use] +pub fn with_site_name(mut self, name: impl Into) -> Self +``` + +**Purpose**: Builder‑style method that sets the `site.site_name` and returns the modified `Config`. + +**Parameters**: + +- `name`: The new site name. + +**Returns**: The same `Config` with updated site name. + +**Example**: + +```rust +let config = Config::new().with_site_name("My Awesome Site"); +assert_eq!(config.site.site_name, "My Awesome Site"); +``` + +### Config Rule Management + +#### `add_content_rule` + +```rust +pub fn add_content_rule(&mut self, rule: ContentRule) +``` + +**Purpose**: Appends a `ContentRule` to the `content_rules` vector. + +**Parameters**: + +- `rule`: The rule to add. + +**Example**: + +```rust +config.add_content_rule(ContentRule::new("blog", "**/*.md", "post")); +``` + +#### `find_rule_by_name` + +```rust +#[must_use] +pub fn find_rule_by_name(&self, name: &str) -> Option<&ContentRule> +``` + +**Purpose**: Searches for a content rule by its `name` field. + +**Parameters**: + +- `name`: The rule name to search for. + +**Returns**: `Some(&ContentRule)` if found, otherwise `None`. + +**Example**: + +```rust +if let Some(rule) = config.find_rule_by_name("blog") { + // ... +} +``` + +#### `remove_rule_by_name` + +```rust +pub fn remove_rule_by_name(&mut self, name: &str) -> Option +``` + +**Purpose**: Removes and returns the first content rule whose `name` matches the given string. + +**Parameters**: + +- `name`: The name of the rule to remove. + +**Returns**: `Some(ContentRule)` if found and removed, otherwise `None`. + +**Example**: + +```rust +let removed = config.remove_rule_by_name("blog"); +``` + +#### `has_duplicate_rule_names` + +```rust +#[must_use] +pub fn has_duplicate_rule_names(&self) -> bool +``` + +**Purpose**: Checks whether any two content rules share the same `name`. + +**Returns**: `true` if duplicates exist, `false` otherwise. + +**Implementation**: Uses a `HashSet` to detect duplicates; O(n) time. + +**Example**: + +```rust +if config.has_duplicate_rule_names() { + // handle error +} +``` + +### Config::validate + +```rust +pub fn validate(&self) -> Result<()> +``` + +**Purpose**: Performs comprehensive validation of the configuration. Returns `Ok(())` if the configuration is valid, otherwise an `Err(Error::Validation(...))` with a descriptive message. + +**Validation Rules**: + +1. `site.site_name` must not be empty or whitespace‑only. +2. At least one content rule must be defined. +3. No duplicate content rule names. +4. For each content rule (indexed from 0): + - `name` must not be empty or whitespace‑only. + - `pattern` must not be empty or whitespace‑only. + - `pattern` must not contain the substring `".."` (to prevent path traversal). + - `template` must not be empty or whitespace‑only. +5. If `site.base_url` is `Some`, it must start with `"http://"` or `"https://"`. + +**Returns**: + +- `Ok(())` if all checks pass. +- `Err(Error::Validation(message))` on the first failure encountered. + +**Example** (from tests): + +```rust +let config = valid_config(); // has one rule +assert!(config.validate().is_ok()); +``` + +### Config Serialization + +The `Config` struct can be serialized to and deserialized from YAML and JSON via convenience methods. + +#### `from_yaml_str` + +```rust +pub fn from_yaml_str(yaml: &str) -> Result +``` + +**Purpose**: Parses a YAML string into a `Config`. + +**Parameters**: + +- `yaml`: YAML content as a string. + +**Returns**: + +- `Ok(Config)` on success. +- `Err(Error::Config)` if the YAML is invalid (with the underlying `serde_yaml` error message included). + +**Example**: + +```rust +let config = Config::from_yaml_str("site:\n site_name: Test\n")?; +``` + +#### `to_yaml_string` + +```rust +pub fn to_yaml_string(&self) -> Result +``` + +**Purpose**: Serializes the `Config` to a YAML string. + +**Returns**: + +- `Ok(String)` with YAML representation. +- `Err(Error::Serialization)` if serialization fails. + +#### `from_json_str` + +```rust +pub fn from_json_str(json: &str) -> Result +``` + +**Purpose**: Parses a JSON string into a `Config`. + +**Parameters**: + +- `json`: JSON content as a string. + +**Returns**: + +- `Ok(Config)` on success. +- `Err(Error::Config)` if the JSON is invalid. + +#### `to_json_string` + +```rust +pub fn to_json_string(&self) -> Result +``` + +**Purpose**: Serializes the `Config` to a JSON string. + +**Returns**: + +- `Ok(String)` with JSON representation. +- `Err(Error::Serialization)` on failure. + +**Example roundtrip**: + +```rust +let yaml = config.to_yaml_string()?; +let parsed = Config::from_yaml_str(&yaml)?; +assert_eq!(config, parsed); +``` + +--- + +## Error Handling + +The `Config` methods and `validate` use `librawssg_error::Result` (alias for `std::result::Result`). The relevant error variants used in this crate are: + +- `Error::Config` – For YAML/JSON deserialization failures. +- `Error::Serialization` – For serialization failures. +- `Error::Validation` – For `validate()` failures. + +All error messages are descriptive and include context (e.g., which rule is invalid, what condition was violated). + +--- + +## Serialization Details + +All configuration structs derive `Serialize` and `Deserialize` from `serde`. Default values are applied during deserialization for missing fields via `#[serde(default = "function")]` or `#[serde(default)]` (which uses `Default::default()` for the field type). + +- `BuildConfig`: Each field has a custom default function. +- `ContentRule`: Optional fields use `#[serde(default)]`. +- `NavItem`: No special defaults; all fields are required in input, but `Default` is derived for programmatic creation. +- `SiteConfig`: `site_name` has a custom default, `language` has a custom default returning `Some("en")`, others use `#[serde(default)]`. +- `Config`: `site` and `build` are required in YAML/JSON (they don't have `#[serde(default)]` at the field level, but the struct itself derives `Default` and the fields are not marked optional; however, when deserializing a top‑level `Config`, missing `site` or `build` will cause an error because they are not optional. In practice, configuration files should include these sections or rely on the `Default` implementation when constructing programmatically). **Important**: The `Config` struct's fields are not marked with `#[serde(default)]`, so during deserialization, missing `site` or `build` will cause a parse error. Users must provide at least `site` and `build` keys (can be empty maps to get defaults via the inner structs' own defaults). The `content_rules` and `extra` fields have `#[serde(default)]` so they can be omitted. + +--- + +## Examples from Tests + +The test suite provides extensive examples for each type. Below are selected snippets. + +### BuildConfig + +```rust +let build = BuildConfig::default(); +assert_eq!(build.output_dir, "dist"); + +let yaml = "content_dir: custom_content\noutput_dir: public\n"; +let build: BuildConfig = serde_yaml::from_str(yaml)?; +assert_eq!(build.content_dir, "custom_content"); +assert_eq!(build.templates_dir, "templates"); +``` + +### ContentRule + +```rust +let mut rule = ContentRule::new("page", "**/*.html", "base"); +rule.list_enabled = true; +rule.list_template = Some("list".into()); +rule.extra.insert("key".into(), json!("value")); +``` + +### NavItem + +```rust +let mut parent = NavItem::new("Docs", "/docs"); +parent.children.push(NavItem::new("API", "/docs/api")); +``` + +### SiteConfig + +```rust +let site = SiteConfig::new("My Site"); +assert_eq!(site.language.as_deref(), Some("en")); +``` + +### Config Validation + +```rust +let mut config = Config::new().with_site_name("My Site"); +config.add_content_rule(ContentRule::new("page", "**/*.html", "base")); +assert!(config.validate().is_ok()); + +config.site.base_url = Some("ftp://example.com".to_string()); +assert!(config.validate().is_err()); +``` + +--- + +## Testing Suite Overview + +The crate includes five test files: + +- `build_tests.rs` – Tests `BuildConfig` defaults, `new()`, and YAML roundtrip. +- `config_tests.rs` – Extensive tests for `Config`: creation, rule management, validation (all rules), YAML/JSON roundtrip, and error cases. +- `content_rule_tests.rs` – Tests `ContentRule` constructor, defaults, and serialization. +- `nav_tests.rs` – Tests `NavItem` constructor, defaults, children, and serialization. +- `site_tests.rs` – Tests `SiteConfig` constructor, defaults, and serialization. + +All tests are self‑contained and use the `must!` macro to unwrap results with a helpful message on failure. They serve as executable examples of the API usage. + +--- + +## Conclusion + +`librawssg_config` provides a clean and extensible configuration system for a static site generator. With sensible defaults, comprehensive validation, and full serde support, it covers the needs of both simple and complex site configurations. The types are designed for ergonomic use and can be easily loaded from YAML or JSON files, making it straightforward to define site‑wide settings, build paths, navigation, and content processing rules. + +For further details, refer to the source code and test files. diff --git a/librawssg_demo/README.md b/librawssg_demo/README.md new file mode 100644 index 0000000..75b8ae0 --- /dev/null +++ b/librawssg_demo/README.md @@ -0,0 +1,259 @@ +# librawssg_demo + +A complete demo application for **librawssg**, a modular static site generator framework written in Rust. This project showcases how to assemble the various `librawssg` crates into a working static site generator that reads raw HTML fragments, renders them through a Tera template, copies static assets, and outputs a fully static website. + +--- + +## Table of Contents + +1. [Overview](#overview) +2. [Features](#features) +3. [Project Structure](#project-structure) +4. [Prerequisites](#prerequisites) +5. [Installation & Build](#installation--build) +6. [Running the Demo](#running-the-demo) +7. [How It Works](#how-it-works) + - [The `RawFileProcessor`](#the-rawfileprocessor) + - [Template Rendering with Tera](#template-rendering-with-tera) + - [Configuration](#configuration) + - [Static Assets](#static-assets) + - [Output Generation](#output-generation) +8. [Customization Guide](#customization-guide) + - [Adding New Content Files](#adding-new-content-files) + - [Changing the Template](#changing-the-template) + - [Adding Custom Processors](#adding-custom-processors) + - [Adding Generators](#adding-generators) +9. [Underlying Crates](#underlying-crates) +10. [Troubleshooting](#troubleshooting) +11. [License](#license) + +--- + +## Overview + +`librawssg_demo` demonstrates a minimal but functional static site generator built with the `librawssg` framework. It uses: + +- **`librawssg_config`** for configuration management. +- **`librawssg_handler`** to define a custom `Processor` that handles `.raw` files. +- **`librawssg_templates`** for Tera-based rendering. +- **`librawssg_fs`** for filesystem abstraction (using `RealFs`). +- **`librawssg_compiler`** to orchestrate the build pipeline. + +The demo processes `.raw` files (containing HTML fragments) from `src/content`, renders them using a Tera template (`base.tera`), copies static files from `src/static`, and outputs the final site into the `dist/` folder. + +--- + +## Features + +- **Modular architecture**: Each component (processing, rendering, file I/O, configuration) is separated and replaceable. +- **Custom content processing**: The included `RawFileProcessor` reads `.raw` files and converts them into `Document` objects. +- **Template rendering**: Uses the Tera template engine with a `TeraContextBuilder` to inject page data. +- **Static asset copying**: Automatically copies the `static` directory to the output. +- **Atomic output**: The build process writes to a temporary directory and atomically replaces the final output. +- **Extensible**: Easily add new processors, renderers, context builders, or generators. + +--- + +## Project Structure + +``` +librawssg_demo/ +├── Cargo.toml +└── src/ + ├── content/ + │ ├── about.raw + │ └── index.raw + ├── static/ + │ └── style.css + ├── templates/ + │ └── base.tera + └── main.rs +``` + +- **`Cargo.toml`** – Defines the package and dependencies on the `librawssg_*` crates (via path). +- **`src/main.rs`** – Entry point; configures and runs the pipeline. +- **`src/content/`** – Contains source content files (`.raw`). These are processed by `RawFileProcessor`. +- **`src/static/`** – Static assets (CSS, images, etc.) that are copied verbatim to the output. +- **`src/templates/`** – Tera templates used for rendering. +- **`dist/`** – Generated output (created at runtime, not stored in version control). + +--- + +## Prerequisites + +- **Rust toolchain** (stable, edition 2024) – Install via [rustup](https://rustup.rs/). +- **Cargo** – Comes with Rust. +- The `librawssg_*` crates must be available at the relative paths specified in `Cargo.toml`. This demo assumes a workspace layout where the crates are siblings of `librawssg_demo`. + +--- + +## Installation & Build + +1. **Clone the repository** (or navigate to the demo directory inside the workspace). + +2. **Build the project**: + + ```bash + cargo build + ``` + +3. **Run the demo**: + ```bash + cargo run + ``` + +Upon successful execution, the output site will be generated in the `dist/` folder (relative to the project root). + +--- + +## Running the Demo + +Execute: + +```bash +cargo run +``` + +The program will: + +1. Read the configuration (created programmatically in `main.rs`). +2. Load templates from `src/templates`. +3. Process all `.raw` files in `src/content`. +4. Render each document using the `base.tera` template. +5. Copy static files from `src/static`. +6. Write everything into `dist/` atomically. + +After completion, you can open `dist/index.html` in a browser to view the generated site. + +--- + +## How It Works + +### The `RawFileProcessor` + +The `RawFileProcessor` implements the `Processor` trait from `librawssg_handler`. Its responsibilities: + +- **`can_process`**: Returns `true` only for files with a `.raw` extension. +- **`process`**: + - Reads the file content using the provided `FileSystem`. + - Derives a title from the file stem (e.g., `my-page` becomes `My Page`). + - Creates a `Metadata` object with the title. + - Constructs a `Document` with: + - `body`: the raw HTML content (will be inserted into the template). + - `url`: the same relative path but with `.html` extension. + - `output_path`: the relative output path (same as URL). + - `content_type`: hardcoded to `"page"`. + +### Template Rendering with Tera + +- **Renderer**: `TeraRenderer` (from `librawssg_templates`) is used. It loads all templates from the `src/templates` directory recursively. +- **Context Builder**: `TeraContextBuilder` creates a `tera::Context` containing: + - `site`: the full `SiteConfig`. + - `page_title`, `page_description`, `page_author`, etc. + - `page_content`: the raw body of the document (inserted with `| safe` filter in the template). +- **Template**: `base.tera` defines the overall HTML structure. It uses `{{ page_title }}` and `{{ page_content | safe }}` to inject data. + +### Configuration + +In `main.rs`, a `Config` object is built: + +- `site_name` is set to `"Demo Site"`. +- One `ContentRule` is added: `name = "page"`, `pattern = "**/*.raw"`, `template = "base.tera"`. +- Build paths (`content_dir`, `output_dir`, `static_dir`) are set to absolute paths under the project root. + +### Static Assets + +The `config.build.static_dir` points to `src/static`. During generation, the pipeline copies this directory recursively to the output. In this demo, the `style.css` file will appear at `dist/static/style.css`. The template references it via `/style.css` assuming the static directory is copied with its name preserved (i.e., `static/`). To make the CSS load correctly, the static directory is copied as `dist/static/`, and the link in the template should be `/static/style.css`. However, the provided template uses `/style.css` – this might be a slight mismatch. In a real deployment you may want to adjust either the static directory name or the link. The demo as provided will copy `static/` into `dist/static/`, but the HTML link expects `/style.css` which would 404 unless the server serves the static directory at root. For local file viewing, it won't work directly. This is a common issue; you can either change the static dir to be copied to the root (by setting `static_dir` to a path with no subdirectory) or modify the template link to `/static/style.css`. The demo code keeps the default behavior; user may need to adjust. + +### Output Generation + +The `Pipeline` orchestrates the build: + +1. Processes all content files. +2. Groups documents by content type. +3. Renders non-list documents. +4. (No list pages in this demo because `list_enabled` is not set.) +5. Copies static assets. +6. Runs any custom generators (none in this demo). +7. Atomically replaces the `dist/` directory. + +--- + +## Customization Guide + +### Adding New Content Files + +Simply create a new `.raw` file in `src/content/`. For example, `src/content/contact.raw`: + +```html +

Contact

+

Email us at hello@example.com

+``` + +The next `cargo run` will automatically process it and generate `dist/contact.html`. + +### Changing the Template + +Edit `src/templates/base.tera`. You can use any Tera syntax. The available context variables include: + +- `page_title` +- `page_content` (raw HTML) +- `page_description` +- `page_author` +- `page_date` +- `page_tags` +- `page_url` +- `page_depth` +- `page_type` +- `page_is_list` +- `page_list_items` +- `site` (the full `SiteConfig`) + +You can also add custom fields to `Metadata` or use the `extra` maps. + +### Adding Custom Processors + +Implement the `Processor` trait and add it to the pipeline builder with `.add_processor(Box::new(MyProcessor))`. For example, you could create a Markdown processor that handles `.md` files. + +### Adding Generators + +Implement the `Generator` trait and add it with `.add_generator(Box::new(MyGenerator))`. Generators run after documents and static files are written, allowing you to create RSS feeds, sitemaps, or search indexes. + +--- + +## Underlying Crates + +This demo relies on the following `librawssg` crates (all located in sibling directories): + +- [`librawssg_compiler`](../librawssg_compiler) – Pipeline and builder. +- [`librawssg_config`](../librawssg_config) – Configuration types. +- [`librawssg_fs`](../librawssg_fs) – Filesystem abstraction. +- [`librawssg_handler`](../librawssg_handler) – `Document`, `Metadata`, and `Processor` trait. +- [`librawssg_templates`](../librawssg_templates) – Rendering traits and Tera implementation. +- [`librawssg_error`](../librawssg_error) – Unified error types. + +All are used via path dependencies, so they must be present in the workspace. + +--- + +## Troubleshooting + +- **Build errors about missing crates** + Ensure all `librawssg_*` crates are checked out at the correct relative paths (siblings to this demo). Check `Cargo.toml` for the `path` attributes. + +- **Static CSS not loading** + By default, the static directory is copied with its name (`static/`). The template currently links to `/style.css`. Either: + - Change the template link to `/static/style.css`, or + - Modify `config.build.static_dir` to a path whose basename is empty (e.g., copy contents directly to output root) by adjusting the pipeline's static copying logic (not recommended for this demo). + +- **`cargo run` fails with permission errors** + The output directory `dist/` may already exist and be locked. Try deleting it manually or ensuring you have write permissions. + +- **Tera template syntax errors** + Validate your template syntax. The error messages from Tera are descriptive and will indicate the problem line. + +--- + +## License + +This demo is licensed under the **MIT License**. See the `LICENSE` file in the repository root for details. diff --git a/librawssg_demo/src/static/style.css b/librawssg_demo/src/static/style.css index 458e2aa..1a9201e 100644 --- a/librawssg_demo/src/static/style.css +++ b/librawssg_demo/src/static/style.css @@ -1,9 +1,9 @@ body { - font-family: sans-serif; - max-width: 800px; - margin: 0 auto; - padding: 2rem; + font-family: sans-serif; + max-width: 800px; + margin: 0 auto; + padding: 2rem; } nav a { - margin-right: 1rem; + margin-right: 1rem; } diff --git a/librawssg_error/README.md b/librawssg_error/README.md index 07aee98..76e814b 100644 --- a/librawssg_error/README.md +++ b/librawssg_error/README.md @@ -1,4 +1,685 @@ # librawssg_error -Unified error types for the librawssg static site generator ecosystem. -Provides a single `Error` enum and `Result` alias used by all other crates. +**Version**: 1.0.0 (implied) +**Crate name**: `librawssg_error` +**Description**: Defines a comprehensive error enum and `Result` alias for use across the `librawssg` static site generator ecosystem. The error type is built with `thiserror` for ergonomic `Display` and `Error` implementations, supports source chaining, and is designed to cover all common failure modes in SSG operations. + +--- + +## Table of Contents + +1. [Overview](#overview) +2. [Dependencies](#dependencies) +3. [The `Error` Enum](#the-error-enum) + - [Enum Definition](#enum-definition) + - [Attributes and Derives](#attributes-and-derives) + - [Non‑Exhaustive](#non-exhaustive) +4. [Variants](#variants) + - [`Io`](#io) + - [`Config`](#config) + - [`Metadata`](#metadata) + - [`Render`](#render) + - [`Processor`](#processor) + - [`Generator`](#generator) + - [`PathTraversal`](#pathtraversal) + - [`MissingConfig`](#missingconfig) + - [`Generation`](#generation) + - [`NotFound`](#notfound) + - [`Serialization`](#serialization) + - [`Validation`](#validation) + - [`Duplicate`](#duplicate) + - [`InvalidState`](#invalidstate) + - [`Internal`](#internal) +5. [`Result` Type Alias](#resultt-type-alias) +6. [Error Sources and `std::error::Error`](#error-sources-and-stderroerror) +7. [Conversion from `std::io::Error`](#conversion-from-stdioerror) +8. [Usage Examples from Tests](#usage-examples-from-tests) + - [Display Messages](#display-messages) + - [Using the `?` Operator](#using-the--operator) + - [Source Chain](#source-chain) + - [Property Tests](#property-tests) +9. [Guidelines for Error Usage](#guidelines-for-error-usage) +10. [Testing Suite Overview](#testing-suite-overview) +11. [Conclusion](#conclusion) + +--- + +## Overview + +The `librawssg_error` crate provides a single, unified error type for the entire static site generator ecosystem. Instead of having each module define its own error types, they all share this `Error` enum, which categorizes failures into well‑defined variants. The enum is derived with `thiserror::Error`, giving each variant an automatic `Display` implementation based on a custom message pattern, and an automatic `std::error::Error` implementation that preserves source chains when applicable. + +The crate also exports a `Result` type alias, simplifying function signatures throughout the codebase. + +Key features: + +- **Rich error categories** – 15 distinct variants covering I/O, configuration, parsing, rendering, processing, generation, security, and internal errors. +- **Source chaining** – The `Metadata` variant can wrap an underlying error (e.g., a YAML parsing error) and expose it via `source()`. +- **Convenient conversion** – `From` allows using the `?` operator directly in functions returning `Result`. +- **Non‑exhaustive** – The enum is marked `#[non_exhaustive]`, enabling future additions without breaking downstream code. + +--- + +## Dependencies + +- `thiserror` – Provides the `#[derive(Error)]` macro that generates `Display` and `Error` implementations from the attributes. +- `core::error::Error` (or `std::error::Error`) – Used as a trait object for the `source` field in the `Metadata` variant. + +No other external crates are required. + +--- + +## The `Error` Enum + +### Enum Definition + +```rust +use core::error::Error as CoreError; +use std::path::PathBuf; +use thiserror::Error; + +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum Error { + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), + + #[error("Configuration error: {0}")] + Config(String), + + #[error("Failed to parse metadata in {path}")] + Metadata { + path: PathBuf, + #[source] + source: Box, + }, + + #[error("Template rendering error: {0}")] + Render(String), + + #[error("Content processor error: {0}")] + Processor(String), + + #[error("Generator error: {0}")] + Generator(String), + + #[error("Path traversal attempt detected: {0}")] + PathTraversal(String), + + #[error("Missing configuration key: {0}")] + MissingConfig(String), + + #[error("Site generation error: {0}")] + Generation(String), + + #[error("Resource not found: {0}")] + NotFound(String), + + #[error("Serialization error: {0}")] + Serialization(String), + + #[error("Validation error: {0}")] + Validation(String), + + #[error("Duplicate value: {0}")] + Duplicate(String), + + #[error("Invalid state: {0}")] + InvalidState(String), + + #[error("Internal error: {0}")] + Internal(String), +} +``` + +### Attributes and Derives + +- **`#[derive(Debug, Error)]`** – Derives `Debug` and `std::error::Error`. The `Error` derive from `thiserror` also generates a `Display` implementation based on the `#[error("...")]` attributes. +- **`#[non_exhaustive]`** – Indicates that the enum may gain new variants in future releases. Downstream crates must not exhaustively match on this enum; they must include a wildcard arm (`_`) when matching. + +### Non‑Exhaustive + +Because the enum is non‑exhaustive, external code cannot write: + +```rust +match err { + Error::Io(_) => ..., + Error::Config(_) => ..., + // all variants... +} +``` + +without including a catch‑all arm: + +```rust +match err { + Error::Io(_) => ..., + Error::Config(_) => ..., + // ... + _ => { /* handle unknown future variants */ } +} +``` + +This ensures forward compatibility. + +--- + +## Variants + +### `Io` + +```rust +#[error("I/O error: {0}")] +Io(#[from] std::io::Error), +``` + +- **Description**: Wraps a standard library I/O error. Used for any filesystem operation failure (reading, writing, deleting, etc.). +- **Fields**: Contains a single `std::io::Error`. +- **Display**: `"I/O error: {underlying_io_error_message}"`. +- **`#[from]`**: Automatically provides `From for Error`, allowing the `?` operator in functions returning `Result`. +- **Source**: `source()` returns `Some(&io_error)` because `std::io::Error` implements `std::error::Error`. + +**Example**: + +```rust +let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file missing"); +let err = Error::Io(io_err); +assert_eq!(err.to_string(), "I/O error: file missing"); +``` + +--- + +### `Config` + +```rust +#[error("Configuration error: {0}")] +Config(String), +``` + +- **Description**: Indicates a problem with configuration data (e.g., invalid YAML, malformed settings). +- **Fields**: A `String` containing a human‑readable description. +- **Display**: `"Configuration error: {message}"`. +- **Source**: `None` (no underlying error is stored). + +**Example**: + +```rust +let err = Error::Config("invalid YAML".to_string()); +assert_eq!(err.to_string(), "Configuration error: invalid YAML"); +``` + +--- + +### `Metadata` + +```rust +#[error("Failed to parse metadata in {path}")] +Metadata { + path: PathBuf, + #[source] + source: Box, +}, +``` + +- **Description**: Used when parsing metadata (e.g., front matter) fails. Stores the path of the problematic file and the original error. +- **Fields**: + - `path: PathBuf` – The path to the source file where metadata parsing failed. + - `source: Box` – The underlying error that caused the failure (boxed trait object). +- **Display**: `"Failed to parse metadata in {path}"`. The `{path}` placeholder prints the `PathBuf` using its `Display` implementation. +- **Source**: `source()` returns `Some(&*source)` (the boxed error as a `&dyn Error`), enabling error chain inspection. +- **Note**: The `#[source]` attribute tells `thiserror` to use this field as the error source. The `Box` allows storing any error type that is `Send + Sync`. + +**Example**: + +```rust +use std::path::PathBuf; + +let source: Box = + Box::new(std::io::Error::other("bad yaml")); +let err = Error::Metadata { + path: PathBuf::from("content/post.md"), + source, +}; + +assert_eq!(err.to_string(), "Failed to parse metadata in content/post.md"); +let as_core_error: &dyn core::error::Error = &err; +let source_ref = as_core_error.source().unwrap(); +assert_eq!(source_ref.to_string(), "bad yaml"); +``` + +--- + +### `Render` + +```rust +#[error("Template rendering error: {0}")] +Render(String), +``` + +- **Description**: Signifies an error during template rendering (e.g., missing variable, template not found, syntax error). +- **Fields**: A `String` describing the rendering problem. +- **Display**: `"Template rendering error: {message}"`. +- **Source**: `None`. + +**Example**: + +```rust +let err = Error::Render("template not found".to_string()); +assert_eq!(err.to_string(), "Template rendering error: template not found"); +``` + +--- + +### `Processor` + +```rust +#[error("Content processor error: {0}")] +Processor(String), +``` + +- **Description**: Used when a content processor (e.g., Markdown parser, Sass compiler) fails. +- **Fields**: A `String` with details about the processor failure. +- **Display**: `"Content processor error: {message}"`. +- **Source**: `None`. + +**Example**: + +```rust +let err = Error::Processor("custom processor failed".to_string()); +assert_eq!(err.to_string(), "Content processor error: custom processor failed"); +``` + +--- + +### `Generator` + +```rust +#[error("Generator error: {0}")] +Generator(String), +``` + +- **Description**: Represents an error in a generator component (e.g., RSS feed generation, sitemap creation). +- **Fields**: A `String` describing the generator error. +- **Display**: `"Generator error: {message}"`. +- **Source**: `None`. + +**Example**: + +```rust +let err = Error::Generator("RSS generation failed".to_string()); +assert_eq!(err.to_string(), "Generator error: RSS generation failed"); +``` + +--- + +### `PathTraversal` + +```rust +#[error("Path traversal attempt detected: {0}")] +PathTraversal(String), +``` + +- **Description**: Indicates a path traversal attack was attempted or a path escapes a safe root directory. +- **Fields**: A `String` containing the offending path or description. +- **Display**: `"Path traversal attempt detected: {message}"`. +- **Source**: `None`. + +**Example**: + +```rust +let err = Error::PathTraversal("../escape".to_string()); +assert_eq!(err.to_string(), "Path traversal attempt detected: ../escape"); +``` + +--- + +### `MissingConfig` + +```rust +#[error("Missing configuration key: {0}")] +MissingConfig(String), +``` + +- **Description**: Signals that a required configuration key is absent. +- **Fields**: A `String` naming the missing key. +- **Display**: `"Missing configuration key: {key}"`. +- **Source**: `None`. + +**Example**: + +```rust +let err = Error::MissingConfig("base_url".to_string()); +assert_eq!(err.to_string(), "Missing configuration key: base_url"); +``` + +--- + +### `Generation` + +```rust +#[error("Site generation error: {0}")] +Generation(String), +``` + +- **Description**: A general error during the site generation phase (e.g., failed to write output). +- **Fields**: A `String` with more information. +- **Display**: `"Site generation error: {message}"`. +- **Source**: `None`. + +**Example**: + +```rust +let err = Error::Generation("output write failed".to_string()); +assert_eq!(err.to_string(), "Site generation error: output write failed"); +``` + +--- + +### `NotFound` + +```rust +#[error("Resource not found: {0}")] +NotFound(String), +``` + +- **Description**: Used when a requested resource (file, asset, page) cannot be found. +- **Fields**: A `String` identifying the missing resource. +- **Display**: `"Resource not found: {resource}"`. +- **Source**: `None`. + +**Example**: + +```rust +let err = Error::NotFound("asset.css".to_string()); +assert_eq!(err.to_string(), "Resource not found: asset.css"); +``` + +--- + +### `Serialization` + +```rust +#[error("Serialization error: {0}")] +Serialization(String), +``` + +- **Description**: Indicates a failure during serialization or deserialization (e.g., JSON conversion error). +- **Fields**: A `String` describing the serialization problem. +- **Display**: `"Serialization error: {message}"`. +- **Source**: `None`. + +**Example**: + +```rust +let err = Error::Serialization("invalid JSON".to_string()); +assert_eq!(err.to_string(), "Serialization error: invalid JSON"); +``` + +--- + +### `Validation` + +```rust +#[error("Validation error: {0}")] +Validation(String), +``` + +- **Description**: Represents a validation failure (e.g., invalid input, constraint violation). +- **Fields**: A `String` explaining what failed validation. +- **Display**: `"Validation error: {message}"`. +- **Source**: `None`. + +**Example**: + +```rust +let err = Error::Validation("name too long".to_string()); +assert_eq!(err.to_string(), "Validation error: name too long"); +``` + +--- + +### `Duplicate` + +```rust +#[error("Duplicate value: {0}")] +Duplicate(String), +``` + +- **Description**: Signals that a duplicate value was encountered where uniqueness was expected (e.g., duplicate key in a map). +- **Fields**: A `String` identifying the duplicated item. +- **Display**: `"Duplicate value: {message}"`. +- **Source**: `None`. + +**Example**: + +```rust +let err = Error::Duplicate("duplicate key".to_string()); +assert_eq!(err.to_string(), "Duplicate value: duplicate key"); +``` + +--- + +### `InvalidState` + +```rust +#[error("Invalid state: {0}")] +InvalidState(String), +``` + +- **Description**: Indicates an unexpected program state (e.g., a null where a value is required, inconsistent internal data). +- **Fields**: A `String` describing the invalid state. +- **Display**: `"Invalid state: {message}"`. +- **Source**: `None`. + +**Example**: + +```rust +let err = Error::InvalidState("unexpected null".to_string()); +assert_eq!(err.to_string(), "Invalid state: unexpected null"); +``` + +--- + +### `Internal` + +```rust +#[error("Internal error: {0}")] +Internal(String), +``` + +- **Description**: Used for internal errors that should not normally occur (e.g., bugs in the code, unreachable conditions). +- **Fields**: A `String` with details suitable for debugging. +- **Display**: `"Internal error: {message}"`. +- **Source**: `None`. + +**Example**: + +```rust +let err = Error::Internal("bug in code".to_string()); +assert_eq!(err.to_string(), "Internal error: bug in code"); +``` + +--- + +## `Result` Type Alias + +```rust +pub type Result = core::result::Result; +``` + +- **Purpose**: A convenient alias so that functions can return `Result` instead of the more verbose `std::result::Result`. +- **Usage**: Throughout the `librawssg` ecosystem, functions that may fail with any of the above errors use this alias. + +**Example**: + +```rust +fn read_config(path: &str) -> librawssg_error::Result { + let content = std::fs::read_to_string(path)?; // `?` converts io::Error into Error::Io + Ok(content) +} +``` + +--- + +## Error Sources and `std::error::Error` + +All variants of `Error` implement `std::error::Error` (via `thiserror`). The `source()` method returns: + +- For `Io`: `Some(&self.0)` (the underlying `io::Error`). +- For `Metadata`: `Some(self.source.as_ref())` (the boxed error). +- For all other variants: `None`. + +This allows error chains to be inspected using `std::error::Error::source()`. + +**Example** (from integration tests): + +```rust +let source: Box = + Box::new(std::io::Error::other("bad yaml")); +let err = Error::Metadata { + path: PathBuf::from("content/post.md"), + source, +}; + +let as_core_error: &dyn core::error::Error = &err; +let source_ref = as_core_error.source().unwrap(); +assert_eq!(source_ref.to_string(), "bad yaml"); +``` + +--- + +## Conversion from `std::io::Error` + +The `Io` variant has the `#[from]` attribute, which automatically generates: + +```rust +impl From for Error { + fn from(err: std::io::Error) -> Error { + Error::Io(err) + } +} +``` + +This enables the `?` operator to convert `std::io::Error` into `Error` in any function returning `Result` (or `librawssg_error::Result`). + +**Example**: + +```rust +fn read_file(path: &str) -> Result { + let content = std::fs::read_to_string(path)?; // io::Error becomes Error::Io + Ok(content) +} +``` + +--- + +## Usage Examples from Tests + +The test suite provides excellent examples of how to construct and use the error type. + +### Display Messages + +Each variant has a test asserting its exact `Display` output. For example: + +```rust +#[test] +fn display_for_config_error() { + let err = Error::Config("invalid YAML".to_string()); + assert_eq!(err.to_string(), "Configuration error: invalid YAML"); +} +``` + +All 15 variants have similar tests in `unit_tests.rs`. + +### Using the `?` Operator + +The integration test `io_error_propagates_via_question_mark` demonstrates how `?` works: + +```rust +fn read_file(path: &str) -> Result { + let content = std::fs::read_to_string(path)?; + Ok(content) +} + +#[test] +fn io_error_propagates_via_question_mark() { + let result = read_file("definitely_not_exists.txt"); + assert!(result.is_err()); + assert!(matches!(result, Err(Error::Io(_)))); +} +``` + +### Source Chain + +The `metadata_error_can_hold_boxed_dyn_error` test shows how to store an arbitrary error and retrieve it via `source()`: + +```rust +let source: Box = + Box::new(std::io::Error::other("bad yaml")); +let err = Error::Metadata { + path: PathBuf::from("content/post.md"), + source, +}; + +let as_core_error: &dyn core::error::Error = &err; +let source_ref = as_core_error.source(); +assert!(source_ref.is_some()); +``` + +### Property Tests + +Property tests verify that the error message always preserves the input string exactly, regardless of content (including empty strings, newlines, special characters): + +```rust +#[test] +fn config_error_message_preserves_input() { + let samples = [ + "", + "short", + "a very long error message with symbols !@#$%^&*()", + "line1\nline2", + ]; + + for sample in samples { + let err = Error::Config(sample.to_string()); + assert_eq!(err.to_string(), format!("Configuration error: {sample}")); + } +} +``` + +Similar tests exist for `PathTraversal` and `Render`. + +--- + +## Guidelines for Error Usage + +When writing code in the `librawssg` ecosystem, follow these recommendations: + +- **Use the most specific variant** that describes the failure. For example: + - I/O failures → `Error::Io`. + - Missing file/resource → `Error::NotFound`. + - Invalid user input → `Error::Validation`. + - Security issue (path traversal) → `Error::PathTraversal`. +- **Attach context when possible** – Include the relevant path, key, or identifier in the error message string. +- **Preserve source errors** – If an underlying error is available, use the `Metadata` variant (or add a new variant with a `#[source]` field) to maintain the error chain. +- **Avoid matching exhaustively on `Error`** – Because the enum is non‑exhaustive, always include a catch‑all arm when matching to prevent future breakage. +- **Use `Result` alias** for concise function signatures. + +--- + +## Testing Suite Overview + +The crate includes three test files: + +- **`unit_tests.rs`** – Tests each variant’s `Display` message, `source()` for `Metadata`, `From` conversion, the `Result` alias, and `Debug` output. +- **`integration_tests.rs`** – Tests the `?` operator integration and the source chain for `Metadata` using a boxed dynamic error. +- **`property_tests.rs`** – Property‑based tests that verify error messages preserve arbitrary input strings for `Config`, `PathTraversal`, and `Render`. + +Together, these tests ensure the error type is robust, easy to use, and consistent. + +--- + +## Conclusion + +`librawssg_error` provides a centralized, well‑structured error type for the entire static site generator project. With 15 descriptive variants, automatic `Display` and `Error` implementations, convenient conversion from `io::Error`, and support for error sources, it simplifies error handling across all modules. The non‑exhaustive design guarantees future extensibility without breaking downstream code. + +For further details, refer to the source code and test files. diff --git a/librawssg_fs/README.md b/librawssg_fs/README.md index 3ca0a9b..e5f42de 100644 --- a/librawssg_fs/README.md +++ b/librawssg_fs/README.md @@ -1,4 +1,867 @@ # librawssg_fs -Filesystem abstraction layer for librawssg. -Defines the `FileSystem` trait and provides a real implementation. +**Version**: 1.0.0 (implied) +**Crate name**: `librawssg_fs` +**Description**: A filesystem abstraction layer for static site generators. Defines the `FileSystem` trait with a comprehensive set of file and directory operations, and provides a concrete implementation `RealFs` that delegates to `std::fs` and `walkdir`. The trait includes built‑in path traversal protection and convenience methods for atomic operations. + +--- + +## Table of Contents + +1. [Overview](#overview) +2. [Modules](#modules) +3. [Trait `FileSystem`](#trait-filesystem) + - [Trait Definition](#trait-definition) + - [Required Methods](#required-methods) + - [`read_to_string`](#read_to_string) + - [`read_bytes`](#read_bytes) + - [`write`](#write) + - [`create_dir_all`](#create_dir_all) + - [`remove_dir_all`](#remove_dir_all) + - [`remove_file`](#remove_file) + - [`create_dir`](#create_dir) + - [`exists`](#exists) + - [`is_dir`](#is_dir) + - [`is_file`](#is_file) + - [`read_dir`](#read_dir) + - [`copy_file`](#copy_file) + - [`copy_dir_all`](#copy_dir_all) + - [`walk_dir`](#walk_dir) + - [`canonicalize`](#canonicalize) + - [`rename`](#rename) + - [`atomic_write`](#atomic_write) + - [`touch`](#touch) + - [`metadata`](#metadata) + - [`symlink_metadata`](#symlink_metadata) + - [`permissions`](#permissions) + - [`set_permissions`](#set_permissions) + - [`read_link`](#read_link) + - [`hard_link`](#hard_link) + - [Provided (Default) Methods](#provided-default-methods) + - [`is_symlink`](#is_symlink) + - [`canonicalize_or_join`](#canonicalize_or_join) + - [`safe_join`](#safe_join) + - [`copy`](#copy) + - [`rename_or_copy`](#rename_or_copy) +4. [Struct `RealFs`](#struct-realfs) + - [Implementation Details](#implementation-details) + - [Example Usage](#example-usage) +5. [Error Handling](#error-handling) +6. [Implementing a Custom `FileSystem`](#implementing-a-custom-filesystem) +7. [Security Considerations](#security-considerations) +8. [Testing Suite Overview](#testing-suite-overview) +9. [Complete Code Examples from Tests](#complete-code-examples-from-tests) + +--- + +## Overview + +`librawssg_fs` provides a trait‑based abstraction over filesystem operations. This allows static site generator components to interact with the filesystem without being tightly coupled to `std::fs`. It enables: + +- **Testability**: Mock filesystems can be injected in unit tests. +- **Portability**: Different filesystem backends (e.g., in‑memory, virtual) can implement the trait. +- **Security**: Built‑in path traversal protection through `safe_join` and `canonicalize_or_join`. + +The crate exports: + +- `pub trait FileSystem` – The main abstraction. +- `pub struct RealFs` – A zero‑sized type that implements `FileSystem` using the real OS filesystem. + +--- + +## Modules + +The crate root (`lib.rs`) defines the `FileSystem` trait and re‑exports `RealFs` from the `real` module. + +```rust +pub mod real; +pub use real::RealFs; +``` + +There is also an internal module `real.rs` containing the `RealFs` implementation. + +--- + +## Trait `FileSystem` + +The `FileSystem` trait is the core of this crate. It is object‑safe and requires implementors to be `Send + Sync` (safe to share across threads). The trait provides many required methods and several methods with default implementations. + +```rust +pub trait FileSystem: Send + Sync { + // Required methods (see below) + // Provided methods with default implementations +} +``` + +### Required Methods + +These methods **must** be implemented by any type that implements `FileSystem`. They map closely to `std::fs` functions and `walkdir` functionality. + +#### `read_to_string` + +```rust +fn read_to_string(&self, path: &Path) -> io::Result; +``` + +**Purpose**: Reads the entire contents of a file into a `String`. + +**Parameters**: + +- `path`: The path to the file to read. + +**Returns**: `Ok(String)` containing the file contents, or an `Err(io::Error)` if the file cannot be read (e.g., not found, permission denied, invalid UTF‑8). + +**Example**: + +```rust +let content = fs.read_to_string(Path::new("hello.txt"))?; +``` + +#### `read_bytes` + +```rust +fn read_bytes(&self, path: &Path) -> io::Result>; +``` + +**Purpose**: Reads the entire contents of a file as raw bytes. + +**Parameters**: + +- `path`: The path to the file. + +**Returns**: `Ok(Vec)` with the file bytes, or an `Err(io::Error)`. + +**Example**: + +```rust +let data = fs.read_bytes(Path::new("image.png"))?; +``` + +#### `write` + +```rust +fn write(&self, path: &Path, content: &[u8]) -> io::Result<()>; +``` + +**Purpose**: Writes the given bytes to a file, creating any necessary parent directories. + +**Parameters**: + +- `path`: Destination file path. +- `content`: Bytes to write. + +**Returns**: `Ok(())` on success, or `Err(io::Error)` on failure (e.g., permission denied, disk full). + +**Behavior**: The default `RealFs` implementation creates parent directories before writing (via `create_dir_all` on the parent). This is convenient for writing deeply nested outputs. + +**Example**: + +```rust +fs.write(Path::new("a/b/c.txt"), b"hello")?; +``` + +#### `create_dir_all` + +```rust +fn create_dir_all(&self, path: &Path) -> io::Result<()>; +``` + +**Purpose**: Creates a directory and all its missing parents. + +**Parameters**: + +- `path`: The directory path to create. + +**Returns**: `Ok(())` or `Err(io::Error)`. + +**Note**: Unlike `create_dir`, this does **not** error if the directory already exists. + +**Example**: + +```rust +fs.create_dir_all(Path::new("a/b/c"))?; +``` + +#### `remove_dir_all` + +```rust +fn remove_dir_all(&self, path: &Path) -> io::Result<()>; +``` + +**Purpose**: Removes a directory and all its contents recursively. + +**Parameters**: + +- `path`: Directory path to remove. + +**Returns**: `Ok(())` or `Err(io::Error)` (e.g., directory does not exist, permission denied). + +**Warning**: This is destructive and cannot be undone. + +#### `remove_file` + +```rust +fn remove_file(&self, path: &Path) -> io::Result<()>; +``` + +**Purpose**: Deletes a single file. + +**Parameters**: + +- `path`: File path to remove. + +**Returns**: `Ok(())` or `Err(io::Error)`. + +#### `create_dir` + +```rust +fn create_dir(&self, path: &Path) -> io::Result<()>; +``` + +**Purpose**: Creates a single directory. Fails if the parent directory does not exist or if the directory already exists. + +**Parameters**: + +- `path`: Directory path to create. + +**Returns**: `Ok(())` or `Err(io::Error)` (e.g., already exists, parent missing). + +#### `exists` + +```rust +fn exists(&self, path: &Path) -> bool; +``` + +**Purpose**: Checks whether a path exists (as a file, directory, symlink, etc.). + +**Parameters**: + +- `path`: Path to check. + +**Returns**: `true` if the path exists, `false` otherwise. + +**Note**: This method does not follow symlinks for broken symlinks; it returns `false` for a broken symlink. + +#### `is_dir` + +```rust +fn is_dir(&self, path: &Path) -> bool; +``` + +**Purpose**: Checks whether the path points to a directory. + +**Returns**: `true` if it is a directory, `false` otherwise (including if it does not exist). + +#### `is_file` + +```rust +fn is_file(&self, path: &Path) -> bool; +``` + +**Purpose**: Checks whether the path points to a regular file. + +**Returns**: `true` if it is a regular file, `false` otherwise. + +#### `read_dir` + +```rust +fn read_dir(&self, path: &Path) -> io::Result>; +``` + +**Purpose**: Lists all entries (files and directories) directly inside a directory. + +**Parameters**: + +- `path`: Directory path. + +**Returns**: `Ok(Vec)` containing the full paths of all entries, or `Err(io::Error)`. + +**Note**: The order is not guaranteed. It does not recurse into subdirectories. + +#### `copy_file` + +```rust +fn copy_file(&self, from: &Path, to: &Path) -> io::Result; +``` + +**Purpose**: Copies a file from `from` to `to`. If `to` already exists, it will be overwritten. + +**Parameters**: + +- `from`: Source file path. +- `to`: Destination file path. + +**Returns**: `Ok(u64)` with the number of bytes copied, or `Err(io::Error)`. + +**Note**: Does not create parent directories of `to` in the default `RealFs`; use `copy` or `copy_dir_all` for that. + +#### `copy_dir_all` + +```rust +fn copy_dir_all(&self, from: &Path, to: &Path) -> io::Result<()>; +``` + +**Purpose**: Recursively copies a directory tree from `from` to `to`. Creates the destination directory and all parent directories as needed. + +**Parameters**: + +- `from`: Source directory path. +- `to`: Destination directory path. + +**Returns**: `Ok(())` or `Err(io::Error)`. + +**Behavior**: + +1. Creates `to` directory. +2. Walks all files in `from` (using `walk_dir`). +3. For each file, computes relative path and creates parent directories in `to`, then copies the file. + +#### `walk_dir` + +```rust +fn walk_dir(&self, root: &Path) -> io::Result>; +``` + +**Purpose**: Recursively collects all **files** under `root`. Does not include directories or symlinks to directories. + +**Parameters**: + +- `root`: Root directory to traverse. + +**Returns**: `Ok(Vec)` with the full paths of all files, or `Err(io::Error)`. + +**Note**: The default `RealFs` uses the `walkdir` crate to handle traversal. It follows symlinks? (The `WalkDir::new` default does not follow symlinks; it will include symlinks but not traverse into them unless `.follow_links(true)` is set. Here symlinks to files will be included? `entry.file_type().is_file()` will be true for a symlink to a file? Actually `file_type()` returns the type of the symlink itself, not the target, unless `follow_links` is used. So symlinks are not considered files and are skipped.) + +#### `canonicalize` + +```rust +fn canonicalize(&self, path: &Path) -> io::Result; +``` + +**Purpose**: Returns the canonical, absolute form of a path, resolving all symbolic links and normalizing `.` and `..` components. + +**Parameters**: + +- `path`: The path to canonicalize. + +**Returns**: `Ok(PathBuf)` with the canonical path, or `Err(io::Error)` (e.g., path does not exist). + +#### `rename` + +```rust +fn rename(&self, from: &Path, to: &Path) -> io::Result<()>; +``` + +**Purpose**: Renames (moves) a file or directory from `from` to `to`. On most filesystems this is an atomic operation when source and destination are on the same filesystem. + +**Parameters**: + +- `from`: Source path. +- `to`: Destination path. + +**Returns**: `Ok(())` or `Err(io::Error)`. + +**Note**: If `to` exists, it may be overwritten (platform‑dependent). Does not work across different mount points (returns `CrossesDevices` error). + +#### `atomic_write` + +```rust +fn atomic_write(&self, path: &Path, content: &[u8]) -> io::Result<()>; +``` + +**Purpose**: Writes data to a file atomically by first writing to a temporary file and then renaming it over the target path. + +**Parameters**: + +- `path`: Destination file path. +- `content`: Bytes to write. + +**Returns**: `Ok(())` or `Err(io::Error)`. + +**Behavior**: + +1. Creates a temporary file with extension `.tmp` (by calling `with_extension("tmp")` on the target path). +2. Writes the content to the temporary file (using `write`, which creates parent directories). +3. Renames the temporary file to the target path (using `rename`). +4. If the rename fails, attempts to remove the temporary file and returns the error. + +**Note**: The temporary file name is derived from the target; it is not a hidden file and may collide if multiple writes happen concurrently to the same path. This is a best‑effort atomic write suitable for many use cases. + +#### `touch` + +```rust +fn touch(&self, path: &Path) -> io::Result<()>; +``` + +**Purpose**: Creates an empty file at `path` or updates its access/modification timestamp if it already exists. + +**Parameters**: + +- `path`: File path. + +**Returns**: `Ok(())` or `Err(io::Error)`. + +**Behavior**: + +1. Creates parent directories (like `write`). +2. Opens the file in append/create mode, which creates it if missing. +3. Calls `sync_all()` to flush to disk (optional, but ensures metadata is updated). + +**Note**: Existing file content is preserved. + +#### `metadata` + +```rust +fn metadata(&self, path: &Path) -> io::Result; +``` + +**Purpose**: Returns metadata for a file or directory, following symlinks. + +**Parameters**: + +- `path`: Path to query. + +**Returns**: `Ok(fs::Metadata)` or `Err(io::Error)`. + +#### `symlink_metadata` + +```rust +fn symlink_metadata(&self, path: &Path) -> io::Result; +``` + +**Purpose**: Returns metadata for a path **without** following symlinks (i.e., metadata of the symlink itself). + +**Parameters**: + +- `path`: Path to query. + +**Returns**: `Ok(fs::Metadata)` or `Err(io::Error)`. + +#### `permissions` + +```rust +fn permissions(&self, path: &Path) -> io::Result; +``` + +**Purpose**: Reads the permissions of a file or directory. + +**Parameters**: + +- `path`: Path to query. + +**Returns**: `Ok(fs::Permissions)` or `Err(io::Error)`. + +**Note**: The default `RealFs` obtains permissions from `metadata`, which follows symlinks. + +#### `set_permissions` + +```rust +fn set_permissions(&self, path: &Path, permissions: std::fs::Permissions) -> io::Result<()>; +``` + +**Purpose**: Sets the permissions of a file or directory. + +**Parameters**: + +- `path`: Target path. +- `permissions`: New permissions. + +**Returns**: `Ok(())` or `Err(io::Error)`. + +#### `read_link` + +```rust +fn read_link(&self, path: &Path) -> io::Result; +``` + +**Purpose**: Reads the target of a symbolic link. + +**Parameters**: + +- `path`: Path to the symlink. + +**Returns**: `Ok(PathBuf)` containing the link target, or `Err(io::Error)` if the path is not a symlink or does not exist. + +#### `hard_link` + +```rust +fn hard_link(&self, from: &Path, to: &Path) -> io::Result<()>; +``` + +**Purpose**: Creates a hard link from `from` to `to`. + +**Parameters**: + +- `from`: Existing file path. +- `to`: New hard link path. + +**Returns**: `Ok(())` or `Err(io::Error)`. + +**Note**: Both paths must be on the same filesystem. + +--- + +### Provided (Default) Methods + +These methods have default implementations that rely on the required methods. Implementors may override them for performance or platform‑specific behavior. + +#### `is_symlink` + +```rust +fn is_symlink(&self, path: &Path) -> bool { + self.symlink_metadata(path) + .is_ok_and(|meta| meta.file_type().is_symlink()) +} +``` + +**Purpose**: Checks whether the given path is a symbolic link. + +**Returns**: `true` if the path is a symlink (even if broken), `false` otherwise. + +**Implementation**: Uses `symlink_metadata` (which does not follow symlinks) and checks the file type. + +**Example**: + +```rust +if fs.is_symlink(Path::new("link")) { ... } +``` + +#### `canonicalize_or_join` + +```rust +fn canonicalize_or_join(&self, base: &Path, candidate: &Path) -> io::Result +``` + +**Purpose**: Safely resolves a possibly non‑existent path relative to `base`. If the joined path exists, it is canonicalized; otherwise it returns the canonical parent joined with the file name, after normalizing `.` and `..` components. + +**Parameters**: + +- `base`: The base directory (usually already canonical). +- `candidate`: A relative path (may contain `.` and `..`). + +**Returns**: `Ok(PathBuf)` with the resolved path, or `Err(io::Error)` if path traversal is detected or other errors occur. + +**Detailed Behavior**: + +1. Normalizes the `candidate` path by iterating over its components: + - `CurDir` (`.`) is ignored. + - `ParentDir` (`..`) causes the last normal component to be popped. If there is no previous normal component (i.e., attempt to go above root), it returns `PermissionDenied` with message `"path traversal detected"`. + - `Prefix` and `RootDir` components are pushed (though they are unusual for relative candidates and may cause issues later). + - `Normal` components are pushed. +2. Joins the normalized candidate with `base`. +3. If the joined path exists, canonicalizes it (resolving symlinks, etc.). +4. If it does not exist: + - Canonicalizes the parent directory of the joined path. + - Appends the file name of the joined path to the canonical parent. + - Returns that path. + +**Security**: This method prevents `..` from escaping the base directory (unless there are symlinks that point outside; canonicalization of existing paths can still lead outside base, which is why `safe_join` adds an extra check). For non‑existent paths, the parent canonicalization ensures that the final path is within the canonical base. + +**Example** (from tests): + +```rust +let existing = base.join("existing.txt"); +fs.write(&existing, b"data")?; + +let canon_existing = fs.canonicalize_or_join(base, Path::new("existing.txt"))?; +let canon_direct = fs.canonicalize(&existing)?; +assert_eq!(canon_existing, canon_direct); + +let missing = Path::new("missing.txt"); +let canon_missing = fs.canonicalize_or_join(base, missing)?; +let canon_base = fs.canonicalize(base)?; +assert_eq!(canon_missing, canon_base.join(missing)); +``` + +#### `safe_join` + +```rust +fn safe_join(&self, base: &Path, candidate: &Path) -> io::Result +``` + +**Purpose**: Safely joins a candidate path to a base directory, ensuring the result is **within** the base directory (no path traversal). This is the recommended way to compute destination paths for user‑provided or untrusted relative paths. + +**Parameters**: + +- `base`: The base directory (can be relative; it will be canonicalized internally). +- `candidate`: A relative path (may contain `.` and `..`). + +**Returns**: `Ok(PathBuf)` with the resolved path, guaranteed to start with the canonical base. Returns `Err(io::Error)` with `PermissionDenied` if the resolved path escapes the base (e.g., `candidate = "../secret"`). + +**Implementation Details**: + +1. Canonicalizes `base`. +2. Calls `canonicalize_or_join` with the canonical base and `candidate`. +3. Checks that the resulting path starts with the canonical base. If not, returns `PermissionDenied`. + +**Why needed**: Although `canonicalize_or_join` prevents simple `..` traversal, symlinks inside the base directory could cause a resolved path to point outside the base even after normalization. The `starts_with` check enforces containment. + +**Example**: + +```rust +let base = tmp.path().join("base"); +fs.create_dir_all(&base)?; + +let safe = fs.safe_join(&base, Path::new("inside.txt"))?; +assert!(safe.starts_with(&base)); + +let traversal = Path::new("../escape.txt"); +let result = fs.safe_join(&base, traversal); +assert!(result.is_err()); +``` + +#### `copy` + +```rust +fn copy(&self, from: &Path, to: &Path) -> io::Result<()> +``` + +**Purpose**: Copies a file or directory from `from` to `to`. If `from` is a directory, it recursively copies the whole tree; if it is a file, it performs a single file copy. + +**Parameters**: + +- `from`: Source path. +- `to`: Destination path. + +**Returns**: `Ok(())` or `Err(io::Error)`. + +**Implementation**: + +```rust +if self.is_dir(from) { + self.copy_dir_all(from, to) +} else { + self.copy_file(from, to).map(|_| ()) +} +``` + +**Note**: Does not create parent directories of `to` for file copies (unless `copy_file` implementation does; the default `RealFs::copy_file` does not). For directories, `copy_dir_all` does create `to` and parents as needed. + +**Example**: + +```rust +fs.copy(&src_file, &dst_file)?; +fs.copy(&src_dir, &dst_dir)?; +``` + +#### `rename_or_copy` + +```rust +fn rename_or_copy(&self, from: &Path, to: &Path) -> io::Result<()> +``` + +**Purpose**: Attempts to rename `from` to `to`. If the rename fails with `ErrorKind::CrossesDevices` (i.e., source and destination are on different filesystems), it falls back to copying the directory tree and then removing the source. + +**Parameters**: + +- `from`: Source path. +- `to`: Destination path. + +**Returns**: `Ok(())` or `Err(io::Error)`. + +**Behavior**: + +1. Try `rename(from, to)`. +2. If success, return `Ok(())`. +3. If error kind is `CrossesDevices`: + - `copy_dir_all(from, to)` to copy contents. + - `remove_dir_all(from)` to delete source. + - Return `Ok(())`. +4. Otherwise, return the original error. + +**Note**: The fallback only works for directories (as the code uses `copy_dir_all` and `remove_dir_all`). For a file across devices, this will likely fail. This method is useful for moving directories across mount points. + +**Example**: + +```rust +fs.rename_or_copy(&src_dir, &dst_dir)?; +``` + +--- + +## Struct `RealFs` + +`RealFs` is a zero‑sized struct that implements `FileSystem` by delegating directly to the operating system’s filesystem APIs. + +```rust +#[derive(Debug, Default, Clone, Copy)] +pub struct RealFs; +``` + +It has no fields and can be instantiated with `RealFs` or `RealFs::default()`. + +### Implementation Details + +`RealFs` uses: + +- `std::fs` for most operations. +- `walkdir::WalkDir` for `walk_dir`. +- The `tracing::instrument` attribute is applied to most methods for logging (though `tracing` is not enabled by default; it can be used with a subscriber). + +All methods follow the behavior described in the trait definitions. The `write` method creates parent directories before writing, and `atomic_write` uses a temporary `.tmp` file. + +### Example Usage + +```rust +use librawssg_fs::{FileSystem, RealFs}; +use std::path::Path; + +let fs = RealFs; + +// Write a file +fs.write(Path::new("output/file.txt"), b"Hello")?; + +// Read it back +let content = fs.read_to_string(Path::new("output/file.txt"))?; +assert_eq!(content, "Hello"); + +// Create directory +fs.create_dir_all(Path::new("output/sub"))?; + +// Copy directory +fs.copy_dir_all(Path::new("output"), Path::new("backup"))?; +``` + +--- + +## Error Handling + +All methods that can fail return `io::Result` (i.e., `Result`). This is the standard error type from the standard library, so no custom error enum is defined in this crate. Consumers can inspect the error kind (e.g., `ErrorKind::NotFound`, `PermissionDenied`, `CrossesDevices`) to handle specific failures. + +The provided security methods (`canonicalize_or_join` and `safe_join`) return `io::Error` with `ErrorKind::PermissionDenied` when path traversal is detected, along with the message `"path traversal detected"`. + +--- + +## Implementing a Custom `FileSystem` + +To create a mock filesystem or an alternative backend, implement the `FileSystem` trait. You must provide implementations for all 24 required methods. The provided methods can be left as default unless you need custom behavior. + +**Example of a minimal mock** (from tests, adapted): + +```rust +use librawssg_fs::FileSystem; +use std::io; +use std::path::{Path, PathBuf}; + +struct DummyFs; + +impl FileSystem for DummyFs { + fn read_to_string(&self, _path: &Path) -> io::Result { + Err(io::Error::other("not implemented")) + } + // ... implement all other required methods similarly + // (returning Err or trivial values) +} +``` + +Because `FileSystem` is `Send + Sync`, your mock must also be thread‑safe. In practice, you can use `Arc` or interior mutability if state is needed. + +--- + +## Security Considerations + +The library includes two methods specifically designed to prevent path traversal attacks: + +- **`canonicalize_or_join`**: Normalizes `..` and `.` and ensures the final path does not go above the base (in terms of lexical components). However, it may still follow symlinks that point outside the base if the path exists. +- **`safe_join`**: Combines `canonicalize_or_join` with a `starts_with` check on the canonical base, providing a stronger guarantee that the result is contained within the base directory. + +**Recommendation**: Always use `safe_join` when constructing output paths from untrusted input (e.g., user‑supplied relative URLs). Avoid using `join` directly followed by canonicalization without containment checks. + +--- + +## Testing Suite Overview + +The test file `tests/filesystem.rs` contains comprehensive tests for `RealFs` and the provided methods. It uses `tempfile::TempDir` to create isolated temporary directories. The tests cover: + +- Basic read/write operations (string and bytes) +- Creating directories and files +- Error cases for non‑existent paths +- `copy_file`, `copy_dir_all`, `rename`, `remove_file`, `remove_dir_all` +- `atomic_write` (including nested paths and overwriting) +- `touch` (creating new and preserving existing content) +- `walk_dir` (recursive collection) +- `canonicalize_or_join` (existing and missing paths) +- `safe_join` (rejecting traversal, allowing dot segments inside) +- Metadata and permissions +- Symlink and hard link operations (Unix only) + +All tests can be run with `cargo test`. + +--- + +## Complete Code Examples from Tests + +Below are selected examples from the test suite that illustrate common usage patterns. They can be copied and adapted. + +### Writing and Reading a String + +```rust +use librawssg_fs::{FileSystem, RealFs}; +use std::path::Path; +use tempfile::TempDir; + +let tmp = TempDir::new().unwrap(); +let fs = RealFs; +let file_path = tmp.path().join("hello.txt"); + +fs.write(&file_path, b"world").unwrap(); +let content = fs.read_to_string(&file_path).unwrap(); +assert_eq!(content, "world"); +``` + +### Atomic Write Overwriting + +```rust +let file = tmp.path().join("atomic.txt"); +fs.atomic_write(&file, b"first").unwrap(); +fs.atomic_write(&file, b"second").unwrap(); +let content = fs.read_to_string(&file).unwrap(); +assert_eq!(content, "second"); +``` + +### Safe Join Blocking Traversal + +```rust +let base = tmp.path().join("base"); +fs.create_dir_all(&base).unwrap(); + +let safe = fs.safe_join(&base, Path::new("inside.txt")).unwrap(); +assert!(safe.starts_with(&base)); + +let traversal = Path::new("../escape.txt"); +assert!(fs.safe_join(&base, traversal).is_err()); +``` + +### Copying a Directory Recursively + +```rust +let src = tmp.path().join("src_dir"); +let dst = tmp.path().join("dst_dir"); +fs.create_dir_all(&src.join("nested")).unwrap(); +fs.write(&src.join("file1.txt"), b"one").unwrap(); +fs.write(&src.join("nested").join("file2.txt"), b"two").unwrap(); + +fs.copy_dir_all(&src, &dst).unwrap(); +assert!(fs.exists(&dst.join("file1.txt"))); +assert!(fs.exists(&dst.join("nested").join("file2.txt"))); +``` + +### Using `walk_dir` to Gather All Files + +```rust +let root = tmp.path().join("root"); +fs.create_dir_all(&root.join("sub")).unwrap(); +fs.write(&root.join("root.txt"), b"root").unwrap(); +fs.write(&root.join("sub").join("sub.txt"), b"sub").unwrap(); + +let files = fs.walk_dir(&root).unwrap(); +assert_eq!(files.len(), 2); +``` + +--- + +## Summary + +`librawssg_fs` provides a robust, thread‑safe filesystem abstraction with built‑in path traversal protection and convenience methods for atomic operations and cross‑device moves. The `RealFs` implementation is ready to use, and the trait enables easy mocking for unit tests. The extensive test suite validates all features and serves as living documentation. + +For any additional details, refer to the source code and inline comments. diff --git a/librawssg_handler/README.md b/librawssg_handler/README.md index 581174e..f61dde5 100644 --- a/librawssg_handler/README.md +++ b/librawssg_handler/README.md @@ -1,4 +1,775 @@ # librawssg_handler -Content processing contracts for librawssg. -Defines the `Processor` trait and related types for turning source files into documents. +**Version**: 1.0.0 (implied) +**Crate name**: `librawssg_handler` +**Description**: Core data structures and traits for building a static site generator (SSG) handler. Provides `Document`, `Metadata`, and a `Processor` trait for processing content files. + +--- + +## Table of Contents + +1. [Overview](#overview) +2. [Modules](#modules) +3. [Struct `Document`](#struct-document) + - [Fields](#document-fields) + - [Constructor `new()`](#document-new) + - [Method `relative_url()`](#document-relative_url) + - [Method `add_taxonomy()`](#document-add_taxonomy) + - [Method `depth()`](#document-depth) + - [Method `with_list_items()`](#document-with_list_items) +4. [Struct `Metadata`](#struct-metadata) + - [Fields](#metadata-fields) + - [Constructor `new()`](#metadata-new) + - [Method `is_draft()`](#metadata-is_draft) + - [Method `insert_extra()`](#metadata-insert_extra) + - [Method `get_extra()`](#metadata-get_extra) + - [Serialization & Deserialization](#metadata-serialization) + - [Default Implementation](#metadata-default) +5. [Trait `Processor`](#trait-processor) + - [Required Methods](#processor-required-methods) + - [Provided Methods](#processor-provided-methods) + - [Implementing the Trait](#processor-implementation) +6. [Error Handling](#error-handling) +7. [External Traits & Types](#external-traits-and-types) +8. [Examples from Tests](#examples-from-tests) +9. [Validation Rules Summary](#validation-rules-summary) +10. [Testing Suite Overview](#testing-suite-overview) + +--- + +## Overview + +`librawssg_handler` is the core library for a static site generator. It defines the essential data structures used to represent a processed document (`Document`) and its front matter (`Metadata`). Additionally, it provides a pluggable `Processor` trait that allows different file types to be processed into `Document` instances. + +The crate is intended to be used in conjunction with: + +- `librawssg_error`: Provides the `Error` and `Result` types for consistent error handling. +- `librawssg_fs`: Defines a `FileSystem` trait abstracting file I/O operations (used by `Processor`). + +--- + +## Modules + +The library is organized into three public modules: + +- **`document`** – Contains the `Document` struct. +- **`metadata`** – Contains the `Metadata` struct. +- **`processor`** – Contains the `Processor` trait. + +All public types are re‑exported at the crate root for convenience: + +```rust +pub use document::Document; +pub use metadata::Metadata; +pub use processor::Processor; +``` + +--- + +## Struct `Document` + +Represents a fully processed content item ready for rendering or further processing. + +```rust +#[derive(Debug, Clone, PartialEq, Serialize)] +#[non_exhaustive] +pub struct Document { + pub metadata: Metadata, + pub body: String, + pub url: String, + pub output_path: PathBuf, + pub source_path: PathBuf, + pub depth: usize, + pub content_type: String, + pub is_list: bool, + pub list_items: Option>, + pub taxonomies: HashMap>, +} +``` + +### Document Fields + +| Field | Type | Description | +| -------------- | ------------------------------ | -------------------------------------------------------------------------------------------------- | +| `metadata` | `Metadata` | Front matter metadata associated with the document. | +| `body` | `String` | The processed content body (e.g., rendered HTML, Markdown text, etc.). | +| `url` | `String` | The relative URL where the document will be accessible (e.g., `"blog/my-post.html"`). | +| `output_path` | `PathBuf` | Filesystem path where the final output file should be written (e.g., `"blog/my-post/index.html"`). | +| `source_path` | `PathBuf` | Path to the original source file (e.g., `"content/blog/my-post.md"`). | +| `depth` | `usize` | Depth of the document in the site hierarchy (0 for top‑level). Used for sorting or navigation. | +| `content_type` | `String` | Identifier for the kind of content (e.g., `"blog"`, `"page"`, `"article"`). | +| `is_list` | `bool` | Indicates whether this document represents a list of other documents (e.g., an index page). | +| `list_items` | `Option>` | If `is_list` is true, may contain the child documents. `None` otherwise or when not set. | +| `taxonomies` | `HashMap>` | A map of taxonomy names (e.g., `"categories"`, `"tags"`) to lists of terms. | + +> **Note:** The `#[non_exhaustive]` attribute means that external crates cannot exhaustively match on `Document` or construct it with a struct literal; they must use the provided constructor or update syntax. This allows adding fields in the future without breaking downstream code. + +### Document::new + +```rust +pub fn new( + metadata: Metadata, + body: impl Into, + url: impl Into, + output_path: impl Into, + source_path: impl Into, + depth: usize, + content_type: impl Into, + is_list: bool, +) -> Result +``` + +**Purpose**: Creates a new `Document` after validating the provided arguments. + +**Parameters**: + +- `metadata`: A fully constructed `Metadata` instance. +- `body`: The content body (accepts any type convertible to `String`). +- `url`: The desired relative URL (must not be empty or whitespace only). +- `output_path`: The target output path (must not be empty). +- `source_path`: The source file path (must have a file name component). +- `depth`: The hierarchy depth (must be ≤ 1000). +- `content_type`: A string identifying the content type (e.g., `"blog"`, `"page"`). +- `is_list`: Boolean indicating whether this document is a list container. + +**Returns**: + +- `Ok(Document)` on success. +- `Err(librawssg_error::Error::Validation(message))` if any validation rule fails. + +**Validation Rules**: + +1. `url` must not be empty or contain only whitespace. +2. `output_path` must not be empty (as an OS string). +3. `source_path` must have a file name (i.e., its last component is not `..` or empty). +4. `depth` must not exceed 1000. + +**Example**: + +```rust +use librawssg_handler::{Document, Metadata}; +use std::path::PathBuf; + +let metadata = Metadata::new("My Post", "A short description")?; + +let document = Document::new( + metadata, + "

Hello

World

", + "blog/my-post.html", + "blog/my-post/index.html", + "content/blog/my-post.md", + 1, + "blog", + false, +)?; +``` + +--- + +### Document::relative_url + +```rust +#[must_use] +pub fn relative_url(&self) -> &str +``` + +**Purpose**: Returns the relative URL of the document. + +**Returns**: A string slice referencing the `url` field. + +**Example**: + +```rust +let doc = /* ... */; +assert_eq!(doc.relative_url(), "blog/my-post.html"); +``` + +--- + +### Document::add_taxonomy + +```rust +pub fn add_taxonomy(&mut self, name: impl Into, items: Vec) +``` + +**Purpose**: Inserts or replaces a taxonomy entry in the document’s `taxonomies` map. + +**Parameters**: + +- `name`: Taxonomy name (converted into `String`). +- `items`: A vector of string terms belonging to that taxonomy. + +**Behavior**: If a taxonomy with the same name already exists, its value is replaced. + +**Example**: + +```rust +let mut doc = /* ... */; +doc.add_taxonomy("categories", vec!["rust".to_string(), "ssg".to_string()]); +assert_eq!(doc.taxonomies["categories"], vec!["rust", "ssg"]); +``` + +--- + +### Document::depth + +```rust +#[must_use] +pub const fn depth(&self) -> usize +``` + +**Purpose**: Returns the `depth` field. + +**Returns**: The document’s depth as a `usize`. + +**Example**: + +```rust +let doc = /* ... */; +assert_eq!(doc.depth(), 1); +``` + +--- + +### Document::with_list_items + +```rust +#[must_use] +pub fn with_list_items(mut self, items: Vec) -> Self +``` + +**Purpose**: Consumes the document, sets its `list_items` field to `Some(items)`, and returns the modified document. + +**Parameters**: + +- `items`: A vector of `Document` instances that are children of this list document. + +**Returns**: The same document with `list_items` set. + +**Example**: + +```rust +let parent = Document::new(/* ... */)?; +let child1 = Document::new(/* ... */)?; +let child2 = Document::new(/* ... */)?; +let list_doc = parent.with_list_items(vec![child1, child2]); +assert!(list_doc.list_items.is_some()); +``` + +--- + +## Struct `Metadata` + +Represents front matter (metadata) for a document. The struct is serializable and deserializable, making it suitable for parsing from formats like YAML or TOML front matter. + +```rust +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[non_exhaustive] +pub struct Metadata { + pub title: String, + pub description: String, + pub author: Option, + pub repo_url: Option, + pub license: Option, + pub date: Option, + pub updated: Option, + pub tags: Vec, + pub draft: bool, + pub extra: HashMap, +} +``` + +### Metadata Fields + +| Field | Type | Description | +| ------------- | ------------------------------------ | ---------------------------------------------------------------------------- | +| `title` | `String` | The document title (required, cannot be empty). | +| `description` | `String` | A short description of the content. | +| `author` | `Option` | The author’s name, if known. | +| `repo_url` | `Option` | URL to the source repository. | +| `license` | `Option` | License identifier (e.g., `"MIT"`, `"Apache-2.0"`). | +| `date` | `Option` | Publication date (ISO 8601 date, e.g., `2026-09-08`). | +| `updated` | `Option` | Last modification date. | +| `tags` | `Vec` | List of tags (keywords) associated with the content. | +| `draft` | `bool` | If true, the document is considered a draft and may be excluded from builds. | +| `extra` | `HashMap` | Arbitrary extra key–value pairs for custom metadata. | + +> **Note:** `#[non_exhaustive]` prevents exhaustive struct literals outside the crate; use the provided constructors or update syntax. + +### Metadata::new + +```rust +pub fn new( + title: impl Into, + description: impl Into, +) -> librawssg_error::Result +``` + +**Purpose**: Creates a `Metadata` instance with a required `title` and `description`. All other fields are set to their default values. + +**Parameters**: + +- `title`: The title (must not be empty or whitespace only). +- `description`: A description string. + +**Returns**: + +- `Ok(Metadata)` on success. +- `Err(librawssg_error::Error::Validation("metadata title cannot be empty"))` if the title is empty or whitespace. + +**Example**: + +```rust +use librawssg_handler::Metadata; + +let meta = Metadata::new("My Title", "My Description")?; +assert_eq!(meta.title, "My Title"); +assert!(!meta.draft); +assert!(meta.tags.is_empty()); +``` + +--- + +### Metadata::is_draft + +```rust +#[must_use] +pub const fn is_draft(&self) -> bool +``` + +**Purpose**: Returns the `draft` field. + +**Returns**: `true` if the document is marked as a draft, otherwise `false`. + +**Example**: + +```rust +let mut meta = Metadata::new("Title", "Desc")?; +assert!(!meta.is_draft()); +meta.draft = true; +assert!(meta.is_draft()); +``` + +--- + +### Metadata::insert_extra + +```rust +pub fn insert_extra(&mut self, key: impl Into, value: impl Into) +``` + +**Purpose**: Inserts or updates an entry in the `extra` map. + +**Parameters**: + +- `key`: The key (converted to `String`). +- `value`: Any type convertible to `serde_json::Value` (e.g., strings, numbers, booleans, arrays, objects, or `serde_json::json!` macro results). + +**Behavior**: If the key already exists, its value is overwritten. + +**Example**: + +```rust +use serde_json::json; + +let mut meta = Metadata::new("Title", "Desc")?; +meta.insert_extra("key", "value"); +meta.insert_extra("number", 42); +meta.insert_extra("flag", true); +meta.insert_extra("nested", json!({"foo": "bar"})); +``` + +--- + +### Metadata::get_extra + +```rust +#[must_use] +pub fn get_extra(&self, key: &str) -> Option<&serde_json::Value> +``` + +**Purpose**: Retrieves a reference to the value stored under `key` in the `extra` map. + +**Parameters**: + +- `key`: The key to look up. + +**Returns**: + +- `Some(&Value)` if the key exists. +- `None` otherwise. + +**Example**: + +```rust +let meta = /* ... */; +if let Some(v) = meta.get_extra("key") { + assert_eq!(v, &json!("value")); +} +``` + +--- + +### Metadata Serialization + +`Metadata` derives both `Serialize` and `Deserialize`, so it can be converted to/from JSON, YAML, etc. This is particularly useful for reading front matter from source files. + +**Serialization Example**: + +```rust +use serde_json; + +let meta = Metadata::new("Hello", "World")?; +let json_str = serde_json::to_string(&meta)?; +// {"title":"Hello","description":"World","author":null,...} +``` + +**Deserialization Example**: + +```rust +let json_str = r#"{ + "title": "Hello", + "description": "World", + "author": "Alice", + "date": "2026-09-08", + "tags": ["rust", "ssg"], + "draft": false, + "extra": {"foo": "bar"} +}"#; + +let meta: Metadata = serde_json::from_str(json_str)?; +assert_eq!(meta.author.as_deref(), Some("Alice")); +``` + +--- + +### Metadata Default + +The `Default` trait is implemented. All fields are set to sensible empty values: + +- `title`: empty string +- `description`: empty string +- `author`, `repo_url`, `license`, `date`, `updated`: `None` +- `tags`: empty vector +- `draft`: `false` +- `extra`: empty `HashMap` + +**Example**: + +```rust +let meta = Metadata::default(); +assert_eq!(meta.title, ""); +assert!(!meta.draft); +assert!(meta.tags.is_empty()); +``` + +--- + +## Trait `Processor` + +The `Processor` trait defines an interface for components that can transform a source file into a `Document`. Multiple processors may be registered and invoked based on their ability to handle a given file. + +```rust +pub trait Processor: Send + Sync { + fn name(&self) -> &str; + + fn priority(&self) -> i32 { + 0 + } + + fn can_process(&self, relative_path: &Path, original_path: &Path) -> bool; + + fn process( + &self, + fs: &dyn FileSystem, + relative_path: &Path, + content_dir: &Path, + ) -> Result>; +} +``` + +### Processor Required Methods + +#### `name()` + +```rust +fn name(&self) -> &str +``` + +**Purpose**: Returns a human‑readable identifier for the processor (e.g., `"markdown"`, `"sass"`). + +#### `can_process()` + +```rust +fn can_process(&self, relative_path: &Path, original_path: &Path) -> bool +``` + +**Purpose**: Determines whether this processor should handle the given file. + +**Parameters**: + +- `relative_path`: The path of the file relative to the content directory. +- `original_path`: The full original path (often the same as `content_dir.join(relative_path)`). + +**Returns**: `true` if the processor can process this file; `false` otherwise. + +**Typical Implementation**: Check file extension or other attributes. + +**Example**: + +```rust +fn can_process(&self, relative_path: &Path, _original_path: &Path) -> bool { + relative_path.extension().and_then(|e| e.to_str()) == Some("md") +} +``` + +#### `process()` + +```rust +fn process( + &self, + fs: &dyn FileSystem, + relative_path: &Path, + content_dir: &Path, +) -> Result> +``` + +**Purpose**: Reads the source file, processes it, and returns an optional `Document`. + +**Parameters**: + +- `fs`: A reference to a `FileSystem` implementation for performing I/O operations. +- `relative_path`: Path of the source file relative to the content directory. +- `content_dir`: The root directory containing all source content. + +**Returns**: + +- `Ok(Some(document))` if processing succeeded and produced a document. +- `Ok(None)` if the processor decides not to produce a document (e.g., the file is ignored). +- `Err(librawssg_error::Error)` if an error occurred during processing. + +**Note**: The `FileSystem` trait is defined in the `librawssg_fs` crate. It abstracts many common file operations, allowing processors to be tested with mock filesystems. + +--- + +### Processor Provided Methods + +#### `priority()` + +```rust +fn priority(&self) -> i32 { + 0 +} +``` + +**Purpose**: Returns the priority of this processor. Processors with higher priority are invoked before those with lower priority. The default is `0`. + +**Usage**: Allows ordering of processors when multiple might handle the same file. + +**Example**: + +```rust +fn priority(&self) -> i32 { + 10 +} +``` + +--- + +### Processor Implementation + +To create a custom processor, implement the `Processor` trait. Below is a complete example based on the test suite: + +```rust +use librawssg_error::Result; +use librawssg_fs::FileSystem; +use librawssg_handler::{Document, Metadata, Processor}; +use std::path::Path; + +struct MarkdownProcessor; + +impl Processor for MarkdownProcessor { + fn name(&self) -> &str { + "markdown" + } + + fn can_process(&self, relative_path: &Path, _original_path: &Path) -> bool { + relative_path.extension().and_then(|e| e.to_str()) == Some("md") + } + + fn process( + &self, + fs: &dyn FileSystem, + relative_path: &Path, + content_dir: &Path, + ) -> Result> { + // Read the source file + let source_path = content_dir.join(relative_path); + let content = fs.read_to_string(&source_path)?; + + // Parse front matter and body (simplified here) + let metadata = Metadata::new("Untitled", "")?; + let body = content; // In reality, you would render Markdown to HTML + + // Construct Document + let doc = Document::new( + metadata, + body, + relative_path.with_extension("html").to_string_lossy().to_string(), + relative_path.with_extension("index.html").to_string_lossy().into(), + source_path, + 1, + "page", + false, + )?; + + Ok(Some(doc)) + } +} +``` + +--- + +## Error Handling + +The library uses the `librawssg_error::Error` enum for all fallible operations. Relevant variants: + +- `Error::Validation(String)` – Used when a validation rule fails (e.g., empty URL, invalid depth). +- `Error::Processor(String)` – Used by processors to signal processing errors. +- Other variants may exist but are not directly used in this crate. + +The return type `Result` is an alias for `std::result::Result`. + +**Example of Validation Error**: + +```rust +let result = Document::new(meta, "body", "", "out", "src.md", 0, "page", false); +assert!(matches!( + result, + Err(librawssg_error::Error::Validation(ref msg)) if msg == "document url cannot be empty" +)); +``` + +--- + +## External Traits & Types + +### `FileSystem` Trait + +The `Processor::process` method takes a `&dyn FileSystem`. This trait is defined in `librawssg_fs` and provides a comprehensive set of file operations (read, write, create directories, walk, etc.). A typical implementation wraps `std::fs`, but for testing, mock implementations are often used. + +A minimal `FileSystem` implementation (used in tests) might implement all methods returning `io::Error::other("not implemented")` for those not needed. + +--- + +## Examples from Tests + +The test suite contains numerous examples that demonstrate correct usage and error conditions. Below are selected examples. + +### Creating a Valid Document + +```rust +use librawssg_handler::{Document, Metadata}; + +let metadata = Metadata::new("Title", "Description")?; +let doc = Document::new( + metadata, + "

Body

", + "blog/my-post.html", + "blog/my-post/index.html", + "content/blog/my-post.md", + 1, + "blog", + false, +)?; + +assert_eq!(doc.metadata.title, "Title"); +assert_eq!(doc.body, "

Body

"); +``` + +### Handling Invalid URL + +```rust +let result = Document::new( + Metadata::new("Title", "Desc")?, + "body", + "", // empty URL + "out", + "src.md", + 0, + "page", + false, +); +assert!(result.is_err()); +``` + +### Adding Taxonomy Terms + +```rust +let mut doc = /* ... */; +doc.add_taxonomy("categories", vec!["rust".to_string(), "ssg".to_string()]); +``` + +### Working with `extra` Metadata + +```rust +use serde_json::json; + +let mut meta = Metadata::new("Title", "Desc")?; +meta.insert_extra("key", "value"); +if let Some(v) = meta.get_extra("key") { + assert_eq!(v, &json!("value")); +} +``` + +### Processor Mock Example + +```rust +struct MockProcessor { /* ... */ } + +impl Processor for MockProcessor { + fn name(&self) -> &str { "mock" } + fn can_process(&self, _: &Path, _: &Path) -> bool { true } + fn process(&self, _fs: &dyn FileSystem, _rel: &Path, _cd: &Path) -> Result> { + Ok(Some(document)) + } +} +``` + +--- + +## Validation Rules Summary + +### `Metadata::new` + +- `title` must not be empty or contain only whitespace. + +### `Document::new` + +1. `url` must not be empty or contain only whitespace. +2. `output_path` must not be empty (as an OS string). +3. `source_path` must have a file name component. +4. `depth` must be ≤ 1000. + +Any violation results in an `Err(Error::Validation(...))`. + +--- + +## Testing Suite Overview + +The tests are organized into four files: + +1. **`document_tests.rs`** – Validates `Document` construction, field defaults, error cases, and methods (`relative_url`, `add_taxonomy`, `depth`, `with_list_items`). +2. **`metadata_tests.rs`** – Tests `Metadata` creation, validation, `is_draft`, `insert_extra`/`get_extra`, default values, and JSON serialization/deserialization round‑trip. +3. **`processor_tests.rs`** – Tests the `Processor` trait using mock implementations: `name`, `priority`, `can_process`, `process` returning `Some`, `None`, and error. +4. **`unit_tests.rs`** – Verifies that all public items are re‑exported at the crate root. + +All tests can serve as executable examples of the API usage. + +--- + +## Conclusion + +This documentation covers the public API of `librawssg_handler` in detail. The crate provides a flexible foundation for building static site generators by separating metadata handling (`Metadata`), document representation (`Document`), and pluggable processing logic (`Processor`). The validation rules ensure data integrity, and the use of traits like `FileSystem` enables testability. + +For further details, refer to the source code and the accompanying test suite. diff --git a/librawssg_templates/README.md b/librawssg_templates/README.md index 81b90c1..f21e43c 100644 --- a/librawssg_templates/README.md +++ b/librawssg_templates/README.md @@ -1,4 +1,656 @@ # librawssg_templates -Template rendering contracts for librawssg. -Defines the `Renderer` and `RenderContext` traits for pluggable template engines. +**Version**: 1.0.0 (implied) +**Crate name**: `librawssg_templates` +**Description**: Defines rendering abstractions for static site generators. Provides the `Renderer` and `RenderContext` traits, and an optional `TeraRenderer` implementation (when the `tera` feature is enabled) that integrates the Tera template engine. + +--- + +## Table of Contents + +1. [Overview](#overview) +2. [Modules and Features](#modules-and-features) +3. [Core Traits](#core-traits) + - [`RenderContext`](#trait-rendercontext) + - [Required Methods](#rendercontext-required-methods) + - [`Renderer`](#trait-renderer) + - [Required Method](#renderer-required-method) +4. [`TeraRenderer`](#struct-terarenderer) + - [Struct Definition](#struct-definition) + - [Constructor `new()`](#terarenderer-new) + - [Method `add_raw_template()`](#terarenderer-add_raw_template) + - [Method `add_template_file()`](#terarenderer-add_template_file) + - [Method `add_template_files_from_dir()`](#terarenderer-add_template_files_from_dir) + - [Method `load_templates_dir()`](#terarenderer-load_templates_dir) + - [Method `enable_autoescape()`](#terarenderer-enable_autoescape) + - [Method `render_str()`](#terarenderer-render_str) + - [Method `as_tera()`](#terarenderer-as_tera) + - [Method `as_tera_mut()`](#terarenderer-as_tera_mut) + - [Trait Implementations](#terarenderer-trait-implementations) + - [`Default`](#terarenderer-default) + - [`Renderer` for `TeraRenderer`](#terarenderer-renderer-impl) + - [`RenderContext` for `tera::Context`](#rendercontext-for-teracontext) +5. [Internal Helper Function](#internal-helper-function) +6. [Error Handling](#error-handling) +7. [Feature Gating](#feature-gating) +8. [Examples from Tests](#examples-from-tests) + - [Basic Rendering](#basic-rendering) + - [Loops, Filters, Conditions](#loops-filters-conditions) + - [File Loading](#file-loading) + - [Autoescaping](#autoescaping) + - [Template Inheritance and Macros](#template-inheritance-and-macros) + - [Context Downcasting](#context-downcasting) +9. [Testing Suite Overview](#testing-suite-overview) +10. [Conclusion](#conclusion) + +--- + +## Overview + +`librawssg_templates` provides a pluggable template rendering system. It abstracts the rendering process with two traits: + +- **`Renderer`** – Defines the `render` method that takes a template name and a context, returning a rendered string. +- **`RenderContext`** – An object‑safe trait that allows type erasure for context objects; specifically, it provides `as_any` and `as_mut_any` to downcast to concrete context types. + +The crate optionally includes a **`TeraRenderer`** implementation for the [Tera](https://tera.netlify.app/) template engine. This implementation is gated behind the `tera` feature flag. + +--- + +## Modules and Features + +The crate root (`lib.rs`) declares: + +```rust +pub mod renderer; +#[cfg(feature = "tera")] +pub mod tera_renderer; + +pub use renderer::{RenderContext, Renderer}; +#[cfg(feature = "tera")] +pub use tera_renderer::TeraRenderer; +``` + +- **`renderer`** – Always available; contains the two core traits. +- **`tera_renderer`** – Only compiled when the `tera` feature is enabled; contains `TeraRenderer`. +- Re‑exports at the crate root make the traits and `TeraRenderer` easy to import. + +The `tera` feature must be explicitly enabled in `Cargo.toml` to use `TeraRenderer`. Without it, the crate still provides the traits for custom renderer implementations. + +--- + +## Core Traits + +### Trait `RenderContext` + +```rust +pub trait RenderContext: Send + Sync { + fn as_any(&self) -> &dyn Any; + fn as_mut_any(&mut self) -> &mut dyn Any; +} +``` + +**Purpose**: Allows arbitrary context types to be passed to a `Renderer` as a trait object. The renderer can then downcast the `&dyn RenderContext` to the concrete context type it expects (e.g., `tera::Context`). This provides flexibility without requiring all renderers to accept a single concrete type. + +**Requirements**: + +- Implementors must be `Send + Sync` (thread‑safe). +- Must provide `as_any` and `as_mut_any` to expose the underlying `Any` reference. + +**Typical Implementation**: + +For any type `T`, you can implement: + +```rust +impl RenderContext for T { + fn as_any(&self) -> &dyn Any { + self + } + fn as_mut_any(&mut self) -> &mut dyn Any { + self + } +} +``` + +**Example** (from tests): + +```rust +struct MockContext; + +impl RenderContext for MockContext { + fn as_any(&self) -> &dyn Any { + self + } + fn as_mut_any(&mut self) -> &mut dyn Any { + self + } +} +``` + +--- + +### Trait `Renderer` + +```rust +pub trait Renderer: Send + Sync { + fn render(&self, template_name: &str, context: &dyn RenderContext) -> Result; +} +``` + +**Purpose**: Defines the rendering interface. A `Renderer` takes a template identifier (name) and a context object, and returns the rendered output as a `String`. + +**Parameters**: + +- `template_name`: A string identifying the template (e.g., `"index.html"`, `"blog/post.tera"`). +- `context`: A reference to an object implementing `RenderContext`. The renderer is expected to downcast this to the appropriate concrete context type. + +**Returns**: + +- `Ok(String)` containing the rendered output. +- `Err(librawssg_error::Error)` if rendering fails (e.g., template not found, invalid syntax, missing variable, or context type mismatch). + +**Note**: Implementors must be `Send + Sync`. + +**Example** (custom mock renderer): + +```rust +struct MockRenderer { output: String } + +impl Renderer for MockRenderer { + fn render(&self, _template_name: &str, _context: &dyn RenderContext) -> Result { + Ok(self.output.clone()) + } +} +``` + +--- + +## Struct `TeraRenderer` + +`TeraRenderer` is a wrapper around `tera::Tera`, providing convenient methods to load templates and render them using the `Renderer` trait. It is only available when the `tera` feature is enabled. + +### Struct Definition + +```rust +#[derive(Debug)] +pub struct TeraRenderer { + tera: tera::Tera, +} +``` + +The `tera` field is private; access is provided via `as_tera` and `as_tera_mut`. + +--- + +### `TeraRenderer::new` + +```rust +#[must_use] +pub fn new() -> Self +``` + +**Purpose**: Creates a new `TeraRenderer` with an empty Tera instance (`tera::Tera::default()`). + +**Returns**: A new `TeraRenderer`. + +**Example**: + +```rust +let renderer = TeraRenderer::new(); +``` + +--- + +### `TeraRenderer::add_raw_template` + +```rust +pub fn add_raw_template(&mut self, name: &str, content: &str) -> Result<()> +``` + +**Purpose**: Adds a template from a string, associating it with the given `name`. The template is parsed and stored internally. + +**Parameters**: + +- `name`: The template name (e.g., `"index.html"`, `"partial"`). +- `content`: The raw template source (e.g., `"Hello {{ name }}"`). + +**Returns**: + +- `Ok(())` if the template was added successfully. +- `Err(Error::Render)` if the template syntax is invalid (the underlying `tera::Error` is converted to a string and wrapped). + +**Behavior**: Calls `tera.add_raw_template(name, content)`. Template names must be unique; adding a duplicate name will replace the existing template. + +**Example**: + +```rust +renderer.add_raw_template("hello", "Hello {{ name }}")?; +``` + +--- + +### `TeraRenderer::add_template_file` + +```rust +pub fn add_template_file(&mut self, path: &Path) -> Result<()> +``` + +**Purpose**: Reads a template file from disk and adds it to the renderer. The template name is derived from the file name (including extension). + +**Parameters**: + +- `path`: Path to the template file. + +**Returns**: + +- `Ok(())` on success. +- `Err(Error::Io)` if the file cannot be read (wrapped as `Error::Io` with a message containing the original I/O error). +- `Err(Error::Render)` if the file name is not valid UTF‑8 or missing (unlikely). + +**Behavior**: + +1. Reads the file content using `std::fs::read_to_string`. On failure, maps to `Error::Io(std::io::Error::other(format!("{e}")))`. +2. Extracts the file name (the last component of the path) and converts it to a `&str`. If missing or non‑UTF‑8, returns `Error::Render("template file has no valid file name")`. +3. Calls `add_raw_template` with that file name as the template name. + +**Example**: + +```rust +renderer.add_template_file(Path::new("templates/index.html"))?; +// Template is registered as "index.html" +``` + +--- + +### `TeraRenderer::add_template_files_from_dir` + +```rust +pub fn add_template_files_from_dir(&mut self, dir: &Path) -> Result<()> +``` + +**Purpose**: Adds all files directly inside a directory as templates. This method is **not recursive**; it only considers files in the immediate directory. + +**Parameters**: + +- `dir`: Directory containing template files. + +**Returns**: + +- `Ok(())` if at least the directory is readable and processing completes. +- `Err(Error::Io)` on directory read failure. +- `Err(Error::Render)` if any individual file cannot be added. + +**Behavior**: + +1. Reads the directory entries using `std::fs::read_dir`. +2. For each entry: + - If the entry is a file, calls `add_template_file` with its path. + - If that returns an error, the method immediately returns the error (fail‑fast). +3. Non‑file entries (subdirectories, symlinks) are ignored. + +**Note**: Template names are the file names (including extensions). + +**Example**: + +```rust +renderer.add_template_files_from_dir(Path::new("templates/"))?; +// Adds all files in templates/ as templates with names like "base.tera", "index.html", etc. +``` + +--- + +### `TeraRenderer::load_templates_dir` + +```rust +pub fn load_templates_dir(&mut self, dir: &Path) -> Result<()> +``` + +**Purpose**: Recursively loads all template files from a directory tree. Template names are derived from the relative path (using forward slashes as separators), allowing nested template structures (e.g., `"sub/nested.tera"`). + +**Parameters**: + +- `dir`: Root directory to traverse. + +**Returns**: + +- `Ok(())` on success. +- `Err(Error::Io)` for filesystem errors during traversal or reading. +- `Err(Error::Render)` for invalid UTF‑8 paths or component issues. + +**Behavior**: + +1. Canonicalizes the input directory (using `dir.canonicalize()`) to ensure a stable base. +2. Walks the directory recursively using `walkdir::WalkDir`. Only files are processed. +3. For each file: + - Computes its path relative to the canonical directory using `strip_prefix`. + - Converts the relative path to a template name using the internal helper `rel_path_to_template_name` (which joins components with `/` and rejects non‑normal components). + - Reads the file content. + - Calls `add_raw_template` with the computed template name and content. + +**Note**: This method is similar to `add_template_files_from_dir` but recursive and with namespace‑like template names. + +**Example**: + +```rust +renderer.load_templates_dir(Path::new("templates"))?; +// If templates contains sub/child.tera, it can be referenced as "sub/child.tera" +``` + +--- + +### `TeraRenderer::enable_autoescape` + +```rust +pub fn enable_autoescape(&mut self) +``` + +**Purpose**: Turns on automatic escaping for HTML, HTM, and XML file extensions. This is a convenience method that calls `tera.autoescape_on(vec!["html", "htm", "xml"])`. + +**Parameters**: None. + +**Returns**: Nothing. + +**Behavior**: After calling this, templates with names ending in `.html`, `.htm`, or `.xml` will automatically escape variable output (HTML escaping). For other file extensions, autoescaping remains off. + +**Example**: + +```rust +renderer.enable_autoescape(); +renderer.add_raw_template("page.html", "{{ user_input }}")?; +// Rendering will escape HTML special characters in user_input +``` + +--- + +### `TeraRenderer::render_str` + +```rust +pub fn render_str(&self, template_str: &str, context: &dyn RenderContext) -> Result +``` + +**Purpose**: Renders a one‑off template string without registering it. This is useful for small, inline templates. + +**Parameters**: + +- `template_str`: The template source as a string. +- `context`: A `&dyn RenderContext` that must downcast to `tera::Context`. + +**Returns**: + +- `Ok(String)` with rendered output. +- `Err(Error::Render)` if the context is not a `tera::Context` or if rendering fails. + +**Behavior**: + +1. Attempts to downcast `context.as_any()` to `&tera::Context`. If the cast fails, returns `Error::Render("invalid context type for Tera")`. +2. Calls `tera::Tera::one_off(template_str, tera_ctx, true)`. The third argument `true` enables autoescaping for the one‑off render **by default**, regardless of the renderer's autoescape settings. + +**Note**: `render_str` always autoescapes (the `true` parameter forces autoescape). This may differ from `render` which respects the renderer's autoescape configuration. + +**Example**: + +```rust +let renderer = TeraRenderer::new(); +let mut ctx = tera::Context::new(); +ctx.insert("content", "bold"); +let output = renderer.render_str("{{ content }}", &ctx)?; +// Output is escaped: "<b>bold</b>" +``` + +--- + +### `TeraRenderer::as_tera` + +```rust +#[must_use] +pub const fn as_tera(&self) -> &tera::Tera +``` + +**Purpose**: Returns an immutable reference to the underlying `tera::Tera` instance. This allows advanced operations not directly exposed by `TeraRenderer`. + +**Returns**: `&tera::Tera`. + +**Example**: + +```rust +let tera = renderer.as_tera(); +// e.g., inspect registered templates +``` + +--- + +### `TeraRenderer::as_tera_mut` + +```rust +#[must_use] +pub const fn as_tera_mut(&mut self) -> &mut tera::Tera +``` + +**Purpose**: Returns a mutable reference to the underlying `tera::Tera` instance. Useful for direct manipulation, such as adding templates or changing settings. + +**Returns**: `&mut tera::Tera`. + +**Example**: + +```rust +let tera_mut = renderer.as_tera_mut(); +tera_mut.add_raw_template("direct", "Hello")?; +``` + +--- + +### Trait Implementations + +#### `TeraRenderer::Default` + +```rust +impl Default for TeraRenderer { + fn default() -> Self { + Self::new() + } +} +``` + +Allows creating a `TeraRenderer` with `TeraRenderer::default()`, equivalent to `new()`. + +#### `Renderer` for `TeraRenderer` + +```rust +impl Renderer for TeraRenderer { + fn render(&self, template_name: &str, context: &dyn RenderContext) -> Result { + let tera_ctx = context + .as_any() + .downcast_ref::() + .ok_or_else(|| Error::Render("invalid context type for Tera".into()))?; + self.tera + .render(template_name, tera_ctx) + .map_err(|e| Error::Render(e.to_string())) + } +} +``` + +- **`render` method**: + - Downcasts the context to `tera::Context`. + - Delegates to `tera.render(template_name, tera_ctx)`. + - Maps any error to `Error::Render`. + +#### `RenderContext` for `tera::Context` + +```rust +impl RenderContext for tera::Context { + fn as_any(&self) -> &dyn core::any::Any { + self + } + fn as_mut_any(&mut self) -> &mut dyn core::any::Any { + self + } +} +``` + +This implementation allows `tera::Context` to be used directly as a `RenderContext` when calling `render`. Users typically create a `tera::Context`, populate it, and pass `&ctx` to `render`. + +--- + +## Internal Helper Function + +`rel_path_to_template_name` (private) + +```rust +fn rel_path_to_template_name(rel_path: &Path) -> Result +``` + +**Purpose**: Converts a relative path (from `strip_prefix`) into a template name string using forward slashes as separators. It rejects paths with unusual components (prefixes, root, parent, or current directory). + +**Parameters**: + +- `rel_path`: A relative path (assumed to have been stripped of a base). + +**Returns**: + +- `Ok(String)` with the template name (e.g., `"sub/nested.tera"`). +- `Err(Error::Render)` if: + - A component is not `Normal` (e.g., contains `..` or `/` absolute parts). + - The path contains non‑UTF‑8 characters. + - The resulting name is empty. + +**Note**: This function is not public but is essential for `load_templates_dir`. + +--- + +## Error Handling + +All fallible methods in `TeraRenderer` return `librawssg_error::Result`. The errors originate from: + +- Filesystem operations → converted to `Error::Io` (with `std::io::Error::other` wrapper to preserve the original message). +- Tera template parsing/rendering → converted to `Error::Render` with the error message as string. +- Context type mismatch → `Error::Render("invalid context type for Tera")`. +- Invalid template file name or path component → `Error::Render`. + +The `Renderer` trait method also returns `Result`, allowing custom renderers to use the same error type. + +--- + +## Feature Gating + +- The core traits (`Renderer`, `RenderContext`) are always available. +- `TeraRenderer` and the `tera` integration are only compiled when the `tera` feature is enabled. +- Tests that use `TeraRenderer` are also gated with `#![cfg(feature = "tera")]`. + +To enable the feature, add to `Cargo.toml`: + +```toml +[dependencies] +librawssg_templates = { version = "...", features = ["tera"] } +``` + +--- + +## Examples from Tests + +The test suite (`tera_tests.rs` and `unit_tests.rs`) provides extensive examples. Below are selected snippets with explanations. + +### Basic Rendering + +```rust +let mut renderer = TeraRenderer::new(); +renderer.add_raw_template("simple", "{{ title }}")?; + +let mut ctx = tera::Context::new(); +ctx.insert("title", "Hello World"); +let output = renderer.render("simple", &ctx)?; +assert_eq!(output, "Hello World"); +``` + +### Loops, Filters, Conditions + +```rust +renderer.add_raw_template("loop", "{% for item in items %}{{ item }}{% if not loop.last %},{% endif %}{% endfor %}")?; +// context: items = ["a","b","c"] -> output "a,b,c" + +renderer.add_raw_template("filter", "{{ title | upper }}")?; +// context: title = "Hello" -> "HELLO" + +renderer.add_raw_template("condition", "{% if number > 40 %}high{% else %}low{% endif %}")?; +// context: number = 42 -> "high" +``` + +### File Loading + +```rust +// Add a single file +let file_path = dir.path().join("hello.tera"); +std::fs::write(&file_path, "{{ name }}")?; +renderer.add_template_file(&file_path)?; +// Template name is "hello.tera" + +// Add all files from a directory (non-recursive) +renderer.add_template_files_from_dir(dir.path())?; + +// Recursive loading with namespaced names +renderer.load_templates_dir(dir.path())?; +// If sub/nested.tera exists, use renderer.render("sub/nested.tera", &ctx) +``` + +### Autoescaping + +```rust +renderer.enable_autoescape(); +renderer.add_raw_template("esc.html", "{{ content }}")?; +let mut ctx = tera::Context::new(); +ctx.insert("content", ""); +let output = renderer.render("esc.html", &ctx)?; +// Output: <script>alert(1)</script> +``` + +Note: `render_str` always autoescapes: + +```rust +let output = renderer.render_str("{{ content }}", &ctx)?; +// Also escaped +``` + +### Template Inheritance and Macros + +```rust +renderer.add_raw_template("base", "{% block content %}Default{% endblock %}")?; +renderer.add_raw_template("child", "{% extends \"base\" %}{% block content %}Child content{% endblock %}")?; +// Rendering "child" yields "Child content" + +renderer.add_raw_template("macro", "{% macro hello(name) %}Hello, {{ name }}{% endmacro hello %}{{ self::hello(name=\"World\") }}")?; +// Rendering "macro" yields "Hello, World" +``` + +### Context Downcasting + +```rust +let ctx = sample_context(); +let dyn_ctx: &dyn RenderContext = &ctx; +assert!(dyn_ctx.as_any().is::()); +``` + +This shows how the `RenderContext` trait enables type erasure and safe downcasting. + +--- + +## Testing Suite Overview + +The crate contains two test files: + +- **`unit_tests.rs`** (always compiled): Tests the core traits using mock implementations, verifies re‑exports are available, and ensures the traits can be used without the `tera` feature. +- **`tera_tests.rs`** (compiled only with `tera` feature): Comprehensive tests for `TeraRenderer` including: + - Basic variable substitution, loops, filters, conditions. + - Error cases (missing template, missing variable, invalid syntax). + - Loading templates from files and directories (both non‑recursive and recursive). + - Autoescaping behavior. + - Template inheritance, macros, includes. + - Context downcasting. + - Access to underlying `Tera` via `as_tera` and `as_tera_mut`. + +The tests use `tempfile` for temporary directories and `walkdir` for directory traversal validation. + +--- + +## Conclusion + +`librawssg_templates` offers a flexible and extensible template rendering abstraction. The core `Renderer` and `RenderContext` traits allow any template engine to be integrated, while the built‑in `TeraRenderer` provides a powerful, full‑featured implementation for the Tera engine. With methods for loading templates from files or directories, autoescaping control, and direct access to the underlying engine, it covers the needs of most static site generators. + +The crate is designed with testability and thread‑safety in mind, and the comprehensive test suite serves as both documentation and validation. By enabling the `tera` feature, developers can immediately start rendering templates with minimal setup. From 5557406f230bcd646a65ce3280d5ba9927aeb39c Mon Sep 17 00:00:00 2001 From: mroczect Date: Wed, 9 Sep 2026 00:16:40 +0700 Subject: [PATCH 22/48] docs: init struckture --- docs/Cargo.toml | 9 +++++++++ docs/src/content/api/compiler.raw | 0 docs/src/content/api/config.raw | 0 docs/src/content/api/error.raw | 0 docs/src/content/api/fecade.raw | 0 docs/src/content/api/fs.raw | 0 docs/src/content/api/handler.raw | 0 docs/src/content/api/index.raw | 0 docs/src/content/api/templates.raw | 0 docs/src/content/code_of_conduct.raw | 0 docs/src/content/configuration.raw | 0 docs/src/content/contributing.raw | 0 docs/src/content/index.raw | 0 docs/src/content/installation.raw | 0 docs/src/content/license.raw | 0 docs/src/main.rs | 3 +++ 16 files changed, 12 insertions(+) create mode 100644 docs/Cargo.toml create mode 100644 docs/src/content/api/compiler.raw create mode 100644 docs/src/content/api/config.raw create mode 100644 docs/src/content/api/error.raw create mode 100644 docs/src/content/api/fecade.raw create mode 100644 docs/src/content/api/fs.raw create mode 100644 docs/src/content/api/handler.raw create mode 100644 docs/src/content/api/index.raw create mode 100644 docs/src/content/api/templates.raw create mode 100644 docs/src/content/code_of_conduct.raw create mode 100644 docs/src/content/configuration.raw create mode 100644 docs/src/content/contributing.raw create mode 100644 docs/src/content/index.raw create mode 100644 docs/src/content/installation.raw create mode 100644 docs/src/content/license.raw create mode 100644 docs/src/main.rs diff --git a/docs/Cargo.toml b/docs/Cargo.toml new file mode 100644 index 0000000..5efd76f --- /dev/null +++ b/docs/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "docs" +version = "0.1.0" +edition = "2024" + +[dependencies] + +[lints] +workspace = true diff --git a/docs/src/content/api/compiler.raw b/docs/src/content/api/compiler.raw new file mode 100644 index 0000000..e69de29 diff --git a/docs/src/content/api/config.raw b/docs/src/content/api/config.raw new file mode 100644 index 0000000..e69de29 diff --git a/docs/src/content/api/error.raw b/docs/src/content/api/error.raw new file mode 100644 index 0000000..e69de29 diff --git a/docs/src/content/api/fecade.raw b/docs/src/content/api/fecade.raw new file mode 100644 index 0000000..e69de29 diff --git a/docs/src/content/api/fs.raw b/docs/src/content/api/fs.raw new file mode 100644 index 0000000..e69de29 diff --git a/docs/src/content/api/handler.raw b/docs/src/content/api/handler.raw new file mode 100644 index 0000000..e69de29 diff --git a/docs/src/content/api/index.raw b/docs/src/content/api/index.raw new file mode 100644 index 0000000..e69de29 diff --git a/docs/src/content/api/templates.raw b/docs/src/content/api/templates.raw new file mode 100644 index 0000000..e69de29 diff --git a/docs/src/content/code_of_conduct.raw b/docs/src/content/code_of_conduct.raw new file mode 100644 index 0000000..e69de29 diff --git a/docs/src/content/configuration.raw b/docs/src/content/configuration.raw new file mode 100644 index 0000000..e69de29 diff --git a/docs/src/content/contributing.raw b/docs/src/content/contributing.raw new file mode 100644 index 0000000..e69de29 diff --git a/docs/src/content/index.raw b/docs/src/content/index.raw new file mode 100644 index 0000000..e69de29 diff --git a/docs/src/content/installation.raw b/docs/src/content/installation.raw new file mode 100644 index 0000000..e69de29 diff --git a/docs/src/content/license.raw b/docs/src/content/license.raw new file mode 100644 index 0000000..e69de29 diff --git a/docs/src/main.rs b/docs/src/main.rs new file mode 100644 index 0000000..e7a11a9 --- /dev/null +++ b/docs/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + println!("Hello, world!"); +} From effd22c03b413f2752bff0f039c39532f9d4522e Mon Sep 17 00:00:00 2001 From: mroczect Date: Wed, 9 Sep 2026 00:32:36 +0700 Subject: [PATCH 23/48] feat(docs): add empty base CSS partial Add placeholder file for base CSS rules. This file will contain foundational styles shared across the documentation site. --- docs/src/static/css/_base.css | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/src/static/css/_base.css diff --git a/docs/src/static/css/_base.css b/docs/src/static/css/_base.css new file mode 100644 index 0000000..e69de29 From 9dbbcf1ba5933dceb59b2a695fbef9c9e3dee177 Mon Sep 17 00:00:00 2001 From: mroczect Date: Wed, 9 Sep 2026 00:32:36 +0700 Subject: [PATCH 24/48] feat(docs): add empty content CSS partial Add placeholder file for content-specific CSS rules. This file will style the main article and text content. --- docs/src/static/css/_content.css | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/src/static/css/_content.css diff --git a/docs/src/static/css/_content.css b/docs/src/static/css/_content.css new file mode 100644 index 0000000..e69de29 From 349db254e3c6ccda9f2559481bcbce349ba93c32 Mon Sep 17 00:00:00 2001 From: mroczect Date: Wed, 9 Sep 2026 00:32:36 +0700 Subject: [PATCH 25/48] feat(docs): add empty footer CSS partial Add placeholder file for footer styling. This file will contain styles for the page footer. --- docs/src/static/css/_footer.css | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/src/static/css/_footer.css diff --git a/docs/src/static/css/_footer.css b/docs/src/static/css/_footer.css new file mode 100644 index 0000000..e69de29 From ce576c603cd21cab2ef4b2d5cf5350f5b376a2dd Mon Sep 17 00:00:00 2001 From: mroczect Date: Wed, 9 Sep 2026 00:32:36 +0700 Subject: [PATCH 26/48] feat(docs): add empty layout CSS partial Add placeholder file for layout rules. This file will define the overall page structure and grid. --- docs/src/static/css/_layout.css | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/src/static/css/_layout.css diff --git a/docs/src/static/css/_layout.css b/docs/src/static/css/_layout.css new file mode 100644 index 0000000..e69de29 From abc27d876ba320bb126c1ae88df127332dc416d0 Mon Sep 17 00:00:00 2001 From: mroczect Date: Wed, 9 Sep 2026 00:32:36 +0700 Subject: [PATCH 27/48] feat(docs): add empty navbar CSS partial Add placeholder file for navbar styles. This file will style the top navigation bar. --- docs/src/static/css/_navbar.css | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/src/static/css/_navbar.css diff --git a/docs/src/static/css/_navbar.css b/docs/src/static/css/_navbar.css new file mode 100644 index 0000000..e69de29 From 8965552a08fa518a37a976cdd6a4171548addc7f Mon Sep 17 00:00:00 2001 From: mroczect Date: Wed, 9 Sep 2026 00:32:36 +0700 Subject: [PATCH 28/48] feat(docs): add empty reset CSS partial Add placeholder file for CSS reset rules. This file will normalize browser default styles. --- docs/src/static/css/_reset.css | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/src/static/css/_reset.css diff --git a/docs/src/static/css/_reset.css b/docs/src/static/css/_reset.css new file mode 100644 index 0000000..e69de29 From 9e5a58a088a37639f51729fb3b193b511d71b476 Mon Sep 17 00:00:00 2001 From: mroczect Date: Wed, 9 Sep 2026 00:32:37 +0700 Subject: [PATCH 29/48] feat(docs): add empty responsive CSS partial Add placeholder file for responsive design rules. This file will contain media queries and adaptive styles. --- docs/src/static/css/_responsive.css | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/src/static/css/_responsive.css diff --git a/docs/src/static/css/_responsive.css b/docs/src/static/css/_responsive.css new file mode 100644 index 0000000..e69de29 From 3f65f3d68cb16e7a7588dab55c7d03f3654f5e61 Mon Sep 17 00:00:00 2001 From: mroczect Date: Wed, 9 Sep 2026 00:32:37 +0700 Subject: [PATCH 30/48] feat(docs): add empty sidebar CSS partial Add placeholder file for sidebar styles. This file will style the documentation sidebar. --- docs/src/static/css/_sidebar.css | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/src/static/css/_sidebar.css diff --git a/docs/src/static/css/_sidebar.css b/docs/src/static/css/_sidebar.css new file mode 100644 index 0000000..e69de29 From 7cfbd14663209a607b33b5ae11c8c00cb56d392e Mon Sep 17 00:00:00 2001 From: mroczect Date: Wed, 9 Sep 2026 00:32:37 +0700 Subject: [PATCH 31/48] feat(docs): add empty typography CSS partial Add placeholder file for typography rules. This file will define font sizes, line heights, and text styling. --- docs/src/static/css/_typography.css | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/src/static/css/_typography.css diff --git a/docs/src/static/css/_typography.css b/docs/src/static/css/_typography.css new file mode 100644 index 0000000..e69de29 From d75a056b16677f9b5fa8632ba5a54288da0d5bc9 Mon Sep 17 00:00:00 2001 From: mroczect Date: Wed, 9 Sep 2026 00:32:37 +0700 Subject: [PATCH 32/48] feat(docs): add empty CSS variables partial Add placeholder file for CSS custom properties. This file will hold design tokens such as colors and spacing. --- docs/src/static/css/_variables.css | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/src/static/css/_variables.css diff --git a/docs/src/static/css/_variables.css b/docs/src/static/css/_variables.css new file mode 100644 index 0000000..e69de29 From 6770636bcb0fa062f1d840fd617e1dba593ba5cc Mon Sep 17 00:00:00 2001 From: mroczect Date: Wed, 9 Sep 2026 00:32:37 +0700 Subject: [PATCH 33/48] feat(docs): add empty main stylesheet Add main stylesheet placeholder that will import all CSS partials. This file will serve as the entry point for styles. --- docs/src/static/css/style.css | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/src/static/css/style.css diff --git a/docs/src/static/css/style.css b/docs/src/static/css/style.css new file mode 100644 index 0000000..e69de29 From d131cd9ec1bb58bd612464e20f95867e47d69a00 Mon Sep 17 00:00:00 2001 From: mroczect Date: Wed, 9 Sep 2026 00:32:37 +0700 Subject: [PATCH 34/48] feat(docs): add empty main JavaScript file Add placeholder JavaScript entry point. This file will initialize UI components and load modules. --- docs/src/static/js/main.js | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/src/static/js/main.js diff --git a/docs/src/static/js/main.js b/docs/src/static/js/main.js new file mode 100644 index 0000000..e69de29 From 798f574a9d7106862449443b994fdd7bc3f1dfd7 Mon Sep 17 00:00:00 2001 From: mroczect Date: Wed, 9 Sep 2026 00:32:37 +0700 Subject: [PATCH 35/48] feat(docs): add empty navbar JavaScript module Add placeholder JavaScript module for navbar interactivity. This file will handle mobile menu toggling and active states. --- docs/src/static/js/modules/navbar.js | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/src/static/js/modules/navbar.js diff --git a/docs/src/static/js/modules/navbar.js b/docs/src/static/js/modules/navbar.js new file mode 100644 index 0000000..e69de29 From acfa355fea7f45f599a78c84b4b77cf37800400b Mon Sep 17 00:00:00 2001 From: mroczect Date: Wed, 9 Sep 2026 00:32:37 +0700 Subject: [PATCH 36/48] feat(docs): add empty sidebar JavaScript module Add placeholder JavaScript module for sidebar behavior. This file will manage collapsible sections and highlight current page. --- docs/src/static/js/modules/sidebar.js | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/src/static/js/modules/sidebar.js diff --git a/docs/src/static/js/modules/sidebar.js b/docs/src/static/js/modules/sidebar.js new file mode 100644 index 0000000..e69de29 From d60ae4a3daaaae3bb919b711bd2ba75ec4facdff Mon Sep 17 00:00:00 2001 From: mroczect Date: Wed, 9 Sep 2026 00:32:37 +0700 Subject: [PATCH 37/48] feat(docs): add base Tera template Add the base HTML template that includes head, navbar, sidebar, content, footer, and scripts partials. This template structures the overall layout of documentation pages. --- docs/src/templates/base.tera | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 docs/src/templates/base.tera diff --git a/docs/src/templates/base.tera b/docs/src/templates/base.tera new file mode 100644 index 0000000..bff1b9c --- /dev/null +++ b/docs/src/templates/base.tera @@ -0,0 +1,15 @@ +{% include "partials/head.tera" %} + + {% include "partials/navbar.tera" %} +
+ {% include "partials/sidebar.tera" %} +
+
+ {{ page_content | safe }} +
+
+
+ {% include "partials/footer.tera" %} + {% include "partials/scripts.tera" %} + + \ No newline at end of file From 6702896538828687ba518112807c179c6dbffdc5 Mon Sep 17 00:00:00 2001 From: mroczect Date: Wed, 9 Sep 2026 00:32:37 +0700 Subject: [PATCH 38/48] feat(docs): add Tera macro definitions Add macros for rendering navigation items recursively and generating breadcrumbs. These macros are reused across templates to reduce duplication. --- docs/src/templates/macros.tera | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 docs/src/templates/macros.tera diff --git a/docs/src/templates/macros.tera b/docs/src/templates/macros.tera new file mode 100644 index 0000000..e6bbaf6 --- /dev/null +++ b/docs/src/templates/macros.tera @@ -0,0 +1,31 @@ +{% macro render_nav(items, active_url='') %} +
    + {% for item in items %} +
  • + + {{ item.label }} + + {% if item.children %} + {{ self::render_nav(items=item.children, active_url=active_url) }} + {% endif %} +
  • + {% endfor %} +
+{% endmacro %} + +{% macro render_breadcrumb(items) %} + +{% endmacro %} \ No newline at end of file From 7e3456329404ae0d8ff7d19ea91f96f7c72e43f3 Mon Sep 17 00:00:00 2001 From: mroczect Date: Wed, 9 Sep 2026 00:32:37 +0700 Subject: [PATCH 39/48] feat(docs): add footer partial template Add footer partial that displays the current year, site name, and license. It uses the now() function for dynamic year and default filter for license. --- docs/src/templates/partials/footer.tera | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 docs/src/templates/partials/footer.tera diff --git a/docs/src/templates/partials/footer.tera b/docs/src/templates/partials/footer.tera new file mode 100644 index 0000000..cb58cff --- /dev/null +++ b/docs/src/templates/partials/footer.tera @@ -0,0 +1,6 @@ +
+ +
\ No newline at end of file From f25b4cc23dfef86bcb7584c3a783beff779fa690 Mon Sep 17 00:00:00 2001 From: mroczect Date: Wed, 9 Sep 2026 00:32:37 +0700 Subject: [PATCH 40/48] feat(docs): add head partial template Add head partial that defines the HTML document head, including meta tags, title, description, and stylesheet inclusion. --- docs/src/templates/partials/head.tera | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 docs/src/templates/partials/head.tera diff --git a/docs/src/templates/partials/head.tera b/docs/src/templates/partials/head.tera new file mode 100644 index 0000000..7b19e1e --- /dev/null +++ b/docs/src/templates/partials/head.tera @@ -0,0 +1,9 @@ + + + + + + {% if page_title %}{{ page_title }} | {% endif %}{{ site.site_name }} + + {% include "partials/stylesheets.tera" %} + \ No newline at end of file From 1c48f9e4e9f7503fb0af714a6e3214fa3210dd9c Mon Sep 17 00:00:00 2001 From: mroczect Date: Wed, 9 Sep 2026 00:32:38 +0700 Subject: [PATCH 41/48] feat(docs): add navbar partial template Add navbar partial with brand, toggle button for mobile, and navigation links rendered via the nav macro. --- docs/src/templates/partials/navbar.tera | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 docs/src/templates/partials/navbar.tera diff --git a/docs/src/templates/partials/navbar.tera b/docs/src/templates/partials/navbar.tera new file mode 100644 index 0000000..3e44a72 --- /dev/null +++ b/docs/src/templates/partials/navbar.tera @@ -0,0 +1,14 @@ +{% import "macros.tera" as nav %} + \ No newline at end of file From 8b8ecad3cc52ae2a7e4f5579543a8fb96309d75d Mon Sep 17 00:00:00 2001 From: mroczect Date: Wed, 9 Sep 2026 00:32:38 +0700 Subject: [PATCH 42/48] feat(docs): add scripts partial template Add scripts partial that includes the main JavaScript module file. --- docs/src/templates/partials/scripts.tera | 1 + 1 file changed, 1 insertion(+) create mode 100644 docs/src/templates/partials/scripts.tera diff --git a/docs/src/templates/partials/scripts.tera b/docs/src/templates/partials/scripts.tera new file mode 100644 index 0000000..069997c --- /dev/null +++ b/docs/src/templates/partials/scripts.tera @@ -0,0 +1 @@ + \ No newline at end of file From 451fc79a70661bb4bc495765daea799bc3ddcd80 Mon Sep 17 00:00:00 2001 From: mroczect Date: Wed, 9 Sep 2026 00:32:38 +0700 Subject: [PATCH 43/48] feat(docs): add sidebar partial template Add sidebar partial with a heading and navigation links rendered via the nav macro. This provides the documentation sidebar. --- docs/src/templates/partials/sidebar.tera | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 docs/src/templates/partials/sidebar.tera diff --git a/docs/src/templates/partials/sidebar.tera b/docs/src/templates/partials/sidebar.tera new file mode 100644 index 0000000..a1ab77d --- /dev/null +++ b/docs/src/templates/partials/sidebar.tera @@ -0,0 +1,7 @@ +{% import "macros.tera" as nav %} + \ No newline at end of file From e75ac26d3f7e657bef8d2690d936884807a962fc Mon Sep 17 00:00:00 2001 From: mroczect Date: Wed, 9 Sep 2026 00:32:38 +0700 Subject: [PATCH 44/48] feat(docs): add stylesheets partial template Add stylesheets partial that links the main CSS file. --- docs/src/templates/partials/stylesheets.tera | 1 + 1 file changed, 1 insertion(+) create mode 100644 docs/src/templates/partials/stylesheets.tera diff --git a/docs/src/templates/partials/stylesheets.tera b/docs/src/templates/partials/stylesheets.tera new file mode 100644 index 0000000..2ba10c8 --- /dev/null +++ b/docs/src/templates/partials/stylesheets.tera @@ -0,0 +1 @@ + \ No newline at end of file From ce95010e21507733644965623b482b321fa029d9 Mon Sep 17 00:00:00 2001 From: mroczect Date: Wed, 9 Sep 2026 00:42:01 +0700 Subject: [PATCH 45/48] feat(docs): implement documentation styles and scripts (#38) * feat(docs): add base styles for documentation Add body, link, strong, hr, table, and button styling. Use CSS variables for colors and transitions. This establishes the foundational look for the documentation site. * feat(docs): add content and breadcrumb styles Add styles for markdown body, tables, images, and breadcrumb navigation. This improves readability and layout of article content. * feat(docs): add footer styles Add footer background, text color, link styling, and inner layout. The footer appears at the bottom with flexible alignment. * feat(docs): add layout styles Add wrapper, container, content, row, and col classes for page structure. Defines max widths and spacing for the main layout. * feat(docs): add navbar styles Add sticky navbar with brand, nav links, toggle button, and theme toggle. Styles include hover states and responsive visibility. * feat(docs): add CSS reset rules Add modern CSS reset covering box-sizing, margins, typography, forms, and hidden elements. This normalizes browser defaults across the documentation site. * feat(docs): add responsive styles Add media queries for mobile navigation and sidebar. Adjust layout to stack vertically and hide/show sidebar with transitions. * feat(docs): add sidebar styles Add sidebar width, sticky positioning, and link styling. Include active state, nested list indentation, and close button for mobile. * feat(docs): add typography styles Add heading, paragraph, blockquote, code, and preformatted text styles. Define font sizes, weights, and colors for content readability. * feat(docs): add CSS variables Define light and dark theme color tokens, fonts, and layout dimensions. Variables are used throughout the documentation styles. * feat(docs): add main stylesheet imports Add style.css that imports all CSS partials in the correct order. This is the entry point for documentation styles. * feat(docs): add main JavaScript entry point Add main.js that imports and initializes navbar and sidebar modules on DOMContentLoaded. * feat(docs): add navbar JavaScript module Add navbar module to toggle mobile menu and manage theme switching with localStorage and prefers-color-scheme. * feat(docs): add sidebar JavaScript module Add sidebar module to highlight active link, expand parent lists, and handle open/close behavior for mobile. --- docs/src/static/css/_base.css | 47 +++++++ docs/src/static/css/_content.css | 81 +++++++++++ docs/src/static/css/_footer.css | 22 +++ docs/src/static/css/_layout.css | 33 +++++ docs/src/static/css/_navbar.css | 79 +++++++++++ docs/src/static/css/_reset.css | 187 ++++++++++++++++++++++++++ docs/src/static/css/_responsive.css | 64 +++++++++ docs/src/static/css/_sidebar.css | 71 ++++++++++ docs/src/static/css/_typography.css | 82 +++++++++++ docs/src/static/css/_variables.css | 38 ++++++ docs/src/static/css/style.css | 10 ++ docs/src/static/js/main.js | 7 + docs/src/static/js/modules/navbar.js | 30 +++++ docs/src/static/js/modules/sidebar.js | 52 +++++++ 14 files changed, 803 insertions(+) diff --git a/docs/src/static/css/_base.css b/docs/src/static/css/_base.css index e69de29..be66485 100644 --- a/docs/src/static/css/_base.css +++ b/docs/src/static/css/_base.css @@ -0,0 +1,47 @@ +body { + font-family: var(--font-sans); + font-size: 16px; + line-height: 1.5; + color: var(--color-text); + background-color: var(--color-bg); + display: flex; + flex-direction: column; + min-height: 100vh; + transition: + background-color 0.3s, + color 0.3s; +} + +a { + color: var(--color-primary); + text-decoration: none; +} + +a:hover { + color: var(--color-primary-hover); + text-decoration: underline; +} + +b, +strong { + font-weight: 600; +} + +hr { + height: 1px; + margin: 1.5rem 0; + background-color: var(--color-border-light); + border: none; +} + +table { + border-spacing: 0; + border-collapse: collapse; +} + +button { + cursor: pointer; + border: none; + background: none; + font: inherit; +} diff --git a/docs/src/static/css/_content.css b/docs/src/static/css/_content.css index e69de29..c58ee5e 100644 --- a/docs/src/static/css/_content.css +++ b/docs/src/static/css/_content.css @@ -0,0 +1,81 @@ +.markdown-body { + font-size: 1rem; + line-height: 1.6; + word-wrap: break-word; +} + +.markdown-body > *:first-child { + margin-top: 0 !important; +} + +.markdown-body > *:last-child { + margin-bottom: 0 !important; +} + +.markdown-body a { + color: var(--color-primary); +} + +.markdown-body a:hover { + color: var(--color-primary-hover); +} + +.markdown-body table { + display: block; + width: 100%; + overflow: auto; + margin-bottom: 1rem; +} + +.markdown-body th, +.markdown-body td { + padding: 0.5rem 0.75rem; + border: 1px solid var(--color-border); +} + +.markdown-body th { + background-color: var(--color-sidebar-bg); + font-weight: 600; +} + +.markdown-body tr:nth-child(2n) { + background-color: var(--color-code-bg); +} + +.markdown-body img { + max-width: 100%; + height: auto; +} + +.breadcrumb { + margin-bottom: 1.5rem; + font-size: 0.9rem; + color: var(--color-text-secondary); +} + +.breadcrumb ol { + list-style: none; + display: flex; + flex-wrap: wrap; + padding: 0; + margin: 0; +} + +.breadcrumb li:not(:last-child)::after { + content: "/"; + margin: 0 0.5rem; + color: var(--color-border); +} + +.breadcrumb a { + color: var(--color-text-secondary); +} + +.breadcrumb a:hover { + color: var(--color-primary); +} + +.breadcrumb span { + color: var(--color-text); + font-weight: 500; +} diff --git a/docs/src/static/css/_footer.css b/docs/src/static/css/_footer.css index e69de29..dc83206 100644 --- a/docs/src/static/css/_footer.css +++ b/docs/src/static/css/_footer.css @@ -0,0 +1,22 @@ +.footer { + background-color: var(--color-navbar-bg); + color: var(--color-navbar-text); + padding: 1rem; + text-align: center; + margin-top: auto; +} + +.footer a { + color: var(--color-navbar-text); + text-decoration: underline; +} + +.footer-inner { + max-width: 1200px; + margin: 0 auto; + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 0.5rem; +} diff --git a/docs/src/static/css/_layout.css b/docs/src/static/css/_layout.css index e69de29..b3fda9d 100644 --- a/docs/src/static/css/_layout.css +++ b/docs/src/static/css/_layout.css @@ -0,0 +1,33 @@ +.wrapper { + display: flex; + flex: 1; + min-height: calc(100vh - var(--navbar-height)); +} + +.container { + width: 100%; + max-width: 1200px; + margin-right: auto; + margin-left: auto; + padding: 0 1rem; +} + +.content { + flex: 1; + padding: 2rem; + max-width: var(--content-max-width); + min-width: 0; +} + +.row { + display: flex; + flex-wrap: wrap; + margin-right: -10px; + margin-left: -10px; +} + +.col { + flex: 1; + padding-right: 10px; + padding-left: 10px; +} diff --git a/docs/src/static/css/_navbar.css b/docs/src/static/css/_navbar.css index e69de29..e239a9f 100644 --- a/docs/src/static/css/_navbar.css +++ b/docs/src/static/css/_navbar.css @@ -0,0 +1,79 @@ +.navbar { + background-color: var(--color-navbar-bg); + color: var(--color-navbar-text); + height: var(--navbar-height); + position: sticky; + top: 0; + z-index: 1000; + display: flex; + align-items: center; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.navbar-inner { + width: 100%; + max-width: 1200px; + margin: 0 auto; + padding: 0 1rem; + display: flex; + align-items: center; + justify-content: space-between; +} + +.brand { + font-size: 1.25rem; + font-weight: 600; + color: var(--color-navbar-text); + text-decoration: none; +} + +.brand:hover { + color: var(--color-navbar-text); + text-decoration: none; +} + +.nav-links ul { + list-style: none; + display: flex; + gap: 0.5rem; + margin: 0; + padding: 0; +} + +.nav-links a { + display: block; + padding: 0.5rem 0.75rem; + color: var(--color-navbar-text); + text-decoration: none; + border-radius: 4px; + transition: background-color 0.2s; +} + +.nav-links a:hover, +.nav-links a.active { + background-color: rgba(255, 255, 255, 0.15); + text-decoration: none; +} + +.navbar-toggle { + display: none; + flex-direction: column; + cursor: pointer; + padding: 0.5rem; +} + +.navbar-toggle .bar { + width: 25px; + height: 3px; + background-color: var(--color-navbar-text); + margin: 3px 0; + transition: 0.3s; +} + +.theme-toggle { + color: var(--color-navbar-text); + font-size: 1.2rem; + cursor: pointer; + margin-left: auto; + padding: 0 0.5rem; +} diff --git a/docs/src/static/css/_reset.css b/docs/src/static/css/_reset.css index e69de29..788be98 100644 --- a/docs/src/static/css/_reset.css +++ b/docs/src/static/css/_reset.css @@ -0,0 +1,187 @@ +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + line-height: 1.15; + -webkit-text-size-adjust: 100%; +} + +body { + margin: 0; +} + +main { + display: block; +} + +h1 { + font-size: 2em; + margin: 0.67em 0; +} + +hr { + box-sizing: content-box; + height: 0; + overflow: visible; +} + +pre { + font-family: monospace, monospace; + font-size: 1em; +} + +a { + background-color: transparent; +} + +abbr[title] { + border-bottom: none; + text-decoration: underline; + text-decoration: underline dotted; +} + +b, +strong { + font-weight: bolder; +} + +code, +kbd, +samp { + font-family: monospace, monospace; + font-size: 1em; +} + +small { + font-size: 80%; +} + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +sub { + bottom: -0.25em; +} + +sup { + top: -0.5em; +} + +img { + border-style: none; +} + +button, +input, +optgroup, +select, +textarea { + font-family: inherit; + font-size: 100%; + line-height: 1.15; + margin: 0; +} + +button, +input { + overflow: visible; +} + +button, +select { + text-transform: none; +} + +button, +[type="button"], +[type="reset"], +[type="submit"] { + -webkit-appearance: button; +} + +button::-moz-focus-inner, +[type="button"]::-moz-focus-inner, +[type="reset"]::-moz-focus-inner, +[type="submit"]::-moz-focus-inner { + border-style: none; + padding: 0; +} + +button:-moz-focusring, +[type="button"]:-moz-focusring, +[type="reset"]:-moz-focusring, +[type="submit"]:-moz-focusring { + outline: 1px dotted ButtonText; +} + +fieldset { + padding: 0.35em 0.75em 0.625em; +} + +legend { + box-sizing: border-box; + color: inherit; + display: table; + max-width: 100%; + padding: 0; + white-space: normal; +} + +progress { + vertical-align: baseline; +} + +textarea { + overflow: auto; +} + +[type="checkbox"], +[type="radio"] { + box-sizing: border-box; + padding: 0; +} + +[type="number"]::-webkit-inner-spin-button, +[type="number"]::-webkit-outer-spin-button { + height: auto; +} + +[type="search"] { + -webkit-appearance: textfield; + outline-offset: -2px; +} + +[type="search"]::-webkit-search-decoration { + -webkit-appearance: none; +} + +::-webkit-file-upload-button { + -webkit-appearance: button; + font: inherit; +} + +details { + display: block; +} + +summary { + display: list-item; +} + +template { + display: none; +} + +[hidden] { + display: none; +} diff --git a/docs/src/static/css/_responsive.css b/docs/src/static/css/_responsive.css index e69de29..6671f14 100644 --- a/docs/src/static/css/_responsive.css +++ b/docs/src/static/css/_responsive.css @@ -0,0 +1,64 @@ +@media (max-width: 768px) { + .navbar-toggle { + display: flex; + } + + .nav-links { + display: none; + position: absolute; + top: var(--navbar-height); + left: 0; + right: 0; + background-color: var(--color-navbar-bg); + padding: 1rem; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + } + + .nav-links.active { + display: block; + } + + .nav-links ul { + flex-direction: column; + gap: 0; + } + + .nav-links li { + margin-bottom: 0.25rem; + } + + .wrapper { + flex-direction: column; + } + + .sidebar { + width: 100%; + height: auto; + position: fixed; + top: var(--navbar-height); + left: 0; + bottom: 0; + z-index: 999; + transform: translateX(-100%); + transition: transform 0.3s ease; + box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1); + } + + .sidebar.open { + transform: translateX(0); + } + + .sidebar-close { + display: block; + float: right; + } + + .content { + padding: 1rem; + } + + .footer-inner { + flex-direction: column; + text-align: center; + } +} diff --git a/docs/src/static/css/_sidebar.css b/docs/src/static/css/_sidebar.css index e69de29..6fc5f8a 100644 --- a/docs/src/static/css/_sidebar.css +++ b/docs/src/static/css/_sidebar.css @@ -0,0 +1,71 @@ +.sidebar { + width: var(--sidebar-width); + flex-shrink: 0; + background-color: var(--color-sidebar-bg); + border-right: 1px solid var(--color-border); + padding: 1.5rem 1rem; + overflow-y: auto; + position: sticky; + top: var(--navbar-height); + height: calc(100vh - var(--navbar-height)); +} + +.sidebar h2 { + font-size: 1rem; + font-weight: 600; + margin-bottom: 1rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--color-border); +} + +.sidebar h2 a { + color: var(--color-text); + text-decoration: none; +} + +.sidebar ul { + list-style: none; + padding: 0; + margin: 0; +} + +.sidebar li { + margin-bottom: 0.15rem; +} + +.sidebar a { + display: block; + padding: 0.3rem 0.5rem; + color: var(--color-text-secondary); + text-decoration: none; + font-size: 0.9rem; + border-radius: 4px; + transition: + background-color 0.2s, + color 0.2s; +} + +.sidebar a:hover { + text-decoration: none; + background-color: var(--color-accent); + color: var(--color-text); +} + +.sidebar a.active { + font-weight: 600; + color: var(--color-text); + background-color: var(--color-accent); +} + +.sidebar li > ul { + margin-left: 0.75rem; + border-left: 1px solid var(--color-border); + padding-left: 0.5rem; +} + +.sidebar-close { + display: none; + font-size: 1.5rem; + cursor: pointer; + color: var(--color-text); +} diff --git a/docs/src/static/css/_typography.css b/docs/src/static/css/_typography.css index e69de29..95a7a43 100644 --- a/docs/src/static/css/_typography.css +++ b/docs/src/static/css/_typography.css @@ -0,0 +1,82 @@ +h1, +h2, +h3, +h4, +h5, +h6 { + margin-top: 0; + margin-bottom: 0.5em; + font-weight: 600; + line-height: 1.25; + color: var(--color-text); +} + +h1 { + font-size: 2em; + padding-bottom: 0.3em; + border-bottom: 1px solid var(--color-border-light); +} + +h2 { + font-size: 1.5em; + padding-bottom: 0.3em; + border-bottom: 1px solid var(--color-border-light); +} + +h3 { + font-size: 1.25em; +} + +h4 { + font-size: 1em; +} + +h5 { + font-size: 0.875em; +} + +h6 { + font-size: 0.85em; + color: var(--color-text-secondary); +} + +p { + margin-bottom: 1rem; +} + +small { + font-size: 90%; +} + +blockquote { + margin: 0 0 1rem; + padding: 0 1em; + color: var(--color-text-secondary); + border-left: 0.25em solid var(--color-border); +} + +code, +tt { + font-family: var(--font-mono); + font-size: 85%; + padding: 0.2em 0.4em; + background-color: var(--color-code-bg); + border-radius: 3px; +} + +pre { + margin-bottom: 1rem; + padding: 1rem; + overflow: auto; + font-family: var(--font-mono); + font-size: 85%; + line-height: 1.45; + background-color: var(--color-code-bg); + border-radius: 6px; +} + +pre code { + padding: 0; + background: none; + font-size: 100%; +} diff --git a/docs/src/static/css/_variables.css b/docs/src/static/css/_variables.css index e69de29..e5b4c75 100644 --- a/docs/src/static/css/_variables.css +++ b/docs/src/static/css/_variables.css @@ -0,0 +1,38 @@ +:root { + --color-bg: #ffffff; + --color-text: #24292e; + --color-text-secondary: #586069; + --color-border: #e1e4e8; + --color-border-light: #eaecef; + --color-primary: #0366d6; + --color-primary-hover: #0256b3; + --color-accent: #f6f8fa; + --color-code-bg: #f6f8fa; + --color-sidebar-bg: #f6f8fa; + --color-navbar-bg: #24292e; + --color-navbar-text: #ffffff; + + --sidebar-width: 260px; + --navbar-height: 60px; + --content-max-width: 900px; + --font-sans: + -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, + "Apple Color Emoji", "Segoe UI Emoji"; + --font-mono: + "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace; +} + +[data-theme="dark"] { + --color-bg: #0d1117; + --color-text: #c9d1d9; + --color-text-secondary: #8b949e; + --color-border: #30363d; + --color-border-light: #21262d; + --color-primary: #58a6ff; + --color-primary-hover: #79b8ff; + --color-accent: #161b22; + --color-code-bg: #161b22; + --color-sidebar-bg: #161b22; + --color-navbar-bg: #161b22; + --color-navbar-text: #c9d1d9; +} diff --git a/docs/src/static/css/style.css b/docs/src/static/css/style.css index e69de29..ad35aaa 100644 --- a/docs/src/static/css/style.css +++ b/docs/src/static/css/style.css @@ -0,0 +1,10 @@ +@import url("_variables.css"); +@import url("_reset.css"); +@import url("_base.css"); +@import url("_typography.css"); +@import url("_layout.css"); +@import url("_navbar.css"); +@import url("_sidebar.css"); +@import url("_content.css"); +@import url("_footer.css"); +@import url("_responsive.css"); diff --git a/docs/src/static/js/main.js b/docs/src/static/js/main.js index e69de29..dc6c77c 100644 --- a/docs/src/static/js/main.js +++ b/docs/src/static/js/main.js @@ -0,0 +1,7 @@ +import { initNavbar } from "./modules/navbar.js"; +import { initSidebar } from "./modules/sidebar.js"; + +document.addEventListener("DOMContentLoaded", () => { + initNavbar(); + initSidebar(); +}); diff --git a/docs/src/static/js/modules/navbar.js b/docs/src/static/js/modules/navbar.js index e69de29..f0aa85a 100644 --- a/docs/src/static/js/modules/navbar.js +++ b/docs/src/static/js/modules/navbar.js @@ -0,0 +1,30 @@ +export function initNavbar() { + const toggleButton = document.getElementById("navbar-toggle"); + const navLinks = document.getElementById("nav-links"); + const themeToggle = document.getElementById("theme-toggle"); + + if (toggleButton && navLinks) { + toggleButton.addEventListener("click", () => { + navLinks.classList.toggle("active"); + }); + } + + if (themeToggle) { + const prefersDark = window.matchMedia( + "(prefers-color-scheme: dark)", + ).matches; + const storedTheme = localStorage.getItem("theme"); + if (storedTheme) { + document.documentElement.setAttribute("data-theme", storedTheme); + } else if (prefersDark) { + document.documentElement.setAttribute("data-theme", "dark"); + } + + themeToggle.addEventListener("click", () => { + const current = document.documentElement.getAttribute("data-theme"); + const next = current === "dark" ? "light" : "dark"; + document.documentElement.setAttribute("data-theme", next); + localStorage.setItem("theme", next); + }); + } +} diff --git a/docs/src/static/js/modules/sidebar.js b/docs/src/static/js/modules/sidebar.js index e69de29..3c24b7b 100644 --- a/docs/src/static/js/modules/sidebar.js +++ b/docs/src/static/js/modules/sidebar.js @@ -0,0 +1,52 @@ +export function initSidebar() { + const currentPath = window.location.pathname; + const sidebarLinks = document.querySelectorAll(".sidebar-nav a"); + const sidebar = document.querySelector(".sidebar"); + const overlay = document.getElementById("sidebar-overlay"); + const closeBtn = document.getElementById("sidebar-close"); + + sidebarLinks.forEach((link) => { + const linkPath = link.getAttribute("href"); + if (linkPath && currentPath.endsWith(linkPath)) { + link.classList.add("active"); + let parent = link.closest("li"); + while (parent) { + const parentUl = parent.parentElement; + if (parentUl && parentUl.tagName === "UL") { + parentUl.style.display = "block"; + } + parent = parentUl ? parentUl.closest("li") : null; + } + } + }); + + const navbarToggle = document.getElementById("navbar-toggle"); + + function openSidebar() { + sidebar.classList.add("open"); + overlay.classList.add("active"); + } + + function closeSidebar() { + sidebar.classList.remove("open"); + overlay.classList.remove("active"); + } + + if (navbarToggle && sidebar) { + navbarToggle.addEventListener("click", () => { + if (sidebar.classList.contains("open")) { + closeSidebar(); + } else { + openSidebar(); + } + }); + } + + if (closeBtn) { + closeBtn.addEventListener("click", closeSidebar); + } + + if (overlay) { + overlay.addEventListener("click", closeSidebar); + } +} From 5edc5ca32ca66ea12ad3f5df94b66d1929604549 Mon Sep 17 00:00:00 2001 From: mroczect Date: Wed, 9 Sep 2026 01:02:56 +0700 Subject: [PATCH 46/48] add raw doc format --- docs/src/content/api/compiler.raw | 1139 ++++++++++++++++++++++++ docs/src/content/api/config.raw | 1071 +++++++++++++++++++++++ docs/src/content/api/error.raw | 893 +++++++++++++++++++ docs/src/content/api/fecade.raw | 1198 ++++++++++++++++++++++++++ docs/src/content/api/fs.raw | 1102 +++++++++++++++++++++++ docs/src/content/api/handler.raw | 1007 ++++++++++++++++++++++ docs/src/content/api/index.raw | 14 + docs/src/content/api/templates.raw | 947 ++++++++++++++++++++ docs/src/content/code_of_conduct.raw | 108 +++ docs/src/content/configuration.raw | 402 +++++++++ docs/src/content/contributing.raw | 185 ++++ docs/src/content/index.raw | 99 +++ docs/src/content/installation.raw | 191 ++++ docs/src/content/license.raw | 29 + 14 files changed, 8385 insertions(+) diff --git a/docs/src/content/api/compiler.raw b/docs/src/content/api/compiler.raw index e69de29..0f76215 100644 --- a/docs/src/content/api/compiler.raw +++ b/docs/src/content/api/compiler.raw @@ -0,0 +1,1139 @@ +

librawssg_compiler

+

+ Version: 1.0.0 (implied)
Crate name: + librawssg_compiler
Description: Provides + the core compilation pipeline for the librawssg static site + generator. This crate orchestrates the entire build process: reading content + files, processing them via pluggable processors, rendering templates, copying + static assets, running custom generators, and outputting the final site + atomically. +

+
+

Table of Contents

+
    +
  1. Overview
  2. +
  3. Modules and Re‑exports
  4. +
  5. + PipelineBuilder + +
  6. +
  7. + ContextBuilder Trait + +
  8. +
  9. + Generator Trait + +
  10. +
  11. + Pattern Matching Function + +
  12. +
  13. + Pipeline Struct + +
  14. +
  15. Error Handling
  16. +
  17. + Complete Example from Tests + +
  18. +
  19. Testing Suite Overview
  20. +
  21. Conclusion
  22. +
+
+

Overview

+

+ librawssg_compiler is the orchestration layer that ties together + all other components of the static site generator: +

+
    +
  • + Config from + librawssg_config defines content rules and paths. +
  • +
  • + Processor from + librawssg_handler transforms source files into + Document objects. +
  • +
  • + Renderer and + RenderContext from + librawssg_templates handle template rendering. +
  • +
  • + FileSystem from + librawssg_fs abstracts all I/O operations. +
  • +
  • + Generator (defined here) allows custom + post‑processing steps. +
  • +
  • + ContextBuilder (defined here) constructs the + render context from a Document and Config. +
  • +
+

+ The main entry point is PipelineBuilder, which constructs a + Pipeline after validating the configuration and ensuring + mandatory components are present. Running the pipeline performs the full site + generation in an atomic fashion, producing output in the configured output + directory. +

+
+

Modules and Re‑exports

+

+ The crate root (lib.rs) declares the following public modules: +

+
    +
  • builder – Contains PipelineBuilder.
  • +
  • + context – Contains ContextBuilder trait and + TeraContextBuilder. +
  • +
  • generator – Contains Generator trait.
  • +
  • pattern – Contains match_pattern function.
  • +
  • pipeline – Contains Pipeline struct.
  • +
+

Re‑exported types at the crate root:

+
pub use builder::PipelineBuilder;
+pub use context::ContextBuilder;
+pub use context::TeraContextBuilder;
+pub use generator::Generator;
+pub use pipeline::Pipeline;
+
+

+ The match_pattern function is also re‑exported? Actually it is + in pattern module and not re‑exported at root, so users must use + librawssg_compiler::pattern::match_pattern. However, in + pipeline.rs it is imported via + crate::pattern::match_pattern, but for external users they need + to access it via module path. +

+
+

Struct PipelineBuilder

+

+ PipelineBuilder is a builder‑style struct that collects all + components needed to run the compilation pipeline and then builds a + Pipeline. +

+
pub struct PipelineBuilder {
+    config: Config,
+    content_dir: PathBuf,
+    output_dir: PathBuf,
+    fs: Box<dyn FileSystem>,
+    renderer: Option<Box<dyn Renderer>>,
+    processors: Vec<Box<dyn Processor>>,
+    context_builder: Option<Box<dyn ContextBuilder>>,
+    generators: Vec<Box<dyn Generator>>,
+}
+
+

+ Note: Fields are private; use the builder methods to + configure. +

+

PipelineBuilder::new

+
#[must_use]
+pub fn new() -> Self
+
+

Purpose: Creates a new builder with default values:

+
    +
  • config: Config::default()
  • +
  • content_dir: "content"
  • +
  • output_dir: "dist"
  • +
  • fs: Box::new(RealFs)
  • +
  • renderer: None
  • +
  • processors: empty vector
  • +
  • context_builder: None
  • +
  • generators: empty vector
  • +
+

Returns: A fresh PipelineBuilder.

+

Example:

+
let builder = PipelineBuilder::new();
+
+
+

PipelineBuilder Builder Methods

+

+ All builder methods consume self and return Self, + allowing method chaining. +

+

config

+
#[must_use]
+pub fn config(mut self, config: Config) -> Self
+
+

Purpose: Sets the configuration object.

+

Parameters:

+
    +
  • + config: A Config instance from + librawssg_config. +
  • +
+

Returns: The builder with the config set.

+

load_config

+
pub fn load_config<P: AsRef<Path> + Send + Sync>(mut self, path: P) -> Result<Self>
+
+

+ Purpose: Reads a YAML config file from disk and parses it + into a Config. Errors are converted to + Error::Config. +

+

Parameters:

+
    +
  • path: Path to the YAML file.
  • +
+

+ Returns: Ok(Self) if parsing succeeds, + otherwise Err(Error::Config). +

+

+ Note: The file is read using standard + std::fs::read_to_string; the error is wrapped in + Error::Config. +

+

content_dir

+
#[must_use]
+pub fn content_dir(mut self, dir: impl Into<PathBuf>) -> Self
+
+

Purpose: Overrides the content directory.

+

Parameters:

+
    +
  • dir: Any type convertible to PathBuf.
  • +
+

+ Default: "content" (but may be + overridden by config if not explicitly set; see build()). +

+

output_dir

+
#[must_use]
+pub fn output_dir(mut self, dir: impl Into<PathBuf>) -> Self
+
+

Purpose: Overrides the output directory.

+

Default: "dist".

+

with_fs

+
#[must_use]
+pub fn with_fs(mut self, fs: Box<dyn FileSystem>) -> Self
+
+

+ Purpose: Sets a custom filesystem implementation. Useful for + testing or non‑standard backends. +

+

Default: RealFs.

+

with_renderer

+
#[must_use]
+pub fn with_renderer(mut self, renderer: Box<dyn Renderer>) -> Self
+
+

+ Purpose: Sets the template renderer. + Mandatory; build() will fail if not set. +

+

add_processor

+
#[must_use]
+pub fn add_processor(mut self, processor: Box<dyn Processor>) -> Self
+
+

+ Purpose: Adds a content processor to the pipeline. Multiple + processors can be added; they are tried in order for each source file. +

+

with_context_builder

+
#[must_use]
+pub fn with_context_builder(mut self, builder: Box<dyn ContextBuilder>) -> Self
+
+

+ Purpose: Sets the context builder. + Mandatory; build() will fail if not set. +

+

add_generator

+
#[must_use]
+pub fn add_generator(mut self, generator: Box<dyn Generator>) -> Self
+
+

+ Purpose: Adds a post‑processing generator. Generators run + after all documents are rendered and static assets copied. +

+
+

PipelineBuilder::build

+
pub fn build(mut self) -> Result<Pipeline>
+
+

+ Purpose: Validates the configuration, ensures required + components are present, and constructs a Pipeline. +

+

Behavior:

+
    +
  1. + Calls self.config.validate()? (see + librawssg_config::Config::validate). +
  2. +
  3. + If content_dir is still the default + "content" (i.e., not changed by + content_dir()), it is replaced with + self.config.build.content_dir. +
  4. +
  5. + Similarly, if output_dir is still + "dist", it is replaced with + self.config.build.output_dir. +
  6. +
  7. + Takes the renderer from self.renderer (using + take()). If None, returns + Error::Config("template renderer not set"). +
  8. +
  9. + Takes the context builder from self.context_builder. If + None, returns + Error::Config("context builder not set"). +
  10. +
  11. + Moves all remaining fields into a new Pipeline and returns + Ok. +
  12. +
+

Returns:

+
    +
  • Ok(Pipeline) on success.
  • +
  • Err(Error::Validation) if config invalid.
  • +
  • + Err(Error::Config) if renderer or context builder missing. +
  • +
+
+

PipelineBuilder Default

+
impl Default for PipelineBuilder {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+

Allows creating with PipelineBuilder::default().

+
+

PipelineBuilder Example

+
use librawssg_compiler::{PipelineBuilder, TeraContextBuilder};
+use librawssg_config::Config;
+use librawssg_fs::RealFs;
+use librawssg_handler::Processor;
+use librawssg_templates::{Renderer, TeraRenderer};
+
+// Assuming custom processor and renderer exist
+let config = Config::new().with_site_name("My Site");
+let processor = Box::new(MyProcessor);
+let renderer = Box::new(TeraRenderer::new()); // TeraRenderer must be configured with templates beforehand
+let context_builder = Box::new(TeraContextBuilder);
+
+let pipeline = PipelineBuilder::new()
+    .config(config)
+    .content_dir("src")
+    .output_dir("public")
+    .with_fs(Box::new(RealFs))
+    .with_renderer(renderer)
+    .with_context_builder(context_builder)
+    .add_processor(processor)
+    .build()?;
+
+
+

Trait ContextBuilder

+
pub trait ContextBuilder: Send + Sync {
+    fn build_context(&self, config: &Config, doc: &Document) -> Result<Box<dyn RenderContext>>;
+}
+
+

+ Purpose: Abstract factory that creates a + RenderContext from the global Config and a specific + Document. The renderer then uses this context to render the + document's template. +

+

+ Requirements: Implementors must be Send + Sync. +

+

build_context

+

Parameters:

+
    +
  • config: Reference to the site configuration.
  • +
  • doc: Reference to the document being rendered.
  • +
+

Returns:

+
    +
  • + Ok(Box<dyn RenderContext>) – a boxed trait object + holding the render context. +
  • +
  • Err(librawssg_error::Error) if context creation fails.
  • +
+
+

Struct TeraContextBuilder

+
#[derive(Debug, Default, Clone, Copy)]
+pub struct TeraContextBuilder;
+
+

+ A concrete implementation of ContextBuilder that produces a + tera::Context populated with common page and site data. +

+

Implementation Details

+

+ TeraContextBuilder creates a new tera::Context and + inserts the following keys: +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KeyValue SourceDescription
site&config.siteThe full SiteConfig object.
page_title&doc.metadata.titleDocument title.
page_description&doc.metadata.descriptionDocument description.
page_author&doc.metadata.authorOptional author.
page_date&doc.metadata.dateOptional publication date.
page_tags&doc.metadata.tagsVector of tags.
page_content&doc.bodyThe rendered body content of the document.
page_url&doc.urlRelative URL of the document.
page_depth&doc.depthDepth in the site hierarchy.
page_type&doc.content_typeContent type identifier (e.g., "blog").
page_is_list&doc.is_listBoolean indicating list page.
page_list_items&doc.list_itemsOptional vector of child documents for lists.
+

+ Note: The page_list_items field is inserted as + &doc.list_items which is + Option<Vec<Document>>. In Tera templates, this will + be None or an array. +

+

Example:

+
use librawssg_compiler::TeraContextBuilder;
+let builder = TeraContextBuilder;
+let ctx = builder.build_context(&config, &doc)?;
+// Pass `ctx` to renderer.render(...)
+
+
+

Trait Generator

+
pub trait Generator: Send + Sync {
+    fn generate(&self, pipeline: &Pipeline, output_base: &Path) -> Result<()>;
+}
+
+

+ Purpose: Allows custom post‑processing steps after the main + site generation. Generators can write additional files to the output + directory (e.g., RSS feed, sitemap, search index). +

+

+ Requirements: Implementors must be Send + Sync. +

+

generate

+

Parameters:

+
    +
  • + pipeline: Reference to the running Pipeline, + which provides access to its configuration, filesystem, etc. (though the + fields are crate‑private, the config() method is available). +
  • +
  • + output_base: Path to the temporary output directory where + generated files should be written. The pipeline's + run() method later moves this directory atomically to the + final output location. +
  • +
+

Returns:

+
    +
  • Ok(()) on success.
  • +
  • Err(librawssg_error::Error) on failure.
  • +
+

Example (from tests):

+
struct DummyGenerator;
+
+impl Generator for DummyGenerator {
+    fn generate(&self, _pipeline: &Pipeline, output_base: &Path) -> Result<()> {
+        let path = output_base.join("generated.txt");
+        std::fs::write(&path, b"generated")?;
+        Ok(())
+    }
+}
+
+
+

Function match_pattern

+
#[must_use]
+pub fn match_pattern(pattern: &str, path: &Path) -> bool
+
+

Location: librawssg_compiler::pattern

+

+ Purpose: Checks whether a file path matches a glob pattern + with support for * (within a segment) and + ** (across segments). Used by the pipeline to assign content + types based on ContentRule patterns. +

+

Parameters:

+
    +
  • + pattern: A glob‑like pattern string, e.g., + "blog/**/*.html". +
  • +
  • + path: A Path to test (usually a relative path + from the content directory). +
  • +
+

+ Returns: true if the path matches the pattern; + false otherwise. +

+

Supported Glob Syntax

+
    +
  • + * – Matches any sequence of characters within a single path + segment (i.e., does not cross /). +
  • +
  • + ** – Matches any number of path segments, including zero. +
  • +
+

Limitations:

+
    +
  • + Only * and ** are supported; no character + classes ([abc]) or alternation. +
  • +
  • + Patterns are split on /; backslashes are not treated as + separators (path normalization may be needed on Windows). +
  • +
  • + The implementation uses a custom recursive algorithm; for complex + patterns, behavior may differ from standard glob libraries. +
  • +
+

Algorithm Overview

+

+ The function first converts the path to a string (lossy) and splits both + pattern and path by /. It then calls an internal recursive + match_pattern_slice. The logic: +

+
    +
  • If both pattern and path segments are exhausted → true.
  • +
  • + If pattern still has segments but path is empty → only + ** segments are allowed. +
  • +
  • + If first pattern segment is "**": +
      +
    • If it's the only segment → true.
    • +
    • + Otherwise, try matching the rest of the pattern against every suffix + of the path. +
    • +
    +
  • +
  • + Otherwise, the first pattern segment must match the first path segment + using segment_matches (handles * wildcards), and + recursion continues on the rest. +
  • +
  • + segment_matches handles * by trying to match the + remainder of the pattern segment against suffixes of the path segment. +
  • +
+

Examples

+
use std::path::Path;
+use librawssg_compiler::pattern::match_pattern;
+
+assert!(match_pattern("**/*.html", Path::new("blog/post.html")));
+assert!(match_pattern("blog/**/*.html", Path::new("blog/2024/post.html")));
+assert!(match_pattern("*.html", Path::new("index.html")));
+assert!(!match_pattern("*.html", Path::new("blog/post.html")));
+assert!(match_pattern("**", Path::new("anything/at/all")));
+assert!(match_pattern("**/*.md", Path::new("readme.md"))); // zero segments before .md
+
+
+

Struct Pipeline

+

+ The Pipeline is the core execution engine. It is created by + PipelineBuilder::build() and holds all necessary components. +

+
pub struct Pipeline {
+    config: Config,
+    fs: Box<dyn FileSystem>,
+    renderer: Box<dyn Renderer>,
+    processors: Vec<Box<dyn Processor>>,
+    context_builder: Box<dyn ContextBuilder>,
+    generators: Vec<Box<dyn Generator>>,
+    content_dir: PathBuf,
+    output_dir: PathBuf,
+}
+
+

+ All fields are private; access to configuration is provided via the + config() method. +

+

Pipeline::config

+
#[must_use]
+pub const fn config(&self) -> &Config
+
+

+ Purpose: Returns a reference to the configuration used by + this pipeline. +

+

Returns: &Config.

+
+

Pipeline::run

+
pub fn run(&self) -> Result<()>
+
+

+ Purpose: Executes the full site generation process + atomically. +

+

Behavior:

+
    +
  1. + Determines a temporary output directory: + output_dir.with_extension("tmp"). For example, if + output_dir is "dist", the temp dir is + "dist.tmp". +
  2. +
  3. If the temp dir already exists, it is removed.
  4. +
  5. Creates the temp dir.
  6. +
  7. + Calls internal generate_to(&tmp_dir) to perform the + actual generation into the temporary location. +
  8. +
  9. If the final output directory exists, it is removed.
  10. +
  11. + Attempts to rename the temp dir to the final output dir. +
      +
    • On success, returns Ok(()).
    • +
    • + If rename fails with CrossesDevices error (different + filesystems), fallback: copy the temp dir contents to the final + output dir using copy_dir_all (internal method), then + remove the temp dir. +
    • +
    • + For any other error, returns + Error::Generation("atomic rename failed: ..."). +
    • +
    +
  12. +
+

+ Returns: Ok(()) on success, or + Err(Error). +

+

+ Note: This atomic approach ensures that the final output + directory is never left in a partially generated state; either the old output + remains untouched (if generation fails) or the new output replaces it + atomically (or near‑atomically). +

+
+

Pipeline Internal Workflow

+

+ The internal method + generate_to(output_base: &Path) orchestrates the entire + generation. The following steps are performed (not public, but described for + understanding): +

+
    +
  1. + Create output directory – + fs.create_dir_all(output_base). +
  2. +
  3. + Process documents – Calls + process_documents() to get a vector of Document. +
  4. +
  5. + Group documents by content type – Builds a + HashMap<String, Vec<Document>>. +
  6. +
  7. + Render non‑list documents – For each + Document where is_list == false, calls + render_document(output_base, doc). +
  8. +
  9. + Generate list pages – For each content type that has a + matching ContentRule with list_enabled == true, + list_template set, and at least one document, creates a + synthetic list document (is_list = true) and renders it using + the list template. The list document’s list_items are all + documents of that content type. The output path is + "{content_type}/index.html". +
  10. +
  11. + Copy static assets – If the directory specified by + config.build.static_dir exists, its entire contents are + copied to output_base/static_dir_name (using + copy_dir_all). +
  12. +
  13. + Run generators – Iterates over all + generators and calls generate() for each, + passing the output base. +
  14. +
+

Document Processing

+

+ process_documents() walks the content directory (using + fs.walk_dir). For each file, it: +

+
    +
  • Computes the path relative to content_dir.
  • +
  • + Iterates through the processors in order; the first processor whose + can_process(rel, &file_path) returns true is + used. +
  • +
  • + Calls that processor’s + process(&*fs, rel, &content_dir), which returns + Option<Document>. +
  • +
  • + If a document is returned, its content_type is overridden + based on the matching content rule (via + determine_content_type), and its depth is set to + the number of path components minus 1. +
  • +
  • The document is added to the result list.
  • +
+

+ If no processor matches, the file is ignored. If a processor returns + Ok(None), the file is skipped. If any processor returns an + error, the whole processing fails. +

+

Content Type Determination

+

+ determine_content_type(rel) iterates through the + config.content_rules in reverse order (so later + rules take precedence) and returns the name of the first rule + whose pattern matches the relative path. If no rule matches, the + content type defaults to "page". +

+

Rendering

+

+ render_document calls template_for_document(doc) to + get the template name: +

+
    +
  • + Iterates through content rules, finds the rule whose + name equals doc.content_type. +
  • +
  • + If doc.is_list and the rule has a list_template, + that template is used. +
  • +
  • Otherwise, the rule’s template is used.
  • +
  • + If no rule is found, returns + Error::Generation("no content rule found for type + '...'"). +
  • +
+

The actual rendering uses:

+
    +
  • + context_builder.build_context(&config, doc) to get a + RenderContext. +
  • +
  • + renderer.render(template, &*ctx) to produce the HTML + string. +
  • +
  • + write_output(output_base, doc, html.as_bytes()) to write the + result. +
  • +
+

List Generation

+

+ When generating list pages, the pipeline creates a Metadata with + title = content_type and empty description. Then constructs a + Document with: +

+
    +
  • + body: empty string (the list template is expected to use + page_list_items). +
  • +
  • + url: "{content_type}/index.html". +
  • +
  • + output_path: + "{content_type}/index.html". +
  • +
  • + source_path: + PathBuf::from("__list__") (placeholder). +
  • +
  • depth: 1.
  • +
  • content_type: same as the content type.
  • +
  • is_list: true.
  • +
+

+ Then sets list_items to the cloned vector of documents of that + type, and renders using the list template. +

+

Static Assets

+

+ The static directory from config.build.static_dir is copied + verbatim. The destination is output_base joined with the static + directory’s base name (e.g., if static_dir = "static", + files are copied to output_base/static/). Directories are + recursively copied. +

+

Generators

+

+ After all documents and static files are in place, each registered + Generator is invoked with &self and + output_base. This allows adding custom files like RSS feeds, + sitemaps, etc. +

+
+

Error Handling

+

+ librawssg_compiler uses librawssg_error::Error for + all fallible operations. The common error variants encountered: +

+
    +
  • + Error::Config – For configuration issues (e.g., missing + renderer, invalid config file). +
  • +
  • Error::Validation – From Config::validate.
  • +
  • + Error::Generation – For errors during pipeline execution + (e.g., write failures, unsafe output path). +
  • +
  • + Error::Io – From filesystem operations (though these may be + wrapped in Error::Generation in some places). +
  • +
  • Error::Render – From template rendering.
  • +
+

+ Methods return Result<T> (alias for + std::result::Result<T, librawssg_error::Error>). +

+
+

Complete Example from Tests

+

+ The test file full_compiler_test.rs demonstrates a full working + pipeline with mock components. Below is a simplified but complete example. +

+

Example Setup

+

Define mock renderer, context, context builder, and processor:

+
use librawssg_compiler::{PipelineBuilder, TeraContextBuilder};
+use librawssg_config::{Config, ContentRule};
+use librawssg_fs::{FileSystem, RealFs};
+use librawssg_handler::{Document, Metadata, Processor};
+use librawssg_templates::{RenderContext, Renderer};
+use std::path::{Path, PathBuf};
+
+// Mock renderer: returns "rendered:{template_name}"
+struct MockRenderer;
+impl Renderer for MockRenderer {
+    fn render(&self, template_name: &str, _ctx: &dyn RenderContext) -> Result<String> {
+        Ok(format!("rendered:{template_name}"))
+    }
+}
+
+// Mock context
+struct MockContext;
+impl RenderContext for MockContext {
+    fn as_any(&self) -> &dyn Any { self }
+    fn as_mut_any(&mut self) -> &mut dyn Any { self }
+}
+
+// Mock context builder
+struct MockContextBuilder;
+impl ContextBuilder for MockContextBuilder {
+    fn build_context(&self, _config: &Config, _doc: &Document) -> Result<Box<dyn RenderContext>> {
+        Ok(Box::new(MockContext))
+    }
+}
+
+// Processor that handles .html files
+struct RawHtmlProcessor;
+impl Processor for RawHtmlProcessor {
+    fn name(&self) -> &'static str { "raw-html" }
+    fn can_process(&self, relative_path: &Path, _original_path: &Path) -> bool {
+        relative_path.extension().is_some_and(|ext| ext == "html")
+    }
+    fn process(&self, fs: &dyn FileSystem, relative_path: &Path, content_dir: &Path) -> Result<Option<Document>> {
+        let full_path = content_dir.join(relative_path);
+        let content = fs.read_to_string(&full_path)?;
+        let url = relative_path.with_extension("html").to_string_lossy().to_string();
+        let output_path = PathBuf::from(&url);
+        let metadata = Metadata::new("Test", "Description")?;
+        let doc = Document::new(
+            metadata,
+            content,
+            url,
+            output_path,
+            relative_path.to_path_buf(),
+            0,
+            "page".to_string(),
+            false,
+        )?;
+        Ok(Some(doc))
+    }
+}
+
+// Config with one rule
+fn setup_config() -> Config {
+    let mut config = Config::new().with_site_name("Compiler Test");
+    config.add_content_rule(ContentRule::new("page", "**/*.html", "base"));
+    config
+}
+
+// Build pipeline
+fn build_pipeline(content_dir: &Path, output_dir: &Path) -> Result<Pipeline> {
+    PipelineBuilder::new()
+        .config(setup_config())
+        .content_dir(content_dir)
+        .output_dir(output_dir)
+        .with_fs(Box::new(RealFs))
+        .with_renderer(Box::new(MockRenderer))
+        .with_context_builder(Box::new(MockContextBuilder))
+        .add_processor(Box::new(RawHtmlProcessor))
+        .build()
+}
+
+

Running the Pipeline

+
let tmp = tempfile::TempDir::new()?;
+let content_dir = tmp.path().join("content");
+let output_dir = tmp.path().join("dist");
+std::fs::create_dir_all(&content_dir)?;
+std::fs::write(content_dir.join("index.html"), "<h1>Home</h1>")?;
+
+let pipeline = build_pipeline(&content_dir, &output_dir)?;
+pipeline.run()?;
+
+

Verifying Output

+
let output_file = output_dir.join("index.html");
+assert!(output_file.exists());
+let content = std::fs::read_to_string(output_file)?;
+assert_eq!(content, "rendered:base");
+
+
+

Testing Suite Overview

+

+ The test file tests/full_compiler_test.rs contains comprehensive + integration tests covering: +

+
    +
  • Generation of a single page.
  • +
  • + Content type rules (different templates for different content types). +
  • +
  • List page generation.
  • +
  • Static asset copying.
  • +
  • Custom generators.
  • +
  • Atomic replacement of existing output directory.
  • +
  • Handling empty content directory.
  • +
  • Skipping files that no processor handles.
  • +
  • + Error cases: missing renderer, missing context builder, invalid config. +
  • +
+

+ Each test uses temporary directories (tempfile) and mock + implementations to isolate components. The tests serve as executable examples + of how to configure and run the pipeline. +

+
+

Conclusion

+

+ librawssg_compiler is the central execution engine of the static + site generator. It provides a flexible builder to assemble the necessary + components, a robust Pipeline that orchestrates all steps, and + extension points via Processor, Renderer, + ContextBuilder, and Generator. The pattern matching + function and atomic output generation ensure correctness and safety. The + comprehensive test suite demonstrates practical usage and edge cases. +

+

For further details, refer to the source code and the test file.

diff --git a/docs/src/content/api/config.raw b/docs/src/content/api/config.raw index e69de29..c9b0b22 100644 --- a/docs/src/content/api/config.raw +++ b/docs/src/content/api/config.raw @@ -0,0 +1,1071 @@ +

librawssg_config

+

+ Version: 1.0.0 (implied)
Crate name: + librawssg_config
Description: Defines + configuration data structures for the librawssg static site + generator. Provides Config, SiteConfig, + BuildConfig, ContentRule, and + NavItem types, along with serialization/deserialization support + (YAML and JSON) and validation logic. +

+
+

Table of Contents

+
    +
  1. Overview
  2. +
  3. Modules
  4. +
  5. + BuildConfig + +
  6. +
  7. + ContentRule + +
  8. +
  9. + NavItem + +
  10. +
  11. + SiteConfig + +
  12. +
  13. + Config + +
  14. +
  15. Error Handling
  16. +
  17. Serialization Details
  18. +
  19. Examples from Tests
  20. +
  21. Testing Suite Overview
  22. +
  23. Conclusion
  24. +
+
+

Overview

+

+ librawssg_config provides the central configuration types used + by the static site generator. The main Config struct combines + site settings (SiteConfig), build paths + (BuildConfig), content processing rules + (ContentRule), and arbitrary extra data. All types are + serializable/deserializable via serde, enabling configuration to + be read from and written to YAML or JSON files. +

+

+ The types are designed with sensible defaults and include a + validate() method to ensure the configuration is internally + consistent and safe (e.g., preventing path traversal in patterns). +

+
+

Modules

+

+ The crate root (lib.rs) declares the following public modules: +

+
    +
  • build – Contains BuildConfig.
  • +
  • config – Contains Config.
  • +
  • content_rule – Contains ContentRule.
  • +
  • nav – Contains NavItem.
  • +
  • site – Contains SiteConfig.
  • +
+

All public types are re‑exported at the crate root for convenience:

+
pub use build::BuildConfig;
+pub use config::Config;
+pub use content_rule::ContentRule;
+pub use nav::NavItem;
+pub use site::SiteConfig;
+
+
+

Struct BuildConfig

+

Represents filesystem path configuration for the build process.

+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[non_exhaustive]
+pub struct BuildConfig {
+    #[serde(default = "default_content_dir")]
+    pub content_dir: String,
+    #[serde(default = "default_output_dir")]
+    pub output_dir: String,
+    #[serde(default = "default_templates_dir")]
+    pub templates_dir: String,
+    #[serde(default = "default_static_dir")]
+    pub static_dir: String,
+}
+
+

BuildConfig Fields

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDefault ValueDescription
content_dirString"content"Directory containing source content files.
output_dirString"dist"Directory where generated site output will be written.
templates_dirString"templates"Directory containing template files.
static_dirString"static"Directory containing static assets (copied as-is).
+

+ Note: #[non_exhaustive] prevents external + crates from exhaustively matching or constructing with a struct literal. Use + the provided constructors or update syntax. +

+

BuildConfig::new

+
#[must_use]
+pub fn new() -> Self
+
+

+ Purpose: Creates a BuildConfig with default + values (identical to BuildConfig::default()). +

+

+ Returns: A new BuildConfig with all fields set + to their defaults. +

+

Example:

+
let build = BuildConfig::new();
+assert_eq!(build.content_dir, "content");
+
+

BuildConfig Default

+

The Default trait is implemented with the following values:

+
    +
  • content_dir: "content"
  • +
  • output_dir: "dist"
  • +
  • templates_dir: "templates"
  • +
  • static_dir: "static"
  • +
+

+ These defaults can be overridden during deserialization; missing fields in + serialized data will fall back to these defaults (thanks to + #[serde(default = "...")]). +

+

BuildConfig Serialization

+

+ BuildConfig derives Serialize and + Deserialize. When deserializing from YAML/JSON, any omitted + fields will use the specified default functions. This allows partial + configuration. +

+

Example (from tests):

+
let yaml = "content_dir: custom_content\noutput_dir: public\n";
+let build: BuildConfig = serde_yaml::from_str(yaml)?;
+assert_eq!(build.content_dir, "custom_content");
+assert_eq!(build.templates_dir, "templates"); // default
+
+
+

Struct ContentRule

+

Defines how a certain group of content files should be processed.

+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
+#[non_exhaustive]
+pub struct ContentRule {
+    pub name: String,
+    pub pattern: String,
+    pub template: String,
+    #[serde(default)]
+    pub list_template: Option<String>,
+    #[serde(default)]
+    pub list_enabled: bool,
+    #[serde(default)]
+    pub extra: HashMap<String, serde_json::Value>,
+}
+
+

ContentRule Fields

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDefaultDescription
nameString"" + Unique identifier for the rule (e.g., "blog", + "page"). +
patternString"" + Glob pattern matching content files (e.g., + "**/*.md"). Must not contain ... +
templateString""Name of the template to use for rendering each matched file.
list_templateOption<String>None + Optional template name for rendering list pages (e.g., index pages). +
list_enabledboolfalseWhether list generation is enabled for this rule.
extraHashMap<String, serde_json::Value>empty mapArbitrary extra data associated with the rule.
+

ContentRule::new

+
#[must_use]
+pub fn new(
+    name: impl Into<String>,
+    pattern: impl Into<String>,
+    template: impl Into<String>,
+) -> Self
+
+

+ Purpose: Creates a ContentRule with the + required fields (name, pattern, + template). All other fields are set to their defaults. +

+

Parameters:

+
    +
  • name: The rule name (converted to String).
  • +
  • + pattern: The glob pattern (converted to String). +
  • +
  • + template: The template name (converted to + String). +
  • +
+

Returns: A new ContentRule instance.

+

Example:

+
let rule = ContentRule::new("blog", "**/*.md", "post");
+assert_eq!(rule.name, "blog");
+assert!(!rule.list_enabled);
+
+

ContentRule Default

+

The Default implementation (derived) sets:

+
    +
  • + name, pattern, template: empty + strings +
  • +
  • list_template: None
  • +
  • list_enabled: false
  • +
  • extra: empty map
  • +
+

ContentRule Serialization

+

+ Serializes/deserializes with serde. Missing optional fields + default as specified. The extra map can hold any JSON‑compatible + values. +

+

Example:

+
let mut rule = ContentRule::new("page", "**/*.html", "base");
+rule.list_enabled = true;
+rule.list_template = Some("list".into());
+rule.extra.insert("key".into(), json!("value"));
+let yaml = serde_yaml::to_string(&rule)?;
+let parsed: ContentRule = serde_yaml::from_str(&yaml)?;
+assert_eq!(rule, parsed);
+
+
+

Struct NavItem

+

Represents an item in a navigation menu (navbar or sidebar).

+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
+#[non_exhaustive]
+pub struct NavItem {
+    pub label: String,
+    pub url: String,
+    pub children: Vec<Self>,
+}
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDefaultDescription
labelString""Display text for the navigation link.
urlString""URL the link points to (relative or absolute).
childrenVec<NavItem>emptyNested sub‑items, enabling hierarchical menus.
+ +
#[must_use]
+pub fn new(label: impl Into<String>, url: impl Into<String>) -> Self
+
+

+ Purpose: Creates a NavItem with a label and + URL. The children vector starts empty. +

+

Parameters:

+
    +
  • label: Display label.
  • +
  • url: Target URL.
  • +
+

Returns: A new NavItem.

+

Example:

+
let item = NavItem::new("Home", "/");
+assert_eq!(item.label, "Home");
+assert!(item.children.is_empty());
+
+ +

+ NavItem::default() creates an item with empty label, empty URL, + and no children. +

+ +

+ Supports serialization and deserialization via serde. Nested + children are handled recursively. +

+

Example:

+
let parent = NavItem::new("Docs", "/docs");
+parent.children.push(NavItem::new("API", "/docs/api"));
+let json = serde_json::to_string(&parent)?;
+let parsed: NavItem = serde_json::from_str(&json)?;
+assert_eq!(parent, parsed);
+
+
+

Struct SiteConfig

+

Holds global site metadata and navigation structures.

+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[non_exhaustive]
+pub struct SiteConfig {
+    #[serde(default)]
+    pub navbar: Vec<super::NavItem>,
+    #[serde(default)]
+    pub sidebar: Vec<super::NavItem>,
+    #[serde(default = "default_site_name")]
+    pub site_name: String,
+    #[serde(default)]
+    pub description: Option<String>,
+    #[serde(default = "default_language")]
+    pub language: Option<String>,
+    #[serde(default)]
+    pub base_url: Option<String>,
+    #[serde(default)]
+    pub author: Option<String>,
+    #[serde(default)]
+    pub repo_url: Option<String>,
+    #[serde(default)]
+    pub license: Option<String>,
+    #[serde(default)]
+    pub extra: HashMap<String, serde_json::Value>,
+}
+
+

SiteConfig Fields

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDefaultDescription
navbarVec<NavItem>emptyNavigation items for the top bar.
sidebarVec<NavItem>emptyNavigation items for the sidebar.
site_nameString"librawssg"The name of the website.
descriptionOption<String>NoneShort site description.
languageOption<String>Some("en") + Site language code (e.g., "en", + "id"). +
base_urlOption<String>None + Base URL for the site; must start with http:// or + https:// if set. +
authorOption<String>NoneDefault author name.
repo_urlOption<String>NoneURL to the source repository.
licenseOption<String>NoneLicense identifier (e.g., "MIT").
extraHashMap<String, serde_json::Value>empty mapArbitrary extra site‑wide metadata.
+

SiteConfig::new

+
#[must_use]
+pub fn new(site_name: impl Into<String>) -> Self
+
+

+ Purpose: Creates a SiteConfig with a custom + site name. All other fields are set to their defaults (navbar/sidebar empty, + language Some("en"), etc.). +

+

Parameters:

+
    +
  • + site_name: The site name (converted to String). +
  • +
+

Returns: A new SiteConfig.

+

Example:

+
let site = SiteConfig::new("My Site");
+assert_eq!(site.site_name, "My Site");
+assert_eq!(site.language.as_deref(), Some("en"));
+
+

SiteConfig Default

+

SiteConfig::default() sets:

+
    +
  • site_name: "librawssg"
  • +
  • language: Some("en")
  • +
  • All Option fields: None
  • +
  • Vectors and map: empty
  • +
+

SiteConfig Serialization

+

+ Supports YAML/JSON. Missing fields during deserialization use defaults. The + language default is provided by a custom function. +

+

Example:

+
let mut site = SiteConfig::new("Test");
+site.extra.insert("foo".into(), json!("bar"));
+let yaml = serde_yaml::to_string(&site)?;
+let parsed: SiteConfig = serde_yaml::from_str(&yaml)?;
+assert_eq!(site, parsed);
+
+
+

Struct Config

+

The top‑level configuration combining all other components.

+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
+#[non_exhaustive]
+pub struct Config {
+    pub site: SiteConfig,
+    pub build: BuildConfig,
+    pub content_rules: Vec<ContentRule>,
+    #[serde(default)]
+    pub extra: HashMap<String, serde_json::Value>,
+}
+
+

Config Fields

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDefaultDescription
siteSiteConfig + SiteConfig::default() (site name "librawssg") + Global site configuration.
buildBuildConfigBuildConfig::default()Build path settings.
content_rulesVec<ContentRule>emptyList of content processing rules.
extraHashMap<String, serde_json::Value>empty mapArbitrary top‑level extra data.
+

Config::new

+
#[must_use]
+pub fn new() -> Self
+
+

+ Purpose: Creates a Config with all fields + defaulted. Equivalent to Config::default(). +

+

Returns: A new Config.

+

Example:

+
let config = Config::new();
+assert!(config.content_rules.is_empty());
+
+

Config::with_site_name

+
#[must_use]
+pub fn with_site_name(mut self, name: impl Into<String>) -> Self
+
+

+ Purpose: Builder‑style method that sets the + site.site_name and returns the modified Config. +

+

Parameters:

+
    +
  • name: The new site name.
  • +
+

+ Returns: The same Config with updated site + name. +

+

Example:

+
let config = Config::new().with_site_name("My Awesome Site");
+assert_eq!(config.site.site_name, "My Awesome Site");
+
+

Config Rule Management

+

add_content_rule

+
pub fn add_content_rule(&mut self, rule: ContentRule)
+
+

+ Purpose: Appends a ContentRule to the + content_rules vector. +

+

Parameters:

+
    +
  • rule: The rule to add.
  • +
+

Example:

+
config.add_content_rule(ContentRule::new("blog", "**/*.md", "post"));
+
+

find_rule_by_name

+
#[must_use]
+pub fn find_rule_by_name(&self, name: &str) -> Option<&ContentRule>
+
+

+ Purpose: Searches for a content rule by its + name field. +

+

Parameters:

+
    +
  • name: The rule name to search for.
  • +
+

+ Returns: Some(&ContentRule) if found, + otherwise None. +

+

Example:

+
if let Some(rule) = config.find_rule_by_name("blog") {
+    // ...
+}
+
+

remove_rule_by_name

+
pub fn remove_rule_by_name(&mut self, name: &str) -> Option<ContentRule>
+
+

+ Purpose: Removes and returns the first content rule whose + name matches the given string. +

+

Parameters:

+
    +
  • name: The name of the rule to remove.
  • +
+

+ Returns: Some(ContentRule) if found and + removed, otherwise None. +

+

Example:

+
let removed = config.remove_rule_by_name("blog");
+
+

has_duplicate_rule_names

+
#[must_use]
+pub fn has_duplicate_rule_names(&self) -> bool
+
+

+ Purpose: Checks whether any two content rules share the same + name. +

+

+ Returns: true if duplicates exist, + false otherwise. +

+

+ Implementation: Uses a HashSet to detect + duplicates; O(n) time. +

+

Example:

+
if config.has_duplicate_rule_names() {
+    // handle error
+}
+
+

Config::validate

+
pub fn validate(&self) -> Result<()>
+
+

+ Purpose: Performs comprehensive validation of the + configuration. Returns Ok(()) if the configuration is valid, + otherwise an Err(Error::Validation(...)) with a descriptive + message. +

+

Validation Rules:

+
    +
  1. site.site_name must not be empty or whitespace‑only.
  2. +
  3. At least one content rule must be defined.
  4. +
  5. No duplicate content rule names.
  6. +
  7. + For each content rule (indexed from 0): +
      +
    • name must not be empty or whitespace‑only.
    • +
    • pattern must not be empty or whitespace‑only.
    • +
    • + pattern must not contain the substring + ".." (to prevent path traversal). +
    • +
    • template must not be empty or whitespace‑only.
    • +
    +
  8. +
  9. + If site.base_url is Some, it must start with + "http://" or "https://". +
  10. +
+

Returns:

+
    +
  • Ok(()) if all checks pass.
  • +
  • + Err(Error::Validation(message)) on the first failure + encountered. +
  • +
+

Example (from tests):

+
let config = valid_config(); // has one rule
+assert!(config.validate().is_ok());
+
+

Config Serialization

+

+ The Config struct can be serialized to and deserialized from + YAML and JSON via convenience methods. +

+

from_yaml_str

+
pub fn from_yaml_str(yaml: &str) -> Result<Self>
+
+

+ Purpose: Parses a YAML string into a Config. +

+

Parameters:

+
    +
  • yaml: YAML content as a string.
  • +
+

Returns:

+
    +
  • Ok(Config) on success.
  • +
  • + Err(Error::Config) if the YAML is invalid (with the + underlying serde_yaml error message included). +
  • +
+

Example:

+
let config = Config::from_yaml_str("site:\n  site_name: Test\n")?;
+
+

to_yaml_string

+
pub fn to_yaml_string(&self) -> Result<String>
+
+

+ Purpose: Serializes the Config to a YAML + string. +

+

Returns:

+
    +
  • Ok(String) with YAML representation.
  • +
  • Err(Error::Serialization) if serialization fails.
  • +
+

from_json_str

+
pub fn from_json_str(json: &str) -> Result<Self>
+
+

+ Purpose: Parses a JSON string into a Config. +

+

Parameters:

+
    +
  • json: JSON content as a string.
  • +
+

Returns:

+
    +
  • Ok(Config) on success.
  • +
  • Err(Error::Config) if the JSON is invalid.
  • +
+

to_json_string

+
pub fn to_json_string(&self) -> Result<String>
+
+

+ Purpose: Serializes the Config to a JSON + string. +

+

Returns:

+
    +
  • Ok(String) with JSON representation.
  • +
  • Err(Error::Serialization) on failure.
  • +
+

Example roundtrip:

+
let yaml = config.to_yaml_string()?;
+let parsed = Config::from_yaml_str(&yaml)?;
+assert_eq!(config, parsed);
+
+
+

Error Handling

+

+ The Config methods and validate use + librawssg_error::Result<T> (alias for + std::result::Result<T, librawssg_error::Error>). The + relevant error variants used in this crate are: +

+
    +
  • Error::Config – For YAML/JSON deserialization failures.
  • +
  • Error::Serialization – For serialization failures.
  • +
  • + Error::Validation – For validate() failures. +
  • +
+

+ All error messages are descriptive and include context (e.g., which rule is + invalid, what condition was violated). +

+
+

Serialization Details

+

+ All configuration structs derive Serialize and + Deserialize from serde. Default values are applied + during deserialization for missing fields via + #[serde(default = "function")] or + #[serde(default)] (which uses + Default::default() for the field type). +

+
    +
  • BuildConfig: Each field has a custom default function.
  • +
  • + ContentRule: Optional fields use + #[serde(default)]. +
  • +
  • + NavItem: No special defaults; all fields are required in + input, but Default is derived for programmatic creation. +
  • +
  • + SiteConfig: site_name has a custom default, + language has a custom default returning + Some("en"), others use + #[serde(default)]. +
  • +
  • + Config: site and build are required + in YAML/JSON (they don't have #[serde(default)] at the + field level, but the struct itself derives Default and the + fields are not marked optional; however, when deserializing a top‑level + Config, missing site or build will + cause an error because they are not optional. In practice, configuration + files should include these sections or rely on the + Default implementation when constructing programmatically). + Important: The Config struct's fields + are not marked with #[serde(default)], so during + deserialization, missing site or build will + cause a parse error. Users must provide at least site and + build keys (can be empty maps to get defaults via the inner + structs' own defaults). The content_rules and + extra fields have #[serde(default)] so they can + be omitted. +
  • +
+
+

Examples from Tests

+

+ The test suite provides extensive examples for each type. Below are selected + snippets. +

+

BuildConfig

+
let build = BuildConfig::default();
+assert_eq!(build.output_dir, "dist");
+
+let yaml = "content_dir: custom_content\noutput_dir: public\n";
+let build: BuildConfig = serde_yaml::from_str(yaml)?;
+assert_eq!(build.content_dir, "custom_content");
+assert_eq!(build.templates_dir, "templates");
+
+

ContentRule

+
let mut rule = ContentRule::new("page", "**/*.html", "base");
+rule.list_enabled = true;
+rule.list_template = Some("list".into());
+rule.extra.insert("key".into(), json!("value"));
+
+ +
let mut parent = NavItem::new("Docs", "/docs");
+parent.children.push(NavItem::new("API", "/docs/api"));
+
+

SiteConfig

+
let site = SiteConfig::new("My Site");
+assert_eq!(site.language.as_deref(), Some("en"));
+
+

Config Validation

+
let mut config = Config::new().with_site_name("My Site");
+config.add_content_rule(ContentRule::new("page", "**/*.html", "base"));
+assert!(config.validate().is_ok());
+
+config.site.base_url = Some("ftp://example.com".to_string());
+assert!(config.validate().is_err());
+
+
+

Testing Suite Overview

+

The crate includes five test files:

+
    +
  • + build_tests.rs – Tests BuildConfig defaults, + new(), and YAML roundtrip. +
  • +
  • + config_tests.rs – Extensive tests for Config: + creation, rule management, validation (all rules), YAML/JSON roundtrip, + and error cases. +
  • +
  • + content_rule_tests.rs – Tests + ContentRule constructor, defaults, and serialization. +
  • +
  • + nav_tests.rs – Tests NavItem constructor, + defaults, children, and serialization. +
  • +
  • + site_tests.rs – Tests SiteConfig constructor, + defaults, and serialization. +
  • +
+

+ All tests are self‑contained and use the must! macro to unwrap + results with a helpful message on failure. They serve as executable examples + of the API usage. +

+
+

Conclusion

+

+ librawssg_config provides a clean and extensible configuration + system for a static site generator. With sensible defaults, comprehensive + validation, and full serde support, it covers the needs of both simple and + complex site configurations. The types are designed for ergonomic use and can + be easily loaded from YAML or JSON files, making it straightforward to define + site‑wide settings, build paths, navigation, and content processing rules. +

+

For further details, refer to the source code and test files.

diff --git a/docs/src/content/api/error.raw b/docs/src/content/api/error.raw index e69de29..f21a2ab 100644 --- a/docs/src/content/api/error.raw +++ b/docs/src/content/api/error.raw @@ -0,0 +1,893 @@ +

librawssg_error

+

+ Version: 1.0.0 (implied)
Crate name: + librawssg_error
Description: Defines a + comprehensive error enum and Result alias for use across the + librawssg static site generator ecosystem. The error type is + built with thiserror for ergonomic Display and + Error implementations, supports source chaining, and is designed + to cover all common failure modes in SSG operations. +

+
+

Table of Contents

+
    +
  1. Overview
  2. +
  3. Dependencies
  4. +
  5. + The Error Enum + +
  6. +
  7. + Variants + +
  8. +
  9. + Result<T> Type Alias +
  10. +
  11. + Error Sources and std::error::Error +
  12. +
  13. + Conversion from std::io::Error +
  14. +
  15. + Usage Examples from Tests + +
  16. +
  17. Guidelines for Error Usage
  18. +
  19. Testing Suite Overview
  20. +
  21. Conclusion
  22. +
+
+

Overview

+

+ The librawssg_error crate provides a single, unified error type + for the entire static site generator ecosystem. Instead of having each module + define its own error types, they all share this Error enum, + which categorizes failures into well‑defined variants. The enum is derived + with thiserror::Error, giving each variant an automatic + Display implementation based on a custom message pattern, and an + automatic std::error::Error implementation that preserves source + chains when applicable. +

+

+ The crate also exports a Result<T> type alias, simplifying + function signatures throughout the codebase. +

+

Key features:

+
    +
  • + Rich error categories – 15 distinct variants covering + I/O, configuration, parsing, rendering, processing, generation, security, + and internal errors. +
  • +
  • + Source chaining – The Metadata variant can + wrap an underlying error (e.g., a YAML parsing error) and expose it via + source(). +
  • +
  • + Convenient conversion – + From<std::io::Error> allows using the + ? operator directly in functions returning + Result<T, Error>. +
  • +
  • + Non‑exhaustive – The enum is marked + #[non_exhaustive], enabling future additions without breaking + downstream code. +
  • +
+
+

Dependencies

+
    +
  • + thiserror – Provides the #[derive(Error)] macro + that generates Display and Error implementations + from the attributes. +
  • +
  • + core::error::Error (or std::error::Error) – Used + as a trait object for the source field in the + Metadata variant. +
  • +
+

No other external crates are required.

+
+

The Error Enum

+

Enum Definition

+
use core::error::Error as CoreError;
+use std::path::PathBuf;
+use thiserror::Error;
+
+#[derive(Debug, Error)]
+#[non_exhaustive]
+pub enum Error {
+    #[error("I/O error: {0}")]
+    Io(#[from] std::io::Error),
+
+    #[error("Configuration error: {0}")]
+    Config(String),
+
+    #[error("Failed to parse metadata in {path}")]
+    Metadata {
+        path: PathBuf,
+        #[source]
+        source: Box<dyn CoreError + Send + Sync>,
+    },
+
+    #[error("Template rendering error: {0}")]
+    Render(String),
+
+    #[error("Content processor error: {0}")]
+    Processor(String),
+
+    #[error("Generator error: {0}")]
+    Generator(String),
+
+    #[error("Path traversal attempt detected: {0}")]
+    PathTraversal(String),
+
+    #[error("Missing configuration key: {0}")]
+    MissingConfig(String),
+
+    #[error("Site generation error: {0}")]
+    Generation(String),
+
+    #[error("Resource not found: {0}")]
+    NotFound(String),
+
+    #[error("Serialization error: {0}")]
+    Serialization(String),
+
+    #[error("Validation error: {0}")]
+    Validation(String),
+
+    #[error("Duplicate value: {0}")]
+    Duplicate(String),
+
+    #[error("Invalid state: {0}")]
+    InvalidState(String),
+
+    #[error("Internal error: {0}")]
+    Internal(String),
+}
+
+

Attributes and Derives

+
    +
  • + #[derive(Debug, Error)] – Derives + Debug and std::error::Error. The + Error derive from thiserror also generates a + Display implementation based on the + #[error("...")] attributes. +
  • +
  • + #[non_exhaustive] – Indicates that the enum + may gain new variants in future releases. Downstream crates must not + exhaustively match on this enum; they must include a wildcard arm + (_) when matching. +
  • +
+

Non‑Exhaustive

+

Because the enum is non‑exhaustive, external code cannot write:

+
match err {
+    Error::Io(_) => ...,
+    Error::Config(_) => ...,
+    // all variants...
+}
+
+

without including a catch‑all arm:

+
match err {
+    Error::Io(_) => ...,
+    Error::Config(_) => ...,
+    // ...
+    _ => { /* handle unknown future variants */ }
+}
+
+

This ensures forward compatibility.

+
+

Variants

+

Io

+
#[error("I/O error: {0}")]
+Io(#[from] std::io::Error),
+
+
    +
  • + Description: Wraps a standard library I/O error. Used for + any filesystem operation failure (reading, writing, deleting, etc.). +
  • +
  • + Fields: Contains a single std::io::Error. +
  • +
  • + Display: + "I/O error: {underlying_io_error_message}". +
  • +
  • + #[from]: Automatically provides + From<std::io::Error> for Error, allowing the + ? operator in functions returning + Result<T, Error>. +
  • +
  • + Source: source() returns + Some(&io_error) because + std::io::Error implements std::error::Error. +
  • +
+

Example:

+
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file missing");
+let err = Error::Io(io_err);
+assert_eq!(err.to_string(), "I/O error: file missing");
+
+
+

Config

+
#[error("Configuration error: {0}")]
+Config(String),
+
+
    +
  • + Description: Indicates a problem with configuration data + (e.g., invalid YAML, malformed settings). +
  • +
  • + Fields: A String containing a human‑readable + description. +
  • +
  • + Display: + "Configuration error: {message}". +
  • +
  • + Source: None (no underlying error is + stored). +
  • +
+

Example:

+
let err = Error::Config("invalid YAML".to_string());
+assert_eq!(err.to_string(), "Configuration error: invalid YAML");
+
+
+

Metadata

+
#[error("Failed to parse metadata in {path}")]
+Metadata {
+    path: PathBuf,
+    #[source]
+    source: Box<dyn CoreError + Send + Sync>,
+},
+
+
    +
  • + Description: Used when parsing metadata (e.g., front + matter) fails. Stores the path of the problematic file and the original + error. +
  • +
  • + Fields: +
      +
    • + path: PathBuf – The path to the source file where + metadata parsing failed. +
    • +
    • + source: Box<dyn CoreError + Send + Sync> – The + underlying error that caused the failure (boxed trait object). +
    • +
    +
  • +
  • + Display: + "Failed to parse metadata in {path}". The + {path} placeholder prints the PathBuf using its + Display implementation. +
  • +
  • + Source: source() returns + Some(&*source) (the boxed error as a + &dyn Error), enabling error chain inspection. +
  • +
  • + Note: The #[source] attribute tells + thiserror to use this field as the error source. The + Box<dyn CoreError + Send + Sync> allows storing any + error type that is Send + Sync. +
  • +
+

Example:

+
use std::path::PathBuf;
+
+let source: Box<dyn core::error::Error + Send + Sync> =
+    Box::new(std::io::Error::other("bad yaml"));
+let err = Error::Metadata {
+    path: PathBuf::from("content/post.md"),
+    source,
+};
+
+assert_eq!(err.to_string(), "Failed to parse metadata in content/post.md");
+let as_core_error: &dyn core::error::Error = &err;
+let source_ref = as_core_error.source().unwrap();
+assert_eq!(source_ref.to_string(), "bad yaml");
+
+
+

Render

+
#[error("Template rendering error: {0}")]
+Render(String),
+
+
    +
  • + Description: Signifies an error during template rendering + (e.g., missing variable, template not found, syntax error). +
  • +
  • + Fields: A String describing the rendering + problem. +
  • +
  • + Display: + "Template rendering error: {message}". +
  • +
  • Source: None.
  • +
+

Example:

+
let err = Error::Render("template not found".to_string());
+assert_eq!(err.to_string(), "Template rendering error: template not found");
+
+
+

Processor

+
#[error("Content processor error: {0}")]
+Processor(String),
+
+
    +
  • + Description: Used when a content processor (e.g., + Markdown parser, Sass compiler) fails. +
  • +
  • + Fields: A String with details about the + processor failure. +
  • +
  • + Display: + "Content processor error: {message}". +
  • +
  • Source: None.
  • +
+

Example:

+
let err = Error::Processor("custom processor failed".to_string());
+assert_eq!(err.to_string(), "Content processor error: custom processor failed");
+
+
+

Generator

+
#[error("Generator error: {0}")]
+Generator(String),
+
+
    +
  • + Description: Represents an error in a generator component + (e.g., RSS feed generation, sitemap creation). +
  • +
  • + Fields: A String describing the generator + error. +
  • +
  • + Display: + "Generator error: {message}". +
  • +
  • Source: None.
  • +
+

Example:

+
let err = Error::Generator("RSS generation failed".to_string());
+assert_eq!(err.to_string(), "Generator error: RSS generation failed");
+
+
+

PathTraversal

+
#[error("Path traversal attempt detected: {0}")]
+PathTraversal(String),
+
+
    +
  • + Description: Indicates a path traversal attack was + attempted or a path escapes a safe root directory. +
  • +
  • + Fields: A String containing the offending + path or description. +
  • +
  • + Display: + "Path traversal attempt detected: {message}". +
  • +
  • Source: None.
  • +
+

Example:

+
let err = Error::PathTraversal("../escape".to_string());
+assert_eq!(err.to_string(), "Path traversal attempt detected: ../escape");
+
+
+

MissingConfig

+
#[error("Missing configuration key: {0}")]
+MissingConfig(String),
+
+
    +
  • + Description: Signals that a required configuration key is + absent. +
  • +
  • + Fields: A String naming the missing key. +
  • +
  • + Display: + "Missing configuration key: {key}". +
  • +
  • Source: None.
  • +
+

Example:

+
let err = Error::MissingConfig("base_url".to_string());
+assert_eq!(err.to_string(), "Missing configuration key: base_url");
+
+
+

Generation

+
#[error("Site generation error: {0}")]
+Generation(String),
+
+
    +
  • + Description: A general error during the site generation + phase (e.g., failed to write output). +
  • +
  • + Fields: A String with more information. +
  • +
  • + Display: + "Site generation error: {message}". +
  • +
  • Source: None.
  • +
+

Example:

+
let err = Error::Generation("output write failed".to_string());
+assert_eq!(err.to_string(), "Site generation error: output write failed");
+
+
+

NotFound

+
#[error("Resource not found: {0}")]
+NotFound(String),
+
+
    +
  • + Description: Used when a requested resource (file, asset, + page) cannot be found. +
  • +
  • + Fields: A String identifying the missing + resource. +
  • +
  • + Display: + "Resource not found: {resource}". +
  • +
  • Source: None.
  • +
+

Example:

+
let err = Error::NotFound("asset.css".to_string());
+assert_eq!(err.to_string(), "Resource not found: asset.css");
+
+
+

Serialization

+
#[error("Serialization error: {0}")]
+Serialization(String),
+
+
    +
  • + Description: Indicates a failure during serialization or + deserialization (e.g., JSON conversion error). +
  • +
  • + Fields: A String describing the + serialization problem. +
  • +
  • + Display: + "Serialization error: {message}". +
  • +
  • Source: None.
  • +
+

Example:

+
let err = Error::Serialization("invalid JSON".to_string());
+assert_eq!(err.to_string(), "Serialization error: invalid JSON");
+
+
+

Validation

+
#[error("Validation error: {0}")]
+Validation(String),
+
+
    +
  • + Description: Represents a validation failure (e.g., + invalid input, constraint violation). +
  • +
  • + Fields: A String explaining what failed + validation. +
  • +
  • + Display: + "Validation error: {message}". +
  • +
  • Source: None.
  • +
+

Example:

+
let err = Error::Validation("name too long".to_string());
+assert_eq!(err.to_string(), "Validation error: name too long");
+
+
+

Duplicate

+
#[error("Duplicate value: {0}")]
+Duplicate(String),
+
+
    +
  • + Description: Signals that a duplicate value was + encountered where uniqueness was expected (e.g., duplicate key in a map). +
  • +
  • + Fields: A String identifying the duplicated + item. +
  • +
  • + Display: + "Duplicate value: {message}". +
  • +
  • Source: None.
  • +
+

Example:

+
let err = Error::Duplicate("duplicate key".to_string());
+assert_eq!(err.to_string(), "Duplicate value: duplicate key");
+
+
+

InvalidState

+
#[error("Invalid state: {0}")]
+InvalidState(String),
+
+
    +
  • + Description: Indicates an unexpected program state (e.g., + a null where a value is required, inconsistent internal data). +
  • +
  • + Fields: A String describing the invalid + state. +
  • +
  • + Display: + "Invalid state: {message}". +
  • +
  • Source: None.
  • +
+

Example:

+
let err = Error::InvalidState("unexpected null".to_string());
+assert_eq!(err.to_string(), "Invalid state: unexpected null");
+
+
+

Internal

+
#[error("Internal error: {0}")]
+Internal(String),
+
+
    +
  • + Description: Used for internal errors that should not + normally occur (e.g., bugs in the code, unreachable conditions). +
  • +
  • + Fields: A String with details suitable for + debugging. +
  • +
  • + Display: + "Internal error: {message}". +
  • +
  • Source: None.
  • +
+

Example:

+
let err = Error::Internal("bug in code".to_string());
+assert_eq!(err.to_string(), "Internal error: bug in code");
+
+
+

Result<T> Type Alias

+
pub type Result<T> = core::result::Result<T, Error>;
+
+
    +
  • + Purpose: A convenient alias so that functions can return + Result<T> instead of the more verbose + std::result::Result<T, librawssg_error::Error>. +
  • +
  • + Usage: Throughout the librawssg ecosystem, + functions that may fail with any of the above errors use this alias. +
  • +
+

Example:

+
fn read_config(path: &str) -> librawssg_error::Result<String> {
+    let content = std::fs::read_to_string(path)?; // `?` converts io::Error into Error::Io
+    Ok(content)
+}
+
+
+

+ Error Sources and std::error::Error +

+

+ All variants of Error implement + std::error::Error (via thiserror). The + source() method returns: +

+
    +
  • + For Io: Some(&self.0) (the underlying + io::Error). +
  • +
  • + For Metadata: Some(self.source.as_ref()) (the + boxed error). +
  • +
  • For all other variants: None.
  • +
+

+ This allows error chains to be inspected using + std::error::Error::source(). +

+

Example (from integration tests):

+
let source: Box<dyn core::error::Error + Send + Sync> =
+    Box::new(std::io::Error::other("bad yaml"));
+let err = Error::Metadata {
+    path: PathBuf::from("content/post.md"),
+    source,
+};
+
+let as_core_error: &dyn core::error::Error = &err;
+let source_ref = as_core_error.source().unwrap();
+assert_eq!(source_ref.to_string(), "bad yaml");
+
+
+

+ Conversion from std::io::Error +

+

+ The Io variant has the #[from] attribute, which + automatically generates: +

+
impl From<std::io::Error> for Error {
+    fn from(err: std::io::Error) -> Error {
+        Error::Io(err)
+    }
+}
+
+

+ This enables the ? operator to convert + std::io::Error into Error in any function returning + Result<T, Error> (or + librawssg_error::Result<T>). +

+

Example:

+
fn read_file(path: &str) -> Result<String> {
+    let content = std::fs::read_to_string(path)?; // io::Error becomes Error::Io
+    Ok(content)
+}
+
+
+

Usage Examples from Tests

+

+ The test suite provides excellent examples of how to construct and use the + error type. +

+

Display Messages

+

+ Each variant has a test asserting its exact Display output. For + example: +

+
#[test]
+fn display_for_config_error() {
+    let err = Error::Config("invalid YAML".to_string());
+    assert_eq!(err.to_string(), "Configuration error: invalid YAML");
+}
+
+

All 15 variants have similar tests in unit_tests.rs.

+

Using the ? Operator

+

+ The integration test + io_error_propagates_via_question_mark demonstrates how + ? works: +

+
fn read_file(path: &str) -> Result<String> {
+    let content = std::fs::read_to_string(path)?;
+    Ok(content)
+}
+
+#[test]
+fn io_error_propagates_via_question_mark() {
+    let result = read_file("definitely_not_exists.txt");
+    assert!(result.is_err());
+    assert!(matches!(result, Err(Error::Io(_))));
+}
+
+

Source Chain

+

+ The metadata_error_can_hold_boxed_dyn_error test shows how to + store an arbitrary error and retrieve it via source(): +

+
let source: Box<dyn core::error::Error + Send + Sync> =
+    Box::new(std::io::Error::other("bad yaml"));
+let err = Error::Metadata {
+    path: PathBuf::from("content/post.md"),
+    source,
+};
+
+let as_core_error: &dyn core::error::Error = &err;
+let source_ref = as_core_error.source();
+assert!(source_ref.is_some());
+
+

Property Tests

+

+ Property tests verify that the error message always preserves the input + string exactly, regardless of content (including empty strings, newlines, + special characters): +

+
#[test]
+fn config_error_message_preserves_input() {
+    let samples = [
+        "",
+        "short",
+        "a very long error message with symbols !@#$%^&*()",
+        "line1\nline2",
+    ];
+
+    for sample in samples {
+        let err = Error::Config(sample.to_string());
+        assert_eq!(err.to_string(), format!("Configuration error: {sample}"));
+    }
+}
+
+

+ Similar tests exist for PathTraversal and Render. +

+
+

Guidelines for Error Usage

+

+ When writing code in the librawssg ecosystem, follow these + recommendations: +

+
    +
  • + Use the most specific variant that describes the failure. + For example: +
      +
    • I/O failures → Error::Io.
    • +
    • Missing file/resource → Error::NotFound.
    • +
    • Invalid user input → Error::Validation.
    • +
    • + Security issue (path traversal) → Error::PathTraversal. +
    • +
    +
  • +
  • + Attach context when possible – Include the relevant path, + key, or identifier in the error message string. +
  • +
  • + Preserve source errors – If an underlying error is + available, use the Metadata variant (or add a new variant + with a #[source] field) to maintain the error chain. +
  • +
  • + Avoid matching exhaustively on Error – + Because the enum is non‑exhaustive, always include a catch‑all arm when + matching to prevent future breakage. +
  • +
  • + Use Result<T> alias for concise + function signatures. +
  • +
+
+

Testing Suite Overview

+

The crate includes three test files:

+
    +
  • + unit_tests.rs – Tests each variant’s + Display message, source() for + Metadata, From<io::Error> conversion, the + Result alias, and Debug output. +
  • +
  • + integration_tests.rs – Tests the + ? operator integration and the source chain for + Metadata using a boxed dynamic error. +
  • +
  • + property_tests.rs – Property‑based tests + that verify error messages preserve arbitrary input strings for + Config, PathTraversal, and Render. +
  • +
+

+ Together, these tests ensure the error type is robust, easy to use, and + consistent. +

+
+

Conclusion

+

+ librawssg_error provides a centralized, well‑structured error + type for the entire static site generator project. With 15 descriptive + variants, automatic Display and + Error implementations, convenient conversion from + io::Error, and support for error sources, it simplifies error + handling across all modules. The non‑exhaustive design guarantees future + extensibility without breaking downstream code. +

+

For further details, refer to the source code and test files.

diff --git a/docs/src/content/api/fecade.raw b/docs/src/content/api/fecade.raw index e69de29..7e4a6ac 100644 --- a/docs/src/content/api/fecade.raw +++ b/docs/src/content/api/fecade.raw @@ -0,0 +1,1198 @@ +

librawssg

+

A modular static site generator library for Rust.

+

+ This facade crate re‑exports the essential building blocks from the + librawssg ecosystem, providing a single convenient entry point + for building static site generators. It aggregates configuration management, + filesystem abstraction, content processing, template rendering (with Tera + built‑in), and build pipeline orchestration. +

+
+

Table of Contents

+
    +
  1. Overview
  2. +
  3. Installation
  4. +
  5. Modules
  6. +
  7. + Core Types and Traits + +
  8. +
  9. Usage Example
  10. +
  11. + Full API Reference + +
  12. +
  13. Feature Flags
  14. +
  15. License
  16. +
+
+

Overview

+

+ librawssg is the top‑level crate that brings together six + specialized crates: +

+
    +
  • + librawssg_config – Configuration data + structures and validation. +
  • +
  • + librawssg_fs – Trait‑based filesystem + abstraction with path traversal protection. +
  • +
  • + librawssg_handler – Core document and + metadata types, plus the Processor trait. +
  • +
  • + librawssg_templates – Rendering traits + (Renderer, RenderContext) and a Tera + implementation. +
  • +
  • + librawssg_compiler – Build pipeline + orchestration. +
  • +
  • + librawssg_error – Unified error enum and + Result alias. +
  • +
+

+ By depending on librawssg, you get all these components without + needing to specify each one individually. The facade also re‑exports the most + commonly used types at the crate root for ergonomic access. +

+
+

Installation

+

Add librawssg to your Cargo.toml:

+
[dependencies]
+librawssg = "1.0.0"
+
+

+ If you are working in the same workspace as the + librawssg source, you can use a path dependency: +

+
[dependencies]
+librawssg = { path = "../librawssg" }
+
+

The crate is compatible with Rust edition 2024 and later.

+
+

Modules

+

The crate organises its re‑exports into submodules for clarity:

+
    +
  • + librawssg::config – Configuration types + (Config, SiteConfig, BuildConfig, + ContentRule, NavItem). +
  • +
  • + librawssg::fs – Filesystem trait and + RealFs. +
  • +
  • + librawssg::handler – Document, metadata, and + processor contracts. +
  • +
  • + librawssg::templates – Rendering traits and + TeraRenderer. +
  • +
  • + librawssg::compiler – Pipeline builder, + pipeline, context builders, generators. +
  • +
  • + librawssg::error – Error type and + Result alias. +
  • +
+

+ Additionally, the most important types are also re‑exported directly at the + crate root for convenience. +

+
+

Core Types and Traits

+

Configuration

+

+ The configuration system revolves around the Config struct, + which contains site settings, build paths, and content processing rules. +

+
    +
  • +

    + Config – Top‑level configuration. +

    +
      +
    • + Fields: site: SiteConfig, + build: BuildConfig, + content_rules: Vec<ContentRule>, + extra: HashMap<String, serde_json::Value>. +
    • +
    • + Constructors: +
        +
      • Config::new() -> Config
      • +
      • Config::default() -> Config
      • +
      +
    • +
    • + Builder method: + with_site_name(name: impl Into<String>) -> Self +
    • +
    • + Rule management: +
        +
      • + add_content_rule(&mut self, rule: ContentRule) +
      • +
      • + find_rule_by_name(&self, name: &str) -> + Option<&ContentRule> +
      • +
      • + remove_rule_by_name(&mut self, name: &str) -> + Option<ContentRule> +
      • +
      • + has_duplicate_rule_names(&self) -> bool +
      • +
      +
    • +
    • + Validation: + validate(&self) -> Result<()> +
    • +
    • + Serialization: +
        +
      • + from_yaml_str(yaml: &str) -> + Result<Self> +
      • +
      • + to_yaml_string(&self) -> Result<String> +
      • +
      • + from_json_str(json: &str) -> + Result<Self> +
      • +
      • + to_json_string(&self) -> Result<String> +
      • +
      +
    • +
    +
  • +
  • +

    + SiteConfig – Global site metadata. +

    +
      +
    • + Fields: navbar, sidebar, + site_name, description, + language, base_url, author, + repo_url, license, extra. +
    • +
    • + Constructors: + SiteConfig::new(site_name: impl Into<String>) -> + Self, SiteConfig::default(). +
    • +
    • + Defaults: + site_name = "librawssg", + language = Some("en"). +
    • +
    +
  • +
  • +

    + BuildConfig – Filesystem path settings. +

    +
      +
    • + Fields: content_dir, + output_dir, templates_dir, + static_dir. +
    • +
    • + Constructors: BuildConfig::new(), + BuildConfig::default(). +
    • +
    • + Defaults: "content", + "dist", "templates", + "static". +
    • +
    +
  • +
  • +

    + ContentRule – Defines how a group of + files should be processed. +

    +
      +
    • + Fields: name, pattern, + template, + list_template: Option<String>, + list_enabled: bool, + extra: HashMap<String, serde_json::Value>. +
    • +
    • + Constructor: + ContentRule::new(name, pattern, template) -> Self. +
    • +
    • + Default: all strings empty, + list_enabled = false. +
    • +
    +
  • +
  • +

    + NavItem – Navigation menu entry. +

    +
      +
    • + Fields: label: String, + url: String, children: Vec<NavItem>. +
    • +
    • + Constructor: + NavItem::new(label, url) -> Self. +
    • +
    +
  • +
+

Filesystem

+
    +
  • +

    + FileSystem trait – Abstract filesystem + operations. All methods return io::Result or + bool. +

    +
      +
    • + Required methods: +
        +
      • + read_to_string, read_bytes, + write, create_dir_all, + remove_dir_all, remove_file, + create_dir, exists, + is_dir, is_file, + read_dir, copy_file, + copy_dir_all, walk_dir, + canonicalize, rename, + atomic_write, touch, + metadata, symlink_metadata, + permissions, set_permissions, + read_link, hard_link. +
      • +
      +
    • +
    • + Provided methods (with default implementations): +
        +
      • is_symlink
      • +
      • canonicalize_or_join
      • +
      • + safe_join – + Important for security: ensures the resulting + path stays within a base directory. +
      • +
      • copy
      • +
      • rename_or_copy
      • +
      +
    • +
    +
  • +
  • +

    + RealFs – Zero‑sized struct implementing + FileSystem using std::fs and + walkdir. +

    +
  • +
+

Content Handling

+
    +
  • +

    + Document – Represents a processed content + item. +

    +
      +
    • + Fields: metadata: Metadata, + body: String, url: String, + output_path: PathBuf, + source_path: PathBuf, depth: usize, + content_type: String, is_list: bool, + list_items: Option<Vec<Document>>, + taxonomies: HashMap<String, Vec<String>>. +
    • +
    • + Constructor: + Document::new(metadata, body, url, output_path, source_path, + depth, content_type, is_list) -> Result<Self>. +
    • +
    • + Methods: +
        +
      • relative_url(&self) -> &str
      • +
      • + add_taxonomy(&mut self, name: impl Into<String>, + items: Vec<String>) +
      • +
      • depth(&self) -> usize
      • +
      • + with_list_items(self, items: Vec<Self>) -> + Self +
      • +
      +
    • +
    +
  • +
  • +

    + Metadata – Front matter data. +

    +
      +
    • + Fields: title, + description, author: Option<String>, + repo_url, license, + date: Option<NaiveDate>, + updated: Option<NaiveDate>, + tags: Vec<String>, draft: bool, + extra: HashMap<String, serde_json::Value>. +
    • +
    • + Constructor: + Metadata::new(title, description) -> Result<Self>. +
    • +
    • + Methods: +
        +
      • is_draft(&self) -> bool
      • +
      • insert_extra(&mut self, key, value)
      • +
      • + get_extra(&self, key: &str) -> + Option<&serde_json::Value> +
      • +
      +
    • +
    +
  • +
  • +

    + Processor trait – Interface for + transforming source files into Documents. +

    +
      +
    • + Required methods: +
        +
      • name(&self) -> &str
      • +
      • + can_process(&self, relative_path: &Path, + original_path: &Path) -> bool +
      • +
      • + process(&self, fs: &dyn FileSystem, relative_path: + &Path, content_dir: &Path) -> + Result<Option<Document>> +
      • +
      +
    • +
    • + Provided method: priority(&self) -> i32 (default + 0). +
    • +
    +
  • +
+

Template Rendering

+
    +
  • +

    + RenderContext trait – Type‑erased context + for renderers. +

    +
      +
    • + Required methods: as_any(&self) -> &dyn Any, + as_mut_any(&mut self) -> &mut dyn Any. +
    • +
    +
  • +
  • +

    + Renderer trait – Interface for template + rendering. +

    +
      +
    • + Required method: + render(&self, template_name: &str, context: &dyn + RenderContext) -> Result<String>. +
    • +
    +
  • +
  • +

    + TeraRenderer – Concrete renderer using + the Tera template engine. +

    +
      +
    • + Available when the tera feature is enabled (enabled by + default). +
    • +
    • + Constructors: TeraRenderer::new(), + Default. +
    • +
    • + Methods: +
        +
      • + add_raw_template(&mut self, name: &str, content: + &str) -> Result<()> +
      • +
      • + add_template_file(&mut self, path: &Path) -> + Result<()> +
      • +
      • + add_template_files_from_dir(&mut self, dir: &Path) + -> Result<()> +
      • +
      • + load_templates_dir(&mut self, dir: &Path) -> + Result<()> +
      • +
      • enable_autoescape(&mut self)
      • +
      • + render_str(&self, template_str: &str, context: + &dyn RenderContext) -> Result<String> +
      • +
      • as_tera(&self) -> &tera::Tera
      • +
      • + as_tera_mut(&mut self) -> &mut tera::Tera +
      • +
      +
    • +
    +
  • +
+

Compiler Pipeline

+
    +
  • +

    + PipelineBuilder – Builds a + Pipeline. +

    +
      +
    • + Constructor: PipelineBuilder::new(). +
    • +
    • + Builder methods: config, + load_config, content_dir, + output_dir, with_fs, + with_renderer, add_processor, + with_context_builder, add_generator. +
    • +
    • + Build: + build(self) -> Result<Pipeline>. +
    • +
    +
  • +
  • +

    + Pipeline – Executes the site generation. +

    +
      +
    • + Method: + run(&self) -> Result<()>. +
    • +
    • + Accessor: + config(&self) -> &Config. +
    • +
    +
  • +
  • +

    + ContextBuilder trait – Creates a + RenderContext from Config and + Document. +

    +
      +
    • + Required method: + build_context(&self, config: &Config, doc: + &Document) -> Result<Box<dyn + RenderContext>>. +
    • +
    +
  • +
  • +

    + TeraContextBuilder – Default + implementation that produces a tera::Context with common + page and site variables. +

    +
  • +
  • +

    + Generator trait – Custom post‑processing + step. +

    +
      +
    • + Required method: + generate(&self, pipeline: &Pipeline, output_base: + &Path) -> Result<()>. +
    • +
    +
  • +
  • +

    + match_pattern function (in + compiler::pattern) – Glob matching for content rule + patterns. +

    +
  • +
+

Error Handling

+
    +
  • + Error enum – Variants: Io, + Config, Metadata, Render, + Processor, Generator, + PathTraversal, MissingConfig, + Generation, NotFound, + Serialization, Validation, + Duplicate, InvalidState, Internal. +
  • +
  • + Result<T> – Alias for + core::result::Result<T, Error>. +
  • +
+
+

Usage Example

+

+ Here’s a minimal but complete example that builds a site from raw HTML + fragments using a custom processor, Tera templates, and the default context + builder: +

+
use librawssg::{
+    Config, ContentRule, Document, FileSystem, Metadata, PipelineBuilder, Processor,
+    RealFs, RenderContext, Renderer, TeraContextBuilder, TeraRenderer,
+};
+use std::path::{Path, PathBuf};
+
+// 1. Define a simple processor for .raw files
+struct RawProcessor;
+impl Processor for RawProcessor {
+    fn name(&self) -> &'static str { "raw" }
+    fn can_process(&self, rel: &Path, _orig: &Path) -> bool {
+        rel.extension().and_then(|e| e.to_str()) == Some("raw")
+    }
+    fn process(
+        &self,
+        fs: &dyn FileSystem,
+        rel: &Path,
+        content_dir: &Path,
+    ) -> librawssg::Result<Option<Document>> {
+        let full_path = content_dir.join(rel);
+        let body = fs.read_to_string(&full_path)?;
+        let title = rel.file_stem().unwrap_or_default().to_string_lossy().to_string();
+        let meta = Metadata::new(title, String::new())?;
+        let url = rel.with_extension("html").to_string_lossy().to_string();
+        let output = PathBuf::from(&url);
+        let doc = Document::new(meta, body, url, output, rel.to_path_buf(), 0, "page".to_string(), false)?;
+        Ok(Some(doc))
+    }
+}
+
+fn main() -> Result<(), Box<dyn std::error::Error>> {
+    // 2. Configure the site
+    let mut config = Config::new().with_site_name("My Site");
+    config.add_content_rule(ContentRule::new("page", "**/*.raw", "base.tera"));
+    config.build.content_dir = "content".to_string();
+    config.build.output_dir = "dist".to_string();
+    config.build.static_dir = "static".to_string();
+
+    // 3. Set up the renderer and load templates
+    let mut renderer = TeraRenderer::new();
+    renderer.load_templates_dir(Path::new("templates"))?;
+
+    // 4. Build the pipeline
+    let pipeline = PipelineBuilder::new()
+        .config(config)
+        .content_dir("content")
+        .output_dir("dist")
+        .with_fs(Box::new(RealFs))
+        .with_renderer(Box::new(renderer))
+        .with_context_builder(Box::new(TeraContextBuilder))
+        .add_processor(Box::new(RawProcessor))
+        .build()?;
+
+    // 5. Run the generation
+    pipeline.run()?;
+    println!("Site generated successfully!");
+    Ok(())
+}
+
+

+ For more detailed examples, see the librawssg_demo crate in the + repository. +

+
+

Full API Reference

+

+ This section provides a concise reference for every public item re‑exported + by librawssg. For deeper details, consult the respective + sub‑crate documentation (e.g., librawssg_config, + librawssg_compiler). +

+

Configuration Types

+

+ All configuration types are in librawssg::config (and + re‑exported at root). +

+
    +
  • +

    + Config +

    +
      +
    • new() -> Self
    • +
    • default() -> Self
    • +
    • + with_site_name(self, name: impl Into<String>) -> + Self +
    • +
    • + add_content_rule(&mut self, rule: ContentRule) +
    • +
    • + find_rule_by_name(&self, name: &str) -> + Option<&ContentRule> +
    • +
    • + remove_rule_by_name(&mut self, name: &str) -> + Option<ContentRule> +
    • +
    • has_duplicate_rule_names(&self) -> bool
    • +
    • validate(&self) -> Result<()>
    • +
    • + from_yaml_str(yaml: &str) -> Result<Self> +
    • +
    • + to_yaml_string(&self) -> Result<String> +
    • +
    • + from_json_str(json: &str) -> Result<Self> +
    • +
    • + to_json_string(&self) -> Result<String> +
    • +
    +
  • +
  • +

    + SiteConfig +

    +
      +
    • + new(site_name: impl Into<String>) -> Self +
    • +
    • default() -> Self
    • +
    • + Fields: navbar: Vec<NavItem>, + sidebar: Vec<NavItem>, + site_name: String, + description: Option<String>, + language: Option<String>, + base_url: Option<String>, + author: Option<String>, + repo_url: Option<String>, + license: Option<String>, + extra: HashMap<String, serde_json::Value> +
    • +
    +
  • +
  • +

    + BuildConfig +

    +
      +
    • new() -> Self
    • +
    • default() -> Self
    • +
    • + Fields: content_dir: String, + output_dir: String, templates_dir: String, + static_dir: String +
    • +
    +
  • +
  • +

    + ContentRule +

    +
      +
    • + new(name: impl Into<String>, pattern: impl + Into<String>, template: impl Into<String>) -> + Self +
    • +
    • default() -> Self
    • +
    • + Fields: name: String, pattern: String, + template: String, + list_template: Option<String>, + list_enabled: bool, + extra: HashMap<String, serde_json::Value> +
    • +
    +
  • +
  • +

    + NavItem +

    +
      +
    • + new(label: impl Into<String>, url: impl + Into<String>) -> Self +
    • +
    • default() -> Self
    • +
    • + Fields: label: String, url: String, + children: Vec<NavItem> +
    • +
    +
  • +
+

Filesystem Types

+
    +
  • +

    + FileSystem trait (in + librawssg::fs, re‑exported at root) +

    +
      +
    • Required methods (see above).
    • +
    • + Provided methods: is_symlink, + canonicalize_or_join, safe_join, + copy, rename_or_copy. +
    • +
    +
  • +
  • +

    + RealFs (in librawssg::fs, + re‑exported at root) +

    +
      +
    • Implements FileSystem using std::fs.
    • +
    • + RealFs (unit struct), RealFs::default(). +
    • +
    +
  • +
+

Handler Types

+
    +
  • +

    + Document (in + librawssg::handler, re‑exported at root) +

    +
      +
    • + new(metadata, body, url, output_path, source_path, depth, + content_type, is_list) -> Result<Self> +
    • +
    • relative_url(&self) -> &str
    • +
    • add_taxonomy(&mut self, name, items)
    • +
    • depth(&self) -> usize
    • +
    • + with_list_items(self, items: Vec<Self>) -> Self +
    • +
    • Fields: as listed earlier.
    • +
    +
  • +
  • +

    + Metadata +

    +
      +
    • new(title, description) -> Result<Self>
    • +
    • is_draft(&self) -> bool
    • +
    • insert_extra(&mut self, key, value)
    • +
    • + get_extra(&self, key: &str) -> + Option<&serde_json::Value> +
    • +
    • Fields: as listed earlier.
    • +
    +
  • +
  • +

    + Processor trait +

    +
      +
    • + Required: name, can_process, + process +
    • +
    • Provided: priority
    • +
    +
  • +
+

Template Types

+
    +
  • +

    + RenderContext trait +

    +
      +
    • as_any(&self) -> &dyn Any
    • +
    • as_mut_any(&mut self) -> &mut dyn Any
    • +
    +
  • +
  • +

    + Renderer trait +

    +
      +
    • + render(&self, template_name: &str, context: &dyn + RenderContext) -> Result<String> +
    • +
    +
  • +
  • +

    + TeraRenderer (feature tera, + enabled by default) +

    +
      +
    • new() -> Self
    • +
    • + add_raw_template(&mut self, name: &str, content: + &str) -> Result<()> +
    • +
    • + add_template_file(&mut self, path: &Path) -> + Result<()> +
    • +
    • + add_template_files_from_dir(&mut self, dir: &Path) -> + Result<()> +
    • +
    • + load_templates_dir(&mut self, dir: &Path) -> + Result<()> +
    • +
    • enable_autoescape(&mut self)
    • +
    • + render_str(&self, template_str: &str, context: &dyn + RenderContext) -> Result<String> +
    • +
    • as_tera(&self) -> &tera::Tera
    • +
    • + as_tera_mut(&mut self) -> &mut tera::Tera +
    • +
    • Implements Renderer and Default.
    • +
    +
  • +
+

Compiler Types

+
    +
  • +

    + PipelineBuilder +

    +
      +
    • new() -> Self
    • +
    • config(self, config: Config) -> Self
    • +
    • + load_config<P: AsRef<Path> + Send + Sync>(self, + path: P) -> Result<Self> +
    • +
    • + content_dir(self, dir: impl Into<PathBuf>) -> + Self +
    • +
    • + output_dir(self, dir: impl Into<PathBuf>) -> Self +
    • +
    • + with_fs(self, fs: Box<dyn FileSystem>) -> Self +
    • +
    • + with_renderer(self, renderer: Box<dyn Renderer>) -> + Self +
    • +
    • + add_processor(self, processor: Box<dyn Processor>) -> + Self +
    • +
    • + with_context_builder(self, builder: Box<dyn + ContextBuilder>) -> Self +
    • +
    • + add_generator(self, generator: Box<dyn Generator>) -> + Self +
    • +
    • build(self) -> Result<Pipeline>
    • +
    +
  • +
  • +

    + Pipeline +

    +
      +
    • run(&self) -> Result<()>
    • +
    • config(&self) -> &Config
    • +
    +
  • +
  • +

    + ContextBuilder trait +

    +
      +
    • + build_context(&self, config: &Config, doc: + &Document) -> Result<Box<dyn + RenderContext>> +
    • +
    +
  • +
  • +

    + TeraContextBuilder (unit struct) +

    +
      +
    • Implements ContextBuilder.
    • +
    • + TeraContextBuilder (no fields), Default. +
    • +
    +
  • +
  • +

    + Generator trait +

    +
      +
    • + generate(&self, pipeline: &Pipeline, output_base: + &Path) -> Result<()> +
    • +
    +
  • +
  • +

    + match_pattern function (accessible via + librawssg::compiler::pattern::match_pattern) +

    +
      +
    • + Signature: + pub fn match_pattern(pattern: &str, path: &Path) -> + bool +
    • +
    • + Supports glob patterns with * and **. +
    • +
    +
  • +
+

Error Types

+
    +
  • +

    + Error enum (in + librawssg::error, re‑exported at root) +

    +
      +
    • Variants: as listed above.
    • +
    • + Implements Display, Debug, + std::error::Error. +
    • +
    • From<std::io::Error> is implemented.
    • +
    +
  • +
  • +

    + Result<T> type alias +

    +
      +
    • + pub type Result<T> = core::result::Result<T, + Error> +
    • +
    +
  • +
+
+

Feature Flags

+

+ The tera feature is enabled by default and provides the + TeraRenderer implementation. To disable it (e.g., if you use a + different template engine), set default-features = false in your + Cargo.toml: +

+
[dependencies]
+librawssg = { version = "1.0.0", default-features = false }
+
+

+ Without this feature, the crate still exports the core traits + (Renderer, RenderContext) and all other + functionality, but TeraRenderer and + TeraContextBuilder are unavailable. +

+
+

License

+

+ This project is licensed under the MIT License. See the + LICENSE file for details. +

+
+

+ This documentation is generated from the source code of the + librawssg facade crate and its sub‑crates. +

diff --git a/docs/src/content/api/fs.raw b/docs/src/content/api/fs.raw index e69de29..a7e4786 100644 --- a/docs/src/content/api/fs.raw +++ b/docs/src/content/api/fs.raw @@ -0,0 +1,1102 @@ +

librawssg_fs

+

+ Version: 1.0.0 (implied)
Crate name: + librawssg_fs
Description: A filesystem + abstraction layer for static site generators. Defines the + FileSystem trait with a comprehensive set of file and directory + operations, and provides a concrete implementation RealFs that + delegates to std::fs and walkdir. The trait + includes built‑in path traversal protection and convenience methods for + atomic operations. +

+
+

Table of Contents

+
    +
  1. Overview
  2. +
  3. Modules
  4. +
  5. + Trait FileSystem + +
  6. +
  7. + Struct RealFs + +
  8. +
  9. Error Handling
  10. +
  11. + Implementing a Custom FileSystem +
  12. +
  13. Security Considerations
  14. +
  15. Testing Suite Overview
  16. +
  17. + Complete Code Examples from Tests +
  18. +
+
+

Overview

+

+ librawssg_fs provides a trait‑based abstraction over filesystem + operations. This allows static site generator components to interact with the + filesystem without being tightly coupled to std::fs. It enables: +

+
    +
  • + Testability: Mock filesystems can be injected in unit + tests. +
  • +
  • + Portability: Different filesystem backends (e.g., + in‑memory, virtual) can implement the trait. +
  • +
  • + Security: Built‑in path traversal protection through + safe_join and canonicalize_or_join. +
  • +
+

The crate exports:

+
    +
  • pub trait FileSystem – The main abstraction.
  • +
  • + pub struct RealFs – A zero‑sized type that implements + FileSystem using the real OS filesystem. +
  • +
+
+

Modules

+

+ The crate root (lib.rs) defines the + FileSystem trait and re‑exports RealFs from the + real module. +

+
pub mod real;
+pub use real::RealFs;
+
+

+ There is also an internal module real.rs containing the + RealFs implementation. +

+
+

Trait FileSystem

+

+ The FileSystem trait is the core of this crate. It is + object‑safe and requires implementors to be Send + Sync (safe to + share across threads). The trait provides many required methods and several + methods with default implementations. +

+
pub trait FileSystem: Send + Sync {
+    // Required methods (see below)
+    // Provided methods with default implementations
+}
+
+

Required Methods

+

+ These methods must be implemented by any type that + implements FileSystem. They map closely to + std::fs functions and walkdir functionality. +

+

read_to_string

+
fn read_to_string(&self, path: &Path) -> io::Result<String>;
+
+

+ Purpose: Reads the entire contents of a file into a + String. +

+

Parameters:

+
    +
  • path: The path to the file to read.
  • +
+

+ Returns: Ok(String) containing the file + contents, or an Err(io::Error) if the file cannot be read (e.g., + not found, permission denied, invalid UTF‑8). +

+

Example:

+
let content = fs.read_to_string(Path::new("hello.txt"))?;
+
+

read_bytes

+
fn read_bytes(&self, path: &Path) -> io::Result<Vec<u8>>;
+
+

+ Purpose: Reads the entire contents of a file as raw bytes. +

+

Parameters:

+
    +
  • path: The path to the file.
  • +
+

+ Returns: Ok(Vec<u8>) with the file bytes, + or an Err(io::Error). +

+

Example:

+
let data = fs.read_bytes(Path::new("image.png"))?;
+
+

write

+
fn write(&self, path: &Path, content: &[u8]) -> io::Result<()>;
+
+

+ Purpose: Writes the given bytes to a file, creating any + necessary parent directories. +

+

Parameters:

+
    +
  • path: Destination file path.
  • +
  • content: Bytes to write.
  • +
+

+ Returns: Ok(()) on success, or + Err(io::Error) on failure (e.g., permission denied, disk full). +

+

+ Behavior: The default RealFs implementation + creates parent directories before writing (via create_dir_all on + the parent). This is convenient for writing deeply nested outputs. +

+

Example:

+
fs.write(Path::new("a/b/c.txt"), b"hello")?;
+
+

create_dir_all

+
fn create_dir_all(&self, path: &Path) -> io::Result<()>;
+
+

+ Purpose: Creates a directory and all its missing parents. +

+

Parameters:

+
    +
  • path: The directory path to create.
  • +
+

+ Returns: Ok(()) or Err(io::Error). +

+

+ Note: Unlike create_dir, this does + not error if the directory already exists. +

+

Example:

+
fs.create_dir_all(Path::new("a/b/c"))?;
+
+

remove_dir_all

+
fn remove_dir_all(&self, path: &Path) -> io::Result<()>;
+
+

+ Purpose: Removes a directory and all its contents + recursively. +

+

Parameters:

+
    +
  • path: Directory path to remove.
  • +
+

+ Returns: Ok(()) or + Err(io::Error) (e.g., directory does not exist, permission + denied). +

+

Warning: This is destructive and cannot be undone.

+

remove_file

+
fn remove_file(&self, path: &Path) -> io::Result<()>;
+
+

Purpose: Deletes a single file.

+

Parameters:

+
    +
  • path: File path to remove.
  • +
+

+ Returns: Ok(()) or Err(io::Error). +

+

create_dir

+
fn create_dir(&self, path: &Path) -> io::Result<()>;
+
+

+ Purpose: Creates a single directory. Fails if the parent + directory does not exist or if the directory already exists. +

+

Parameters:

+
    +
  • path: Directory path to create.
  • +
+

+ Returns: Ok(()) or + Err(io::Error) (e.g., already exists, parent missing). +

+

exists

+
fn exists(&self, path: &Path) -> bool;
+
+

+ Purpose: Checks whether a path exists (as a file, directory, + symlink, etc.). +

+

Parameters:

+
    +
  • path: Path to check.
  • +
+

+ Returns: true if the path exists, + false otherwise. +

+

+ Note: This method does not follow symlinks for broken + symlinks; it returns false for a broken symlink. +

+

is_dir

+
fn is_dir(&self, path: &Path) -> bool;
+
+

Purpose: Checks whether the path points to a directory.

+

+ Returns: true if it is a directory, + false otherwise (including if it does not exist). +

+

is_file

+
fn is_file(&self, path: &Path) -> bool;
+
+

+ Purpose: Checks whether the path points to a regular file. +

+

+ Returns: true if it is a regular file, + false otherwise. +

+

read_dir

+
fn read_dir(&self, path: &Path) -> io::Result<Vec<PathBuf>>;
+
+

+ Purpose: Lists all entries (files and directories) directly + inside a directory. +

+

Parameters:

+
    +
  • path: Directory path.
  • +
+

+ Returns: Ok(Vec<PathBuf>) containing the + full paths of all entries, or Err(io::Error). +

+

+ Note: The order is not guaranteed. It does not recurse into + subdirectories. +

+

copy_file

+
fn copy_file(&self, from: &Path, to: &Path) -> io::Result<u64>;
+
+

+ Purpose: Copies a file from from to + to. If to already exists, it will be overwritten. +

+

Parameters:

+
    +
  • from: Source file path.
  • +
  • to: Destination file path.
  • +
+

+ Returns: Ok(u64) with the number of bytes + copied, or Err(io::Error). +

+

+ Note: Does not create parent directories of + to in the default RealFs; use copy or + copy_dir_all for that. +

+

copy_dir_all

+
fn copy_dir_all(&self, from: &Path, to: &Path) -> io::Result<()>;
+
+

+ Purpose: Recursively copies a directory tree from + from to to. Creates the destination directory and + all parent directories as needed. +

+

Parameters:

+
    +
  • from: Source directory path.
  • +
  • to: Destination directory path.
  • +
+

+ Returns: Ok(()) or Err(io::Error). +

+

Behavior:

+
    +
  1. Creates to directory.
  2. +
  3. Walks all files in from (using walk_dir).
  4. +
  5. + For each file, computes relative path and creates parent directories in + to, then copies the file. +
  6. +
+

walk_dir

+
fn walk_dir(&self, root: &Path) -> io::Result<Vec<PathBuf>>;
+
+

+ Purpose: Recursively collects all + files under root. Does not include directories + or symlinks to directories. +

+

Parameters:

+
    +
  • root: Root directory to traverse.
  • +
+

+ Returns: Ok(Vec<PathBuf>) with the full + paths of all files, or Err(io::Error). +

+

+ Note: The default RealFs uses the + walkdir crate to handle traversal. It follows symlinks? (The + WalkDir::new default does not follow symlinks; it will include + symlinks but not traverse into them unless + .follow_links(true) is set. Here symlinks to files will be + included? entry.file_type().is_file() will be true for a symlink + to a file? Actually file_type() returns the type of the symlink + itself, not the target, unless follow_links is used. So symlinks + are not considered files and are skipped.) +

+

canonicalize

+
fn canonicalize(&self, path: &Path) -> io::Result<PathBuf>;
+
+

+ Purpose: Returns the canonical, absolute form of a path, + resolving all symbolic links and normalizing . and + .. components. +

+

Parameters:

+
    +
  • path: The path to canonicalize.
  • +
+

+ Returns: Ok(PathBuf) with the canonical path, + or Err(io::Error) (e.g., path does not exist). +

+

rename

+
fn rename(&self, from: &Path, to: &Path) -> io::Result<()>;
+
+

+ Purpose: Renames (moves) a file or directory from + from to to. On most filesystems this is an atomic + operation when source and destination are on the same filesystem. +

+

Parameters:

+
    +
  • from: Source path.
  • +
  • to: Destination path.
  • +
+

+ Returns: Ok(()) or Err(io::Error). +

+

+ Note: If to exists, it may be overwritten + (platform‑dependent). Does not work across different mount points (returns + CrossesDevices error). +

+

atomic_write

+
fn atomic_write(&self, path: &Path, content: &[u8]) -> io::Result<()>;
+
+

+ Purpose: Writes data to a file atomically by first writing + to a temporary file and then renaming it over the target path. +

+

Parameters:

+
    +
  • path: Destination file path.
  • +
  • content: Bytes to write.
  • +
+

+ Returns: Ok(()) or Err(io::Error). +

+

Behavior:

+
    +
  1. + Creates a temporary file with extension .tmp (by calling + with_extension("tmp") on the target path). +
  2. +
  3. + Writes the content to the temporary file (using write, which + creates parent directories). +
  4. +
  5. + Renames the temporary file to the target path (using rename). +
  6. +
  7. + If the rename fails, attempts to remove the temporary file and returns the + error. +
  8. +
+

+ Note: The temporary file name is derived from the target; it + is not a hidden file and may collide if multiple writes happen concurrently + to the same path. This is a best‑effort atomic write suitable for many use + cases. +

+

touch

+
fn touch(&self, path: &Path) -> io::Result<()>;
+
+

+ Purpose: Creates an empty file at path or + updates its access/modification timestamp if it already exists. +

+

Parameters:

+
    +
  • path: File path.
  • +
+

+ Returns: Ok(()) or Err(io::Error). +

+

Behavior:

+
    +
  1. Creates parent directories (like write).
  2. +
  3. Opens the file in append/create mode, which creates it if missing.
  4. +
  5. + Calls sync_all() to flush to disk (optional, but ensures + metadata is updated). +
  6. +
+

Note: Existing file content is preserved.

+

metadata

+
fn metadata(&self, path: &Path) -> io::Result<std::fs::Metadata>;
+
+

+ Purpose: Returns metadata for a file or directory, following + symlinks. +

+

Parameters:

+
    +
  • path: Path to query.
  • +
+

+ Returns: Ok(fs::Metadata) or + Err(io::Error). +

+ +
fn symlink_metadata(&self, path: &Path) -> io::Result<std::fs::Metadata>;
+
+

+ Purpose: Returns metadata for a path + without following symlinks (i.e., metadata of the symlink + itself). +

+

Parameters:

+
    +
  • path: Path to query.
  • +
+

+ Returns: Ok(fs::Metadata) or + Err(io::Error). +

+

permissions

+
fn permissions(&self, path: &Path) -> io::Result<std::fs::Permissions>;
+
+

Purpose: Reads the permissions of a file or directory.

+

Parameters:

+
    +
  • path: Path to query.
  • +
+

+ Returns: Ok(fs::Permissions) or + Err(io::Error). +

+

+ Note: The default RealFs obtains permissions + from metadata, which follows symlinks. +

+

set_permissions

+
fn set_permissions(&self, path: &Path, permissions: std::fs::Permissions) -> io::Result<()>;
+
+

Purpose: Sets the permissions of a file or directory.

+

Parameters:

+
    +
  • path: Target path.
  • +
  • permissions: New permissions.
  • +
+

+ Returns: Ok(()) or Err(io::Error). +

+ +
fn read_link(&self, path: &Path) -> io::Result<PathBuf>;
+
+

Purpose: Reads the target of a symbolic link.

+

Parameters:

+
    +
  • path: Path to the symlink.
  • +
+

+ Returns: Ok(PathBuf) containing the link + target, or Err(io::Error) if the path is not a symlink or does + not exist. +

+ +
fn hard_link(&self, from: &Path, to: &Path) -> io::Result<()>;
+
+

+ Purpose: Creates a hard link from from to + to. +

+

Parameters:

+
    +
  • from: Existing file path.
  • +
  • to: New hard link path.
  • +
+

+ Returns: Ok(()) or Err(io::Error). +

+

Note: Both paths must be on the same filesystem.

+
+

Provided (Default) Methods

+

+ These methods have default implementations that rely on the required methods. + Implementors may override them for performance or platform‑specific behavior. +

+ +
fn is_symlink(&self, path: &Path) -> bool {
+    self.symlink_metadata(path)
+        .is_ok_and(|meta| meta.file_type().is_symlink())
+}
+
+

+ Purpose: Checks whether the given path is a symbolic link. +

+

+ Returns: true if the path is a symlink (even if + broken), false otherwise. +

+

+ Implementation: Uses symlink_metadata (which + does not follow symlinks) and checks the file type. +

+

Example:

+
if fs.is_symlink(Path::new("link")) { ... }
+
+

canonicalize_or_join

+
fn canonicalize_or_join(&self, base: &Path, candidate: &Path) -> io::Result<PathBuf>
+
+

+ Purpose: Safely resolves a possibly non‑existent path + relative to base. If the joined path exists, it is + canonicalized; otherwise it returns the canonical parent joined with the file + name, after normalizing . and .. components. +

+

Parameters:

+
    +
  • base: The base directory (usually already canonical).
  • +
  • + candidate: A relative path (may contain . and + ..). +
  • +
+

+ Returns: Ok(PathBuf) with the resolved path, or + Err(io::Error) if path traversal is detected or other errors + occur. +

+

Detailed Behavior:

+
    +
  1. + Normalizes the candidate path by iterating over its + components: +
      +
    • CurDir (.) is ignored.
    • +
    • + ParentDir (..) causes the last normal + component to be popped. If there is no previous normal component + (i.e., attempt to go above root), it returns + PermissionDenied with message + "path traversal detected". +
    • +
    • + Prefix and RootDir components are pushed + (though they are unusual for relative candidates and may cause + issues later). +
    • +
    • Normal components are pushed.
    • +
    +
  2. +
  3. Joins the normalized candidate with base.
  4. +
  5. + If the joined path exists, canonicalizes it (resolving symlinks, etc.). +
  6. +
  7. + If it does not exist: +
      +
    • Canonicalizes the parent directory of the joined path.
    • +
    • + Appends the file name of the joined path to the canonical parent. +
    • +
    • Returns that path.
    • +
    +
  8. +
+

+ Security: This method prevents .. from escaping + the base directory (unless there are symlinks that point outside; + canonicalization of existing paths can still lead outside base, which is why + safe_join adds an extra check). For non‑existent paths, the + parent canonicalization ensures that the final path is within the canonical + base. +

+

Example (from tests):

+
let existing = base.join("existing.txt");
+fs.write(&existing, b"data")?;
+
+let canon_existing = fs.canonicalize_or_join(base, Path::new("existing.txt"))?;
+let canon_direct = fs.canonicalize(&existing)?;
+assert_eq!(canon_existing, canon_direct);
+
+let missing = Path::new("missing.txt");
+let canon_missing = fs.canonicalize_or_join(base, missing)?;
+let canon_base = fs.canonicalize(base)?;
+assert_eq!(canon_missing, canon_base.join(missing));
+
+

safe_join

+
fn safe_join(&self, base: &Path, candidate: &Path) -> io::Result<PathBuf>
+
+

+ Purpose: Safely joins a candidate path to a base directory, + ensuring the result is within the base directory (no path + traversal). This is the recommended way to compute destination paths for + user‑provided or untrusted relative paths. +

+

Parameters:

+
    +
  • + base: The base directory (can be relative; it will be + canonicalized internally). +
  • +
  • + candidate: A relative path (may contain . and + ..). +
  • +
+

+ Returns: Ok(PathBuf) with the resolved path, + guaranteed to start with the canonical base. Returns + Err(io::Error) with PermissionDenied if the + resolved path escapes the base (e.g., + candidate = "../secret"). +

+

Implementation Details:

+
    +
  1. Canonicalizes base.
  2. +
  3. + Calls canonicalize_or_join with the canonical base and + candidate. +
  4. +
  5. + Checks that the resulting path starts with the canonical base. If not, + returns PermissionDenied. +
  6. +
+

+ Why needed: Although + canonicalize_or_join prevents simple .. traversal, + symlinks inside the base directory could cause a resolved path to point + outside the base even after normalization. The starts_with check + enforces containment. +

+

Example:

+
let base = tmp.path().join("base");
+fs.create_dir_all(&base)?;
+
+let safe = fs.safe_join(&base, Path::new("inside.txt"))?;
+assert!(safe.starts_with(&base));
+
+let traversal = Path::new("../escape.txt");
+let result = fs.safe_join(&base, traversal);
+assert!(result.is_err());
+
+

copy

+
fn copy(&self, from: &Path, to: &Path) -> io::Result<()>
+
+

+ Purpose: Copies a file or directory from + from to to. If from is a directory, it + recursively copies the whole tree; if it is a file, it performs a single file + copy. +

+

Parameters:

+
    +
  • from: Source path.
  • +
  • to: Destination path.
  • +
+

+ Returns: Ok(()) or Err(io::Error). +

+

Implementation:

+
if self.is_dir(from) {
+    self.copy_dir_all(from, to)
+} else {
+    self.copy_file(from, to).map(|_| ())
+}
+
+

+ Note: Does not create parent directories of + to for file copies (unless copy_file implementation + does; the default RealFs::copy_file does not). For directories, + copy_dir_all does create to and parents as needed. +

+

Example:

+
fs.copy(&src_file, &dst_file)?;
+fs.copy(&src_dir, &dst_dir)?;
+
+

rename_or_copy

+
fn rename_or_copy(&self, from: &Path, to: &Path) -> io::Result<()>
+
+

+ Purpose: Attempts to rename from to + to. If the rename fails with + ErrorKind::CrossesDevices (i.e., source and destination are on + different filesystems), it falls back to copying the directory tree and then + removing the source. +

+

Parameters:

+
    +
  • from: Source path.
  • +
  • to: Destination path.
  • +
+

+ Returns: Ok(()) or Err(io::Error). +

+

Behavior:

+
    +
  1. Try rename(from, to).
  2. +
  3. If success, return Ok(()).
  4. +
  5. + If error kind is CrossesDevices: +
      +
    • copy_dir_all(from, to) to copy contents.
    • +
    • remove_dir_all(from) to delete source.
    • +
    • Return Ok(()).
    • +
    +
  6. +
  7. Otherwise, return the original error.
  8. +
+

+ Note: The fallback only works for directories (as the code + uses copy_dir_all and remove_dir_all). For a file + across devices, this will likely fail. This method is useful for moving + directories across mount points. +

+

Example:

+
fs.rename_or_copy(&src_dir, &dst_dir)?;
+
+
+

Struct RealFs

+

+ RealFs is a zero‑sized struct that implements + FileSystem by delegating directly to the operating system’s + filesystem APIs. +

+
#[derive(Debug, Default, Clone, Copy)]
+pub struct RealFs;
+
+

+ It has no fields and can be instantiated with RealFs or + RealFs::default(). +

+

Implementation Details

+

RealFs uses:

+
    +
  • std::fs for most operations.
  • +
  • walkdir::WalkDir for walk_dir.
  • +
  • + The tracing::instrument attribute is applied to most methods + for logging (though tracing is not enabled by default; it can + be used with a subscriber). +
  • +
+

+ All methods follow the behavior described in the trait definitions. The + write method creates parent directories before writing, and + atomic_write uses a temporary .tmp file. +

+

Example Usage

+
use librawssg_fs::{FileSystem, RealFs};
+use std::path::Path;
+
+let fs = RealFs;
+
+// Write a file
+fs.write(Path::new("output/file.txt"), b"Hello")?;
+
+// Read it back
+let content = fs.read_to_string(Path::new("output/file.txt"))?;
+assert_eq!(content, "Hello");
+
+// Create directory
+fs.create_dir_all(Path::new("output/sub"))?;
+
+// Copy directory
+fs.copy_dir_all(Path::new("output"), Path::new("backup"))?;
+
+
+

Error Handling

+

+ All methods that can fail return io::Result<T> (i.e., + Result<T, std::io::Error>). This is the standard error + type from the standard library, so no custom error enum is defined in this + crate. Consumers can inspect the error kind (e.g., + ErrorKind::NotFound, PermissionDenied, + CrossesDevices) to handle specific failures. +

+

+ The provided security methods (canonicalize_or_join and + safe_join) return io::Error with + ErrorKind::PermissionDenied when path traversal is detected, + along with the message "path traversal detected". +

+
+

+ Implementing a Custom FileSystem +

+

+ To create a mock filesystem or an alternative backend, implement the + FileSystem trait. You must provide implementations for all 24 + required methods. The provided methods can be left as default unless you need + custom behavior. +

+

Example of a minimal mock (from tests, adapted):

+
use librawssg_fs::FileSystem;
+use std::io;
+use std::path::{Path, PathBuf};
+
+struct DummyFs;
+
+impl FileSystem for DummyFs {
+    fn read_to_string(&self, _path: &Path) -> io::Result<String> {
+        Err(io::Error::other("not implemented"))
+    }
+    // ... implement all other required methods similarly
+    // (returning Err or trivial values)
+}
+
+

+ Because FileSystem is Send + Sync, your mock must + also be thread‑safe. In practice, you can use Arc or interior + mutability if state is needed. +

+
+

Security Considerations

+

+ The library includes two methods specifically designed to prevent path + traversal attacks: +

+
    +
  • + canonicalize_or_join: Normalizes .. and . and ensures the final + path does not go above the base (in terms of lexical components). However, + it may still follow symlinks that point outside the base if the path + exists. +
  • +
  • + safe_join: Combines canonicalize_or_join with a + starts_with check on the canonical base, providing a stronger + guarantee that the result is contained within the base directory. +
  • +
+

+ Recommendation: Always use safe_join when + constructing output paths from untrusted input (e.g., user‑supplied relative + URLs). Avoid using join directly followed by canonicalization + without containment checks. +

+
+

Testing Suite Overview

+

+ The test file tests/filesystem.rs contains comprehensive tests + for RealFs and the provided methods. It uses + tempfile::TempDir to create isolated temporary directories. The + tests cover: +

+
    +
  • Basic read/write operations (string and bytes)
  • +
  • Creating directories and files
  • +
  • Error cases for non‑existent paths
  • +
  • + copy_file, copy_dir_all, rename, + remove_file, remove_dir_all +
  • +
  • atomic_write (including nested paths and overwriting)
  • +
  • touch (creating new and preserving existing content)
  • +
  • walk_dir (recursive collection)
  • +
  • canonicalize_or_join (existing and missing paths)
  • +
  • + safe_join (rejecting traversal, allowing dot segments inside) +
  • +
  • Metadata and permissions
  • +
  • Symlink and hard link operations (Unix only)
  • +
+

All tests can be run with cargo test.

+
+

+ Complete Code Examples from Tests +

+

+ Below are selected examples from the test suite that illustrate common usage + patterns. They can be copied and adapted. +

+

Writing and Reading a String

+
use librawssg_fs::{FileSystem, RealFs};
+use std::path::Path;
+use tempfile::TempDir;
+
+let tmp = TempDir::new().unwrap();
+let fs = RealFs;
+let file_path = tmp.path().join("hello.txt");
+
+fs.write(&file_path, b"world").unwrap();
+let content = fs.read_to_string(&file_path).unwrap();
+assert_eq!(content, "world");
+
+

Atomic Write Overwriting

+
let file = tmp.path().join("atomic.txt");
+fs.atomic_write(&file, b"first").unwrap();
+fs.atomic_write(&file, b"second").unwrap();
+let content = fs.read_to_string(&file).unwrap();
+assert_eq!(content, "second");
+
+

Safe Join Blocking Traversal

+
let base = tmp.path().join("base");
+fs.create_dir_all(&base).unwrap();
+
+let safe = fs.safe_join(&base, Path::new("inside.txt")).unwrap();
+assert!(safe.starts_with(&base));
+
+let traversal = Path::new("../escape.txt");
+assert!(fs.safe_join(&base, traversal).is_err());
+
+

Copying a Directory Recursively

+
let src = tmp.path().join("src_dir");
+let dst = tmp.path().join("dst_dir");
+fs.create_dir_all(&src.join("nested")).unwrap();
+fs.write(&src.join("file1.txt"), b"one").unwrap();
+fs.write(&src.join("nested").join("file2.txt"), b"two").unwrap();
+
+fs.copy_dir_all(&src, &dst).unwrap();
+assert!(fs.exists(&dst.join("file1.txt")));
+assert!(fs.exists(&dst.join("nested").join("file2.txt")));
+
+

+ Using walk_dir to Gather All Files +

+
let root = tmp.path().join("root");
+fs.create_dir_all(&root.join("sub")).unwrap();
+fs.write(&root.join("root.txt"), b"root").unwrap();
+fs.write(&root.join("sub").join("sub.txt"), b"sub").unwrap();
+
+let files = fs.walk_dir(&root).unwrap();
+assert_eq!(files.len(), 2);
+
+
+

Summary

+

+ librawssg_fs provides a robust, thread‑safe filesystem + abstraction with built‑in path traversal protection and convenience methods + for atomic operations and cross‑device moves. The + RealFs implementation is ready to use, and the trait enables + easy mocking for unit tests. The extensive test suite validates all features + and serves as living documentation. +

+

For any additional details, refer to the source code and inline comments.

diff --git a/docs/src/content/api/handler.raw b/docs/src/content/api/handler.raw index e69de29..552dc07 100644 --- a/docs/src/content/api/handler.raw +++ b/docs/src/content/api/handler.raw @@ -0,0 +1,1007 @@ +

librawssg_handler

+

+ Version: 1.0.0 (implied)
Crate name: + librawssg_handler
Description: Core data + structures and traits for building a static site generator (SSG) handler. + Provides Document, Metadata, and a + Processor trait for processing content files. +

+
+

Table of Contents

+
    +
  1. Overview
  2. +
  3. Modules
  4. +
  5. + Struct Document + +
  6. +
  7. + Struct Metadata + +
  8. +
  9. + Trait Processor + +
  10. +
  11. Error Handling
  12. +
  13. External Traits & Types
  14. +
  15. Examples from Tests
  16. +
  17. Validation Rules Summary
  18. +
  19. Testing Suite Overview
  20. +
+
+

Overview

+

+ librawssg_handler is the core library for a static site + generator. It defines the essential data structures used to represent a + processed document (Document) and its front matter + (Metadata). Additionally, it provides a pluggable + Processor trait that allows different file types to be processed + into Document instances. +

+

The crate is intended to be used in conjunction with:

+
    +
  • + librawssg_error: Provides the Error and + Result types for consistent error handling. +
  • +
  • + librawssg_fs: Defines a FileSystem trait + abstracting file I/O operations (used by Processor). +
  • +
+
+

Modules

+

The library is organized into three public modules:

+
    +
  • + document – Contains the + Document struct. +
  • +
  • + metadata – Contains the + Metadata struct. +
  • +
  • + processor – Contains the + Processor trait. +
  • +
+

All public types are re‑exported at the crate root for convenience:

+
pub use document::Document;
+pub use metadata::Metadata;
+pub use processor::Processor;
+
+
+

Struct Document

+

+ Represents a fully processed content item ready for rendering or further + processing. +

+
#[derive(Debug, Clone, PartialEq, Serialize)]
+#[non_exhaustive]
+pub struct Document {
+    pub metadata: Metadata,
+    pub body: String,
+    pub url: String,
+    pub output_path: PathBuf,
+    pub source_path: PathBuf,
+    pub depth: usize,
+    pub content_type: String,
+    pub is_list: bool,
+    pub list_items: Option<Vec<Self>>,
+    pub taxonomies: HashMap<String, Vec<String>>,
+}
+
+

Document Fields

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
metadataMetadataFront matter metadata associated with the document.
bodyString + The processed content body (e.g., rendered HTML, Markdown text, + etc.). +
urlString + The relative URL where the document will be accessible (e.g., + "blog/my-post.html"). +
output_pathPathBuf + Filesystem path where the final output file should be written (e.g., + "blog/my-post/index.html"). +
source_pathPathBuf + Path to the original source file (e.g., + "content/blog/my-post.md"). +
depthusize + Depth of the document in the site hierarchy (0 for top‑level). Used + for sorting or navigation. +
content_typeString + Identifier for the kind of content (e.g., + "blog", "page", + "article"). +
is_listbool + Indicates whether this document represents a list of other documents + (e.g., an index page). +
list_itemsOption<Vec<Document>> + If is_list is true, may contain the child documents. + None otherwise or when not set. +
taxonomiesHashMap<String, Vec<String>> + A map of taxonomy names (e.g., "categories", + "tags") to lists of terms. +
+
+

+ Note: The #[non_exhaustive] attribute means + that external crates cannot exhaustively match on Document or + construct it with a struct literal; they must use the provided constructor + or update syntax. This allows adding fields in the future without breaking + downstream code. +

+
+

Document::new

+
pub fn new(
+    metadata: Metadata,
+    body: impl Into<String>,
+    url: impl Into<String>,
+    output_path: impl Into<PathBuf>,
+    source_path: impl Into<PathBuf>,
+    depth: usize,
+    content_type: impl Into<String>,
+    is_list: bool,
+) -> Result<Self>
+
+

+ Purpose: Creates a new Document after + validating the provided arguments. +

+

Parameters:

+
    +
  • + metadata: A fully constructed Metadata instance. +
  • +
  • + body: The content body (accepts any type convertible to + String). +
  • +
  • + url: The desired relative URL (must not be empty or + whitespace only). +
  • +
  • + output_path: The target output path (must not be empty). +
  • +
  • + source_path: The source file path (must have a file name + component). +
  • +
  • depth: The hierarchy depth (must be ≤ 1000).
  • +
  • + content_type: A string identifying the content type (e.g., + "blog", "page"). +
  • +
  • + is_list: Boolean indicating whether this document is a list + container. +
  • +
+

Returns:

+
    +
  • Ok(Document) on success.
  • +
  • + Err(librawssg_error::Error::Validation(message)) if any + validation rule fails. +
  • +
+

Validation Rules:

+
    +
  1. url must not be empty or contain only whitespace.
  2. +
  3. output_path must not be empty (as an OS string).
  4. +
  5. + source_path must have a file name (i.e., its last component + is not .. or empty). +
  6. +
  7. depth must not exceed 1000.
  8. +
+

Example:

+
use librawssg_handler::{Document, Metadata};
+use std::path::PathBuf;
+
+let metadata = Metadata::new("My Post", "A short description")?;
+
+let document = Document::new(
+    metadata,
+    "<h1>Hello</h1><p>World</p>",
+    "blog/my-post.html",
+    "blog/my-post/index.html",
+    "content/blog/my-post.md",
+    1,
+    "blog",
+    false,
+)?;
+
+
+

Document::relative_url

+
#[must_use]
+pub fn relative_url(&self) -> &str
+
+

Purpose: Returns the relative URL of the document.

+

+ Returns: A string slice referencing the + url field. +

+

Example:

+
let doc = /* ... */;
+assert_eq!(doc.relative_url(), "blog/my-post.html");
+
+
+

Document::add_taxonomy

+
pub fn add_taxonomy(&mut self, name: impl Into<String>, items: Vec<String>)
+
+

+ Purpose: Inserts or replaces a taxonomy entry in the + document’s taxonomies map. +

+

Parameters:

+
    +
  • + name: Taxonomy name (converted into String). +
  • +
  • + items: A vector of string terms belonging to that taxonomy. +
  • +
+

+ Behavior: If a taxonomy with the same name already exists, + its value is replaced. +

+

Example:

+
let mut doc = /* ... */;
+doc.add_taxonomy("categories", vec!["rust".to_string(), "ssg".to_string()]);
+assert_eq!(doc.taxonomies["categories"], vec!["rust", "ssg"]);
+
+
+

Document::depth

+
#[must_use]
+pub const fn depth(&self) -> usize
+
+

Purpose: Returns the depth field.

+

Returns: The document’s depth as a usize.

+

Example:

+
let doc = /* ... */;
+assert_eq!(doc.depth(), 1);
+
+
+

Document::with_list_items

+
#[must_use]
+pub fn with_list_items(mut self, items: Vec<Self>) -> Self
+
+

+ Purpose: Consumes the document, sets its + list_items field to Some(items), and returns the + modified document. +

+

Parameters:

+
    +
  • + items: A vector of Document instances that are + children of this list document. +
  • +
+

+ Returns: The same document with list_items set. +

+

Example:

+
let parent = Document::new(/* ... */)?;
+let child1 = Document::new(/* ... */)?;
+let child2 = Document::new(/* ... */)?;
+let list_doc = parent.with_list_items(vec![child1, child2]);
+assert!(list_doc.list_items.is_some());
+
+
+

Struct Metadata

+

+ Represents front matter (metadata) for a document. The struct is serializable + and deserializable, making it suitable for parsing from formats like YAML or + TOML front matter. +

+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
+#[non_exhaustive]
+pub struct Metadata {
+    pub title: String,
+    pub description: String,
+    pub author: Option<String>,
+    pub repo_url: Option<String>,
+    pub license: Option<String>,
+    pub date: Option<NaiveDate>,
+    pub updated: Option<NaiveDate>,
+    pub tags: Vec<String>,
+    pub draft: bool,
+    pub extra: HashMap<String, serde_json::Value>,
+}
+
+

Metadata Fields

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
titleStringThe document title (required, cannot be empty).
descriptionStringA short description of the content.
authorOption<String>The author’s name, if known.
repo_urlOption<String>URL to the source repository.
licenseOption<String> + License identifier (e.g., "MIT", + "Apache-2.0"). +
dateOption<NaiveDate> + Publication date (ISO 8601 date, e.g., 2026-09-08). +
updatedOption<NaiveDate>Last modification date.
tagsVec<String>List of tags (keywords) associated with the content.
draftbool + If true, the document is considered a draft and may be excluded from + builds. +
extraHashMap<String, serde_json::Value>Arbitrary extra key–value pairs for custom metadata.
+
+

+ Note: #[non_exhaustive] prevents exhaustive + struct literals outside the crate; use the provided constructors or update + syntax. +

+
+

Metadata::new

+
pub fn new(
+    title: impl Into<String>,
+    description: impl Into<String>,
+) -> librawssg_error::Result<Self>
+
+

+ Purpose: Creates a Metadata instance with a + required title and description. All other fields + are set to their default values. +

+

Parameters:

+
    +
  • + title: The title (must not be empty or whitespace only). +
  • +
  • description: A description string.
  • +
+

Returns:

+
    +
  • Ok(Metadata) on success.
  • +
  • + Err(librawssg_error::Error::Validation("metadata title cannot be + empty")) + if the title is empty or whitespace. +
  • +
+

Example:

+
use librawssg_handler::Metadata;
+
+let meta = Metadata::new("My Title", "My Description")?;
+assert_eq!(meta.title, "My Title");
+assert!(!meta.draft);
+assert!(meta.tags.is_empty());
+
+
+

Metadata::is_draft

+
#[must_use]
+pub const fn is_draft(&self) -> bool
+
+

Purpose: Returns the draft field.

+

+ Returns: true if the document is marked as a + draft, otherwise false. +

+

Example:

+
let mut meta = Metadata::new("Title", "Desc")?;
+assert!(!meta.is_draft());
+meta.draft = true;
+assert!(meta.is_draft());
+
+
+

Metadata::insert_extra

+
pub fn insert_extra(&mut self, key: impl Into<String>, value: impl Into<serde_json::Value>)
+
+

+ Purpose: Inserts or updates an entry in the + extra map. +

+

Parameters:

+
    +
  • key: The key (converted to String).
  • +
  • + value: Any type convertible to + serde_json::Value (e.g., strings, numbers, booleans, arrays, + objects, or serde_json::json! macro results). +
  • +
+

+ Behavior: If the key already exists, its value is + overwritten. +

+

Example:

+
use serde_json::json;
+
+let mut meta = Metadata::new("Title", "Desc")?;
+meta.insert_extra("key", "value");
+meta.insert_extra("number", 42);
+meta.insert_extra("flag", true);
+meta.insert_extra("nested", json!({"foo": "bar"}));
+
+
+

Metadata::get_extra

+
#[must_use]
+pub fn get_extra(&self, key: &str) -> Option<&serde_json::Value>
+
+

+ Purpose: Retrieves a reference to the value stored under + key in the extra map. +

+

Parameters:

+
    +
  • key: The key to look up.
  • +
+

Returns:

+
    +
  • Some(&Value) if the key exists.
  • +
  • None otherwise.
  • +
+

Example:

+
let meta = /* ... */;
+if let Some(v) = meta.get_extra("key") {
+    assert_eq!(v, &json!("value"));
+}
+
+
+

Metadata Serialization

+

+ Metadata derives both Serialize and + Deserialize, so it can be converted to/from JSON, YAML, etc. + This is particularly useful for reading front matter from source files. +

+

Serialization Example:

+
use serde_json;
+
+let meta = Metadata::new("Hello", "World")?;
+let json_str = serde_json::to_string(&meta)?;
+// {"title":"Hello","description":"World","author":null,...}
+
+

Deserialization Example:

+
let json_str = r#"{
+    "title": "Hello",
+    "description": "World",
+    "author": "Alice",
+    "date": "2026-09-08",
+    "tags": ["rust", "ssg"],
+    "draft": false,
+    "extra": {"foo": "bar"}
+}"#;
+
+let meta: Metadata = serde_json::from_str(json_str)?;
+assert_eq!(meta.author.as_deref(), Some("Alice"));
+
+
+

Metadata Default

+

+ The Default trait is implemented. All fields are set to sensible + empty values: +

+
    +
  • title: empty string
  • +
  • description: empty string
  • +
  • + author, repo_url, license, + date, updated: None +
  • +
  • tags: empty vector
  • +
  • draft: false
  • +
  • extra: empty HashMap
  • +
+

Example:

+
let meta = Metadata::default();
+assert_eq!(meta.title, "");
+assert!(!meta.draft);
+assert!(meta.tags.is_empty());
+
+
+

Trait Processor

+

+ The Processor trait defines an interface for components that can + transform a source file into a Document. Multiple processors may + be registered and invoked based on their ability to handle a given file. +

+
pub trait Processor: Send + Sync {
+    fn name(&self) -> &str;
+
+    fn priority(&self) -> i32 {
+        0
+    }
+
+    fn can_process(&self, relative_path: &Path, original_path: &Path) -> bool;
+
+    fn process(
+        &self,
+        fs: &dyn FileSystem,
+        relative_path: &Path,
+        content_dir: &Path,
+    ) -> Result<Option<Document>>;
+}
+
+

Processor Required Methods

+

name()

+
fn name(&self) -> &str
+
+

+ Purpose: Returns a human‑readable identifier for the + processor (e.g., "markdown", + "sass"). +

+

can_process()

+
fn can_process(&self, relative_path: &Path, original_path: &Path) -> bool
+
+

+ Purpose: Determines whether this processor should handle the + given file. +

+

Parameters:

+
    +
  • + relative_path: The path of the file relative to the content + directory. +
  • +
  • + original_path: The full original path (often the same as + content_dir.join(relative_path)). +
  • +
+

+ Returns: true if the processor can process this + file; false otherwise. +

+

+ Typical Implementation: Check file extension or other + attributes. +

+

Example:

+
fn can_process(&self, relative_path: &Path, _original_path: &Path) -> bool {
+    relative_path.extension().and_then(|e| e.to_str()) == Some("md")
+}
+
+

process()

+
fn process(
+    &self,
+    fs: &dyn FileSystem,
+    relative_path: &Path,
+    content_dir: &Path,
+) -> Result<Option<Document>>
+
+

+ Purpose: Reads the source file, processes it, and returns an + optional Document. +

+

Parameters:

+
    +
  • + fs: A reference to a FileSystem implementation + for performing I/O operations. +
  • +
  • + relative_path: Path of the source file relative to the + content directory. +
  • +
  • + content_dir: The root directory containing all source + content. +
  • +
+

Returns:

+
    +
  • + Ok(Some(document)) if processing succeeded and produced a + document. +
  • +
  • + Ok(None) if the processor decides not to produce a document + (e.g., the file is ignored). +
  • +
  • + Err(librawssg_error::Error) if an error occurred during + processing. +
  • +
+

+ Note: The FileSystem trait is defined in the + librawssg_fs crate. It abstracts many common file operations, + allowing processors to be tested with mock filesystems. +

+
+

Processor Provided Methods

+

priority()

+
fn priority(&self) -> i32 {
+    0
+}
+
+

+ Purpose: Returns the priority of this processor. Processors + with higher priority are invoked before those with lower priority. The + default is 0. +

+

+ Usage: Allows ordering of processors when multiple might + handle the same file. +

+

Example:

+
fn priority(&self) -> i32 {
+    10
+}
+
+
+

Processor Implementation

+

+ To create a custom processor, implement the Processor trait. + Below is a complete example based on the test suite: +

+
use librawssg_error::Result;
+use librawssg_fs::FileSystem;
+use librawssg_handler::{Document, Metadata, Processor};
+use std::path::Path;
+
+struct MarkdownProcessor;
+
+impl Processor for MarkdownProcessor {
+    fn name(&self) -> &str {
+        "markdown"
+    }
+
+    fn can_process(&self, relative_path: &Path, _original_path: &Path) -> bool {
+        relative_path.extension().and_then(|e| e.to_str()) == Some("md")
+    }
+
+    fn process(
+        &self,
+        fs: &dyn FileSystem,
+        relative_path: &Path,
+        content_dir: &Path,
+    ) -> Result<Option<Document>> {
+        // Read the source file
+        let source_path = content_dir.join(relative_path);
+        let content = fs.read_to_string(&source_path)?;
+
+        // Parse front matter and body (simplified here)
+        let metadata = Metadata::new("Untitled", "")?;
+        let body = content; // In reality, you would render Markdown to HTML
+
+        // Construct Document
+        let doc = Document::new(
+            metadata,
+            body,
+            relative_path.with_extension("html").to_string_lossy().to_string(),
+            relative_path.with_extension("index.html").to_string_lossy().into(),
+            source_path,
+            1,
+            "page",
+            false,
+        )?;
+
+        Ok(Some(doc))
+    }
+}
+
+
+

Error Handling

+

+ The library uses the librawssg_error::Error enum for all + fallible operations. Relevant variants: +

+
    +
  • + Error::Validation(String) – Used when a validation rule fails + (e.g., empty URL, invalid depth). +
  • +
  • + Error::Processor(String) – Used by processors to signal + processing errors. +
  • +
  • Other variants may exist but are not directly used in this crate.
  • +
+

+ The return type Result<T> is an alias for + std::result::Result<T, librawssg_error::Error>. +

+

Example of Validation Error:

+
let result = Document::new(meta, "body", "", "out", "src.md", 0, "page", false);
+assert!(matches!(
+    result,
+    Err(librawssg_error::Error::Validation(ref msg)) if msg == "document url cannot be empty"
+));
+
+
+

External Traits & Types

+

FileSystem Trait

+

+ The Processor::process method takes a + &dyn FileSystem. This trait is defined in + librawssg_fs and provides a comprehensive set of file operations + (read, write, create directories, walk, etc.). A typical implementation wraps + std::fs, but for testing, mock implementations are often used. +

+

+ A minimal FileSystem implementation (used in tests) might + implement all methods returning + io::Error::other("not implemented") for those not + needed. +

+
+

Examples from Tests

+

+ The test suite contains numerous examples that demonstrate correct usage and + error conditions. Below are selected examples. +

+

Creating a Valid Document

+
use librawssg_handler::{Document, Metadata};
+
+let metadata = Metadata::new("Title", "Description")?;
+let doc = Document::new(
+    metadata,
+    "<p>Body</p>",
+    "blog/my-post.html",
+    "blog/my-post/index.html",
+    "content/blog/my-post.md",
+    1,
+    "blog",
+    false,
+)?;
+
+assert_eq!(doc.metadata.title, "Title");
+assert_eq!(doc.body, "<p>Body</p>");
+
+

Handling Invalid URL

+
let result = Document::new(
+    Metadata::new("Title", "Desc")?,
+    "body",
+    "", // empty URL
+    "out",
+    "src.md",
+    0,
+    "page",
+    false,
+);
+assert!(result.is_err());
+
+

Adding Taxonomy Terms

+
let mut doc = /* ... */;
+doc.add_taxonomy("categories", vec!["rust".to_string(), "ssg".to_string()]);
+
+

+ Working with extra Metadata +

+
use serde_json::json;
+
+let mut meta = Metadata::new("Title", "Desc")?;
+meta.insert_extra("key", "value");
+if let Some(v) = meta.get_extra("key") {
+    assert_eq!(v, &json!("value"));
+}
+
+

Processor Mock Example

+
struct MockProcessor { /* ... */ }
+
+impl Processor for MockProcessor {
+    fn name(&self) -> &str { "mock" }
+    fn can_process(&self, _: &Path, _: &Path) -> bool { true }
+    fn process(&self, _fs: &dyn FileSystem, _rel: &Path, _cd: &Path) -> Result<Option<Document>> {
+        Ok(Some(document))
+    }
+}
+
+
+

Validation Rules Summary

+

Metadata::new

+
    +
  • title must not be empty or contain only whitespace.
  • +
+

Document::new

+
    +
  1. url must not be empty or contain only whitespace.
  2. +
  3. output_path must not be empty (as an OS string).
  4. +
  5. source_path must have a file name component.
  6. +
  7. depth must be ≤ 1000.
  8. +
+

Any violation results in an Err(Error::Validation(...)).

+
+

Testing Suite Overview

+

The tests are organized into four files:

+
    +
  1. + document_tests.rs – Validates + Document construction, field defaults, error cases, and + methods (relative_url, add_taxonomy, + depth, with_list_items). +
  2. +
  3. + metadata_tests.rs – Tests + Metadata creation, validation, is_draft, + insert_extra/get_extra, default values, and JSON + serialization/deserialization round‑trip. +
  4. +
  5. + processor_tests.rs – Tests the + Processor trait using mock implementations: + name, priority, can_process, + process returning Some, None, and + error. +
  6. +
  7. + unit_tests.rs – Verifies that all public + items are re‑exported at the crate root. +
  8. +
+

All tests can serve as executable examples of the API usage.

+
+

Conclusion

+

+ This documentation covers the public API of librawssg_handler in + detail. The crate provides a flexible foundation for building static site + generators by separating metadata handling (Metadata), document + representation (Document), and pluggable processing logic + (Processor). The validation rules ensure data integrity, and the + use of traits like FileSystem enables testability. +

+

+ For further details, refer to the source code and the accompanying test + suite. +

diff --git a/docs/src/content/api/index.raw b/docs/src/content/api/index.raw index e69de29..6b0a3ca 100644 --- a/docs/src/content/api/index.raw +++ b/docs/src/content/api/index.raw @@ -0,0 +1,14 @@ +

API Reference

+ +

Welcome to the API documentation for the librawssg workspace. Each crate is documented individually, covering its public types, traits, functions, and usage examples.

+ +
    +
  • librawssg_compiler – Build pipeline, builder, context builder, generator, pattern matching, and orchestration.
  • +
  • librawssg_config – Configuration structures (Config, SiteConfig, BuildConfig, ContentRule, NavItem) with validation and serialization.
  • +
  • librawssg_error – Unified error enum and Result alias used across all crates.
  • +
  • librawssg_fs – Filesystem abstraction trait and RealFs implementation with path traversal protection.
  • +
  • librawssg_handler – Document, Metadata, and the Processor trait for content processing.
  • +
  • librawssg_templates – RenderContext, Renderer traits, and the TeraRenderer implementation.
  • +
+ +

Select a crate from the list above to view its complete API documentation, including methods, fields, examples, and testing notes.

diff --git a/docs/src/content/api/templates.raw b/docs/src/content/api/templates.raw index e69de29..1c67df2 100644 --- a/docs/src/content/api/templates.raw +++ b/docs/src/content/api/templates.raw @@ -0,0 +1,947 @@ +

librawssg_templates

+ +

+ Version: 1.0.0 (implied)
+ Crate name: librawssg_templates
+ Description: Defines rendering abstractions for static site + generators. Provides the Renderer and + RenderContext traits, and an optional + TeraRenderer implementation (when the tera feature + is enabled) that integrates the Tera template engine. +

+ +
+ +

Table of Contents

+
    +
  1. Overview
  2. +
  3. Modules and Features
  4. +
  5. + Core Traits + +
  6. +
  7. + TeraRenderer + +
  8. +
  9. Internal Helper Function
  10. +
  11. Error Handling
  12. +
  13. Feature Gating
  14. +
  15. + Examples from Tests + +
  16. +
  17. Testing Suite Overview
  18. +
  19. Conclusion
  20. +
+ +
+ +

Overview

+

+ librawssg_templates provides a pluggable template rendering + system. It abstracts the rendering process with two traits: +

+
    +
  • + Renderer – Defines the + render method that takes a template name and a context, + returning a rendered string. +
  • +
  • + RenderContext – An object‑safe trait that + allows type erasure for context objects; specifically, it provides + as_any and as_mut_any to downcast to concrete + context types. +
  • +
+

+ The crate optionally includes a + TeraRenderer implementation for the + Tera template engine. This + implementation is gated behind the tera feature flag. +

+ +
+ +

Modules and Features

+

The crate root (lib.rs) declares:

+
pub mod renderer;
+#[cfg(feature = "tera")]
+pub mod tera_renderer;
+
+pub use renderer::{RenderContext, Renderer};
+#[cfg(feature = "tera")]
+pub use tera_renderer::TeraRenderer;
+
+
    +
  • + renderer – Always available; contains the + two core traits. +
  • +
  • + tera_renderer – Only compiled when the + tera feature is enabled; contains + TeraRenderer. +
  • +
  • + Re‑exports at the crate root make the traits and + TeraRenderer easy to import. +
  • +
+

+ The tera feature must be explicitly enabled in + Cargo.toml to use TeraRenderer. Without it, the + crate still provides the traits for custom renderer implementations. +

+ +
+ +

Core Traits

+

Trait RenderContext

+
pub trait RenderContext: Send + Sync {
+    fn as_any(&self) -> &dyn Any;
+    fn as_mut_any(&mut self) -> &mut dyn Any;
+}
+
+

+ Purpose: Allows arbitrary context types to be passed to a + Renderer as a trait object. The renderer can then downcast the + &dyn RenderContext to the concrete context type it expects + (e.g., tera::Context). This provides flexibility without + requiring all renderers to accept a single concrete type. +

+

Requirements:

+
    +
  • Implementors must be Send + Sync (thread‑safe).
  • +
  • + Must provide as_any and as_mut_any to expose + the underlying Any reference. +
  • +
+

Typical Implementation:

+

For any type T, you can implement:

+
impl RenderContext for T {
+    fn as_any(&self) -> &dyn Any {
+        self
+    }
+    fn as_mut_any(&mut self) -> &mut dyn Any {
+        self
+    }
+}
+
+

Example (from tests):

+
struct MockContext;
+
+impl RenderContext for MockContext {
+    fn as_any(&self) -> &dyn Any {
+        self
+    }
+    fn as_mut_any(&mut self) -> &mut dyn Any {
+        self
+    }
+}
+
+ +
+ +

Trait Renderer

+
pub trait Renderer: Send + Sync {
+    fn render(&self, template_name: &str, context: &dyn RenderContext) -> Result<String>;
+}
+
+

+ Purpose: Defines the rendering interface. A + Renderer takes a template identifier (name) and a context + object, and returns the rendered output as a String. +

+

Parameters:

+
    +
  • + template_name: A string identifying the template (e.g., + "index.html", "blog/post.tera"). +
  • +
  • + context: A reference to an object implementing + RenderContext. The renderer is expected to downcast this to + the appropriate concrete context type. +
  • +
+

Returns:

+
    +
  • Ok(String) containing the rendered output.
  • +
  • + Err(librawssg_error::Error) if rendering fails (e.g., + template not found, invalid syntax, missing variable, or context type + mismatch). +
  • +
+

Note: Implementors must be Send + Sync.

+

Example (custom mock renderer):

+
struct MockRenderer { output: String }
+
+impl Renderer for MockRenderer {
+    fn render(&self, _template_name: &str, _context: &dyn RenderContext) -> Result<String> {
+        Ok(self.output.clone())
+    }
+}
+
+ +
+ +

Struct TeraRenderer

+

+ TeraRenderer is a wrapper around tera::Tera, + providing convenient methods to load templates and render them using the + Renderer trait. It is only available when the + tera feature is enabled. +

+ +

Struct Definition

+
#[derive(Debug)]
+pub struct TeraRenderer {
+    tera: tera::Tera,
+}
+
+

+ The tera field is private; access is provided via + as_tera and as_tera_mut. +

+ +
+ +

TeraRenderer::new

+
#[must_use]
+pub fn new() -> Self
+
+

+ Purpose: Creates a new TeraRenderer with an + empty Tera instance (tera::Tera::default()). +

+

Returns: A new TeraRenderer.

+

Example:

+
let renderer = TeraRenderer::new();
+
+ +
+ +

+ TeraRenderer::add_raw_template +

+
pub fn add_raw_template(&mut self, name: &str, content: &str) -> Result<()>
+
+

+ Purpose: Adds a template from a string, associating it with + the given name. The template is parsed and stored internally. +

+

Parameters:

+
    +
  • + name: The template name (e.g., "index.html", + "partial"). +
  • +
  • + content: The raw template source (e.g., + "Hello {{ name }}"). +
  • +
+

Returns:

+
    +
  • Ok(()) if the template was added successfully.
  • +
  • + Err(Error::Render) if the template syntax is invalid (the + underlying tera::Error is converted to a string and + wrapped). +
  • +
+

+ Behavior: Calls + tera.add_raw_template(name, content). Template names must be + unique; adding a duplicate name will replace the existing template. +

+

Example:

+
renderer.add_raw_template("hello", "Hello {{ name }}")?;
+
+ +
+ +

+ TeraRenderer::add_template_file +

+
pub fn add_template_file(&mut self, path: &Path) -> Result<()>
+
+

+ Purpose: Reads a template file from disk and adds it to the + renderer. The template name is derived from the file name (including + extension). +

+

Parameters:

+
    +
  • path: Path to the template file.
  • +
+

Returns:

+
    +
  • Ok(()) on success.
  • +
  • + Err(Error::Io) if the file cannot be read (wrapped as + Error::Io with a message containing the original I/O + error). +
  • +
  • + Err(Error::Render) if the file name is not valid UTF‑8 or + missing (unlikely). +
  • +
+

Behavior:

+
    +
  1. + Reads the file content using std::fs::read_to_string. On + failure, maps to + Error::Io(std::io::Error::other(format!("{e}"))). +
  2. +
  3. + Extracts the file name (the last component of the path) and converts it + to a &str. If missing or non‑UTF‑8, returns + Error::Render("template file has no valid file name"). +
  4. +
  5. + Calls add_raw_template with that file name as the template + name. +
  6. +
+

Example:

+
renderer.add_template_file(Path::new("templates/index.html"))?;
+// Template is registered as "index.html"
+
+ +
+ +

+ TeraRenderer::add_template_files_from_dir +

+
pub fn add_template_files_from_dir(&mut self, dir: &Path) -> Result<()>
+
+

+ Purpose: Adds all files directly inside a directory as + templates. This method is not recursive; it only considers + files in the immediate directory. +

+

Parameters:

+
    +
  • dir: Directory containing template files.
  • +
+

Returns:

+
    +
  • + Ok(()) if at least the directory is readable and processing + completes. +
  • +
  • Err(Error::Io) on directory read failure.
  • +
  • + Err(Error::Render) if any individual file cannot be added. +
  • +
+

Behavior:

+
    +
  1. Reads the directory entries using std::fs::read_dir.
  2. +
  3. + For each entry: +
      +
    • + If the entry is a file, calls + add_template_file with its path. +
    • +
    • + If that returns an error, the method immediately returns the + error (fail‑fast). +
    • +
    +
  4. +
  5. Non‑file entries (subdirectories, symlinks) are ignored.
  6. +
+

+ Note: Template names are the file names (including + extensions). +

+

Example:

+
renderer.add_template_files_from_dir(Path::new("templates/"))?;
+// Adds all files in templates/ as templates with names like "base.tera", "index.html", etc.
+
+ +
+ +

+ TeraRenderer::load_templates_dir +

+
pub fn load_templates_dir(&mut self, dir: &Path) -> Result<()>
+
+

+ Purpose: Recursively loads all template files from a + directory tree. Template names are derived from the relative path (using + forward slashes as separators), allowing nested template structures (e.g., + "sub/nested.tera"). +

+

Parameters:

+
    +
  • dir: Root directory to traverse.
  • +
+

Returns:

+
    +
  • Ok(()) on success.
  • +
  • + Err(Error::Io) for filesystem errors during traversal or + reading. +
  • +
  • + Err(Error::Render) for invalid UTF‑8 paths or component + issues. +
  • +
+

Behavior:

+
    +
  1. + Canonicalizes the input directory (using + dir.canonicalize()) to ensure a stable base. +
  2. +
  3. + Walks the directory recursively using walkdir::WalkDir. + Only files are processed. +
  4. +
  5. + For each file: +
      +
    • + Computes its path relative to the canonical directory using + strip_prefix. +
    • +
    • + Converts the relative path to a template name using the internal + helper rel_path_to_template_name (which joins + components with / and rejects non‑normal + components). +
    • +
    • Reads the file content.
    • +
    • + Calls add_raw_template with the computed template + name and content. +
    • +
    +
  6. +
+

+ Note: This method is similar to + add_template_files_from_dir but recursive and with + namespace‑like template names. +

+

Example:

+
renderer.load_templates_dir(Path::new("templates"))?;
+// If templates contains sub/child.tera, it can be referenced as "sub/child.tera"
+
+ +
+ +

+ TeraRenderer::enable_autoescape +

+
pub fn enable_autoescape(&mut self)
+
+

+ Purpose: Turns on automatic escaping for HTML, HTM, and XML + file extensions. This is a convenience method that calls + tera.autoescape_on(vec!["html", "htm", "xml"]). +

+

Parameters: None.

+

Returns: Nothing.

+

+ Behavior: After calling this, templates with names ending + in .html, .htm, or .xml will + automatically escape variable output (HTML escaping). For other file + extensions, autoescaping remains off. +

+

Example:

+
renderer.enable_autoescape();
+renderer.add_raw_template("page.html", "{{ user_input }}")?;
+// Rendering will escape HTML special characters in user_input
+
+ +
+ +

TeraRenderer::render_str

+
pub fn render_str(&self, template_str: &str, context: &dyn RenderContext) -> Result<String>
+
+

+ Purpose: Renders a one‑off template string without + registering it. This is useful for small, inline templates. +

+

Parameters:

+
    +
  • template_str: The template source as a string.
  • +
  • + context: A &dyn RenderContext that must + downcast to tera::Context. +
  • +
+

Returns:

+
    +
  • Ok(String) with rendered output.
  • +
  • + Err(Error::Render) if the context is not a + tera::Context or if rendering fails. +
  • +
+

Behavior:

+
    +
  1. + Attempts to downcast context.as_any() to + &tera::Context. If the cast fails, returns + Error::Render("invalid context type for Tera"). +
  2. +
  3. + Calls tera::Tera::one_off(template_str, tera_ctx, true). + The third argument true enables autoescaping for the + one‑off render by default, regardless of the renderer's + autoescape settings. +
  4. +
+

+ Note: render_str always autoescapes (the + true parameter forces autoescape). This may differ from + render which respects the renderer's autoescape configuration. +

+

Example:

+
let renderer = TeraRenderer::new();
+let mut ctx = tera::Context::new();
+ctx.insert("content", "<b>bold</b>");
+let output = renderer.render_str("{{ content }}", &ctx)?;
+// Output is escaped: "&lt;b&gt;bold&lt;&#x2F;b&gt;"
+
+ +
+ +

TeraRenderer::as_tera

+
#[must_use]
+pub const fn as_tera(&self) -> &tera::Tera
+
+

+ Purpose: Returns an immutable reference to the underlying + tera::Tera instance. This allows advanced operations not + directly exposed by TeraRenderer. +

+

Returns: &tera::Tera.

+

Example:

+
let tera = renderer.as_tera();
+// e.g., inspect registered templates
+
+ +
+ +

TeraRenderer::as_tera_mut

+
#[must_use]
+pub const fn as_tera_mut(&mut self) -> &mut tera::Tera
+
+

+ Purpose: Returns a mutable reference to the underlying + tera::Tera instance. Useful for direct manipulation, such as + adding templates or changing settings. +

+

Returns: &mut tera::Tera.

+

Example:

+
let tera_mut = renderer.as_tera_mut();
+tera_mut.add_raw_template("direct", "Hello")?;
+
+ +
+ +

Trait Implementations

+ +

TeraRenderer::Default

+
impl Default for TeraRenderer {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+

+ Allows creating a TeraRenderer with + TeraRenderer::default(), equivalent to new(). +

+ +

+ Renderer for TeraRenderer +

+
impl Renderer for TeraRenderer {
+    fn render(&self, template_name: &str, context: &dyn RenderContext) -> Result<String> {
+        let tera_ctx = context
+            .as_any()
+            .downcast_ref::<tera::Context>()
+            .ok_or_else(|| Error::Render("invalid context type for Tera".into()))?;
+        self.tera
+            .render(template_name, tera_ctx)
+            .map_err(|e| Error::Render(e.to_string()))
+    }
+}
+
+
    +
  • + render method: +
      +
    • Downcasts the context to tera::Context.
    • +
    • + Delegates to tera.render(template_name, tera_ctx). +
    • +
    • Maps any error to Error::Render.
    • +
    +
  • +
+ +

+ RenderContext for tera::Context +

+
impl RenderContext for tera::Context {
+    fn as_any(&self) -> &dyn core::any::Any {
+        self
+    }
+    fn as_mut_any(&mut self) -> &mut dyn core::any::Any {
+        self
+    }
+}
+
+

+ This implementation allows tera::Context to be used directly as + a RenderContext when calling render. Users + typically create a tera::Context, populate it, and pass + &ctx to render. +

+ +
+ +

Internal Helper Function

+

rel_path_to_template_name (private)

+
fn rel_path_to_template_name(rel_path: &Path) -> Result<String>
+
+

+ Purpose: Converts a relative path (from + strip_prefix) into a template name string using forward slashes + as separators. It rejects paths with unusual components (prefixes, root, + parent, or current directory). +

+

Parameters:

+
    +
  • + rel_path: A relative path (assumed to have been stripped of + a base). +
  • +
+

Returns:

+
    +
  • + Ok(String) with the template name (e.g., + "sub/nested.tera"). +
  • +
  • + Err(Error::Render) if: +
      +
    • + A component is not Normal (e.g., contains + .. or / absolute parts). +
    • +
    • The path contains non‑UTF‑8 characters.
    • +
    • The resulting name is empty.
    • +
    +
  • +
+

+ Note: This function is not public but is essential for + load_templates_dir. +

+ +
+ +

Error Handling

+

+ All fallible methods in TeraRenderer return + librawssg_error::Result<T>. The errors originate from: +

+
    +
  • + Filesystem operations → converted to Error::Io (with + std::io::Error::other wrapper to preserve the original + message). +
  • +
  • + Tera template parsing/rendering → converted to + Error::Render with the error message as string. +
  • +
  • + Context type mismatch → + Error::Render("invalid context type for Tera"). +
  • +
  • + Invalid template file name or path component → + Error::Render. +
  • +
+

+ The Renderer trait method also returns + Result<String>, allowing custom renderers to use the same + error type. +

+ +
+ +

Feature Gating

+
    +
  • + The core traits (Renderer, RenderContext) are + always available. +
  • +
  • + TeraRenderer and the tera integration are only + compiled when the tera feature is enabled. +
  • +
  • + Tests that use TeraRenderer are also gated with + #![cfg(feature = "tera")]. +
  • +
+

To enable the feature, add to Cargo.toml:

+
[dependencies]
+librawssg_templates = { version = "...", features = ["tera"] }
+
+ +
+ +

Examples from Tests

+

+ The test suite (tera_tests.rs and unit_tests.rs) + provides extensive examples. Below are selected snippets with explanations. +

+ +

Basic Rendering

+
let mut renderer = TeraRenderer::new();
+renderer.add_raw_template("simple", "{{ title }}")?;
+
+let mut ctx = tera::Context::new();
+ctx.insert("title", "Hello World");
+let output = renderer.render("simple", &ctx)?;
+assert_eq!(output, "Hello World");
+
+ +

Loops, Filters, Conditions

+
renderer.add_raw_template("loop", "{% for item in items %}{{ item }}{% if not loop.last %},{% endif %}{% endfor %}")?;
+// context: items = ["a","b","c"] -> output "a,b,c"
+
+renderer.add_raw_template("filter", "{{ title | upper }}")?;
+// context: title = "Hello" -> "HELLO"
+
+renderer.add_raw_template("condition", "{% if number > 40 %}high{% else %}low{% endif %}")?;
+// context: number = 42 -> "high"
+
+ +

File Loading

+
// Add a single file
+let file_path = dir.path().join("hello.tera");
+std::fs::write(&file_path, "{{ name }}")?;
+renderer.add_template_file(&file_path)?;
+// Template name is "hello.tera"
+
+// Add all files from a directory (non-recursive)
+renderer.add_template_files_from_dir(dir.path())?;
+
+// Recursive loading with namespaced names
+renderer.load_templates_dir(dir.path())?;
+// If sub/nested.tera exists, use renderer.render("sub/nested.tera", &ctx)
+
+ +

Autoescaping

+
renderer.enable_autoescape();
+renderer.add_raw_template("esc.html", "{{ content }}")?;
+let mut ctx = tera::Context::new();
+ctx.insert("content", "<script>alert(1)</script>");
+let output = renderer.render("esc.html", &ctx)?;
+// Output: &lt;script&gt;alert(1)&lt;&#x2F;script&gt;
+
+

Note: render_str always autoescapes:

+
let output = renderer.render_str("{{ content }}", &ctx)?;
+// Also escaped
+
+ +

Template Inheritance and Macros

+
renderer.add_raw_template("base", "<html>{% block content %}Default{% endblock %}</html>")?;
+renderer.add_raw_template("child", "{% extends \"base\" %}{% block content %}Child content{% endblock %}")?;
+// Rendering "child" yields "<html>Child content</html>"
+
+renderer.add_raw_template("macro", "{% macro hello(name) %}Hello, {{ name }}{% endmacro hello %}{{ self::hello(name=\"World\") }}")?;
+// Rendering "macro" yields "Hello, World"
+
+ +

Context Downcasting

+
let ctx = sample_context();
+let dyn_ctx: &dyn RenderContext = &ctx;
+assert!(dyn_ctx.as_any().is::<tera::Context>());
+
+

+ This shows how the RenderContext trait enables type erasure and + safe downcasting. +

+ +
+ +

Testing Suite Overview

+

The crate contains two test files:

+
    +
  • + unit_tests.rs (always compiled): Tests the + core traits using mock implementations, verifies re‑exports are + available, and ensures the traits can be used without the + tera feature. +
  • +
  • + tera_tests.rs (compiled only with + tera feature): Comprehensive tests for + TeraRenderer including: +
      +
    • Basic variable substitution, loops, filters, conditions.
    • +
    • + Error cases (missing template, missing variable, invalid + syntax). +
    • +
    • + Loading templates from files and directories (both non‑recursive + and recursive). +
    • +
    • Autoescaping behavior.
    • +
    • Template inheritance, macros, includes.
    • +
    • Context downcasting.
    • +
    • + Access to underlying Tera via + as_tera and as_tera_mut. +
    • +
    +
  • +
+

+ The tests use tempfile for temporary directories and + walkdir for directory traversal validation. +

+ +
+ +

Conclusion

+

+ librawssg_templates offers a flexible and extensible template + rendering abstraction. The core Renderer and + RenderContext traits allow any template engine to be + integrated, while the built‑in TeraRenderer provides a + powerful, full‑featured implementation for the Tera engine. With methods for + loading templates from files or directories, autoescaping control, and + direct access to the underlying engine, it covers the needs of most static + site generators. +

+

+ The crate is designed with testability and thread‑safety in mind, and the + comprehensive test suite serves as both documentation and validation. By + enabling the tera feature, developers can immediately start + rendering templates with minimal setup. +

diff --git a/docs/src/content/code_of_conduct.raw b/docs/src/content/code_of_conduct.raw index e69de29..0da5442 100644 --- a/docs/src/content/code_of_conduct.raw +++ b/docs/src/content/code_of_conduct.raw @@ -0,0 +1,108 @@ +

Contributor Covenant Code of Conduct

+ +

Our Pledge

+

+ We as members, contributors, and leaders pledge to make participation in our + community a harassment-free experience for everyone, regardless of age, body + size, visible or invisible disability, ethnicity, sex characteristics, gender + identity and expression, level of experience, education, socio-economic status, + nationality, personal appearance, race, religion, or sexual identity and + orientation. +

+

+ We pledge to act and interact in ways that contribute to an open, welcoming, + diverse, inclusive, and healthy community. +

+ +

Our Standards

+

Examples of behavior that contributes to a positive environment for our community include:

+
    +
  • Demonstrating empathy and kindness toward other people
  • +
  • Being respectful of differing opinions, viewpoints, and experiences
  • +
  • Giving and gracefully accepting constructive feedback
  • +
  • Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
  • +
  • Focusing on what is best not just for us as individuals, but for the overall community
  • +
+

Examples of unacceptable behavior include:

+
    +
  • The use of sexualized language or imagery, and sexual attention or advances of any kind
  • +
  • Trolling, insulting or derogatory comments, and personal or political attacks
  • +
  • Public or private harassment
  • +
  • Publishing others' private information, such as a physical or email address, without their explicit permission
  • +
  • Other conduct which could reasonably be considered inappropriate in a professional setting
  • +
+ +

Enforcement Responsibilities

+

+ Community leaders are responsible for clarifying and enforcing our standards of + acceptable behavior and will take appropriate and fair corrective action in + response to any behavior that they deem inappropriate, threatening, offensive, + or harmful. +

+

+ Community leaders have the right and responsibility to remove, edit, or reject + comments, commits, code, wiki edits, issues, and other contributions that are + not aligned to this Code of Conduct, and will communicate reasons for moderation + decisions when appropriate. +

+ +

Scope

+

+ This Code of Conduct applies within all community spaces, and also applies when + an individual is officially representing the community in public spaces. + Examples of representing our community include using an official e-mail address, + posting via an official social media account, or acting as an appointed + representative at an online or offline event. +

+ +

Enforcement

+

+ Instances of abusive, harassing, or otherwise unacceptable behavior may be + reported to the community leaders responsible for enforcement at + mroczect@proton.me. + All complaints will be reviewed and investigated promptly and fairly. +

+

+ All community leaders are obligated to respect the privacy and security of the + reporter of any incident. +

+ +

Enforcement Guidelines

+

+ Community leaders will follow these Community Impact Guidelines in determining + the consequences for any action they deem in violation of this Code of Conduct: +

+ +

1. Correction

+

Community Impact: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.

+

Consequence: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.

+ +

2. Warning

+

Community Impact: A violation through a single incident or series of actions.

+

Consequence: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.

+ +

3. Temporary Ban

+

Community Impact: A serious violation of community standards, including sustained inappropriate behavior.

+

Consequence: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.

+ +

4. Permanent Ban

+

Community Impact: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.

+

Consequence: A permanent ban from any sort of public interaction within the community.

+ +

Attribution

+

+ This Code of Conduct is adapted from the + Contributor Covenant, + version 2.0, available at + https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. +

+

+ Community Impact Guidelines were inspired by + Mozilla's code of conduct enforcement ladder. +

+

+ For answers to common questions about this code of conduct, see the FAQ at + https://www.contributor-covenant.org/faq. + Translations are available at + https://www.contributor-covenant.org/translations. +

\ No newline at end of file diff --git a/docs/src/content/configuration.raw b/docs/src/content/configuration.raw index e69de29..00e326c 100644 --- a/docs/src/content/configuration.raw +++ b/docs/src/content/configuration.raw @@ -0,0 +1,402 @@ +

Configuration

+ +

+ librawssg uses a single configuration file to control the entire + build process. This file can be written in YAML or JSON format and contains + site settings, directory locations, content processing rules, and custom + extra data. +

+ +

Configuration File Format

+ +

+ The configuration can be stored as config.yaml, + config.yml, or config.json. By default, the + PipelineBuilder supports reading YAML via its + load_config() method. For JSON, you can manually call + Config::from_json_str(). +

+ +
# config.yaml
+site:
+  site_name: "My Documentation"
+  description: "A site built with librawssg"
+  language: "en"
+  base_url: "https://example.com"
+  author: "Your Name"
+  repo_url: "https://github.com/username/repo"
+  license: "MIT"
+
+build:
+  content_dir: "content"
+  output_dir: "dist"
+  templates_dir: "templates"
+  static_dir: "static"
+
+content_rules:
+  - name: "page"
+    pattern: "**/*.raw"
+    template: "base.tera"
+    list_enabled: false
+
+ +

Configuration Sections

+ +

site

+ +

+ Contains site metadata and navigation structures. All fields except + site_name are optional and have sensible defaults. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDefaultDescription
site_namestring"librawssg"The name of the website. Must not be empty or whitespace-only.
descriptionstring | nullnullA short description of the site.
languagestring | null"en"Site language code (e.g., "en", "id").
base_urlstring | nullnullThe base URL of the site. Must start with http:// or https:// if set.
authorstring | nullnullDefault author name.
repo_urlstring | nullnullURL to the source repository.
licensestring | nullnullLicense identifier (e.g., "MIT").
navbararray<NavItem>[]List of navigation items for the top bar.
sidebararray<NavItem>[]List of navigation items for the sidebar.
extraobject{}Arbitrary extra site-wide metadata.
+ +

NavItem Structure

+ +

+ Each NavItem has the following fields: +

+ +
{
+  "label": "Home",
+  "url": "/",
+  "children": []
+}
+ +
    +
  • label – Display text for the link.
  • +
  • url – URL the link points to (relative or absolute).
  • +
  • children – Optional nested sub-items for hierarchical menus.
  • +
+ +

build

+ +

+ Specifies the directory locations used during the build. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDefaultDescription
content_dirstring"content"Directory containing source content files.
output_dirstring"dist"Directory where generated site output will be written.
templates_dirstring"templates"Directory containing template files.
static_dirstring"static"Directory containing static assets (copied as-is).
+ +

content_rules

+ +

+ A list of rules that map file patterns to templates. Each rule has the + following fields: +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDefaultDescription
namestringrequiredA unique identifier for the rule (e.g., "blog", "page").
patternstringrequiredGlob pattern matching content files (e.g., "**/*.md"). Must not contain ...
templatestringrequiredName of the template to use for rendering each matched file.
list_templatestring | nullnullOptional template name for rendering list pages (index pages).
list_enabledbooleanfalseWhether list generation is enabled for this rule.
extraobject{}Arbitrary extra data associated with the rule.
+ +

Example with List Generation

+ +
content_rules:
+  - name: "blog"
+    pattern: "blog/**/*.md"
+    template: "post.tera"
+    list_template: "blog_list.tera"
+    list_enabled: true
+
+ +

extra

+ +

+ A free-form object for storing custom top-level data. This data can be + accessed from templates via the context. +

+ +

Validation Rules

+ +

+ The configuration is validated when the pipeline is built. The following + checks are performed: +

+ +
    +
  • site.site_name must not be empty or contain only whitespace.
  • +
  • At least one content rule must be defined.
  • +
  • Content rule names must be unique.
  • +
  • Each content rule must have non-empty name, pattern, and template.
  • +
  • The pattern must not contain ".." (to prevent path traversal).
  • +
  • If site.base_url is set, it must begin with http:// or https://.
  • +
+ +

+ If any validation fails, an error of type + Error::Validation is returned with a descriptive message. +

+ +

Loading Configuration

+ +

From YAML

+ +
use librawssg_compiler::PipelineBuilder;
+
+let pipeline = PipelineBuilder::new()
+    .load_config("config.yaml")?
+    // ... other builder methods
+    .build()?;
+
+ +

From JSON

+ +
use librawssg_config::Config;
+
+let json_str = r#"{
+    "site": {
+        "site_name": "My Site"
+    },
+    "build": {},
+    "content_rules": [
+        {
+            "name": "page",
+            "pattern": "**/*.html",
+            "template": "base.tera"
+        }
+    ]
+}"#;
+
+let config = Config::from_json_str(json_str)?;
+ +

Programmatic Construction

+ +
use librawssg_config::{Config, ContentRule};
+
+let mut config = Config::new()
+    .with_site_name("My Site");
+
+config.add_content_rule(
+    ContentRule::new("page", "**/*.raw", "base.tera")
+);
+ +

Serialization

+ +

+ The Config struct supports serialization to YAML and JSON: +

+ +
let yaml = config.to_yaml_string()?;
+let json = config.to_json_string()?;
+ +

+ This allows saving the configuration back to a file for later use. +

+ +

Common Patterns

+ +

Multiple Content Types

+ +
content_rules:
+  - name: "page"
+    pattern: "**/*.raw"
+    template: "base.tera"
+  - name: "blog"
+    pattern: "blog/**/*.md"
+    template: "post.tera"
+    list_enabled: true
+    list_template: "blog_list.tera"
+
+ +

Custom Navigation

+ +
site:
+  navbar:
+    - label: "Home"
+      url: "/"
+    - label: "API"
+      url: "/api/index.html"
+      children:
+        - label: "Compiler"
+          url: "/api/compiler.html"
+        - label: "Config"
+          url: "/api/config.html"
+  sidebar:
+    - label: "Getting Started"
+      url: "/"
+    - label: "Installation"
+      url: "/installation.html"
+
+ +

Custom Extra Data

+ +
site:
+  extra:
+    analytics_id: "UA-123456-7"
+    social:
+      twitter: "handle"
+
+ +

+ This extra data is accessible in templates via {{ site.extra }}. +

+ +

Tips

+ +
    +
  • + Use YAML for better readability; JSON is also fully supported. +
  • +
  • + Keep content_rules ordered from most specific to least + specific, as later rules take precedence. +
  • +
  • + The build section can be omitted entirely; defaults will be + used. +
  • +
  • + Remember to set list_enabled: true and provide a + list_template if you want index pages for a content type. +
  • +
diff --git a/docs/src/content/contributing.raw b/docs/src/content/contributing.raw index e69de29..4413ee0 100644 --- a/docs/src/content/contributing.raw +++ b/docs/src/content/contributing.raw @@ -0,0 +1,185 @@ +

Contributing to librawssg

+ +

+ Thank you for your interest in contributing to librawssg. This document + outlines the process for reporting issues, proposing changes, and submitting + code contributions. Following these guidelines helps maintain the quality and + consistency of the project. +

+ +

Table of Contents

+ + +

Code of Conduct

+

+ This project adheres to a minimal set of social rules: be respectful, + constructive, and inclusive. Harassment, discrimination, or hostile behaviour + is not tolerated. If you experience or witness such conduct, please contact + the maintainers. +

+ +

Getting Started

+
    +
  1. Fork the repository on GitHub.
  2. +
  3. + Clone your fork locally: +
    git clone https://github.com/YOUR_USERNAME/librawssg.git
    +cd librawssg
    +
  4. +
  5. + Add the upstream remote to keep your fork in sync: +
    git remote add upstream https://github.com/mroczect/librawssg.git
    +
  6. +
  7. + Create a branch for your work: +
    git checkout -b feat/my-feature
    +
  8. +
+ +

Development Environment

+
    +
  • + Rust: Install the latest stable Rust toolchain via + rustup. +
  • +
  • + Dependencies: The project uses several crates (serde, + thiserror, miette, walkdir, chrono, etc.). They will be fetched + automatically by Cargo. Optional features (tera, + pulldown, serve) pull additional crates only when + enabled. +
  • +
  • + OS support: librawssg is a pure Rust library and should + compile and run on all platforms supported by Rust (Linux, macOS, Windows). + Ensure any changes remain cross-platform. +
  • +
+ +

Building and Testing

+

All commands below are run from the repository root.

+ +

Build

+
cargo build
+

To build with all features enabled:

+
cargo build --all-features
+ +

Run tests

+
cargo test
+cargo test --features tera,pulldown
+cargo test --all-features
+

+ This runs unit tests, integration tests (located in tests/), and + doc-tests. All tests must pass before a pull request is accepted. +

+ +

Lint and format

+
cargo fmt --all -- --check
+cargo clippy --all-targets --all-features -- -D warnings
+

These are enforced in CI. Run them locally to avoid surprises.

+ +

Coding Style

+
    +
  • Follow the standard Rust formatting (enforced by cargo fmt).
  • +
  • Use rustc and clippy lints strictly; any warning is treated as an error in CI.
  • +
  • Write idiomatic Rust: +
      +
    • Use Result and Option appropriately.
    • +
    • Prefer From implementations for error conversions.
    • +
    • Document public API items with /// comments.
    • +
    +
  • +
  • Keep functions small and focused.
  • +
  • Add tests for new functionality.
  • +
  • For any platform-specific code (unlikely in a pure SSG kernel), guard with #[cfg(...)] attributes.
  • +
+ +

Commit Messages

+

+ Use + conventional commit format: +

+
type(scope): short description
+
+Optional longer explanation.
+

+ Types: feat, fix, docs, + test, ci, chore, refactor, + style. +

+

+ Scope: librawssg (for core library), ci, + docs, etc. +

+

Examples:

+
    +
  • feat(librawssg): add support for custom content handlers
  • +
  • fix(librawssg): prevent path traversal when outputting files
  • +
  • docs(librawssg): add API reference for PageContext
  • +
+

This format enables automatic changelog generation and clear history.

+ +

Pull Request Process

+
    +
  1. Ensure your branch is based on an up-to-date master.
  2. +
  3. Run cargo test, cargo fmt --all -- --check, and cargo clippy --all-targets --all-features -- -D warnings to verify there are no issues.
  4. +
  5. If you added or modified public API, update the README and any relevant documentation comments.
  6. +
  7. Push your branch and open a pull request against the master branch of the main repository.
  8. +
  9. In the PR description: +
      +
    • Explain what the change does and why.
    • +
    • Mention any breaking changes.
    • +
    • Link to any related issues.
    • +
    • Note if documentation updates are included.
    • +
    +
  10. +
  11. The CI will run automatically. All checks must be green.
  12. +
  13. A maintainer will review your code. Please respond to feedback and make requested changes.
  14. +
  15. Once approved, the PR will be merged via squash merge to keep the history linear.
  16. +
+ +

Reporting Bugs

+

Open an issue on GitHub and include:

+
    +
  • A clear description of the problem.
  • +
  • Steps to reproduce.
  • +
  • Expected vs actual behaviour.
  • +
  • Environment details: OS, Rust version (rustc --version), librawssg version or commit hash.
  • +
  • If applicable, a minimal code example that demonstrates the bug.
  • +
+ +

Feature Requests

+

Feature requests are welcome. When opening an issue:

+
    +
  • Describe the feature and the problem it solves.
  • +
  • Explain how it fits into the library's scope.
  • +
  • Be open to discussion about design and implementation.
  • +
+

For large features, consider opening an issue first to gather feedback before writing code.

+ +

Documentation

+
    +
  • The main documentation is the README and API docs (cargo doc).
  • +
  • If you add a new public type or function, include clear doc comments with examples where appropriate.
  • +
  • Update the README if a new feature or major change affects the usage flow.
  • +
+ +

Community

+
    +
  • The main communication channel is GitHub issues and pull requests.
  • +
  • For questions or informal discussion, you can reach out via the repository's Discussions tab if enabled.
  • +
+ +

Thank you for contributing to librawssg. Your effort helps make the project better for everyone.

\ No newline at end of file diff --git a/docs/src/content/index.raw b/docs/src/content/index.raw index e69de29..e17075f 100644 --- a/docs/src/content/index.raw +++ b/docs/src/content/index.raw @@ -0,0 +1,99 @@ +

librawssg

+ +

+ A modular static site generator library for Rust. Build your own static site + generator with composable, testable, and safe components. +

+ +

What is librawssg?

+ +

+ librawssg is a collection of Rust crates that provide the core + building blocks for creating a static site generator. It is not a + ready‑to‑use CLI tool, but a framework that gives you full control over your + site’s behaviour. +

+ +
    +
  • + Modular architecture – Each aspect (configuration, + filesystem, content handling, templating, compilation) is isolated in its + own crate. +
  • +
  • + Pluggable processors – Define custom content processors via + the Processor trait. +
  • +
  • + Template engine integration – Built‑in support for Tera + templates (optional). +
  • +
  • + Strong filesystem abstraction – Trait‑based filesystem with + built‑in path traversal protection. +
  • +
  • + Atomic output generation – The build pipeline writes to a + temporary directory and atomically replaces the final output. +
  • +
  • + Comprehensive configuration – YAML/JSON support, + validation, and nested site/build settings. +
  • +
+ +

Quick Start

+ +

Add the facade crate to your Cargo.toml:

+ +
[dependencies]
+librawssg = "1.0.0"
+ +

+ Then, in your main.rs, assemble a pipeline: +

+ +
use librawssg::{Config, ContentRule, PipelineBuilder, RealFs, TeraContextBuilder, TeraRenderer};
+
+fn main() -> Result<(), Box<dyn std::error::Error>> {
+    let mut config = Config::new().with_site_name("My Site");
+    config.add_content_rule(ContentRule::new("page", "**/*.raw", "base.tera"));
+
+    let mut renderer = TeraRenderer::new();
+    renderer.load_templates_dir("templates")?;
+
+    let pipeline = PipelineBuilder::new()
+        .config(config)
+        .content_dir("content")
+        .output_dir("dist")
+        .with_fs(Box::new(RealFs))
+        .with_renderer(Box::new(renderer))
+        .with_context_builder(Box::new(TeraContextBuilder))
+        .build()?;
+
+    pipeline.run()?;
+    Ok(())
+}
+ +

+ For a complete example, see the Installation + page. +

+ +

Explore the Documentation

+ + + +

Repository

+ +

+ The source code is available on + GitHub. +

diff --git a/docs/src/content/installation.raw b/docs/src/content/installation.raw index e69de29..977ee7d 100644 --- a/docs/src/content/installation.raw +++ b/docs/src/content/installation.raw @@ -0,0 +1,191 @@ +

Installation

+ +

+ This guide covers how to install and set up librawssg for building + your own static site generator, or for integrating it into an existing Rust + project. +

+ +

Prerequisites

+ +
    +
  • + Rust toolchain – Install the latest stable Rust via + rustup. +
  • +
  • + Cargo – Comes with Rust; used for building and managing + dependencies. +
  • +
+ +

Adding librawssg as a Dependency

+ +

+ librawssg is a workspace of several crates. The easiest way is to + use the facade crate librawssg that re‑exports the essential + components. +

+ +

Add the following to your Cargo.toml:

+ +
[dependencies]
+librawssg = "1.0.0"
+ +

+ If you are working within the same workspace as the librawssg + source, use a path dependency instead: +

+ +
[dependencies]
+librawssg = { path = "../librawssg" }
+ +

Feature Flags

+ +

+ The facade crate re‑exports librawssg_templates with the + tera feature enabled by default. This gives you + TeraRenderer and TeraContextBuilder. If you want to + disable the Tera integration (for a custom renderer), set + default-features = false: +

+ +
[dependencies]
+librawssg = { version = "1.0.0", default-features = false }
+ +

Basic Project Setup

+ +

+ To create a minimal static site generator using librawssg, follow + these steps: +

+ +
    +
  1. +

    Create a new binary crate:

    +
    cargo new my-site-generator
    +cd my-site-generator
    +
  2. +
  3. +

    Add dependencies to Cargo.toml:

    +
    [dependencies]
    +librawssg = "1.0.0"
    +
  4. +
  5. +

    Create the necessary directories and files:

    +
    mkdir -p content templates static
    +
  6. +
  7. +

    Place your content, templates, and static assets in the respective folders.

    +
  8. +
  9. +

    Write a main.rs that builds and runs the pipeline (see example below).

    +
  10. +
+ +

Minimal Example

+ +

+ The following example uses a simple raw HTML processor and the Tera renderer. + It processes .raw files, renders them with a base template, and + copies static files. +

+ +
use librawssg::{
+    Config, ContentRule, Document, FileSystem, Metadata, PipelineBuilder, Processor,
+    RealFs, TeraContextBuilder, TeraRenderer,
+};
+use std::path::{Path, PathBuf};
+
+struct RawProcessor;
+
+impl Processor for RawProcessor {
+    fn name(&self) -> &'static str {
+        "raw"
+    }
+
+    fn can_process(&self, rel: &Path, _orig: &Path) -> bool {
+        rel.extension().and_then(|e| e.to_str()) == Some("raw")
+    }
+
+    fn process(
+        &self,
+        fs: &dyn FileSystem,
+        rel: &Path,
+        content_dir: &Path,
+    ) -> librawssg::Result<Option<Document>> {
+        let full_path = content_dir.join(rel);
+        let body = fs.read_to_string(&full_path)?;
+        let title = rel.file_stem().unwrap_or_default().to_string_lossy().to_string();
+        let meta = Metadata::new(title, String::new())?;
+        let url = rel.with_extension("html").to_string_lossy().to_string();
+        let output = PathBuf::from(&url);
+        let doc = Document::new(meta, body, url, output, rel.to_path_buf(), 0, "page".to_string(), false)?;
+        Ok(Some(doc))
+    }
+}
+
+fn main() -> Result<(), Box<dyn std::error::Error>> {
+    // 1. Configure the site
+    let mut config = Config::new().with_site_name("My Site");
+    config.add_content_rule(ContentRule::new("page", "**/*.raw", "base.tera"));
+    config.build.content_dir = "content".into();
+    config.build.output_dir = "dist".into();
+    config.build.static_dir = "static".into();
+
+    // 2. Set up the renderer and load templates
+    let mut renderer = TeraRenderer::new();
+    renderer.load_templates_dir(Path::new("templates"))?;
+
+    // 3. Build the pipeline
+    let pipeline = PipelineBuilder::new()
+        .config(config)
+        .content_dir("content")
+        .output_dir("dist")
+        .with_fs(Box::new(RealFs))
+        .with_renderer(Box::new(renderer))
+        .with_context_builder(Box::new(TeraContextBuilder))
+        .add_processor(Box::new(RawProcessor))
+        .build()?;
+
+    // 4. Run the generation
+    pipeline.run()?;
+    println!("Site generated in 'dist'");
+    Ok(())
+}
+ +

Building the Documentation Site Itself

+ +

+ The librawssg repository includes a docs crate that + generates this documentation website. To build it locally: +

+ +
cargo run -p docs
+ +

+ The output will be written to docs/dist/. You can open + index.html to view the site. +

+ +

Directory Structure

+ +

+ A typical project using librawssg has the following layout: +

+ +
my-site-generator/
+├── Cargo.toml
+├── content/           # Source content files (.raw, .md, etc.)
+├── templates/         # Tera templates
+├── static/            # Static assets (CSS, JS, images)
+└── src/
+    └── main.rs        # Entry point
+ +

Next Steps

+ + diff --git a/docs/src/content/license.raw b/docs/src/content/license.raw index e69de29..ce50c6d 100644 --- a/docs/src/content/license.raw +++ b/docs/src/content/license.raw @@ -0,0 +1,29 @@ +

License

+ +

The MIT License (MIT)

+ +

Copyright (c) 2026 mroczect

+ +

+ Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: +

+ +

+ The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. +

+ +

+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. +

\ No newline at end of file From ea8e84abd8ca07df078ac354d047a4e4473f81eb Mon Sep 17 00:00:00 2001 From: mroczect Date: Wed, 9 Sep 2026 01:39:43 +0700 Subject: [PATCH 47/48] feat(docs): implement documentation site generator and styling (#39) * chore(docs): update lockfile for docs crate Add docs crate entry with dependency on librawssg. Keeps lockfile synchronized with workspace changes. * feat(workspace): add docs to workspace members Include the docs application in the workspace member list to enable building and testing documentation generation. * feat(docs): add librawssg dependency Add path dependency to librawssg in docs Cargo.toml to use the facade crate for documentation generation. * feat(docs): update API index links to subdirectory Change link paths to include `/api/` prefix so they point to the correct generated files under the API section. * feat(docs): implement documentation site generator Replace placeholder main with a complete generator using librawssg pipeline. Define RawFileProcessor, configure site navigation/sidebar, load templates, and run pipeline to produce documentation output. * style(docs): refine base CSS styles Adjust font size, add focus styles, transitions, and consolidate element styling for better consistency. * style(docs): update content and breadcrumb styles Tweak table, image, breadcrumb styles, and typography for improved readability and visual polish. * style(docs): restyle footer Change footer background to match page background, add border, adjust padding and typography. * style(docs): simplify layout styles Remove unused row/col classes, adjust content padding, and add file header comment. * style(docs): enhance navbar styling Add focus states, improve brand and nav link styles, hamburger animation, and theme toggle transitions. * style(docs): improve responsive behavior Adjust mobile sidebar width and shadow, nav link flex direction, and content padding for small screens. * style(docs): refine sidebar styles Restructure sidebar header, improve link hover/active states, add overlay styles, and close button polish. * style(docs): update typography styles Adjust heading margins, code and blockquote styling, and font sizes for better hierarchy. * style(docs): expand theme variables Add more color tokens, radius, shadows, overlay, and adjust dark theme values for consistency. * feat(docs): improve navbar theme and toggle handling Add function to manage highlight.js theme stylesheets, handle aria-expanded, and initialize theme from localStorage or system preference. * feat(docs): enhance sidebar behavior Set aria-expanded on toggle, close sidebar on resize to desktop, and simplify event listener registration. * feat(docs): update base template Import macros, add wrapper div, render breadcrumb if present, and use markdown-body class for content. * style(docs): add newline at end of macros file Ensure file ends with newline to match formatting conventions. * feat(docs): use static year in footer Replace dynamic now() with hardcoded 2026 to avoid dependency on date function. * feat(docs): add theme initialization script in head Insert inline script to set data-theme before CSS loads to prevent flash of incorrect theme. * feat(docs): add theme toggle and aria-expanded Add theme toggle button with icon and initial aria-expanded=false on navbar toggle button. * feat(docs): add highlight.js script and initialization Load highlight.js from CDN and call highlightAll on DOMContentLoaded before main.js module. * feat(docs): add sidebar close button and overlay Add header with close button and overlay div for mobile sidebar behavior. * feat(docs): add highlight.js theme stylesheets Include light and dark highlight.js themes, with dark theme disabled by default for dynamic switching. * chore(docs): add .gitignore for dist Ignore the generated dist directory in docs to prevent build artifacts from being tracked. --- Cargo.lock | 7 + Cargo.toml | 2 +- docs/.gitignore | 1 + docs/Cargo.toml | 1 + docs/src/content/api/index.raw | 12 +- docs/src/main.rs | 127 ++++++++++++++++++- docs/src/static/css/_base.css | 33 +++-- docs/src/static/css/_content.css | 34 ++--- docs/src/static/css/_footer.css | 16 +-- docs/src/static/css/_layout.css | 19 +-- docs/src/static/css/_navbar.css | 76 ++++++++--- docs/src/static/css/_responsive.css | 17 ++- docs/src/static/css/_sidebar.css | 95 ++++++++++---- docs/src/static/css/_typography.css | 40 +++--- docs/src/static/css/_variables.css | 55 +++++--- docs/src/static/js/modules/navbar.js | 31 +++-- docs/src/static/js/modules/sidebar.js | 16 ++- docs/src/templates/base.tera | 12 +- docs/src/templates/macros.tera | 2 +- docs/src/templates/partials/footer.tera | 4 +- docs/src/templates/partials/head.tera | 13 +- docs/src/templates/partials/navbar.tera | 7 +- docs/src/templates/partials/scripts.tera | 8 +- docs/src/templates/partials/sidebar.tera | 8 +- docs/src/templates/partials/stylesheets.tera | 6 +- 25 files changed, 461 insertions(+), 181 deletions(-) create mode 100644 docs/.gitignore diff --git a/Cargo.lock b/Cargo.lock index 4e5a8bf..83eaee6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -135,6 +135,13 @@ version = "1.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" +[[package]] +name = "docs" +version = "0.1.0" +dependencies = [ + "librawssg", +] + [[package]] name = "equivalent" version = "1.0.2" diff --git a/Cargo.toml b/Cargo.toml index b4fa58a..743fecc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "3" -members = ["librawssg","librawssg_compiler", "librawssg_config", "librawssg_demo", "librawssg_error", "librawssg_fs","librawssg_handler", "librawssg_templates"] +members = ["librawssg","librawssg_compiler", "librawssg_config", "librawssg_demo", "librawssg_error", "librawssg_fs","librawssg_handler", "librawssg_templates", "docs"] [workspace.lints.clippy] all = { level = "deny", priority = -1 } diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 0000000..3e22129 --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1 @@ +/dist \ No newline at end of file diff --git a/docs/Cargo.toml b/docs/Cargo.toml index 5efd76f..fa076fb 100644 --- a/docs/Cargo.toml +++ b/docs/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] +librawssg = {version = "1.0.0", path = "../librawssg" } [lints] workspace = true diff --git a/docs/src/content/api/index.raw b/docs/src/content/api/index.raw index 6b0a3ca..460bf45 100644 --- a/docs/src/content/api/index.raw +++ b/docs/src/content/api/index.raw @@ -3,12 +3,12 @@

Welcome to the API documentation for the librawssg workspace. Each crate is documented individually, covering its public types, traits, functions, and usage examples.

    -
  • librawssg_compiler – Build pipeline, builder, context builder, generator, pattern matching, and orchestration.
  • -
  • librawssg_config – Configuration structures (Config, SiteConfig, BuildConfig, ContentRule, NavItem) with validation and serialization.
  • -
  • librawssg_error – Unified error enum and Result alias used across all crates.
  • -
  • librawssg_fs – Filesystem abstraction trait and RealFs implementation with path traversal protection.
  • -
  • librawssg_handler – Document, Metadata, and the Processor trait for content processing.
  • -
  • librawssg_templates – RenderContext, Renderer traits, and the TeraRenderer implementation.
  • +
  • librawssg_compiler – Build pipeline, builder, context builder, generator, pattern matching, and orchestration.
  • +
  • librawssg_config – Configuration structures (Config, SiteConfig, BuildConfig, ContentRule, NavItem) with validation and serialization.
  • +
  • librawssg_error – Unified error enum and Result alias used across all crates.
  • +
  • librawssg_fs – Filesystem abstraction trait and RealFs implementation with path traversal protection.
  • +
  • librawssg_handler – Document, Metadata, and the Processor trait for content processing.
  • +
  • librawssg_templates – RenderContext, Renderer traits, and the TeraRenderer implementation.

Select a crate from the list above to view its complete API documentation, including methods, fields, examples, and testing notes.

diff --git a/docs/src/main.rs b/docs/src/main.rs index e7a11a9..e94213d 100644 --- a/docs/src/main.rs +++ b/docs/src/main.rs @@ -1,3 +1,126 @@ -fn main() { - println!("Hello, world!"); +#![allow(clippy::multiple_crate_versions)] + +use librawssg::{ + Config, ContentRule, Document, FileSystem, Metadata, NavItem, PipelineBuilder, Processor, + RealFs, TeraContextBuilder, TeraRenderer, +}; +use std::path::{Path, PathBuf}; + +struct RawFileProcessor; + +impl Processor for RawFileProcessor { + fn name(&self) -> &'static str { + "raw-file" + } + + fn can_process(&self, relative_path: &Path, _original_path: &Path) -> bool { + relative_path.extension().is_some_and(|ext| ext == "raw") + } + + fn process( + &self, + fs: &dyn FileSystem, + relative_path: &Path, + content_dir: &Path, + ) -> librawssg::Result> { + let full_path = content_dir.join(relative_path); + let body = fs.read_to_string(&full_path)?; + + let stem = relative_path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("untitled"); + let title = stem + .split('-') + .map(|word| { + let mut c = word.chars(); + c.next().map_or_else(String::new, |first| { + first.to_uppercase().collect::() + c.as_str() + }) + }) + .collect::>() + .join(" "); + + let metadata = Metadata::new(title, String::new())?; + let url = relative_path + .with_extension("html") + .to_string_lossy() + .to_string(); + let output_path = PathBuf::from(&url); + + let doc = Document::new( + metadata, + body, + url, + output_path, + relative_path.to_path_buf(), + relative_path.components().count().saturating_sub(1), + "page".to_string(), + false, + )?; + Ok(Some(doc)) + } +} + +fn main() -> Result<(), Box> { + let base = Path::new(env!("CARGO_MANIFEST_DIR")); + let content_dir = base.join("src/content"); + let templates_dir = base.join("src/templates"); + let static_dir = base.join("src/static"); + let output_dir = base.join("dist"); + + let mut renderer = TeraRenderer::new(); + renderer.add_template_file(&templates_dir.join("macros.tera"))?; + renderer.load_templates_dir(&templates_dir)?; + + let mut config = Config::new().with_site_name("librawssg Docs"); + + config.add_content_rule(ContentRule::new("page", "**/*.raw", "base.tera")); + + config.build.content_dir = content_dir.to_string_lossy().to_string(); + config.build.output_dir = output_dir.to_string_lossy().to_string(); + config.build.static_dir = static_dir.to_string_lossy().to_string(); + + config.site.navbar = vec![ + NavItem::new("Home", "/"), + NavItem::new("Installation", "/installation.html"), + NavItem::new("Configuration", "/configuration.html"), + NavItem::new("API", "/api/index.html"), + NavItem::new("Contributing", "/contributing.html"), + ]; + + let mut api_item = NavItem::new("API Reference", "/api/index.html"); + api_item.children = vec![ + NavItem::new("Compiler", "/api/compiler.html"), + NavItem::new("Config", "/api/config.html"), + NavItem::new("Error", "/api/error.html"), + NavItem::new("Filesystem", "/api/fs.html"), + NavItem::new("Handler", "/api/handler.html"), + NavItem::new("Templates", "/api/templates.html"), + ]; + + config.site.sidebar = vec![ + NavItem::new("Getting Started", "/"), + NavItem::new("Installation", "/installation.html"), + NavItem::new("Configuration", "/configuration.html"), + api_item, + NavItem::new("Contributing", "/contributing.html"), + NavItem::new("Code of Conduct", "/code_of_conduct.html"), + NavItem::new("License", "/license.html"), + ]; + + let pipeline = PipelineBuilder::new() + .config(config) + .content_dir(&content_dir) + .output_dir(&output_dir) + .with_fs(Box::new(RealFs)) + .with_renderer(Box::new(renderer)) + .with_context_builder(Box::new(TeraContextBuilder)) + .add_processor(Box::new(RawFileProcessor)) + .build()?; + + pipeline.run()?; + + println!("✅ Docs generated in '{}'", output_dir.display()); + Ok(()) } diff --git a/docs/src/static/css/_base.css b/docs/src/static/css/_base.css index be66485..b711beb 100644 --- a/docs/src/static/css/_base.css +++ b/docs/src/static/css/_base.css @@ -1,12 +1,13 @@ body { font-family: var(--font-sans); - font-size: 16px; - line-height: 1.5; + font-size: 15px; + line-height: 1.6; color: var(--color-text); background-color: var(--color-bg); display: flex; flex-direction: column; min-height: 100vh; + margin: 0; transition: background-color 0.3s, color 0.3s; @@ -15,6 +16,9 @@ body { a { color: var(--color-primary); text-decoration: none; + transition: + color 0.2s, + text-decoration-color 0.2s; } a:hover { @@ -22,21 +26,13 @@ a:hover { text-decoration: underline; } -b, -strong { - font-weight: 600; -} - -hr { - height: 1px; - margin: 1.5rem 0; - background-color: var(--color-border-light); - border: none; -} - -table { - border-spacing: 0; - border-collapse: collapse; +/* Fokus yang jelas untuk keyboard */ +a:focus-visible, +button:focus-visible, +input:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; + border-radius: 2px; } button { @@ -44,4 +40,7 @@ button { border: none; background: none; font: inherit; + transition: + background-color 0.2s, + color 0.2s; } diff --git a/docs/src/static/css/_content.css b/docs/src/static/css/_content.css index c58ee5e..0167ed5 100644 --- a/docs/src/static/css/_content.css +++ b/docs/src/static/css/_content.css @@ -1,55 +1,53 @@ .markdown-body { - font-size: 1rem; - line-height: 1.6; + font-size: 0.95rem; + line-height: 1.7; word-wrap: break-word; } .markdown-body > *:first-child { margin-top: 0 !important; } - .markdown-body > *:last-child { margin-bottom: 0 !important; } -.markdown-body a { - color: var(--color-primary); -} - -.markdown-body a:hover { - color: var(--color-primary-hover); -} - .markdown-body table { display: block; width: 100%; overflow: auto; - margin-bottom: 1rem; + margin: 1.25rem 0; + border-collapse: collapse; + font-size: 0.9rem; + border-radius: var(--radius-sm); + box-shadow: var(--shadow-sm); } .markdown-body th, .markdown-body td { - padding: 0.5rem 0.75rem; + padding: 0.5rem 0.8rem; border: 1px solid var(--color-border); + text-align: left; } .markdown-body th { background-color: var(--color-sidebar-bg); font-weight: 600; + color: var(--color-heading); } .markdown-body tr:nth-child(2n) { - background-color: var(--color-code-bg); + background-color: var(--color-accent); } .markdown-body img { max-width: 100%; height: auto; + border-radius: var(--radius-sm); } .breadcrumb { - margin-bottom: 1.5rem; - font-size: 0.9rem; + margin-bottom: 1rem; + font-size: 0.85rem; color: var(--color-text-secondary); } @@ -59,11 +57,12 @@ flex-wrap: wrap; padding: 0; margin: 0; + gap: 0.25rem; } .breadcrumb li:not(:last-child)::after { content: "/"; - margin: 0 0.5rem; + margin: 0 0.4rem; color: var(--color-border); } @@ -79,3 +78,4 @@ color: var(--color-text); font-weight: 500; } + diff --git a/docs/src/static/css/_footer.css b/docs/src/static/css/_footer.css index dc83206..05c2858 100644 --- a/docs/src/static/css/_footer.css +++ b/docs/src/static/css/_footer.css @@ -1,21 +1,17 @@ .footer { - background-color: var(--color-navbar-bg); - color: var(--color-navbar-text); - padding: 1rem; + background-color: var(--color-bg); + color: var(--color-text-secondary); + padding: 0.75rem 1rem; text-align: center; - margin-top: auto; -} - -.footer a { - color: var(--color-navbar-text); - text-decoration: underline; + border-top: 1px solid var(--color-border); + font-size: 0.85rem; } .footer-inner { max-width: 1200px; margin: 0 auto; display: flex; - justify-content: space-between; + justify-content: center; align-items: center; flex-wrap: wrap; gap: 0.5rem; diff --git a/docs/src/static/css/_layout.css b/docs/src/static/css/_layout.css index b3fda9d..5ec33a5 100644 --- a/docs/src/static/css/_layout.css +++ b/docs/src/static/css/_layout.css @@ -1,3 +1,7 @@ +/* ========================================================================== + _layout.css + ========================================================================== */ + .wrapper { display: flex; flex: 1; @@ -14,20 +18,7 @@ .content { flex: 1; - padding: 2rem; + padding: 1.5rem 2.5rem; max-width: var(--content-max-width); min-width: 0; } - -.row { - display: flex; - flex-wrap: wrap; - margin-right: -10px; - margin-left: -10px; -} - -.col { - flex: 1; - padding-right: 10px; - padding-left: 10px; -} diff --git a/docs/src/static/css/_navbar.css b/docs/src/static/css/_navbar.css index e239a9f..bc75eb2 100644 --- a/docs/src/static/css/_navbar.css +++ b/docs/src/static/css/_navbar.css @@ -7,7 +7,8 @@ z-index: 1000; display: flex; align-items: center; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + border-bottom: 1px solid var(--color-border); + box-shadow: var(--shadow-sm); } .navbar-inner { @@ -17,63 +18,100 @@ padding: 0 1rem; display: flex; align-items: center; - justify-content: space-between; + gap: 1rem; } .brand { - font-size: 1.25rem; - font-weight: 600; + font-size: 1.2rem; + font-weight: 700; color: var(--color-navbar-text); text-decoration: none; + white-space: nowrap; + letter-spacing: -0.01em; } .brand:hover { - color: var(--color-navbar-text); + color: var(--color-primary); text-decoration: none; } +.nav-links { + display: flex; + align-items: center; + margin-left: auto; +} + .nav-links ul { list-style: none; display: flex; - gap: 0.5rem; + gap: 0.25rem; margin: 0; padding: 0; } .nav-links a { display: block; - padding: 0.5rem 0.75rem; - color: var(--color-navbar-text); + padding: 0.4rem 0.75rem; + color: var(--color-text-secondary); text-decoration: none; - border-radius: 4px; - transition: background-color 0.2s; + border-radius: var(--radius-sm); + font-size: 0.9rem; + transition: + color 0.2s, + background-color 0.2s; } -.nav-links a:hover, -.nav-links a.active { - background-color: rgba(255, 255, 255, 0.15); +.nav-links a:hover { + color: var(--color-primary); + background-color: var(--color-accent); text-decoration: none; } +.nav-links a.active { + color: var(--color-primary); + font-weight: 600; + background-color: rgba(13, 110, 253, 0.08); +} + +/* Hamburger button dengan animasi 3 garis */ .navbar-toggle { display: none; flex-direction: column; cursor: pointer; padding: 0.5rem; + background: none; + border: none; + margin-left: auto; + gap: 5px; } .navbar-toggle .bar { - width: 25px; - height: 3px; + width: 22px; + height: 2px; background-color: var(--color-navbar-text); - margin: 3px 0; transition: 0.3s; + border-radius: 2px; +} + +.navbar-toggle[aria-expanded="true"] .bar:nth-child(1) { + transform: translateY(7px) rotate(45deg); +} +.navbar-toggle[aria-expanded="true"] .bar:nth-child(2) { + opacity: 0; +} +.navbar-toggle[aria-expanded="true"] .bar:nth-child(3) { + transform: translateY(-7px) rotate(-45deg); } .theme-toggle { color: var(--color-navbar-text); - font-size: 1.2rem; + font-size: 1.1rem; cursor: pointer; - margin-left: auto; - padding: 0 0.5rem; + padding: 0.35rem 0.5rem; + border-radius: var(--radius-sm); + transition: background-color 0.2s; +} + +.theme-toggle:hover { + background-color: var(--color-accent); } diff --git a/docs/src/static/css/_responsive.css b/docs/src/static/css/_responsive.css index 6671f14..306d8a7 100644 --- a/docs/src/static/css/_responsive.css +++ b/docs/src/static/css/_responsive.css @@ -11,16 +11,19 @@ right: 0; background-color: var(--color-navbar-bg); padding: 1rem; - box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + box-shadow: var(--shadow-md); + flex-direction: column; + border-bottom: 1px solid var(--color-border); } .nav-links.active { - display: block; + display: flex; } .nav-links ul { flex-direction: column; gap: 0; + width: 100%; } .nav-links li { @@ -32,8 +35,9 @@ } .sidebar { - width: 100%; - height: auto; + width: 85%; + max-width: 320px; + height: 100vh; position: fixed; top: var(--navbar-height); left: 0; @@ -41,7 +45,7 @@ z-index: 999; transform: translateX(-100%); transition: transform 0.3s ease; - box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1); + box-shadow: var(--shadow-md); } .sidebar.open { @@ -50,11 +54,10 @@ .sidebar-close { display: block; - float: right; } .content { - padding: 1rem; + padding: 1.25rem; } .footer-inner { diff --git a/docs/src/static/css/_sidebar.css b/docs/src/static/css/_sidebar.css index 6fc5f8a..eecd05e 100644 --- a/docs/src/static/css/_sidebar.css +++ b/docs/src/static/css/_sidebar.css @@ -3,26 +3,40 @@ flex-shrink: 0; background-color: var(--color-sidebar-bg); border-right: 1px solid var(--color-border); - padding: 1.5rem 1rem; + padding: 1rem 0.5rem; overflow-y: auto; position: sticky; top: var(--navbar-height); height: calc(100vh - var(--navbar-height)); + transition: transform 0.3s ease; } -.sidebar h2 { - font-size: 1rem; - font-weight: 600; - margin-bottom: 1rem; - padding-bottom: 0.5rem; - border-bottom: 1px solid var(--color-border); +.sidebar-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.75rem; + padding: 0 0.5rem; } -.sidebar h2 a { - color: var(--color-text); +.sidebar-header h2 { + font-size: 0.8rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-text-secondary); + margin: 0; +} + +.sidebar-header h2 a { + color: inherit; text-decoration: none; } +.sidebar-header h2 a:hover { + color: var(--color-primary); +} + .sidebar ul { list-style: none; padding: 0; @@ -30,42 +44,75 @@ } .sidebar li { - margin-bottom: 0.15rem; + margin-bottom: 0.05rem; } .sidebar a { display: block; - padding: 0.3rem 0.5rem; - color: var(--color-text-secondary); + padding: 0.35rem 0.6rem; + color: var(--color-text); text-decoration: none; font-size: 0.9rem; - border-radius: 4px; + line-height: 1.4; + border-left: 2px solid transparent; + border-radius: 0 var(--radius-sm) var(--radius-sm) 0; transition: - background-color 0.2s, - color 0.2s; + background-color 0.15s, + border-color 0.15s, + color 0.15s; } .sidebar a:hover { - text-decoration: none; background-color: var(--color-accent); - color: var(--color-text); + text-decoration: none; + color: var(--color-primary); } .sidebar a.active { font-weight: 600; - color: var(--color-text); - background-color: var(--color-accent); + color: var(--color-primary); + border-left-color: var(--color-primary); + background-color: rgba(13, 110, 253, 0.08); } +/* Nested list lebih rapi */ .sidebar li > ul { - margin-left: 0.75rem; - border-left: 1px solid var(--color-border); - padding-left: 0.5rem; + margin-left: 0.5rem; + border-left: 1px solid var(--color-border-light); + padding-left: 0.25rem; +} + +.sidebar li > ul > li > a { + padding-left: 1.2rem; + font-size: 0.85rem; } .sidebar-close { display: none; - font-size: 1.5rem; + font-size: 1.3rem; cursor: pointer; - color: var(--color-text); + color: var(--color-text-secondary); + background: none; + border: none; + padding: 0.25rem; + border-radius: var(--radius-sm); +} + +.sidebar-close:hover { + background-color: var(--color-accent); +} + +.sidebar-overlay { + display: none; + position: fixed; + inset: 0; + background: var(--color-overlay); + z-index: 998; + transition: opacity 0.3s; + opacity: 0; +} + +.sidebar-overlay.active { + display: block; + opacity: 1; } diff --git a/docs/src/static/css/_typography.css b/docs/src/static/css/_typography.css index 95a7a43..fd9a1c4 100644 --- a/docs/src/static/css/_typography.css +++ b/docs/src/static/css/_typography.css @@ -4,27 +4,28 @@ h3, h4, h5, h6 { - margin-top: 0; + margin-top: 1.5em; margin-bottom: 0.5em; font-weight: 600; - line-height: 1.25; - color: var(--color-text); + line-height: 1.3; + color: var(--color-heading); } h1 { font-size: 2em; - padding-bottom: 0.3em; + padding-bottom: 0.25em; border-bottom: 1px solid var(--color-border-light); + letter-spacing: -0.01em; } h2 { font-size: 1.5em; - padding-bottom: 0.3em; + padding-bottom: 0.2em; border-bottom: 1px solid var(--color-border-light); } h3 { - font-size: 1.25em; + font-size: 1.15em; } h4 { @@ -32,7 +33,7 @@ h4 { } h5 { - font-size: 0.875em; + font-size: 0.9em; } h6 { @@ -45,38 +46,45 @@ p { } small { - font-size: 90%; + font-size: 85%; } blockquote { margin: 0 0 1rem; - padding: 0 1em; + padding: 0.25rem 1rem; color: var(--color-text-secondary); - border-left: 0.25em solid var(--color-border); + border-left: 3px solid var(--color-border); + background-color: var(--color-accent); + border-radius: var(--radius-sm); + font-size: 0.95em; } code, tt { font-family: var(--font-mono); - font-size: 85%; - padding: 0.2em 0.4em; + font-size: 0.85em; + padding: 0.15em 0.3em; background-color: var(--color-code-bg); + border: 1px solid var(--color-border-light); border-radius: 3px; } pre { margin-bottom: 1rem; - padding: 1rem; + padding: 0.75rem 1rem; overflow: auto; font-family: var(--font-mono); - font-size: 85%; - line-height: 1.45; + font-size: 0.85em; + line-height: 1.5; background-color: var(--color-code-bg); - border-radius: 6px; + border: 1px solid var(--color-border-light); + border-radius: var(--radius-md); + box-shadow: none; } pre code { padding: 0; background: none; + border: none; font-size: 100%; } diff --git a/docs/src/static/css/_variables.css b/docs/src/static/css/_variables.css index e5b4c75..5fda29f 100644 --- a/docs/src/static/css/_variables.css +++ b/docs/src/static/css/_variables.css @@ -1,38 +1,51 @@ :root { + /* Light theme */ --color-bg: #ffffff; - --color-text: #24292e; - --color-text-secondary: #586069; - --color-border: #e1e4e8; - --color-border-light: #eaecef; - --color-primary: #0366d6; - --color-primary-hover: #0256b3; - --color-accent: #f6f8fa; + --color-text: #1a1a1a; + --color-text-secondary: #555555; + --color-heading: #000000; + --color-border: #d0d7de; + --color-border-light: #eaeef2; + --color-primary: #0d6efd; + --color-primary-hover: #0b5ed7; + --color-accent: #f8f9fa; --color-code-bg: #f6f8fa; - --color-sidebar-bg: #f6f8fa; - --color-navbar-bg: #24292e; - --color-navbar-text: #ffffff; + --color-sidebar-bg: #f8f9fa; + --color-navbar-bg: #ffffff; + --color-navbar-text: #1a1a1a; + --color-overlay: rgba(0, 0, 0, 0.5); + --color-focus: rgba(13, 110, 253, 0.25); + + --sidebar-width: 280px; + --navbar-height: 56px; /* sedikit lebih tinggi */ + --content-max-width: 880px; + --radius-sm: 4px; + --radius-md: 6px; + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08); - --sidebar-width: 260px; - --navbar-height: 60px; - --content-max-width: 900px; --font-sans: - -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, - "Apple Color Emoji", "Segoe UI Emoji"; + -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, + sans-serif; --font-mono: - "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace; + ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", + monospace; } [data-theme="dark"] { --color-bg: #0d1117; - --color-text: #c9d1d9; + --color-text: #e6edf3; --color-text-secondary: #8b949e; + --color-heading: #f0f6fc; --color-border: #30363d; --color-border-light: #21262d; --color-primary: #58a6ff; - --color-primary-hover: #79b8ff; + --color-primary-hover: #79c0ff; --color-accent: #161b22; --color-code-bg: #161b22; - --color-sidebar-bg: #161b22; - --color-navbar-bg: #161b22; - --color-navbar-text: #c9d1d9; + --color-sidebar-bg: #010409; + --color-navbar-bg: #010409; + --color-navbar-text: #e6edf3; + --color-overlay: rgba(0, 0, 0, 0.7); + --color-focus: rgba(88, 166, 255, 0.35); } diff --git a/docs/src/static/js/modules/navbar.js b/docs/src/static/js/modules/navbar.js index f0aa85a..d7dd9a8 100644 --- a/docs/src/static/js/modules/navbar.js +++ b/docs/src/static/js/modules/navbar.js @@ -3,28 +3,43 @@ export function initNavbar() { const navLinks = document.getElementById("nav-links"); const themeToggle = document.getElementById("theme-toggle"); + function setHighlightTheme(theme) { + const lightLink = document.getElementById("hljs-light"); + const darkLink = document.getElementById("hljs-dark"); + if (!lightLink || !darkLink) return; + + if (theme === "dark") { + lightLink.disabled = true; + darkLink.disabled = false; + } else { + lightLink.disabled = false; + darkLink.disabled = true; + } + } + if (toggleButton && navLinks) { toggleButton.addEventListener("click", () => { + const expanded = toggleButton.getAttribute("aria-expanded") === "true"; navLinks.classList.toggle("active"); + toggleButton.setAttribute("aria-expanded", !expanded); }); } if (themeToggle) { - const prefersDark = window.matchMedia( - "(prefers-color-scheme: dark)", - ).matches; + const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; const storedTheme = localStorage.getItem("theme"); - if (storedTheme) { - document.documentElement.setAttribute("data-theme", storedTheme); - } else if (prefersDark) { - document.documentElement.setAttribute("data-theme", "dark"); - } + const currentTheme = storedTheme || (prefersDark ? "dark" : "light"); + + // Terapkan tema awal + document.documentElement.setAttribute("data-theme", currentTheme); + setHighlightTheme(currentTheme); themeToggle.addEventListener("click", () => { const current = document.documentElement.getAttribute("data-theme"); const next = current === "dark" ? "light" : "dark"; document.documentElement.setAttribute("data-theme", next); localStorage.setItem("theme", next); + setHighlightTheme(next); }); } } diff --git a/docs/src/static/js/modules/sidebar.js b/docs/src/static/js/modules/sidebar.js index 3c24b7b..ff4bf4b 100644 --- a/docs/src/static/js/modules/sidebar.js +++ b/docs/src/static/js/modules/sidebar.js @@ -25,11 +25,13 @@ export function initSidebar() { function openSidebar() { sidebar.classList.add("open"); overlay.classList.add("active"); + navbarToggle?.setAttribute("aria-expanded", "true"); } function closeSidebar() { sidebar.classList.remove("open"); overlay.classList.remove("active"); + navbarToggle?.setAttribute("aria-expanded", "false"); } if (navbarToggle && sidebar) { @@ -42,11 +44,13 @@ export function initSidebar() { }); } - if (closeBtn) { - closeBtn.addEventListener("click", closeSidebar); - } + if (closeBtn) closeBtn.addEventListener("click", closeSidebar); + if (overlay) overlay.addEventListener("click", closeSidebar); - if (overlay) { - overlay.addEventListener("click", closeSidebar); - } + // Tutup sidebar jika layar di-resize ke desktop + window.addEventListener("resize", () => { + if (window.innerWidth > 768 && sidebar.classList.contains("open")) { + closeSidebar(); + } + }); } diff --git a/docs/src/templates/base.tera b/docs/src/templates/base.tera index bff1b9c..5bd9a2f 100644 --- a/docs/src/templates/base.tera +++ b/docs/src/templates/base.tera @@ -1,15 +1,21 @@ +{% import "macros.tera" as nav %} {% include "partials/head.tera" %} {% include "partials/navbar.tera" %} -
+ +
{% include "partials/sidebar.tera" %}
-
+ {% if page_breadcrumb %} + {{ nav::render_breadcrumb(items=page_breadcrumb) }} + {% endif %} +
{{ page_content | safe }}
+ {% include "partials/footer.tera" %} {% include "partials/scripts.tera" %} - \ No newline at end of file + diff --git a/docs/src/templates/macros.tera b/docs/src/templates/macros.tera index e6bbaf6..06e1aa6 100644 --- a/docs/src/templates/macros.tera +++ b/docs/src/templates/macros.tera @@ -28,4 +28,4 @@ {% endfor %} -{% endmacro %} \ No newline at end of file +{% endmacro %} diff --git a/docs/src/templates/partials/footer.tera b/docs/src/templates/partials/footer.tera index cb58cff..bb25fc7 100644 --- a/docs/src/templates/partials/footer.tera +++ b/docs/src/templates/partials/footer.tera @@ -1,6 +1,6 @@
-
\ No newline at end of file + diff --git a/docs/src/templates/partials/head.tera b/docs/src/templates/partials/head.tera index 7b19e1e..e503001 100644 --- a/docs/src/templates/partials/head.tera +++ b/docs/src/templates/partials/head.tera @@ -5,5 +5,16 @@ {% if page_title %}{{ page_title }} | {% endif %}{{ site.site_name }} + + {# Set tema awal sebelum CSS dimuat untuk mencegah flash #} + + {% include "partials/stylesheets.tera" %} - \ No newline at end of file + diff --git a/docs/src/templates/partials/navbar.tera b/docs/src/templates/partials/navbar.tera index 3e44a72..e6b9cb8 100644 --- a/docs/src/templates/partials/navbar.tera +++ b/docs/src/templates/partials/navbar.tera @@ -2,7 +2,7 @@ \ No newline at end of file + diff --git a/docs/src/templates/partials/scripts.tera b/docs/src/templates/partials/scripts.tera index 069997c..89b762c 100644 --- a/docs/src/templates/partials/scripts.tera +++ b/docs/src/templates/partials/scripts.tera @@ -1 +1,7 @@ - \ No newline at end of file + + + diff --git a/docs/src/templates/partials/sidebar.tera b/docs/src/templates/partials/sidebar.tera index a1ab77d..c4d447a 100644 --- a/docs/src/templates/partials/sidebar.tera +++ b/docs/src/templates/partials/sidebar.tera @@ -1,7 +1,11 @@ {% import "macros.tera" as nav %} \ No newline at end of file + + \ No newline at end of file diff --git a/docs/src/templates/partials/stylesheets.tera b/docs/src/templates/partials/stylesheets.tera index 2ba10c8..6aec86b 100644 --- a/docs/src/templates/partials/stylesheets.tera +++ b/docs/src/templates/partials/stylesheets.tera @@ -1 +1,5 @@ - \ No newline at end of file + + + + + From 3d026eabd2ef9cb7591e671db41e3309fde8557d Mon Sep 17 00:00:00 2001 From: mroczect Date: Wed, 9 Sep 2026 01:40:23 +0700 Subject: [PATCH 48/48] Create docs.yml --- .github/workflows/docs.yml | 60 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 .github/workflows/docs.yml diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..8c0e653 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,60 @@ +name: Build and Deploy Docs + +on: + push: + branches: [ "main" ] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + toolchain: stable + components: rustfmt, clippy + + - name: Cache Cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- + + - name: Build documentation site + run: cargo run -p docs --release + env: + CARGO_NET_GIT_FETCH_WITH_CLI: true + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: docs/dist + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4