diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ec4f3f9..47ed0c5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,22 +2,27 @@ name: CI on: push: - branches: ["master"] + branches: ["*"] pull_request: - branches: ["master"] + branches: ["*"] env: CARGO_TERM_COLOR: always 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..b265701 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,6 @@ /dev .velodiff_dist.diff .snapcat.md +/dist +pull_request_body.md /dist \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 75d8594..4e5a8bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3,35 +3,23 @@ version = 4 [[package]] -name = "addr2line" -version = "0.25.1" +name = "aho-corasick" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ - "gimli", + "memchr", ] -[[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" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" 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" @@ -39,50 +27,21 @@ 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" +name = "bitflags" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50" -dependencies = [ - "backtrace", -] +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] -name = "bit-set" -version = "0.8.0" +name = "bstr" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" dependencies = [ - "bit-vec", + "memchr", + "serde_core", ] -[[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" @@ -91,9 +50,9 @@ checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "cc" -version = "1.2.67" +version = "1.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" +checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80" dependencies = [ "find-msvc-tools", "shlex", @@ -112,18 +71,32 @@ 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" +name = "chrono-tz" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" +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" @@ -131,6 +104,37 @@ 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" @@ -144,7 +148,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -155,42 +159,27 @@ 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" +version = "0.1.12" 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", -] +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", "futures-task", @@ -198,25 +187,15 @@ dependencies = [ "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" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "libc", - "r-efi 5.3.0", - "wasip2", + "wasi", ] [[package]] @@ -227,20 +206,32 @@ checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", - "r-efi 6.0.0", + "r-efi", ] [[package]] -name = "gimli" -version = "0.32.3" +name = "globset" +version = "0.4.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" +checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] [[package]] -name = "glob" -version = "0.3.4" +name = "globwalk" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" +checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757" +dependencies = [ + "bitflags", + "ignore", + "walkdir", +] [[package]] name = "hashbrown" @@ -249,10 +240,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] -name = "httpdate" -version = "1.0.3" +name = "humansize" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7" +dependencies = [ + "libm", +] [[package]] name = "iana-time-zone" @@ -278,102 +272,149 @@ 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.0" +version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", "hashbrown", ] [[package]] -name = "inotify" -version = "0.11.4" +name = "itoa" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "153be1941a183ec9ccd095ddbe17a8b8d435ef6c76e9e02451b933c3999af2c8" -dependencies = [ - "bitflags", - "inotify-sys", - "libc", -] +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] -name = "inotify-sys" -version = "0.1.8" +name = "js-sys" +version = "0.3.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" dependencies = [ - "libc", + "cfg-if", + "futures-util", + "wasm-bindgen", ] [[package]] -name = "is_ci" -version = "1.2.0" +name = "lazy_static" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] -name = "itoa" -version = "1.0.18" +name = "libc" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] -name = "js-sys" -version = "0.3.103" +name = "libm" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "librawssg" +version = "1.0.0" dependencies = [ - "cfg-if", - "futures-util", - "wasm-bindgen", + "librawssg_compiler", + "librawssg_config", + "librawssg_error", + "librawssg_fs", + "librawssg_handler", + "librawssg_templates", ] [[package]] -name = "kqueue" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" +name = "librawssg_compiler" +version = "1.0.0" dependencies = [ - "kqueue-sys", - "libc", + "librawssg_config", + "librawssg_error", + "librawssg_fs", + "librawssg_handler", + "librawssg_templates", + "tempfile", + "tera", ] [[package]] -name = "kqueue-sys" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +name = "librawssg_config" +version = "1.0.0" dependencies = [ - "bitflags", - "libc", + "librawssg_error", + "serde", + "serde_json", + "serde_yaml", + "tempfile", ] [[package]] -name = "libc" -version = "0.2.189" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +name = "librawssg_demo" +version = "0.1.0" +dependencies = [ + "librawssg_compiler", + "librawssg_config", + "librawssg_error", + "librawssg_fs", + "librawssg_handler", + "librawssg_templates", +] [[package]] -name = "librawssg" -version = "0.5.0" +name = "librawssg_error" +version = "1.0.0" +dependencies = [ + "thiserror", +] + +[[package]] +name = "librawssg_fs" +version = "1.0.0" +dependencies = [ + "tempfile", + "tracing", + "walkdir", +] + +[[package]] +name = "librawssg_handler" +version = "1.0.0" dependencies = [ "chrono", - "glob", - "miette", - "notify", - "proptest", - "pulldown-cmark", + "librawssg_error", + "librawssg_fs", "serde", - "serde_yaml", + "serde_json", +] + +[[package]] +name = "librawssg_templates" +version = "1.0.0" +dependencies = [ + "librawssg_error", "tempfile", "tera", - "thiserror", - "tiny_http", - "tracing", "walkdir", ] @@ -385,9 +426,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "log" -version = "0.4.33" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "memchr" @@ -396,112 +437,114 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] -name = "miette" -version = "7.6.0" +name = "num-traits" +version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ - "backtrace", - "backtrace-ext", - "cfg-if", - "miette-derive", - "owo-colors", - "supports-color", - "supports-hyperlinks", - "supports-unicode", - "terminal_size", - "textwrap", - "unicode-width 0.1.14", + "autocfg", ] [[package]] -name = "miette-derive" -version = "7.6.0" +name = "once_cell" +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 = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +checksum = "1f2a05b18d44e2957b88f96ba460715e295bc1d7510468a2f3d3b44535d26c24" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", + "regex", ] [[package]] -name = "miniz_oxide" -version = "0.8.9" +name = "percent-encoding" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d45aeb61b4bf818e12d4205f2466f8c4748f85f4fce0146d1c03d69d753f0ad" dependencies = [ - "adler2", + "memchr", + "ucd-trie", ] [[package]] -name = "mio" -version = "1.2.2" +name = "pest_derive" +version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +checksum = "89cc5a242e25ed4e7704d0be240f2cfbe20a8c27e7e252d94835be93d92dc39f" dependencies = [ - "libc", - "log", - "wasi", - "windows-sys 0.61.2", + "pest", + "pest_generator", ] [[package]] -name = "notify" -version = "8.2.0" +name = "pest_generator" +version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +checksum = "7abf21475cc3820fe4b2ca2dc2142902f67a02189f3b5b3a229f4febc01a43e5" dependencies = [ - "bitflags", - "fsevent-sys", - "inotify", - "kqueue", - "libc", - "log", - "mio", - "notify-types", - "walkdir", - "windows-sys 0.60.2", + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] -name = "notify-types" -version = "2.1.0" +name = "pest_meta" +version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +checksum = "adba4db388f687393c18c51348d44a41d870ca9df71a2c98172ea3035dc6936e" dependencies = [ - "bitflags", + "pest", ] [[package]] -name = "num-traits" -version = "0.2.19" +name = "phf" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" dependencies = [ - "autocfg", + "phf_shared", ] [[package]] -name = "object" -version = "0.37.3" +name = "phf_codegen" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" dependencies = [ - "memchr", + "phf_generator", + "phf_shared", ] [[package]] -name = "once_cell" -version = "1.21.4" +name = "phf_generator" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand", +] [[package]] -name = "owo-colors" -version = "4.3.0" +name = "phf_shared" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] [[package]] name = "pin-project-lite" @@ -527,50 +570,6 @@ 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" @@ -580,12 +579,6 @@ 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" @@ -594,19 +587,20 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.9.5" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" dependencies = [ + "libc", "rand_chacha", "rand_core", ] [[package]] name = "rand_chacha" -version = "0.9.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", "rand_core", @@ -614,33 +608,41 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.9.5" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.2.17", ] [[package]] -name = "rand_xorshift" -version = "0.4.0" +name = "regex" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ - "rand_core", + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", ] [[package]] -name = "regex-syntax" -version = "0.8.11" +name = "regex-automata" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] [[package]] -name = "rustc-demangle" -version = "0.1.28" +name = "regex-syntax" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "rustix" @@ -652,7 +654,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -661,18 +663,6 @@ 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" @@ -715,7 +705,20 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "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]] @@ -737,6 +740,12 @@ 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" @@ -744,26 +753,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] -name = "supports-color" -version = "3.0.2" +name = "slug" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6" +checksum = "882a80f72ee45de3cc9a5afeb2da0331d58df69e4e7d8eeb5d3c7784ae67e724" dependencies = [ - "is_ci", + "deunicode", + "wasm-bindgen", ] -[[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" @@ -777,9 +775,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.3" +version = "3.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" dependencies = [ "proc-macro2", "quote", @@ -796,68 +794,49 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] name = "tera" -version = "2.1.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "511f07fd91a70e92efbe4793d111aaa9035f8474dd157aaa1e31e7c27f5051da" +checksum = "e8004bca281f2d32df3bacd59bc67b312cb4c70cea46cbd79dbe8ac5ed206722" dependencies = [ + "chrono", + "chrono-tz", + "globwalk", + "humansize", + "lazy_static", + "percent-encoding", + "pest", + "pest_derive", + "rand", + "regex", "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", + "serde_json", + "slug", + "unicode-segmentation", ] [[package]] name = "thiserror" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" 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", + "syn 3.0.5", ] [[package]] @@ -892,16 +871,10 @@ dependencies = [ ] [[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" +name = "ucd-trie" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" [[package]] name = "unicode-ident" @@ -910,22 +883,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] -name = "unicode-linebreak" -version = "0.1.5" +name = "unicode-segmentation" +version = "1.13.3" 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" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unsafe-libyaml" @@ -933,15 +894,6 @@ 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" @@ -958,20 +910,11 @@ 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" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" dependencies = [ "cfg-if", "once_cell", @@ -982,9 +925,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.126" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -992,22 +935,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.126" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.5", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.126" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" dependencies = [ "unicode-ident", ] @@ -1018,7 +961,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -1080,15 +1023,6 @@ 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" @@ -1098,93 +1032,28 @@ 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" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.55" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" dependencies = [ "proc-macro2", "quote", "syn 2.0.119", ] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index 722a3df..b4fa58a 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","librawssg_compiler", "librawssg_config", "librawssg_demo", "librawssg_error", "librawssg_fs","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/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/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/librawssg/Cargo.toml b/librawssg/Cargo.toml new file mode 100644 index 0000000..1e6e861 --- /dev/null +++ b/librawssg/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "librawssg" +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 new file mode 100644 index 0000000..095386a --- /dev/null +++ b/librawssg/src/lib.rs @@ -0,0 +1,29 @@ +#![allow(clippy::multiple_crate_versions)] + +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}; diff --git a/librawssg_compiler/Cargo.toml b/librawssg_compiler/Cargo.toml new file mode 100644 index 0000000..fa6f5f0 --- /dev/null +++ b/librawssg_compiler/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "librawssg_compiler" +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] +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" } +tera = "1.20.0" + +[dev-dependencies] +tempfile = "3" + +[lints] +workspace = true 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_compiler/src/builder.rs b/librawssg_compiler/src/builder.rs new file mode 100644 index 0000000..0adae23 --- /dev/null +++ b/librawssg_compiler/src/builder.rs @@ -0,0 +1,130 @@ +use super::ContextBuilder; +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::{Path, PathBuf}; + +#[allow(missing_debug_implementations)] +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.as_path() == Path::new("content") { + self.content_dir = PathBuf::from(&self.config.build.content_dir); + } + if self.output_dir.as_path() == Path::new("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() + } +} diff --git a/librawssg_compiler/src/context.rs b/librawssg_compiler/src/context.rs new file mode 100644 index 0000000..d15df15 --- /dev/null +++ b/librawssg_compiler/src/context.rs @@ -0,0 +1,30 @@ +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>; +} + +#[derive(Debug, Default, Clone, Copy)] +pub struct TeraContextBuilder; + +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)) + } +} diff --git a/librawssg_compiler/src/generator.rs b/librawssg_compiler/src/generator.rs new file mode 100644 index 0000000..5c79349 --- /dev/null +++ b/librawssg_compiler/src/generator.rs @@ -0,0 +1,7 @@ +use crate::pipeline::Pipeline; +use librawssg_error::Result; +use std::path::Path; + +pub trait Generator: Send + Sync { + fn generate(&self, pipeline: &Pipeline, output_base: &Path) -> Result<()>; +} diff --git a/librawssg_compiler/src/lib.rs b/librawssg_compiler/src/lib.rs new file mode 100644 index 0000000..a2225bd --- /dev/null +++ b/librawssg_compiler/src/lib.rs @@ -0,0 +1,17 @@ +#![allow(clippy::multiple_crate_versions)] + +pub mod builder; +pub mod context; +pub mod generator; +pub mod pattern; +pub mod pipeline; + +pub use builder::PipelineBuilder; +pub use context::ContextBuilder; +pub use generator::Generator; +pub use pipeline::Pipeline; + +pub use context::TeraContextBuilder; + +#[cfg(test)] +use tempfile as _; diff --git a/librawssg_compiler/src/pattern.rs b/librawssg_compiler/src/pattern.rs new file mode 100644 index 0000000..45037fd --- /dev/null +++ b/librawssg_compiler/src/pattern.rs @@ -0,0 +1,77 @@ +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 { + 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 + } + } + } +} + +fn segment_matches(pattern: &str, segment: &str) -> bool { + let mut pattern_iter = pattern.chars(); + let mut segment_iter = segment.chars(); + + loop { + match pattern_iter.next() { + Some('*') => { + let rest: String = pattern_iter.clone().collect(); + if rest.is_empty() { + return true; + } + let mut remaining: String = segment_iter.clone().collect(); + while !remaining.is_empty() { + if segment_matches(&rest, &remaining) { + return true; + } + if segment_iter.next().is_none() { + break; + } + remaining = segment_iter.clone().collect(); + } + return false; + } + Some(pc) => match segment_iter.next() { + Some(sc) if pc == sc => {} + _ => return false, + }, + None => return segment_iter.next().is_none(), + } + } +} diff --git a/librawssg_compiler/src/pipeline.rs b/librawssg_compiler/src/pipeline.rs new file mode 100644 index 0000000..1235e83 --- /dev/null +++ b/librawssg_compiler/src/pipeline.rs @@ -0,0 +1,214 @@ +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, 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) 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) + && rule.list_enabled + && !docs.is_empty() + && let Some(list_template) = &rule.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)?; + } + } + + 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, output_base)?; + } + 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.iter().rev() { + 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(()) + } +} 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_config/Cargo.toml b/librawssg_config/Cargo.toml new file mode 100644 index 0000000..8e43b21 --- /dev/null +++ b/librawssg_config/Cargo.toml @@ -0,0 +1,22 @@ +[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] +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/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/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 new file mode 100644 index 0000000..6d74594 --- /dev/null +++ b/librawssg_config/src/lib.rs @@ -0,0 +1,16 @@ +#![allow(clippy::multiple_crate_versions)] + +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; + +#[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_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 }} +
+ + 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..fa0ff54 --- /dev/null +++ b/librawssg_error/tests/integration_tests.rs @@ -0,0 +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 new file mode 100644 index 0000000..b78848a --- /dev/null +++ b/librawssg_error/tests/property_tests.rs @@ -0,0 +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 new file mode 100644 index 0000000..5ee39a4 --- /dev/null +++ b/librawssg_error/tests/unit_tests.rs @@ -0,0 +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")); +} diff --git a/librawssg_fs/Cargo.toml b/librawssg_fs/Cargo.toml new file mode 100644 index 0000000..f9beb47 --- /dev/null +++ b/librawssg_fs/Cargo.toml @@ -0,0 +1,20 @@ +[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] +tracing = "0.1.44" +walkdir = "2.5.0" + +[lints] +workspace = true + +[dev-dependencies] +tempfile = "3.27.0" 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..69040cf --- /dev/null +++ b/librawssg_fs/src/lib.rs @@ -0,0 +1,118 @@ +pub mod real; + +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(|_| ()) + } + } + + 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 new file mode 100644 index 0000000..c77e3ad --- /dev/null +++ b/librawssg_handler/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "librawssg_handler" +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] +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" + +[lints] +workspace = true 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_handler/src/document.rs b/librawssg_handler/src/document.rs new file mode 100644 index 0000000..645bfa8 --- /dev/null +++ b/librawssg_handler/src/document.rs @@ -0,0 +1,88 @@ +use crate::Metadata; +use librawssg_error::{Error, Result}; +use serde::Serialize; +use std::collections::HashMap; +use std::path::PathBuf; + +#[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>, +} + +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 + } + + #[must_use] + pub fn with_list_items(mut self, items: Vec) -> Self { + self.list_items = Some(items); + self + } +} diff --git a/librawssg_handler/src/lib.rs b/librawssg_handler/src/lib.rs new file mode 100644 index 0000000..6751f36 --- /dev/null +++ b/librawssg_handler/src/lib.rs @@ -0,0 +1,9 @@ +#![allow(clippy::multiple_crate_versions)] + +pub mod document; +pub mod metadata; +pub mod processor; + +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..7d6b60e --- /dev/null +++ b/librawssg_handler/src/metadata.rs @@ -0,0 +1,51 @@ +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, + 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, +} + +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 new file mode 100644 index 0000000..5ff7b2d --- /dev/null +++ b/librawssg_handler/src/processor.rs @@ -0,0 +1,21 @@ +use crate::Document; +use librawssg_error::Result; +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( + &self, + fs: &dyn FileSystem, + relative_path: &Path, + content_dir: &Path, + ) -> Result>; +} 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; +} diff --git a/librawssg_templates/Cargo.toml b/librawssg_templates/Cargo.toml new file mode 100644 index 0000000..28629ed --- /dev/null +++ b/librawssg_templates/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "librawssg_templates" +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", "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/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. diff --git a/librawssg_templates/src/lib.rs b/librawssg_templates/src/lib.rs new file mode 100644 index 0000000..7c32251 --- /dev/null +++ b/librawssg_templates/src/lib.rs @@ -0,0 +1,12 @@ +#![allow(clippy::multiple_crate_versions)] + +pub mod renderer; +#[cfg(feature = "tera")] +pub mod tera_renderer; + +pub use renderer::{RenderContext, Renderer}; +#[cfg(feature = "tera")] +pub use tera_renderer::TeraRenderer; + +#[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; +} 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(), - } - } -} 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"); -}