From 72e4c23be1e884cb6eac6d96061f3b6d2c7ca61e Mon Sep 17 00:00:00 2001 From: squidfunk Date: Wed, 2 Sep 2026 20:45:31 +0200 Subject: [PATCH] feature: add `social` MkDocs plugin replacement Signed-off-by: squidfunk --- Cargo.lock | 770 +++++++++++++++- Cargo.toml | 10 + crates/zensical/Cargo.toml | 10 +- crates/zensical/src/compat/mkdocs/plugin.rs | 1 + .../src/compat/mkdocs/plugin/social.rs | 793 ++++++++++++++++ .../src/compat/mkdocs/plugin/social/font.rs | 379 ++++++++ .../src/compat/mkdocs/plugin/social/layout.rs | 513 +++++++++++ .../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 | 871 ++++++++++++++++++ .../src/compat/mkdocs/plugin/social/writer.rs | 124 +++ crates/zensical/src/config.rs | 6 +- crates/zensical/src/config/plugins.rs | 4 + crates/zensical/src/config/plugins/social.rs | 384 ++++++++ crates/zensical/src/watcher.rs | 53 +- crates/zensical/src/workflow.rs | 58 +- python/tests/integration/test_social.py | 401 ++++++++ python/zensical/config.py | 9 +- 21 files changed, 5382 insertions(+), 58 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/integration/test_social.py diff --git a/Cargo.lock b/Cargo.lock index 2604e00..7d50c04 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" @@ -145,6 +169,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" @@ -170,12 +220,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" @@ -215,6 +281,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" @@ -281,6 +356,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 = "digest" version = "0.10.7" @@ -309,7 +390,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -349,12 +430,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" @@ -364,12 +463,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" @@ -387,6 +509,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" @@ -403,7 +545,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5ff35a391aef949120a0340d690269b3d9f63460a6106e99bd07b961f345ea9" dependencies = [ "swc_macros_common", - "syn", + "syn 2.0.118", ] [[package]] @@ -449,6 +591,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" @@ -472,6 +625,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" @@ -485,6 +648,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", + "bytemuck", + "read-fonts", + "smallvec", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -540,12 +715,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" @@ -690,6 +881,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" @@ -729,7 +936,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -774,7 +981,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn", + "syn 2.0.118", ] [[package]] @@ -793,7 +1000,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -827,6 +1034,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" @@ -970,6 +1189,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" @@ -1162,7 +1401,7 @@ checksum = "b237422b014f8f8fff75bb9379e697d13f8d57551a22c88bebb39f073c1bf696" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -1203,7 +1442,7 @@ dependencies = [ "phf", "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -1457,7 +1696,7 @@ dependencies = [ "phf_shared", "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -1469,12 +1708,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", + "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" @@ -1552,7 +1819,7 @@ dependencies = [ "proc-macro2", "pyo3-macros-backend", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -1564,9 +1831,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" @@ -1617,6 +1890,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" @@ -1634,7 +1918,7 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -1666,6 +1950,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" @@ -1694,6 +2028,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" @@ -1789,7 +2158,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -1831,6 +2200,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" @@ -1847,6 +2228,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" @@ -1859,12 +2249,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" @@ -1889,6 +2298,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" @@ -1897,7 +2315,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]] @@ -1986,7 +2420,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7e32e407d0a010fedb53cf9dfdccf091521a2c9081efc077da647f7c8963741" dependencies = [ "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -2053,7 +2487,7 @@ checksum = "c16ce73424a6316e95e09065ba6a207eba7765496fed113702278b7711d4b632" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -2064,7 +2498,7 @@ checksum = "aae1efbaa74943dc5ad2a2fb16cbd78b77d7e4d63188f3c5b4df2b4dcd2faaae" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -2087,6 +2521,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" @@ -2095,7 +2540,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -2145,7 +2590,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -2157,6 +2602,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" @@ -2168,6 +2639,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" @@ -2187,7 +2673,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -2264,6 +2750,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" @@ -2282,18 +2774,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" @@ -2306,12 +2845,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" @@ -2393,7 +2965,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.118", "wasm-bindgen-shared", ] @@ -2432,6 +3004,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" @@ -2447,13 +3034,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]] @@ -2465,6 +3061,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" @@ -2472,58 +3084,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" @@ -2548,6 +3208,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" @@ -2573,7 +3239,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", "synstructure", ] @@ -2584,9 +3250,11 @@ dependencies = [ "ahash", "anyhow", "ariadne", + "base64 0.22.1", "crossbeam", "fluent-uri", "globset", + "html-escape", "html5gum", "icu_casemap", "icu_locale_core", @@ -2602,10 +3270,12 @@ dependencies = [ "percent-encoding", "pyo3", "regex", + "resvg", "saphyr", "serde", "serde_json", "sha2", + "svgtypes", "swc_common", "swc_css", "tempfile", @@ -2613,6 +3283,7 @@ dependencies = [ "tracing", "tracing-chrome", "tracing-subscriber", + "ureq", "webbrowser", "zensical-serve", "zensical-watch", @@ -2623,7 +3294,7 @@ dependencies = [ name = "zensical-serve" version = "0.0.6" dependencies = [ - "base64", + "base64 0.22.1", "crossbeam", "httparse", "httpdate", @@ -2667,7 +3338,7 @@ checksum = "4714fd92cf900833d49538023a9b3915155210801d1c1169eba513b2addefd71" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -2687,10 +3358,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" @@ -2723,9 +3400,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" @@ -2841,3 +3524,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 68499cd..b6c460b 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" @@ -74,16 +75,25 @@ 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", +] } 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 e0f1f16..f8c5ad8 100644 --- a/crates/zensical/Cargo.toml +++ b/crates/zensical/Cargo.toml @@ -48,17 +48,22 @@ 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 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 @@ -68,13 +73,16 @@ oxc_span.workspace = true percent-encoding.workspace = true pyo3.workspace = true regex.workspace = true +resvg.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 e4095eb..c6b2b13 100644 --- a/crates/zensical/src/compat/mkdocs/plugin.rs +++ b/crates/zensical/src/compat/mkdocs/plugin.rs @@ -42,6 +42,7 @@ pub mod minify; pub mod mkdocstrings; pub mod redirects; 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..4c20ff5 --- /dev/null +++ b/crates/zensical/src/compat/mkdocs/plugin/social.rs @@ -0,0 +1,793 @@ +// 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 { + instances: Arc<[Instance]>, + concurrency: usize, + 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 { + tags: Arc<[Tag]>, + hash: u64, +} + +#[derive(Clone, Debug)] +struct Instance { + id: usize, + name: String, + config: Arc, + project: Arc, + root: PathBuf, + renderer: Renderer, + filter: SourceFilter, + serve: bool, + strict: bool, +} + +#[derive(Clone, Debug)] +struct SourceFilter { + include: GlobSet, + exclude: GlobSet, + has_include: bool, + error: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct Card { + instance: usize, + path: SitePath, + source: PathBuf, +} + +#[derive(Clone, Debug)] +struct Bundle { + cards: Vec<(Key, Card)>, + metadata: Metadata, +} + +type Generated = (Key, Card, Vec); + +#[derive(Clone, Debug, PartialEq, Eq)] +struct Fingerprint { + source: PathBuf, + digest: [u8; 32], +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +struct AssetRevision(Arc>); + +/// Card error that upstream treats as recoverable plugin input failure. +#[derive(Debug, thiserror::Error)] +#[error("{0}")] +struct PluginError(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 { + 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, + } + } + + 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, + )) + } + + 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) + } + + 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)) + } + } + + 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) + } + + 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) + } + + 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, + )) + } + + 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 { + 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, + } + } + + 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..32c7ee1 --- /dev/null +++ b/crates/zensical/src/compat/mkdocs/plugin/social/font.rs @@ -0,0 +1,379 @@ +// 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 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 { + cache: PathBuf, + agent: ureq::Agent, + 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) + } + + 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 id = database + .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, + }, + }) + .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 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, 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"); + } +} 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..0395203 --- /dev/null +++ b/crates/zensical/src/compat/mkdocs/plugin/social/layout.rs @@ -0,0 +1,513 @@ +// 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 { + pub tags: Vec<(String, String)>, + pub size: Size, + pub layers: Vec, +} + +/// Pixel dimensions. +#[derive(Clone, Copy, Debug, Default, Hash, Serialize)] +pub struct Size { + pub width: u32, + pub height: u32, +} + +/// Signed layer offset. +#[derive(Clone, Copy, Debug, Default, Hash, Serialize)] +pub struct Offset { + pub x: i32, + pub y: i32, +} + +/// One composited card layer. +#[derive(Clone, Debug, Hash, Serialize)] +pub struct Layer { + pub size: Size, + pub offset: Offset, + pub origin: String, + pub background: Background, + pub icon: Icon, + pub typography: Typography, +} + +/// Layer background. +#[derive(Clone, Debug, Default, Hash, Serialize)] +pub struct Background { + pub color: String, + pub image: String, +} + +/// Layer icon. +#[derive(Clone, Debug, Default, Hash, Serialize)] +pub struct Icon { + pub value: String, + pub color: String, +} + +/// Layer typography. +#[derive(Clone, Debug, Hash, Serialize)] +pub struct Typography { + pub content: String, + pub align: String, + pub overflow: String, + pub color: String, + pub line: Line, + pub font: Font, +} + +/// Typography line settings. +#[derive(Clone, Copy, Debug, Serialize)] +pub struct Line { + pub amount: usize, + pub height: f64, +} + +/// Typography font settings. +#[derive(Clone, Debug, Hash, PartialEq, Eq, Serialize)] +pub struct Font { + pub family: String, + pub variant: String, + 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..3c8d549 --- /dev/null +++ b/crates/zensical/src/compat/mkdocs/plugin/social/render.rs @@ -0,0 +1,871 @@ +// 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 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: std::sync::Arc, + theme_dirs: std::sync::Arc<[PathBuf]>, + fonts: Fonts, + dependencies: Arc>>, +} + +#[derive(Serialize)] +struct ImageContext<'a> { + url: &'a str, + #[serde(rename = "type")] + kind: &'static str, + width: u32, + 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 svg = debug_svg(layout, color, grid, step)?; + let fonts = self.fonts.load(&Font { + family: "Roboto".into(), + variant: String::new(), + style: "Regular".into(), + })?; + 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() + } + + 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()) + } + + fn background(&self, value: &str) -> Result { + let path = self.background_path(value); + data_url(&path).with_context(|| { + format!("couldn't find image '{}'", path.display()) + }) + } + + 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) + } + } + + 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) + )) + } + + fn icon_source(&self, name: &str) -> Result { + let path = self.icon_path(name)?; + Ok(fs::read_to_string(path)?) + } + + 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}'"))) + } + + 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)); + Ok(context! { + config => project, + 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); + 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); + 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 line_height = size * 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); + 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) -> f64 { + let lines = u32::try_from(lines).map_or(f64::from(u32::MAX), f64::from); + let extent = lines + 0.25 + (lines - 1.0) * (line_height - 1.0); + f64::from(height) / extent +} + +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) -> f64 { + let words = align.split_whitespace().collect::>(); + if words.contains(&"top") { + 0.0 + } else if words.contains(&"bottom") { + height - text_height + } else if words.contains(&"center") { + (height - text_height) / 2.0 + } else { + 0.0 + } +} + +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, +) -> 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, + "", + 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); + write!( + body, + "", + layer.size.width, + layer.size.height, + xml_attribute(color), + ) + .expect("writing to a string cannot fail"); + let label = format!("{index} – {x}, {y}"); + let width = 12_u32.saturating_mul( + u32::try_from(label.chars().count()).unwrap_or(u32::MAX), + ); + write!( + body, + "{}", + xml_attribute(color), + x.saturating_add(4), + y.saturating_add(3), + 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 super::{ + balance_two_lines, colorize_icon, debug_svg, environment, + font_attributes, layer_svg, offset, render_svg, render_template, Font, + Layer, Layout, + }; + use minijinja::Value; + + #[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 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: super::super::layout::Size { width: 10, height: 10 }, + layers: vec![Layer::default()], + }; + assert!(debug_svg(&layout, "white", false, 1) + .unwrap() + .contains("fill=\"black\"")); + assert!(debug_svg(&layout, "not-a-color", false, 1).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..f448c25 --- /dev/null +++ b/crates/zensical/src/compat/mkdocs/plugin/social/writer.rs @@ -0,0 +1,124 @@ +// 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 +// ---------------------------------------------------------------------------- + +#[derive(Clone)] +struct Writer { + output: OutputRoot, +} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl Writer { + fn path(&self, card: &Card) -> PathBuf { + self.output.join(&card.path) + } + + 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(()) + } + + 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 = (); + + fn concurrency(&self) -> Concurrency { + Concurrency::adaptive() + } + + 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 +// ---------------------------------------------------------------------------- + +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 c71c421..0ef04d8 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::TagsPlugin; +use crate::config::plugins::{SocialPlugin, TagsPlugin}; use crate::path::{OutputRoot, SourceRoot}; mod error; @@ -124,6 +124,10 @@ impl Config { .get_item("plugins")? .get_item("tags")? .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 df33b8b..0990cf9 100644 --- a/crates/zensical/src/config/plugins.rs +++ b/crates/zensical/src/config/plugins.rs @@ -29,8 +29,10 @@ use pyo3::FromPyObject; use serde::Serialize; use std::collections::BTreeMap; +mod social; mod tags; +pub use social::{SocialPlugin, SocialPluginConfig, SocialPluginInstance}; pub use tags::{ python_bool, python_float, python_scalar, TagsListingConfig, TagsPlugin, TagsPluginConfig, @@ -62,6 +64,8 @@ pub struct Plugins { pub minify: MinifyPlugin, /// Material tags plugin instances. pub tags: TagsPlugin, + /// 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/watcher.rs b/crates/zensical/src/watcher.rs index 2c0d5b9..3f1ec40 100644 --- a/crates/zensical/src/watcher.rs +++ b/crates/zensical/src/watcher.rs @@ -94,6 +94,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, @@ -123,7 +153,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, { @@ -284,6 +313,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 (path, _) in &config.project.watched_files { agent.watch(path)?; @@ -383,6 +419,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 3fb49c0..86e3591 100644 --- a/crates/zensical/src/workflow.rs +++ b/crates/zensical/src/workflow.rs @@ -45,7 +45,7 @@ use crate::compat::mkdocs::plugin::autorefs::UnresolvedAutorefs; use crate::compat::mkdocs::{ plugin::{ self, autorefs, awesome_nav, literate_nav, meta, minify, mkdocstrings, - redirects, search, tags, + redirects, search, social, tags, }, resource, }; @@ -142,7 +142,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, @@ -167,6 +167,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 {} @@ -307,10 +309,16 @@ impl Main { }); mkdocstrings::Mkdocstrings::new(&self.config) .setup(mkdocstrings::Dependencies { navigation: &nav }); + 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, &nav, &assets, &minify); let unresolved = render_pages( &self.config, &site_page, + &social_metadata, &nav, &autorefs, &assets, @@ -381,12 +389,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() } @@ -588,22 +599,32 @@ fn template_output(id: &Id) -> Result { /// Render pages. fn render_pages( config: &Config, pages: &Stream, - nav: &Signal, autorefs: &Signal, + social: &Stream, nav: &Signal, + autorefs: &Signal, assets: &Signal, minify: &minify::Minify, ) -> 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(); @@ -622,7 +643,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, |(_, _, _, _)| { @@ -639,6 +660,7 @@ fn render_pages( // Replace autorefs and retain unresolved identifiers let (data, unresolved) = input.autorefs.replace_in(rendered, references, &page.url); + let data = input.social.inject(data); let data = minify.html(data); let path = config.output_root().join(page.destination()); 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 a05e279..3efc383 100644 --- a/python/zensical/config.py +++ b/python/zensical/config.py @@ -1294,12 +1294,15 @@ def _convert_plugins(value: Any, config: dict) -> dict: """Convert plugins configuration to something we can work with.""" plugins: dict[str, Any] = {} tags: list[dict[str, Any]] = [] + social: list[dict[str, Any]] = [] def add(name: str, data: Any) -> None: - """Canonicalize Material aliases while preserving tag instances.""" + """Canonicalize Material aliases and preserve multi-instance plugins.""" name = name.removeprefix("material/") if name == "tags": tags.append({"name": name, "config": dict(data or {})}) + elif name == "social" or name.startswith("social/"): + social.append({"name": name, "config": dict(data or {})}) else: plugins[name] = data @@ -1324,6 +1327,10 @@ def _convert_plugins(value: Any, config: dict) -> dict: # configuration, as it does for future native compatibility modules. plugins["tags"] = tags + # Rust owns all social defaults, validation and rendering. Python only + # preserves ordered plugin instances and their raw configuration. + plugins["social"] = social + # Define defaults for search plugin search = set_default(plugins, "search", {}, dict) set_default(search, "enabled", True, bool)