diff --git a/Cargo.lock b/Cargo.lock index 277e75c..06d7766 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -186,6 +186,12 @@ 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.6" @@ -225,12 +231,56 @@ dependencies = [ "zbus", ] +[[package]] +name = "askama" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b79091df18a97caea757e28cd2d5fda49c6cd4bd01ddffd7ff01ace0c0ad2c28" +dependencies = [ + "askama_derive", + "askama_escape 0.10.3", + "humansize", + "num-traits", + "percent-encoding", +] + +[[package]] +name = "askama_derive" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19fe8d6cb13c4714962c072ea496f3392015f0989b1a2847bb4b2d9effd71d83" +dependencies = [ + "askama_parser", + "basic-toml", + "mime", + "mime_guess", + "proc-macro2", + "quote", + "serde", + "syn 2.0.110", +] + +[[package]] +name = "askama_escape" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "619743e34b5ba4e9703bba34deac3427c72507c7159f5fd030aea8cac0cfe341" + [[package]] name = "askama_escape" version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3df27b8d5ddb458c5fb1bbc1ce172d4a38c614a97d550b0ac89003897fb01de4" +[[package]] +name = "askama_parser" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acb1161c6b64d1c3d83108213c2a2533a342ac225aabd0bda218278c2ddb00c0" +dependencies = [ + "nom", +] + [[package]] name = "ast_node" version = "3.0.4" @@ -538,6 +588,15 @@ version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + [[package]] name = "better_scoped_tls" version = "1.0.1" @@ -733,12 +792,24 @@ dependencies = [ "allocator-api2", ] +[[package]] +name = "bytemuck" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" + [[package]] name = "byteorder" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[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.11.0" @@ -1003,6 +1074,12 @@ dependencies = [ "objc", ] +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "colorchoice" version = "1.0.4" @@ -1278,6 +1355,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + [[package]] name = "cow-replace" version = "0.1.1" @@ -1550,6 +1636,12 @@ dependencies = [ "syn 2.0.110", ] +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + [[package]] name = "deadpool" version = "0.12.3" @@ -2653,7 +2745,7 @@ version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "592391fc30a77f94bc5a3385d1569052907e3b3cecb28099671b9d5801dee6c6" dependencies = [ - "askama_escape", + "askama_escape 0.13.0", "dioxus-core 0.7.1", "dioxus-core-types 0.7.1", "rustc-hash 2.1.1", @@ -3250,6 +3342,12 @@ dependencies = [ "miniz_oxide", ] +[[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.3.2" @@ -3278,6 +3376,29 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree", +] + +[[package]] +name = "fontdb" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3a6f9af55fb97ad673fb7a69533eb2f967648a06fa21f8c9bb2cd6d33975716" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", + "ttf-parser", +] + [[package]] name = "foreign-types" version = "0.3.2" @@ -3675,6 +3796,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "gif" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gimli" version = "0.32.3" @@ -4501,6 +4632,22 @@ dependencies = [ "version_check", ] +[[package]] +name = "image-webp" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f79afb8cbee2ef20f59ccd477a218c12a93943d075b492015ecb1bb81f8ee904" +dependencies = [ + "byteorder-lite", + "quick-error 2.0.1", +] + +[[package]] +name = "imagesize" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285" + [[package]] name = "indexmap" version = "1.9.3" @@ -5036,6 +5183,17 @@ dependencies = [ "selectors", ] +[[package]] +name = "kurbo" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62" +dependencies = [ + "arrayvec", + "euclid", + "smallvec", +] + [[package]] name = "langtag" version = "0.4.0" @@ -5903,7 +6061,7 @@ dependencies = [ "log", "mime", "mime_guess", - "quick-error", + "quick-error 1.2.3", "rand 0.8.5", "safemem", "tempfile", @@ -6702,6 +6860,12 @@ dependencies = [ "siphasher 1.0.1", ] +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + [[package]] name = "pin-project" version = "1.1.10" @@ -6977,6 +7141,12 @@ version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.37.5" @@ -7343,6 +7513,23 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" +[[package]] +name = "resvg" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a325d5e8d1cebddd070b13f44cec8071594ab67d1012797c121f27a669b7958" +dependencies = [ + "gif", + "image-webp", + "log", + "pico-args", + "rgb", + "svgtypes", + "tiny-skia", + "usvg", + "zune-jpeg", +] + [[package]] name = "rfc6979" version = "0.4.0" @@ -7377,6 +7564,15 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "rgb" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce" +dependencies = [ + "bytemuck", +] + [[package]] name = "ring" version = "0.17.14" @@ -7415,6 +7611,12 @@ dependencies = [ "url", ] +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + [[package]] name = "rsa" version = "0.9.9" @@ -7528,6 +7730,24 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rustybuzz" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c85d1ccd519e61834798eb52c4e886e8c2d7d698dd3d6ce0b1b47eb8557f1181" +dependencies = [ + "bitflags 2.10.0", + "bytemuck", + "core_maths", + "log", + "smallvec", + "ttf-parser", + "unicode-bidi-mirroring", + "unicode-ccc", + "unicode-properties", + "unicode-script", +] + [[package]] name = "ryu" version = "1.0.20" @@ -8111,6 +8331,15 @@ version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" +[[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" @@ -8198,6 +8427,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "smawk" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" + [[package]] name = "smol_str" version = "0.3.4" @@ -8339,6 +8574,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_cache" version = "0.8.9" @@ -8436,6 +8680,16 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" +[[package]] +name = "svgtypes" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc" +dependencies = [ + "kurbo", + "siphasher 1.0.1", +] + [[package]] name = "swc_allocator" version = "4.0.1" @@ -8808,6 +9062,7 @@ version = "0.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" dependencies = [ + "smawk", "unicode-linebreak", "unicode-width 0.2.2", ] @@ -8904,6 +9159,32 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + [[package]] name = "tiny_http" version = "0.12.0" @@ -9403,6 +9684,15 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "ttf-parser" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be21190ff5d38e8b4a2d3b6a3ae57f612cc39c96e83cedeaf7abc338a8bac4a" +dependencies = [ + "core_maths", +] + [[package]] name = "tungstenite" version = "0.23.0" @@ -9527,6 +9817,24 @@ version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-bidi-mirroring" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64af057ad7466495ca113126be61838d8af947f41d93a949980b2389a118082f" + +[[package]] +name = "unicode-ccc" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "260bc6647b3893a9a90668360803a15f96b85a5257b1c3a0c3daf6ae2496de42" + [[package]] name = "unicode-id-start" version = "1.4.0" @@ -9554,12 +9862,30 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-script" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb421b350c9aff471779e262955939f565ec18b86c15364e6bdf0d662ca7c1f" + [[package]] name = "unicode-segmentation" version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[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.1.14" @@ -9608,6 +9934,33 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" +[[package]] +name = "usvg" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7447e703d7223b067607655e625e0dbca80822880248937da65966194c4864e6" +dependencies = [ + "base64 0.22.1", + "data-url", + "flate2", + "fontdb", + "imagesize", + "kurbo", + "log", + "pico-args", + "roxmltree", + "rustybuzz", + "simplecss", + "siphasher 1.0.1", + "strict-num", + "svgtypes", + "tiny-skia-path", + "unicode-bidi", + "unicode-script", + "unicode-vo", + "xmlwriter", +] + [[package]] name = "utf-8" version = "0.7.6" @@ -9929,6 +10282,7 @@ dependencies = [ name = "weaver-app" version = "0.1.0" dependencies = [ + "askama", "axum", "base64 0.22.1", "chrono", @@ -9938,6 +10292,7 @@ dependencies = [ "dioxus-free-icons", "dioxus-primitives", "dotenvy", + "fontdb", "gloo-storage", "gloo-timers", "http", @@ -9958,17 +10313,21 @@ dependencies = [ "regex", "regex-lite", "reqwest", + "resvg", "serde", "serde_html_form", "serde_ipld_dagcbor", "serde_json", "syntect", + "textwrap", "time", + "tiny-skia", "tokio", "tracing", "tracing-subscriber", "tracing-wasm", "urlencoding", + "usvg", "wasm-bindgen", "wasm-bindgen-futures", "weaver-api", @@ -10249,6 +10608,12 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "widestring" version = "1.2.1" @@ -10889,6 +11254,12 @@ dependencies = [ "markup5ever 0.12.1", ] +[[package]] +name = "xmlwriter" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" + [[package]] name = "xxhash-rust" version = "0.8.15" @@ -11123,6 +11494,21 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + +[[package]] +name = "zune-jpeg" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" +dependencies = [ + "zune-core", +] + [[package]] name = "zvariant" version = "5.8.0" diff --git a/crates/weaver-app/Cargo.toml b/crates/weaver-app/Cargo.toml index aa6625f..84e6f3f 100644 --- a/crates/weaver-app/Cargo.toml +++ b/crates/weaver-app/Cargo.toml @@ -14,7 +14,7 @@ no-app-index = [] web = ["dioxus/web", "dioxus-primitives/web"] desktop = ["dioxus/desktop"] mobile = ["dioxus/mobile"] -server = [ "dioxus/server", "dep:jacquard-axum", "dep:axum"] +server = [ "dioxus/server", "dep:jacquard-axum", "dep:axum", "dep:resvg", "dep:usvg", "dep:tiny-skia", "dep:textwrap", "dep:askama", "dep:fontdb"] [dependencies] @@ -56,6 +56,14 @@ web-time = "1.1" urlencoding = "2.1" tracing-subscriber = { version = "0.3", default-features = false, features = ["std", "registry"] } +# OG image generation (server-only) +resvg = { version = "0.44", optional = true } +usvg = { version = "0.44", optional = true } +tiny-skia = { version = "0.11", optional = true } +textwrap = { version = "0.16", optional = true } +askama = { version = "0.12", optional = true } +fontdb = { version = "0.22", optional = true } + [target.'cfg(not(all(target_arch = "wasm32", target_os = "unknown")))'.dependencies] webbrowser = "1.0.6" reqwest = { version = "0.12", default-features = false, features = ["json"] } diff --git a/crates/weaver-app/assets/styling/main.css b/crates/weaver-app/assets/styling/main.css index 2355c61..b7486bb 100644 --- a/crates/weaver-app/assets/styling/main.css +++ b/crates/weaver-app/assets/styling/main.css @@ -40,3 +40,46 @@ body { color: var(--color-subtle); opacity: 0.5; } + +a {{ + color: var(--color-link); + text-decoration: none; +}} + + +@font-face { + font-family: "Ioskeley Mono"; + font-style: normal; + font-weight: normal; + src: url("/assets/IoskeleyMono-Regular.woff2") format("woff2"); +} +@font-face { + font-family: "Ioskeley Mono"; + font-style: normal; + font-weight: lighter; + src: url("/assets/IoskeleyMono-Light.woff2") format("woff2"); +} +@font-face { + font-family: "Ioskeley Mono"; + font-style: italic; + font-weight: lighter; + src: url("/assets/IoskeleyMono-LightItalic.woff2") format("woff2"); +} +@font-face { + font-family: "Ioskeley Mono"; + font-style: normal; + font-weight: bold; + src: url("/assets/IoskeleyMono-Bold.woff2") format("woff2"); +} +@font-face { + font-family: "Ioskeley Mono"; + font-style: italic; + font-weight: normal; + src: url("/assets/IoskeleyMono-Italic.woff2") format("woff2"); +} +@font-face { + font-family: "Ioskeley Mono"; + font-style: italic; + font-weight: bold; + src: url("/assets/IoskeleyMono-BoldItalic.woff2") format("woff2"); +} diff --git a/crates/weaver-app/src/components/entry.rs b/crates/weaver-app/src/components/entry.rs index 219633e..eef23d7 100644 --- a/crates/weaver-app/src/components/entry.rs +++ b/crates/weaver-app/src/components/entry.rs @@ -117,6 +117,38 @@ pub fn EntryPage( } } +/// Extract a plain-text preview from markdown content (first ~160 chars) +fn extract_preview(content: &str, max_len: usize) -> String { + // Simple extraction: skip markdown syntax, get plain text + let plain: String = content + .lines() + .filter(|line| { + let trimmed = line.trim(); + // Skip headings, images, links, code blocks + !trimmed.starts_with('#') + && !trimmed.starts_with('!') + && !trimmed.starts_with("```") + && !trimmed.is_empty() + }) + .take(5) + .collect::>() + .join(" "); + + // Clean up markdown inline syntax + let cleaned = plain + .replace("**", "") + .replace("__", "") + .replace('*', "") + .replace('_', "") + .replace('`', ""); + + if cleaned.len() <= max_len { + cleaned + } else { + format!("{}...", &cleaned[..max_len - 3]) + } +} + /// Full entry page with metadata, content, and navigation #[component] fn EntryPageView( @@ -133,11 +165,75 @@ fn EntryPageView( .map(|t| t.as_ref()) .unwrap_or("Untitled"); + // Get entry path for URLs + let entry_path = entry_view + .path + .as_ref() + .map(|p| p.as_ref().to_string()) + .unwrap_or_else(|| title.to_string()); + + // Get author handle + let author_handle = entry_view + .authors + .first() + .map(|a| { + use weaver_api::sh_weaver::actor::ProfileDataViewInner; + match &a.record.inner { + ProfileDataViewInner::ProfileView(p) => p.handle.as_ref().to_string(), + ProfileDataViewInner::ProfileViewDetailed(p) => p.handle.as_ref().to_string(), + ProfileDataViewInner::TangledProfileView(p) => p.handle.as_ref().to_string(), + _ => "unknown".to_string(), + } + }) + .unwrap_or_else(|| "unknown".to_string()); + + // Build OG URLs + let base = if crate::env::WEAVER_APP_ENV == "dev" { + format!("http://127.0.0.1:{}", crate::env::WEAVER_PORT) + } else { + crate::env::WEAVER_APP_HOST.to_string() + }; + let canonical_url = format!( + "{}/{}/{}/{}", + base, + ident(), + book_title(), + entry_path + ); + let og_image_url = format!( + "{}/og/{}/{}/{}.png", + base, + ident(), + book_title(), + entry_path + ); + + // Extract description preview from content + let description = extract_preview(entry_record().content.as_ref(), 160); + + // Full page title + let page_title = format!("{} | {} | Weaver", title, book_title()); + tracing::info!("Entry: {book_title} - {title}"); rsx! { - // Set page title - document::Title { "{title}" } + // Page title and OG meta tags + document::Title { "{page_title}" } + + // OpenGraph tags + document::Meta { property: "og:title", content: "{title}" } + document::Meta { property: "og:description", content: "{description}" } + document::Meta { property: "og:image", content: "{og_image_url}" } + document::Meta { property: "og:type", content: "article" } + document::Meta { property: "og:url", content: "{canonical_url}" } + document::Meta { property: "og:site_name", content: "Weaver" } + + // Twitter Card tags + document::Meta { name: "twitter:card", content: "summary_large_image" } + document::Meta { name: "twitter:title", content: "{title}" } + document::Meta { name: "twitter:description", content: "{description}" } + document::Meta { name: "twitter:image", content: "{og_image_url}" } + document::Meta { name: "twitter:creator", content: "@{author_handle}" } document::Link { rel: "stylesheet", href: ENTRY_CSS } div { class: "entry-page-layout", diff --git a/crates/weaver-app/src/main.rs b/crates/weaver-app/src/main.rs index f6ab1bc..ca889ed 100644 --- a/crates/weaver-app/src/main.rs +++ b/crates/weaver-app/src/main.rs @@ -31,6 +31,8 @@ mod auth; #[cfg(feature = "server")] mod blobcache; mod cache_impl; +#[cfg(feature = "server")] +mod og; /// Define a components module that contains all shared components for our app. mod components; mod config; @@ -421,6 +423,170 @@ pub async fn image_entry( } } +// Route: /og/{ident}/{book_title}/{entry_title} - OpenGraph image for entry +#[cfg(all(feature = "fullstack-server", feature = "server"))] +#[get("/og/{ident}/{book_title}/{entry_title}", fetcher: Extension>)] +pub async fn og_image( + ident: SmolStr, + book_title: SmolStr, + entry_title: SmolStr, +) -> Result { + use axum::{ + http::{header::{CACHE_CONTROL, CONTENT_TYPE}, StatusCode}, + response::IntoResponse, + }; + use weaver_api::sh_weaver::actor::ProfileDataViewInner; + use weaver_api::sh_weaver::notebook::Title; + + // Strip .png extension if present + let entry_title = entry_title.strip_suffix(".png").unwrap_or(&entry_title); + + let Ok(at_ident) = AtIdentifier::new_owned(ident.clone()) else { + return Ok((StatusCode::BAD_REQUEST, "Invalid identifier").into_response()); + }; + + // Fetch entry data + let entry_result = fetcher.get_entry(at_ident.clone(), book_title.clone(), entry_title.into()).await; + + let arc_data = match entry_result { + Ok(Some(data)) => data, + Ok(None) => return Ok((StatusCode::NOT_FOUND, "Entry not found").into_response()), + Err(e) => { + tracing::error!("Failed to fetch entry for OG image: {:?}", e); + return Ok((StatusCode::INTERNAL_SERVER_ERROR, "Failed to fetch entry").into_response()); + } + }; + let (book_entry, entry) = arc_data.as_ref(); + + // Build cache key using entry CID + let entry_cid = book_entry.entry.cid.as_ref(); + let cache_key = og::cache_key(&ident, &book_title, entry_title, entry_cid); + + // Check cache first + if let Some(cached) = og::get_cached(&cache_key) { + return Ok(( + [ + (CONTENT_TYPE, "image/png"), + (CACHE_CONTROL, "public, max-age=3600"), + ], + cached, + ).into_response()); + } + + // Extract metadata + let title: &str = entry.title.as_ref(); + + // Use book_title from URL - it's the notebook slug/title + // TODO: Could fetch actual notebook record to get display title + let notebook_title_str: String = book_title.to_string(); + + let author_handle = book_entry.entry.authors.first() + .map(|a| match &a.record.inner { + ProfileDataViewInner::ProfileView(p) => p.handle.as_ref().to_string(), + ProfileDataViewInner::ProfileViewDetailed(p) => p.handle.as_ref().to_string(), + ProfileDataViewInner::TangledProfileView(p) => p.handle.as_ref().to_string(), + _ => "unknown".to_string(), + }) + .unwrap_or_else(|| "unknown".to_string()); + + // Check for hero image in embeds + let hero_image_data = if let Some(ref embeds) = entry.embeds { + if let Some(ref images) = embeds.images { + if let Some(first_image) = images.images.first() { + // Get DID from the entry URI + let did = book_entry.entry.uri.authority(); + + let blob = first_image.image.blob(); + let cid = blob.cid(); + let mime = blob.mime_type.as_ref(); + let format = mime.strip_prefix("image/").unwrap_or("jpeg"); + + // Build CDN URL + let cdn_url = format!( + "https://cdn.bsky.app/img/feed_fullsize/plain/{}/{}@{}", + did.as_str(), cid.as_ref(), format + ); + + // Fetch the image + match reqwest::get(&cdn_url).await { + Ok(response) if response.status().is_success() => { + match response.bytes().await { + Ok(bytes) => { + use base64::Engine; + let base64_str = base64::engine::general_purpose::STANDARD.encode(&bytes); + Some(format!("data:{};base64,{}", mime, base64_str)) + } + Err(_) => None + } + } + _ => None + } + } else { + None + } + } else { + None + } + } else { + None + }; + + // Extract content snippet - render markdown to HTML then strip tags + let content_snippet: String = { + let parser = markdown_weaver::Parser::new(entry.content.as_ref()); + let mut html = String::new(); + markdown_weaver::html::push_html(&mut html, parser); + // Strip HTML tags + regex_lite::Regex::new(r"<[^>]+>") + .unwrap() + .replace_all(&html, "") + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("'", "'") + .replace('\n', " ") + .split_whitespace() + .collect::>() + .join(" ") + }; + + // Generate image - hero or text-only based on available data + let png_bytes = if let Some(ref hero_data) = hero_image_data { + match og::generate_hero_image(hero_data, title, ¬ebook_title_str, &author_handle) { + Ok(bytes) => bytes, + Err(e) => { + tracing::error!("Failed to generate hero OG image: {:?}, falling back to text", e); + og::generate_text_only(title, &content_snippet, ¬ebook_title_str, &author_handle) + .map_err(|e| { + tracing::error!("Failed to generate text OG image: {:?}", e); + }) + .ok() + .unwrap_or_default() + } + } + } else { + match og::generate_text_only(title, &content_snippet, ¬ebook_title_str, &author_handle) { + Ok(bytes) => bytes, + Err(e) => { + tracing::error!("Failed to generate OG image: {:?}", e); + return Ok((StatusCode::INTERNAL_SERVER_ERROR, "Failed to generate image").into_response()); + } + } + }; + + // Cache the generated image + og::cache_image(cache_key, png_bytes.clone()); + + Ok(( + [ + (CONTENT_TYPE, "image/png"), + (CACHE_CONTROL, "public, max-age=3600"), + ], + png_bytes, + ).into_response()) +} + // #[server(endpoint = "static_routes", output = server_fn::codec::Json)] // async fn static_routes() -> Result, ServerFnError> { // // The `Routable` trait has a `static_routes` method that returns all static routes in the enum diff --git a/crates/weaver-app/src/og/mod.rs b/crates/weaver-app/src/og/mod.rs new file mode 100644 index 0000000..dfbfaf3 --- /dev/null +++ b/crates/weaver-app/src/og/mod.rs @@ -0,0 +1,206 @@ +//! OpenGraph image generation module +//! +//! Generates social card images for entry pages using SVG templates rendered to PNG. + +use askama::Template; +use std::sync::OnceLock; +use std::time::Duration; + +use crate::cache_impl::{Cache, new_cache}; + +/// Cache for generated OG images +/// Key: "{ident}/{book}/{entry}/{cid}" - includes CID for invalidation +static OG_CACHE: OnceLock>> = OnceLock::new(); + +fn get_cache() -> &'static Cache> { + OG_CACHE.get_or_init(|| { + // Cache up to 1000 images for 1 hour + new_cache(1000, Duration::from_secs(3600)) + }) +} + +/// Generate cache key from entry identifiers +pub fn cache_key(ident: &str, book: &str, entry: &str, cid: &str) -> String { + format!("{}/{}/{}/{}", ident, book, entry, cid) +} + +/// Try to get a cached OG image +pub fn get_cached(key: &str) -> Option> { + get_cache().get(&key.to_string()) +} + +/// Store an OG image in the cache +pub fn cache_image(key: String, image: Vec) { + get_cache().insert(key, image); +} + +/// Error type for OG image generation +#[derive(Debug)] +pub enum OgError { + NotFound, + FetchError(String), + RenderError(String), + TemplateError(String), +} + +impl std::fmt::Display for OgError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + OgError::NotFound => write!(f, "Entry not found"), + OgError::FetchError(e) => write!(f, "Fetch error: {}", e), + OgError::RenderError(e) => write!(f, "Render error: {}", e), + OgError::TemplateError(e) => write!(f, "Template error: {}", e), + } + } +} + +impl std::error::Error for OgError {} + +/// Standard OG image dimensions +pub const OG_WIDTH: u32 = 1200; +pub const OG_HEIGHT: u32 = 630; + +/// Rose Pine theme colors +mod colors { + pub const BASE: &str = "#191724"; + pub const TEXT: &str = "#e0def4"; + pub const SUBTLE: &str = "#908caa"; + pub const MUTED: &str = "#6e6a86"; + pub const OVERLAY: &str = "#524f67"; +} + +/// Text-only template (no hero image) +#[derive(Template)] +#[template(path = "og_text_only.svg", escape = "none")] +pub struct TextOnlyTemplate { + pub title_lines: Vec, + pub content_lines: Vec, + pub notebook_title: String, + pub author_handle: String, +} + +/// Hero image template (full-bleed image with overlay) +#[derive(Template)] +#[template(path = "og_hero_image.svg", escape = "none")] +pub struct HeroImageTemplate { + pub hero_image_data: String, + pub title_lines: Vec, + pub notebook_title: String, + pub author_handle: String, +} + +/// Global font database, initialized once +static FONTDB: OnceLock = OnceLock::new(); + +fn get_fontdb() -> &'static fontdb::Database { + FONTDB.get_or_init(|| { + let mut db = fontdb::Database::new(); + // Load IBM Plex Sans from embedded bytes + let font_data = include_bytes!("../../assets/fonts/IBMPlexSans-VariableFont_wdth,wght.ttf"); + db.load_font_data(font_data.to_vec()); + let font_data = + include_bytes!("../../assets/fonts/ioskeley-mono/IoskeleyMono-Regular.woff2"); + db.load_font_data(font_data.to_vec()); + db + }) +} + +/// Wrap title text into lines that fit the SVG width +pub fn wrap_title(title: &str, max_chars: usize, max_lines: usize) -> Vec { + textwrap::wrap(title, max_chars) + .into_iter() + .take(max_lines) + .map(|s| s.to_string()) + .collect() +} + +/// Render an SVG string to PNG bytes +pub fn render_svg_to_png(svg: &str) -> Result, OgError> { + let fontdb = get_fontdb(); + + let options = usvg::Options { + fontdb: std::sync::Arc::new(fontdb.clone()), + ..Default::default() + }; + + let tree = usvg::Tree::from_str(svg, &options) + .map_err(|e| OgError::RenderError(format!("Failed to parse SVG: {}", e)))?; + + let mut pixmap = tiny_skia::Pixmap::new(OG_WIDTH, OG_HEIGHT) + .ok_or_else(|| OgError::RenderError("Failed to create pixmap".to_string()))?; + + resvg::render(&tree, tiny_skia::Transform::default(), &mut pixmap.as_mut()); + + pixmap + .encode_png() + .map_err(|e| OgError::RenderError(format!("Failed to encode PNG: {}", e))) +} + +/// Generate a text-only OG image +pub fn generate_text_only( + title: &str, + content: &str, + notebook_title: &str, + author_handle: &str, +) -> Result, OgError> { + let title_lines = wrap_title(title, 50, 2); + let content_lines = wrap_title(content, 70, 5); + + let template = TextOnlyTemplate { + title_lines, + content_lines, + notebook_title: notebook_title.to_string(), + author_handle: author_handle.to_string(), + }; + + let svg = template + .render() + .map_err(|e| OgError::TemplateError(e.to_string()))?; + + render_svg_to_png(&svg) +} + +/// Generate a hero image OG image +pub fn generate_hero_image( + hero_image_data: &str, + title: &str, + notebook_title: &str, + author_handle: &str, +) -> Result, OgError> { + let title_lines = wrap_title(title, 50, 2); + + let template = HeroImageTemplate { + hero_image_data: hero_image_data.to_string(), + title_lines, + notebook_title: notebook_title.to_string(), + author_handle: author_handle.to_string(), + }; + + let svg = template + .render() + .map_err(|e| OgError::TemplateError(e.to_string()))?; + + render_svg_to_png(&svg) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_wrap_title_short() { + let lines = wrap_title("Hello World", 28, 3); + assert_eq!(lines, vec!["Hello World"]); + } + + #[test] + fn test_wrap_title_long() { + let lines = wrap_title( + "This is a very long title that should wrap onto multiple lines", + 28, + 3, + ); + assert!(lines.len() > 1); + assert!(lines.len() <= 3); + } +} diff --git a/crates/weaver-app/templates/og_hero_image.svg b/crates/weaver-app/templates/og_hero_image.svg new file mode 100644 index 0000000..d98c567 --- /dev/null +++ b/crates/weaver-app/templates/og_hero_image.svg @@ -0,0 +1,18 @@ + + + + + + + + + {% for line in title_lines %} + {{ line }} + {% endfor %} + + + {{ notebook_title }} · @{{ author_handle }} + + + weaver.sh + diff --git a/crates/weaver-app/templates/og_text_only.svg b/crates/weaver-app/templates/og_text_only.svg new file mode 100644 index 0000000..544bcc9 --- /dev/null +++ b/crates/weaver-app/templates/og_text_only.svg @@ -0,0 +1,22 @@ + + + + + + {% for line in title_lines %} + {{ line }} + {% endfor %} + + + {{ notebook_title }} · @{{ author_handle }} + + + {% for line in content_lines %} + {{ line }} + {% endfor %} + + + + + weaver.sh +