From 16e490e30ebba815a813fa049ceda0c34607df85 Mon Sep 17 00:00:00 2001 From: mroczect Date: Tue, 8 Sep 2026 00:35:02 +0700 Subject: [PATCH 01/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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.