From f7596cf94a446619ea7e269648f4a5f46488eca2 Mon Sep 17 00:00:00 2001 From: Martin Donath Date: Tue, 29 Sep 2026 16:45:31 +0200 Subject: [PATCH] feature: add `social` MkDocs plugin replacement * feature: add `social` MkDocs plugin replacement Signed-off-by: squidfunk * fix: align social card rendering with Material Signed-off-by: squidfunk * chore: document social internals and clear lint gate Signed-off-by: squidfunk * fix: align social typography and expand parity coverage Signed-off-by: squidfunk * test: avoid case-colliding exclude fixture paths Signed-off-by: squidfunk * fix: define ARM architecture for Linux cross builds Signed-off-by: squidfunk --------- Signed-off-by: squidfunk --- .github/workflows/build.yml | 3 + Cargo.lock | 775 +++++++++++++- Cargo.toml | 11 + crates/zensical/Cargo.toml | 11 +- crates/zensical/src/compat/mkdocs/plugin.rs | 1 + .../src/compat/mkdocs/plugin/social.rs | 841 ++++++++++++++++ .../src/compat/mkdocs/plugin/social/font.rs | 460 +++++++++ .../src/compat/mkdocs/plugin/social/layout.rs | 541 ++++++++++ .../mkdocs/plugin/social/layouts/default.yml | 244 +++++ .../plugin/social/layouts/default/accent.yml | 234 +++++ .../plugin/social/layouts/default/invert.yml | 244 +++++ .../social/layouts/default/only/image.yml | 77 ++ .../plugin/social/layouts/default/variant.yml | 255 +++++ .../src/compat/mkdocs/plugin/social/render.rs | 953 ++++++++++++++++++ .../src/compat/mkdocs/plugin/social/writer.rs | 132 +++ crates/zensical/src/config.rs | 6 +- crates/zensical/src/config/plugins.rs | 4 + crates/zensical/src/config/plugins/social.rs | 384 +++++++ crates/zensical/src/config/theme.rs | 6 + crates/zensical/src/watcher.rs | 53 +- crates/zensical/src/workflow.rs | 58 +- pyproject.toml | 1 + python/tests/fixtures/social/README.md | 69 ++ .../social/basic/docs/guide/advanced.md | 1 + .../fixtures/social/basic/docs/guide/index.md | 1 + .../tests/fixtures/social/basic/docs/index.md | 6 + .../fixtures/social/basic/layouts/flat.yml | 12 + python/tests/fixtures/social/basic/mkdocs.yml | 10 + .../fixtures/social/blog/docs/blog/index.md | 1 + .../social/blog/docs/blog/posts/first.md | 5 + .../social/blog/docs/blog/posts/second.md | 5 + .../tests/fixtures/social/blog/docs/index.md | 1 + .../fixtures/social/blog/layouts/flat.yml | 6 + python/tests/fixtures/social/blog/mkdocs.yml | 9 + .../fixtures/social/bundled/docs/accent.md | 7 + .../fixtures/social/bundled/docs/index.md | 6 + .../fixtures/social/bundled/docs/invert.md | 7 + .../fixtures/social/bundled/docs/variant.md | 7 + .../tests/fixtures/social/bundled/mkdocs.yml | 19 + .../fixtures/social/cards-off/docs/index.md | 3 + .../fixtures/social/cards-off/docs/opt-in.md | 8 + .../social/cards-off/layouts/flat.yml | 6 + .../fixtures/social/cards-off/mkdocs.yml | 9 + .../social/custom-typography/docs/index.md | 6 + .../social/custom-typography/docs/long.md | 6 + .../social/custom-typography/layouts/type.yml | 24 + .../social/custom-typography/mkdocs.yml | 9 + .../social/debug-no-grid/docs/index.md | 1 + .../social/debug-no-grid/layouts/flat.yml | 5 + .../fixtures/social/debug-no-grid/mkdocs.yml | 13 + .../tests/fixtures/social/debug/docs/index.md | 1 + .../fixtures/social/debug/layouts/flat.yml | 5 + python/tests/fixtures/social/debug/mkdocs.yml | 14 + .../fixtures/social/deprecated/docs/index.md | 1 + .../social/deprecated/layouts/flat.yml | 5 + .../fixtures/social/deprecated/mkdocs.yml | 10 + .../fixtures/social/disabled/docs/index.md | 3 + .../fixtures/social/disabled/docs/opt-in.md | 8 + .../tests/fixtures/social/disabled/mkdocs.yml | 7 + .../social/filters/docs/guides/hidden.md | 1 + .../social/filters/docs/guides/index.md | 1 + .../social/filters/docs/guides/opt-out.md | 6 + .../fixtures/social/filters/docs/index.md | 1 + .../fixtures/social/filters/layouts/flat.yml | 5 + .../tests/fixtures/social/filters/mkdocs.yml | 10 + .../social/image-only/docs/background.svg | 3 + .../fixtures/social/image-only/docs/index.md | 1 + .../fixtures/social/image-only/mkdocs.yml | 10 + .../layer-composition/docs/background.svg | 4 + .../social/layer-composition/docs/index.md | 1 + .../layer-composition/layouts/composed.yml | 15 + .../social/layer-composition/mkdocs.yml | 8 + .../social/layout-options/docs/background.svg | 4 + .../social/layout-options/docs/index.md | 8 + .../social/layout-options/docs/logo.svg | 4 + .../social/layout-options/docs/override.md | 15 + .../fixtures/social/layout-options/mkdocs.yml | 23 + .../social/lifecycle/docs/background.svg | 3 + .../fixtures/social/lifecycle/docs/index.md | 5 + .../social/lifecycle/layouts/flat.yml | 6 + .../fixtures/social/lifecycle/mkdocs.yml | 9 + .../fixtures/social/lifecycle/steps.json | 20 + .../fixtures/social/logo-icon/docs/index.md | 6 + .../fixtures/social/logo-icon/mkdocs.yml | 11 + .../social/metadata/docs/guides/.meta.yml | 4 + .../social/metadata/docs/guides/index.md | 1 + .../social/metadata/docs/guides/override.md | 8 + .../fixtures/social/metadata/docs/index.md | 1 + .../fixtures/social/metadata/layouts/flat.yml | 6 + .../tests/fixtures/social/metadata/mkdocs.yml | 12 + .../fixtures/social/multiple/docs/guide.md | 1 + .../fixtures/social/multiple/docs/index.md | 1 + .../fixtures/social/multiple/layouts/flat.yml | 6 + .../tests/fixtures/social/multiple/mkdocs.yml | 17 + .../fixtures/social/no-site-url/docs/index.md | 1 + .../social/no-site-url/layouts/flat.yml | 5 + .../fixtures/social/no-site-url/mkdocs.yml | 7 + .../social/paths/card-layouts/flat.yml | 6 + .../fixtures/social/paths/docs/guides.md | 1 + .../tests/fixtures/social/paths/docs/index.md | 1 + .../social/paths/docs/nested/index.md | 1 + python/tests/fixtures/social/paths/mkdocs.yml | 11 + .../social/recoverable-error/docs/index.md | 1 + .../layouts/missing-image.yml | 5 + .../social/recoverable-error/mkdocs.yml | 10 + .../template-context/docs/guides/advanced.md | 1 + .../social/template-context/docs/index.md | 1 + .../template-context/layouts/context.yml | 7 + .../social/template-context/mkdocs.yml | 8 + .../theme-defaults/docs/assets/logo.png | Bin 0 -> 1266 bytes .../theme-defaults/docs/assets/logo.svg | 4 + .../social/theme-defaults/docs/exact.md | 7 + .../social/theme-defaults/docs/index.md | 6 + .../social/theme-defaults/docs/png.md | 9 + .../social/theme-defaults/docs/variant.md | 9 + .../social/theme-defaults/layouts/exact.yml | 12 + .../fixtures/social/theme-defaults/mkdocs.yml | 21 + python/tests/integration/test_exclude.py | 4 +- python/tests/integration/test_social.py | 401 ++++++++ python/zensical/config.py | 20 +- scripts/social_compatibility.py | 283 ++++++ uv.lock | 96 ++ 122 files changed, 6722 insertions(+), 63 deletions(-) create mode 100644 crates/zensical/src/compat/mkdocs/plugin/social.rs create mode 100644 crates/zensical/src/compat/mkdocs/plugin/social/font.rs create mode 100644 crates/zensical/src/compat/mkdocs/plugin/social/layout.rs create mode 100644 crates/zensical/src/compat/mkdocs/plugin/social/layouts/default.yml create mode 100644 crates/zensical/src/compat/mkdocs/plugin/social/layouts/default/accent.yml create mode 100644 crates/zensical/src/compat/mkdocs/plugin/social/layouts/default/invert.yml create mode 100644 crates/zensical/src/compat/mkdocs/plugin/social/layouts/default/only/image.yml create mode 100644 crates/zensical/src/compat/mkdocs/plugin/social/layouts/default/variant.yml create mode 100644 crates/zensical/src/compat/mkdocs/plugin/social/render.rs create mode 100644 crates/zensical/src/compat/mkdocs/plugin/social/writer.rs create mode 100644 crates/zensical/src/config/plugins/social.rs create mode 100644 python/tests/fixtures/social/README.md create mode 100644 python/tests/fixtures/social/basic/docs/guide/advanced.md create mode 100644 python/tests/fixtures/social/basic/docs/guide/index.md create mode 100644 python/tests/fixtures/social/basic/docs/index.md create mode 100644 python/tests/fixtures/social/basic/layouts/flat.yml create mode 100644 python/tests/fixtures/social/basic/mkdocs.yml create mode 100644 python/tests/fixtures/social/blog/docs/blog/index.md create mode 100644 python/tests/fixtures/social/blog/docs/blog/posts/first.md create mode 100644 python/tests/fixtures/social/blog/docs/blog/posts/second.md create mode 100644 python/tests/fixtures/social/blog/docs/index.md create mode 100644 python/tests/fixtures/social/blog/layouts/flat.yml create mode 100644 python/tests/fixtures/social/blog/mkdocs.yml create mode 100644 python/tests/fixtures/social/bundled/docs/accent.md create mode 100644 python/tests/fixtures/social/bundled/docs/index.md create mode 100644 python/tests/fixtures/social/bundled/docs/invert.md create mode 100644 python/tests/fixtures/social/bundled/docs/variant.md create mode 100644 python/tests/fixtures/social/bundled/mkdocs.yml create mode 100644 python/tests/fixtures/social/cards-off/docs/index.md create mode 100644 python/tests/fixtures/social/cards-off/docs/opt-in.md create mode 100644 python/tests/fixtures/social/cards-off/layouts/flat.yml create mode 100644 python/tests/fixtures/social/cards-off/mkdocs.yml create mode 100644 python/tests/fixtures/social/custom-typography/docs/index.md create mode 100644 python/tests/fixtures/social/custom-typography/docs/long.md create mode 100644 python/tests/fixtures/social/custom-typography/layouts/type.yml create mode 100644 python/tests/fixtures/social/custom-typography/mkdocs.yml create mode 100644 python/tests/fixtures/social/debug-no-grid/docs/index.md create mode 100644 python/tests/fixtures/social/debug-no-grid/layouts/flat.yml create mode 100644 python/tests/fixtures/social/debug-no-grid/mkdocs.yml create mode 100644 python/tests/fixtures/social/debug/docs/index.md create mode 100644 python/tests/fixtures/social/debug/layouts/flat.yml create mode 100644 python/tests/fixtures/social/debug/mkdocs.yml create mode 100644 python/tests/fixtures/social/deprecated/docs/index.md create mode 100644 python/tests/fixtures/social/deprecated/layouts/flat.yml create mode 100644 python/tests/fixtures/social/deprecated/mkdocs.yml create mode 100644 python/tests/fixtures/social/disabled/docs/index.md create mode 100644 python/tests/fixtures/social/disabled/docs/opt-in.md create mode 100644 python/tests/fixtures/social/disabled/mkdocs.yml create mode 100644 python/tests/fixtures/social/filters/docs/guides/hidden.md create mode 100644 python/tests/fixtures/social/filters/docs/guides/index.md create mode 100644 python/tests/fixtures/social/filters/docs/guides/opt-out.md create mode 100644 python/tests/fixtures/social/filters/docs/index.md create mode 100644 python/tests/fixtures/social/filters/layouts/flat.yml create mode 100644 python/tests/fixtures/social/filters/mkdocs.yml create mode 100644 python/tests/fixtures/social/image-only/docs/background.svg create mode 100644 python/tests/fixtures/social/image-only/docs/index.md create mode 100644 python/tests/fixtures/social/image-only/mkdocs.yml create mode 100644 python/tests/fixtures/social/layer-composition/docs/background.svg create mode 100644 python/tests/fixtures/social/layer-composition/docs/index.md create mode 100644 python/tests/fixtures/social/layer-composition/layouts/composed.yml create mode 100644 python/tests/fixtures/social/layer-composition/mkdocs.yml create mode 100644 python/tests/fixtures/social/layout-options/docs/background.svg create mode 100644 python/tests/fixtures/social/layout-options/docs/index.md create mode 100644 python/tests/fixtures/social/layout-options/docs/logo.svg create mode 100644 python/tests/fixtures/social/layout-options/docs/override.md create mode 100644 python/tests/fixtures/social/layout-options/mkdocs.yml create mode 100644 python/tests/fixtures/social/lifecycle/docs/background.svg create mode 100644 python/tests/fixtures/social/lifecycle/docs/index.md create mode 100644 python/tests/fixtures/social/lifecycle/layouts/flat.yml create mode 100644 python/tests/fixtures/social/lifecycle/mkdocs.yml create mode 100644 python/tests/fixtures/social/lifecycle/steps.json create mode 100644 python/tests/fixtures/social/logo-icon/docs/index.md create mode 100644 python/tests/fixtures/social/logo-icon/mkdocs.yml create mode 100644 python/tests/fixtures/social/metadata/docs/guides/.meta.yml create mode 100644 python/tests/fixtures/social/metadata/docs/guides/index.md create mode 100644 python/tests/fixtures/social/metadata/docs/guides/override.md create mode 100644 python/tests/fixtures/social/metadata/docs/index.md create mode 100644 python/tests/fixtures/social/metadata/layouts/flat.yml create mode 100644 python/tests/fixtures/social/metadata/mkdocs.yml create mode 100644 python/tests/fixtures/social/multiple/docs/guide.md create mode 100644 python/tests/fixtures/social/multiple/docs/index.md create mode 100644 python/tests/fixtures/social/multiple/layouts/flat.yml create mode 100644 python/tests/fixtures/social/multiple/mkdocs.yml create mode 100644 python/tests/fixtures/social/no-site-url/docs/index.md create mode 100644 python/tests/fixtures/social/no-site-url/layouts/flat.yml create mode 100644 python/tests/fixtures/social/no-site-url/mkdocs.yml create mode 100644 python/tests/fixtures/social/paths/card-layouts/flat.yml create mode 100644 python/tests/fixtures/social/paths/docs/guides.md create mode 100644 python/tests/fixtures/social/paths/docs/index.md create mode 100644 python/tests/fixtures/social/paths/docs/nested/index.md create mode 100644 python/tests/fixtures/social/paths/mkdocs.yml create mode 100644 python/tests/fixtures/social/recoverable-error/docs/index.md create mode 100644 python/tests/fixtures/social/recoverable-error/layouts/missing-image.yml create mode 100644 python/tests/fixtures/social/recoverable-error/mkdocs.yml create mode 100644 python/tests/fixtures/social/template-context/docs/guides/advanced.md create mode 100644 python/tests/fixtures/social/template-context/docs/index.md create mode 100644 python/tests/fixtures/social/template-context/layouts/context.yml create mode 100644 python/tests/fixtures/social/template-context/mkdocs.yml create mode 100644 python/tests/fixtures/social/theme-defaults/docs/assets/logo.png create mode 100644 python/tests/fixtures/social/theme-defaults/docs/assets/logo.svg create mode 100644 python/tests/fixtures/social/theme-defaults/docs/exact.md create mode 100644 python/tests/fixtures/social/theme-defaults/docs/index.md create mode 100644 python/tests/fixtures/social/theme-defaults/docs/png.md create mode 100644 python/tests/fixtures/social/theme-defaults/docs/variant.md create mode 100644 python/tests/fixtures/social/theme-defaults/layouts/exact.yml create mode 100644 python/tests/fixtures/social/theme-defaults/mkdocs.yml create mode 100644 python/tests/integration/test_social.py create mode 100755 scripts/social_compatibility.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c45dc1b..4c6213a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -76,6 +76,9 @@ jobs: with: target: ${{ matrix.platform.target }} manylinux: auto + before-script-linux: | + # The manylinux2014 AArch64 cross compiler omits this ACLE macro. + export CFLAGS_aarch64_unknown_linux_gnu="${CFLAGS_aarch64_unknown_linux_gnu:-} -D__ARM_ARCH=8" # Temporary fix for https://github.com/zensical/zensical/issues/425 args: >- --release --out artifacts diff --git a/Cargo.lock b/Cargo.lock index eefb953..0d90f34 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # 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 = "ahash" version = "0.8.12" @@ -52,6 +58,18 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + [[package]] name = "ast_node" version = "7.0.0" @@ -60,7 +78,7 @@ checksum = "edf54a7a1bf98e127c22e9e2b9d19619092c74283fdc14e4d67da69780f90db6" dependencies = [ "quote", "swc_macros_common", - "syn", + "syn 2.0.118", ] [[package]] @@ -71,7 +89,7 @@ checksum = "ffdcb70bdbc4d478427380519163274ac86e52916e10f0a8889adf0f96d3fee7" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -86,6 +104,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "base64-simd" version = "0.8.0" @@ -151,6 +175,32 @@ dependencies = [ "allocator-api2", ] +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.12.0" @@ -176,12 +226,28 @@ dependencies = [ "rustversion", ] +[[package]] +name = "cc" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +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 = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "combine" version = "4.6.7" @@ -221,6 +287,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam" version = "0.8.4" @@ -287,6 +362,12 @@ dependencies = [ "typenum", ] +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + [[package]] name = "defmt" version = "1.1.1" @@ -306,7 +387,7 @@ dependencies = [ "defmt-parser", "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -346,7 +427,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -386,12 +467,30 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + [[package]] name = "fastrand" version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "file-id" version = "0.2.3" @@ -401,12 +500,35 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + [[package]] name = "fixedbitset" version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" + [[package]] name = "fluent-uri" version = "0.4.1" @@ -424,6 +546,26 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "font-types" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e64eb721ca85a34323425f4041adc5d82704d3782d5f8f03793bc012419dce23" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "fontdb" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2660c5e9157bf76d2db1294e4a9feba604ef610819a3b591088d0d8392a3290f" +dependencies = [ + "log", + "slotmap", + "tinyvec", +] + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -440,7 +582,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5ff35a391aef949120a0340d690269b3d9f63460a6106e99bd07b961f345ea9" dependencies = [ "swc_macros_common", - "syn", + "syn 2.0.118", ] [[package]] @@ -486,6 +628,17 @@ dependencies = [ "version_check", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -509,6 +662,16 @@ dependencies = [ "r-efi 6.0.0", ] +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "globset" version = "0.4.18" @@ -522,6 +685,18 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "harfrust" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9" +dependencies = [ + "bitflags 2.13.0", + "bytemuck", + "read-fonts", + "smallvec", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -577,12 +752,28 @@ dependencies = [ "triomphe", ] +[[package]] +name = "html-escape" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9356095b4b41197bba32173600e1582792cda618f65d12f68e2e77d273413c5" + [[package]] name = "html5gum" version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "428502d3ec1742c35e015871aef742bc4722a9acfcb616b8ff79b519922e1c36" +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + [[package]] name = "httparse" version = "1.10.1" @@ -727,6 +918,22 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imagesize" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65b27460c2c92b037f3f94c538ed9a3342f3fdf923606781629ccb35f82d042a" + [[package]] name = "indexmap" version = "2.14.1" @@ -766,7 +973,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -819,7 +1026,7 @@ dependencies = [ "jiff-core", "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -864,7 +1071,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn", + "syn 2.0.118", ] [[package]] @@ -883,7 +1090,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -917,6 +1124,18 @@ dependencies = [ "libc", ] +[[package]] +name = "kurbo" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +dependencies = [ + "arrayvec", + "euclid", + "polycool", + "smallvec", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1060,6 +1279,26 @@ dependencies = [ "serde", ] +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.1" @@ -1252,7 +1491,7 @@ checksum = "b237422b014f8f8fff75bb9379e697d13f8d57551a22c88bebb39f073c1bf696" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -1293,7 +1532,7 @@ dependencies = [ "phf", "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -1547,7 +1786,7 @@ dependencies = [ "phf_shared", "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -1559,12 +1798,40 @@ dependencies = [ "siphasher 1.0.3", ] +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -1651,7 +1918,7 @@ dependencies = [ "proc-macro2", "pyo3-macros-backend", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -1663,9 +1930,15 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quote" version = "1.0.46" @@ -1716,6 +1989,17 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "read-fonts" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" +dependencies = [ + "bytemuck", + "font-types", + "once_cell", +] + [[package]] name = "ref-cast" version = "1.0.25" @@ -1733,7 +2017,7 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -1765,6 +2049,56 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "resvg" +version = "0.48.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67e3803f97b999e80cbf7c6ecdd07a8102204d92e1633cf48783720c521196bd" +dependencies = [ + "bytemuck", + "gif", + "image-webp", + "log", + "pico-args", + "rgb", + "svgtypes", + "tiny-skia", + "usvg", + "zune-jpeg", +] + +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +dependencies = [ + "bytemuck", +] + +[[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 = "roxmltree" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +dependencies = [ + "memchr", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -1793,6 +2127,41 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +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 = "rustversion" version = "1.0.23" @@ -1888,7 +2257,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -1930,6 +2299,18 @@ dependencies = [ "lazy_static", ] +[[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 = "simd_cesu8" version = "1.1.1" @@ -1946,6 +2327,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "simplecss" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c" +dependencies = [ + "log", +] + [[package]] name = "siphasher" version = "0.3.11" @@ -1958,12 +2348,31 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" +[[package]] +name = "skrifa" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" +dependencies = [ + "bytemuck", + "read-fonts", +] + [[package]] name = "slab" version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + [[package]] name = "smallvec" version = "1.15.2" @@ -1988,6 +2397,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +dependencies = [ + "float-cmp", +] + [[package]] name = "string_enum" version = "1.0.2" @@ -1996,7 +2414,23 @@ checksum = "ae36a4951ca7bd1cfd991c241584a9824a70f6aff1e7d4f693fb3f2465e4030e" dependencies = [ "quote", "swc_macros_common", - "syn", + "syn 2.0.118", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "svgtypes" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d" +dependencies = [ + "kurbo", + "siphasher 1.0.3", ] [[package]] @@ -2085,7 +2519,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7e32e407d0a010fedb53cf9dfdccf091521a2c9081efc077da647f7c8963741" dependencies = [ "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -2152,7 +2586,7 @@ checksum = "c16ce73424a6316e95e09065ba6a207eba7765496fed113702278b7711d4b632" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -2163,7 +2597,7 @@ checksum = "aae1efbaa74943dc5ad2a2fb16cbd78b77d7e4d63188f3c5b4df2b4dcd2faaae" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -2186,6 +2620,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "synstructure" version = "0.13.2" @@ -2194,7 +2639,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -2244,7 +2689,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -2256,6 +2701,32 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tiny-skia" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -2267,6 +2738,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tracing" version = "0.1.44" @@ -2286,7 +2772,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -2363,6 +2849,12 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + [[package]] name = "unicode-id-start" version = "1.4.0" @@ -2381,18 +2873,65 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + [[package]] name = "unicode-segmentation" version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "unicode-vo" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" + [[package]] name = "unicode-width" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "972d7902c8735f2695410b8aed7df6ed12a47394aa1c8d7af49f0497b731a94d" +dependencies = [ + "base64 0.23.1", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da5f78b09e6941e1a0f2e30e695e4b120377b54d5e0aec11b594bb57b3971613" +dependencies = [ + "base64 0.23.1", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -2405,12 +2944,45 @@ dependencies = [ "serde", ] +[[package]] +name = "usvg" +version = "0.48.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "977d0a4abdef933f424a99fe09f95576e089b90aebc6f016a3bc813762493e91" +dependencies = [ + "base64 0.23.1", + "data-url", + "fontdb", + "harfrust", + "imagesize", + "kurbo", + "log", + "pico-args", + "roxmltree", + "simplecss", + "siphasher 1.0.3", + "skrifa", + "strict-num", + "svgtypes", + "tiny-skia-path", + "unicode-bidi", + "unicode-script", + "unicode-vo", + "xmlwriter", +] + [[package]] name = "utf16_iter" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -2492,7 +3064,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.118", "wasm-bindgen-shared", ] @@ -2531,6 +3103,21 @@ dependencies = [ "web-sys", ] +[[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 = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "winapi-util" version = "0.1.11" @@ -2546,13 +3133,22 @@ 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 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets", + "windows-targets 0.53.5", ] [[package]] @@ -2564,6 +3160,22 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + [[package]] name = "windows-targets" version = "0.53.5" @@ -2571,58 +3183,106 @@ 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", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + [[package]] name = "windows_aarch64_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + [[package]] name = "windows_aarch64_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + [[package]] name = "windows_i686_gnu" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + [[package]] name = "windows_i686_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + [[package]] name = "windows_i686_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + [[package]] name = "windows_x86_64_gnu" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + [[package]] name = "windows_x86_64_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "windows_x86_64_msvc" version = "0.53.1" @@ -2647,6 +3307,12 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "xmlwriter" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" + [[package]] name = "yansi" version = "1.0.1" @@ -2672,7 +3338,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", "synstructure", ] @@ -2683,9 +3349,11 @@ dependencies = [ "ahash", "anyhow", "ariadne", + "base64 0.22.1", "crossbeam", "fluent-uri", "globset", + "html-escape", "html5gum", "icu_casemap", "icu_locale_core", @@ -2702,10 +3370,13 @@ dependencies = [ "percent-encoding", "pyo3", "regex", + "resvg", "saphyr", "serde", "serde_json", "sha2", + "skrifa", + "svgtypes", "swc_common", "swc_css", "tempfile", @@ -2713,6 +3384,7 @@ dependencies = [ "tracing", "tracing-chrome", "tracing-subscriber", + "ureq", "webbrowser", "zensical-serve", "zensical-watch", @@ -2723,7 +3395,7 @@ dependencies = [ name = "zensical-serve" version = "0.0.8" dependencies = [ - "base64", + "base64 0.22.1", "crossbeam", "httparse", "httpdate", @@ -2767,7 +3439,7 @@ checksum = "4714fd92cf900833d49538023a9b3915155210801d1c1169eba513b2addefd71" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -2787,10 +3459,16 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", "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.4" @@ -2823,9 +3501,15 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + [[package]] name = "zmij" version = "1.0.21" @@ -2941,3 +3625,18 @@ dependencies = [ "zrx-scheduler", "zrx-store", ] + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml index 2274d1c..1d00698 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,6 +56,7 @@ file-id = "0.2" fluent-uri = "0.4" globset = "0.4.18" html5gum = { version = "0.8.4", default-features = false } +html-escape = "0.2.14" httparse = "1.10" httpdate = "1.0" icu_casemap = "2.2.0" @@ -75,16 +76,26 @@ oxc_sourcemap = "=4.0.5" oxc_span = "0.81.0" percent-encoding = "2.3" regex = "1.12.3" +resvg = { version = "0.48.1", default-features = false, features = [ + "raster-images", + "text", +] } +skrifa = "0.44.0" saphyr = "0.0.6" sha1_smol = "1.0" slab = "0.4.12" serde = "1.0" serde_json = "1.0" sha2 = "0.10.9" +svgtypes = "0.16.1" swc_common = "26.0.0" swc_css = { version = "31.0.0", features = ["minifier"] } thiserror = "2.0.18" tungstenite = { version = "0.29.0", default-features = false } +ureq = { version = "3.4.0", default-features = false, features = [ + "gzip", + "rustls", +] } tracing = { version = "0.1" } tracing-chrome = "0.7" tracing-subscriber = "0.3.23" diff --git a/crates/zensical/Cargo.toml b/crates/zensical/Cargo.toml index 8583f04..e391b52 100644 --- a/crates/zensical/Cargo.toml +++ b/crates/zensical/Cargo.toml @@ -48,10 +48,12 @@ zensical-watch.workspace = true ahash.workspace = true anyhow.workspace = true ariadne.workspace = true +base64.workspace = true crossbeam.workspace = true fluent-uri.workspace = true globset.workspace = true html5gum.workspace = true +html-escape.workspace = true icu_casemap.workspace = true icu_locale_core.workspace = true icu_normalizer.workspace = true @@ -59,7 +61,10 @@ jiff.workspace = true minijinja = { workspace = true, features = [ "json", "loader", "builtins", "urlencode" ] } -minijinja-contrib = { workspace = true, features = ["html_entities"] } +minijinja-contrib = { workspace = true, features = [ + "html_entities", + "pycompat", +] } mio = { workspace = true, features = ["net", "os-poll"] } oxc_allocator.workspace = true oxc_codegen.workspace = true @@ -69,13 +74,17 @@ oxc_span.workspace = true percent-encoding.workspace = true pyo3.workspace = true regex.workspace = true +resvg.workspace = true +skrifa.workspace = true saphyr.workspace = true serde = { workspace = true, features = ["derive", "rc"] } serde_json.workspace = true sha2.workspace = true +svgtypes.workspace = true swc_common.workspace = true swc_css.workspace = true thiserror.workspace = true +ureq.workspace = true tracing = { workspace = true, optional = true, features = [ "max_level_trace" ] } diff --git a/crates/zensical/src/compat/mkdocs/plugin.rs b/crates/zensical/src/compat/mkdocs/plugin.rs index 1aebf38..1132b33 100644 --- a/crates/zensical/src/compat/mkdocs/plugin.rs +++ b/crates/zensical/src/compat/mkdocs/plugin.rs @@ -47,6 +47,7 @@ pub mod mkdocstrings; pub mod redirects; pub mod rss; pub mod search; +pub mod social; pub mod tags; // ---------------------------------------------------------------------------- diff --git a/crates/zensical/src/compat/mkdocs/plugin/social.rs b/crates/zensical/src/compat/mkdocs/plugin/social.rs new file mode 100644 index 0000000..1ca8e2c --- /dev/null +++ b/crates/zensical/src/compat/mkdocs/plugin/social.rs @@ -0,0 +1,841 @@ +// Copyright (c) 2025-2026 Zensical and contributors + +// SPDX-License-Identifier: MIT +// All contributions are certified under the DCO + +// 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 NON-INFRINGEMENT. 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. + +// ---------------------------------------------------------------------------- + +//! Material social plugin compatibility pipeline. + +use anyhow::{bail, Context, Result}; +use globset::{GlobBuilder, GlobSet, GlobSetBuilder}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; +use std::fs; +use std::io; +use std::path::{Component, Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; + +use zrx::id::Id; +use zrx::stream::function::Collection; +use zrx::stream::{concurrent, Key, Stream, Value}; + +use crate::config::plugins::{SocialPluginConfig, SocialPluginInstance}; +use crate::config::{Config, Project}; +use crate::path::{OutputRoot, SitePath}; +use crate::structure::dynamic::Dynamic; +use crate::structure::page::Page; +use crate::watcher::Source; + +mod font; +mod layout; +mod render; +mod writer; + +use layout::Layout; +use render::{Renderer, Tag}; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Material social compatibility pipeline. +#[derive(Clone, Debug)] +pub struct Social { + /// Enabled plugin instances in configuration order. + instances: Arc<[Instance]>, + /// Maximum card-rendering concurrency across the instances. + concurrency: usize, + /// Root for generated site files. + output: OutputRoot, +} + +/// Inputs required to generate social cards and metadata. +pub struct Dependencies<'a> { + /// Rendered pages after page-local compatibility processing. + pub pages: &'a Stream, + /// Physical sources used to invalidate affected card cache checks. + pub sources: &'a Stream, +} + +/// Social metadata derived for one page. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Metadata { + /// Rendered social tags to inject into the page head. + tags: Arc<[Tag]>, + /// Revision of the rendered tags for page cache invalidation. + hash: u64, +} + +/// One enabled social plugin configuration and its rendering state. +#[derive(Clone, Debug)] +struct Instance { + /// Configuration-order priority for conflicting card paths. + id: usize, + /// Configured plugin name for diagnostics. + name: String, + /// Validated options for this instance. + config: Arc, + /// Shared project settings used by templates and routes. + project: Arc, + /// Directory containing the project configuration. + root: PathBuf, + /// Renderer and its shared font and dependency caches. + renderer: Renderer, + /// Compiled page include and exclude patterns. + filter: SourceFilter, + /// Whether the current workflow serves changes continuously. + serve: bool, + /// Whether warnings fail the build. + strict: bool, +} + +/// Compiled source-path filters and any deferred pattern error. +#[derive(Clone, Debug)] +struct SourceFilter { + /// Patterns that take precedence when configured. + include: GlobSet, + /// Patterns used when no include patterns are configured. + exclude: GlobSet, + /// Whether inclusion is decided by the include set. + has_include: bool, + /// Invalid pattern reported when the filter is used. + error: Option, +} + +/// Generated card and its cached PNG source. +#[derive(Clone, Debug, PartialEq, Eq)] +struct Card { + /// Plugin instance that produced this card. + instance: usize, + /// Site-relative output path. + path: SitePath, + /// Cached PNG copied into the output tree. + source: PathBuf, +} + +/// Cards and HTML metadata derived from one page. +#[derive(Clone, Debug)] +struct Bundle { + /// Generated cards keyed by plugin instance. + cards: Vec<(Key, Card)>, + /// Tags selected for this page. + metadata: Metadata, +} + +/// Instance key, generated card, and page metadata tags. +type Generated = (Key, Card, Vec); + +/// Content hash of one physical card dependency. +#[derive(Clone, Debug, PartialEq, Eq)] +struct Fingerprint { + /// Absolute path of the watched dependency. + source: PathBuf, + /// SHA-256 digest of its current bytes. + digest: [u8; 32], +} + +/// Revisions of watched assets available to one card-rendering pass. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +struct AssetRevision( + /// SHA-256 digests indexed by physical asset path. + Arc>, +); + +/// Card error that upstream treats as recoverable plugin input failure. +#[derive(Debug, thiserror::Error)] +#[error("{0}")] +struct PluginError( + /// Error text surfaced according to the configured log level. + String, +); + +static TEMPORARY_ID: AtomicU64 = AtomicU64::new(0); + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl Social { + /// Resolves immutable settings for all configured plugin instances. + pub fn new(config: &Config, serve: bool, strict: bool) -> Self { + let instances = config + .project + .plugins + .social + .config + .iter() + .enumerate() + .filter(|(_, plugin)| plugin.config.enabled) + .map(|(id, plugin)| { + Instance::new(id, plugin, config, serve, strict) + }) + .collect::>(); + let concurrency = instances + .iter() + .map(|instance| instance.config.concurrency) + .max() + .unwrap_or(1); + if !instances.is_empty() && config.project.site_url.is_none() { + eprintln!( + "WARNING - The 'site_url' option is not set. Social cards are generated but not linked." + ); + } + if instances.iter().any(|instance| instance.config.debug) { + eprintln!( + "WARNING - Debug mode is enabled for the 'social' plugin." + ); + } + for instance in &instances { + if instance.config.has_deprecated_cards_color() { + eprintln!( + "WARNING - The 'cards_color' option of the 'social' plugin is deprecated; use 'cards_layout_options.background_color' and 'cards_layout_options.color'." + ); + } + if instance.config.has_deprecated_cards_font() { + eprintln!( + "WARNING - The 'cards_font' option of the 'social' plugin is deprecated; use 'cards_layout_options.font_family'." + ); + } + } + Self { + instances: instances.into(), + concurrency, + output: config.output_root().clone(), + } + } + + /// Generates cards and returns page-local metadata for HTML injection. + pub fn setup( + &self, dependencies: Dependencies<'_>, + ) -> Stream { + if self.instances.is_empty() { + return dependencies.pages.map(|_page: &Page| Metadata::default()); + } + let ignored = self + .instances + .iter() + .map(|instance| { + resolve_from(&instance.root, &instance.config.cache_dir) + }) + .chain(std::iter::once(self.output.as_path().to_owned())) + .collect::>(); + let assets = dependencies + .sources + .filter(move |id: &Id, source: &Source| { + is_card_dependency(id, source, &ignored) + }) + .map(|source: &Source| { + Ok::<_, anyhow::Error>(Fingerprint { + source: source.to_path_buf(), + digest: Sha256::digest(fs::read(&**source)?).into(), + }) + }) + .reduce(|sources: &dyn Collection, Fingerprint>| { + Some(asset_revision(sources.values())) + }); + let instances = self.instances.clone(); + let bundles = dependencies.pages.product(&assets).map(concurrent( + self.concurrency, + move |page: &Page, assets: &AssetRevision| { + render_page(&instances, page, assets) + }, + )); + let cards = bundles + .flat_map(|bundle: &Bundle| bundle.cards.clone()) + .reduce_by_key( + |card: &Card| output_key(&card.path), + |cards: &dyn Collection, Card>| { + cards.values().max_by_key(|card| card.instance).cloned() + }, + ); + writer::setup(self.output.clone(), &cards); + bundles.map(|bundle: &Bundle| bundle.metadata.clone()) + } +} + +impl Metadata { + /// Inserts generated meta tags immediately before the closing head tag. + pub fn inject(&self, mut html: String) -> String { + if self.tags.is_empty() { + return html; + } + let Some(offset) = html.find("") else { + return html; + }; + let tags = self + .tags + .iter() + .map(|tag| { + format!( + "", + html_attribute(&tag.property), + html_attribute(&tag.content), + ) + }) + .collect::>() + .join("\n"); + html.insert_str(offset, &format!("{tags}\n")); + html + } + + /// Adds metadata to the page-render cache key. + pub fn hash(&self, state: &mut H) { + std::hash::Hash::hash(&self.hash, state); + } +} + +impl Instance { + /// Creates rendering state for one enabled plugin instance. + fn new( + id: usize, plugin: &SocialPluginInstance, config: &Config, serve: bool, + strict: bool, + ) -> Self { + let root = config + .path + .parent() + .expect("configuration has parent") + .to_owned(); + let cache = resolve_from(&root, &plugin.config.cache_dir); + Self { + id, + name: plugin.name.clone(), + config: Arc::new(plugin.config.clone()), + project: config.project.clone(), + root, + renderer: Renderer::new( + config.project.clone(), + config.theme_dirs.clone(), + cache, + ), + filter: SourceFilter::new( + &plugin.config.cards_include, + &plugin.config.cards_exclude, + ), + serve, + strict, + } + } + + /// Builds one page's card, cache entry, and metadata tags. + fn render(&self, page: &Page, assets: &AssetRevision) -> Result { + let name = page_string(page, "cards_layout")? + .unwrap_or_else(|| self.config.cards_layout.clone()); + let name = name + .strip_suffix(".yml") + .or_else(|| name.strip_suffix(".yaml")) + .unwrap_or(&name); + let layout = self.layout(name)?; + let options = page_options(page, &self.config.cards_layout_options)?; + let path = card_path( + &self.config.cards_dir, + page.destination(), + self.project.use_directory_urls, + matches!(page.source().file_name(), "index.md" | "README.md"), + )?; + let prepared = self.renderer.prepare(&layout, page, &options)?; + let dependencies = + self.renderer.dependency_revision(&prepared, &assets.0)?; + let source = self.card(&prepared, &dependencies)?; + let tags = self + .project + .site_url + .as_ref() + .map(|site_url| { + let url = format!( + "{}/{}", + site_url.trim_end_matches('/'), + path.as_str() + ); + self.renderer.tags(&layout, page, &options, &url) + }) + .transpose()? + .unwrap_or_default(); + let key = instance_key(self.id)?; + Ok(( + key, + Card { + instance: self.id, + path, + source, + }, + tags, + )) + } + + /// Validates page options even when this instance does not render a card. + fn validate_page(&self, page: &Page) -> Result { + if !self.includes(page)? { + return Ok(false); + } + page_string(page, "cards_layout")?; + page_options(page, &self.config.cards_layout_options)?; + Ok(true) + } + + /// Checks page-level card settings and source-path filters. + fn includes(&self, page: &Page) -> Result { + self.filter.validate(&self.name)?; + let cards = page_bool(page, "cards")?.unwrap_or(self.config.cards); + if !cards { + return Ok(false); + } + let source = page.source().as_str(); + if self.filter.has_include { + Ok(self.filter.include.is_match(source)) + } else { + Ok(!self.filter.exclude.is_match(source)) + } + } + + /// Loads a custom layout or one of the bundled Material layouts. + fn layout(&self, name: &str) -> Result { + validate_layout_name(name).map_err(plugin_error)?; + let directory = resolve_from(&self.root, &self.config.cards_layout_dir); + let path = directory.join(format!("{name}.yml")); + if path.is_file() { + let source = fs::read_to_string(&path).with_context(|| { + format!("failed to read social layout '{}'", path.display()) + })?; + return layout::parse(&path.display().to_string(), &source) + .map_err(plugin_error); + } + let source = builtin_layout(name) + .with_context(|| format!("social card layout not found: {name}")) + .map_err(plugin_error)?; + layout::parse(name, source).map_err(plugin_error) + } + + /// Returns a cached PNG path, rendering the card when needed. + fn card( + &self, layout: &Layout, dependencies: &[u8; 32], + ) -> Result { + let cache = + resolve_from(&self.root, &self.config.cache_dir).join("cards"); + let digest = card_digest(layout, dependencies, self.debug())?; + let path = cache.join(format!("{digest}.png")); + if self.config.cache { + match fs::metadata(&path) { + Ok(metadata) if metadata.is_file() => return Ok(path), + Ok(_) => { + bail!( + "social card cache path is not a file: {}", + path.display() + ) + } + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + } + let contents = self.renderer.card(layout, self.debug())?; + fs::create_dir_all(&cache)?; + let temporary = cache.join(format!( + ".{digest}.{}.{}.tmp", + std::process::id(), + TEMPORARY_ID.fetch_add(1, Ordering::Relaxed), + )); + fs::write(&temporary, &contents)?; + if let Err(error) = replace_file(&temporary, &path) { + let _ = fs::remove_file(&temporary); + return Err(error.into()); + } + Ok(path) + } + + /// Returns active debug-overlay settings for this build mode. + fn debug(&self) -> Option<(&str, bool, usize)> { + (self.config.debug && (self.serve || self.config.debug_on_build)) + .then_some(( + self.config.debug_color.as_str(), + self.config.debug_grid, + self.config.debug_grid_step, + )) + } + + /// Reports a recoverable card error according to the configured level. + fn report(&self, page: &Page, error: &anyhow::Error) -> Result<()> { + match self.config.log_level.as_str() { + "warn" => eprintln!( + "WARNING - Couldn't render social card for '{}': {error:#}", + page.source() + ), + "info" => eprintln!( + "INFO - Couldn't render social card for '{}': {error:#}", + page.source() + ), + "ignore" => return Ok(()), + _ => unreachable!("social log level is validated during loading"), + } + if self.strict && self.config.log_level == "warn" { + bail!("Aborted because --strict flag is set") + } + Ok(()) + } +} + +impl SourceFilter { + /// Compiles source patterns while retaining invalid-pattern diagnostics. + fn new(include: &[String], exclude: &[String]) -> Self { + let mut error = None; + Self { + include: compile_globs(include, &mut error), + exclude: compile_globs(exclude, &mut error), + has_include: !include.is_empty(), + error, + } + } + + /// Reports a deferred invalid pattern for this plugin instance. + fn validate(&self, name: &str) -> Result<()> { + if let Some(error) = &self.error { + bail!("invalid source pattern for plugin '{name}': {error}") + } + Ok(()) + } +} + +// ---------------------------------------------------------------------------- +// Trait implementations +// ---------------------------------------------------------------------------- + +impl Value for Metadata {} +impl Value for Card {} +impl Value for Bundle {} +impl Value for Fingerprint {} +impl Value for AssetRevision {} + +// ---------------------------------------------------------------------------- +// Functions +// ---------------------------------------------------------------------------- + +fn render_page( + instances: &[Instance], page: &Page, assets: &AssetRevision, +) -> Result { + let mut cards = Vec::new(); + let mut tags = Vec::new(); + for instance in instances { + if !instance.validate_page(page)? { + continue; + } + match instance.render(page, assets) { + Ok((key, card, instance_tags)) => { + cards.push((key, card)); + tags.extend(instance_tags); + } + Err(error) + if instance.config.log + && error.downcast_ref::().is_some() => + { + instance.report(page, &error)?; + } + Err(error) => return Err(error), + } + } + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + std::hash::Hash::hash(&tags, &mut hasher); + Ok(Bundle { + cards, + metadata: Metadata { + tags: tags.into(), + hash: std::hash::Hasher::finish(&hasher), + }, + }) +} + +fn card_digest( + layout: &Layout, dependencies: &[u8; 32], + debug: Option<(&str, bool, usize)>, +) -> Result { + let mut digest = Sha256::new(); + digest.update(b"zensical-social-card-v1"); + digest.update(serde_json::to_vec(&(layout.size, &layout.layers))?); + digest.update(serde_json::to_vec(&debug)?); + digest.update(dependencies); + Ok(format!("{:x}", digest.finalize())) +} + +fn plugin_error(error: anyhow::Error) -> anyhow::Error { + PluginError(format!("{error:#}")).into() +} + +fn asset_revision<'a>( + sources: impl Iterator, +) -> AssetRevision { + let mut files = BTreeMap::new(); + for source in sources { + files.insert(source.source.clone(), source.digest); + } + AssetRevision(Arc::new(files)) +} + +fn is_card_dependency(id: &Id, source: &Source, ignored: &[PathBuf]) -> bool { + if ignored + .iter() + .any(|directory| source.starts_with(directory)) + { + return false; + } + let location = id.location(); + Path::new(location.as_ref()) + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| { + matches!( + extension.to_ascii_lowercase().as_str(), + "yml" + | "yaml" + | "svg" + | "png" + | "jpg" + | "jpeg" + | "gif" + | "webp" + ) + }) +} + +fn page_social(page: &Page) -> Result>> { + match page.meta.get("social") { + None | Some(Dynamic::Null) => Ok(None), + Some(Dynamic::Map(value)) => Ok(Some(value)), + Some(_) => bail!("page social configuration must be a mapping"), + } +} + +fn page_bool(page: &Page, name: &str) -> Result> { + match page_social(page)?.and_then(|config| config.get(name)) { + None | Some(Dynamic::Null) => Ok(None), + Some(Dynamic::Bool(value)) => Ok(Some(*value)), + Some(_) => bail!("page social option '{name}' must be a Boolean"), + } +} + +fn page_string(page: &Page, name: &str) -> Result> { + match page_social(page)?.and_then(|config| config.get(name)) { + None | Some(Dynamic::Null) => Ok(None), + Some(Dynamic::String(value)) => Ok(Some(value.clone())), + Some(_) => bail!("page social option '{name}' must be a string"), + } +} + +fn page_options( + page: &Page, defaults: &BTreeMap, +) -> Result> { + let mut options = defaults.clone(); + match page_social(page)? + .and_then(|config| config.get("cards_layout_options")) + { + None | Some(Dynamic::Null) => {} + Some(Dynamic::Map(values)) => options.extend(values.clone()), + Some(_) => { + bail!("page social option 'cards_layout_options' must be a mapping") + } + } + Ok(options) +} + +fn card_path( + directory: &str, destination: &SitePath, use_directory_urls: bool, + is_index: bool, +) -> Result { + let mut path = destination.as_str().to_owned(); + let suffix = if use_directory_urls && !is_index { + "/index.html" + } else { + ".html" + }; + let stem = path.strip_suffix(suffix).with_context(|| { + format!("unexpected page destination: {destination}") + })?; + path = format!("{stem}.png"); + Ok(directory.parse::()?.join(&path)?) +} + +fn instance_key(instance: usize) -> Result> { + Ok(Key::from( + Id::builder() + .provider("social") + .context("instance") + .location(instance.to_string()) + .build()?, + )) +} + +fn output_key(path: &SitePath) -> Result> { + Ok(Key::from( + Id::builder() + .provider("file") + .context(".") + .location(path.as_str()) + .build()?, + )) +} + +fn compile_globs(patterns: &[String], error: &mut Option) -> GlobSet { + let mut builder = GlobSetBuilder::new(); + for pattern in patterns { + match GlobBuilder::new(pattern) + .literal_separator(false) + .backslash_escape(false) + .build() + { + Ok(pattern) => { + builder.add(pattern); + } + Err(reason) => { + error.get_or_insert_with(|| reason.to_string()); + } + } + } + builder.build().unwrap_or_else(|reason| { + error.get_or_insert_with(|| reason.to_string()); + GlobSetBuilder::new().build().expect("empty glob set") + }) +} + +fn validate_layout_name(name: &str) -> Result<()> { + if name.is_empty() + || Path::new(name).components().any(|component| { + matches!( + component, + Component::Prefix(_) + | Component::RootDir + | Component::ParentDir + ) + }) + { + bail!("invalid social card layout name: {name}") + } + Ok(()) +} + +fn resolve_from(root: &Path, value: &str) -> PathBuf { + let path = Path::new(value); + if path.is_absolute() { + path.to_owned() + } else { + root.join(path) + } +} + +fn replace_file(temporary: &Path, target: &Path) -> io::Result<()> { + match fs::rename(temporary, target) { + Ok(()) => Ok(()), + Err(error) + if target.is_file() + && matches!( + error.kind(), + io::ErrorKind::AlreadyExists + | io::ErrorKind::PermissionDenied + ) => + { + fs::remove_file(target)?; + fs::rename(temporary, target) + } + Err(error) => Err(error), + } +} + +fn builtin_layout(name: &str) -> Option<&'static str> { + match name { + "default" => Some(include_str!("social/layouts/default.yml")), + "default/accent" => { + Some(include_str!("social/layouts/default/accent.yml")) + } + "default/invert" => { + Some(include_str!("social/layouts/default/invert.yml")) + } + "default/only/image" => { + Some(include_str!("social/layouts/default/only/image.yml")) + } + "default/variant" => { + Some(include_str!("social/layouts/default/variant.yml")) + } + _ => None, + } +} + +fn html_attribute(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +// ---------------------------------------------------------------------------- +// Tests +// ---------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::{ + builtin_layout, card_path, html_attribute, layout, validate_layout_name, + }; + use crate::path::SitePath; + + #[test] + fn parses_all_bundled_layouts() { + for name in [ + "default", + "default/accent", + "default/invert", + "default/only/image", + "default/variant", + ] { + layout::parse(name, builtin_layout(name).unwrap()).unwrap(); + } + } + + #[test] + fn derives_upstream_card_paths() { + assert_eq!( + card_path( + "assets/images/social", + &"guide/index.html".parse::().unwrap(), + true, + false, + ) + .unwrap() + .as_str(), + "assets/images/social/guide.png" + ); + assert_eq!( + card_path( + "assets/images/social", + &"index.html".parse::().unwrap(), + true, + true, + ) + .unwrap() + .as_str(), + "assets/images/social/index.png" + ); + } + + #[test] + fn rejects_layout_traversal_and_escapes_metadata() { + assert!(validate_layout_name("../secret").is_err()); + assert_eq!(html_attribute("a&\"b"), "a&"b"); + } +} diff --git a/crates/zensical/src/compat/mkdocs/plugin/social/font.rs b/crates/zensical/src/compat/mkdocs/plugin/social/font.rs new file mode 100644 index 0000000..e02d263 --- /dev/null +++ b/crates/zensical/src/compat/mkdocs/plugin/social/font.rs @@ -0,0 +1,460 @@ +// Copyright (c) 2025-2026 Zensical and contributors + +// SPDX-License-Identifier: MIT +// All contributions are certified under the DCO + +// 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 NON-INFRINGEMENT. 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. + +// ---------------------------------------------------------------------------- + +//! Deterministic Google Font acquisition and loading. + +use anyhow::{anyhow, bail, Context, Result}; +use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC}; +use regex::Regex; +use resvg::usvg::fontdb::{Database, Family, Query, Stretch, Style, Weight}; +use sha2::{Digest, Sha256}; +use skrifa::{string::StringId, MetadataProvider}; +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, LazyLock, Mutex}; +use std::time::Duration; + +use super::layout::Font; +use super::plugin_error; + +// ---------------------------------------------------------------------------- +// Constants +// ---------------------------------------------------------------------------- + +static DOWNLOADS: Mutex<()> = Mutex::new(()); +static FONT_URL: LazyLock = LazyLock::new(|| { + Regex::new(r#"\"(https:[^\"]+\.[ot]tf)\""#) + .expect("constant regular expression") +}); + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Shared font resolver for one social plugin instance. +#[derive(Clone, Debug)] +pub struct Fonts { + /// Directory holding downloaded font faces. + cache: PathBuf, + /// HTTP client for Google Fonts requests. + agent: ureq::Agent, + /// Selected font faces shared across card renders. + loaded: Arc>>>, +} + +/// Normalized CSS font properties used by SVG and font database selection. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Attributes { + /// Numeric CSS font weight. + pub weight: u16, + /// CSS font style name. + pub style: &'static str, + /// CSS font stretch name. + pub stretch: &'static str, +} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl Fonts { + /// Creates a resolver rooted in one instance cache directory. + pub fn new(cache: PathBuf) -> Self { + let config = ureq::Agent::config_builder() + .timeout_global(Some(Duration::from_secs(30))) + .build(); + Self { + cache: cache.join("fonts"), + agent: config.into(), + loaded: Arc::default(), + } + } + + /// Loads the closest face for a font request, downloading its family once. + pub fn load(&self, font: &Font) -> Result> { + if let Some(database) = self + .loaded + .lock() + .map_err(|_| anyhow::anyhow!("social font cache was poisoned"))? + .get(font) + .cloned() + { + return Ok(database); + } + let _guard = DOWNLOADS.lock().map_err(|_| { + anyhow::anyhow!("social font cache lock was poisoned") + })?; + if let Some(database) = self + .loaded + .lock() + .map_err(|_| anyhow::anyhow!("social font cache was poisoned"))? + .get(font) + .cloned() + { + return Ok(database); + } + + let directory = self.cache.join(safe_family(&font.family)?); + let mut data = read_fonts(&directory)?; + if data.is_empty() { + self.fetch(&font.family, &directory)?; + data = read_fonts(&directory)?; + } + if data.is_empty() { + bail!( + "Google Fonts returned no usable faces for '{}'", + font.family + ) + } + let database = Arc::new(select_face(font, data)?); + self.loaded + .lock() + .map_err(|_| anyhow::anyhow!("social font cache was poisoned"))? + .insert(font.clone(), database.clone()); + Ok(database) + } + + /// Downloads and validates the configured font family. + fn fetch(&self, family: &str, directory: &Path) -> Result<()> { + let encoded = utf8_percent_encode(family, NON_ALPHANUMERIC); + let url = + format!("https://fonts.google.com/download/list?family={encoded}"); + let mut response = match self.agent.get(&url).call() { + Ok(response) => response, + Err(ureq::Error::StatusCode(status)) => { + return Err(plugin_error(anyhow!( + "couldn't find font family '{family}' on Google Fonts \ + ({status})" + ))); + } + Err(error) => { + return Err(error).with_context(|| { + format!("failed to fetch font family '{family}'") + }); + } + }; + let manifest = response + .body_mut() + .with_config() + .limit(2 * 1024 * 1024) + .read_to_string() + .context("failed to read Google Fonts manifest")?; + let urls = FONT_URL + .captures_iter(&manifest) + .filter_map(|captures| captures.get(1).map(|value| value.as_str())) + .collect::>(); + if urls.is_empty() { + bail!("Google Fonts returned no downloadable faces for '{family}'") + } + fs::create_dir_all(directory)?; + for url in urls { + if !url.starts_with("https://fonts.gstatic.com/") { + bail!("Google Fonts returned an unexpected font URL") + } + let mut response = + self.agent.get(url).call().with_context(|| { + format!("failed to download font from {url}") + })?; + let data = response + .body_mut() + .with_config() + .limit(32 * 1024 * 1024) + .read_to_vec() + .with_context(|| format!("failed to read font from {url}"))?; + validate_font(&data).with_context(|| { + format!("invalid font downloaded from {url}") + })?; + let digest = format!("{:x}", Sha256::digest(&data)); + let extension = url.rsplit('.').next().unwrap_or("ttf"); + let target = directory.join(format!("{digest}.{extension}")); + let temporary = directory.join(format!(".{digest}.tmp")); + fs::write(&temporary, data)?; + fs::rename(temporary, target)?; + } + Ok(()) + } +} + +// ---------------------------------------------------------------------------- +// Functions +// ---------------------------------------------------------------------------- + +/// Returns normalized properties for a configured font face. +pub fn attributes(font: &Font) -> Attributes { + let style = font.style.to_ascii_lowercase(); + let compact = style.replace([' ', '-'], ""); + let weight = if compact.contains("thin") { + 100 + } else if compact.contains("extralight") { + 200 + } else if compact.contains("light") { + 300 + } else if compact.contains("medium") { + 500 + } else if compact.contains("semibold") { + 600 + } else if compact.contains("extrabold") { + 800 + } else if compact.contains("black") { + 900 + } else if compact.contains("bold") { + 700 + } else { + 400 + }; + let style = if style.contains("italic") { + "italic" + } else if style.contains("oblique") { + "oblique" + } else { + "normal" + }; + let variant = font.variant.to_ascii_lowercase().replace('-', " "); + let stretch = if variant.contains("ultra condensed") { + "ultra-condensed" + } else if variant.contains("extra condensed") { + "extra-condensed" + } else if variant.contains("semi condensed") { + "semi-condensed" + } else if variant.contains("condensed") { + "condensed" + } else if variant.contains("ultra expanded") { + "ultra-expanded" + } else if variant.contains("extra expanded") { + "extra-expanded" + } else if variant.contains("semi expanded") { + "semi-expanded" + } else if variant.contains("expanded") { + "expanded" + } else { + "normal" + }; + Attributes { weight, style, stretch } +} + +fn select_face(font: &Font, data: Vec>) -> Result { + let mut database = Database::new(); + for data in data { + database.load_font_data(data); + } + let attributes = attributes(font); + let families = [Family::Name(&font.family)]; + let query = Query { + families: &families, + weight: Weight(attributes.weight), + stretch: match attributes.stretch { + "ultra-condensed" => Stretch::UltraCondensed, + "extra-condensed" => Stretch::ExtraCondensed, + "semi-condensed" => Stretch::SemiCondensed, + "condensed" => Stretch::Condensed, + "semi-expanded" => Stretch::SemiExpanded, + "expanded" => Stretch::Expanded, + "extra-expanded" => Stretch::ExtraExpanded, + "ultra-expanded" => Stretch::UltraExpanded, + _ => Stretch::Normal, + }, + style: match attributes.style { + "italic" => Style::Italic, + "oblique" => Style::Oblique, + _ => Style::Normal, + }, + }; + // Material names cached faces using the font's family and subfamily, not + // its PostScript name. Preserve that lookup and its sorted-file fallback. + let faces = database + .faces() + .filter_map(|face| { + database + .with_face_data(face.id, |data, index| { + material_style_name(data, index, &font.family) + }) + .flatten() + .map(|name| (name, face.id)) + }) + .collect::>(); + let requested = if font.variant.is_empty() { + font.style.clone() + } else { + format!("{} {}", font.variant, font.style) + }; + let id = preferred_face(&requested, &faces) + .or_else(|| database.query(&query)) + .or_else(|| database.faces().next().map(|face| face.id)) + .with_context(|| { + format!("font family '{}' has no usable faces", font.family) + })?; + let data = database + .with_face_data(id, |data, _| data.to_vec()) + .context("selected font face has no data")?; + let mut selected = Database::new(); + selected.load_font_data(data); + Ok(selected) +} + +fn material_style_name( + data: &[u8], index: u32, family: &str, +) -> Option { + let font = skrifa::FontRef::from_index(data, index).ok()?; + let name = |id| { + font.localized_strings(StringId::new(id)) + .english_or_first() + .map(|value| value.to_string()) + }; + // FreeType (and Pillow) prefers typographic family/subfamily names when + // both are present, then falls back to the legacy family/style pair. + let (name, style) = + name(16).zip(name(17)).or_else(|| name(1).zip(name(2)))?; + Some( + format!("{} {style}", name.replace(family, "")) + .trim() + .into(), + ) +} + +fn preferred_face( + requested: &str, faces: &[(String, T)], +) -> Option { + let mut order = (0..faces.len()).collect::>(); + order.sort_by(|left, right| { + format!("{}.ttf", faces[*left].0) + .cmp(&format!("{}.ttf", faces[*right].0)) + }); + if let Some(index) = + order.iter().find(|&&index| faces[index].0 == requested) + { + return Some(faces[*index].1); + } + let mut fallback = *order.first()?; + for index in order.into_iter().skip(1) { + if faces[index].0.contains("Regular") + && faces[index].0.len() < faces[fallback].0.len() + { + fallback = index; + } + } + Some(faces[fallback].1) +} + +fn validate_font(data: &[u8]) -> Result<()> { + if data.starts_with(&[0x00, 0x01, 0x00, 0x00]) + || data.starts_with(b"OTTO") + || data.starts_with(b"ttcf") + || data.starts_with(b"true") + { + Ok(()) + } else { + bail!("unsupported font data") + } +} + +fn safe_family(family: &str) -> Result { + let family = family.trim(); + if family.is_empty() + || family == "." + || family == ".." + || family.contains(['/', '\\']) + { + bail!("invalid font family '{family}'") + } + Ok(family.into()) +} + +fn read_fonts(directory: &Path) -> Result>> { + if !directory.is_dir() { + return Ok(Vec::new()); + } + let mut entries = + fs::read_dir(directory)?.collect::, _>>()?; + entries.sort_by_key(std::fs::DirEntry::file_name); + entries + .into_iter() + .filter(|entry| { + entry + .path() + .extension() + .and_then(|value| value.to_str()) + .is_some_and(|value| matches!(value, "ttf" | "otf")) + }) + .map(|entry| { + let path = entry.path(); + let data = fs::read(&path)?; + validate_font(&data).with_context(|| { + format!("invalid font in cache at {}", path.display()) + })?; + Ok(data) + }) + .collect() +} + +// ---------------------------------------------------------------------------- +// Tests +// ---------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::{attributes, preferred_face, safe_family, validate_font}; + use crate::compat::mkdocs::plugin::social::layout::Font; + + #[test] + fn validates_font_cache_components() { + assert_eq!(safe_family("Roboto").unwrap(), "Roboto"); + assert!(safe_family("../font").is_err()); + assert!(safe_family("").is_err()); + } + + #[test] + fn validates_supported_font_headers() { + assert!(validate_font(&[0x00, 0x01, 0x00, 0x00]).is_ok()); + assert!(validate_font(b"OTTO").is_ok()); + assert!(validate_font(b"not a font").is_err()); + } + + #[test] + fn normalizes_font_attributes_without_substring_collisions() { + let font = Font { + family: "Roboto".into(), + variant: "Semi Expanded".into(), + style: "Extra Light Oblique".into(), + }; + let attributes = attributes(&font); + assert_eq!(attributes.weight, 200); + assert_eq!(attributes.style, "oblique"); + assert_eq!(attributes.stretch, "semi-expanded"); + } + + #[test] + fn selects_exact_face_or_materials_sorted_fallback() { + let faces = [ + ("Bold".into(), 0), + ("Black".into(), 1), + ("Regular".into(), 2), + ("Black Italic".into(), 3), + ]; + assert_eq!(preferred_face("Black Italic", &faces), Some(3)); + assert_eq!(preferred_face("Black Bold", &faces), Some(2)); + assert_eq!(preferred_face("Missing", &faces[..2]), Some(1)); + } +} diff --git a/crates/zensical/src/compat/mkdocs/plugin/social/layout.rs b/crates/zensical/src/compat/mkdocs/plugin/social/layout.rs new file mode 100644 index 0000000..8834172 --- /dev/null +++ b/crates/zensical/src/compat/mkdocs/plugin/social/layout.rs @@ -0,0 +1,541 @@ +// Copyright (c) 2025-2026 Zensical and contributors + +// SPDX-License-Identifier: MIT +// All contributions are certified under the DCO + +// 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 NON-INFRINGEMENT. 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. + +// ---------------------------------------------------------------------------- + +//! Social card layout model and YAML parser. + +use anyhow::{bail, Context, Result}; +use saphyr::{LoadableYamlNode, YamlOwned}; +use serde::Serialize; + +// ---------------------------------------------------------------------------- +// Constants +// ---------------------------------------------------------------------------- + +const ORIGINS: &[&str] = &[ + "start top", + "center top", + "end top", + "start center", + "center", + "end center", + "start bottom", + "center bottom", + "end bottom", + "start", + "end", +]; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Social card layout. +#[derive(Clone, Debug, Hash, Serialize)] +pub struct Layout { + /// Metadata property names and their template values. + pub tags: Vec<(String, String)>, + /// Final card dimensions in pixels. + pub size: Size, + /// Layers composited in declaration order. + pub layers: Vec, +} + +/// Pixel dimensions. +#[derive(Clone, Copy, Debug, Default, Hash, Serialize)] +pub struct Size { + /// Horizontal extent in pixels. + pub width: u32, + /// Vertical extent in pixels. + pub height: u32, +} + +/// Signed layer offset. +#[derive(Clone, Copy, Debug, Default, Hash, Serialize)] +pub struct Offset { + /// Horizontal displacement from the layer origin. + pub x: i32, + /// Vertical displacement from the layer origin. + pub y: i32, +} + +/// One composited card layer. +#[derive(Clone, Debug, Hash, Serialize)] +pub struct Layer { + /// Dimensions of this layer in pixels. + pub size: Size, + /// Displacement from its selected origin. + pub offset: Offset, + /// Anchor position within the card. + pub origin: String, + /// Background color and image settings. + pub background: Background, + /// Optional theme icon settings. + pub icon: Icon, + /// Text content and font settings. + pub typography: Typography, +} + +/// Layer background. +#[derive(Clone, Debug, Default, Hash, Serialize)] +pub struct Background { + /// Background color value. + pub color: String, + /// Optional path to a background image. + pub image: String, +} + +/// Layer icon. +#[derive(Clone, Debug, Default, Hash, Serialize)] +pub struct Icon { + /// Theme icon name. + pub value: String, + /// Color applied to the icon SVG. + pub color: String, +} + +/// Layer typography. +#[derive(Clone, Debug, Hash, Serialize)] +pub struct Typography { + /// Text or template content to render. + pub content: String, + /// Horizontal and vertical text alignment. + pub align: String, + /// Strategy for text exceeding the allowed line count. + pub overflow: String, + /// Text color value. + pub color: String, + /// Line count and spacing settings. + pub line: Line, + /// Font family and face settings. + pub font: Font, +} + +/// Typography line settings. +#[derive(Clone, Copy, Debug, Serialize)] +pub struct Line { + /// Maximum number of lines before overflow handling. + pub amount: usize, + /// Relative distance between text baselines. + pub height: f64, +} + +/// Typography font settings. +#[derive(Clone, Debug, Hash, PartialEq, Eq, Serialize)] +pub struct Font { + /// Font family name. + pub family: String, + /// Weight or width variant within the family. + pub variant: String, + /// Font style name, such as italic. + pub style: String, +} + +// ---------------------------------------------------------------------------- +// Trait implementations +// ---------------------------------------------------------------------------- + +impl std::hash::Hash for Line { + fn hash(&self, state: &mut H) { + self.amount.hash(state); + self.height.to_bits().hash(state); + } +} + +impl Default for Layer { + fn default() -> Self { + Self { + size: Size::default(), + offset: Offset::default(), + origin: "start top".into(), + background: Background::default(), + icon: Icon::default(), + typography: Typography::default(), + } + } +} + +impl Default for Typography { + fn default() -> Self { + Self { + content: String::new(), + align: "start top".into(), + overflow: "truncate".into(), + color: String::new(), + line: Line { amount: 1, height: 1.0 }, + font: Font { + family: "Roboto".into(), + variant: String::new(), + style: "Regular".into(), + }, + } + } +} + +// ---------------------------------------------------------------------------- +// Functions +// ---------------------------------------------------------------------------- + +/// Parses and validates one complete layout document. +pub fn parse(path: &str, source: &str) -> Result { + let documents = + YamlOwned::load_from_str(source.trim_start_matches('\u{feff}')) + .with_context(|| format!("error reading social layout '{path}'"))?; + if documents.len() != 1 { + bail!("social layout must contain exactly one YAML document [{path}]") + } + parse_layout(&documents[0]) + .with_context(|| format!("error reading social layout '{path}'")) +} + +fn parse_layout(node: &YamlOwned) -> Result { + let mut size = Size::default(); + let mut tags = Vec::new(); + let mut layers = Vec::new(); + for (key, value) in mapping(node, "layout")? { + match string(key, "layout option")? { + "definitions" => { + for definition in sequence(value, "definitions")? { + string(definition, "definition")?; + } + } + "tags" => tags = parse_tags(value)?, + "size" => size = parse_size(value, "size")?, + "layers" => { + layers = sequence(value, "layers")? + .iter() + .map(parse_layer) + .collect::>()?; + } + key => bail!("unknown layout option: {key}"), + } + } + if size.width == 0 || size.height == 0 { + bail!("layout width and height must be greater than zero") + } + for layer in &mut layers { + if layer.size.width == 0 { + layer.size.width = size.width; + } + if layer.size.height == 0 { + layer.size.height = size.height; + } + } + Ok(Layout { tags, size, layers }) +} + +fn parse_tags(node: &YamlOwned) -> Result> { + mapping(node, "tags")? + .into_iter() + .map(|(key, value)| { + Ok(( + string(key, "tag name")?.into(), + owned_string(value, "tag value")?, + )) + }) + .collect() +} + +fn parse_layer(node: &YamlOwned) -> Result { + let mut layer = Layer::default(); + for (key, value) in mapping(node, "layer")? { + match string(key, "layer option")? { + "size" => layer.size = parse_size(value, "layer.size")?, + "offset" => layer.offset = parse_offset(value)?, + "origin" => { + layer.origin = choice(value, "origin", ORIGINS)?; + } + "background" => layer.background = parse_background(value)?, + "icon" => layer.icon = parse_icon(value)?, + "typography" => layer.typography = parse_typography(value)?, + key => bail!("unknown layer option: {key}"), + } + } + Ok(layer) +} + +fn parse_size(node: &YamlOwned, name: &str) -> Result { + let mut size = Size::default(); + for (key, value) in mapping(node, name)? { + match string(key, "size option")? { + "width" => size.width = unsigned(value, "width")?, + "height" => size.height = unsigned(value, "height")?, + key => bail!("unknown size option: {key}"), + } + } + Ok(size) +} + +fn parse_offset(node: &YamlOwned) -> Result { + let mut offset = Offset::default(); + for (key, value) in mapping(node, "offset")? { + match string(key, "offset option")? { + "x" => offset.x = integer(value, "offset.x")?, + "y" => offset.y = integer(value, "offset.y")?, + key => bail!("unknown offset option: {key}"), + } + } + Ok(offset) +} + +fn parse_background(node: &YamlOwned) -> Result { + let mut background = Background::default(); + for (key, value) in mapping(node, "background")? { + match string(key, "background option")? { + "color" => background.color = owned_string(value, "color")?, + "image" => background.image = owned_string(value, "image")?, + key => bail!("unknown background option: {key}"), + } + } + Ok(background) +} + +fn parse_icon(node: &YamlOwned) -> Result { + let mut icon = Icon::default(); + for (key, value) in mapping(node, "icon")? { + match string(key, "icon option")? { + "value" => icon.value = owned_string(value, "value")?, + "color" => icon.color = owned_string(value, "color")?, + key => bail!("unknown icon option: {key}"), + } + } + Ok(icon) +} + +fn parse_typography(node: &YamlOwned) -> Result { + let mut typography = Typography::default(); + for (key, value) in mapping(node, "typography")? { + match string(key, "typography option")? { + "content" => { + typography.content = owned_string(value, "content")?; + } + "align" => { + typography.align = choice(value, "align", ORIGINS)?; + } + "overflow" => { + typography.overflow = + choice(value, "overflow", &["truncate", "shrink"])?; + } + "color" => { + typography.color = owned_string(value, "color")?; + } + "line" => typography.line = parse_line(value)?, + "font" => typography.font = parse_font(value)?, + key => bail!("unknown typography option: {key}"), + } + } + if typography.line.amount == 0 + || !typography.line.height.is_finite() + || typography.line.height <= 0.0 + { + bail!("typography line amount and height must be greater than zero") + } + Ok(typography) +} + +fn parse_line(node: &YamlOwned) -> Result { + let mut line = Typography::default().line; + for (key, value) in mapping(node, "line")? { + match string(key, "line option")? { + "amount" => line.amount = positive_usize(value, "line.amount")?, + "height" => line.height = number(value, "line.height")?, + key => bail!("unknown line option: {key}"), + } + } + Ok(line) +} + +fn parse_font(node: &YamlOwned) -> Result { + let mut font = Typography::default().font; + for (key, value) in mapping(node, "font")? { + match string(key, "font option")? { + "family" => font.family = owned_string(value, "family")?, + "variant" => font.variant = owned_string(value, "variant")?, + "style" => font.style = owned_string(value, "style")?, + key => bail!("unknown font option: {key}"), + } + } + Ok(font) +} + +fn mapping<'a>( + node: &'a YamlOwned, name: &str, +) -> Result> { + let values = node + .as_mapping() + .with_context(|| format!("{name} must be a mapping"))?; + let mut inherited = Vec::new(); + let mut explicit = Vec::new(); + for (key, value) in values { + if key.as_str() == Some("<<") { + merge(&mut inherited, value, name)?; + } else { + explicit.push((key, value)); + } + } + for (key, value) in explicit { + if let Some(entry) = inherited + .iter_mut() + .find(|(inherited, _)| inherited == &key) + { + *entry = (key, value); + } else { + inherited.push((key, value)); + } + } + Ok(inherited) +} + +fn merge<'a>( + target: &mut Vec<(&'a YamlOwned, &'a YamlOwned)>, node: &'a YamlOwned, + name: &str, +) -> Result<()> { + if let Some(values) = node.as_mapping() { + for entry in values { + if !target.iter().any(|(key, _)| key == &entry.0) { + target.push(entry); + } + } + return Ok(()); + } + if let Some(values) = node.as_sequence() { + for value in values { + merge(target, value, name)?; + } + return Ok(()); + } + bail!("{name} merge value must be a mapping or list of mappings") +} + +fn sequence<'a>(node: &'a YamlOwned, name: &str) -> Result<&'a [YamlOwned]> { + node.as_sequence() + .map(Vec::as_slice) + .with_context(|| format!("{name} must be a list")) +} + +fn string<'a>(node: &'a YamlOwned, name: &str) -> Result<&'a str> { + node.as_str() + .with_context(|| format!("{name} must be a string")) +} + +fn owned_string(node: &YamlOwned, name: &str) -> Result { + string(node, name).map(Into::into) +} + +fn integer(node: &YamlOwned, name: &str) -> Result { + node.as_integer() + .and_then(|value| i32::try_from(value).ok()) + .with_context(|| format!("{name} must be an integer")) +} + +fn unsigned(node: &YamlOwned, name: &str) -> Result { + node.as_integer() + .and_then(|value| u32::try_from(value).ok()) + .with_context(|| format!("{name} must be a non-negative integer")) +} + +fn number(node: &YamlOwned, name: &str) -> Result { + node.as_floating_point() + .or_else(|| { + node.as_integer() + .and_then(|value| value.to_string().parse().ok()) + }) + .with_context(|| format!("{name} must be a number")) +} + +fn positive_usize(node: &YamlOwned, name: &str) -> Result { + if let Some(value) = node.as_integer() { + return usize::try_from(value) + .ok() + .filter(|value| *value > 0) + .with_context(|| format!("{name} must be a positive integer")); + } + let value = node + .as_floating_point() + .with_context(|| format!("{name} must be a positive integer"))?; + if !value.is_finite() || value <= 0.0 || value.fract() != 0.0 { + bail!("{name} must be a positive integer") + } + value + .to_string() + .parse() + .with_context(|| format!("{name} must be a positive integer")) +} + +fn choice(node: &YamlOwned, name: &str, values: &[&str]) -> Result { + let value = string(node, name)?; + if values.contains(&value) { + Ok(value.into()) + } else { + bail!("invalid {name}: {value}") + } +} + +// ---------------------------------------------------------------------------- +// Tests +// ---------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::parse; + + #[test] + fn parses_anchors_and_defaults_layer_size() { + let layout = parse( + "custom.yml", + "definitions: [&value '#fff']\ntags: { og:title: title }\nsize: { width: 1200, height: 630 }\nlayers:\n - background: { color: *value }\n", + ) + .unwrap(); + assert_eq!(layout.layers[0].size.width, 1200); + assert_eq!(layout.layers[0].background.color, "#fff"); + } + + #[test] + fn expands_yaml_merge_keys() { + let layout = parse( + "custom.yml", + "size: { width: 1200, height: 630 }\nlayers:\n - &layer\n background: { color: '#fff' }\n - <<: *layer\n origin: center\n", + ) + .unwrap(); + assert_eq!(layout.layers[1].background.color, "#fff"); + assert_eq!(layout.layers[1].origin, "center"); + } + + #[test] + fn rejects_invalid_layouts() { + assert!(parse("bad.yml", "size: { width: 0, height: 2 }\n").is_err()); + assert!(parse("bad.yml", "size: { width: 2, height: 2 }\nwat: 1\n") + .is_err()); + assert!(parse( + "bad.yml", + "definitions: [{ bad: value }]\nsize: { width: 2, height: 2 }\n" + ) + .is_err()); + assert!(parse( + "bad.yml", + "size: { width: 2, height: 2 }\nlayers: [{ background: { color: null } }]\n" + ) + .is_err()); + } +} diff --git a/crates/zensical/src/compat/mkdocs/plugin/social/layouts/default.yml b/crates/zensical/src/compat/mkdocs/plugin/social/layouts/default.yml new file mode 100644 index 0000000..2913cd9 --- /dev/null +++ b/crates/zensical/src/compat/mkdocs/plugin/social/layouts/default.yml @@ -0,0 +1,244 @@ +# Copyright (c) 2016-2025 Martin Donath + +# 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 NON-INFRINGEMENT. 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. + +# ----------------------------------------------------------------------------- +# Configuration +# ----------------------------------------------------------------------------- + +# Definitions +definitions: + + # Background image + - &background_image >- + {{ layout.background_image | x }} + + # Background color (default: indigo) + - &background_color >- + {%- if layout.background_color -%} + {{ layout.background_color }} + {%- else -%} + {%- set palette = config.theme.palette or {} -%} + {%- if not palette is mapping -%} + {%- set list = palette | selectattr("primary") | list + palette -%} + {%- set palette = list | first -%} + {%- endif -%} + {%- set primary = palette.get("primary", "indigo") -%} + {%- set primary = primary.replace(" ", "-") -%} + {{ { + "red": "#ef5552", + "pink": "#e92063", + "purple": "#ab47bd", + "deep-purple": "#7e56c2", + "indigo": "#4051b5", + "blue": "#2094f3", + "light-blue": "#02a6f2", + "cyan": "#00bdd6", + "teal": "#009485", + "green": "#4cae4f", + "light-green": "#8bc34b", + "lime": "#cbdc38", + "yellow": "#ffec3d", + "amber": "#ffc105", + "orange": "#ffa724", + "deep-orange": "#ff6e42", + "brown": "#795649", + "grey": "#757575", + "blue-grey": "#546d78", + "black": "#000000", + "white": "#ffffff" + }[primary] or "#4051b5" }} + {%- endif -%} + + # Text color (default: white) + - &color >- + {%- if layout.color -%} + {{ layout.color }} + {%- else -%} + {%- set palette = config.theme.palette or {} -%} + {%- if not palette is mapping -%} + {%- set list = palette | selectattr("primary") | list + palette -%} + {%- set palette = list | first -%} + {%- endif -%} + {%- set primary = palette.get("primary", "indigo") -%} + {%- set primary = primary.replace(" ", "-") -%} + {{ { + "red": "#ffffff", + "pink": "#ffffff", + "purple": "#ffffff", + "deep-purple": "#ffffff", + "indigo": "#ffffff", + "blue": "#ffffff", + "light-blue": "#ffffff", + "cyan": "#ffffff", + "teal": "#ffffff", + "green": "#ffffff", + "light-green": "#ffffff", + "lime": "#000000", + "yellow": "#000000", + "amber": "#000000", + "orange": "#000000", + "deep-orange": "#ffffff", + "brown": "#ffffff", + "grey": "#ffffff", + "blue-grey": "#ffffff", + "black": "#ffffff", + "white": "#000000" + }[primary] or "#ffffff" }} + {%- endif -%} + + # Font family (default: Roboto) + - &font_family >- + {%- if layout.font_family -%} + {{ layout.font_family }} + {%- elif config.theme.font is mapping -%} + {{ config.theme.font.get("text", "Roboto") }} + {%- else -%} + Roboto + {%- endif -%} + + # Font variant + - &font_variant >- + {%- if layout.font_variant -%} + {{ layout.font_variant }} + {%- endif -%} + + # Site name + - &site_name >- + {{ config.site_name }} + + # Page title + - &page_title >- + {%- if layout.title -%} + {{ layout.title }} + {%- else -%} + {{ page.meta.get("title", page.title) }} + {%- endif -%} + + # Page title with site name + - &page_title_with_site_name >- + {%- if not page.is_homepage -%} + {{ page.meta.get("title", page.title) }} - {{ config.site_name }} + {%- else -%} + {{ config.site_name }} + {%- endif -%} + + # Page description + - &page_description >- + {%- if layout.description -%} + {{ layout.description }} + {%- else -%} + {{ page.meta.get("description", config.site_description) | x }} + {%- endif -%} + + # Logo + - &logo >- + {%- if layout.logo -%} + {{ layout.logo }} + {%- elif config.theme.logo -%} + {{ config.docs_dir }}/{{ config.theme.logo }} + {%- endif -%} + + # Logo (icon) + - &logo_icon >- + {%- if not layout.logo and config.theme.icon -%} + {{ config.theme.icon.logo | x }} + {%- endif -%} + +# Meta tags +tags: + + # Open Graph + og:type: website + og:title: *page_title_with_site_name + og:description: *page_description + og:image: "{{ image.url }}" + og:image:type: "{{ image.type }}" + og:image:width: "{{ image.width }}" + og:image:height: "{{ image.height }}" + og:url: "{{ page.canonical_url }}" + + # Twitter + twitter:card: summary_large_image + twitter:title: *page_title_with_site_name + twitter:description: *page_description + twitter:image: "{{ image.url }}" + +# ----------------------------------------------------------------------------- +# Specification +# ----------------------------------------------------------------------------- + +# Card size and layers +size: { width: 1200, height: 630 } +layers: + + # Background + - background: + image: *background_image + color: *background_color + + # Logo + - size: { width: 144, height: 144 } + offset: { x: 992, y: 64 } + background: + image: *logo + icon: + value: *logo_icon + color: *color + + # Site name + - size: { width: 832, height: 42 } + offset: { x: 64, y: 64 } + typography: + content: *site_name + color: *color + font: + family: *font_family + variant: *font_variant + style: Bold + + # Page title + - size: { width: 832, height: 310 } + offset: { x: 62, y: 160 } + typography: + content: *page_title + align: start + color: *color + line: + amount: 3 + height: 1.25 + font: + family: *font_family + variant: *font_variant + style: Bold + + # Page description + - size: { width: 832, height: 64 } + offset: { x: 64, y: 512 } + typography: + content: *page_description + align: start + color: *color + line: + amount: 2 + height: 1.5 + font: + family: *font_family + variant: *font_variant + style: Regular diff --git a/crates/zensical/src/compat/mkdocs/plugin/social/layouts/default/accent.yml b/crates/zensical/src/compat/mkdocs/plugin/social/layouts/default/accent.yml new file mode 100644 index 0000000..0a3b74c --- /dev/null +++ b/crates/zensical/src/compat/mkdocs/plugin/social/layouts/default/accent.yml @@ -0,0 +1,234 @@ +# Copyright (c) 2016-2025 Martin Donath + +# 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 NON-INFRINGEMENT. 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. + +# ----------------------------------------------------------------------------- +# Configuration +# ----------------------------------------------------------------------------- + +# Definitions +definitions: + + # Background image + - &background_image >- + {{ layout.background_image | x }} + + # Background color (default: indigo) + - &background_color >- + {%- if layout.background_color -%} + {{ layout.background_color }} + {%- else -%} + {%- set palette = config.theme.palette or {} -%} + {%- if not palette is mapping -%} + {%- set list = palette | selectattr("accent") | list + palette -%} + {%- set palette = list | first -%} + {%- endif -%} + {%- set accent = palette.get("accent", "indigo") -%} + {%- set accent = accent.replace(" ", "-") -%} + {{ { + "red": "#ff1a47", + "pink": "#f50056", + "purple": "#df41fb", + "deep-purple": "#7c4dff", + "indigo": "#526cfe", + "blue": "#4287ff", + "light-blue": "#0091eb", + "cyan": "#00bad6", + "teal": "#00bda4", + "green": "#00c753", + "light-green": "#63de17", + "lime": "#b0eb00", + "yellow": "#ffd500", + "amber": "#ffaa00", + "orange": "#ff9100", + "deep-orange": "#ff6e42" + }[accent] or "#4051b5" }} + {%- endif -%} + + # Text color (default: white) + - &color >- + {%- if layout.color -%} + {{ layout.color }} + {%- else -%} + {%- set palette = config.theme.palette or {} -%} + {%- if not palette is mapping -%} + {%- set list = palette | selectattr("accent") | list + palette -%} + {%- set palette = list | first -%} + {%- endif -%} + {%- set accent = palette.get("accent", "indigo") -%} + {%- set accent = accent.replace(" ", "-") -%} + {{ { + "red": "#ffffff", + "pink": "#ffffff", + "purple": "#ffffff", + "deep-purple": "#ffffff", + "indigo": "#ffffff", + "blue": "#ffffff", + "light-blue": "#ffffff", + "cyan": "#ffffff", + "teal": "#ffffff", + "green": "#ffffff", + "light-green": "#ffffff", + "lime": "#000000", + "yellow": "#000000", + "amber": "#000000", + "orange": "#000000", + "deep-orange": "#ffffff" + }[accent] or "#ffffff" }} + {%- endif -%} + + # Font family (default: Roboto) + - &font_family >- + {%- if layout.font_family -%} + {{ layout.font_family }} + {%- elif config.theme.font is mapping -%} + {{ config.theme.font.get("text", "Roboto") }} + {%- else -%} + Roboto + {%- endif -%} + + # Font variant + - &font_variant >- + {%- if layout.font_variant -%} + {{ layout.font_variant }} + {%- endif -%} + + # Site name + - &site_name >- + {{ config.site_name }} + + # Page title + - &page_title >- + {%- if layout.title -%} + {{ layout.title }} + {%- else -%} + {{ page.meta.get("title", page.title) }} + {%- endif -%} + + # Page title with site name + - &page_title_with_site_name >- + {%- if not page.is_homepage -%} + {{ page.meta.get("title", page.title) }} - {{ config.site_name }} + {%- else -%} + {{ config.site_name }} + {%- endif -%} + + # Page description + - &page_description >- + {%- if layout.description -%} + {{ layout.description }} + {%- else -%} + {{ page.meta.get("description", config.site_description) | x }} + {%- endif -%} + + # Logo + - &logo >- + {%- if layout.logo -%} + {{ layout.logo }} + {%- elif config.theme.logo -%} + {{ config.docs_dir }}/{{ config.theme.logo }} + {%- endif -%} + + # Logo (icon) + - &logo_icon >- + {%- if not layout.logo and config.theme.icon -%} + {{ config.theme.icon.logo | x }} + {%- endif -%} + +# Meta tags +tags: + + # Open Graph + og:type: website + og:title: *page_title_with_site_name + og:description: *page_description + og:image: "{{ image.url }}" + og:image:type: "{{ image.type }}" + og:image:width: "{{ image.width }}" + og:image:height: "{{ image.height }}" + og:url: "{{ page.canonical_url }}" + + # Twitter + twitter:card: summary_large_image + twitter:title: *page_title_with_site_name + twitter:description: *page_description + twitter:image: "{{ image.url }}" + +# ----------------------------------------------------------------------------- +# Specification +# ----------------------------------------------------------------------------- + +# Card size and layers +size: { width: 1200, height: 630 } +layers: + + # Background + - background: + image: *background_image + color: *background_color + + # Logo + - size: { width: 144, height: 144 } + offset: { x: 992, y: 64 } + background: + image: *logo + icon: + value: *logo_icon + color: *color + + # Site name + - size: { width: 832, height: 42 } + offset: { x: 64, y: 64 } + typography: + content: *site_name + color: *color + font: + family: *font_family + variant: *font_variant + style: Bold + + # Page title + - size: { width: 832, height: 310 } + offset: { x: 62, y: 160 } + typography: + content: *page_title + align: start + color: *color + line: + amount: 3 + height: 1.25 + font: + family: *font_family + variant: *font_variant + style: Bold + + # Page description + - size: { width: 832, height: 64 } + offset: { x: 64, y: 512 } + typography: + content: *page_description + align: start + color: *color + line: + amount: 2 + height: 1.5 + font: + family: *font_family + variant: *font_variant + style: Regular diff --git a/crates/zensical/src/compat/mkdocs/plugin/social/layouts/default/invert.yml b/crates/zensical/src/compat/mkdocs/plugin/social/layouts/default/invert.yml new file mode 100644 index 0000000..12d2af1 --- /dev/null +++ b/crates/zensical/src/compat/mkdocs/plugin/social/layouts/default/invert.yml @@ -0,0 +1,244 @@ +# Copyright (c) 2016-2025 Martin Donath + +# 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 NON-INFRINGEMENT. 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. + +# ----------------------------------------------------------------------------- +# Configuration +# ----------------------------------------------------------------------------- + +# Definitions +definitions: + + # Background image + - &background_image >- + {{ layout.background_image | x }} + + # Background color (default: white) + - &background_color >- + {%- if layout.background_color -%} + {{ layout.background_color }} + {%- else -%} + {%- set palette = config.theme.palette or {} -%} + {%- if not palette is mapping -%} + {%- set list = palette | selectattr("primary") | list + palette -%} + {%- set palette = list | first -%} + {%- endif -%} + {%- set primary = palette.get("primary", "indigo") -%} + {%- set primary = primary.replace(" ", "-") -%} + {{ { + "red": "#ffffff", + "pink": "#ffffff", + "purple": "#ffffff", + "deep-purple": "#ffffff", + "indigo": "#ffffff", + "blue": "#ffffff", + "light-blue": "#ffffff", + "cyan": "#ffffff", + "teal": "#ffffff", + "green": "#ffffff", + "light-green": "#ffffff", + "lime": "#000000", + "yellow": "#000000", + "amber": "#000000", + "orange": "#000000", + "deep-orange": "#ffffff", + "brown": "#ffffff", + "grey": "#ffffff", + "blue-grey": "#ffffff", + "black": "#ffffff", + "white": "#000000" + }[primary] or "#ffffff" }} + {%- endif -%} + + # Text color (default: indigo) + - &color >- + {%- if layout.color -%} + {{ layout.color }} + {%- else -%} + {%- set palette = config.theme.palette or {} -%} + {%- if not palette is mapping -%} + {%- set list = palette | selectattr("primary") | list + palette -%} + {%- set palette = list | first -%} + {%- endif -%} + {%- set primary = palette.get("primary", "indigo") -%} + {%- set primary = primary.replace(" ", "-") -%} + {{ { + "red": "#ef5552", + "pink": "#e92063", + "purple": "#ab47bd", + "deep-purple": "#7e56c2", + "indigo": "#4051b5", + "blue": "#2094f3", + "light-blue": "#02a6f2", + "cyan": "#00bdd6", + "teal": "#009485", + "green": "#4cae4f", + "light-green": "#8bc34b", + "lime": "#cbdc38", + "yellow": "#ffec3d", + "amber": "#ffc105", + "orange": "#ffa724", + "deep-orange": "#ff6e42", + "brown": "#795649", + "grey": "#757575", + "blue-grey": "#546d78", + "black": "#000000", + "white": "#ffffff" + }[primary] or "#4051b5" }} + {%- endif -%} + + # Font family (default: Roboto) + - &font_family >- + {%- if layout.font_family -%} + {{ layout.font_family }} + {%- elif config.theme.font is mapping -%} + {{ config.theme.font.get("text", "Roboto") }} + {%- else -%} + Roboto + {%- endif -%} + + # Font variant + - &font_variant >- + {%- if layout.font_variant -%} + {{ layout.font_variant }} + {%- endif -%} + + # Site name + - &site_name >- + {{ config.site_name }} + + # Page title + - &page_title >- + {%- if layout.title -%} + {{ layout.title }} + {%- else -%} + {{ page.meta.get("title", page.title) }} + {%- endif -%} + + # Page title with site name + - &page_title_with_site_name >- + {%- if not page.is_homepage -%} + {{ page.meta.get("title", page.title) }} - {{ config.site_name }} + {%- else -%} + {{ config.site_name }} + {%- endif -%} + + # Page description + - &page_description >- + {%- if layout.description -%} + {{ layout.description }} + {%- else -%} + {{ page.meta.get("description", config.site_description) | x }} + {%- endif -%} + + # Logo + - &logo >- + {%- if layout.logo -%} + {{ layout.logo }} + {%- elif config.theme.logo -%} + {{ config.docs_dir }}/{{ config.theme.logo }} + {%- endif -%} + + # Logo (icon) + - &logo_icon >- + {%- if not layout.logo and config.theme.icon -%} + {{ config.theme.icon.logo | x }} + {%- endif -%} + +# Meta tags +tags: + + # Open Graph + og:type: website + og:title: *page_title_with_site_name + og:description: *page_description + og:image: "{{ image.url }}" + og:image:type: "{{ image.type }}" + og:image:width: "{{ image.width }}" + og:image:height: "{{ image.height }}" + og:url: "{{ page.canonical_url }}" + + # Twitter + twitter:card: summary_large_image + twitter:title: *page_title_with_site_name + twitter:description: *page_description + twitter:image: "{{ image.url }}" + +# ----------------------------------------------------------------------------- +# Specification +# ----------------------------------------------------------------------------- + +# Card size and layers +size: { width: 1200, height: 630 } +layers: + + # Background + - background: + image: *background_image + color: *background_color + + # Logo + - size: { width: 144, height: 144 } + offset: { x: 992, y: 64 } + background: + image: *logo + icon: + value: *logo_icon + color: *color + + # Site name + - size: { width: 832, height: 42 } + offset: { x: 64, y: 64 } + typography: + content: *site_name + color: *color + font: + family: *font_family + variant: *font_variant + style: Bold + + # Page title + - size: { width: 832, height: 310 } + offset: { x: 62, y: 160 } + typography: + content: *page_title + align: start + color: *color + line: + amount: 3 + height: 1.25 + font: + family: *font_family + variant: *font_variant + style: Bold + + # Page description + - size: { width: 832, height: 64 } + offset: { x: 64, y: 512 } + typography: + content: *page_description + align: start + color: *color + line: + amount: 2 + height: 1.5 + font: + family: *font_family + variant: *font_variant + style: Regular diff --git a/crates/zensical/src/compat/mkdocs/plugin/social/layouts/default/only/image.yml b/crates/zensical/src/compat/mkdocs/plugin/social/layouts/default/only/image.yml new file mode 100644 index 0000000..4d0d3cb --- /dev/null +++ b/crates/zensical/src/compat/mkdocs/plugin/social/layouts/default/only/image.yml @@ -0,0 +1,77 @@ +# Copyright (c) 2016-2025 Martin Donath + +# 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 NON-INFRINGEMENT. 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. + +# ----------------------------------------------------------------------------- +# Configuration +# ----------------------------------------------------------------------------- + +# Definitions +definitions: + + # Background image + - &background_image >- + {{ layout.background_image }} + + # Page title with site name + - &page_title_with_site_name >- + {%- if not page.is_homepage -%} + {{ page.meta.get("title", page.title) }} - {{ config.site_name }} + {%- else -%} + {{ page.meta.get("title", page.title) }} + {%- endif -%} + + # Page description + - &page_description >- + {%- if layout.description -%} + {{ layout.description }} + {%- else -%} + {{ page.meta.get("description", config.site_description) | x }} + {%- endif -%} + +# Meta tags +tags: + + # Open Graph + og:type: website + og:title: *page_title_with_site_name + og:description: *page_description + og:image: "{{ image.url }}" + og:image:type: "{{ image.type }}" + og:image:width: "{{ image.width }}" + og:image:height: "{{ image.height }}" + og:url: "{{ page.canonical_url }}" + + # Twitter + twitter:card: summary_large_image + twitter:title: *page_title_with_site_name + twitter:description: *page_description + twitter:image: "{{ image.url }}" + +# ----------------------------------------------------------------------------- +# Specification +# ----------------------------------------------------------------------------- + +# Card size and layers +size: { width: 1200, height: 630 } +layers: + + # Background + - background: + image: *background_image diff --git a/crates/zensical/src/compat/mkdocs/plugin/social/layouts/default/variant.yml b/crates/zensical/src/compat/mkdocs/plugin/social/layouts/default/variant.yml new file mode 100644 index 0000000..3721acd --- /dev/null +++ b/crates/zensical/src/compat/mkdocs/plugin/social/layouts/default/variant.yml @@ -0,0 +1,255 @@ +# Copyright (c) 2016-2025 Martin Donath + +# 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 NON-INFRINGEMENT. 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. + +# ----------------------------------------------------------------------------- +# Configuration +# ----------------------------------------------------------------------------- + +# Definitions +definitions: + + # Background image + - &background_image >- + {{ layout.background_image | x }} + + # Background color (default: indigo) + - &background_color >- + {%- if layout.background_color -%} + {{ layout.background_color }} + {%- else -%} + {%- set palette = config.theme.palette or {} -%} + {%- if not palette is mapping -%} + {%- set list = palette | selectattr("primary") | list + palette -%} + {%- set palette = list | first -%} + {%- endif -%} + {%- set primary = palette.get("primary", "indigo") -%} + {%- set primary = primary.replace(" ", "-") -%} + {{ { + "red": "#ef5552", + "pink": "#e92063", + "purple": "#ab47bd", + "deep-purple": "#7e56c2", + "indigo": "#4051b5", + "blue": "#2094f3", + "light-blue": "#02a6f2", + "cyan": "#00bdd6", + "teal": "#009485", + "green": "#4cae4f", + "light-green": "#8bc34b", + "lime": "#cbdc38", + "yellow": "#ffec3d", + "amber": "#ffc105", + "orange": "#ffa724", + "deep-orange": "#ff6e42", + "brown": "#795649", + "grey": "#757575", + "blue-grey": "#546d78", + "black": "#000000", + "white": "#ffffff" + }[primary] or "#4051b5" }} + {%- endif -%} + + # Text color (default: white) + - &color >- + {%- if layout.color -%} + {{ layout.color }} + {%- else -%} + {%- set palette = config.theme.palette or {} -%} + {%- if not palette is mapping -%} + {%- set list = palette | selectattr("primary") | list + palette -%} + {%- set palette = list | first -%} + {%- endif -%} + {%- set primary = palette.get("primary", "indigo") -%} + {%- set primary = primary.replace(" ", "-") -%} + {{ { + "red": "#ffffff", + "pink": "#ffffff", + "purple": "#ffffff", + "deep-purple": "#ffffff", + "indigo": "#ffffff", + "blue": "#ffffff", + "light-blue": "#ffffff", + "cyan": "#ffffff", + "teal": "#ffffff", + "green": "#ffffff", + "light-green": "#ffffff", + "lime": "#000000", + "yellow": "#000000", + "amber": "#000000", + "orange": "#000000", + "deep-orange": "#ffffff", + "brown": "#ffffff", + "grey": "#ffffff", + "blue-grey": "#ffffff", + "black": "#ffffff", + "white": "#000000" + }[primary] or "#ffffff" }} + {%- endif -%} + + # Font family (default: Roboto) + - &font_family >- + {%- if layout.font_family -%} + {{ layout.font_family }} + {%- elif config.theme.font is mapping -%} + {{ config.theme.font.get("text", "Roboto") }} + {%- else -%} + Roboto + {%- endif -%} + + # Font variant + - &font_variant >- + {%- if layout.font_variant -%} + {{ layout.font_variant }} + {%- endif -%} + + # Site name + - &site_name >- + {{ config.site_name }} + + # Page title + - &page_title >- + {%- if layout.title -%} + {{ layout.title }} + {%- else -%} + {{ page.meta.get("title", page.title) }} + {%- endif -%} + + # Page title with site name + - &page_title_with_site_name >- + {%- if not page.is_homepage -%} + {{ page.meta.get("title", page.title) }} - {{ config.site_name }} + {%- else -%} + {{ config.site_name }} + {%- endif -%} + + # Page description + - &page_description >- + {%- if layout.description -%} + {{ layout.description }} + {%- else -%} + {{ page.meta.get("description", config.site_description) | x }} + {%- endif -%} + + # Page icon + - &page_icon >- + {{ page.meta.icon | x }} + + # Logo + - &logo >- + {%- if layout.logo -%} + {{ layout.logo }} + {%- elif config.theme.logo -%} + {{ config.docs_dir }}/{{ config.theme.logo }} + {%- endif -%} + + # Logo (icon) + - &logo_icon >- + {%- if not layout.logo and config.theme.icon -%} + {{ config.theme.icon.logo | x }} + {%- endif -%} + +# Meta tags +tags: + + # Open Graph + og:type: website + og:title: *page_title_with_site_name + og:description: *page_description + og:image: "{{ image.url }}" + og:image:type: "{{ image.type }}" + og:image:width: "{{ image.width }}" + og:image:height: "{{ image.height }}" + og:url: "{{ page.canonical_url }}" + + # Twitter + twitter:card: summary_large_image + twitter:title: *page_title_with_site_name + twitter:description: *page_description + twitter:image: "{{ image.url }}" + +# ----------------------------------------------------------------------------- +# Specification +# ----------------------------------------------------------------------------- + +# Card size and layers +size: { width: 1200, height: 630 } +layers: + + # Background + - background: + image: *background_image + color: *background_color + + # Page icon + - size: { width: 630, height: 630 } + offset: { x: 800, y: 0 } + icon: + value: *page_icon + color: "#00000033" + + # Logo + - size: { width: 64, height: 64 } + offset: { x: 64, y: 64 } + background: + image: *logo + icon: + value: *logo_icon + color: *color + + # Site name + - size: { width: 768, height: 42 } + offset: { x: 160, y: 74 } + typography: + content: *site_name + color: *color + font: + family: *font_family + variant: *font_variant + style: Bold + + # Page title + - size: { width: 864, height: 256 } + offset: { x: 62, y: 192 } + typography: + content: *page_title + align: start + color: *color + line: + amount: 3 + height: 1.25 + font: + family: *font_family + variant: *font_variant + style: Bold + + # Page description + - size: { width: 864, height: 64 } + offset: { x: 64, y: 512 } + typography: + content: *page_description + align: start + color: *color + line: + amount: 2 + height: 1.5 + font: + family: *font_family + variant: *font_variant + style: Regular diff --git a/crates/zensical/src/compat/mkdocs/plugin/social/render.rs b/crates/zensical/src/compat/mkdocs/plugin/social/render.rs new file mode 100644 index 0000000..e255339 --- /dev/null +++ b/crates/zensical/src/compat/mkdocs/plugin/social/render.rs @@ -0,0 +1,953 @@ +// Copyright (c) 2025-2026 Zensical and contributors + +// SPDX-License-Identifier: MIT +// All contributions are certified under the DCO + +// 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 NON-INFRINGEMENT. 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. + +// ---------------------------------------------------------------------------- + +//! Social card template and image rendering. + +use anyhow::{anyhow, bail, Context, Result}; +use base64::Engine as _; +use minijinja::{context, AutoEscape, Environment, Value}; +use resvg::tiny_skia::{Pixmap, PixmapPaint, Transform}; +use resvg::usvg; +use serde::Serialize; +use sha2::{Digest, Sha256}; +use skrifa::{ + instance::{LocationRef, Size}, + MetadataProvider, +}; +use std::collections::{BTreeMap, HashMap}; +use std::fmt::Write as _; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use crate::config::Project; +use crate::structure::dynamic::Dynamic; +use crate::structure::page::Page; + +use super::font::{attributes, Fonts}; +use super::layout::{Font, Layer, Layout, Typography}; +use super::plugin_error; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Rendered social metadata tag. +#[derive(Clone, Debug, Hash, PartialEq, Eq)] +pub struct Tag { + /// HTML meta property name. + pub property: String, + /// Rendered HTML meta content. + pub content: String, +} + +/// Immutable renderer shared by concurrent card jobs. +#[derive(Clone, Debug)] +pub struct Renderer { + /// Project settings used by card templates and asset resolution. + project: std::sync::Arc, + /// Theme directories searched for icons. + theme_dirs: std::sync::Arc<[PathBuf]>, + /// Resolver for selected font faces. + fonts: Fonts, + /// Known revisions of untracked image and icon dependencies. + dependencies: Arc>>, +} + +/// Generated image properties exposed to metadata templates. +#[derive(Serialize)] +struct ImageContext<'a> { + /// Absolute URL of the generated card. + url: &'a str, + /// Media type of the generated card. + #[serde(rename = "type")] + kind: &'static str, + /// Card width in pixels. + width: u32, + /// Card height in pixels. + height: u32, +} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl Renderer { + /// Creates one renderer for a plugin instance. + pub fn new( + project: std::sync::Arc, theme_dirs: Vec, + cache: PathBuf, + ) -> Self { + Self { + project, + theme_dirs: theme_dirs.into(), + fonts: Fonts::new(cache), + dependencies: Arc::default(), + } + } + + /// Resolves all page- and configuration-dependent layer templates. + pub fn prepare( + &self, layout: &Layout, page: &Page, + options: &BTreeMap, + ) -> Result { + let mut layout = layout.clone(); + let environment = environment(); + let context = template_context(&self.project, page, options, None)?; + for layer in &mut layout.layers { + render_layer_templates(layer, &environment, &context)?; + validate_layer(layer)?; + } + Ok(layout) + } + + /// Hashes physical images and icons referenced by a prepared layout. + pub fn dependency_revision( + &self, layout: &Layout, known: &BTreeMap, + ) -> Result<[u8; 32]> { + let mut digest = Sha256::new(); + for layer in &layout.layers { + if !layer.background.image.is_empty() { + let path = self.background_path(&layer.background.image); + if !path.is_file() { + return Err(plugin_error(anyhow!( + "couldn't find image '{}'", + path.display() + ))); + } + digest.update(b"background\0"); + digest.update(self.file_revision(&path, known)?); + } + if !layer.icon.value.is_empty() { + let path = self.icon_path(&layer.icon.value)?; + digest.update(b"icon\0"); + digest.update(self.file_revision(&path, known)?); + } + } + Ok(digest.finalize().into()) + } + + /// Rasterizes one fully prepared card layout. + pub fn card( + &self, layout: &Layout, debug: Option<(&str, bool, usize)>, + ) -> Result> { + let mut card = Pixmap::new(layout.size.width, layout.size.height) + .context("social card dimensions are too large")?; + for layer in &layout.layers { + let image = self.layer(layer)?; + let (x, y) = offset(layer, layout.size.width, layout.size.height); + card.draw_pixmap( + x, + y, + image.as_ref(), + &PixmapPaint::default(), + Transform::identity(), + None, + ); + } + if let Some((color, grid, step)) = debug { + let fonts = self.fonts.load(&Font { + family: "Roboto".into(), + variant: String::new(), + style: "Regular".into(), + })?; + let svg = debug_svg(layout, color, grid, step, Some(&fonts))?; + let overlay = render_svg( + &svg, + layout.size.width, + layout.size.height, + Some(&fonts), + )?; + card.draw_pixmap( + 0, + 0, + overlay.as_ref(), + &PixmapPaint::default(), + Transform::identity(), + None, + ); + } + card.encode_png().context("failed to encode social card") + } + + /// Renders the layout-provided Open Graph and Twitter tags. + pub fn tags( + &self, layout: &Layout, page: &Page, + options: &BTreeMap, image_url: &str, + ) -> Result> { + let image = ImageContext { + url: image_url, + kind: "image/png", + width: layout.size.width, + height: layout.size.height, + }; + let environment = environment(); + let template_data = + template_context(&self.project, page, options, Some(image))?; + layout + .tags + .iter() + .filter_map(|(property, source)| { + let rendered = + render_template(&environment, source, &template_data); + match rendered { + Ok(rendered) if rendered.is_empty() => None, + Ok(rendered) => Some(Ok(Tag { + property: property.clone(), + content: rendered, + })), + Err(error) => Some(Err(error)), + } + }) + .collect() + } + + /// Rasterizes one layer with its image, icon, and typography. + fn layer(&self, layer: &Layer) -> Result { + let fonts = (!layer.typography.content.is_empty()) + .then(|| self.fonts.load(&layer.typography.font)) + .transpose()?; + let background = if layer.background.image.is_empty() { + None + } else { + Some(self.background(&layer.background.image)?) + }; + let icon = if layer.icon.value.is_empty() { + None + } else { + Some(self.icon(&layer.icon.value, &layer.icon.color)?) + }; + let svg = layer_svg( + layer, + background.as_deref(), + icon.as_deref(), + fonts.as_ref(), + )?; + render_svg(&svg, layer.size.width, layer.size.height, fonts.as_ref()) + } + + /// Encodes a background image as a data URL. + fn background(&self, value: &str) -> Result { + let path = self.background_path(value); + data_url(&path).with_context(|| { + format!("couldn't find image '{}'", path.display()) + }) + } + + /// Resolves a background image relative to the project root. + fn background_path(&self, value: &str) -> PathBuf { + let path = Path::new(value); + if path.is_absolute() { + path.to_owned() + } else { + self.project.root_dir.join(path) + } + } + + /// Encodes a theme icon as a colorized SVG data URL. + fn icon(&self, name: &str, color: &str) -> Result { + let mut data = self.icon_source(name)?; + if !color.is_empty() { + data = colorize_icon(&data, color); + } + Ok(format!( + "data:image/svg+xml;base64,{}", + base64::engine::general_purpose::STANDARD.encode(data) + )) + } + + /// Reads an icon from the selected theme directory. + fn icon_source(&self, name: &str) -> Result { + let path = self.icon_path(name)?; + Ok(fs::read_to_string(path)?) + } + + /// Finds a named icon in the available theme directories. + fn icon_path(&self, name: &str) -> Result { + for base in self.theme_dirs.iter() { + let path = base.join(".icons").join(format!("{name}.svg")); + match fs::metadata(&path) { + Ok(metadata) if metadata.is_file() => return Ok(path), + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + } + Err(plugin_error(anyhow!("couldn't find icon '{name}'"))) + } + + /// Returns a watched or locally cached file revision. + fn file_revision( + &self, path: &Path, known: &BTreeMap, + ) -> Result<[u8; 32]> { + if let Some(revision) = known.get(path) { + return Ok(*revision); + } + let mut dependencies = self.dependencies.lock().map_err(|_| { + anyhow!("social dependency cache lock was poisoned") + })?; + if let Some(revision) = dependencies.get(path) { + return Ok(*revision); + } + let revision = Sha256::digest(fs::read(path)?).into(); + dependencies.insert(path.to_owned(), revision); + Ok(revision) + } +} + +// ---------------------------------------------------------------------------- +// Functions +// ---------------------------------------------------------------------------- + +fn template_context( + project: &Project, page: &Page, options: &BTreeMap, + image: Option>, +) -> Result { + let is_homepage = page.source().parent().is_none() + && matches!(page.source().file_name(), "index.md" | "README.md"); + let mut page_value = serde_json::to_value(page)?; + let page_map = page_value + .as_object_mut() + .context("serialized page must be a mapping")?; + page_map.insert("is_homepage".into(), serde_json::Value::Bool(is_homepage)); + let mut config = serde_json::to_value(project)?; + if !project.theme.font_explicit { + config["theme"]["font"] = serde_json::Value::Null; + } + if !project.theme.icon.logo_explicit { + config["theme"]["icon"]["logo"] = serde_json::Value::Null; + } + Ok(context! { + config => config, + page => page_value, + layout => options, + image => image, + }) +} + +fn environment() -> Environment<'static> { + let mut environment = Environment::new(); + environment.set_auto_escape_callback(|_| AutoEscape::None); + environment.set_unknown_method_callback( + minijinja_contrib::pycompat::unknown_method_callback, + ); + environment.add_filter("x", |value: Value| { + if value.is_undefined() || value.is_none() || !value.is_true() { + Value::from("") + } else { + value + } + }); + environment +} + +fn render_template( + environment: &Environment<'_>, source: &str, context: &Value, +) -> Result { + let source = html_escape::decode_html_entities(source); + environment + .render_str(&source, context) + .map(|value| value.trim().to_owned()) + .context("failed to render social layout expression") +} + +fn render_layer_templates( + layer: &mut Layer, environment: &Environment<'_>, context: &Value, +) -> Result<()> { + layer.origin = render_template(environment, &layer.origin, context)?; + layer.background.color = + render_template(environment, &layer.background.color, context)?; + layer.background.image = + render_template(environment, &layer.background.image, context)?; + layer.icon.value = + render_template(environment, &layer.icon.value, context)?; + layer.icon.color = + render_template(environment, &layer.icon.color, context)?; + layer.typography.content = + render_template(environment, &layer.typography.content, context)?; + layer.typography.align = + render_template(environment, &layer.typography.align, context)?; + layer.typography.overflow = + render_template(environment, &layer.typography.overflow, context)?; + layer.typography.color = + render_template(environment, &layer.typography.color, context)?; + layer.typography.font.family = + render_template(environment, &layer.typography.font.family, context)?; + layer.typography.font.variant = + render_template(environment, &layer.typography.font.variant, context)?; + layer.typography.font.style = + render_template(environment, &layer.typography.font.style, context)?; + Ok(()) +} + +fn validate_layer(layer: &Layer) -> Result<()> { + if !layer.background.color.is_empty() { + parse_color(&layer.background.color, "background color")?; + } + if !layer.icon.color.is_empty() { + parse_color(&layer.icon.color, "icon color")?; + } + if !layer.typography.content.is_empty() { + parse_color(&layer.typography.color, "typography color")?; + } + Ok(()) +} + +fn parse_color(value: &str, name: &str) -> Result { + value + .parse() + .with_context(|| format!("invalid social card {name}: {value}")) +} + +fn layer_svg( + layer: &Layer, background: Option<&str>, icon: Option<&str>, + fonts: Option<&Arc>, +) -> Result { + let mut body = String::new(); + if let Some(background) = background { + write!( + body, + "", + layer.size.width, + layer.size.height, + xml_attribute(background), + ) + .expect("writing to a string cannot fail"); + } + if !layer.background.color.is_empty() + && layer.background.color != "transparent" + { + write!( + body, + "", + xml_attribute(&layer.background.color), + ) + .expect("writing to a string cannot fail"); + } + if let Some(icon) = icon { + write!( + body, + "", + layer.size.width, + layer.size.height, + xml_attribute(icon), + ) + .expect("writing to a string cannot fail"); + } + if !layer.typography.content.is_empty() { + body.push_str(&typography_svg(&layer.typography, layer, fonts)?); + } + Ok(format!( + "{body}", + layer.size.width, layer.size.height, layer.size.width, layer.size.height, + )) +} + +fn typography_svg( + typography: &Typography, layer: &Layer, + fonts: Option<&Arc>, +) -> Result { + let mut allowed = typography.line.amount; + let mut size = + font_size(layer.size.height, allowed, typography.line.height, fonts); + let mut lines = wrap( + &typography.content, + f64::from(layer.size.width), + size, + &typography.font, + fonts, + )?; + if typography.overflow == "shrink" { + while lines.len() > allowed { + allowed += 1; + size = font_size( + layer.size.height, + allowed, + typography.line.height, + fonts, + ); + lines = wrap( + &typography.content, + f64::from(layer.size.width), + size, + &typography.font, + fonts, + )?; + } + balance_two_lines(&mut lines); + } else if lines.len() > allowed { + lines.truncate(allowed); + let last = lines.last_mut().expect("at least one line"); + let appended = format!("{last} ..."); + if measure(&appended, size, &typography.font, fonts)? + <= f64::from(layer.size.width) + { + *last = appended; + } else { + while let Some((head, _)) = last.rsplit_once(' ') { + *last = head.into(); + if measure( + &format!("{last} ..."), + size, + &typography.font, + fonts, + )? <= f64::from(layer.size.width) + { + break; + } + } + *last = if last.is_empty() { + "...".into() + } else { + format!("{last} ...") + }; + } + } else { + balance_two_lines(&mut lines); + } + + let (ascender, _) = font_metrics(fonts); + let line_height = size * ascender / 1000.0 * typography.line.height; + let additional = u32::try_from(lines.len().saturating_sub(1)) + .map_or(f64::from(u32::MAX), f64::from); + let height = size + line_height * additional; + let (anchor, x) = + horizontal(&typography.align, f64::from(layer.size.width)); + let y = vertical( + &typography.align, + f64::from(layer.size.height), + height, + size, + ); + let attributes = font_attributes(&typography.font); + let mut spans = String::new(); + for (index, line) in lines.iter().enumerate() { + let dy = if index == 0 { 0.0 } else { line_height }; + write!( + spans, + "{}", + xml_text(line), + ) + .expect("writing to a string cannot fail"); + } + Ok(format!( + "{spans}", + xml_attribute(&typography.color), + xml_attribute(&typography.font.family), + )) +} + +fn wrap( + content: &str, width: f64, size: f64, font: &Font, + fonts: Option<&Arc>, +) -> Result> { + let content = html_escape::decode_html_entities(content); + let words = content.split_whitespace().collect::>(); + if words.is_empty() { + return Ok(vec![String::new()]); + } + let mut lines = vec![String::new()]; + for word in words { + let line = lines.last_mut().expect("one line exists"); + let candidate = if line.is_empty() { + word.into() + } else { + format!("{line} {word}") + }; + if !line.is_empty() && measure(&candidate, size, font, fonts)? > width { + lines.push(word.into()); + } else { + *line = candidate; + } + } + Ok(lines) +} + +fn measure( + text: &str, size: f64, font: &Font, + fonts: Option<&Arc>, +) -> Result { + let svg = format!( + "{}", + xml_attribute(&font.family), + font_attributes(font), + xml_text(text), + ); + let tree = parse_svg(&svg, fonts)?; + Ok(f64::from(tree.root().abs_layer_bounding_box().width())) +} + +fn parse_svg( + source: &str, fonts: Option<&Arc>, +) -> Result { + let mut options = usvg::Options::default(); + if let Some(fonts) = fonts { + options.fontdb = Arc::clone(fonts); + } + usvg::Tree::from_data(source.as_bytes(), &options) + .context("failed to parse generated social card SVG") +} + +fn render_svg( + source: &str, width: u32, height: u32, + fonts: Option<&Arc>, +) -> Result { + let tree = parse_svg(source, fonts)?; + let mut pixmap = + Pixmap::new(width, height).context("social layer is too large")?; + resvg::render(&tree, Transform::identity(), &mut pixmap.as_mut()); + Ok(pixmap) +} + +fn font_size( + height: u32, lines: usize, line_height: f64, + fonts: Option<&Arc>, +) -> f64 { + let lines = u32::try_from(lines).map_or(f64::from(u32::MAX), f64::from); + let (ascender, descender) = font_metrics(fonts); + let extent = lines * ascender + + descender + + (lines - 1.0) * (line_height - 1.0) * ascender; + (1000.0 * f64::from(height) / extent).floor() +} + +fn font_metrics(fonts: Option<&Arc>) -> (f64, f64) { + fonts + .and_then(|database| database.faces().next().map(|face| face.id)) + .and_then(|id| { + fonts?.with_face_data(id, |data, index| { + let font = skrifa::FontRef::from_index(data, index).ok()?; + let metrics = + font.metrics(Size::new(1000.0), LocationRef::default()); + Some((f64::from(metrics.ascent), -f64::from(metrics.descent))) + })? + }) + .unwrap_or((1000.0, 250.0)) +} + +fn font_attributes(font: &Font) -> String { + let attributes = attributes(font); + format!( + "font-weight=\"{}\" font-style=\"{}\" font-stretch=\"{}\"", + attributes.weight, attributes.style, attributes.stretch, + ) +} + +fn balance_two_lines(lines: &mut [String]) { + if lines.len() != 2 { + return; + } + let Some((head, word)) = lines[0].rsplit_once(' ') else { + return; + }; + let before = lines[0].len().abs_diff(lines[1].len()); + let second = format!("{word} {}", lines[1]); + let after = head.len().abs_diff(second.len()); + if after < before { + lines[0] = head.into(); + lines[1] = second; + } +} + +fn horizontal(align: &str, width: f64) -> (&'static str, f64) { + let words = align.split_whitespace().collect::>(); + if words.contains(&"start") { + ("start", 0.0) + } else if words.contains(&"end") { + ("end", width) + } else if words.contains(&"center") { + ("middle", width / 2.0) + } else { + ("start", 0.0) + } +} + +fn vertical(align: &str, height: f64, text_height: f64, size: f64) -> f64 { + let words = align.split_whitespace().collect::>(); + // Pillow's ascender, middle, and descender anchors have different glyph + // insets from SVG's hanging baseline. Resolve the vertical axis once so + // horizontal "center" never overrides an explicit "top" or "bottom". + if words.contains(&"top") { + size * 0.2 + } else if words.contains(&"bottom") { + height - text_height - size * 0.05 + } else if words.contains(&"center") { + (height - text_height) / 2.0 + size * 0.1 + } else { + size * 0.2 + } +} + +fn offset(layer: &Layer, width: u32, height: u32) -> (i32, i32) { + let words = layer.origin.split_whitespace().collect::>(); + let mut x = i64::from(layer.offset.x); + let mut y = i64::from(layer.offset.y); + if !words.contains(&"start") { + if words.contains(&"end") { + x = i64::from(width) - i64::from(layer.size.width) - x; + } else if words.contains(&"center") { + x += (i64::from(width) - i64::from(layer.size.width)) / 2; + } + } + if !words.contains(&"top") { + if words.contains(&"bottom") { + y = i64::from(height) - i64::from(layer.size.height) - y; + } else if words.contains(&"center") { + y += (i64::from(height) - i64::from(layer.size.height)) / 2; + } + } + (clamp_coordinate(x), clamp_coordinate(y)) +} + +fn clamp_coordinate(value: i64) -> i32 { + i32::try_from(value).unwrap_or_else(|_| { + if value.is_negative() { + i32::MIN + } else { + i32::MAX + } + }) +} + +fn data_url(path: &Path) -> Result { + let data = fs::read(path)?; + let kind = match path + .extension() + .and_then(|value| value.to_str()) + .map(str::to_ascii_lowercase) + .as_deref() + { + Some("svg") => "image/svg+xml", + Some("png") => "image/png", + Some("jpg" | "jpeg") => "image/jpeg", + Some("gif") => "image/gif", + Some("webp") => "image/webp", + _ => bail!("unsupported social background format: {}", path.display()), + }; + Ok(format!( + "data:{kind};base64,{}", + base64::engine::general_purpose::STANDARD.encode(data) + )) +} + +fn colorize_icon(source: &str, color: &str) -> String { + let color = xml_attribute(color); + if source.contains("currentColor") { + return source.replace("currentColor", &color); + } + let Some(end) = source.find('>') else { + return source.into(); + }; + let (root, body) = source.split_at(end); + let root = if root.contains("fill=\"none\"") { + root.replacen("fill=\"none\"", &format!("fill=\"{color}\""), 1) + } else if root.contains("fill='none'") { + root.replacen("fill='none'", &format!("fill='{color}'"), 1) + } else if root.contains(" fill=") { + root.into() + } else { + format!("{root} fill=\"{color}\"") + }; + format!("{root}{body}") +} + +fn debug_svg( + layout: &Layout, color: &str, grid: bool, step: usize, + fonts: Option<&Arc>, +) -> Result { + let parsed = parse_color(color, "debug color")?; + let label_color = if f64::from(parsed.red) * 0.299 + + f64::from(parsed.green) * 0.587 + + f64::from(parsed.blue) * 0.114 + > 150.0 + { + "black" + } else { + "white" + }; + let mut body = String::new(); + if grid { + for x in (0..layout.size.width).step_by(step) { + for y in (0..layout.size.height).step_by(step) { + write!( + body, + "", + i64::from(x) - 1, + xml_attribute(color), + i64::from(y) - 1, + xml_attribute(color), + ) + .expect("writing to a string cannot fail"); + } + } + } + for (index, layer) in layout.layers.iter().enumerate() { + let (x, y) = offset(layer, layout.size.width, layout.size.height); + let right = (i64::from(x) + i64::from(layer.size.width)) + .min(i64::from(layout.size.width) - 1); + let bottom = (i64::from(y) + i64::from(layer.size.height)) + .min(i64::from(layout.size.height) - 1); + let width = layer.size.width.saturating_add(1); + let height = layer.size.height.saturating_add(1); + write!( + body, + "", + xml_attribute(color), + ) + .expect("writing to a string cannot fail"); + let label = format!("{index} – {x}, {y}"); + let font = Font { + family: "Roboto".into(), + variant: String::new(), + style: "Regular".into(), + }; + let width = measure(&label, 12.0, &font, fonts)?.ceil() + 9.0; + let (ascender, descender) = font_metrics(fonts); + let height = ((ascender + descender) * 12.0 / 1000.0).ceil() + 4.0; + write!( + body, + "{}", + xml_attribute(color), + x.saturating_add(4), + y.saturating_add(5), + xml_text(&label), + ) + .expect("writing to a string cannot fail"); + } + Ok(format!( + "{body}", + layout.size.width, layout.size.height, + )) +} + +fn xml_text(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") +} + +fn xml_attribute(value: &str) -> String { + xml_text(value) + .replace('"', """) + .replace('\'', "'") +} + +// ---------------------------------------------------------------------------- +// Tests +// ---------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use base64::Engine as _; + use minijinja::Value; + + use crate::compat::mkdocs::plugin::social::layout::Size; + + use super::{ + balance_two_lines, colorize_icon, debug_svg, environment, + font_attributes, layer_svg, offset, render_svg, render_template, + vertical, Font, Layer, Layout, + }; + #[test] + fn balances_two_lines_by_moving_one_word() { + let mut lines = vec!["one two three".into(), "four".into()]; + balance_two_lines(&mut lines); + assert_eq!(lines, ["one two", "three four"]); + } + + #[test] + fn vertical_anchor_preserves_material_axis_precedence() { + assert_eq!(vertical("center top", 100.0, 30.0, 20.0), 4.0); + assert_eq!(vertical("center bottom", 100.0, 30.0, 20.0), 69.0); + assert_eq!(vertical("end center", 100.0, 30.0, 20.0), 37.0); + assert_eq!(vertical("start", 100.0, 30.0, 20.0), 4.0); + } + + #[test] + fn maps_font_style_to_svg_attributes() { + let font = Font { + family: "Roboto".into(), + variant: "Condensed".into(), + style: "Bold Italic".into(), + }; + let attributes = font_attributes(&font); + assert!(attributes.contains("font-weight=\"700\"")); + assert!(attributes.contains("font-style=\"italic\"")); + assert!(attributes.contains("font-stretch=\"condensed\"")); + } + + #[test] + fn renders_embedded_svg_icons() { + let source = r#""#; + let source = colorize_icon(source, "white"); + let icon = format!( + "data:image/svg+xml;base64,{}", + base64::engine::general_purpose::STANDARD.encode(source) + ); + let mut layer = Layer::default(); + layer.size.width = 10; + layer.size.height = 10; + let svg = layer_svg(&layer, None, Some(&icon), None).unwrap(); + let pixmap = render_svg(&svg, 10, 10, None).unwrap(); + + assert!(pixmap + .data() + .as_chunks::<4>() + .0 + .iter() + .any(|pixel| pixel[3] != 0)); + } + + #[test] + fn decodes_html_entities_before_rendering_templates() { + let rendered = + render_template(&environment(), "A & B", &Value::UNDEFINED) + .unwrap(); + assert_eq!(rendered, "A & B"); + } + + #[test] + fn computes_signed_offsets_for_oversized_layers() { + let mut layer = Layer::default(); + layer.size.width = 20; + layer.size.height = 20; + layer.origin = "end bottom".into(); + assert_eq!(offset(&layer, 10, 10), (-10, -10)); + } + + #[test] + fn debug_labels_contrast_with_the_overlay() { + let layout = Layout { + tags: Vec::new(), + size: Size { width: 10, height: 10 }, + layers: vec![Layer::default()], + }; + assert!(debug_svg(&layout, "white", false, 1, None) + .unwrap() + .contains("fill=\"black\"")); + assert!(debug_svg(&layout, "not-a-color", false, 1, None).is_err()); + } +} diff --git a/crates/zensical/src/compat/mkdocs/plugin/social/writer.rs b/crates/zensical/src/compat/mkdocs/plugin/social/writer.rs new file mode 100644 index 0000000..9a04deb --- /dev/null +++ b/crates/zensical/src/compat/mkdocs/plugin/social/writer.rs @@ -0,0 +1,132 @@ +// Copyright (c) 2025-2026 Zensical and contributors + +// SPDX-License-Identifier: MIT +// All contributions are certified under the DCO + +// 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 NON-INFRINGEMENT. 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. + +// ---------------------------------------------------------------------------- + +//! Removal-aware social card output. + +use anyhow::anyhow; +use std::fs; +use std::io; +use std::path::PathBuf; + +use zrx::id::Id; +use zrx::scheduler::action::{Action, Concurrency, Context}; +use zrx::stream::operator::Operator; +use zrx::stream::{Change, Key, Stream}; + +use crate::path::{OutputRoot, SitePath}; + +use super::Card; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Copies generated cards into the output tree and removes stale ones. +#[derive(Clone)] +struct Writer { + /// Root for generated site files. + output: OutputRoot, +} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl Writer { + /// Resolves a card's site-relative output path. + fn path(&self, card: &Card) -> PathBuf { + self.output.join(&card.path) + } + + /// Copies a cached card into the generated site. + fn insert(&self, card: &Card) -> anyhow::Result<()> { + let path = self.path(card); + fs::create_dir_all(path.parent().expect("social card has parent"))?; + let mut source = fs::File::open(&card.source)?; + let mut target = fs::File::create(path)?; + io::copy(&mut source, &mut target)?; + Ok(()) + } + + /// Removes a previously generated card by its output identity. + fn remove(&self, key: &Key) -> anyhow::Result<()> { + let id = key.try_as_id()?; + let path = id.location().parse::()?; + let path = self.output.join(&path); + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error.into()), + } + } +} + +// ---------------------------------------------------------------------------- +// Trait implementations +// ---------------------------------------------------------------------------- + +impl Action> for Writer { + type Inputs = (Card,); + type Output = (); + + /// Lets the scheduler choose output-copy concurrency. + fn concurrency(&self) -> Concurrency { + Concurrency::adaptive() + } + + /// Applies card insertions and removals to the output tree. + fn execute(&mut self, context: Context<'_, Key, Self>) { + let Context { inputs: input, output, .. } = context; + input.for_each(output, |change, emit| { + match change { + Change::Insert(key, card) => { + let id = key.try_as_id()?; + if id.provider() != "file" || id.context() != "." { + return Err(anyhow!( + "invalid social card output identity" + ) + .into()); + } + self.insert(card.as_ref())?; + emit.insert(key, ()); + } + Change::Remove(key) => { + self.remove(&key)?; + emit.remove(key); + } + } + Ok(()) + }); + } +} + +// ---------------------------------------------------------------------------- +// Functions +// ---------------------------------------------------------------------------- + +/// Subscribes the output writer to the generated card stream. +pub fn setup(output: OutputRoot, cards: &Stream) { + let _ = cards.subscribe(Writer { output }); +} diff --git a/crates/zensical/src/config.rs b/crates/zensical/src/config.rs index f5b82a1..5b1456e 100644 --- a/crates/zensical/src/config.rs +++ b/crates/zensical/src/config.rs @@ -35,7 +35,7 @@ use std::sync::Arc; use zrx::path::PathExt; -use crate::config::plugins::{BlogPlugin, TagsPlugin}; +use crate::config::plugins::{BlogPlugin, SocialPlugin, TagsPlugin}; use crate::path::{OutputRoot, SourceRoot}; mod error; @@ -146,6 +146,10 @@ impl Config { .get_item("plugins")? .get_item("blogs")? .extract::()?; + config + .get_item("plugins")? + .get_item("social")? + .extract::()?; let project = config.extract::()?; // Return configuration and theme directory diff --git a/crates/zensical/src/config/plugins.rs b/crates/zensical/src/config/plugins.rs index 78b6473..3637637 100644 --- a/crates/zensical/src/config/plugins.rs +++ b/crates/zensical/src/config/plugins.rs @@ -33,12 +33,14 @@ mod api_autonav; mod autoapi; mod blog; mod rss; +mod social; mod tags; pub use api_autonav::{ApiAutonavConfig, ApiAutonavPlugin}; pub use autoapi::AutoApiPlugin; pub use blog::{BlogPlugin, BlogPluginConfig, CategorySort, ExcerptPolicy}; pub use rss::{RssDateConfig, RssPlugin, RssPluginConfig}; +pub use social::{SocialPlugin, SocialPluginConfig, SocialPluginInstance}; pub use tags::{ python_bool, python_float, python_scalar, TagsListingConfig, TagsPlugin, TagsPluginConfig, @@ -88,6 +90,8 @@ pub struct Plugins { pub blogs: BlogPlugin, /// RSS feed plugin instances. pub rss: RssPlugin, + /// Material social plugin instances. + pub social: SocialPlugin, /// Literate navigation plugin. pub literate_nav: LiterateNavPlugin, /// Awesome navigation plugin. diff --git a/crates/zensical/src/config/plugins/social.rs b/crates/zensical/src/config/plugins/social.rs new file mode 100644 index 0000000..fbe4106 --- /dev/null +++ b/crates/zensical/src/config/plugins/social.rs @@ -0,0 +1,384 @@ +// Copyright (c) 2025-2026 Zensical and contributors + +// SPDX-License-Identifier: MIT +// All contributions are certified under the DCO + +// 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 NON-INFRINGEMENT. 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. + +// ---------------------------------------------------------------------------- + +//! Material social plugin configuration. + +use pyo3::exceptions::PyValueError; +use pyo3::types::{ + PyAny, PyAnyMethods, PyDict, PyDictMethods, PyList, PyListMethods, +}; +use pyo3::{Borrowed, Bound, FromPyObject, PyErr, PyResult}; +use serde::Serialize; +use std::collections::{BTreeMap, BTreeSet}; + +use crate::path::SitePath; +use crate::structure::dynamic::Dynamic; + +const OPTIONS: &[&str] = &[ + "enabled", + "concurrency", + "cache", + "cache_dir", + "log", + "log_level", + "cards", + "cards_dir", + "cards_layout_dir", + "cards_layout", + "cards_layout_options", + "cards_include", + "cards_exclude", + "debug", + "debug_on_build", + "debug_grid", + "debug_grid_step", + "debug_color", + "cards_color", + "cards_font", +]; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Material social plugin instances. +#[derive(Clone, Debug, Hash, Serialize)] +pub struct SocialPlugin { + /// Ordered plugin instances. + pub config: Vec, +} + +/// One Material social plugin instance. +#[derive(Clone, Debug, Hash, Serialize)] +pub struct SocialPluginInstance { + /// Configured plugin name. + pub name: String, + /// Fully normalized configuration. + pub config: SocialPluginConfig, +} + +/// Material social plugin configuration. +#[allow(clippy::struct_excessive_bools)] +#[derive(Clone, Debug, Hash, Serialize)] +pub struct SocialPluginConfig { + /// Whether this instance participates in the build. + pub enabled: bool, + /// Maximum number of cards rendered concurrently. + pub concurrency: usize, + /// Whether the persistent card cache is used. + pub cache: bool, + /// Project-relative cache directory. + pub cache_dir: String, + /// Whether card errors are logged instead of failing the build. + pub log: bool, + /// Log level for recoverable card errors. + pub log_level: String, + /// Default page-level card generation switch. + pub cards: bool, + /// Site-relative generated card directory. + pub cards_dir: String, + /// Project-relative or absolute custom layout directory. + pub cards_layout_dir: String, + /// Default layout name. + pub cards_layout: String, + /// Arbitrary variables supplied to layouts. + pub cards_layout_options: BTreeMap, + /// Source inclusion patterns. + pub cards_include: Vec, + /// Source exclusion patterns. + pub cards_exclude: Vec, + /// Whether debug overlays are enabled. + pub debug: bool, + /// Whether debug overlays are retained for ordinary builds. + pub debug_on_build: bool, + /// Whether the debug grid is shown. + pub debug_grid: bool, + /// Debug grid spacing in pixels. + pub debug_grid_step: usize, + /// Debug overlay color. + pub debug_color: String, + /// Whether the deprecated `cards_color` option was supplied. + #[serde(skip)] + deprecated_cards_color: bool, + /// Whether the deprecated `cards_font` option was supplied. + #[serde(skip)] + deprecated_cards_font: bool, +} + +/// Strict reader for one Python mapping. +struct Reader<'py> { + value: &'py Bound<'py, PyDict>, + path: String, +} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl SocialPluginConfig { + /// Normalizes and validates one raw plugin mapping. + fn from_python(value: &Bound<'_, PyAny>, path: String) -> PyResult { + let value = value.cast::().map_err(|_| { + configuration_error(&path, "expected a configuration mapping") + })?; + let reader = Reader { value, path }; + reader.reject_unknown(OPTIONS)?; + let mut config = Self::default(); + config.enabled = reader.bool("enabled", config.enabled)?; + config.concurrency = reader.usize("concurrency", config.concurrency)?; + config.cache = reader.bool("cache", config.cache)?; + config.cache_dir = reader.string("cache_dir", &config.cache_dir)?; + config.log = reader.bool("log", config.log)?; + config.log_level = reader.string("log_level", &config.log_level)?; + config.cards = reader.bool("cards", config.cards)?; + config.cards_dir = reader.string("cards_dir", &config.cards_dir)?; + config.cards_layout_dir = + reader.string("cards_layout_dir", &config.cards_layout_dir)?; + config.cards_layout = + reader.string("cards_layout", &config.cards_layout)?; + config.cards_layout_options = reader.mapping("cards_layout_options")?; + config.cards_include = reader.strings("cards_include")?; + config.cards_exclude = reader.strings("cards_exclude")?; + config.debug = reader.bool("debug", config.debug)?; + config.debug_on_build = + reader.bool("debug_on_build", config.debug_on_build)?; + config.debug_grid = reader.bool("debug_grid", config.debug_grid)?; + config.debug_grid_step = + reader.usize("debug_grid_step", config.debug_grid_step)?; + config.debug_color = + reader.string("debug_color", &config.debug_color)?; + config.deprecated_cards_color = reader.get("cards_color")?.is_some(); + config.deprecated_cards_font = reader.get("cards_font")?.is_some(); + validate(&config, &reader)?; + Ok(config) + } + + /// Returns whether the deprecated `cards_color` option was supplied. + pub fn has_deprecated_cards_color(&self) -> bool { + self.deprecated_cards_color + } + + /// Returns whether the deprecated `cards_font` option was supplied. + pub fn has_deprecated_cards_font(&self) -> bool { + self.deprecated_cards_font + } +} + +impl Default for SocialPluginConfig { + fn default() -> Self { + let concurrency = std::thread::available_parallelism() + .map_or(1, usize::from) + .saturating_sub(1) + .max(1); + Self { + enabled: true, + concurrency, + cache: true, + cache_dir: ".cache/plugin/social".into(), + log: true, + log_level: "warn".into(), + cards: true, + cards_dir: "assets/images/social".into(), + cards_layout_dir: "layouts".into(), + cards_layout: "default".into(), + cards_layout_options: BTreeMap::new(), + cards_include: Vec::new(), + cards_exclude: Vec::new(), + debug: false, + debug_on_build: false, + debug_grid: true, + debug_grid_step: 32, + debug_color: "grey".into(), + deprecated_cards_color: false, + deprecated_cards_font: false, + } + } +} + +impl<'a, 'py> FromPyObject<'a, 'py> for SocialPlugin { + type Error = PyErr; + + fn extract(obj: Borrowed<'a, 'py, PyAny>) -> PyResult { + let root = obj.cast::().map_err(|_| { + configuration_error("plugins.social", "expected a mapping") + })?; + let entries = root.get_item("config")?.ok_or_else(|| { + configuration_error("plugins.social", "missing configuration") + })?; + let entries = entries.cast::().map_err(|_| { + configuration_error("plugins.social", "expected an instance list") + })?; + let mut config = Vec::with_capacity(entries.len()); + for (index, entry) in entries.iter().enumerate() { + let entry = entry.cast::().map_err(|_| { + configuration_error( + &format!("plugins.social[{index}]"), + "expected an instance mapping", + ) + })?; + let name = entry + .get_item("name")? + .ok_or_else(|| { + configuration_error( + &format!("plugins.social[{index}]"), + "missing instance name", + ) + })? + .extract::()?; + let raw = entry.get_item("config")?.ok_or_else(|| { + configuration_error( + &format!("plugins.social[{index}]"), + "missing instance configuration", + ) + })?; + config.push(SocialPluginInstance { + config: SocialPluginConfig::from_python( + &raw, + format!("plugins.{name}"), + )?, + name, + }); + } + Ok(Self { config }) + } +} + +impl<'py> Reader<'py> { + /// Rejects misspelled options before defaults can hide them. + fn reject_unknown(&self, allowed: &[&str]) -> PyResult<()> { + let allowed = allowed.iter().copied().collect::>(); + for (key, _) in self.value.iter() { + let key = key.extract::().map_err(|_| { + configuration_error(&self.path, "option names must be strings") + })?; + if !allowed.contains(key.as_str()) { + return Err(self.error(&key, "is not a supported option")); + } + } + Ok(()) + } + + /// Returns one present non-null option. + fn get(&self, name: &str) -> PyResult>> { + Ok(self.value.get_item(name)?.filter(|value| !value.is_none())) + } + + fn bool(&self, name: &str, default: bool) -> PyResult { + self.get(name)? + .map(|value| { + value + .extract::() + .map_err(|_| self.error(name, "must be a Boolean")) + }) + .transpose() + .map(|value| value.unwrap_or(default)) + } + + fn usize(&self, name: &str, default: usize) -> PyResult { + self.get(name)? + .map(|value| { + value + .extract::() + .map_err(|_| self.error(name, "must be a positive integer")) + }) + .transpose() + .map(|value| value.unwrap_or(default)) + } + + fn string(&self, name: &str, default: &str) -> PyResult { + self.get(name)? + .map(|value| { + value + .extract::() + .map_err(|_| self.error(name, "must be a string")) + }) + .transpose() + .map(|value| value.unwrap_or_else(|| default.into())) + } + + fn strings(&self, name: &str) -> PyResult> { + let Some(value) = self.get(name)? else { + return Ok(Vec::new()); + }; + value + .cast::() + .map_err(|_| self.error(name, "must be a list"))? + .iter() + .enumerate() + .map(|(index, value)| { + value.extract::().map_err(|_| { + self.error(name, &format!("item {index} must be a string")) + }) + }) + .collect() + } + + fn mapping(&self, name: &str) -> PyResult> { + self.get(name)? + .map(|value| { + value.extract::>().map_err(|_| { + self.error(name, "must be a mapping with string keys") + }) + }) + .transpose() + .map(Option::unwrap_or_default) + } + + fn error(&self, name: &str, reason: &str) -> PyErr { + configuration_error(&format!("{}.{}", self.path, name), reason) + } +} + +// ---------------------------------------------------------------------------- +// Functions +// ---------------------------------------------------------------------------- + +fn validate(config: &SocialPluginConfig, reader: &Reader<'_>) -> PyResult<()> { + if config.concurrency == 0 { + return Err(reader.error("concurrency", "must be greater than zero")); + } + if config.debug_grid_step == 0 { + return Err( + reader.error("debug_grid_step", "must be greater than zero") + ); + } + if config.cards_dir.trim().is_empty() { + return Err(reader.error("cards_dir", "must not be empty")); + } + if config.cards_dir.parse::().is_err() { + return Err(reader.error("cards_dir", "must be a safe site path")); + } + if config.cards_layout.trim().is_empty() { + return Err(reader.error("cards_layout", "must not be empty")); + } + if !["warn", "info", "ignore"].contains(&config.log_level.as_str()) { + return Err(reader.error("log_level", "is not a valid log level")); + } + Ok(()) +} + +fn configuration_error(path: &str, reason: &str) -> PyErr { + PyValueError::new_err(format!("invalid configuration at {path}: {reason}")) +} diff --git a/crates/zensical/src/config/theme.rs b/crates/zensical/src/config/theme.rs index 3bcda65..ec5f36e 100644 --- a/crates/zensical/src/config/theme.rs +++ b/crates/zensical/src/config/theme.rs @@ -67,6 +67,9 @@ pub struct Theme { pub features: Vec, /// Font settings. pub font: Font, + /// Whether the project explicitly configured its font settings. + #[serde(skip)] + pub font_explicit: bool, /// Static templates. pub static_templates: Vec, /// Favicon. @@ -103,6 +106,9 @@ pub struct Icon { pub view: Option, /// Logo icon. pub logo: Option, + /// Whether the project explicitly configured the logo icon. + #[serde(skip)] + pub logo_explicit: bool, /// Repository icon. pub repo: Option, /// Annotation icon. diff --git a/crates/zensical/src/watcher.rs b/crates/zensical/src/watcher.rs index b2f80f7..793c876 100644 --- a/crates/zensical/src/watcher.rs +++ b/crates/zensical/src/watcher.rs @@ -98,6 +98,36 @@ impl Watcher { )); } + // Resolve custom social layout directories before source mounts are + // finalized. They may live outside the project root, in which case + // they need their own provider-relative identity context. + let project_root = + config.path.parent().expect("configuration has parent"); + let social_layout_dirs = config + .project + .plugins + .social + .config + .iter() + .filter(|plugin| plugin.config.enabled) + .map(|plugin| { + let path = Path::new(&plugin.config.cards_layout_dir); + let path = if path.is_absolute() { + path.to_owned() + } else { + project_root.join(path) + }; + canonical_or_clone(&path) + }) + .filter(|path| path.is_dir()) + .collect::>(); + for (index, directory) in social_layout_dirs.iter().enumerate() { + sources.push(SourceMount::new( + directory.clone(), + format!("plugins/social/{index}"), + )); + } + // Add configuration file last, or we might run into overlapping paths. // Note that right now, we need to monitor the whole directory. We'll // integrate identification generation deeper into the file agent, @@ -137,7 +167,6 @@ impl Watcher { .iter() .map(|(path, _)| canonical_or_clone(path)) .collect::>(); - // Initialize file agent - we use a debounce interval of 20ms, which // should be sufficient to correctly determine rename events let agent = Agent::new(Duration::from_millis(20), serve, { @@ -321,6 +350,13 @@ impl Watcher { } } + // Custom social card layouts are runtime inputs, just like Markdown + // and assets. Forward their changes into the retained workflow so + // cards and metadata can be derived again without restarting serve. + for directory in social_layout_dirs { + agent.watch(directory)?; + } + // Watch files used by extensions for root in &config.api.roots { agent.watch(root)?; @@ -423,6 +459,21 @@ mod tests { assert_eq!(id.as_uri().as_str(), "guide/index.html"); } + #[test] + fn external_plugin_sources_use_their_own_mount() { + let directory = tempdir().unwrap(); + let layouts = directory.path().join("shared/layouts"); + fs::create_dir_all(&layouts).unwrap(); + let file = layouts.join("custom.yml"); + let sources = + [SourceMount::new(layouts, String::from("plugins/social/0"))]; + + let id = to_id(&file, &sources).unwrap(); + + assert_eq!(id.context(), "plugins/social/0"); + assert_eq!(id.location(), "custom.yml"); + } + #[cfg(unix)] #[test] fn rejects_non_utf8_provider_identity_instead_of_collapsing_it() { diff --git a/crates/zensical/src/workflow.rs b/crates/zensical/src/workflow.rs index b9e9efd..f03fa8b 100644 --- a/crates/zensical/src/workflow.rs +++ b/crates/zensical/src/workflow.rs @@ -49,7 +49,7 @@ use crate::compat::mkdocs::{ html, plugin::{ self, autorefs, awesome_nav, blog, exclude, literate_nav, meta, minify, - mkdocstrings, redirects, rss, search, tags, + mkdocstrings, redirects, rss, search, social, tags, }, resource, }; @@ -151,7 +151,7 @@ impl Deref for Input { } /// Page render input retained after site-wide settlement. -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq, Eq)] struct SitePage { /// Page passed to the template renderer. page: Page, @@ -195,6 +195,8 @@ struct PageRender { project: Arc, /// Stable asset mapping hash for the template cache key. asset_hash: u64, + /// Page-local social metadata inserted after template rendering. + social: social::Metadata, } impl Value for PageRender {} @@ -380,6 +382,11 @@ impl Main { // Feed inputs are final pages and their original Markdown bodies. let rss_artifacts = rss::Rss::new(&self.config).setup(&page, &markdown, &configuration); + let social = social::Social::new(&self.config, self.serve, self.strict); + let social_metadata = social.setup(social::Dependencies { + pages: &page, + sources: &sources, + }); let _ = render_templates( &self.config, &files, @@ -391,6 +398,7 @@ impl Main { let unresolved = render_pages( &self.config, &site_page, + &social_metadata, &nav, &autorefs, &assets, @@ -617,12 +625,15 @@ fn validate( } /// Compute a hash of the page content relevant to template rendering. -fn page_hash(page: &Page, autorefs: &autorefs::References) -> u64 { +fn page_hash( + page: &Page, autorefs: &autorefs::References, social: &social::Metadata, +) -> u64 { let mut hasher = DefaultHasher::new(); page.content.hash(&mut hasher); page.meta.hash(&mut hasher); page.hash_derived_template_context(&mut hasher); autorefs.hash(&mut hasher); + social.hash(&mut hasher); hasher.finish() } @@ -921,24 +932,34 @@ fn template_output(id: &Id) -> Result { #[allow(clippy::too_many_arguments)] fn render_pages( config: &Config, pages: &Stream, - nav: &Signal, autorefs: &Signal, + social: &Stream, nav: &Signal, + autorefs: &Signal, assets: &Signal, minify: &minify::Minify, mkdocstrings: &mkdocstrings::Mkdocstrings, extra: &Stream, ) -> Stream { - let pages = pages.product(nav).product(autorefs).product(assets).map( - |input: &((SitePage, Navigation), autorefs::Registry), - assets: &minify::Manifest| { - let ((page, nav), autorefs) = input; - PageRender { - input: page.clone(), - nav: nav.clone(), - autorefs: autorefs.clone(), - project: assets.project.clone(), - asset_hash: assets.hash, - } - }, - ); + let pages = (pages.clone(), social.clone()) + .join() + .product(nav) + .product(autorefs) + .product(assets) + .map( + |input: &( + ((SitePage, social::Metadata), Navigation), + autorefs::Registry, + ), + assets: &minify::Manifest| { + let (((page, social), nav), autorefs) = input; + PageRender { + input: page.clone(), + nav: nav.clone(), + autorefs: autorefs.clone(), + project: assets.project.clone(), + asset_hash: assets.hash, + social: social.clone(), + } + }, + ); let template = OnceLock::new(); let theme_dirs = config.theme_dirs.clone(); @@ -959,7 +980,7 @@ fn render_pages( config.hash, input.nav.hash, input.asset_hash, - page_hash(&page, references), + page_hash(&page, references, &input.social), ); let rendered = cached(&config, ("template", id), args, |(_, _, _, _)| { @@ -981,6 +1002,7 @@ fn render_pages( &mkdocstrings, &page.url, )?; + let data = input.social.inject(data); let data = minify.html(data); Ok::<_, anyhow::Error>(RenderedSitePage { diff --git a/pyproject.toml b/pyproject.toml index 8f00a1b..d269e5e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -76,6 +76,7 @@ dev = [ "maturin>=1.10.2", "mkdocstrings[python]>=1.0.6", "pandas>=2.3.3", + "pillow>=11.3.0", "pytest>=9.0.3", "ruff>=0.12.8", "tabulate>=0.10.0", diff --git a/python/tests/fixtures/social/README.md b/python/tests/fixtures/social/README.md new file mode 100644 index 0000000..14cef61 --- /dev/null +++ b/python/tests/fixtures/social/README.md @@ -0,0 +1,69 @@ +# Social plugin compatibility matrix + +These projects compare the user-visible output of Material for MkDocs and +Zensical with the same `mkdocs.yml` and source files. Run the matrix from the +repository root: + +```console +/Users/squidfunk/.workspace/squidfunk/repos/mkdocs-material/community/venv/bin/python \ + scripts/social_compatibility.py \ + --mkdocs /Users/squidfunk/.workspace/squidfunk/repos/mkdocs-material/community/venv/bin/mkdocs \ + --zensical .venv/bin/zensical +``` + +| Case | Behavior | +| --- | --- | +| `basic` | Custom layout, root and nested card routes, page title and metadata | +| `filters` | Include precedence, exclude patterns, page-level opt-out | +| `metadata` | Meta plugin inheritance and page-level layout options | +| `multiple` | Ordered plugin instances, separate card directories and metadata | +| `no-site-url` | Cards generated without public image metadata | +| `image-only` | Bundled image-only layout and a local SVG dependency | +| `bundled` | Default, accent, invert and variant layouts with typography | +| `logo-icon` | Explicit theme logo icon and omitted font settings | +| `debug` | Build-time debug grid and color settings | +| `blog` | Cards for generated blog views and posts | +| `disabled` | Disabled plugin stays disabled despite a page opt-in | +| `cards-off` | Global card switch with a page-level opt-in | +| `paths` | Flat URLs, alternate card directory and custom layout directory | +| `layout-options` | Bundled-layout image, logo, title, description and page overrides | +| `layer-composition` | YAML definitions, SVG background, icon, origins and offsets | +| `theme-defaults` | Palette list, SVG/PNG logos, exact font face and variant fallback | +| `custom-typography` | Custom alignment, wrapping, shrinking and line spacing | +| `template-context` | Page file and URL values inside custom tag templates | +| `lifecycle` | Cached rebuilds after image, page metadata and layout edits | +| `recoverable-error` | Missing image with ignored card-generation error | +| `deprecated` | Deprecated `cards_color` and `cards_font` settings | +| `debug-no-grid` | Debug overlay without a grid | + +The check compares generated card paths, dimensions and every card's mean RGB +pixel difference, plus each page's social metadata. A case passes when the +metadata and dimensions match and every card's mean RGB difference is at most +1 on a 0–255 scale. The `lifecycle` case also rebuilds both engines after each +exact source edit and checks that the expected output changed. PNG file bytes +may differ between image libraries even when the visible output is the same. +Build logs and full output are kept in a temporary directory only while the +command runs; pass `--output PATH` to keep them for inspection. + +The cases using typography or debug labels download Roboto from Google Fonts +on their first build. Use `--font-cache PATH` to seed both temporary projects +from an existing Material font cache and run without a network connection. + +On 2026-09-23, 19 of 22 cases passed the strict pixel threshold using Material +for MkDocs 9.7.1 with Pillow 12.1.1 and this Zensical branch. One remaining +case is an accepted rasterization difference; the other two are behavior gaps: + +- `custom-typography`: text placement matches visually. Different text + rasterizers exceed the strict pixel threshold (maximum mean RGB difference + 3.636), but this is not considered a user-visible defect. +- `template-context`: Material exposes `page.file.src_uri` to layout templates; + the Zensical build fails because that value is undefined. +- `lifecycle`: after a cached SVG background edit, Material retains its old + card while Zensical updates it. The initial build and later layout edit match. + +The full matrix currently exits nonzero because of the strict pixel threshold +and the two behavior gaps. +It does not prove every custom layout, live `serve` edit, remote font, or error +policy combination. Configuration keys are represented across this matrix and +the Python integration tests, but a finite set of cases cannot prove universal +behavioral parity. diff --git a/python/tests/fixtures/social/basic/docs/guide/advanced.md b/python/tests/fixtures/social/basic/docs/guide/advanced.md new file mode 100644 index 0000000..931fae0 --- /dev/null +++ b/python/tests/fixtures/social/basic/docs/guide/advanced.md @@ -0,0 +1 @@ +# Advanced diff --git a/python/tests/fixtures/social/basic/docs/guide/index.md b/python/tests/fixtures/social/basic/docs/guide/index.md new file mode 100644 index 0000000..8c0d02f --- /dev/null +++ b/python/tests/fixtures/social/basic/docs/guide/index.md @@ -0,0 +1 @@ +# Guide diff --git a/python/tests/fixtures/social/basic/docs/index.md b/python/tests/fixtures/social/basic/docs/index.md new file mode 100644 index 0000000..483e4cb --- /dev/null +++ b/python/tests/fixtures/social/basic/docs/index.md @@ -0,0 +1,6 @@ +--- +title: Home & Intro +description: A concise & useful description. +--- + +# Welcome diff --git a/python/tests/fixtures/social/basic/layouts/flat.yml b/python/tests/fixtures/social/basic/layouts/flat.yml new file mode 100644 index 0000000..ead527f --- /dev/null +++ b/python/tests/fixtures/social/basic/layouts/flat.yml @@ -0,0 +1,12 @@ +tags: + og:type: website + og:title: '{{ page.meta.get("title", page.title) }}' + og:description: '{{ page.meta.get("description", config.site_description) | x }}' + og:image: '{{ image.url }}' + og:image:width: '{{ image.width }}' + og:image:height: '{{ image.height }}' + og:url: '{{ page.canonical_url }}' + twitter:card: summary_large_image +size: { width: 320, height: 168 } +layers: + - background: { color: '#123456' } diff --git a/python/tests/fixtures/social/basic/mkdocs.yml b/python/tests/fixtures/social/basic/mkdocs.yml new file mode 100644 index 0000000..fca939e --- /dev/null +++ b/python/tests/fixtures/social/basic/mkdocs.yml @@ -0,0 +1,10 @@ +site_name: Social parity +site_description: A small parity site. +site_url: https://example.test/docs/ +site_dir: site +theme: + name: material +plugins: + - social: + cache: false + cards_layout: flat diff --git a/python/tests/fixtures/social/blog/docs/blog/index.md b/python/tests/fixtures/social/blog/docs/blog/index.md new file mode 100644 index 0000000..3d84c38 --- /dev/null +++ b/python/tests/fixtures/social/blog/docs/blog/index.md @@ -0,0 +1 @@ +# Journal diff --git a/python/tests/fixtures/social/blog/docs/blog/posts/first.md b/python/tests/fixtures/social/blog/docs/blog/posts/first.md new file mode 100644 index 0000000..6441b62 --- /dev/null +++ b/python/tests/fixtures/social/blog/docs/blog/posts/first.md @@ -0,0 +1,5 @@ +--- +date: 2026-09-20 +--- + +# First post diff --git a/python/tests/fixtures/social/blog/docs/blog/posts/second.md b/python/tests/fixtures/social/blog/docs/blog/posts/second.md new file mode 100644 index 0000000..dddb7d3 --- /dev/null +++ b/python/tests/fixtures/social/blog/docs/blog/posts/second.md @@ -0,0 +1,5 @@ +--- +date: 2026-09-21 +--- + +# Second post diff --git a/python/tests/fixtures/social/blog/docs/index.md b/python/tests/fixtures/social/blog/docs/index.md new file mode 100644 index 0000000..291ca38 --- /dev/null +++ b/python/tests/fixtures/social/blog/docs/index.md @@ -0,0 +1 @@ +# Home diff --git a/python/tests/fixtures/social/blog/layouts/flat.yml b/python/tests/fixtures/social/blog/layouts/flat.yml new file mode 100644 index 0000000..1c2dfa9 --- /dev/null +++ b/python/tests/fixtures/social/blog/layouts/flat.yml @@ -0,0 +1,6 @@ +tags: + og:title: '{{ page.meta.get("title", page.title) }}' + og:image: '{{ image.url }}' +size: { width: 320, height: 168 } +layers: + - background: { color: '#123456' } diff --git a/python/tests/fixtures/social/blog/mkdocs.yml b/python/tests/fixtures/social/blog/mkdocs.yml new file mode 100644 index 0000000..2e8d9ff --- /dev/null +++ b/python/tests/fixtures/social/blog/mkdocs.yml @@ -0,0 +1,9 @@ +site_name: Social blog +site_url: https://example.test/docs/ +site_dir: site +theme: { name: material } +plugins: + - blog + - social: + cache: false + cards_layout: flat diff --git a/python/tests/fixtures/social/bundled/docs/accent.md b/python/tests/fixtures/social/bundled/docs/accent.md new file mode 100644 index 0000000..54805de --- /dev/null +++ b/python/tests/fixtures/social/bundled/docs/accent.md @@ -0,0 +1,7 @@ +--- +title: An accent card +social: + cards_layout: default/accent +--- + +# Accent diff --git a/python/tests/fixtures/social/bundled/docs/index.md b/python/tests/fixtures/social/bundled/docs/index.md new file mode 100644 index 0000000..230c171 --- /dev/null +++ b/python/tests/fixtures/social/bundled/docs/index.md @@ -0,0 +1,6 @@ +--- +title: A thoughtful beginning +description: A concise introduction to the project. +--- + +# Home diff --git a/python/tests/fixtures/social/bundled/docs/invert.md b/python/tests/fixtures/social/bundled/docs/invert.md new file mode 100644 index 0000000..7dd6c69 --- /dev/null +++ b/python/tests/fixtures/social/bundled/docs/invert.md @@ -0,0 +1,7 @@ +--- +title: An inverted card +social: + cards_layout: default/invert +--- + +# Invert diff --git a/python/tests/fixtures/social/bundled/docs/variant.md b/python/tests/fixtures/social/bundled/docs/variant.md new file mode 100644 index 0000000..b419b90 --- /dev/null +++ b/python/tests/fixtures/social/bundled/docs/variant.md @@ -0,0 +1,7 @@ +--- +title: A variant card +social: + cards_layout: default/variant +--- + +# Variant diff --git a/python/tests/fixtures/social/bundled/mkdocs.yml b/python/tests/fixtures/social/bundled/mkdocs.yml new file mode 100644 index 0000000..0f8057b --- /dev/null +++ b/python/tests/fixtures/social/bundled/mkdocs.yml @@ -0,0 +1,19 @@ +site_name: Bundled cards +site_description: A site description for cards. +site_url: https://example.test/docs/ +site_dir: site +theme: + name: material + palette: + primary: deep purple + font: + text: Roboto +plugins: + - social: + cache: false + cache_dir: social-cache + cards_layout: default + cards_layout_options: + background_color: '#4c1d95' + color: '#ffffff' + font_family: Roboto diff --git a/python/tests/fixtures/social/cards-off/docs/index.md b/python/tests/fixtures/social/cards-off/docs/index.md new file mode 100644 index 0000000..1183e04 --- /dev/null +++ b/python/tests/fixtures/social/cards-off/docs/index.md @@ -0,0 +1,3 @@ +# Home + +Global card generation is disabled for this page. diff --git a/python/tests/fixtures/social/cards-off/docs/opt-in.md b/python/tests/fixtures/social/cards-off/docs/opt-in.md new file mode 100644 index 0000000..c4962d2 --- /dev/null +++ b/python/tests/fixtures/social/cards-off/docs/opt-in.md @@ -0,0 +1,8 @@ +--- +social: + cards: true +--- + +# Page opt-in + +This page explicitly enables a card. diff --git a/python/tests/fixtures/social/cards-off/layouts/flat.yml b/python/tests/fixtures/social/cards-off/layouts/flat.yml new file mode 100644 index 0000000..27e1121 --- /dev/null +++ b/python/tests/fixtures/social/cards-off/layouts/flat.yml @@ -0,0 +1,6 @@ +tags: + og:image: '{{ image.url }}' + x:mode: opt-in +size: { width: 320, height: 168 } +layers: + - background: { color: '#234567' } diff --git a/python/tests/fixtures/social/cards-off/mkdocs.yml b/python/tests/fixtures/social/cards-off/mkdocs.yml new file mode 100644 index 0000000..22cebbe --- /dev/null +++ b/python/tests/fixtures/social/cards-off/mkdocs.yml @@ -0,0 +1,9 @@ +site_name: Global cards off +site_url: https://example.test/docs/ +site_dir: site +theme: { name: material } +plugins: + - social: + cache: false + cards: false + cards_layout: flat diff --git a/python/tests/fixtures/social/custom-typography/docs/index.md b/python/tests/fixtures/social/custom-typography/docs/index.md new file mode 100644 index 0000000..521eb45 --- /dev/null +++ b/python/tests/fixtures/social/custom-typography/docs/index.md @@ -0,0 +1,6 @@ +--- +title: Custom typography +description: A short description to inspect bottom alignment. +--- + +# Home diff --git a/python/tests/fixtures/social/custom-typography/docs/long.md b/python/tests/fixtures/social/custom-typography/docs/long.md new file mode 100644 index 0000000..31cf621 --- /dev/null +++ b/python/tests/fixtures/social/custom-typography/docs/long.md @@ -0,0 +1,6 @@ +--- +title: A very long headline that should wrap over several lines before the type becomes too small +description: A longer description which should fit into two lines and reveal whether end alignment and line spacing match. +--- + +# Long typography diff --git a/python/tests/fixtures/social/custom-typography/layouts/type.yml b/python/tests/fixtures/social/custom-typography/layouts/type.yml new file mode 100644 index 0000000..694f014 --- /dev/null +++ b/python/tests/fixtures/social/custom-typography/layouts/type.yml @@ -0,0 +1,24 @@ +tags: + og:image: '{{ image.url }}' + x:title: '{{ page.title }}' +size: { width: 600, height: 315 } +layers: + - background: { color: '#14263d' } + - size: { width: 500, height: 110 } + offset: { x: 50, y: 40 } + typography: + content: '{{ page.meta.get("title", page.title) }}' + align: center + overflow: shrink + color: '#ffffff' + line: { amount: 2, height: 1.25 } + font: { family: Roboto, style: Bold } + - size: { width: 500, height: 65 } + offset: { x: 50, y: 220 } + typography: + content: '{{ page.meta.get("description", "") }}' + align: end bottom + overflow: truncate + color: '#bdd4eb' + line: { amount: 2, height: 1.1 } + font: { family: Roboto, style: Regular } diff --git a/python/tests/fixtures/social/custom-typography/mkdocs.yml b/python/tests/fixtures/social/custom-typography/mkdocs.yml new file mode 100644 index 0000000..dd32639 --- /dev/null +++ b/python/tests/fixtures/social/custom-typography/mkdocs.yml @@ -0,0 +1,9 @@ +site_name: Custom typography +site_url: https://example.test/docs/ +site_dir: site +theme: { name: material } +plugins: + - social: + cache: false + cache_dir: social-cache + cards_layout: type diff --git a/python/tests/fixtures/social/debug-no-grid/docs/index.md b/python/tests/fixtures/social/debug-no-grid/docs/index.md new file mode 100644 index 0000000..291ca38 --- /dev/null +++ b/python/tests/fixtures/social/debug-no-grid/docs/index.md @@ -0,0 +1 @@ +# Home diff --git a/python/tests/fixtures/social/debug-no-grid/layouts/flat.yml b/python/tests/fixtures/social/debug-no-grid/layouts/flat.yml new file mode 100644 index 0000000..126e784 --- /dev/null +++ b/python/tests/fixtures/social/debug-no-grid/layouts/flat.yml @@ -0,0 +1,5 @@ +tags: + og:image: '{{ image.url }}' +size: { width: 320, height: 168 } +layers: + - background: { color: '#324a62' } diff --git a/python/tests/fixtures/social/debug-no-grid/mkdocs.yml b/python/tests/fixtures/social/debug-no-grid/mkdocs.yml new file mode 100644 index 0000000..0e52e72 --- /dev/null +++ b/python/tests/fixtures/social/debug-no-grid/mkdocs.yml @@ -0,0 +1,13 @@ +site_name: Debug without grid +site_url: https://example.test/docs/ +site_dir: site +theme: { name: material } +plugins: + - social: + cache: false + cache_dir: social-cache + cards_layout: flat + debug: true + debug_on_build: true + debug_grid: false + debug_color: blue diff --git a/python/tests/fixtures/social/debug/docs/index.md b/python/tests/fixtures/social/debug/docs/index.md new file mode 100644 index 0000000..fe1e66a --- /dev/null +++ b/python/tests/fixtures/social/debug/docs/index.md @@ -0,0 +1 @@ +# Debug view diff --git a/python/tests/fixtures/social/debug/layouts/flat.yml b/python/tests/fixtures/social/debug/layouts/flat.yml new file mode 100644 index 0000000..b4548cd --- /dev/null +++ b/python/tests/fixtures/social/debug/layouts/flat.yml @@ -0,0 +1,5 @@ +tags: + og:image: '{{ image.url }}' +size: { width: 320, height: 168 } +layers: + - background: { color: '#123456' } diff --git a/python/tests/fixtures/social/debug/mkdocs.yml b/python/tests/fixtures/social/debug/mkdocs.yml new file mode 100644 index 0000000..28de5e2 --- /dev/null +++ b/python/tests/fixtures/social/debug/mkdocs.yml @@ -0,0 +1,14 @@ +site_name: Debug cards +site_url: https://example.test/docs/ +site_dir: site +theme: { name: material } +plugins: + - social: + cache: false + cache_dir: social-cache + cards_layout: flat + debug: true + debug_on_build: true + debug_grid: true + debug_grid_step: 32 + debug_color: red diff --git a/python/tests/fixtures/social/deprecated/docs/index.md b/python/tests/fixtures/social/deprecated/docs/index.md new file mode 100644 index 0000000..291ca38 --- /dev/null +++ b/python/tests/fixtures/social/deprecated/docs/index.md @@ -0,0 +1 @@ +# Home diff --git a/python/tests/fixtures/social/deprecated/layouts/flat.yml b/python/tests/fixtures/social/deprecated/layouts/flat.yml new file mode 100644 index 0000000..ce92f66 --- /dev/null +++ b/python/tests/fixtures/social/deprecated/layouts/flat.yml @@ -0,0 +1,5 @@ +tags: + og:image: '{{ image.url }}' +size: { width: 320, height: 168 } +layers: + - background: { color: '#102030' } diff --git a/python/tests/fixtures/social/deprecated/mkdocs.yml b/python/tests/fixtures/social/deprecated/mkdocs.yml new file mode 100644 index 0000000..dce23be --- /dev/null +++ b/python/tests/fixtures/social/deprecated/mkdocs.yml @@ -0,0 +1,10 @@ +site_name: Deprecated social options +site_url: https://example.test/docs/ +site_dir: site +theme: { name: material } +plugins: + - social: + cache: false + cards_layout: flat + cards_color: { fill: '#102030', text: '#ffffff' } + cards_font: Roboto diff --git a/python/tests/fixtures/social/disabled/docs/index.md b/python/tests/fixtures/social/disabled/docs/index.md new file mode 100644 index 0000000..c38ca8e --- /dev/null +++ b/python/tests/fixtures/social/disabled/docs/index.md @@ -0,0 +1,3 @@ +# Home + +The disabled plugin must not generate a card or social metadata. diff --git a/python/tests/fixtures/social/disabled/docs/opt-in.md b/python/tests/fixtures/social/disabled/docs/opt-in.md new file mode 100644 index 0000000..8f5b781 --- /dev/null +++ b/python/tests/fixtures/social/disabled/docs/opt-in.md @@ -0,0 +1,8 @@ +--- +social: + cards: true +--- + +# Page opt-in + +A page override must not re-enable a disabled plugin. diff --git a/python/tests/fixtures/social/disabled/mkdocs.yml b/python/tests/fixtures/social/disabled/mkdocs.yml new file mode 100644 index 0000000..65cb8c1 --- /dev/null +++ b/python/tests/fixtures/social/disabled/mkdocs.yml @@ -0,0 +1,7 @@ +site_name: Disabled social plugin +site_url: https://example.test/docs/ +site_dir: site +theme: { name: material } +plugins: + - social: + enabled: false diff --git a/python/tests/fixtures/social/filters/docs/guides/hidden.md b/python/tests/fixtures/social/filters/docs/guides/hidden.md new file mode 100644 index 0000000..bb943c8 --- /dev/null +++ b/python/tests/fixtures/social/filters/docs/guides/hidden.md @@ -0,0 +1 @@ +# Include wins over exclude diff --git a/python/tests/fixtures/social/filters/docs/guides/index.md b/python/tests/fixtures/social/filters/docs/guides/index.md new file mode 100644 index 0000000..d1a2daa --- /dev/null +++ b/python/tests/fixtures/social/filters/docs/guides/index.md @@ -0,0 +1 @@ +# Included guide diff --git a/python/tests/fixtures/social/filters/docs/guides/opt-out.md b/python/tests/fixtures/social/filters/docs/guides/opt-out.md new file mode 100644 index 0000000..16b5394 --- /dev/null +++ b/python/tests/fixtures/social/filters/docs/guides/opt-out.md @@ -0,0 +1,6 @@ +--- +social: + cards: false +--- + +# Page opt-out diff --git a/python/tests/fixtures/social/filters/docs/index.md b/python/tests/fixtures/social/filters/docs/index.md new file mode 100644 index 0000000..291ca38 --- /dev/null +++ b/python/tests/fixtures/social/filters/docs/index.md @@ -0,0 +1 @@ +# Home diff --git a/python/tests/fixtures/social/filters/layouts/flat.yml b/python/tests/fixtures/social/filters/layouts/flat.yml new file mode 100644 index 0000000..bb7c269 --- /dev/null +++ b/python/tests/fixtures/social/filters/layouts/flat.yml @@ -0,0 +1,5 @@ +tags: + og:image: '{{ image.url }}' +size: { width: 320, height: 168 } +layers: + - background: { color: '#abcdef' } diff --git a/python/tests/fixtures/social/filters/mkdocs.yml b/python/tests/fixtures/social/filters/mkdocs.yml new file mode 100644 index 0000000..570c667 --- /dev/null +++ b/python/tests/fixtures/social/filters/mkdocs.yml @@ -0,0 +1,10 @@ +site_name: Social filters +site_url: https://example.test/docs/ +site_dir: site +theme: { name: material } +plugins: + - social: + cache: false + cards_layout: flat + cards_include: ['guides/**'] + cards_exclude: ['guides/hidden.md'] diff --git a/python/tests/fixtures/social/image-only/docs/background.svg b/python/tests/fixtures/social/image-only/docs/background.svg new file mode 100644 index 0000000..e83454d --- /dev/null +++ b/python/tests/fixtures/social/image-only/docs/background.svg @@ -0,0 +1,3 @@ + + + diff --git a/python/tests/fixtures/social/image-only/docs/index.md b/python/tests/fixtures/social/image-only/docs/index.md new file mode 100644 index 0000000..98e600b --- /dev/null +++ b/python/tests/fixtures/social/image-only/docs/index.md @@ -0,0 +1 @@ +# Image card diff --git a/python/tests/fixtures/social/image-only/mkdocs.yml b/python/tests/fixtures/social/image-only/mkdocs.yml new file mode 100644 index 0000000..3758595 --- /dev/null +++ b/python/tests/fixtures/social/image-only/mkdocs.yml @@ -0,0 +1,10 @@ +site_name: Social image only +site_url: https://example.test/docs/ +site_dir: site +theme: { name: material } +plugins: + - social: + cache: false + cards_layout: default/only/image + cards_layout_options: + background_image: docs/background.svg diff --git a/python/tests/fixtures/social/layer-composition/docs/background.svg b/python/tests/fixtures/social/layer-composition/docs/background.svg new file mode 100644 index 0000000..a77af77 --- /dev/null +++ b/python/tests/fixtures/social/layer-composition/docs/background.svg @@ -0,0 +1,4 @@ + + + + diff --git a/python/tests/fixtures/social/layer-composition/docs/index.md b/python/tests/fixtures/social/layer-composition/docs/index.md new file mode 100644 index 0000000..291ca38 --- /dev/null +++ b/python/tests/fixtures/social/layer-composition/docs/index.md @@ -0,0 +1 @@ +# Home diff --git a/python/tests/fixtures/social/layer-composition/layouts/composed.yml b/python/tests/fixtures/social/layer-composition/layouts/composed.yml new file mode 100644 index 0000000..ea42e7b --- /dev/null +++ b/python/tests/fixtures/social/layer-composition/layouts/composed.yml @@ -0,0 +1,15 @@ +definitions: + - &accent '#eeaa66' +tags: + og:image: '{{ image.url }}' + x:layer: composed +size: { width: 400, height: 200 } +layers: + - background: { image: docs/background.svg } + - size: { width: 100, height: 40 } + offset: { x: 20, y: 20 } + background: { color: *accent } + - size: { width: 80, height: 80 } + offset: { x: 16, y: 16 } + origin: end bottom + icon: { value: material/star, color: '#ffffff' } diff --git a/python/tests/fixtures/social/layer-composition/mkdocs.yml b/python/tests/fixtures/social/layer-composition/mkdocs.yml new file mode 100644 index 0000000..648b646 --- /dev/null +++ b/python/tests/fixtures/social/layer-composition/mkdocs.yml @@ -0,0 +1,8 @@ +site_name: Layer composition +site_url: https://example.test/docs/ +site_dir: site +theme: { name: material } +plugins: + - social: + cache: false + cards_layout: composed diff --git a/python/tests/fixtures/social/layout-options/docs/background.svg b/python/tests/fixtures/social/layout-options/docs/background.svg new file mode 100644 index 0000000..2ed02a5 --- /dev/null +++ b/python/tests/fixtures/social/layout-options/docs/background.svg @@ -0,0 +1,4 @@ + + + + diff --git a/python/tests/fixtures/social/layout-options/docs/index.md b/python/tests/fixtures/social/layout-options/docs/index.md new file mode 100644 index 0000000..15953ba --- /dev/null +++ b/python/tests/fixtures/social/layout-options/docs/index.md @@ -0,0 +1,8 @@ +--- +title: Home title +description: Home description. +--- + +# Home + +The global layout options supply the visible title and description. diff --git a/python/tests/fixtures/social/layout-options/docs/logo.svg b/python/tests/fixtures/social/layout-options/docs/logo.svg new file mode 100644 index 0000000..4f73dfe --- /dev/null +++ b/python/tests/fixtures/social/layout-options/docs/logo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/python/tests/fixtures/social/layout-options/docs/override.md b/python/tests/fixtures/social/layout-options/docs/override.md new file mode 100644 index 0000000..29c4989 --- /dev/null +++ b/python/tests/fixtures/social/layout-options/docs/override.md @@ -0,0 +1,15 @@ +--- +title: Page title +description: Page description. +social: + cards_layout_options: + background_image: null + color: '#ffe0a3' + logo: null + title: Page card title + description: Page card description +--- + +# Override + +This page merges its layout options with the global settings. diff --git a/python/tests/fixtures/social/layout-options/mkdocs.yml b/python/tests/fixtures/social/layout-options/mkdocs.yml new file mode 100644 index 0000000..bac1a9d --- /dev/null +++ b/python/tests/fixtures/social/layout-options/mkdocs.yml @@ -0,0 +1,23 @@ +site_name: Layout options +site_description: Default card description. +site_url: https://example.test/docs/ +site_dir: site +theme: + name: material + palette: + primary: teal + font: + text: Roboto +plugins: + - social: + cache: false + cache_dir: social-cache + cards_layout: default + cards_layout_options: + background_color: '#2d4059' + background_image: docs/background.svg + color: '#ffffff' + font_family: Roboto + logo: docs/logo.svg + title: Global title + description: Global description diff --git a/python/tests/fixtures/social/lifecycle/docs/background.svg b/python/tests/fixtures/social/lifecycle/docs/background.svg new file mode 100644 index 0000000..465bc52 --- /dev/null +++ b/python/tests/fixtures/social/lifecycle/docs/background.svg @@ -0,0 +1,3 @@ + + + diff --git a/python/tests/fixtures/social/lifecycle/docs/index.md b/python/tests/fixtures/social/lifecycle/docs/index.md new file mode 100644 index 0000000..34fc413 --- /dev/null +++ b/python/tests/fixtures/social/lifecycle/docs/index.md @@ -0,0 +1,5 @@ +--- +title: Before title +--- + +# Home diff --git a/python/tests/fixtures/social/lifecycle/layouts/flat.yml b/python/tests/fixtures/social/lifecycle/layouts/flat.yml new file mode 100644 index 0000000..18c4ee3 --- /dev/null +++ b/python/tests/fixtures/social/lifecycle/layouts/flat.yml @@ -0,0 +1,6 @@ +tags: + og:image: '{{ image.url }}' + x:title: '{{ page.meta.get("title", page.title) }}' +size: { width: 320, height: 168 } +layers: + - background: { image: docs/background.svg, color: transparent } diff --git a/python/tests/fixtures/social/lifecycle/mkdocs.yml b/python/tests/fixtures/social/lifecycle/mkdocs.yml new file mode 100644 index 0000000..d789b8f --- /dev/null +++ b/python/tests/fixtures/social/lifecycle/mkdocs.yml @@ -0,0 +1,9 @@ +site_name: Cached social lifecycle +site_url: https://example.test/docs/ +site_dir: site +theme: { name: material } +plugins: + - social: + cache: true + cache_dir: social-cache + cards_layout: flat diff --git a/python/tests/fixtures/social/lifecycle/steps.json b/python/tests/fixtures/social/lifecycle/steps.json new file mode 100644 index 0000000..5b99823 --- /dev/null +++ b/python/tests/fixtures/social/lifecycle/steps.json @@ -0,0 +1,20 @@ +[ + { + "path": "docs/background.svg", + "before": "fill=\"red\"", + "after": "fill=\"blue\"", + "changes": ["cards"] + }, + { + "path": "docs/index.md", + "before": "Before title", + "after": "After title", + "changes": ["pages"] + }, + { + "path": "layouts/flat.yml", + "before": "color: transparent", + "after": "color: '#73628a'", + "changes": ["cards"] + } +] diff --git a/python/tests/fixtures/social/logo-icon/docs/index.md b/python/tests/fixtures/social/logo-icon/docs/index.md new file mode 100644 index 0000000..3552d38 --- /dev/null +++ b/python/tests/fixtures/social/logo-icon/docs/index.md @@ -0,0 +1,6 @@ +--- +title: A bright idea +description: A card with a logo icon. +--- + +# Home diff --git a/python/tests/fixtures/social/logo-icon/mkdocs.yml b/python/tests/fixtures/social/logo-icon/mkdocs.yml new file mode 100644 index 0000000..b33df16 --- /dev/null +++ b/python/tests/fixtures/social/logo-icon/mkdocs.yml @@ -0,0 +1,11 @@ +site_name: Logo icon cards +site_url: https://example.test/docs/ +site_dir: site +theme: + name: material + icon: + logo: material/star +plugins: + - social: + cache: false + cache_dir: social-cache diff --git a/python/tests/fixtures/social/metadata/docs/guides/.meta.yml b/python/tests/fixtures/social/metadata/docs/guides/.meta.yml new file mode 100644 index 0000000..01ff2f7 --- /dev/null +++ b/python/tests/fixtures/social/metadata/docs/guides/.meta.yml @@ -0,0 +1,4 @@ +social: + cards_layout_options: + background_color: '#445566' + label: inherited diff --git a/python/tests/fixtures/social/metadata/docs/guides/index.md b/python/tests/fixtures/social/metadata/docs/guides/index.md new file mode 100644 index 0000000..b0f52a5 --- /dev/null +++ b/python/tests/fixtures/social/metadata/docs/guides/index.md @@ -0,0 +1 @@ +# Inherited options diff --git a/python/tests/fixtures/social/metadata/docs/guides/override.md b/python/tests/fixtures/social/metadata/docs/guides/override.md new file mode 100644 index 0000000..68687f3 --- /dev/null +++ b/python/tests/fixtures/social/metadata/docs/guides/override.md @@ -0,0 +1,8 @@ +--- +social: + cards_layout_options: + background_color: '#778899' + label: page +--- + +# Page options diff --git a/python/tests/fixtures/social/metadata/docs/index.md b/python/tests/fixtures/social/metadata/docs/index.md new file mode 100644 index 0000000..f8d1ea4 --- /dev/null +++ b/python/tests/fixtures/social/metadata/docs/index.md @@ -0,0 +1 @@ +# Global options diff --git a/python/tests/fixtures/social/metadata/layouts/flat.yml b/python/tests/fixtures/social/metadata/layouts/flat.yml new file mode 100644 index 0000000..828d5cd --- /dev/null +++ b/python/tests/fixtures/social/metadata/layouts/flat.yml @@ -0,0 +1,6 @@ +tags: + og:image: '{{ image.url }}' + x:label: '{{ layout.label }}' +size: { width: 320, height: 168 } +layers: + - background: { color: '{{ layout.background_color }}' } diff --git a/python/tests/fixtures/social/metadata/mkdocs.yml b/python/tests/fixtures/social/metadata/mkdocs.yml new file mode 100644 index 0000000..c6b336e --- /dev/null +++ b/python/tests/fixtures/social/metadata/mkdocs.yml @@ -0,0 +1,12 @@ +site_name: Social metadata +site_url: https://example.test/docs/ +site_dir: site +theme: { name: material } +plugins: + - meta + - social: + cache: false + cards_layout: flat + cards_layout_options: + background_color: '#112233' + label: global diff --git a/python/tests/fixtures/social/multiple/docs/guide.md b/python/tests/fixtures/social/multiple/docs/guide.md new file mode 100644 index 0000000..8c0d02f --- /dev/null +++ b/python/tests/fixtures/social/multiple/docs/guide.md @@ -0,0 +1 @@ +# Guide diff --git a/python/tests/fixtures/social/multiple/docs/index.md b/python/tests/fixtures/social/multiple/docs/index.md new file mode 100644 index 0000000..291ca38 --- /dev/null +++ b/python/tests/fixtures/social/multiple/docs/index.md @@ -0,0 +1 @@ +# Home diff --git a/python/tests/fixtures/social/multiple/layouts/flat.yml b/python/tests/fixtures/social/multiple/layouts/flat.yml new file mode 100644 index 0000000..8e35098 --- /dev/null +++ b/python/tests/fixtures/social/multiple/layouts/flat.yml @@ -0,0 +1,6 @@ +tags: + og:image: '{{ image.url }}' + x:instance: '{{ layout.label }}' +size: { width: 320, height: 168 } +layers: + - background: { color: '{{ layout.color }}' } diff --git a/python/tests/fixtures/social/multiple/mkdocs.yml b/python/tests/fixtures/social/multiple/mkdocs.yml new file mode 100644 index 0000000..fb0170a --- /dev/null +++ b/python/tests/fixtures/social/multiple/mkdocs.yml @@ -0,0 +1,17 @@ +site_name: Social instances +site_url: https://example.test/docs/ +site_dir: site +theme: { name: material } +plugins: + - social: + cache: false + cards_layout: flat + cards_dir: assets/cards/first + cards_include: ['index.md'] + cards_layout_options: { label: first, color: '#102030' } + - social: + cache: false + cards_layout: flat + cards_dir: assets/cards/second + cards_include: ['guide.md'] + cards_layout_options: { label: second, color: '#405060' } diff --git a/python/tests/fixtures/social/no-site-url/docs/index.md b/python/tests/fixtures/social/no-site-url/docs/index.md new file mode 100644 index 0000000..291ca38 --- /dev/null +++ b/python/tests/fixtures/social/no-site-url/docs/index.md @@ -0,0 +1 @@ +# Home diff --git a/python/tests/fixtures/social/no-site-url/layouts/flat.yml b/python/tests/fixtures/social/no-site-url/layouts/flat.yml new file mode 100644 index 0000000..b4548cd --- /dev/null +++ b/python/tests/fixtures/social/no-site-url/layouts/flat.yml @@ -0,0 +1,5 @@ +tags: + og:image: '{{ image.url }}' +size: { width: 320, height: 168 } +layers: + - background: { color: '#123456' } diff --git a/python/tests/fixtures/social/no-site-url/mkdocs.yml b/python/tests/fixtures/social/no-site-url/mkdocs.yml new file mode 100644 index 0000000..6416702 --- /dev/null +++ b/python/tests/fixtures/social/no-site-url/mkdocs.yml @@ -0,0 +1,7 @@ +site_name: Social without URL +site_dir: site +theme: { name: material } +plugins: + - social: + cache: false + cards_layout: flat diff --git a/python/tests/fixtures/social/paths/card-layouts/flat.yml b/python/tests/fixtures/social/paths/card-layouts/flat.yml new file mode 100644 index 0000000..05057bb --- /dev/null +++ b/python/tests/fixtures/social/paths/card-layouts/flat.yml @@ -0,0 +1,6 @@ +tags: + og:image: '{{ image.url }}' + og:url: '{{ page.canonical_url }}' +size: { width: 320, height: 168 } +layers: + - background: { color: '#486284' } diff --git a/python/tests/fixtures/social/paths/docs/guides.md b/python/tests/fixtures/social/paths/docs/guides.md new file mode 100644 index 0000000..50a8673 --- /dev/null +++ b/python/tests/fixtures/social/paths/docs/guides.md @@ -0,0 +1 @@ +# Guides diff --git a/python/tests/fixtures/social/paths/docs/index.md b/python/tests/fixtures/social/paths/docs/index.md new file mode 100644 index 0000000..291ca38 --- /dev/null +++ b/python/tests/fixtures/social/paths/docs/index.md @@ -0,0 +1 @@ +# Home diff --git a/python/tests/fixtures/social/paths/docs/nested/index.md b/python/tests/fixtures/social/paths/docs/nested/index.md new file mode 100644 index 0000000..53eb92e --- /dev/null +++ b/python/tests/fixtures/social/paths/docs/nested/index.md @@ -0,0 +1 @@ +# Nested index diff --git a/python/tests/fixtures/social/paths/mkdocs.yml b/python/tests/fixtures/social/paths/mkdocs.yml new file mode 100644 index 0000000..aceb359 --- /dev/null +++ b/python/tests/fixtures/social/paths/mkdocs.yml @@ -0,0 +1,11 @@ +site_name: Flat URL paths +site_url: https://example.test/docs/ +site_dir: site +use_directory_urls: false +theme: { name: material } +plugins: + - social: + cache: false + cards_dir: assets/cards + cards_layout_dir: card-layouts + cards_layout: flat diff --git a/python/tests/fixtures/social/recoverable-error/docs/index.md b/python/tests/fixtures/social/recoverable-error/docs/index.md new file mode 100644 index 0000000..291ca38 --- /dev/null +++ b/python/tests/fixtures/social/recoverable-error/docs/index.md @@ -0,0 +1 @@ +# Home diff --git a/python/tests/fixtures/social/recoverable-error/layouts/missing-image.yml b/python/tests/fixtures/social/recoverable-error/layouts/missing-image.yml new file mode 100644 index 0000000..30d1025 --- /dev/null +++ b/python/tests/fixtures/social/recoverable-error/layouts/missing-image.yml @@ -0,0 +1,5 @@ +tags: + og:image: '{{ image.url }}' +size: { width: 320, height: 168 } +layers: + - background: { image: docs/does-not-exist.svg } diff --git a/python/tests/fixtures/social/recoverable-error/mkdocs.yml b/python/tests/fixtures/social/recoverable-error/mkdocs.yml new file mode 100644 index 0000000..d77eb9a --- /dev/null +++ b/python/tests/fixtures/social/recoverable-error/mkdocs.yml @@ -0,0 +1,10 @@ +site_name: Recoverable social error +site_url: https://example.test/docs/ +site_dir: site +theme: { name: material } +plugins: + - social: + cache: false + cards_layout: missing-image + log: true + log_level: ignore diff --git a/python/tests/fixtures/social/template-context/docs/guides/advanced.md b/python/tests/fixtures/social/template-context/docs/guides/advanced.md new file mode 100644 index 0000000..931fae0 --- /dev/null +++ b/python/tests/fixtures/social/template-context/docs/guides/advanced.md @@ -0,0 +1 @@ +# Advanced diff --git a/python/tests/fixtures/social/template-context/docs/index.md b/python/tests/fixtures/social/template-context/docs/index.md new file mode 100644 index 0000000..291ca38 --- /dev/null +++ b/python/tests/fixtures/social/template-context/docs/index.md @@ -0,0 +1 @@ +# Home diff --git a/python/tests/fixtures/social/template-context/layouts/context.yml b/python/tests/fixtures/social/template-context/layouts/context.yml new file mode 100644 index 0000000..cfa8df0 --- /dev/null +++ b/python/tests/fixtures/social/template-context/layouts/context.yml @@ -0,0 +1,7 @@ +tags: + og:image: '{{ image.url }}' + x:source: '{{ page.file.src_uri }}' + x:page: '{{ page.url }}' +size: { width: 320, height: 168 } +layers: + - background: { color: '#357192' } diff --git a/python/tests/fixtures/social/template-context/mkdocs.yml b/python/tests/fixtures/social/template-context/mkdocs.yml new file mode 100644 index 0000000..0de90ac --- /dev/null +++ b/python/tests/fixtures/social/template-context/mkdocs.yml @@ -0,0 +1,8 @@ +site_name: Social template context +site_url: https://example.test/docs/ +site_dir: site +theme: { name: material } +plugins: + - social: + cache: false + cards_layout: context diff --git a/python/tests/fixtures/social/theme-defaults/docs/assets/logo.png b/python/tests/fixtures/social/theme-defaults/docs/assets/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..57433543dcd936b781f6b21c45e80cb09c593132 GIT binary patch literal 1266 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1|$#LC7uRSY)RhkE)4%caKYZ?lNlITmV3H5 zhE&XXd*^QU>{N;4AK!n?s7}~zsIhNKr}N9Wb0r2_=UJX-bxrhIdxp7`&qeS%dk?4F zndJLw9AV2`9WUA^w3prB<&S>O&3DR7RZCB{WOIzQ$EMV`mr^{d?t8Ak|JlF>01Eq6tiM|9G@DJb&=Z`#{RRNR`RrjA!n; zH?msYkXEQY*?5uZMz>#`3!6<`z|CW-hP~n;-#&QySaT=nd>5H?_8ZfUN!45@)4F&5 z7BZMoEp#&JHV}DU-(Yc}|;lMlk$;0;GR# zXQ(@C+nQ3mjNw_4zv9g^;s<=*X{yYYKXdmz+d0N*)l#0P8ZRC?qsOp$=kke9{AS&3 zz16y4<(kDFe8B;+-|p73{9u+iWjRr0Ge?f`<{-v|G}Q+Y_hzar#&o62F^7~w8 z7KbJ*-p%pGVDsmI@2L}J>N2<&`Kq3jSvR*P;^W2hci9^zA9!~q*i&eEW1;cxpxudA z)9cISnf(}N3-hVHUeEK!aP!Y!(&2B}4K~L&{5?=KYo|(MYC_V}lkIHMPG{aoFWCNX z@xm>q8=2F--91}(qriQ_%vzp3EPwX!iMQxjs;Sknz`bUf7ns>%ltTW z=3m}($6ZC2?)=qD&^O?F!}N~*9ZOr|!8{xHgy}0|j{M~QG53_%KFJ3yGymRv*&NTh Y!1BZ5D|YLr0Shq(Pgg&ebxsLQ08_^ + + + diff --git a/python/tests/fixtures/social/theme-defaults/docs/exact.md b/python/tests/fixtures/social/theme-defaults/docs/exact.md new file mode 100644 index 0000000..8e01a8f --- /dev/null +++ b/python/tests/fixtures/social/theme-defaults/docs/exact.md @@ -0,0 +1,7 @@ +--- +title: Italic +social: + cards_layout: exact +--- + +# Exact font face diff --git a/python/tests/fixtures/social/theme-defaults/docs/index.md b/python/tests/fixtures/social/theme-defaults/docs/index.md new file mode 100644 index 0000000..8e1099b --- /dev/null +++ b/python/tests/fixtures/social/theme-defaults/docs/index.md @@ -0,0 +1,6 @@ +--- +title: Theme colors +description: Card colors and logo come from the theme. +--- + +# Home diff --git a/python/tests/fixtures/social/theme-defaults/docs/png.md b/python/tests/fixtures/social/theme-defaults/docs/png.md new file mode 100644 index 0000000..cb9e223 --- /dev/null +++ b/python/tests/fixtures/social/theme-defaults/docs/png.md @@ -0,0 +1,9 @@ +--- +title: PNG logo +description: This page uses a raster logo through its layout options. +social: + cards_layout_options: + logo: docs/assets/logo.png +--- + +# PNG logo diff --git a/python/tests/fixtures/social/theme-defaults/docs/variant.md b/python/tests/fixtures/social/theme-defaults/docs/variant.md new file mode 100644 index 0000000..087fdbe --- /dev/null +++ b/python/tests/fixtures/social/theme-defaults/docs/variant.md @@ -0,0 +1,9 @@ +--- +title: Heavier font variant +description: This page selects the Black variant of Roboto. +social: + cards_layout_options: + font_variant: Black +--- + +# Variant diff --git a/python/tests/fixtures/social/theme-defaults/layouts/exact.yml b/python/tests/fixtures/social/theme-defaults/layouts/exact.yml new file mode 100644 index 0000000..f66f210 --- /dev/null +++ b/python/tests/fixtures/social/theme-defaults/layouts/exact.yml @@ -0,0 +1,12 @@ +tags: + og:image: '{{ image.url }}' +size: { width: 1200, height: 630 } +layers: + - background: { color: '#ffc107' } + - size: { width: 900, height: 150 } + offset: { x: 64, y: 180 } + typography: + content: '{{ page.title }}' + color: '#000000' + line: { amount: 1, height: 1.25 } + font: { family: Roboto, variant: Black, style: Italic } diff --git a/python/tests/fixtures/social/theme-defaults/mkdocs.yml b/python/tests/fixtures/social/theme-defaults/mkdocs.yml new file mode 100644 index 0000000..1dbff58 --- /dev/null +++ b/python/tests/fixtures/social/theme-defaults/mkdocs.yml @@ -0,0 +1,21 @@ +site_name: Theme defaults +site_description: Theme-derived card settings. +site_url: https://example.test/docs/ +site_dir: site +theme: + name: material + logo: assets/logo.svg + palette: + - media: '(prefers-color-scheme: light)' + primary: amber + accent: deep orange + - media: '(prefers-color-scheme: dark)' + primary: indigo + accent: pink + font: + text: Roboto +plugins: + - social: + cache: false + cache_dir: social-cache + cards_layout: default diff --git a/python/tests/integration/test_exclude.py b/python/tests/integration/test_exclude.py index 242de2c..c106aec 100644 --- a/python/tests/integration/test_exclude.py +++ b/python/tests/integration/test_exclude.py @@ -103,7 +103,7 @@ def test_excludes_pages_resources_and_extra_templates( "private-note.md": "# Excluded root note\n", "root.tmp": "temporary", "files/archive.tmp": "temporary", - "files/archive.TMP": "case matters", + "files/uppercase.TMP": "case matters", "files/archive.bin": "binary", "export.html": "{{ must_not_render() }}", }.items(): @@ -141,7 +141,7 @@ def test_excludes_pages_resources_and_extra_templates( "index.html", "guide/keep/index.html", "guide/private-note/index.html", - "files/archive.TMP", + "files/uppercase.TMP", "theme.txt", ): assert (site / name).exists(), name diff --git a/python/tests/integration/test_social.py b/python/tests/integration/test_social.py new file mode 100644 index 0000000..193020b --- /dev/null +++ b/python/tests/integration/test_social.py @@ -0,0 +1,401 @@ +# Copyright (c) 2025-2026 Zensical and contributors + +# SPDX-License-Identifier: MIT +# All contributions are certified under the DCO + +"""Integration tests for native MkDocs Material social compatibility.""" + +from __future__ import annotations + +import struct +from typing import TYPE_CHECKING, Any + +import pytest + +import zensical + +if TYPE_CHECKING: + from pathlib import Path + + +_BUILD_OPTIONS: dict[str, Any] = {"clean": False, "strict": False} + + +def _png_size(path: Path) -> tuple[int, int]: + """Read PNG dimensions without adding an imaging test dependency.""" + data = path.read_bytes() + assert data.startswith(b"\x89PNG\r\n\x1a\n") + return struct.unpack(">II", data[16:24]) + + +def _write_project(root: Path) -> Path: + """Create a social project whose layout requires no network access.""" + docs = root / "docs" + layouts = root / "layouts" + docs.mkdir() + (docs / "guide").mkdir() + layouts.mkdir() + (docs / "index.md").write_text( + "---\ntitle: 'A social & card'\n---\n# Home\n", + encoding="utf-8", + ) + (docs / "guide" / "index.md").write_text( + "---\nsocial:\n cards: false\n---\n# Guide\n", + encoding="utf-8", + ) + (layouts / "plain.yml").write_text( + """\ +tags: + og:type: website + og:title: "{{ page.title }}" + og:image: "{{ image.url }}" + og:image:width: "{{ image.width }}" + x:layout: "{{ layout.label }}" +size: { width: 320, height: 168 } +layers: + - background: { color: "#123456" } +""", + encoding="utf-8", + ) + config = root / "mkdocs.yml" + config.write_text( + """\ +site_name: Social +site_url: https://example.com/docs +theme: + name: material +plugins: + - material/social: + cache: false + cards_layout: plain + cards_include: ['*.md'] + cards_layout_options: + label: measured +""", + encoding="utf-8", + ) + return config + + +def test_generates_custom_card_and_injects_metadata(tmp_path: Path) -> None: + """YAML, MiniJinja, raster output and HTML injection work together.""" + config = _write_project(tmp_path) + + zensical.build(str(config), _BUILD_OPTIONS) + + card = tmp_path / "site" / "assets" / "images" / "social" / "index.png" + assert _png_size(card) == (320, 168) + + html = (tmp_path / "site" / "index.html").read_text() + assert '' in html + assert '' in html + assert ( + '' + in html + ) + assert '' in html + assert '' in html + assert html.index('") + + assert not ( + tmp_path + / "site" + / "assets" + / "images" + / "social" + / "guide" + / "index.png" + ).exists() + + +def test_material_namespace_and_multiple_instances_are_preserved( + tmp_path: Path, +) -> None: + """Canonical and namespaced aliases remain ordered plugin instances.""" + config = _write_project(tmp_path) + config.write_text( + """\ +site_name: Social +site_url: https://example.com/docs +theme: + name: material +plugins: + - material/social: + cache: false + cards_dir: assets/cards/first + cards_layout: plain + - social/second: + cache: false + cards_dir: assets/cards/second + cards_layout: plain +""", + encoding="utf-8", + ) + + zensical.build(str(config), _BUILD_OPTIONS) + + first = tmp_path / "site" / "assets" / "cards" / "first" / "index.png" + second = tmp_path / "site" / "assets" / "cards" / "second" / "index.png" + assert first.is_file() + assert second.is_file() + + +def test_later_instance_owns_a_shared_card_path(tmp_path: Path) -> None: + """Output collisions follow MkDocs plugin ordering deterministically.""" + config = _write_project(tmp_path) + (tmp_path / "layouts" / "wide.yml").write_text( + (tmp_path / "layouts" / "plain.yml") + .read_text() + .replace("width: 320, height: 168", "width: 640, height: 320"), + encoding="utf-8", + ) + config.write_text( + """\ +site_name: Social +site_url: https://example.com +theme: { name: material } +plugins: + - social: { cache: false, cards_layout: plain } + - social/last: { cache: false, cards_layout: wide } +""", + encoding="utf-8", + ) + + zensical.build(str(config), _BUILD_OPTIONS) + + card = tmp_path / "site" / "assets" / "images" / "social" / "index.png" + assert _png_size(card) == (640, 320) + + +def test_without_site_url_generates_but_does_not_link_card( + tmp_path: Path, +) -> None: + """A missing site URL suppresses metadata without suppressing output.""" + config = _write_project(tmp_path) + config.write_text( + config.read_text().replace("site_url: https://example.com/docs\n", "") + ) + + zensical.build(str(config), _BUILD_OPTIONS) + + assert ( + tmp_path / "site" / "assets" / "images" / "social" / "index.png" + ).is_file() + assert ( + ' None: + """A changed layout dependency invalidates the persistent card cache.""" + config = _write_project(tmp_path) + layout = tmp_path / "layouts" / "plain.yml" + layout.write_text( + layout.read_text().replace( + 'background: { color: "#123456" }', + 'background: { image: "{{ config.docs_dir }}/background.svg" }', + ), + encoding="utf-8", + ) + background = tmp_path / "docs" / "background.svg" + background.write_text( + '' + '', + encoding="utf-8", + ) + config.write_text(config.read_text().replace(" cache: false\n", "")) + + zensical.build(str(config), _BUILD_OPTIONS) + card = tmp_path / "site" / "assets" / "images" / "social" / "index.png" + before = card.read_bytes() + + background.write_text( + background.read_text().replace('fill="red"', 'fill="blue"'), + encoding="utf-8", + ) + zensical.build(str(config), _BUILD_OPTIONS) + + assert card.read_bytes() != before + cached = (tmp_path / ".cache/plugin/social/cards").glob("*.png") + assert len(list(cached)) == 2 + + +def test_unrelated_images_do_not_invalidate_cached_cards( + tmp_path: Path, +) -> None: + """The invalidation signal does not become part of the card cache key.""" + config = _write_project(tmp_path) + config.write_text( + config.read_text().replace(" cache: false\n", "") + ) + + zensical.build(str(config), _BUILD_OPTIONS) + cache = tmp_path / ".cache/plugin/social/cards" + assert len(list(cache.glob("*.png"))) == 1 + + (tmp_path / "docs" / "unused.svg").write_text( + '', + encoding="utf-8", + ) + zensical.build(str(config), _BUILD_OPTIONS) + + assert len(list(cache.glob("*.png"))) == 1 + + +def test_concurrent_identical_cards_do_not_share_temporary_files( + tmp_path: Path, +) -> None: + """Equal card digests remain safe across concurrent page jobs.""" + config = _write_project(tmp_path) + config.write_text( + config.read_text().replace( + " cache: false\n", + " cache: false\n concurrency: 8\n", + ) + ) + for index in range(8): + (tmp_path / "docs" / f"page-{index}.md").write_text( + f"# Page {index}\n", + encoding="utf-8", + ) + + zensical.build(str(config), _BUILD_OPTIONS) + + directory = tmp_path / "site/assets/images/social" + assert all( + (directory / f"page-{index}.png").is_file() + for index in range(8) + ) + + +def test_supports_bundled_image_only_layout(tmp_path: Path) -> None: + """All layouts shipped by upstream are available without Python imaging.""" + config = _write_project(tmp_path) + background = tmp_path / "docs" / "background.svg" + background.write_text( + '' + '', + encoding="utf-8", + ) + config.write_text( + config.read_text() + .replace( + " cards_layout: plain\n", + " cards_layout: default/only/image\n", + ) + .replace( + " cards_layout_options:\n label: measured\n", + " cards_layout_options:\n" + " background_image: docs/background.svg\n", + ) + ) + + zensical.build(str(config), _BUILD_OPTIONS) + + card = tmp_path / "site/assets/images/social/index.png" + assert _png_size(card) == (1200, 630) + html = (tmp_path / "site/index.html").read_text() + assert '' in html + + +def test_root_readme_is_a_homepage_in_layout_context(tmp_path: Path) -> None: + """MkDocs treats a root README exactly like a root index page.""" + config = _write_project(tmp_path) + (tmp_path / "docs/index.md").rename(tmp_path / "docs/README.md") + layout = tmp_path / "layouts/plain.yml" + layout.write_text( + layout.read_text().replace( + ' x:layout: "{{ layout.label }}"\n', + ' x:layout: "{{ layout.label }}"\n' + ' x:homepage: "{{ page.is_homepage }}"\n', + ), + encoding="utf-8", + ) + + zensical.build(str(config), _BUILD_OPTIONS) + + html = (tmp_path / "site/index.html").read_text() + assert '' in html + + +def test_honors_documented_log_levels_and_strict_mode(tmp_path: Path) -> None: + """Ignored errors stay quiet, while warnings fail strict builds.""" + config = _write_project(tmp_path) + layout = tmp_path / "layouts/plain.yml" + layout.write_text( + layout.read_text().replace( + 'background: { color: "#123456" }', + 'background: { image: "missing.png" }', + ), + encoding="utf-8", + ) + config.write_text( + config.read_text().replace( + " cache: false\n", + " cache: false\n log_level: ignore\n", + ) + ) + + zensical.build(str(config), _BUILD_OPTIONS) + assert not ( + tmp_path / "site/assets/images/social/index.png" + ).exists() + + config.write_text( + config.read_text().replace("log_level: ignore", "log_level: warn") + ) + with pytest.raises(RuntimeError, match="strict flag"): + zensical.build( + str(config), {"clean": False, "strict": True} + ) + + +def test_rejects_unknown_social_configuration(tmp_path: Path) -> None: + """Rust validation reports the precise plugin option path.""" + config = _write_project(tmp_path) + config.write_text( + config.read_text().replace( + " cache: false\n", " unknown: true\n" + ) + ) + + with pytest.raises(ValueError, match=r"plugins\.social\.unknown"): + zensical.build(str(config), _BUILD_OPTIONS) + + +def test_rejects_invalid_page_overrides_before_error_logging( + tmp_path: Path, +) -> None: + """Page configuration errors remain fatal when render errors are logged.""" + config = _write_project(tmp_path) + index = tmp_path / "docs/index.md" + index.write_text( + "---\nsocial:\n cards_layout_options: invalid\n---\n# Home\n", + encoding="utf-8", + ) + + with pytest.raises(RuntimeError, match="cards_layout_options"): + zensical.build(str(config), _BUILD_OPTIONS) + + +def test_warns_for_deprecated_options( + tmp_path: Path, capfd: pytest.CaptureFixture[str] +) -> None: + """Accepted legacy settings point users to their layout replacements.""" + config = _write_project(tmp_path) + config.write_text( + config.read_text().replace( + " cache: false\n", + " cache: false\n" + " cards_color: red\n" + " cards_font: Roboto\n", + ) + ) + + zensical.build(str(config), _BUILD_OPTIONS) + + stderr = capfd.readouterr().err + assert "'cards_color' option" in stderr + assert "'cards_font' option" in stderr diff --git a/python/zensical/config.py b/python/zensical/config.py index 5ade825..556a100 100644 --- a/python/zensical/config.py +++ b/python/zensical/config.py @@ -165,6 +165,7 @@ _PLUGIN_UNSUPPORTED_OPTIONS = { "pipeline", "prebuild_index", ), + "social": (), "table-reader": (), "tags": ( "tags_compare", @@ -568,6 +569,13 @@ def _apply_defaults(config: dict, path: str) -> dict: elif "theme" not in config: config["theme"] = {} + font_explicit = "font" in config["theme"] + configured_icons = config["theme"].get("icon") + logo_icon_explicit = ( + isinstance(configured_icons, dict) + and configured_icons.get("logo") is not None + ) + # Set defaults for custom theme directory set_default(config["theme"], "custom_dir", None, str) @@ -597,6 +605,7 @@ def _apply_defaults(config: dict, path: str) -> dict: config["theme"] = {**theme_config, **config["theme"]} theme = config["theme"] + theme["font_explicit"] = font_explicit # Set defaults for theme name # (we do this after loading the theme configuration @@ -629,6 +638,7 @@ def _apply_defaults(config: dict, path: str) -> dict: # Set defaults for theme icons icon = set_default(theme, "icon", {}, dict) + icon["logo_explicit"] = logo_icon_explicit set_default(icon, "repo", None, str) set_default(icon, "annotation", None, str) set_default(icon, "tag", {}, dict) @@ -1738,13 +1748,15 @@ def _convert_plugins(value: Any, config: dict) -> dict: tags: list[dict[str, Any]] = [] blogs: list[dict[str, Any]] = [] rss: list[dict[str, Any]] = [] + social: list[dict[str, Any]] = [] def add(name: Any, data: Any) -> None: """Canonicalize Material aliases while preserving tag instances.""" if not isinstance(name, str): raise ConfigurationError("Plugin names must be strings") name = name.removeprefix("material/") - if name not in _PLUGIN_UNSUPPORTED_OPTIONS: + canonical = "social" if name.startswith("social/") else name + if canonical not in _PLUGIN_UNSUPPORTED_OPTIONS: return if data is None: data = {} @@ -1752,13 +1764,15 @@ def _convert_plugins(value: Any, config: dict) -> dict: raise ConfigurationError(f"{name} configuration must be a mapping") else: data = dict(data) - for option in _PLUGIN_UNSUPPORTED_OPTIONS[name]: + for option in _PLUGIN_UNSUPPORTED_OPTIONS[canonical]: data.pop(option, None) if name == "tags": _reject_unknown_options("tags", data, _TAGS_SUPPORTED_OPTIONS) tags.append({"name": name, "config": data}) elif name == "blog": blogs.append({"name": name, "config": data}) + elif canonical == "social": + social.append({"name": name, "config": data}) elif name == "rss": rss.append({"name": name, "config": _normalize_rss(data)}) else: @@ -1791,6 +1805,8 @@ def _convert_plugins(value: Any, config: dict) -> dict: plugins["blogs"] = blogs plugins["rss"] = rss + # Preserve ordered social instances for native validation and rendering. + plugins["social"] = social # Search is enabled by default, even when it isn't explicitly configured. search = plugins.pop("search", {}) _reject_unknown_options("search", search, {"enabled", "separator"}) diff --git a/scripts/social_compatibility.py b/scripts/social_compatibility.py new file mode 100755 index 0000000..fcdf005 --- /dev/null +++ b/scripts/social_compatibility.py @@ -0,0 +1,283 @@ +#!/usr/bin/env python + +# Copyright (c) 2025-2026 Zensical and contributors + +# SPDX-License-Identifier: MIT +# All contributions are certified under the DCO + +# 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. + +"""Compare generated Material and Zensical social cards and metadata.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import shutil +import subprocess +import tempfile +from pathlib import Path + +from bs4 import BeautifulSoup +from PIL import Image, ImageChops, ImageStat + +ROOT = Path(__file__).resolve().parents[1] +FIXTURES = ROOT / "python" / "tests" / "fixtures" / "social" + + +def arguments() -> argparse.Namespace: + """Parse builder paths and optional cases.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--mkdocs", type=Path, required=True) + parser.add_argument("--zensical", type=Path, required=True) + parser.add_argument("--case", action="append", dest="cases") + parser.add_argument( + "--output", type=Path, help="keep builds in this empty directory" + ) + parser.add_argument( + "--font-cache", type=Path, help="existing social font cache" + ) + return parser.parse_args() + + +def build(executable: Path, project: Path, *, strict: bool) -> None: + """Build one copied fixture in its own project directory.""" + command = [ + str(executable), + "build", + "--clean", + "--config-file", + "mkdocs.yml", + ] + if strict: + command.append("--strict") + result = subprocess.run( + command, cwd=project, capture_output=True, text=True, check=False + ) + (project / "build.log").write_text( + f"$ {' '.join(command)}\n{result.stdout}{result.stderr}", + encoding="utf-8", + ) + if result.returncode: + details = (result.stderr or result.stdout).strip().splitlines() + summary = details[-1] if details else "no diagnostics" + raise RuntimeError( + f"{executable.name} build failed in {project} " + f"({result.returncode}): {summary}" + ) + + +def manifest(site: Path) -> dict: + """Extract only social metadata and generated image facts.""" + pages = {} + for path in sorted(site.rglob("*.html")): + if path.name == "404.html": + continue + soup = BeautifulSoup(path.read_text(encoding="utf-8"), "lxml") + if soup.head is None: + raise ValueError(f"generated page lacks a head element: {path}") + tags = [ + [property_name, tag.get("content")] + for tag in soup.head.find_all("meta", property=True) + if isinstance(property_name := tag.get("property"), str) + and property_name.startswith(("og:", "twitter:", "x:")) + ] + pages[path.relative_to(site).as_posix()] = tags + + cards = {} + for path in sorted(site.rglob("*.png")): + relative = path.relative_to(site).as_posix() + if not relative.startswith(("assets/images/social/", "assets/cards/")): + continue + with Image.open(path) as image: + cards[relative] = list(image.size) + return {"pages": pages, "cards": cards} + + +def image_difference(first: Path, second: Path) -> float: + """Measure mean RGB channel difference independent of PNG encoding.""" + with Image.open(first) as left, Image.open(second) as right: + if left.size != right.size: + return float("inf") + difference = ImageChops.difference( + left.convert("RGB"), right.convert("RGB") + ) + return sum(ImageStat.Stat(difference).mean) / 3 + + +def compare(name: str, material: Path, zensical: Path) -> bool: + """Show semantic and visual differences for one fixture.""" + expected = manifest(material / "site") + actual = manifest(zensical / "site") + (material / "manifest.json").write_text( + json.dumps(expected, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + (zensical / "manifest.json").write_text( + json.dumps(actual, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + cards = set(expected["cards"]) | set(actual["cards"]) + differences = { + path: round( + image_difference( + material / "site" / path, zensical / "site" / path + ), + 3, + ) + for path in sorted(set(expected["cards"]) & set(actual["cards"])) + } + matched = expected == actual and all( + value <= 1 for value in differences.values() + ) + print( + f"{name}: {'MATCH' if matched else 'DIFF'}; " + f"{len(expected['pages'])} pages, {len(cards)} card paths; " + f"mean RGB error {max(differences.values(), default=0):.3f}" + ) + if expected != actual: + for key in ("pages", "cards"): + for path in sorted(set(expected[key]) | set(actual[key])): + if expected[key].get(path) != actual[key].get(path): + print(f" {key}/{path}") + print(f" Material: {expected[key].get(path)}") + print(f" Zensical: {actual[key].get(path)}") + for path, error in differences.items(): + if error > 1: + print(f" pixels/{path}: mean RGB error {error:.3f}") + return matched + + +def snapshot(project: Path) -> dict: + """Capture visible metadata and card contents between cached builds.""" + site = project / "site" + current = manifest(site) + return { + "pages": current["pages"], + "cards": { + path: hashlib.sha256((site / path).read_bytes()).hexdigest() + for path in current["cards"] + }, + } + + +def apply_step(project: Path, step: dict) -> None: + """Apply one exact source replacement in a copied fixture.""" + path = project / step["path"] + source = path.read_text(encoding="utf-8") + before = step["before"] + if source.count(before) != 1: + raise ValueError(f"expected one occurrence of {before!r} in {path}") + path.write_text(source.replace(before, step["after"]), encoding="utf-8") + + +def run(root: Path, args: argparse.Namespace) -> bool: + """Build selected fixtures in both engines and compare their outputs.""" + names = args.cases or sorted( + path.name + for path in FIXTURES.iterdir() + if (path / "mkdocs.yml").is_file() + ) + unknown = [ + name for name in names if not (FIXTURES / name / "mkdocs.yml").is_file() + ] + if unknown: + raise ValueError(f"unknown social cases: {', '.join(unknown)}") + matched = True + for name in names: + project = FIXTURES / name + material = root / name / "material" + zensical = root / name / "zensical" + shutil.copytree(project, material) + shutil.copytree(project, zensical) + if args.font_cache and name in { + "bundled", + "custom-typography", + "debug", + "debug-no-grid", + "layout-options", + "logo-icon", + "theme-defaults", + }: + for destination in (material, zensical): + shutil.copytree( + args.font_cache, + destination / "social-cache/fonts", + ) + strict = name not in { + "debug", + "debug-no-grid", + "deprecated", + "no-site-url", + } + try: + build(args.mkdocs, material, strict=strict) + build(args.zensical, zensical, strict=strict) + matched &= compare(name, material, zensical) + steps_file = project / "steps.json" + if not steps_file.is_file(): + continue + for index, step in enumerate(json.loads(steps_file.read_text())): + previous = { + "material": snapshot(material), + "zensical": snapshot(zensical), + } + for destination in (material, zensical): + apply_step(destination, step) + build(args.mkdocs, material, strict=strict) + build(args.zensical, zensical, strict=strict) + label = f"{name}/step-{index + 1}" + matched &= compare(label, material, zensical) + for engine, destination in ( + ("material", material), + ("zensical", zensical), + ): + current = snapshot(destination) + for kind in step["changes"]: + if previous[engine][kind] == current[kind]: + print(f"{label}: {engine} {kind} did not change") + matched = False + except RuntimeError as error: + print(f"{name}: ERROR; {error}") + matched = False + continue + return matched + + +def main() -> int: + """Run the selected compatibility matrix.""" + args = arguments() + args.mkdocs = args.mkdocs.resolve() + args.zensical = args.zensical.resolve() + if args.font_cache: + args.font_cache = args.font_cache.resolve() + if args.output: + root = args.output.resolve() + root.mkdir(parents=True, exist_ok=True) + if any(root.iterdir()): + raise ValueError(f"output directory must be empty: {root}") + print(f"Builds: {root}") + return 0 if run(root, args) else 1 + with tempfile.TemporaryDirectory(prefix="zensical-social-parity-") as raw: + return 0 if run(Path(raw), args) else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/uv.lock b/uv.lock index 78ce29c..530da27 100644 --- a/uv.lock +++ b/uv.lock @@ -744,6 +744,100 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, ] +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/c2/669d88644cddb1485bd9534e63e8cf476c8e51cb3c3a1297677023505c0e/pillow-12.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:6c0016e7b354317c4e9e525b937ac8596c38d2d232b419529b9cd7a1cd46e39a", size = 5392418, upload-time = "2026-07-01T11:53:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ba/3762f376a2948e3036488d773a146e0ae6ecc2ca03ac20e2615bd0b2ba02/pillow-12.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bcc33feacfaefce60c12fd500a277533bdc02b10a19f7f6d348763d8140bbba7", size = 4785287, upload-time = "2026-07-01T11:53:29.761Z" }, + { url = "https://files.pythonhosted.org/packages/07/50/b5d688cc9c52d4482f3d5bcab6ce20bc2a74a85d2343841c907444a3be2c/pillow-12.3.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5594fc43d548a7ed94949d139aa1341b270f1863f11cfd37f5a6c8b778a6b67f", size = 6253754, upload-time = "2026-07-01T11:53:32.298Z" }, + { url = "https://files.pythonhosted.org/packages/4e/89/36f4cd76cf4baf05c50ababb976249153f18c959171c7f6ba09a6f217260/pillow-12.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0606c8bf2cdefea14a43530f7657cbbb7ecf1c4222512492ef4a4434a9501ec", size = 6925605, upload-time = "2026-07-01T11:53:34.487Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c0/4de58cf6633b9e3a6061ef4be6fb91fc3c90b812ece886f531e3c523d777/pillow-12.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:85f998ea1848bc6757289e739cfbdda3a04adfd58b02fc018ce54d754a5ce468", size = 6327788, upload-time = "2026-07-01T11:53:36.433Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/14d53682a19550dbbaf3b598f807d5457646c510805a44c7d7891cd1cd1a/pillow-12.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:25b9b82bb22e6e2b3cd07b39c68b7b862001226cb3dff7130d1cb914121b39ed", size = 7036288, upload-time = "2026-07-01T11:53:38.712Z" }, + { url = "https://files.pythonhosted.org/packages/38/1d/36279e3c77efe034e4cc2b0393ee74ffdb5a62391dacbf9b916154f5f0b8/pillow-12.3.0-cp310-cp310-win32.whl", hash = "sha256:37dc8f7bbb66efe481bb60defacef820c950c24713fb44962ed6aa2a50966de1", size = 6472396, upload-time = "2026-07-01T11:53:40.781Z" }, + { url = "https://files.pythonhosted.org/packages/48/7c/8fa0039574c476d7c6fa57dd7c32a130436877c6ec1e5ce1cc8ec44878c1/pillow-12.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:300557495eb45ebb8aec96c2da9c4be642fbf7cd937278b4013ba894ea8eb0eb", size = 7226887, upload-time = "2026-07-01T11:53:42.764Z" }, + { url = "https://files.pythonhosted.org/packages/fa/17/e324be141d173c1c919428066c3259f21c1b8982e564e01a4a81e96dbdcf/pillow-12.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:514435a37670e3e5e08f3945b68718b6ed329bb84367777e16f9f4dfe1e61a0f", size = 2568039, upload-time = "2026-07-01T11:53:45.372Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415, upload-time = "2026-07-01T11:53:47.162Z" }, + { url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266, upload-time = "2026-07-01T11:53:49.079Z" }, + { url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814, upload-time = "2026-07-01T11:53:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd", size = 6934408, upload-time = "2026-07-01T11:53:53.487Z" }, + { url = "https://files.pythonhosted.org/packages/a3/48/9c58b685e69d49c31af6c8eb9012055fab7e665785165c84796e2c73ce72/pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c", size = 6337160, upload-time = "2026-07-01T11:53:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/ff/fa/dc2a5c0ba6df93f67c31d34b808b7ce440b40cdbf96f0b81cde1d1e6fa93/pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5", size = 7045172, upload-time = "2026-07-01T11:53:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/86/a5/444817a4d4c4c2417df00513086ca196f388d8f9ef40c2e4ccd1ad1af54b/pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b", size = 6472232, upload-time = "2026-07-01T11:53:59.767Z" }, + { url = "https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a", size = 7233653, upload-time = "2026-07-01T11:54:02.066Z" }, + { url = "https://files.pythonhosted.org/packages/fd/16/00f91ab7760dc842f5aad55217e80fc4a7067a0604535249bc8a2d6d9870/pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26", size = 2568195, upload-time = "2026-07-01T11:54:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, + { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, + { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/75/18/2e8b40223153ccbc60df07f9e8928dc0c76202aa4e55ae9f53962b6510d6/pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468", size = 5302510, upload-time = "2026-07-01T11:56:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/46/3e/51fabf59d5ab801ceab709453d3ab6b180083496579549de4c45ced6528a/pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94", size = 4736058, upload-time = "2026-07-01T11:56:28.041Z" }, + { url = "https://files.pythonhosted.org/packages/bf/20/22fe9384b7949e25fb1293bcfc84fb82590ff4ea6b37c95b24d26d793d86/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e", size = 5237776, upload-time = "2026-07-01T11:56:30.263Z" }, + { url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358, upload-time = "2026-07-01T11:56:32.68Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" }, +] + [[package]] name = "platformdirs" version = "4.11.12" @@ -1120,6 +1214,7 @@ dev = [ { name = "mkdocstrings", extra = ["python"] }, { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "pandas", version = "3.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pillow" }, { name = "pytest" }, { name = "ruff" }, { name = "tabulate" }, @@ -1148,6 +1243,7 @@ dev = [ { name = "maturin", specifier = ">=1.10.2" }, { name = "mkdocstrings", extras = ["python"], specifier = ">=1.0.6" }, { name = "pandas", specifier = ">=2.3.3" }, + { name = "pillow", specifier = ">=11.3.0" }, { name = "pytest", specifier = ">=9.0.3" }, { name = "ruff", specifier = ">=0.12.8" }, { name = "tabulate", specifier = ">=0.10.0" },