diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..b01c33c --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,77 @@ +name: Publish to crates.io + +on: + push: + branches: + - main + pull_request: + branches: + - main + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + timeout-minutes: 20 + outputs: + release: ${{ steps.detect.outputs.release }} + version: ${{ steps.detect.outputs.version }} + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + - name: Install Bake launcher + run: cargo install socketry-cargo-bake --locked + - name: Detect a versioned release change + id: detect + env: + BAKE_BEFORE: ${{ github.event.pull_request.base.sha || github.event.before }} + BAKE_SHA: ${{ github.sha }} + run: cargo bake --locked cargo:release:detect --base "$BAKE_BEFORE" --sha "$BAKE_SHA" + - name: Validate release candidate + if: steps.detect.outputs.release == 'true' + run: cargo bake --locked cargo:release + - name: Check formatting + run: cargo fmt --all -- --check + - name: Run Clippy + run: cargo clippy --workspace --all-targets --locked -- -D warnings + - name: Run tests + run: cargo test --workspace --locked + + publish: + needs: check + if: github.event_name == 'push' && needs.check.outputs.release == 'true' + runs-on: ubuntu-latest + timeout-minutes: 20 + environment: crates-io + concurrency: release-${{ github.ref }} + permissions: + contents: write + id-token: write + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - uses: dtolnay/rust-toolchain@stable + - name: Install Bake launcher + run: cargo install socketry-cargo-bake --locked + - name: Find workspace packages that need publishing + id: packages + env: + BAKE_VERSION: ${{ needs.check.outputs.version }} + run: cargo bake --locked cargo:publish:pending --version "$BAKE_VERSION" + - uses: rust-lang/crates-io-auth-action@v1 + id: auth + if: steps.packages.outputs.has_packages == 'true' + - name: Publish workspace and create GitHub Release + env: + CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }} + GH_TOKEN: ${{ github.token }} + BAKE_VERSION: ${{ needs.check.outputs.version }} + BAKE_SHA: ${{ github.sha }} + run: cargo bake --locked cargo:release:publish --version "$BAKE_VERSION" --sha "$BAKE_SHA" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..91ec612 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,28 @@ +name: Test + +on: + push: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v7 + - uses: actions-rust-lang/setup-rust-toolchain@v2 + with: + components: clippy, llvm-tools-preview, rustfmt + cache-shared-key: coverage-cargo-install-v1 + - name: Install Bake launcher + run: cargo install socketry-cargo-bake --locked + - name: Install coverage tool + run: cargo install cargo-llvm-cov --locked + - run: cargo fmt --all -- --check + - run: cargo clippy --workspace --all-targets --locked -- -D warnings + - name: Run tests and require complete region coverage + run: cargo bake --locked test:coverage --package bake-markdown --all-targets true --all-features true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b83d222 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..6848e9d --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1074 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "bake" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbed34a1e40a3a5893a12c47de45006de3ae53112db64c0919a6e49c1a38d7e0" +dependencies = [ + "bake-macros", + "linkme", + "serde", + "serde_json", +] + +[[package]] +name = "bake-agent-context" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "386910c08614146515e754bf6208aedba73abdb9a2767e8bd5c2d997845c84ef" +dependencies = [ + "bake", + "serde", + "serde_json", + "serde_yaml_ng", + "socketry-markdown 0.1.1", +] + +[[package]] +name = "bake-cargo" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "096c01e12f3fcba42c7df25b3e825c3e1e55c6cd5cd0b123e843cd6f954aff08" +dependencies = [ + "bake", + "bake-license", + "bake-releases", + "serde", + "serde_json", + "socketry-markdown 0.1.1", + "tempfile", + "toml_edit", + "ureq", +] + +[[package]] +name = "bake-license" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4877b435fe237311d88e546e8fb2b4a9021aff3550b625b8286e27da0ec693fd" +dependencies = [ + "bake", + "socketry-markdown 0.1.1", + "tempfile", +] + +[[package]] +name = "bake-macros" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b801275922e3bbc727ecdbb809f1a82591744cad907c9e915bdfa9b28bf001f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "bake-markdown" +version = "0.1.0" +dependencies = [ + "bake", + "socketry-markdown 0.2.0", + "tempfile", +] + +[[package]] +name = "bake-markdown-project" +version = "0.0.0" +dependencies = [ + "bake", + "bake-markdown", + "socketry-project", +] + +[[package]] +name = "bake-readme" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0ddf47d6d04f3452746fadd69e5900b1fb2c41cd1e0995cbc1c4ba2fd63af96" +dependencies = [ + "bake", + "serde_json", + "socketry-markdown 0.1.1", + "tempfile", +] + +[[package]] +name = "bake-releases" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4be2cb3610ff7e22b9d926982e79909c4a936c54f2d1196ab4dd14c95fb8a20" +dependencies = [ + "bake", + "socketry-markdown 0.1.1", + "tempfile", +] + +[[package]] +name = "bake-test-rust" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9d5ceec9cc08a3bb421dd3755ad9e073831934793aa88d8f55095ad98c1dc45" +dependencies = [ + "bake", + "serde_json", + "toml_edit", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "cc" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "linkme" +version = "0.3.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3045e122bd98aef8ec3ad58ce84f0791f64e70163d1a02710af4aa11a4d54cc5" +dependencies = [ + "linkme-impl", +] + +[[package]] +name = "linkme-impl" +version = "0.3.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77060ebe535362c3da75682cd17b0431017b6e7c5661e714fc69a7ad017d1301" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[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.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_yaml_ng" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" +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 = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "smallvec" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" + +[[package]] +name = "socketry-markdown" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5713ab1be01960a2cdbb85d6b696063b406300bc5a876a013e23d6e5bf94b19" +dependencies = [ + "regex", + "unicode-id", +] + +[[package]] +name = "socketry-markdown" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37d3a646262edeca60e86e4c4dd493167e74beb03ea75a60b2d5c40e6d6d8c5b" +dependencies = [ + "regex", + "unicode-id", +] + +[[package]] +name = "socketry-project" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5f3a4391b29d2fcfe7522e3fdf651aaff9fd3de6c530a90412060b6bd1ad4e4" +dependencies = [ + "bake", + "bake-agent-context", + "bake-cargo", + "bake-license", + "bake-readme", + "bake-releases", + "bake-test-rust", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.15+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "unicode-id" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65dd75ab2d9d379dbe86d4059e91d8b5a6e7054865eaba1ddbfc7a817ff96392" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64", + "flate2", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "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.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[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 new file mode 100644 index 0000000..7b08c35 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "bake-markdown" +version = "0.1.0" +edition = "2024" +rust-version = "1.85" +license = "MIT" +repository = "https://github.com/socketry/bake-markdown-rust" +description = "Markdown normalization tasks for Bake" +readme = "readme.md" +include = ["src/**", "tests/**", "context/**", "Cargo.toml", "readme.md", "license.md", "releases.md"] + +[workspace] +members = ["bake"] +resolver = "3" + +[workspace.metadata.bake] +manifest = "bake/Cargo.toml" + +[workspace.metadata.bake.release] +reviewers = ["socketry/managers"] + +[dependencies] +bake = "0" +socketry-markdown = "0.2.0" + +[dev-dependencies] +tempfile = "3.27" diff --git a/bake/Cargo.toml b/bake/Cargo.toml new file mode 100644 index 0000000..cdc62fd --- /dev/null +++ b/bake/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "bake-markdown-project" +version = "0.0.0" +edition = "2024" +publish = false + +[dependencies] +bake = "0.18.0" +bake-markdown = { path = ".." } +socketry-project = ">=0.3.3" diff --git a/bake/src/bake_generated_tasks/mod.rs b/bake/src/bake_generated_tasks/mod.rs new file mode 100644 index 0000000..860794b --- /dev/null +++ b/bake/src/bake_generated_tasks/mod.rs @@ -0,0 +1,4 @@ +// Generated by `cargo bake --regenerate`; do not edit. + +use bake_markdown as _; +use socketry_project as _; diff --git a/bake/src/main.rs b/bake/src/main.rs new file mode 100644 index 0000000..54f8a62 --- /dev/null +++ b/bake/src/main.rs @@ -0,0 +1,31 @@ +// Released under the MIT License. +// Copyright, 2026, by Samuel Williams. + +use bake_markdown as _; +use socketry_project as _; + +#[cfg(not(test))] +fn main() -> bake::Result<()> { + bake::Registry::discover()?.run() +} + +#[cfg(test)] +mod tests { + use bake::Registry; + use std::collections::HashSet; + + #[test] + fn registers_markdown_and_shared_project_tasks_without_duplicates() { + let registry = Registry::discover().unwrap(); + let task_names: Vec<_> = registry.tasks().map(|task| task.name()).collect(); + let unique_task_names: HashSet<_> = task_names.iter().copied().collect(); + + assert_eq!(task_names.len(), unique_task_names.len()); + assert!(unique_task_names.contains("markdown:normalize")); + assert!(unique_task_names.contains("cargo:release")); + assert!(unique_task_names.contains("test:coverage")); + } +} + +#[path = "bake_generated_tasks/mod.rs"] +mod bake_generated_tasks; diff --git a/context/markdown-normalization.md b/context/markdown-normalization.md new file mode 100644 index 0000000..9cb8a8c --- /dev/null +++ b/context/markdown-normalization.md @@ -0,0 +1,25 @@ +# Markdown Normalization + +`bake-markdown` exposes `normalize_document` for Rust callers and the +`markdown:normalize` Bake task for project files. Normalization parses +CommonMark, GitHub Flavored Markdown, YAML and TOML front matter, and math, +then serializes it with soft source line breaks joined as spaces. This keeps +inline code and other parsed syntax such as tables, task list state, footnotes, +strikethrough, code blocks, and math in the syntax tree for serialization. +Rust callers can select another syntax with `normalize_document_with_options`, +such as MDX. + +The serializer preserves explicit Markdown hard breaks, code contents, and +block boundaries. YAML and TOML front matter are parsed as front matter so +their delimiters and content remain intact. The serializer canonicalizes +line endings to LF and emits a trailing newline for non-empty documents. The +normalizer does not wrap paragraphs to a target line width. + +The task accepts one or more repeatable `--path` arguments. Paths are resolved +relative to the Bake project root: + +```sh +cargo bake markdown:normalize --path readme.md --path context/guide.md +``` + +It writes a file only when the serialized content differs from its source. diff --git a/releases.md b/releases.md new file mode 100644 index 0000000..6f4377e --- /dev/null +++ b/releases.md @@ -0,0 +1,6 @@ +# Releases + +## v0.1.0 + +- Add the `markdown:normalize` task to join soft source line breaks in Markdown + files while preserving explicit hard breaks, code, and block boundaries. diff --git a/src/file_system.rs b/src/file_system.rs new file mode 100644 index 0000000..e2c0180 --- /dev/null +++ b/src/file_system.rs @@ -0,0 +1,72 @@ +// Released under the MIT License. +// Copyright, 2026, by Samuel Williams. + +use std::{ + fs, io, + path::{Path, PathBuf}, +}; + +use bake::{Error, Result}; +use socketry_markdown::message::Message; + +trait FileSystem { + fn read_to_string(&mut self, path: &Path) -> io::Result; + fn write(&mut self, path: &Path, contents: &str) -> io::Result<()>; +} + +struct LocalFileSystem; + +impl FileSystem for LocalFileSystem { + fn read_to_string(&mut self, path: &Path) -> io::Result { + fs::read_to_string(path) + } + + fn write(&mut self, path: &Path, contents: &str) -> io::Result<()> { + fs::write(path, contents) + } +} + +pub(super) fn normalize_files(root: &Path, paths: &[PathBuf]) -> Result { + normalize_files_with(&mut LocalFileSystem, root, paths, crate::normalize_document) +} + +fn normalize_files_with( + file_system: &mut impl FileSystem, + root: &Path, + paths: &[PathBuf], + normalize: impl Fn(&str) -> std::result::Result, +) -> Result { + if paths.is_empty() { + return Err(Error::new("provide one or more --path arguments")); + } + + let mut changed = 0; + + for path in paths { + let full_path = root.join(path); + let source = file_system.read_to_string(&full_path).map_err(|error| { + Error::new(format!("failed to read {}: {error}", full_path.display())) + })?; + let normalized = normalize(&source).map_err(|error| { + Error::new(format!( + "failed to normalize {}: {error}", + full_path.display() + )) + })?; + + if normalized != source { + file_system + .write(&full_path, &normalized) + .map_err(|error| { + Error::new(format!("failed to write {}: {error}", full_path.display())) + })?; + changed += 1; + } + } + + Ok(changed) +} + +#[cfg(test)] +#[path = "file_system_tests.rs"] +mod tests; diff --git a/src/file_system_tests.rs b/src/file_system_tests.rs new file mode 100644 index 0000000..bdb702e --- /dev/null +++ b/src/file_system_tests.rs @@ -0,0 +1,180 @@ +// Released under the MIT License. +// Copyright, 2026, by Samuel Williams. + +use std::{ + collections::BTreeMap, + io, + path::{Path, PathBuf}, +}; + +use super::{FileSystem, normalize_files_with}; +use crate::normalize_document; + +#[derive(Default)] +struct MemoryFileSystem { + files: BTreeMap, + writes: Vec, + read_error: Option, + write_error: Option, +} + +impl FileSystem for MemoryFileSystem { + fn read_to_string(&mut self, path: &Path) -> io::Result { + if let Some(error) = self.read_error { + return Err(error.into()); + } + + self.files + .get(path) + .cloned() + .ok_or_else(|| io::Error::from(io::ErrorKind::NotFound)) + } + + fn write(&mut self, path: &Path, contents: &str) -> io::Result<()> { + if let Some(error) = self.write_error { + return Err(error.into()); + } + + self.writes.push(path.to_owned()); + self.files.insert(path.to_owned(), contents.to_owned()); + Ok(()) + } +} + +#[test] +fn rejects_an_empty_path_list() { + let mut file_system = MemoryFileSystem::default(); + + let error = normalize_files_with( + &mut file_system, + Path::new("project"), + &[], + normalize_document, + ) + .unwrap_err(); + + assert_eq!(error.to_string(), "provide one or more --path arguments"); +} + +#[test] +fn returns_a_contextual_error_when_a_file_cannot_be_read() { + let mut file_system = MemoryFileSystem::default(); + + let error = normalize_files_with( + &mut file_system, + Path::new("project"), + &[PathBuf::from("missing.md")], + normalize_document, + ) + .unwrap_err(); + + assert!(error.to_string().contains("project/missing.md")); + assert!( + error + .to_string() + .starts_with("failed to read project/missing.md:") + ); +} + +#[test] +fn returns_a_contextual_error_for_a_filesystem_read_failure() { + let mut file_system = MemoryFileSystem { + read_error: Some(io::ErrorKind::PermissionDenied), + ..MemoryFileSystem::default() + }; + file_system.files.insert( + PathBuf::from("project/document.md"), + "A paragraph.\n".to_owned(), + ); + + let error = normalize_files_with( + &mut file_system, + Path::new("project"), + &[PathBuf::from("document.md")], + normalize_document, + ) + .unwrap_err(); + + assert!( + error + .to_string() + .contains("failed to read project/document.md:") + ); +} + +#[test] +fn writes_only_documents_that_change() { + let mut file_system = MemoryFileSystem::default(); + file_system.files.insert( + PathBuf::from("project/wrapped.md"), + "A paragraph\nwith a soft break.\n".to_owned(), + ); + file_system.files.insert( + PathBuf::from("project/normalized.md"), + "Already one line.\n".to_owned(), + ); + + let changed = normalize_files_with( + &mut file_system, + Path::new("project"), + &[PathBuf::from("wrapped.md"), PathBuf::from("normalized.md")], + normalize_document, + ) + .unwrap(); + + assert_eq!(changed, 1); + assert_eq!( + file_system.writes, + vec![PathBuf::from("project/wrapped.md")] + ); +} + +#[test] +fn returns_a_contextual_error_when_a_file_cannot_be_written() { + let mut file_system = MemoryFileSystem { + write_error: Some(io::ErrorKind::PermissionDenied), + ..MemoryFileSystem::default() + }; + file_system.files.insert( + PathBuf::from("project/document.md"), + "A paragraph\nwith a soft break.\n".to_owned(), + ); + + let error = normalize_files_with( + &mut file_system, + Path::new("project"), + &[PathBuf::from("document.md")], + normalize_document, + ) + .unwrap_err(); + + assert!( + error + .to_string() + .contains("failed to write project/document.md") + ); +} + +#[test] +fn returns_a_contextual_error_when_markdown_cannot_be_parsed() { + let mut file_system = MemoryFileSystem::default(); + file_system.files.insert( + PathBuf::from("project/document.mdx"), + "".to_owned(), + ); + let options = socketry_markdown::ParseOptions::mdx(); + + let error = normalize_files_with( + &mut file_system, + Path::new("project"), + &[PathBuf::from("document.mdx")], + |source| crate::normalize_document_with_options(source, &options), + ) + .unwrap_err(); + + assert!( + error + .to_string() + .contains("failed to normalize project/document.mdx:") + ); +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..a173b64 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,80 @@ +// Released under the MIT License. +// Copyright, 2026, by Samuel Williams. + +//! Markdown normalization tasks for Bake. +//! +//! [`normalize_document`] parses CommonMark, GFM, front matter, and math, then +//! serializes the result with soft source line breaks unwrapped. CommonMark +//! includes inline code. The `markdown:normalize` Bake task applies this to +//! one or more files. + +use socketry_markdown::{ + LineWrapping, MarkdownOptions, ParseOptions, message::Message, to_markdown_with_options, + to_mdast, +}; + +/// Normalize a Markdown document by joining soft source line breaks. +/// +/// CommonMark, GFM, front matter, and math syntax are parsed so these features +/// survive the round trip. This includes inline code, fenced code, tables, +/// task lists, footnotes, and strikethrough. Explicit hard breaks, code +/// content, and block boundaries are preserved. The result ends with a newline +/// when it is non-empty. +/// +/// # Errors +/// +/// Returns an error if the Markdown syntax tree cannot be parsed or serialized. +pub fn normalize_document(source: &str) -> Result { + let mut options = ParseOptions::gfm(); + options.constructs.frontmatter = true; + options.constructs.math_flow = true; + options.constructs.math_text = true; + + normalize_document_with_options(source, &options) +} + +/// Normalize a Markdown document using explicit parser options. +/// +/// Use this for syntax beyond the default GitHub Flavored Markdown, front +/// matter, and math support, such as MDX. +/// +/// # Errors +/// +/// Returns an error if the Markdown syntax tree cannot be parsed or serialized. +pub fn normalize_document_with_options( + source: &str, + parse_options: &ParseOptions, +) -> Result { + let tree = to_mdast(source, parse_options)?; + + to_markdown_with_options( + &tree, + &MarkdownOptions { + line_wrapping: LineWrapping::Unwrap, + ..MarkdownOptions::default() + }, + ) +} + +mod file_system; + +use std::path::PathBuf; + +/// Normalize one or more Markdown files beneath the Bake project root. +#[bake::task] +pub fn normalize( + context: &mut bake::Context, + #[bake(help = "Repeat for each Markdown file, relative to the project root.")] path: Vec< + PathBuf, + >, +) -> bake::Result { + let changed = file_system::normalize_files(context.root(), &path)?; + Ok(format!( + "Normalized {changed} of {} Markdown files", + path.len() + )) +} + +#[cfg(test)] +#[path = "tests.rs"] +mod tests; diff --git a/src/tests.rs b/src/tests.rs new file mode 100644 index 0000000..9e5d42e --- /dev/null +++ b/src/tests.rs @@ -0,0 +1,91 @@ +// Released under the MIT License. +// Copyright, 2026, by Samuel Williams. + +use socketry_markdown::ParseOptions; + +use super::{normalize_document, normalize_document_with_options}; + +#[test] +fn unwraps_soft_source_line_breaks() { + let normalized = normalize_document("A paragraph\nwith a soft break.\n").unwrap(); + + assert_eq!(normalized, "A paragraph with a soft break.\n"); +} + +#[test] +fn preserves_hard_breaks_code_content_and_block_boundaries() { + let source = "First paragraph\ncontinues.\n\nHard break \ncontinues.\n\n```text\ncode line one\ncode line two\n```\n\nLast paragraph.\n"; + + let normalized = normalize_document(source).unwrap(); + + assert_eq!( + normalized, + "First paragraph continues.\n\nHard break\\\ncontinues.\n\n```text\ncode line one\ncode line two\n```\n\nLast paragraph.\n" + ); +} + +#[test] +fn normalization_is_idempotent() { + let first = normalize_document("A paragraph\nwith a soft break.\n").unwrap(); + let second = normalize_document(&first).unwrap(); + + assert_eq!(second, first); +} + +#[test] +fn preserves_front_matter_and_unwraps_nested_phrasing() { + let source = "---\ntitle: Example\n---\n\nA *wrapped\nemphasis* paragraph.\n"; + + let normalized = normalize_document(source).unwrap(); + + assert_eq!( + normalized, + "---\ntitle: Example\n---\n\nA *wrapped emphasis* paragraph.\n" + ); +} + +#[test] +fn preserves_toml_front_matter() { + let source = "+++\ntitle = \"Example\"\n+++\n\nA paragraph.\n"; + + let normalized = normalize_document(source).unwrap(); + + assert_eq!(normalized, source); +} + +#[test] +fn normalizes_crlf_soft_breaks_to_lf_output() { + let normalized = normalize_document("A paragraph\r\nwith a soft break.\r\n").unwrap(); + + assert_eq!(normalized, "A paragraph with a soft break.\n"); +} + +#[test] +fn preserves_inline_code_and_math() { + let source = "Inline `code` and inline math $x + y$.\n"; + + let normalized = normalize_document(source).unwrap(); + + assert_eq!(normalized, source); +} + +#[test] +fn preserves_gfm_tables_and_other_gfm_constructs() { + let source = "| key | value |\n| --- | --- |\n| name | Ada |\n\n~~removed~~ and a task:\n\n- [x] done\n- [ ] pending\n\nA claim[^note].\n\n[^note]: A note.\n"; + + let normalized = normalize_document(source).unwrap(); + + assert_eq!( + normalized, + "| key | value |\n| --- | --- |\n| name | Ada |\n\n~~removed~~ and a task:\n\n* [x] done\n* [ ] pending\n\nA claim[^note].\n\n[^note]: A note.\n" + ); +} + +#[test] +fn reports_errors_from_explicit_mdx_parsing() { + let options = ParseOptions::mdx(); + + let error = normalize_document_with_options("", &options).unwrap_err(); + + assert!(error.to_string().contains("Unexpected closing tag")); +} diff --git a/tests/markdown/main.rs b/tests/markdown/main.rs new file mode 100644 index 0000000..edf631a --- /dev/null +++ b/tests/markdown/main.rs @@ -0,0 +1,5 @@ +// Released under the MIT License. +// Copyright, 2026, by Samuel Williams. + +mod normalization; +mod task; diff --git a/tests/markdown/normalization.rs b/tests/markdown/normalization.rs new file mode 100644 index 0000000..e402a70 --- /dev/null +++ b/tests/markdown/normalization.rs @@ -0,0 +1,11 @@ +// Released under the MIT License. +// Copyright, 2026, by Samuel Williams. + +use bake_markdown::normalize_document; + +#[test] +fn serializes_a_normalized_markdown_document() { + let normalized = normalize_document("# Heading\n\nA wrapped\nparagraph.\n").unwrap(); + + assert_eq!(normalized, "# Heading\n\nA wrapped paragraph.\n"); +} diff --git a/tests/markdown/task.rs b/tests/markdown/task.rs new file mode 100644 index 0000000..ebf2cdb --- /dev/null +++ b/tests/markdown/task.rs @@ -0,0 +1,63 @@ +// Released under the MIT License. +// Copyright, 2026, by Samuel Williams. + +use std::fs; + +use bake::Registry; +use bake_markdown as _; + +#[test] +fn registers_and_normalizes_multiple_files_from_the_project_root() { + let directory = tempfile::tempdir().unwrap(); + fs::write( + directory.path().join("first.md"), + "First line\ncontinues.\n", + ) + .unwrap(); + fs::write( + directory.path().join("second.md"), + "Second line\ncontinues.\n", + ) + .unwrap(); + + let registry = Registry::discover().unwrap(); + assert!( + registry + .tasks() + .any(|task| task.name() == "markdown:normalize") + ); + let help = registry.help(Some("markdown:normalize")).unwrap(); + assert!(help.contains("--path value")); + assert!(help.contains("repeatable")); + let mut context = registry.context(directory.path()); + let result = context + .call( + "markdown:normalize", + &["--path", "first.md", "--path", "second.md"], + ) + .unwrap(); + + assert_eq!(result.as_str(), Some("Normalized 2 of 2 Markdown files")); + assert_eq!( + fs::read_to_string(directory.path().join("first.md")).unwrap(), + "First line continues.\n" + ); + assert_eq!( + fs::read_to_string(directory.path().join("second.md")).unwrap(), + "Second line continues.\n" + ); +} + +#[test] +fn rejects_a_task_call_without_paths() { + let directory = tempfile::tempdir().unwrap(); + let mut context = Registry::discover().unwrap().context(directory.path()); + + let error = context.call("markdown:normalize", &[]).unwrap_err(); + + assert!( + error + .to_string() + .contains("provide one or more --path arguments") + ); +}