From c85edf242fbbd2704777a46f3525fe93c8f2ae66 Mon Sep 17 00:00:00 2001 From: LGUG2Z Date: Sun, 21 Dec 2025 10:52:57 -0800 Subject: [PATCH 01/19] feat(docgen): initial implementation of html doc generator Implement a complete Rust library and CLI tool for generating self-contained HTML documentation from schemars::Schema (JSON Schema) objects. Core Features: - Library API with `generate_html()` function and `Options` struct - CLI binary accepting JSON Schema input files with optional title override - Tera-based HTML templating with base template and reusable macros - Markdown rendering for schema descriptions via pulldown-cmark - Dark/light theme support following system preferences - Searchable properties with instant search functionality - Collapsible nested structures for complex schemas - Responsive design for mobile, tablet, and desktop Library Components: - `generator.rs`: Main HTML generation logic with Tera template engine - `prepare.rs`: Schema preprocessing for template rendering (1667 lines) - `lib.rs`: Public API with SchemaExt trait for typed schema accessors - `main.rs`: CLI interface using clap for argument parsing Template System: - `base.html`: Main HTML structure with header, sidebar, and content areas - `macros.html`: Reusable Tera macros for rendering schema components - `style.css`: Complete CSS with CSS variables for theming (955 lines) --- .gitignore | 1 + Cargo.lock | 1118 +++++++++++++++++++++++++ Cargo.toml | 26 + LICENSE.md | 108 +++ rust-toolchain.toml | 2 + rustfmt.toml | 1 + src/generator.rs | 1204 +++++++++++++++++++++++++++ src/lib.rs | 379 +++++++++ src/main.rs | 44 + src/prepare.rs | 1667 +++++++++++++++++++++++++++++++++++++ src/templates/base.html | 144 ++++ src/templates/macros.html | 348 ++++++++ src/templates/style.css | 955 +++++++++++++++++++++ 13 files changed, 5997 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE.md create mode 100644 rust-toolchain.toml create mode 100644 rustfmt.toml create mode 100644 src/generator.rs create mode 100644 src/lib.rs create mode 100644 src/main.rs create mode 100644 src/prepare.rs create mode 100644 src/templates/base.html create mode 100644 src/templates/macros.html create mode 100644 src/templates/style.css diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..ea8c4bf7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 00000000..b3a10fbc --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1118 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "cc" +version = "1.2.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f50d563227a1c37cc0a263f64eca3334388c01c5e4c4861a9def205c614383c" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link", +] + +[[package]] +name = "chrono-tz" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93698b29de5e97ad0ae26447b344c482a7284c737d9ddc5f9e52b74a336671bb" +dependencies = [ + "chrono", + "chrono-tz-build", + "phf", +] + +[[package]] +name = "chrono-tz-build" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c088aee841df9c3041febbb73934cfc39708749bf96dc827e3359cd39ef11b1" +dependencies = [ + "parse-zoneinfo", + "phf", + "phf_codegen", +] + +[[package]] +name = "clap" +version = "4.5.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "deunicode" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" + +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "find-msvc-tools" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "globwalk" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757" +dependencies = [ + "bitflags", + "ignore", + "walkdir", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "humansize" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7" +dependencies = [ + "libm", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ignore" +version = "0.4.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ee5b5339afb4c41626dde77b7a611bd4f2c202b897852b4bcf5d03eddc61010" + +[[package]] +name = "js-sys" +version = "0.3.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.178" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" + +[[package]] +name = "libm" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "parse-zoneinfo" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f2a05b18d44e2957b88f96ba460715e295bc1d7510468a2f3d3b44535d26c24" +dependencies = [ + "regex", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbcfd20a6d4eeba40179f05735784ad32bdaef05ce8e8af05f180d45bb3e7e22" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51f72981ade67b1ca6adc26ec221be9f463f2b5839c7508998daa17c23d94d7f" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dee9efd8cdb50d719a80088b76f81aec7c41ed6d522ee750178f83883d271625" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pest_meta" +version = "2.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf1d70880e76bdc13ba52eafa6239ce793d85c8e43896507e43dd8984ff05b82" +dependencies = [ + "pest", + "sha2", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + +[[package]] +name = "proc-macro2" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pulldown-cmark" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86ba2052aebccc42cbbb3ed234b8b13ce76f75c3551a303cb2bcffcff12bb14" +dependencies = [ + "bitflags", + "getopts", + "memchr", + "pulldown-cmark-escape", + "unicase", +] + +[[package]] +name = "pulldown-cmark-escape" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" + +[[package]] +name = "quote" +version = "1.0.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62049b2877bf12821e8f9ad256ee38fdc31db7387ec2d3b3f403024de2034aea" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schemars" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9558e172d4e8533736ba97870c4b2cd63f84b382a3d6eb063da41b91cce17289" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars-docgen" +version = "0.1.0" +dependencies = [ + "clap", + "pretty_assertions", + "pulldown-cmark", + "schemars", + "serde", + "serde_json", + "tera", + "thiserror", +] + +[[package]] +name = "schemars_derive" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "301858a4023d78debd2353c7426dc486001bddc91ae31a76fb1f55132f7e2633" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", + "serde_core", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "siphasher" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" + +[[package]] +name = "slug" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "882a80f72ee45de3cc9a5afeb2da0331d58df69e4e7d8eeb5d3c7784ae67e724" +dependencies = [ + "deunicode", + "wasm-bindgen", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tera" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8004bca281f2d32df3bacd59bc67b312cb4c70cea46cbd79dbe8ac5ed206722" +dependencies = [ + "chrono", + "chrono-tz", + "globwalk", + "humansize", + "lazy_static", + "percent-encoding", + "pest", + "pest_derive", + "rand", + "regex", + "serde", + "serde_json", + "slug", + "unicode-segmentation", +] + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicase" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "zerocopy" +version = "0.8.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd74ec98b9250adb3ca554bdde269adf631549f51d8a8f8f0a10b50f1cb298c3" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 00000000..3690b7e9 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "schemars-docgen" +version = "0.1.0" +edition = "2024" +description = "Generate HTML documentation from schemars::Schema objects" +license-file = "LICENSE.md" + +[lib] +name = "schemars_docgen" +path = "src/lib.rs" + +[[bin]] +name = "schemars-docgen" +path = "src/main.rs" + +[dependencies] +schemars = "1.1" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +tera = "1.19" +pulldown-cmark = "0.12" +clap = { version = "4", features = ["derive"] } +thiserror = "2" + +[dev-dependencies] +pretty_assertions = "1.4" diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 00000000..c96f9a11 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,108 @@ +# Komorebi License + +Version 2.0.0 + +## Acceptance + +In order to get any license under these terms, you must agree +to them as both strict obligations and conditions to all +your licenses. + +## Copyright License + +The licensor grants you a copyright license for the software +to do everything you might do with the software that would +otherwise infringe the licensor's copyright in it for any +permitted purpose. However, you may only distribute the source +code of the software according to the [Distribution License]( +#distribution-license), you may only make changes according +to the [Changes License](#changes-license), and you may not +otherwise distribute the software or new works based on the +software. + +## Distribution License + +The licensor grants you an additional copyright license to +distribute copies of the source code of the software. Your +license to distribute covers distributing the source code of +the software with changes permitted by the [Changes License]( +#changes-license). + +## Changes License + +The licensor grants you an additional copyright license to +make changes for any permitted purpose. + +## Patent License + +The licensor grants you a patent license for the software that +covers patent claims the licensor can license, or becomes able +to license, that you would infringe by using the software. + +## Personal Uses + +Personal use for research, experiment, and testing for +the benefit of public knowledge, personal study, private +entertainment, hobby projects, amateur pursuits, or religious +observance, without any anticipated commercial application, +is use for a permitted purpose. + +## Fair Use + +You may have "fair use" rights for the software under the +law. These terms do not limit them. + +## No Other Rights + +These terms do not allow you to sublicense or transfer any of +your licenses to anyone else, or prevent the licensor from +granting licenses to anyone else. These terms do not imply +any other licenses. + +## Patent Defense + +If you make any written claim that the software infringes or +contributes to infringement of any patent, your patent license +for the software granted under these terms ends immediately. If +your company makes such a claim, your patent license ends +immediately for work on behalf of your company. + +## Violations + +The first time you are notified in writing that you have +violated any of these terms, or done anything with the software +not covered by your licenses, your licenses can nonetheless +continue if you come into full compliance with these terms, +and take practical steps to correct past violations, within +32 days of receiving notice. Otherwise, all your licenses +end immediately. + +## No Liability + +***As far as the law allows, the software comes as is, without +any warranty or condition, and the licensor will not be liable +to you for any damages arising out of these terms or the use +or nature of the software, under any kind of legal claim.*** + +## Definitions + +The **licensor** is the individual or entity offering these +terms, and the **software** is the software the licensor makes +available under these terms. + +**You** refers to the individual or entity agreeing to these +terms. + +**Your company** is any legal entity, sole proprietorship, +or other kind of organization that you work for, plus all +organizations that have control over, are under the control of, +or are under common control with that organization. **Control** +means ownership of substantially all the assets of an entity, +or the power to direct its management and policies by vote, +contract, or otherwise. Control can be direct or indirect. + +**Your licenses** are all the licenses granted to you for the +software under these terms. + +**Use** means anything you do with the software requiring one +of your licenses. diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 00000000..31578d3b --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "stable" \ No newline at end of file diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 00000000..74c8af67 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1 @@ +imports_granularity = "Item" \ No newline at end of file diff --git a/src/generator.rs b/src/generator.rs new file mode 100644 index 00000000..a2d0a25d --- /dev/null +++ b/src/generator.rs @@ -0,0 +1,1204 @@ +//! HTML generation using Tera templates + +use crate::Error; +use crate::Options; +use crate::prepare::NavigationData; +use crate::prepare::build_navigation; +use crate::prepare::prepare_for_template; +use crate::prepare::render_markdown; +use crate::prepare::strip_markdown; +use schemars::Schema; +use serde_json::Value; +use tera::Context; +use tera::Tera; + +/// HTML documentation generator +pub struct Generator { + tera: Tera, +} + +impl Generator { + /// Create a new generator with embedded templates + pub fn new() -> Result { + let mut tera = Tera::default(); + + // Add templates from embedded strings + // Order matters: macros and style must be added before base.html which imports them + tera.add_raw_template("style.css", include_str!("templates/style.css"))?; + tera.add_raw_template("macros.html", include_str!("templates/macros.html"))?; + tera.add_raw_template("base.html", include_str!("templates/base.html"))?; + + Ok(Self { tera }) + } + + /// Generate HTML documentation from a schema + pub fn generate(&self, schema: &Schema, options: &Options) -> Result { + // Prepare the schema for template rendering + let prepared_schema = prepare_for_template(schema.as_value()); + + // Build navigation data + let navigation = build_navigation(schema.as_value()); + + // Build the template context + let context = self.build_context(&prepared_schema, &navigation, options)?; + + // Render the template + let html = self.tera.render("base.html", &context)?; + + Ok(html) + } + + /// Build the Tera context from schema and options + fn build_context( + &self, + schema: &Value, + navigation: &NavigationData, + options: &Options, + ) -> Result { + let mut context = Context::new(); + + // Title: use override, then description, then schema title + let title = options + .title_override + .clone() + .or_else(|| { + schema + .get("description") + .and_then(|v| v.as_str()) + .map(String::from) + }) + .or_else(|| { + schema + .get("title") + .and_then(|v| v.as_str()) + .map(String::from) + }) + .unwrap_or_else(|| "Schema Documentation".to_string()); + // Render title as markdown HTML (for h1 display) + let title_html = render_markdown(&title); + // Strip markdown for plain text title (for HTML tag) + let title_plain = strip_markdown(&title); + context.insert("title", &title_plain); + context.insert("title_html", &title_html); + + // The full schema for property rendering + context.insert("schema", schema); + + // Navigation data for sidebar + context.insert("navigation", navigation); + + // Search index as JSON for the JavaScript + context.insert("search_index", &navigation.search_index); + + // Type usages for "Usages" section in type definitions + context.insert("usages", &navigation.usages); + + // Build required set for the root schema + let required_set: Vec<String> = schema + .get("required") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + context.insert("required_set", &required_set); + + Ok(context) + } +} + +impl Default for Generator { + fn default() -> Self { + Self::new().expect("Failed to create default generator") + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn make_schema(value: Value) -> Schema { + serde_json::from_value(value).unwrap() + } + + #[test] + fn test_generator_new() { + let generator = Generator::new(); + assert!(generator.is_ok()); + } + + #[test] + fn test_generate_simple_schema() { + let generator = Generator::new().unwrap(); + let schema = make_schema(json!({ + "title": "TestConfig", + "description": "A test configuration", + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name" + } + } + })); + + let options = Options::default(); + let html = generator.generate(&schema, &options); + assert!(html.is_ok()); + + let html = html.unwrap(); + // Description is now used as the page title + assert!(html.contains("A test configuration")); + assert!(html.contains("name")); + assert!(html.contains("The name")); + } + + #[test] + fn test_generate_with_title_override() { + let generator = Generator::new().unwrap(); + let schema = make_schema(json!({ + "title": "OriginalTitle", + "type": "object" + })); + + let options = Options { + title_override: Some("Custom Title".to_string()), + }; + + let html = generator.generate(&schema, &options).unwrap(); + assert!(html.contains("Custom Title")); + assert!(!html.contains("<title>OriginalTitle")); + } + + #[test] + fn test_generate_with_definitions() { + let generator = Generator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "$defs": { + "MyType": { + "type": "string", + "description": "A custom type" + } + }, + "properties": { + "field": { + "$ref": "#/$defs/MyType" + } + } + })); + + let options = Options::default(); + let html = generator.generate(&schema, &options).unwrap(); + + assert!(html.contains("MyType")); + assert!(html.contains("A custom type")); + assert!(html.contains("href=\"#mytype\"")); + } + + #[test] + fn test_generate_with_enum() { + let generator = Generator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["active", "inactive", "pending"] + } + } + })); + + let options = Options::default(); + let html = generator.generate(&schema, &options).unwrap(); + + assert!(html.contains("active")); + assert!(html.contains("inactive")); + assert!(html.contains("pending")); + } + + #[test] + fn test_generate_with_anyof() { + let generator = Generator::new().unwrap(); + // Use 3 variants to ensure it's not treated as Option + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "properties": { + "value": { + "anyOf": [ + {"type": "string"}, + {"type": "number"}, + {"type": "boolean"} + ] + } + } + })); + + let options = Options::default(); + let html = generator.generate(&schema, &options).unwrap(); + + // anyOf now renders as "enum" badge + assert!(html.contains(">enum<"), "Should show enum badge"); + assert!(html.contains("string")); + assert!(html.contains("number")); + assert!(html.contains("boolean")); + } + + #[test] + fn test_generate_with_markdown_description() { + let generator = Generator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "description": "This is **bold** and `code`", + "type": "object" + })); + + let options = Options::default(); + let html = generator.generate(&schema, &options).unwrap(); + + // Description is now used as the page title (markdown stripped for ) + assert!( + html.contains("This is bold and code"), + "Description should be used as title with markdown stripped" + ); + // The h1 should have rendered markdown + assert!( + html.contains("<strong>bold</strong>"), + "h1 should have rendered markdown" + ); + } + + #[test] + fn test_generate_responsive_html() { + let generator = Generator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object" + })); + + let options = Options::default(); + let html = generator.generate(&schema, &options).unwrap(); + + // Check for responsive viewport meta tag + assert!(html.contains("viewport")); + assert!(html.contains("width=device-width")); + + // Check for responsive CSS + assert!(html.contains("@media")); + assert!(html.contains("min-width: 768px")); + } + + #[test] + fn test_generate_dark_mode_css() { + let generator = Generator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object" + })); + + let options = Options::default(); + let html = generator.generate(&schema, &options).unwrap(); + + // Check for dark mode media query + assert!(html.contains("prefers-color-scheme: dark")); + } + + // Regression test: macro parameter 'schema' was conflicting with global context 'schema' + // This caused infinite recursion when rendering properties with enum values + #[test] + fn test_enum_property_no_infinite_recursion() { + let generator = Generator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["active", "inactive", "pending"], + "description": "The status of the item" + } + } + })); + + let options = Options::default(); + let html = generator.generate(&schema, &options).unwrap(); + + // Should contain enum values, not infinite nesting + assert!(html.contains("active")); + assert!(html.contains("inactive")); + assert!(html.contains("pending")); + + // Should NOT have deeply nested duplicate property names (sign of infinite recursion) + // Count occurrences of "status" in property names - should be limited + let status_count = html.matches("data-search-name=\"status\"").count(); + assert!( + status_count <= 2, + "Expected at most 2 occurrences of status property, found {}", + status_count + ); + } + + // Regression test: anyOf properties should render correctly + // Updated: string + null is now recognized as Option<T> pattern + #[test] + fn test_anyof_property_renders_variants() { + let generator = Generator::new().unwrap(); + // Use string + number to test regular anyOf (not Option<T>) + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "properties": { + "value": { + "description": "A value that can be string or number", + "anyOf": [ + {"type": "string"}, + {"type": "number"} + ] + } + } + })); + + let options = Options::default(); + let html = generator.generate(&schema, &options).unwrap(); + + // Should show enum badge (anyOf now renders as enum) + assert!(html.contains(">enum<"), "Should contain enum badge"); + assert!( + html.contains("type-string") || html.contains(">string<"), + "Should contain string type" + ); + assert!( + html.contains("type-number") || html.contains(">number<"), + "Should contain number type" + ); + } + + // Regression test: additionalProperties check should not fail on undefined + #[test] + fn test_schema_without_additional_properties() { + let generator = Generator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "properties": { + "name": {"type": "string"} + } + })); + + let options = Options::default(); + // Should not error when additionalProperties is not defined + let result = generator.generate(&schema, &options); + assert!(result.is_ok()); + } + + // Test that descriptions are rendered as HTML (markdown processed) + #[test] + fn test_description_markdown_rendered() { + let generator = Generator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "description": "This has **bold** and `code`", + "type": "object", + "properties": { + "field": { + "type": "string", + "description": "Field with *emphasis*" + } + } + })); + + let options = Options::default(); + let html = generator.generate(&schema, &options).unwrap(); + + // Schema description is used as title (markdown stripped for <title>) + assert!( + html.contains("This has bold and code"), + "Description should be used as title with markdown stripped" + ); + // The h1 should have rendered markdown + assert!( + html.contains("<strong>bold</strong>"), + "h1 should have rendered markdown" + ); + // Property description markdown is still rendered to HTML + assert!(html.contains("<em>emphasis</em>")); + } + + // Test Option<T> (anyOf with null) is rendered as optional badge + #[test] + fn test_option_t_rendered_as_optional() { + let generator = Generator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "properties": { + "maybe_name": { + "anyOf": [ + { "type": "string" }, + { "type": "null" } + ] + } + } + })); + + let options = Options::default(); + let html = generator.generate(&schema, &options).unwrap(); + + // Should show "optional" badge + assert!(html.contains("optional"), "Should contain 'optional' badge"); + // Should show the string type + assert!( + html.contains("type-string") || html.contains(">string<"), + "Should contain string type badge" + ); + // Should NOT show "anyOf" badge for Option<T> + assert!( + !html.contains(">anyOf<"), + "Should not show anyOf badge for Option<T>" + ); + // Should NOT show variant markers + assert!( + !html.contains("Variant 1"), + "Should not show Variant markers for Option<T>" + ); + } + + // Test Option<T> with $ref + #[test] + fn test_option_t_with_ref() { + let generator = Generator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "$defs": { + "MyType": { "type": "object", "description": "A custom type" } + }, + "properties": { + "maybe_type": { + "anyOf": [ + { "$ref": "#/$defs/MyType" }, + { "type": "null" } + ] + } + } + })); + + let options = Options::default(); + let html = generator.generate(&schema, &options).unwrap(); + + // Should show "optional" badge + assert!(html.contains("optional"), "Should contain 'optional' badge"); + // Should show the type reference + assert!(html.contains("MyType"), "Should contain MyType reference"); + // Should link to the type + assert!( + html.contains("href=\"#mytype\""), + "Should link to the type definition" + ); + } + + // Test that regular anyOf (not Option<T>) still shows all variants + #[test] + fn test_regular_anyof_shows_variants() { + let generator = Generator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "properties": { + "value": { + "anyOf": [ + { "type": "string" }, + { "type": "number" } + ] + } + } + })); + + let options = Options::default(); + let html = generator.generate(&schema, &options).unwrap(); + + // Should show enum badge (anyOf now renders as enum) + assert!(html.contains(">enum<"), "Should show enum badge"); + // Should show variants summary + assert!( + html.contains("Enum variants (2)"), + "Should show variants summary" + ); + } + + // Test Option<T> with type array (e.g., type: ["boolean", "null"]) + #[test] + fn test_option_t_with_type_array() { + let generator = Generator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "properties": { + "enabled": { + "type": ["boolean", "null"], + "description": "Enable feature" + } + } + })); + + let options = Options::default(); + let html = generator.generate(&schema, &options).unwrap(); + + // Should show "optional" badge + assert!(html.contains("optional"), "Should contain 'optional' badge"); + // Should show boolean type + assert!( + html.contains("type-boolean"), + "Should contain boolean type badge" + ); + // Should NOT show null type badge in the content (CSS class definition is OK) + // Check that there's no actual >null< badge text in the rendered property + assert!( + !html.contains(">null<"), + "Should not show null type badge text for Option<T>" + ); + } + + // Test outline section in sidebar + #[test] + fn test_outline_section_in_sidebar() { + let generator = Generator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "properties": { + "alpha": { "type": "string" }, + "beta": { "type": "number" }, + "gamma": { "type": "boolean" } + } + })); + + let options = Options::default(); + let html = generator.generate(&schema, &options).unwrap(); + + // Should have Outline section + assert!(html.contains("Outline"), "Should have Outline section"); + // Should list top-level properties with links + assert!( + html.contains("href=\"#alpha\""), + "Should link to alpha property" + ); + assert!( + html.contains("href=\"#beta\""), + "Should link to beta property" + ); + assert!( + html.contains("href=\"#gamma\""), + "Should link to gamma property" + ); + } + + // Test HashMap with integer key pattern displays friendly type + #[test] + fn test_hashmap_integer_key_display() { + let generator = Generator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "properties": { + "layout_rules": { + "type": "object", + "patternProperties": { + "^\\d+$": { "type": "string" } + } + } + } + })); + + let options = Options::default(); + let html = generator.generate(&schema, &options).unwrap(); + + // Should show "integer" as the key type (with quotes to indicate string) + assert!( + html.contains("integer"), + "Should display 'integer' for ^\\d+$ pattern" + ); + assert!( + html.contains("key-type"), + "Should have key-type class for styling" + ); + // Should NOT show the raw regex pattern + assert!(!html.contains("^\\d+$"), "Should not show raw pattern"); + // Should show the arrow separator + assert!( + html.contains("→"), + "Should show arrow between key and value type" + ); + } + + // Test HashMap with string key pattern displays friendly type + #[test] + fn test_hashmap_string_key_display() { + let generator = Generator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "properties": { + "string_map": { + "type": "object", + "patternProperties": { + ".*": { "type": "number" } + } + } + } + })); + + let options = Options::default(); + let html = generator.generate(&schema, &options).unwrap(); + + // Should show "string" for .* pattern + assert!( + html.contains(">string<"), + "Should display 'string' for .* pattern" + ); + // Should NOT show the raw pattern in the output + assert!(!html.contains(">.*<"), "Should not show raw .* pattern"); + } + + // Test HashMap with unknown pattern still shows the regex + #[test] + fn test_hashmap_unknown_pattern_shows_regex() { + let generator = Generator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "properties": { + "custom_map": { + "type": "object", + "patternProperties": { + "^[a-z]+$": { "type": "string" } + } + } + } + })); + + let options = Options::default(); + let html = generator.generate(&schema, &options).unwrap(); + + // Unknown patterns should still show the regex + assert!( + html.contains("^[a-z]+$"), + "Should show unknown pattern as-is" + ); + assert!(html.contains("pattern"), "Should have pattern badge"); + } + + // Test: Array of primitives should not show "Array Items" dropdown + #[test] + fn test_array_of_primitives_no_dropdown() { + let generator = Generator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "properties": { + "strings": { + "type": "array", + "items": { "type": "string" } + } + } + })); + + let options = Options::default(); + let html = generator.generate(&schema, &options).unwrap(); + + // Should show array and string badges + assert!(html.contains("type-array"), "Should have array badge"); + assert!(html.contains("type-string"), "Should have string badge"); + // Should NOT show Array Items dropdown + assert!( + !html.contains("Array Items"), + "Should not show redundant Array Items dropdown for simple types" + ); + } + + // Test: Homogeneous tuple should use compact [T; N] notation + #[test] + fn test_homogeneous_tuple_compact_notation() { + let generator = Generator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "properties": { + "point": { + "type": "array", + "prefixItems": [ + { "type": "integer" }, + { "type": "integer" } + ], + "minItems": 2, + "maxItems": 2 + } + } + })); + + let options = Options::default(); + let html = generator.generate(&schema, &options).unwrap(); + + // Should show [array] badge followed by compact notation [integer; 2] + assert!(html.contains("type-array"), "Should have array badge"); + assert!( + html.contains("integer; 2"), + "Should show compact tuple notation [integer; 2]" + ); + // Should NOT show Tuple Items dropdown + assert!( + !html.contains("Tuple Items"), + "Should not show Tuple Items dropdown for homogeneous tuples" + ); + // Should NOT show min/max items constraints + assert!( + !html.contains("Min items"), + "Should not show Min items constraint for tuples" + ); + assert!( + !html.contains("Max items"), + "Should not show Max items constraint for tuples" + ); + } + + // Test: Heterogeneous tuple should show dropdown + #[test] + fn test_heterogeneous_tuple_shows_dropdown() { + let generator = Generator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "properties": { + "mixed": { + "type": "array", + "prefixItems": [ + { "type": "string" }, + { "type": "integer" } + ] + } + } + })); + + let options = Options::default(); + let html = generator.generate(&schema, &options).unwrap(); + + // Should show tuple badge (heterogeneous) + assert!(html.contains(">tuple<"), "Should show tuple badge"); + // Should show Tuple Items dropdown + assert!( + html.contains("Tuple Items"), + "Should show Tuple Items dropdown for heterogeneous tuples" + ); + // Should show index markers + assert!(html.contains("[0]"), "Should show [0] index"); + assert!(html.contains("[1]"), "Should show [1] index"); + } + + // Test: String enum with descriptions uses variant divs + #[test] + fn test_string_enum_uses_variant_divs() { + let generator = Generator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "properties": { + "mode": { + "oneOf": [ + { "type": "string", "const": "A", "description": "Option A" }, + { "type": "string", "const": "B", "description": "Option B" } + ] + } + } + })); + + let options = Options::default(); + let html = generator.generate(&schema, &options).unwrap(); + + // Should use variant divs + assert!( + html.contains("class=\"variant\""), + "Should use variant divs for string enum" + ); + // Should NOT use ul/li enum-values + assert!( + !html.contains("class=\"enum-values\""), + "Should not use ul.enum-values for string enum with descriptions" + ); + } + + // Test: Complex enum (object variants) shows enum badge, not oneOf + #[test] + fn test_complex_enum_shows_enum_badge() { + let generator = Generator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "properties": { + "config": { + "oneOf": [ + { + "type": "object", + "properties": { "mode": { "type": "string" } } + }, + { + "type": "object", + "properties": { "level": { "type": "number" } } + } + ] + } + } + })); + + let options = Options::default(); + let html = generator.generate(&schema, &options).unwrap(); + + // Should show enum badge + assert!(html.contains(">enum<"), "Should show enum badge"); + // Should NOT show oneOf badge + assert!(!html.contains(">oneOf<"), "Should not show oneOf badge"); + // Should show "Enum variants" header + assert!( + html.contains("Enum variants"), + "Should show 'Enum variants' header" + ); + // Should NOT show "One of" header + assert!(!html.contains("One of"), "Should not show 'One of' header"); + } + + // Priority 1: Constraint rendering tests + + #[test] + fn test_format_constraint_renders() { + let generator = Generator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email" + } + } + })); + + let options = Options::default(); + let html = generator.generate(&schema, &options).unwrap(); + + assert!( + html.contains("Format:") && html.contains("email"), + "Should render format constraint" + ); + } + + #[test] + fn test_pattern_constraint_renders() { + let generator = Generator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "properties": { + "code": { + "type": "string", + "pattern": "^[A-Z]{3}$" + } + } + })); + + let options = Options::default(); + let html = generator.generate(&schema, &options).unwrap(); + + assert!( + html.contains("Pattern:") && html.contains("^[A-Z]{3}$"), + "Should render pattern constraint" + ); + } + + #[test] + fn test_min_max_constraints_render() { + let generator = Generator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "properties": { + "age": { + "type": "integer", + "minimum": 0, + "maximum": 120 + } + } + })); + + let options = Options::default(); + let html = generator.generate(&schema, &options).unwrap(); + + assert!(html.contains("Min:"), "Should render minimum constraint"); + assert!(html.contains("Max:"), "Should render maximum constraint"); + } + + // Priority 1: allOf rendering tests + + #[test] + fn test_allof_renders_header() { + let generator = Generator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "properties": { + "combined": { + "allOf": [ + { "type": "object", "properties": { "name": { "type": "string" } } }, + { "type": "object", "properties": { "age": { "type": "integer" } } } + ] + } + } + })); + + let options = Options::default(); + let html = generator.generate(&schema, &options).unwrap(); + + assert!( + html.contains("All of (2 schemas)"), + "Should render 'All of' header with count" + ); + } + + // Priority 1: const rendering tests + + #[test] + fn test_const_badge_renders() { + let generator = Generator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "properties": { + "version": { + "const": "1.0.0" + } + } + })); + + let options = Options::default(); + let html = generator.generate(&schema, &options).unwrap(); + + assert!( + html.contains("type-const"), + "Should render const type badge" + ); + } + + #[test] + fn test_const_value_displays() { + let generator = Generator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "properties": { + "api_version": { + "type": "string", + "const": "v2" + } + } + })); + + let options = Options::default(); + let html = generator.generate(&schema, &options).unwrap(); + + // Value is HTML-encoded as "v2" + assert!( + html.contains("const-value") && html.contains("v2"), + "Should display const value" + ); + } + + // Priority 1: deprecated badge rendering tests + + #[test] + fn test_deprecated_badge_renders() { + let generator = Generator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "properties": { + "old_field": { + "type": "string", + "deprecated": true + } + } + })); + + let options = Options::default(); + let html = generator.generate(&schema, &options).unwrap(); + + assert!( + html.contains("class=\"badge deprecated\""), + "Should render deprecated badge" + ); + assert!( + html.contains(">deprecated<"), + "Should show 'deprecated' text" + ); + } + + // Priority 1: additionalProperties rendering tests + + #[test] + fn test_additional_properties_section_renders() { + let generator = Generator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "properties": { + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + })); + + let options = Options::default(); + let html = generator.generate(&schema, &options).unwrap(); + + assert!( + html.contains("Additional Properties"), + "Should render Additional Properties section" + ); + } + + // Priority 2: Edge case rendering tests + + #[test] + fn test_empty_properties_renders() { + let generator = Generator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "properties": {} + })); + + let options = Options::default(); + let result = generator.generate(&schema, &options); + + assert!(result.is_ok(), "Should not crash on empty properties"); + } + + #[test] + fn test_schema_without_title_uses_fallback() { + let generator = Generator::new().unwrap(); + let schema = make_schema(json!({ + "type": "object", + "properties": { + "name": { "type": "string" } + } + })); + + let options = Options::default(); + let html = generator.generate(&schema, &options).unwrap(); + + assert!( + html.contains("Schema Documentation"), + "Should use fallback title" + ); + } + + #[test] + fn test_empty_definitions_renders() { + let generator = Generator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "$defs": {} + })); + + let options = Options::default(); + let result = generator.generate(&schema, &options); + + assert!(result.is_ok(), "Should not crash on empty definitions"); + } + + #[test] + fn test_multiple_pattern_properties_all_render() { + let generator = Generator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "properties": { + "config": { + "type": "object", + "patternProperties": { + "^[a-z]+$": { "type": "string" }, + "^[A-Z_]+$": { "type": "number" } + } + } + } + })); + + let options = Options::default(); + let html = generator.generate(&schema, &options).unwrap(); + + // Both patterns should be rendered (unknown patterns show as regex) + assert!(html.contains("^[a-z]+$"), "Should render first pattern"); + assert!(html.contains("^[A-Z_]+$"), "Should render second pattern"); + } + + // Priority 3: Type badge branch coverage + + #[test] + fn test_array_with_object_items_shows_both_badges() { + let generator = Generator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { "type": "string" } + } + } + } + } + })); + + let options = Options::default(); + let html = generator.generate(&schema, &options).unwrap(); + + assert!(html.contains("type-array"), "Should show array badge"); + assert!(html.contains("type-object"), "Should show object badge"); + } + + #[test] + fn test_minlength_zero_renders() { + let generator = Generator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "properties": { + "code": { + "type": "string", + "minLength": 0, + "maxLength": 10 + } + } + })); + + let options = Options::default(); + let html = generator.generate(&schema, &options).unwrap(); + + // minLength: 0 should still render (not be falsely skipped) + assert!( + html.contains("Min length:") && html.contains("0"), + "Should render minLength: 0" + ); + assert!(html.contains("Max length:"), "Should render maxLength"); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 00000000..c0badde4 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,379 @@ +//! # schemars-docgen +//! +//! Generate HTML documentation from `schemars::Schema` objects. +//! +//! This library takes a JSON Schema (as produced by schemars) and generates +//! a self-contained HTML documentation page with: +//! +//! - Responsive design (mobile, tablet, desktop) +//! - Dark/light mode following system preference +//! - Searchable properties +//! - Collapsible nested structures +//! - Markdown rendering for descriptions +//! +//! ## Usage +//! +//! ```rust,no_run +//! use schemars::schema_for; +//! use schemars_docgen::{generate_html, Options}; +//! +//! #[derive(schemars::JsonSchema)] +//! struct MyConfig { +//! /// The name of the configuration +//! name: String, +//! } +//! +//! let schema = schema_for!(MyConfig); +//! let options = Options::default(); +//! let html = generate_html(&schema, &options).unwrap(); +//! ``` + +mod generator; +mod prepare; + +pub use generator::Generator; + +use schemars::Schema; +use serde_json::Value; + +/// Options for HTML generation +#[derive(Debug, Clone, Default)] +pub struct Options { + /// Override the schema title + pub title_override: Option<String>, +} + +/// Error type for documentation generation +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("Template error: {0}")] + Template(#[from] tera::Error), + + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), +} + +/// Generate HTML documentation from a schema +/// +/// This is the main entry point for the library. It takes a `schemars::Schema` +/// and returns a self-contained HTML string. +pub fn generate_html(schema: &Schema, options: &Options) -> Result<String, Error> { + let generator = Generator::new()?; + generator.generate(schema, options) +} + +/// Extension trait for convenient access to common schema properties +/// +/// Since `schemars::Schema` is a wrapper around `serde_json::Value`, +/// this trait provides typed accessors for standard JSON Schema keywords. +pub trait SchemaExt { + /// Get the schema title + fn title(&self) -> Option<&str>; + + /// Get the schema description + fn description(&self) -> Option<&str>; + + /// Get the schema type (can be a string or array of strings) + fn schema_type(&self) -> Option<&Value>; + + /// Get object properties + fn properties(&self) -> Option<&serde_json::Map<String, Value>>; + + /// Get schema definitions ($defs) + fn definitions(&self) -> Option<&serde_json::Map<String, Value>>; + + /// Get array items schema + fn items(&self) -> Option<&Value>; + + /// Get anyOf schemas + fn any_of(&self) -> Option<&Vec<Value>>; + + /// Get oneOf schemas + fn one_of(&self) -> Option<&Vec<Value>>; + + /// Get allOf schemas + fn all_of(&self) -> Option<&Vec<Value>>; + + /// Get enum values + fn enum_values(&self) -> Option<&Vec<Value>>; + + /// Get $ref target + fn ref_target(&self) -> Option<&str>; + + /// Get default value + fn default_value(&self) -> Option<&Value>; + + /// Get required fields + fn required_fields(&self) -> Option<&Vec<Value>>; + + /// Get format constraint + fn format(&self) -> Option<&str>; + + /// Get pattern constraint + fn pattern(&self) -> Option<&str>; + + /// Get minimum constraint + fn minimum(&self) -> Option<f64>; + + /// Get maximum constraint + fn maximum(&self) -> Option<f64>; + + /// Get const value + fn const_value(&self) -> Option<&Value>; + + /// Get pattern properties + fn pattern_properties(&self) -> Option<&serde_json::Map<String, Value>>; + + /// Get additional properties schema + fn additional_properties(&self) -> Option<&Value>; + + /// Get prefix items (tuple validation) + fn prefix_items(&self) -> Option<&Vec<Value>>; + + /// Get min items constraint + fn min_items(&self) -> Option<u64>; + + /// Get max items constraint + fn max_items(&self) -> Option<u64>; + + /// Check if deprecated + fn is_deprecated(&self) -> bool; +} + +impl SchemaExt for Schema { + fn title(&self) -> Option<&str> { + self.as_object()?.get("title")?.as_str() + } + + fn description(&self) -> Option<&str> { + self.as_object()?.get("description")?.as_str() + } + + fn schema_type(&self) -> Option<&Value> { + self.as_object()?.get("type") + } + + fn properties(&self) -> Option<&serde_json::Map<String, Value>> { + self.as_object()?.get("properties")?.as_object() + } + + fn definitions(&self) -> Option<&serde_json::Map<String, Value>> { + self.as_object()?.get("$defs")?.as_object() + } + + fn items(&self) -> Option<&Value> { + self.as_object()?.get("items") + } + + fn any_of(&self) -> Option<&Vec<Value>> { + self.as_object()?.get("anyOf")?.as_array() + } + + fn one_of(&self) -> Option<&Vec<Value>> { + self.as_object()?.get("oneOf")?.as_array() + } + + fn all_of(&self) -> Option<&Vec<Value>> { + self.as_object()?.get("allOf")?.as_array() + } + + fn enum_values(&self) -> Option<&Vec<Value>> { + self.as_object()?.get("enum")?.as_array() + } + + fn ref_target(&self) -> Option<&str> { + self.as_object()?.get("$ref")?.as_str() + } + + fn default_value(&self) -> Option<&Value> { + self.as_object()?.get("default") + } + + fn required_fields(&self) -> Option<&Vec<Value>> { + self.as_object()?.get("required")?.as_array() + } + + fn format(&self) -> Option<&str> { + self.as_object()?.get("format")?.as_str() + } + + fn pattern(&self) -> Option<&str> { + self.as_object()?.get("pattern")?.as_str() + } + + fn minimum(&self) -> Option<f64> { + self.as_object()?.get("minimum")?.as_f64() + } + + fn maximum(&self) -> Option<f64> { + self.as_object()?.get("maximum")?.as_f64() + } + + fn const_value(&self) -> Option<&Value> { + self.as_object()?.get("const") + } + + fn pattern_properties(&self) -> Option<&serde_json::Map<String, Value>> { + self.as_object()?.get("patternProperties")?.as_object() + } + + fn additional_properties(&self) -> Option<&Value> { + self.as_object()?.get("additionalProperties") + } + + fn prefix_items(&self) -> Option<&Vec<Value>> { + self.as_object()?.get("prefixItems")?.as_array() + } + + fn min_items(&self) -> Option<u64> { + self.as_object()?.get("minItems")?.as_u64() + } + + fn max_items(&self) -> Option<u64> { + self.as_object()?.get("maxItems")?.as_u64() + } + + fn is_deprecated(&self) -> bool { + self.as_object() + .and_then(|o| o.get("deprecated")) + .and_then(|v| v.as_bool()) + .unwrap_or(false) + } +} + +/// Extension trait for Value to use the same accessors +impl SchemaExt for Value { + fn title(&self) -> Option<&str> { + self.as_object()?.get("title")?.as_str() + } + + fn description(&self) -> Option<&str> { + self.as_object()?.get("description")?.as_str() + } + + fn schema_type(&self) -> Option<&Value> { + self.as_object()?.get("type") + } + + fn properties(&self) -> Option<&serde_json::Map<String, Value>> { + self.as_object()?.get("properties")?.as_object() + } + + fn definitions(&self) -> Option<&serde_json::Map<String, Value>> { + self.as_object()?.get("$defs")?.as_object() + } + + fn items(&self) -> Option<&Value> { + self.as_object()?.get("items") + } + + fn any_of(&self) -> Option<&Vec<Value>> { + self.as_object()?.get("anyOf")?.as_array() + } + + fn one_of(&self) -> Option<&Vec<Value>> { + self.as_object()?.get("oneOf")?.as_array() + } + + fn all_of(&self) -> Option<&Vec<Value>> { + self.as_object()?.get("allOf")?.as_array() + } + + fn enum_values(&self) -> Option<&Vec<Value>> { + self.as_object()?.get("enum")?.as_array() + } + + fn ref_target(&self) -> Option<&str> { + self.as_object()?.get("$ref")?.as_str() + } + + fn default_value(&self) -> Option<&Value> { + self.as_object()?.get("default") + } + + fn required_fields(&self) -> Option<&Vec<Value>> { + self.as_object()?.get("required")?.as_array() + } + + fn format(&self) -> Option<&str> { + self.as_object()?.get("format")?.as_str() + } + + fn pattern(&self) -> Option<&str> { + self.as_object()?.get("pattern")?.as_str() + } + + fn minimum(&self) -> Option<f64> { + self.as_object()?.get("minimum")?.as_f64() + } + + fn maximum(&self) -> Option<f64> { + self.as_object()?.get("maximum")?.as_f64() + } + + fn const_value(&self) -> Option<&Value> { + self.as_object()?.get("const") + } + + fn pattern_properties(&self) -> Option<&serde_json::Map<String, Value>> { + self.as_object()?.get("patternProperties")?.as_object() + } + + fn additional_properties(&self) -> Option<&Value> { + self.as_object()?.get("additionalProperties") + } + + fn prefix_items(&self) -> Option<&Vec<Value>> { + self.as_object()?.get("prefixItems")?.as_array() + } + + fn min_items(&self) -> Option<u64> { + self.as_object()?.get("minItems")?.as_u64() + } + + fn max_items(&self) -> Option<u64> { + self.as_object()?.get("maxItems")?.as_u64() + } + + fn is_deprecated(&self) -> bool { + self.as_object() + .and_then(|o| o.get("deprecated")) + .and_then(|v| v.as_bool()) + .unwrap_or(false) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_schema_ext_title() { + let schema: Schema = + serde_json::from_str(r#"{"title": "Test Schema", "type": "object"}"#).unwrap(); + assert_eq!(schema.title(), Some("Test Schema")); + } + + #[test] + fn test_schema_ext_description() { + let schema: Schema = + serde_json::from_str(r#"{"description": "A test description"}"#).unwrap(); + assert_eq!(schema.description(), Some("A test description")); + } + + #[test] + fn test_schema_ext_ref() { + let schema: Schema = serde_json::from_str(r##"{"$ref": "#/$defs/MyType"}"##).unwrap(); + assert_eq!(schema.ref_target(), Some("#/$defs/MyType")); + } + + #[test] + fn test_schema_ext_properties() { + let schema: Schema = serde_json::from_str( + r#"{"type": "object", "properties": {"name": {"type": "string"}}}"#, + ) + .unwrap(); + let props = schema.properties().unwrap(); + assert!(props.contains_key("name")); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 00000000..b22f99fe --- /dev/null +++ b/src/main.rs @@ -0,0 +1,44 @@ +use clap::Parser; +use schemars_docgen::Options; +use schemars_docgen::generate_html; +use std::path::PathBuf; + +#[derive(Parser)] +#[command(name = "schemars-docgen")] +#[command(about = "Generate HTML documentation from JSON Schema")] +struct Cli { + /// Input JSON Schema file + input: PathBuf, + + /// Output HTML file + #[arg(short, long)] + output: Option<PathBuf>, + + /// Schema title (overrides schema title from the schema) + #[arg(short, long)] + title: Option<String>, +} + +fn main() -> Result<(), Box<dyn std::error::Error>> { + let cli = Cli::parse(); + + let schema_json = std::fs::read_to_string(&cli.input)?; + let schema: schemars::Schema = serde_json::from_str(&schema_json)?; + + let options = Options { + title_override: cli.title, + }; + + let html = generate_html(&schema, &options)?; + + let output_path = cli.output.unwrap_or_else(|| { + let mut path = cli.input.clone(); + path.set_extension("html"); + path + }); + + std::fs::write(&output_path, html)?; + println!("Generated documentation: {}", output_path.display()); + + Ok(()) +} diff --git a/src/prepare.rs b/src/prepare.rs new file mode 100644 index 00000000..976fc36c --- /dev/null +++ b/src/prepare.rs @@ -0,0 +1,1667 @@ +//! Schema preparation for template rendering +//! +//! This module handles light preparation of the schema before passing it to Tera: +//! - Rendering markdown descriptions to HTML +//! - Generating anchor IDs for navigation +//! - Building the search index + +use pulldown_cmark::Parser; +use pulldown_cmark::html; +use serde::Serialize; +use serde_json::Map; +use serde_json::Value; +use std::collections::HashMap; +use std::collections::HashSet; + +/// Prepare a schema value for template rendering +/// +/// This function: +/// 1. Clones the schema value +/// 2. Recursively renders all `description` fields as markdown to `description_html` +/// 3. Returns the modified value ready for Tera +pub fn prepare_for_template(value: &Value) -> Value { + let mut prepared = value.clone(); + process_value(&mut prepared); + prepared +} + +/// Recursively process a value, rendering markdown descriptions +fn process_value(value: &mut Value) { + match value { + Value::Object(map) => { + // Render description if present + if let Some(Value::String(desc)) = map.get("description") { + let html = render_markdown(desc); + map.insert("description_html".to_string(), Value::String(html)); + } + + // Recurse into all nested values + for v in map.values_mut() { + process_value(v); + } + } + Value::Array(arr) => { + for v in arr.iter_mut() { + process_value(v); + } + } + _ => {} + } +} + +/// Render markdown text to HTML +pub fn render_markdown(text: &str) -> String { + let parser = Parser::new(text); + let mut html_output = String::new(); + html::push_html(&mut html_output, parser); + html_output +} + +/// Strip markdown formatting from text, keeping only the plain text content. +/// Useful for HTML <title> tags where markdown syntax shouldn't appear. +pub fn strip_markdown(text: &str) -> String { + let parser = Parser::new(text); + let mut plain_text = String::new(); + for event in parser { + if let pulldown_cmark::Event::Text(t) | pulldown_cmark::Event::Code(t) = event { + plain_text.push_str(&t); + } + } + plain_text +} + +/// Information about a definition for the sidebar navigation +#[derive(Debug, Clone, Serialize)] +pub struct DefinitionInfo { + pub name: String, + pub anchor: String, + pub description: Option<String>, +} + +/// An outline entry for hierarchical navigation +#[derive(Debug, Clone, Serialize)] +pub struct OutlineEntry { + pub name: String, + pub anchor: String, + pub children: Vec<OutlineEntry>, +} + +/// A search index entry +#[derive(Debug, Clone, Serialize)] +pub struct SearchEntry { + pub name: String, + pub path: String, + pub anchor: String, + pub entry_type: String, +} + +/// A usage of a type in the Configuration section +#[derive(Debug, Clone, Serialize)] +pub struct ConfigUsage { + /// jq-style path like ".monitors[].workspaces[].layout" + pub path: String, + /// HTML anchor for linking to the usage location (no leading dash) + pub anchor: String, +} + +/// A reference to a type from another type definition +#[derive(Debug, Clone, Serialize)] +pub struct TypeReference { + /// Name of the referencing type, e.g., "IdWithIdentifier" + pub type_name: String, + /// Anchor to the type definition + pub anchor: String, +} + +/// All usages of a type +#[derive(Debug, Clone, Serialize)] +pub struct TypeUsages { + /// Usages in Configuration section + pub config_usages: Vec<ConfigUsage>, + /// References from other $defs types + pub type_references: Vec<TypeReference>, +} + +/// Navigation data extracted from the schema +#[derive(Debug, Clone, Serialize)] +pub struct NavigationData { + pub outline: Vec<OutlineEntry>, + pub definitions: Vec<DefinitionInfo>, + pub search_index: Vec<SearchEntry>, + /// Map from type name to its usages + pub usages: HashMap<String, TypeUsages>, +} + +/// Maximum depth for outline tree to prevent infinite recursion +const MAX_OUTLINE_DEPTH: usize = 3; + +/// Build a hierarchical outline tree from schema properties +fn build_outline_tree( + schema: &Value, + defs: Option<&Map<String, Value>>, + parent_path: &str, + depth: usize, +) -> Vec<OutlineEntry> { + if depth >= MAX_OUTLINE_DEPTH { + return Vec::new(); + } + + let mut entries = Vec::new(); + + // Resolve the schema, getting both the effective schema and the anchor base + // When we follow a $ref, the anchor base becomes the type name + let (effective_schema, anchor_base) = resolve_schema_for_outline(schema, defs, parent_path); + + // Check if this is a oneOf - show all variants as alternatives + if let Some(one_of) = effective_schema.get("oneOf").and_then(|v| v.as_array()) { + for (i, variant) in one_of.iter().enumerate() { + // Get variant label: prefer title, then look for a const discriminator, then fall back to "Variant N" + let variant_label = variant + .get("title") + .and_then(|t| t.as_str()) + .map(String::from) + .or_else(|| { + // Look for a const property that could serve as a discriminator (e.g., "palette": {"const": "Catppuccin"}) + variant + .get("properties") + .and_then(|p| p.as_object()) + .and_then(|props| { + props.values().find_map(|prop| { + prop.get("const").and_then(|c| c.as_str()).map(String::from) + }) + }) + }) + .unwrap_or_else(|| format!("Variant {}", i + 1)); + + // Build the variant path to match template-generated IDs + // Template uses: path ~ ".oneOf[" ~ loop.index0 ~ "]" + let variant_path = format!("{}.oneOf[{}]", anchor_base, i); + + // Build children for this variant + let variant_children = build_outline_tree(variant, defs, &variant_path, depth + 1); + + if !variant_children.is_empty() { + entries.push(OutlineEntry { + name: format!("⊕ {}", variant_label), // ⊕ indicates "one of" + // Link to the type definition since individual variants don't have anchors + anchor: make_anchor(&anchor_base), + children: variant_children, + }); + } + } + return entries; + } + + if let Some(props) = effective_schema + .get("properties") + .and_then(|v| v.as_object()) + { + for (name, prop) in props { + // Build the anchor path using the resolved anchor base + let child_anchor_path = if anchor_base.is_empty() { + name.clone() + } else { + format!("{}.{}", anchor_base, name) + }; + + // Recursively build children + let children = build_outline_tree(prop, defs, &child_anchor_path, depth + 1); + + entries.push(OutlineEntry { + name: name.clone(), + anchor: make_anchor(&child_anchor_path), + children, + }); + } + // Sort alphabetically + entries.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + } + + entries +} + +/// Resolve schema following $refs, arrays, and Option<T>, returning (effective_schema, anchor_base) +/// When we follow a $ref, anchor_base becomes the type name so links go to the type definition +/// For true union types (anyOf with multiple non-null variants), we stop and treat as leaf +fn resolve_schema_for_outline<'a>( + schema: &'a Value, + defs: Option<&'a Map<String, Value>>, + current_path: &str, +) -> (&'a Value, String) { + // Handle $ref - use the ref name as anchor base + if let Some(ref_path) = schema.get("$ref").and_then(|v| v.as_str()) { + let ref_name = ref_path.trim_start_matches("#/$defs/"); + if let Some(def) = defs.and_then(|d| d.get(ref_name)) { + // Recursively resolve in case the def itself has a $ref or anyOf + // Use the ref_name as the new anchor base + return resolve_schema_for_outline(def, defs, ref_name); + } + return (schema, current_path.to_string()); + } + + // Handle array - unwrap to items schema + if is_array_type(schema) + && let Some(items) = schema.get("items") { + return resolve_schema_for_outline(items, defs, current_path); + } + + // Handle anyOf - only unwrap if it's Option<T> (exactly 2 variants, one null) + // For true unions (multiple non-null variants), don't unwrap - treat as leaf + if let Some(any_of) = schema.get("anyOf").and_then(|v| v.as_array()) + && any_of.len() == 2 { + let null_count = any_of + .iter() + .filter(|v| v.get("type").and_then(|t| t.as_str()) == Some("null")) + .count(); + + // Only unwrap if exactly one null variant (Option<T> pattern) + if null_count == 1 { + for variant in any_of { + let is_null = variant.get("type").and_then(|t| t.as_str()) == Some("null"); + if !is_null { + return resolve_schema_for_outline(variant, defs, current_path); + } + } + } + } + // For true unions (0 or 2+ null variants, or 3+ total variants), don't unwrap + + // Inline object or leaf type - keep current path + (schema, current_path.to_string()) +} + +/// Check if schema represents an array type +fn is_array_type(schema: &Value) -> bool { + // Check type: "array" + if schema.get("type").and_then(|t| t.as_str()) == Some("array") { + return true; + } + // Check type: ["array", "null"] (Option<Vec<T>>) + if let Some(type_arr) = schema.get("type").and_then(|v| v.as_array()) + && type_arr.iter().any(|t| t.as_str() == Some("array")) { + return true; + } + false +} + +/// Collect all usages of type definitions ($refs) in the Configuration section +/// +/// Recursively traverses the schema and records where each type is referenced, +/// building jq-style paths for each usage location. Follows $refs transitively +/// to collect usages for types nested inside other types. +/// +/// Parameters: +/// - `current_type`: When inside a followed $ref, this is the type name we're inside. +/// Used to build correct anchors that point to the type definition's rendered properties. +/// - `type_relative_path`: When inside a type, this tracks the path relative to that type +/// (e.g., "workspaces" inside MonitorConfig), used for building anchors. +fn collect_config_usages( + schema: &Value, + path: String, + usages: &mut HashMap<String, Vec<ConfigUsage>>, + defs: Option<&Map<String, Value>>, + visited: &mut HashSet<String>, + current_type: Option<&str>, + type_relative_path: String, +) { + // Check for $ref at current level + if let Some(ref_path) = schema.get("$ref").and_then(|v| v.as_str()) { + let type_name = ref_path.trim_start_matches("#/$defs/").to_string(); + + // Build the anchor based on whether we're inside a type or at config root + let anchor = if let Some(parent_type) = current_type { + // Inside a type definition - anchor points to property within that type + // e.g., MonitorConfig.workspaces -> "monitorconfig-workspaces" + // e.g., KomorebiTheme.oneOf[0].bar_accent -> "komorebitheme-oneof[0]-bar_accent" + // Note: Keep [N] for variant indices, but remove empty [] from array notation + let clean_path = type_relative_path.replace("[]", ""); + if clean_path.is_empty() { + make_anchor(parent_type) + } else { + make_anchor(&format!("{}.{}", parent_type, clean_path)) + } + } else { + // At config root - anchor points to config section property + // e.g., .monitors[] -> "monitors" (template IDs don't have []) + // Remove all bracket notation for config paths + let clean_path = path + .trim_start_matches('.') + .replace("[]", "") + .replace("[", "-") + .replace("]", ""); + make_anchor(&clean_path) + }; + + let usage = ConfigUsage { + path: if path.is_empty() { + ".".to_string() + } else { + path.clone() + }, + anchor, + }; + usages.entry(type_name.clone()).or_default().push(usage); + + // Follow the $ref into the type definition (if not already visited) + // When following, we enter the new type with an empty relative path + if !visited.contains(&type_name) + && let Some(def) = defs.and_then(|d| d.get(&type_name)) { + visited.insert(type_name.clone()); + collect_config_usages( + def, + path, + usages, + defs, + visited, + Some(&type_name), + String::new(), // Reset relative path when entering new type + ); + visited.remove(&type_name); // Allow revisiting from different paths + } + return; // $ref schema has no other content to process + } + + // Recurse into properties + if let Some(props) = schema.get("properties").and_then(|v| v.as_object()) { + for (prop_name, prop_schema) in props { + let new_path = if path.is_empty() { + format!(".{}", prop_name) + } else { + format!("{}.{}", path, prop_name) + }; + let new_type_relative = if type_relative_path.is_empty() { + prop_name.clone() + } else { + format!("{}.{}", type_relative_path, prop_name) + }; + collect_config_usages( + prop_schema, + new_path, + usages, + defs, + visited, + current_type, + new_type_relative, + ); + } + } + + // Recurse into array items + if let Some(items) = schema.get("items") { + let new_path = format!("{}[]", path); + // For arrays, the relative path stays the same (items are part of the array property) + collect_config_usages( + items, + new_path, + usages, + defs, + visited, + current_type, + type_relative_path.clone(), + ); + } + + // Recurse into prefixItems (tuples) + if let Some(prefix_items) = schema.get("prefixItems").and_then(|v| v.as_array()) { + for (i, item) in prefix_items.iter().enumerate() { + let new_path = format!("{}[{}]", path, i); + collect_config_usages( + item, + new_path, + usages, + defs, + visited, + current_type, + type_relative_path.clone(), + ); + } + } + + // Recurse into anyOf/oneOf/allOf variants + // Only add variant indices to path when the variant has properties (will render sub-IDs) + // For $ref or primitive variants, the template doesn't render separate IDs + for key in ["anyOf", "oneOf", "allOf"] { + if let Some(variants) = schema.get(key).and_then(|v| v.as_array()) { + for (i, variant) in variants.iter().enumerate() { + // Only add variant index if: + // 1. We're inside a type (current_type.is_some()) + // 2. We're at the type root level (type_relative_path.is_empty()) + // 3. The variant has properties (will render sub-IDs in template) + let variant_has_properties = variant.get("properties").is_some(); + let new_type_relative = if current_type.is_some() + && type_relative_path.is_empty() + && variant_has_properties + { + format!("{}[{}]", key, i) + } else { + type_relative_path.clone() + }; + collect_config_usages( + variant, + path.clone(), + usages, + defs, + visited, + current_type, + new_type_relative, + ); + } + } + } + + // Recurse into patternProperties + if let Some(patterns) = schema.get("patternProperties").and_then(|v| v.as_object()) { + for (_pattern, prop_schema) in patterns { + let new_path = format!("{}[*]", path); + collect_config_usages( + prop_schema, + new_path, + usages, + defs, + visited, + current_type, + type_relative_path.clone(), + ); + } + } + + // Recurse into additionalProperties + if let Some(additional) = schema.get("additionalProperties") + && additional.is_object() { + let new_path = format!("{}[*]", path); + collect_config_usages( + additional, + new_path, + usages, + defs, + visited, + current_type, + type_relative_path.clone(), + ); + } +} + +/// Collect references to types from within $defs +/// +/// For each type definition, find all $refs it contains and record them +/// as references from that type to the target type. +fn collect_type_references(defs: &Map<String, Value>) -> HashMap<String, Vec<TypeReference>> { + let mut references: HashMap<String, Vec<TypeReference>> = HashMap::new(); + + for (def_name, def_schema) in defs { + collect_refs_in_schema(def_schema, def_name, &mut references); + } + + // Sort and deduplicate references + for ref_list in references.values_mut() { + ref_list.sort_by(|a, b| a.type_name.cmp(&b.type_name)); + ref_list.dedup_by(|a, b| a.type_name == b.type_name); + } + + references +} + +/// Recursively find all $refs in a schema and record them as references +fn collect_refs_in_schema( + schema: &Value, + source_type: &str, + references: &mut HashMap<String, Vec<TypeReference>>, +) { + if let Some(ref_path) = schema.get("$ref").and_then(|v| v.as_str()) { + let target_type = ref_path.trim_start_matches("#/$defs/").to_string(); + if target_type != source_type { + // Don't self-reference + let reference = TypeReference { + type_name: source_type.to_string(), + anchor: make_anchor(source_type), // Links to type definition + }; + references.entry(target_type).or_default().push(reference); + } + } + + // Recurse into all nested structures + if let Some(obj) = schema.as_object() { + for value in obj.values() { + collect_refs_in_schema(value, source_type, references); + } + } + if let Some(arr) = schema.as_array() { + for value in arr { + collect_refs_in_schema(value, source_type, references); + } + } +} + +/// Build navigation data from a schema value +pub fn build_navigation(schema: &Value) -> NavigationData { + let mut definitions = Vec::new(); + let mut search_index = Vec::new(); + + // Get $defs for resolving references + let defs = schema.get("$defs").and_then(|v| v.as_object()); + + // Build hierarchical outline from top-level properties + let outline = build_outline_tree(schema, defs, "", 0); + + // Also collect for search index + if let Some(props) = schema.get("properties").and_then(|v| v.as_object()) { + collect_properties(props, "", &mut search_index); + } + + // Extract definitions from $defs + if let Some(defs) = schema.get("$defs").and_then(|v| v.as_object()) { + for (name, def) in defs { + let anchor = make_anchor(name); + let description = def.get("description").and_then(|v| v.as_str()).map(|s| { + // Truncate long descriptions for the sidebar + if s.len() > 100 { + format!("{}...", &s[..97]) + } else { + s.to_string() + } + }); + + definitions.push(DefinitionInfo { + name: name.clone(), + anchor: anchor.clone(), + description, + }); + + search_index.push(SearchEntry { + name: name.clone(), + path: name.clone(), + anchor, + entry_type: "definition".to_string(), + }); + } + } + + // Sort definitions alphabetically + definitions.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + + // Collect configuration usages (from main schema, following $refs transitively) + let mut config_usages: HashMap<String, Vec<ConfigUsage>> = HashMap::new(); + let mut visited: HashSet<String> = HashSet::new(); + collect_config_usages( + schema, + String::new(), + &mut config_usages, + defs, + &mut visited, + None, // not inside any type yet + String::new(), // no type-relative path yet + ); + + // Sort config usages alphabetically by path + for usage_list in config_usages.values_mut() { + usage_list.sort_by(|a, b| a.path.cmp(&b.path)); + } + + // Collect type references from $defs + let type_references = if let Some(defs) = schema.get("$defs").and_then(|v| v.as_object()) { + collect_type_references(defs) + } else { + HashMap::new() + }; + + // Merge into TypeUsages for each type + let mut usages: HashMap<String, TypeUsages> = HashMap::new(); + if let Some(defs) = schema.get("$defs").and_then(|v| v.as_object()) { + for type_name in defs.keys() { + usages.insert( + type_name.clone(), + TypeUsages { + config_usages: config_usages.remove(type_name).unwrap_or_default(), + type_references: type_references.get(type_name).cloned().unwrap_or_default(), + }, + ); + } + } + + NavigationData { + outline, + definitions, + search_index, + usages, + } +} + +/// Recursively collect properties for the search index +fn collect_properties(props: &Map<String, Value>, prefix: &str, index: &mut Vec<SearchEntry>) { + for (name, prop) in props { + let path = if prefix.is_empty() { + name.clone() + } else { + format!("{}.{}", prefix, name) + }; + + let anchor = make_anchor(&path); + + index.push(SearchEntry { + name: name.clone(), + path: path.clone(), + anchor, + entry_type: "property".to_string(), + }); + + // Recurse into nested properties + if let Some(nested_props) = prop.get("properties").and_then(|v| v.as_object()) { + collect_properties(nested_props, &path, index); + } + + // Handle anyOf/oneOf which might contain objects with properties + for key in ["anyOf", "oneOf", "allOf"] { + if let Some(variants) = prop.get(key).and_then(|v| v.as_array()) { + for variant in variants { + if let Some(nested_props) = + variant.get("properties").and_then(|v| v.as_object()) + { + collect_properties(nested_props, &path, index); + } + } + } + } + } +} + +/// Create a URL-safe anchor ID from a name +pub fn make_anchor(name: &str) -> String { + name.chars() + .map(|c| { + if c.is_alphanumeric() || c == '-' || c == '_' || c == '[' || c == ']' { + c.to_ascii_lowercase() + } else if c == '.' || c == ' ' { + '-' + } else { + '_' + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_render_markdown() { + let md = "This is **bold** and `code`"; + let html = render_markdown(md); + assert!(html.contains("<strong>bold</strong>")); + assert!(html.contains("<code>code</code>")); + } + + #[test] + fn test_render_markdown_with_links() { + let md = "See [docs](https://example.com)"; + let html = render_markdown(md); + assert!(html.contains("<a href=\"https://example.com\">docs</a>")); + } + + #[test] + fn test_prepare_for_template() { + let schema = json!({ + "title": "Test", + "description": "A **bold** description", + "properties": { + "name": { + "description": "The `name` field" + } + } + }); + + let prepared = prepare_for_template(&schema); + + assert!(prepared.get("description_html").is_some()); + assert!( + prepared["description_html"] + .as_str() + .unwrap() + .contains("<strong>bold</strong>") + ); + + assert!(prepared["properties"]["name"]["description_html"].is_string()); + } + + #[test] + fn test_make_anchor() { + assert_eq!(make_anchor("MyType"), "mytype"); + assert_eq!(make_anchor("my.nested.prop"), "my-nested-prop"); + assert_eq!(make_anchor("with spaces"), "with-spaces"); + assert_eq!(make_anchor("with_underscore"), "with_underscore"); + } + + #[test] + fn test_build_navigation() { + let schema = json!({ + "title": "Test", + "$defs": { + "TypeA": {"type": "object", "description": "Type A description"}, + "TypeB": {"type": "string"} + }, + "properties": { + "config": { + "properties": { + "nested": {"type": "string"} + } + } + } + }); + + let nav = build_navigation(&schema); + + assert_eq!(nav.definitions.len(), 2); + assert!(nav.definitions.iter().any(|d| d.name == "TypeA")); + assert!(nav.definitions.iter().any(|d| d.name == "TypeB")); + + // Check search index includes properties + assert!(nav.search_index.iter().any(|e| e.name == "config")); + assert!(nav.search_index.iter().any(|e| e.path == "config.nested")); + } + + #[test] + fn test_build_navigation_outline() { + let schema = json!({ + "title": "Test", + "properties": { + "zebra": { "type": "string" }, + "alpha": { "type": "number" }, + "beta": { "type": "boolean" } + } + }); + + let nav = build_navigation(&schema); + + // Should have 3 outline entries + assert_eq!(nav.outline.len(), 3); + + // Should be sorted alphabetically + assert_eq!(nav.outline[0].name, "alpha"); + assert_eq!(nav.outline[1].name, "beta"); + assert_eq!(nav.outline[2].name, "zebra"); + + // Check anchors + assert_eq!(nav.outline[0].anchor, "alpha"); + assert_eq!(nav.outline[1].anchor, "beta"); + assert_eq!(nav.outline[2].anchor, "zebra"); + + // Primitive types should have no children + assert!(nav.outline[0].children.is_empty()); + } + + #[test] + fn test_outline_array_shows_item_properties() { + let schema = json!({ + "title": "Test", + "type": "object", + "$defs": { + "ChildType": { + "type": "object", + "properties": { + "nested_prop": { "type": "string" } + } + } + }, + "properties": { + "parent": { + "type": "array", + "items": { "$ref": "#/$defs/ChildType" } + } + } + }); + + let nav = build_navigation(&schema); + + assert_eq!(nav.outline.len(), 1); + assert_eq!(nav.outline[0].name, "parent"); + // Arrays should show their item type's properties + assert_eq!(nav.outline[0].children.len(), 1); + assert_eq!(nav.outline[0].children[0].name, "nested_prop"); + // The anchor should point to the type definition, not the parent path + assert_eq!(nav.outline[0].children[0].anchor, "childtype-nested_prop"); + } + + #[test] + fn test_outline_union_type_is_leaf() { + // Union types (anyOf with multiple non-null variants) should be leaf nodes + let schema = json!({ + "title": "Test", + "type": "object", + "$defs": { + "Colour": { + "anyOf": [ + { "$ref": "#/$defs/Rgb" }, + { "$ref": "#/$defs/Hex" } + ] + }, + "Rgb": { + "type": "object", + "properties": { + "r": { "type": "integer" }, + "g": { "type": "integer" }, + "b": { "type": "integer" } + } + }, + "Hex": { + "type": "string" + } + }, + "properties": { + "color": { "$ref": "#/$defs/Colour" } + } + }); + + let nav = build_navigation(&schema); + + assert_eq!(nav.outline.len(), 1); + assert_eq!(nav.outline[0].name, "color"); + // Union type should be a leaf - don't drill into its variants + assert!(nav.outline[0].children.is_empty()); + } + + #[test] + fn test_outline_unwraps_optional() { + let schema = json!({ + "title": "Test", + "type": "object", + "$defs": { + "InnerType": { + "type": "object", + "properties": { + "value": { "type": "string" } + } + } + }, + "properties": { + "optional_obj": { + "anyOf": [ + { "$ref": "#/$defs/InnerType" }, + { "type": "null" } + ] + } + } + }); + + let nav = build_navigation(&schema); + + assert_eq!(nav.outline[0].name, "optional_obj"); + // Optional should be transparent - show InnerType's properties as children + assert_eq!(nav.outline[0].children.len(), 1); + assert_eq!(nav.outline[0].children[0].name, "value"); + } + + #[test] + fn test_outline_one_of_shows_variants_with_title() { + let schema = json!({ + "title": "Test", + "type": "object", + "$defs": { + "ThemeChoice": { + "oneOf": [ + { + "title": "CatppuccinTheme", + "type": "object", + "properties": { + "name": { "type": "string" }, + "accent": { "type": "string" } + } + }, + { + "title": "CustomTheme", + "type": "object", + "properties": { + "colors": { "type": "object" }, + "font": { "type": "string" } + } + } + ] + } + }, + "properties": { + "theme": { "$ref": "#/$defs/ThemeChoice" } + } + }); + + let nav = build_navigation(&schema); + + assert_eq!(nav.outline.len(), 1); + assert_eq!(nav.outline[0].name, "theme"); + + // Should have 2 variant children (one for each oneOf variant) + assert_eq!(nav.outline[0].children.len(), 2); + + // First variant should use title + assert_eq!(nav.outline[0].children[0].name, "⊕ CatppuccinTheme"); + assert_eq!(nav.outline[0].children[0].children.len(), 2); // name, accent + // Anchor should point to the type definition + assert_eq!(nav.outline[0].children[0].anchor, "themechoice"); + + // Second variant + assert_eq!(nav.outline[0].children[1].name, "⊕ CustomTheme"); + assert_eq!(nav.outline[0].children[1].children.len(), 2); // colors, font + } + + #[test] + fn test_outline_one_of_uses_const_discriminator() { + let schema = json!({ + "title": "Test", + "type": "object", + "$defs": { + "ThemeChoice": { + "oneOf": [ + { + "type": "object", + "properties": { + "palette": { "const": "Catppuccin" }, + "name": { "type": "string" } + } + }, + { + "type": "object", + "properties": { + "palette": { "const": "Base16" }, + "name": { "type": "string" } + } + } + ] + } + }, + "properties": { + "theme": { "$ref": "#/$defs/ThemeChoice" } + } + }); + + let nav = build_navigation(&schema); + + assert_eq!(nav.outline[0].children.len(), 2); + + // Should use const value as discriminator + assert_eq!(nav.outline[0].children[0].name, "⊕ Catppuccin"); + assert_eq!(nav.outline[0].children[1].name, "⊕ Base16"); + } + + #[test] + fn test_outline_one_of_fallback_to_variant_n() { + let schema = json!({ + "title": "Test", + "type": "object", + "$defs": { + "Choice": { + "oneOf": [ + { + "type": "object", + "properties": { + "option_a": { "type": "string" } + } + }, + { + "type": "object", + "properties": { + "option_b": { "type": "number" } + } + } + ] + } + }, + "properties": { + "selection": { "$ref": "#/$defs/Choice" } + } + }); + + let nav = build_navigation(&schema); + + assert_eq!(nav.outline[0].name, "selection"); + assert_eq!(nav.outline[0].children.len(), 2); + + // Without title or const, should use "Variant N" + assert_eq!(nav.outline[0].children[0].name, "⊕ Variant 1"); + assert_eq!(nav.outline[0].children[1].name, "⊕ Variant 2"); + } + + #[test] + fn test_outline_depth_limit() { + // Create a deeply nested schema + let schema = json!({ + "title": "Test", + "type": "object", + "$defs": { + "Level1": { + "type": "object", + "properties": { + "level2": { "$ref": "#/$defs/Level2" } + } + }, + "Level2": { + "type": "object", + "properties": { + "level3": { "$ref": "#/$defs/Level3" } + } + }, + "Level3": { + "type": "object", + "properties": { + "level4": { "$ref": "#/$defs/Level4" } + } + }, + "Level4": { + "type": "object", + "properties": { + "deep": { "type": "string" } + } + } + }, + "properties": { + "root": { "$ref": "#/$defs/Level1" } + } + }); + + let nav = build_navigation(&schema); + + // Should have root + assert_eq!(nav.outline.len(), 1); + assert_eq!(nav.outline[0].name, "root"); + + // Should have level2 as child + assert_eq!(nav.outline[0].children.len(), 1); + assert_eq!(nav.outline[0].children[0].name, "level2"); + + // Should have level3 as grandchild + assert_eq!(nav.outline[0].children[0].children.len(), 1); + assert_eq!(nav.outline[0].children[0].children[0].name, "level3"); + + // Should stop at max depth (3) - level4 should not have children + assert!(nav.outline[0].children[0].children[0].children.is_empty()); + } + + #[test] + fn test_config_usages_collection() { + let schema = json!({ + "title": "Test", + "type": "object", + "$defs": { + "Layout": { + "type": "string", + "enum": ["horizontal", "vertical"] + }, + "Workspace": { + "type": "object" + } + }, + "properties": { + "workspaces": { + "type": "array", + "items": { "$ref": "#/$defs/Workspace" } + }, + "default_layout": { "$ref": "#/$defs/Layout" }, + "alternate_layout": { "$ref": "#/$defs/Layout" } + } + }); + + let nav = build_navigation(&schema); + + // Layout should have 2 config usages + assert!(nav.usages.contains_key("Layout")); + let layout_usages = &nav.usages["Layout"]; + assert_eq!(layout_usages.config_usages.len(), 2); + + // Check paths are jq-style and sorted + let paths: Vec<&str> = layout_usages + .config_usages + .iter() + .map(|u| u.path.as_str()) + .collect(); + assert!(paths.contains(&".alternate_layout")); + assert!(paths.contains(&".default_layout")); + + // Workspace should have 1 config usage + assert!(nav.usages.contains_key("Workspace")); + let workspace_usages = &nav.usages["Workspace"]; + assert_eq!(workspace_usages.config_usages.len(), 1); + assert_eq!(workspace_usages.config_usages[0].path, ".workspaces[]"); + } + + #[test] + fn test_config_usages_anchor_format() { + let schema = json!({ + "title": "Test", + "type": "object", + "$defs": { + "MyType": { "type": "string" } + }, + "properties": { + "my_field": { "$ref": "#/$defs/MyType" } + } + }); + + let nav = build_navigation(&schema); + + let usages = &nav.usages["MyType"]; + assert_eq!(usages.config_usages.len(), 1); + // Anchor should NOT have leading dash + assert_eq!(usages.config_usages[0].anchor, "my_field"); + // Path should have leading dot + assert_eq!(usages.config_usages[0].path, ".my_field"); + } + + #[test] + fn test_config_usages_jq_path_format() { + let schema = json!({ + "title": "Test", + "type": "object", + "$defs": { + "Config": { "type": "object" } + }, + "properties": { + "monitors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "workspaces": { + "type": "array", + "items": { + "type": "object", + "properties": { + "config": { "$ref": "#/$defs/Config" } + } + } + } + } + } + } + } + }); + + let nav = build_navigation(&schema); + + assert!(nav.usages.contains_key("Config")); + let config_usages = &nav.usages["Config"]; + assert_eq!(config_usages.config_usages.len(), 1); + // Should have full jq-style path + assert_eq!( + config_usages.config_usages[0].path, + ".monitors[].workspaces[].config" + ); + // Anchor should match template ID format (no [] brackets, dots become dashes) + assert_eq!( + config_usages.config_usages[0].anchor, + "monitors-workspaces-config" + ); + } + + #[test] + fn test_type_references_collected() { + let schema = json!({ + "title": "Test", + "type": "object", + "$defs": { + "MatchingStrategy": { + "type": "string", + "enum": ["Equals", "Contains"] + }, + "IdWithIdentifier": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "matching_strategy": { + "anyOf": [ + { "$ref": "#/$defs/MatchingStrategy" }, + { "type": "null" } + ] + } + } + } + }, + "properties": { + "rules": { + "type": "array", + "items": { "$ref": "#/$defs/IdWithIdentifier" } + } + } + }); + + let nav = build_navigation(&schema); + + // MatchingStrategy now has transitive config usages (through IdWithIdentifier) + let matching_strategy = &nav.usages["MatchingStrategy"]; + assert_eq!(matching_strategy.config_usages.len(), 1); + assert_eq!( + matching_strategy.config_usages[0].path, + ".rules[].matching_strategy" + ); + + // It should also have a type reference from IdWithIdentifier + assert_eq!(matching_strategy.type_references.len(), 1); + assert_eq!( + matching_strategy.type_references[0].type_name, + "IdWithIdentifier" + ); + assert_eq!( + matching_strategy.type_references[0].anchor, + "idwithidentifier" + ); + + // IdWithIdentifier has config usages + let id_with_identifier = &nav.usages["IdWithIdentifier"]; + assert_eq!(id_with_identifier.config_usages.len(), 1); + assert_eq!(id_with_identifier.config_usages[0].path, ".rules[]"); + } + + #[test] + fn test_type_with_both_usages() { + let schema = json!({ + "title": "Test", + "type": "object", + "$defs": { + "Colour": { + "type": "object", + "properties": { + "r": { "type": "integer" }, + "g": { "type": "integer" }, + "b": { "type": "integer" } + } + }, + "Theme": { + "type": "object", + "properties": { + "primary": { "$ref": "#/$defs/Colour" }, + "secondary": { "$ref": "#/$defs/Colour" } + } + } + }, + "properties": { + "background": { "$ref": "#/$defs/Colour" }, + "theme": { "$ref": "#/$defs/Theme" } + } + }); + + let nav = build_navigation(&schema); + + // Colour has config usages: direct (background) and transitive through Theme + let colour = &nav.usages["Colour"]; + assert_eq!(colour.config_usages.len(), 3); + + // Find usages by path and verify anchors + let background_usage = colour + .config_usages + .iter() + .find(|u| u.path == ".background") + .expect("should have .background usage"); + // Direct usage at config root -> anchor is "background" + assert_eq!(background_usage.anchor, "background"); + + let primary_usage = colour + .config_usages + .iter() + .find(|u| u.path == ".theme.primary") + .expect("should have .theme.primary usage"); + // Transitive through Theme -> anchor points to Theme's property: "theme-primary" + assert_eq!(primary_usage.anchor, "theme-primary"); + + let secondary_usage = colour + .config_usages + .iter() + .find(|u| u.path == ".theme.secondary") + .expect("should have .theme.secondary usage"); + // Transitive through Theme -> anchor points to Theme's property: "theme-secondary" + assert_eq!(secondary_usage.anchor, "theme-secondary"); + + // Also has type reference from Theme + assert_eq!(colour.type_references.len(), 1); + assert_eq!(colour.type_references[0].type_name, "Theme"); + } + + #[test] + fn test_anchor_links_match_property_ids() { + // Verify that anchors generated for config usages match the IDs generated by the template + // Template uses: id="{{ path | lower | replace(from=".", to="-") }}" + let schema = json!({ + "title": "Test", + "type": "object", + "$defs": { + "Type1": { "type": "string" }, + "Type2": { "type": "string" } + }, + "properties": { + "simple_field": { "$ref": "#/$defs/Type1" }, + "nested": { + "type": "object", + "properties": { + "inner_field": { "$ref": "#/$defs/Type2" } + } + } + } + }); + + let nav = build_navigation(&schema); + + // simple_field: path=simple_field, template ID=simple_field + let type1 = &nav.usages["Type1"]; + assert_eq!(type1.config_usages[0].anchor, "simple_field"); + + // nested.inner_field: path=nested.inner_field, template ID=nested-inner_field + let type2 = &nav.usages["Type2"]; + assert_eq!(type2.config_usages[0].anchor, "nested-inner_field"); + } + + #[test] + fn test_transitive_config_usages() { + // Types nested inside other types should show their full Configuration path + let schema = json!({ + "title": "Test", + "type": "object", + "$defs": { + "MonitorConfig": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "workspaces": { + "type": "array", + "items": { "$ref": "#/$defs/WorkspaceConfig" } + } + } + }, + "WorkspaceConfig": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "layout": { "$ref": "#/$defs/Layout" } + } + }, + "Layout": { + "type": "string", + "enum": ["horizontal", "vertical"] + } + }, + "properties": { + "monitors": { + "type": "array", + "items": { "$ref": "#/$defs/MonitorConfig" } + } + } + }); + + let nav = build_navigation(&schema); + + // MonitorConfig should have direct usage at config root + // Anchor points to "monitors" in the Configuration section (no [] in IDs) + let monitor_usages = &nav.usages["MonitorConfig"]; + assert_eq!(monitor_usages.config_usages.len(), 1); + assert_eq!(monitor_usages.config_usages[0].path, ".monitors[]"); + assert_eq!(monitor_usages.config_usages[0].anchor, "monitors"); + + // WorkspaceConfig should have transitive usage through MonitorConfig + // Anchor points to "monitorconfig-workspaces" (property in MonitorConfig type definition) + let workspace_usages = &nav.usages["WorkspaceConfig"]; + assert_eq!(workspace_usages.config_usages.len(), 1); + assert_eq!( + workspace_usages.config_usages[0].path, + ".monitors[].workspaces[]" + ); + assert_eq!( + workspace_usages.config_usages[0].anchor, + "monitorconfig-workspaces" + ); + + // Layout should have transitive usage through WorkspaceConfig + // Anchor points to "workspaceconfig-layout" (property in WorkspaceConfig type definition) + let layout_usages = &nav.usages["Layout"]; + assert_eq!(layout_usages.config_usages.len(), 1); + assert_eq!( + layout_usages.config_usages[0].path, + ".monitors[].workspaces[].layout" + ); + assert_eq!( + layout_usages.config_usages[0].anchor, + "workspaceconfig-layout" + ); + } + + #[test] + fn test_recursive_type_no_infinite_loop() { + // Recursive types should not cause infinite loops + let schema = json!({ + "title": "Test", + "type": "object", + "$defs": { + "Node": { + "type": "object", + "properties": { + "value": { "type": "string" }, + "children": { + "type": "array", + "items": { "$ref": "#/$defs/Node" } + } + } + } + }, + "properties": { + "root": { "$ref": "#/$defs/Node" } + } + }); + + let nav = build_navigation(&schema); + + // Should complete without hanging + let node_usages = &nav.usages["Node"]; + // Node should have at least the direct usage at .root + assert!(node_usages.config_usages.len() >= 1); + assert!(node_usages.config_usages.iter().any(|u| u.path == ".root")); + } + + #[test] + fn test_same_type_multiple_paths() { + // A type used in multiple places should show all paths + let schema = json!({ + "title": "Test", + "type": "object", + "$defs": { + "Colour": { + "type": "object", + "properties": { + "r": { "type": "integer" }, + "g": { "type": "integer" }, + "b": { "type": "integer" } + } + }, + "Theme": { + "type": "object", + "properties": { + "primary": { "$ref": "#/$defs/Colour" }, + "secondary": { "$ref": "#/$defs/Colour" } + } + } + }, + "properties": { + "background": { "$ref": "#/$defs/Colour" }, + "foreground": { "$ref": "#/$defs/Colour" }, + "theme": { "$ref": "#/$defs/Theme" } + } + }); + + let nav = build_navigation(&schema); + + // Colour should have 4 config usages: + // - .background (direct) + // - .foreground (direct) + // - .theme.primary (transitive through Theme) + // - .theme.secondary (transitive through Theme) + let colour_usages = &nav.usages["Colour"]; + assert_eq!(colour_usages.config_usages.len(), 4); + + let paths: Vec<&str> = colour_usages + .config_usages + .iter() + .map(|u| u.path.as_str()) + .collect(); + assert!(paths.contains(&".background")); + assert!(paths.contains(&".foreground")); + assert!(paths.contains(&".theme.primary")); + assert!(paths.contains(&".theme.secondary")); + } + + #[test] + fn test_oneof_with_object_variants_anchor() { + // oneOf types where variants have properties should include variant index in anchor + // This matches template behavior: KomorebiTheme.oneOf[0].bar_accent -> komorebitheme-oneof[0]-bar_accent + let schema = json!({ + "title": "Test", + "type": "object", + "$defs": { + "Colour": { "type": "string" }, + "Theme": { + "oneOf": [ + { + "type": "object", + "properties": { + "primary": { "$ref": "#/$defs/Colour" }, + "name": { "type": "string", "const": "light" } + } + }, + { + "type": "object", + "properties": { + "primary": { "$ref": "#/$defs/Colour" }, + "name": { "type": "string", "const": "dark" } + } + } + ] + } + }, + "properties": { + "theme": { "$ref": "#/$defs/Theme" } + } + }); + + let nav = build_navigation(&schema); + + // Colour should have 2 usages - one from each oneOf variant + let colour_usages = &nav.usages["Colour"]; + assert_eq!(colour_usages.config_usages.len(), 2); + + // Both should have oneOf[N] in the anchor since variants have properties + let anchors: Vec<&str> = colour_usages + .config_usages + .iter() + .map(|u| u.anchor.as_str()) + .collect(); + assert!(anchors.contains(&"theme-oneof[0]-primary")); + assert!(anchors.contains(&"theme-oneof[1]-primary")); + } + + #[test] + fn test_anyof_type_union_anchor() { + // anyOf types that are pure type unions (no properties in variants) should use type anchor + // e.g., Colour = Hex | Rgb - anchor should be "colour", not "colour-anyof[0]" + let schema = json!({ + "title": "Test", + "type": "object", + "$defs": { + "Hex": { "type": "string", "format": "hex" }, + "Rgb": { + "type": "object", + "properties": { + "r": { "type": "integer" }, + "g": { "type": "integer" }, + "b": { "type": "integer" } + } + }, + "Colour": { + "anyOf": [ + { "$ref": "#/$defs/Hex" }, + { "$ref": "#/$defs/Rgb" } + ] + } + }, + "properties": { + "background": { "$ref": "#/$defs/Colour" } + } + }); + + let nav = build_navigation(&schema); + + // Hex and Rgb should have usages through Colour + // Since Colour's anyOf variants are just $refs (no properties), anchor should be "colour" + let hex_usages = &nav.usages["Hex"]; + assert_eq!(hex_usages.config_usages.len(), 1); + assert_eq!(hex_usages.config_usages[0].path, ".background"); + assert_eq!(hex_usages.config_usages[0].anchor, "colour"); + + let rgb_usages = &nav.usages["Rgb"]; + assert_eq!(rgb_usages.config_usages.len(), 1); + assert_eq!(rgb_usages.config_usages[0].path, ".background"); + assert_eq!(rgb_usages.config_usages[0].anchor, "colour"); + } + + #[test] + fn test_nested_oneof_in_property_no_variant_index() { + // Properties with anyOf (like optional values) should NOT add variant index + // e.g., MonitorConfig.wallpaper which is anyOf[Wallpaper, null] -> anchor is "monitorconfig-wallpaper" + let schema = json!({ + "title": "Test", + "type": "object", + "$defs": { + "Wallpaper": { + "type": "object", + "properties": { + "path": { "type": "string" } + } + }, + "MonitorConfig": { + "type": "object", + "properties": { + "wallpaper": { + "anyOf": [ + { "$ref": "#/$defs/Wallpaper" }, + { "type": "null" } + ] + } + } + } + }, + "properties": { + "monitor": { "$ref": "#/$defs/MonitorConfig" } + } + }); + + let nav = build_navigation(&schema); + + // Wallpaper should have usage through MonitorConfig.wallpaper + // The anyOf is within a property, so NO variant index in anchor + let wallpaper_usages = &nav.usages["Wallpaper"]; + assert_eq!(wallpaper_usages.config_usages.len(), 1); + assert_eq!(wallpaper_usages.config_usages[0].path, ".monitor.wallpaper"); + assert_eq!( + wallpaper_usages.config_usages[0].anchor, + "monitorconfig-wallpaper" + ); + } + + #[test] + fn test_array_path_strips_brackets_in_anchor() { + // Array paths like .monitors[] should have anchor "monitors" (no brackets) + // Template IDs don't include [] notation + let schema = json!({ + "title": "Test", + "type": "object", + "$defs": { + "Item": { "type": "string" } + }, + "properties": { + "items": { + "type": "array", + "items": { "$ref": "#/$defs/Item" } + } + } + }); + + let nav = build_navigation(&schema); + + let item_usages = &nav.usages["Item"]; + assert_eq!(item_usages.config_usages.len(), 1); + assert_eq!(item_usages.config_usages[0].path, ".items[]"); + // Anchor should NOT have brackets + assert_eq!(item_usages.config_usages[0].anchor, "items"); + } +} diff --git a/src/templates/base.html b/src/templates/base.html new file mode 100644 index 00000000..8bfe364a --- /dev/null +++ b/src/templates/base.html @@ -0,0 +1,144 @@ +{%- import "macros.html" as macros -%} +<!doctype html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>{{ title | default(value="Schema Documentation") }} + + + +
+
+

+ {{ title_html | default(value="Schema Documentation") | safe + }} +

+
+ +
+
+
+
+
+ +
+
+

Configuration

+ {%- if schema.properties -%} +
+ {%- for prop_name, prop in schema.properties -%} {%- set + is_required = prop_name in required_set -%} {{ + macros::render_property(name=prop_name, node=prop, + required=is_required, depth=0, path=prop_name) }} {%- + endfor -%} +
+ {%- endif -%} +
+ {%- if schema["$defs"] -%} +
+

Type Definitions

+ {%- for def_name, def in schema["$defs"] -%} +
+

{{ def_name }}

+ {%- if def.description_html %} +
+ {{ def.description_html | safe }} +
+ {% endif -%} {{ macros::render_schema(node=def, depth=0, + path=def_name) }} {# Usages section at bottom #} {%- set + type_usages = usages[def_name] -%} {%- if type_usages + and (type_usages.config_usages | length > 0 or + type_usages.type_references | length > 0) -%} +
+ {%- if type_usages.config_usages | length > 0 -%} +
+

Configuration Usage

+
    + {%- for usage in type_usages.config_usages + -%} +
  • + {{ usage.path }} +
  • + {%- endfor -%} +
+
+ {%- endif -%} {%- if type_usages.type_references | + length > 0 -%} +
+

Referenced by

+
    + {%- for ref in type_usages.type_references + -%} +
  • + {{ ref.type_name }} +
  • + {%- endfor -%} +
+
+ {%- endif -%} +
+ {%- endif -%} +
+ {%- endfor -%} +
+ {%- endif -%} +
+
+ + + diff --git a/src/templates/macros.html b/src/templates/macros.html new file mode 100644 index 00000000..d9026647 --- /dev/null +++ b/src/templates/macros.html @@ -0,0 +1,348 @@ +{# Render a hierarchical outline item with collapsible children #} +{% macro render_outline_item(entry, depth) %} +{%- if entry.children | length > 0 -%} +
  • +
    + + {{ entry.name }} + +
      + {%- for child in entry.children -%} + {{ self::render_outline_item(entry=child, depth=depth + 1) }} + {%- endfor -%} +
    +
    +
  • +{%- else -%} +
  • + {{ entry.name }} +
  • +{%- endif -%} +{% endmacro render_outline_item %} + +{# Render a property with its name and schema #} +{# Note: Using 'node' instead of 'schema' to avoid conflict with global context #} +{% macro render_property(name, node, required, depth, path) %} +{%- if depth < 10 -%} +
    +
    + {{ name }} + {%- if required %}required{% endif -%} + {%- if node.deprecated %}deprecated{% endif -%} + {{ self::render_type_badge(node=node) }} +
    + {%- if node.description_html %}
    {{ node.description_html | safe }}
    {% endif -%} + {{ self::render_constraints(node=node) }} + {%- if node.default %}
    Default: {{ node.default | json_encode() }}
    {% endif -%} + {{ self::render_schema_body(node=node, depth=depth, path=path) }} +
    +{%- else -%} +
    Schema nested too deeply. See type definition.
    +{%- endif -%} +{% endmacro render_property %} + +{# Render the type badge for a schema #} +{% macro render_type_badge(node) %} +{%- if node["$ref"] -%} + {%- set ref_name = node["$ref"] | replace(from="#/$defs/", to="") -%} + {{ ref_name }} +{%- elif node.type -%} + {%- if node.type is string -%} + {%- if node.type == "array" and node.prefixItems -%} + {# Check if all prefixItems have the same type (homogeneous tuple) #} + {%- set first_type = node.prefixItems | first -%} + {%- set_global is_homogeneous = true -%} + {%- for item in node.prefixItems -%} + {%- if item.type != first_type.type -%} + {%- set_global is_homogeneous = false -%} + {%- endif -%} + {%- endfor -%} + {%- if is_homogeneous and first_type.type -%} + array {{ first_type.type }}; {{ node.prefixItems | length }} + {%- else -%} + array tuple + {%- endif -%} + {%- elif node.type == "array" and node.items -%} + {# Show array with item type as separate badge #} + array {{ self::render_type_badge(node=node.items) }} + {%- else -%} + {{ node.type }} + {%- endif -%} + {%- else -%} + {# Check for Option pattern: type array with exactly 2 types where one is null #} + {%- if node.type | length == 2 and "null" in node.type -%} + optional + {%- for t in node.type -%} + {%- if t != "null" -%} + {%- if t == "array" and node.items -%} + array {{ self::render_type_badge(node=node.items) }} + {%- else -%} + {{ t }} + {%- endif -%} + {%- endif -%} + {%- endfor -%} + {%- else -%} + {%- for t in node.type -%}{{ t }}{% endfor -%} + {%- endif -%} + {%- endif -%} +{%- elif node.anyOf -%} + {# Check for Option pattern: anyOf with exactly 2 variants where one is null #} + {%- if node.anyOf | length == 2 -%} + {%- set first = node.anyOf | first -%} + {%- set second = node.anyOf | last -%} + {%- if second.type and second.type == "null" -%} + optional{{ self::render_type_badge(node=first) }} + {%- elif first.type and first.type == "null" -%} + optional{{ self::render_type_badge(node=second) }} + {%- else -%} + {# Check if all variants are string const (enum pattern) #} + {%- set_global is_string_enum = true -%} + {%- for variant in node.anyOf -%} + {%- if variant.type and variant.type == "string" and variant.const -%} + {# valid string const #} + {%- else -%} + {%- set_global is_string_enum = false -%} + {%- endif -%} + {%- endfor -%} + {%- if is_string_enum -%} + string enum + {%- else -%} + enum + {%- endif -%} + {%- endif -%} + {%- else -%} + {# Check if all variants are string const (enum pattern) #} + {%- set_global is_string_enum = true -%} + {%- for variant in node.anyOf -%} + {%- if variant.type and variant.type == "string" and variant.const -%} + {# valid string const #} + {%- else -%} + {%- set_global is_string_enum = false -%} + {%- endif -%} + {%- endfor -%} + {%- if is_string_enum -%} + string enum + {%- else -%} + enum + {%- endif -%} + {%- endif -%} +{%- elif node.oneOf -%} + {# Check if all variants are string const (enum pattern from schemars with doc comments) #} + {%- set_global is_string_enum = true -%} + {%- for variant in node.oneOf -%} + {%- if variant.type and variant.type == "string" and variant.const -%} + {# This variant is a string const, keep is_string_enum true #} + {%- else -%} + {%- set_global is_string_enum = false -%} + {%- endif -%} + {%- endfor -%} + {%- if is_string_enum -%} + string enum + {%- else -%} + enum + {%- endif -%} +{%- elif node.allOf -%} + allOf +{%- elif node.const -%} + const +{%- elif node.enum -%} + enum +{%- endif -%} +{% endmacro render_type_badge %} + +{# Render constraints like min, max, pattern, format #} +{% macro render_constraints(node) %} +{# Check if this is a homogeneous tuple - skip minItems/maxItems if so #} +{%- set_global skip_item_constraints = false -%} +{%- if node.prefixItems -%} + {%- set first_type = node.prefixItems | first -%} + {%- set_global is_homogeneous = true -%} + {%- for item in node.prefixItems -%} + {%- if item.type != first_type.type -%} + {%- set_global is_homogeneous = false -%} + {%- endif -%} + {%- endfor -%} + {%- if is_homogeneous and first_type.type -%} + {%- set_global skip_item_constraints = true -%} + {%- endif -%} +{%- endif -%} +{%- if node.format or node.pattern or node.minimum is defined or node.maximum is defined or node.minLength is defined or node.maxLength is defined or (node.minItems is defined and not skip_item_constraints) or (node.maxItems is defined and not skip_item_constraints) -%} +
    + {%- if node.format %}Format: {{ node.format }}{% endif -%} + {%- if node.pattern %}Pattern: {{ node.pattern }}{% endif -%} + {%- if node.minimum is defined %}Min: {{ node.minimum }}{% endif -%} + {%- if node.maximum is defined %}Max: {{ node.maximum }}{% endif -%} + {%- if node.minLength is defined %}Min length: {{ node.minLength }}{% endif -%} + {%- if node.maxLength is defined %}Max length: {{ node.maxLength }}{% endif -%} + {%- if node.minItems is defined and not skip_item_constraints %}Min items: {{ node.minItems }}{% endif -%} + {%- if node.maxItems is defined and not skip_item_constraints %}Max items: {{ node.maxItems }}{% endif -%} +
    +{%- endif -%} +{% endmacro render_constraints %} + +{# Render the body of a schema (properties, items, variants, etc.) #} +{% macro render_schema_body(node, depth, path) %} +{%- if depth < 10 -%} +{%- if node["$ref"] -%} + {# Reference only, already shown as type badge #} +{%- elif node.properties -%} + {%- set required_arr = node.required | default(value=[]) -%} +
    + Properties ({{ node.properties | length }}) +
    + {%- for prop_name, prop in node.properties -%} + {%- set is_required = prop_name in required_arr -%} + {{ self::render_property(name=prop_name, node=prop, required=is_required, depth=depth + 1, path=path ~ "." ~ prop_name) }} + {%- endfor -%} +
    +
    +{%- elif node.patternProperties -%} +
    + {%- for pattern, prop in node.patternProperties -%} +
    +
    + {# Convert known patterns to friendly type names - show as quoted string to indicate JSON string key #} + {# Note: patterns use single backslash in the parsed JSON value #} + {%- if pattern == "^\d+$" or pattern == "^[0-9]+$" or pattern == "^-?\d+$" or pattern == "^-?[0-9]+$" -%} + "<integer>" + {%- elif pattern == ".*" or pattern == "^.*$" -%} + "<string>" + {%- else -%} + {{ pattern }} + pattern + {%- endif -%} + → + {{ self::render_type_badge(node=prop) }} +
    + {%- if prop.description_html %}
    {{ prop.description_html | safe }}
    {% endif -%} + {{ self::render_schema_body(node=prop, depth=depth + 1, path=path ~ ".[key]") }} +
    + {%- endfor -%} +
    +{%- elif node.items -%} + {%- if node.items["$ref"] -%} + {# Already shown in type badge, no dropdown needed #} + {%- elif node.items.type and node.items.type is string -%} + {# Simple type (string, number, integer, boolean) - already shown in type badge #} + {%- else -%} + {# Complex item type needs dropdown #} +
    + Array Items +
    {{ self::render_schema(node=node.items, depth=depth + 1, path=path ~ "[]") }}
    +
    + {%- endif -%} +{%- elif node.prefixItems -%} + {# Check if homogeneous tuple - already shown as [T; N] in badge #} + {%- set first_type = node.prefixItems | first -%} + {%- set_global is_homogeneous = true -%} + {%- for item in node.prefixItems -%} + {%- if item.type != first_type.type -%} + {%- set_global is_homogeneous = false -%} + {%- endif -%} + {%- endfor -%} + {%- if is_homogeneous and first_type.type -%} + {# Homogeneous tuple - already shown as [T; N] in badge, no dropdown needed #} + {%- else -%} + {# Heterogeneous tuple - show dropdown #} +
    + Tuple Items ({{ node.prefixItems | length }}) +
    + {%- for item in node.prefixItems -%} +
    [{{ loop.index0 }}]{{ self::render_schema(node=item, depth=depth + 1, path=path ~ "[" ~ loop.index0 ~ "]") }}
    + {%- endfor -%} +
    +
    + {%- endif -%} +{%- elif node.anyOf -%} + {# Check for Option pattern: anyOf with exactly 2 variants where one is null #} + {%- if node.anyOf | length == 2 -%} + {%- set first = node.anyOf | first -%} + {%- set second = node.anyOf | last -%} + {%- if second.type and second.type == "null" -%} + {# Option - just render the non-null variant's body #} + {{ self::render_schema_body(node=first, depth=depth, path=path) }} + {%- elif first.type and first.type == "null" -%} + {# Option with null first - just render the non-null variant's body #} + {{ self::render_schema_body(node=second, depth=depth, path=path) }} + {%- else -%} + {# Check if all variants are string const (enum pattern) #} + {%- set_global is_string_enum = true -%} + {%- for variant in node.anyOf -%} + {%- if variant.type and variant.type == "string" and variant.const -%} + {# valid string const #} + {%- else -%} + {%- set_global is_string_enum = false -%} + {%- endif -%} + {%- endfor -%} + {# Render all anyOf variants using full schema rendering #} +
    + Enum variants ({{ node.anyOf | length }}) +
    + {%- for variant in node.anyOf -%} +
    {{ self::render_schema(node=variant, depth=depth + 1, path=path ~ ".anyOf[" ~ loop.index0 ~ "]") }}
    + {%- endfor -%} +
    +
    + {%- endif -%} + {%- else -%} + {# Render all anyOf variants using full schema rendering #} +
    + Enum variants ({{ node.anyOf | length }}) +
    + {%- for variant in node.anyOf -%} +
    {{ self::render_schema(node=variant, depth=depth + 1, path=path ~ ".anyOf[" ~ loop.index0 ~ "]") }}
    + {%- endfor -%} +
    +
    + {%- endif -%} +{%- elif node.oneOf -%} + {# Render all oneOf variants using full schema rendering #} +
    + Enum variants ({{ node.oneOf | length }}) +
    + {%- for variant in node.oneOf -%} +
    {{ self::render_schema(node=variant, depth=depth + 1, path=path ~ ".oneOf[" ~ loop.index0 ~ "]") }}
    + {%- endfor -%} +
    +
    +{%- elif node.allOf -%} +
    + All of ({{ node.allOf | length }} schemas) +
    + {%- for variant in node.allOf -%} +
    {{ self::render_schema(node=variant, depth=depth + 1, path=path ~ ".allOf[" ~ loop.index0 ~ "]") }}
    + {%- endfor -%} +
    +
    +{%- elif node.enum -%} + {%- set enum_count = node.enum | length -%} +
    + Enum variants ({{ enum_count }}) +
      + {%- for value in node.enum -%}
    • {{ value | json_encode() }}
    • {%- endfor -%} +
    +
    +{%- elif node.const -%} +
    Value: {{ node.const | json_encode() }}
    +{%- endif -%} +{%- if node.additionalProperties and node.additionalProperties is object -%} +
    + Additional Properties + {{ self::render_schema(node=node.additionalProperties, depth=depth + 1, path=path ~ ".[*]") }} +
    +{%- endif -%} +{%- endif -%} +{% endmacro render_schema_body %} + +{# Render a schema without property wrapper #} +{% macro render_schema(node, depth, path) %} +{%- if depth < 10 -%} +
    + {{ self::render_type_badge(node=node) }} + {%- if node.description_html %}
    {{ node.description_html | safe }}
    {% endif -%} + {{ self::render_constraints(node=node) }} + {%- if node.default %}
    Default: {{ node.default | json_encode() }}
    {% endif -%} + {{ self::render_schema_body(node=node, depth=depth, path=path) }} +
    +{%- endif -%} +{% endmacro render_schema %} diff --git a/src/templates/style.css b/src/templates/style.css new file mode 100644 index 00000000..fefcee81 --- /dev/null +++ b/src/templates/style.css @@ -0,0 +1,955 @@ +/* CSS Custom Properties for theming */ +:root { + --bg-primary: #ffffff; + --bg-secondary: #f8f9fa; + --bg-tertiary: #e9ecef; + --text-primary: #212529; + --text-secondary: #6c757d; + --text-muted: #adb5bd; + --accent: #0d6efd; + --accent-hover: #0b5ed7; + --border: #dee2e6; + --border-light: #e9ecef; + --code-bg: #f1f3f5; + --code-text: #e83e8c; + --description-bg: #f8f9fa; + --description-border: #0d6efd; + --success: #198754; + --warning: #ffc107; + --danger: #dc3545; + --highlight: #fff3cd; + --shadow: rgba(0, 0, 0, 0.1); + --font-sans: + -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", + Arial, sans-serif; + --font-mono: + SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", + "Courier New", monospace; +} + +/* Dark mode - follows system preference */ +@media (prefers-color-scheme: dark) { + :root { + --bg-primary: #1a1d21; + --bg-secondary: #22262b; + --bg-tertiary: #2c3137; + --text-primary: #e9ecef; + --text-secondary: #adb5bd; + --text-muted: #6c757d; + --accent: #6ea8fe; + --accent-hover: #8bb9fe; + --border: #3d4349; + --border-light: #2c3137; + --code-bg: #2c3137; + --code-text: #f8a4c4; + --description-bg: #22262b; + --description-border: #6ea8fe; + --highlight: #3d3520; + --shadow: rgba(0, 0, 0, 0.3); + } +} + +/* Reset and base styles */ +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; + font-size: 16px; +} + +body { + margin: 0; + padding: 0; + font-family: var(--font-sans); + font-size: 1rem; + line-height: 1.6; + color: var(--text-primary); + background-color: var(--bg-primary); +} + +/* Header */ +.header { + position: sticky; + top: 0; + z-index: 100; + background-color: var(--bg-secondary); + border-bottom: 1px solid var(--border); + padding: 1rem; + box-shadow: 0 2px 4px var(--shadow); +} + +.header-content { + max-width: 1400px; + margin: 0 auto; +} + +.header h1 { + margin: 0 0 0.5rem 0; + font-size: 1.5rem; + font-weight: 600; +} + +.header .schema-description { + color: var(--text-secondary); + margin-bottom: 1rem; +} + +.header .schema-description p { + margin: 0; +} + +/* Search */ +.search-container { + position: relative; + max-width: 400px; +} + +#search { + width: 100%; + padding: 0.5rem 1rem; + font-size: 0.9rem; + border: 1px solid var(--border); + border-radius: 6px; + background-color: var(--bg-primary); + color: var(--text-primary); + outline: none; + transition: + border-color 0.15s, + box-shadow 0.15s; +} + +#search:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(13, 110, 253, 0.15); +} + +#search::placeholder { + color: var(--text-muted); +} + +.search-results { + display: none; + position: absolute; + top: 100%; + left: 0; + right: 0; + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: 6px; + margin-top: 4px; + max-height: 300px; + overflow-y: auto; + box-shadow: 0 4px 12px var(--shadow); + z-index: 1000; +} + +.search-results.active { + display: block; +} + +.search-result-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + text-decoration: none; + color: var(--text-primary); + border-bottom: 1px solid var(--border-light); + transition: background-color 0.15s; +} + +.search-result-item:last-child { + border-bottom: none; +} + +.search-result-item:hover { + background-color: var(--bg-secondary); +} + +.search-result-item.no-results { + color: var(--text-secondary); + font-style: italic; +} + +.result-name { + font-weight: 500; +} + +.result-path { + color: var(--text-secondary); + font-size: 0.85rem; + flex: 1; +} + +.result-type { + font-size: 0.75rem; + padding: 0.125rem 0.375rem; + background: var(--bg-tertiary); + border-radius: 3px; + color: var(--text-secondary); +} + +/* Layout */ +.layout { + display: grid; + grid-template-columns: 1fr; + min-height: calc(100vh - 120px); +} + +@media (min-width: 768px) { + .layout { + grid-template-columns: 260px 1fr; + } +} + +@media (min-width: 1200px) { + .layout { + grid-template-columns: 280px 1fr; + } +} + +/* Sidebar */ +.sidebar { + background-color: var(--bg-secondary); + border-right: 1px solid var(--border); + padding: 1rem; + overflow-y: auto; + display: none; +} + +@media (min-width: 768px) { + .sidebar { + display: block; + position: sticky; + top: 80px; + height: calc(100vh - 80px); + } +} + +.sidebar.open { + display: block; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 200; + padding-top: 60px; +} + +.sidebar-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; +} + +.sidebar-header h2 { + margin: 0; + font-size: 1rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-secondary); +} + +.sidebar-toggle { + display: block; + padding: 0.5rem; + background: none; + border: none; + cursor: pointer; +} + +@media (min-width: 768px) { + .sidebar-toggle { + display: none; + } +} + +.sidebar-toggle span { + display: block; + width: 20px; + height: 2px; + background: var(--text-primary); + margin: 4px 0; +} + +.nav-list { + list-style: none; + margin: 0; + padding: 0; +} + +.nav-link { + display: block; + padding: 0.375rem 0.5rem; + color: var(--text-primary); + text-decoration: none; + border-radius: 4px; + font-size: 0.9rem; + transition: background-color 0.15s; +} + +.nav-link:hover { + background-color: var(--bg-tertiary); +} + +/* Sidebar sections */ +.sidebar-section { + margin-bottom: 1.5rem; +} + +.sidebar-section-title { + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); + margin: 0 0 0.5rem 0.5rem; +} + +.outline-list { + border-bottom: 1px solid var(--border); + padding-bottom: 1rem; + margin-bottom: 0.5rem; +} + +/* Hierarchical outline tree */ +.outline-item { + position: relative; +} + +.outline-children { + list-style: none; + margin: 0; + padding-left: 0.75rem; + border-left: 1px solid var(--border-light); + margin-left: 0.5rem; +} + +.outline-item.depth-1 > a.nav-link, +.outline-item.depth-1 > details > summary .nav-link { + font-size: 0.8rem; + color: var(--text-secondary); +} + +.outline-item.depth-2 > a.nav-link, +.outline-item.depth-2 > details > summary .nav-link { + font-size: 0.75rem; + color: var(--text-muted); +} + +/* Collapsible outline items */ +.outline-item details { + margin: 0; +} + +.outline-item details > summary { + list-style: none; + cursor: pointer; + display: flex; + align-items: center; + gap: 0.25rem; +} + +.outline-item details > summary::-webkit-details-marker { + display: none; +} + +/* Custom expand/collapse indicator */ +.outline-item details > summary::before { + content: "▶"; + font-size: 0.5rem; + color: var(--text-muted); + transition: transform 0.15s ease; + flex-shrink: 0; + width: 0.75rem; +} + +.outline-item details[open] > summary::before { + transform: rotate(90deg); +} + +/* Ensure link inside summary works correctly */ +.outline-item details > summary .nav-link { + flex: 1; +} + +/* Leaf items (no children) - add spacer to match expand icon width */ +.outline-item > a.nav-link { + display: flex; + align-items: center; + gap: 0.25rem; /* Same gap as expandable items */ +} + +.outline-item > a.nav-link::before { + content: ""; + width: 0.75rem; /* Same width as expand icon */ + flex-shrink: 0; +} + +/* Mobile sidebar toggle button in header */ +@media (max-width: 767px) { + .header-content { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 1rem; + } + + .header h1 { + flex: 1; + } + + .search-container { + order: 3; + width: 100%; + max-width: none; + } +} + +/* Main content */ +.main-content { + padding: 1.5rem; + max-width: 900px; +} + +@media (min-width: 1200px) { + .main-content { + padding: 2rem; + } +} + +.schema-section { + margin-bottom: 3rem; +} + +.schema-section > h2 { + font-size: 1.5rem; + font-weight: 600; + margin: 0 0 1.5rem 0; + padding-bottom: 0.5rem; + border-bottom: 2px solid var(--accent); +} + +/* Definition */ +.definition { + margin-bottom: 2rem; + padding: 1.5rem; + background-color: var(--bg-secondary); + border-radius: 8px; + border: 1px solid var(--border); + scroll-margin-top: 100px; + transition: box-shadow 0.3s; +} + +.definition.highlight { + box-shadow: 0 0 0 3px var(--accent); +} + +.definition-name { + margin: 0 0 0.75rem 0; + font-size: 1.25rem; + font-weight: 600; + color: var(--accent); +} + +/* Property */ +.property { + padding: 1rem 0; + border-bottom: 1px solid var(--border-light); + scroll-margin-top: 100px; +} + +.property:last-child { + border-bottom: none; +} + +.property.highlight { + background-color: var(--highlight); + margin: 0 -1rem; + padding: 1rem; + border-radius: 4px; +} + +.property-header { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; +} + +.property-name { + font-family: var(--font-mono); + font-size: 1rem; + font-weight: 600; + color: var(--text-primary); + background: none; + padding: 0; +} + +.property-name.pattern { + color: var(--code-text); +} + +/* Badges */ +.badge { + display: inline-block; + padding: 0.125rem 0.5rem; + font-size: 0.7rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.025em; + border-radius: 3px; +} + +.badge.required { + background-color: var(--danger); + color: white; +} + +.badge.deprecated { + background-color: var(--warning); + color: #000; +} + +.badge.pattern { + background-color: var(--accent); + color: white; +} + +/* Map key styling for patternProperties */ +.map-key { + font-family: var(--font-mono); + font-size: 0.95rem; + color: var(--text-primary); +} + +.map-key .key-type { + color: var(--accent); + font-weight: 500; +} + +.map-arrow { + color: var(--text-muted); + margin: 0 0.5rem; +} + +.badge.optional { + background-color: #6c757d; + color: white; +} + +/* Type badges */ +.type-badge { + display: inline-block; + padding: 0.125rem 0.5rem; + font-size: 0.75rem; + font-weight: 500; + border-radius: 3px; + background-color: var(--bg-tertiary); + color: var(--text-secondary); +} + +/* Primitive types */ +.type-badge.type-string { + background-color: #198754; + color: white; +} +.type-badge.type-number { + background-color: #0d6efd; + color: white; +} +.type-badge.type-integer { + background-color: #0a58ca; + color: white; +} +.type-badge.type-boolean { + background-color: #fd7e14; + color: white; +} +.type-badge.type-null { + background-color: #6c757d; + color: white; +} + +/* Complex types */ +.type-badge.type-object { + background-color: #6f42c1; + color: white; +} +.type-badge.type-array { + background-color: #0891b2; + color: white; +} + +/* Composition types */ +.type-badge.type-enum { + background-color: #d63384; + color: white; +} +.type-badge.type-union { + background-color: #6610f2; + color: white; +} +.type-badge.type-const { + background-color: #495057; + color: white; +} + +.type-ref { + display: inline-block; + padding: 0.125rem 0.5rem; + font-size: 0.75rem; + font-weight: 500; + border-radius: 3px; + background-color: #6f42c1; + color: white; + text-decoration: none; + transition: background-color 0.15s; +} + +.type-ref:hover { + background-color: #5a32a3; +} + +/* Description - PROMINENT styling */ +.description { + background-color: var(--description-bg); + border-left: 3px solid var(--description-border); + padding: 0.75rem 1rem; + margin: 0.75rem 0; + border-radius: 0 4px 4px 0; +} + +.description p { + margin: 0 0 0.5rem 0; +} + +.description p:last-child { + margin-bottom: 0; +} + +.description code { + background-color: var(--code-bg); + padding: 0.125rem 0.375rem; + border-radius: 3px; + font-family: var(--font-mono); + font-size: 0.875em; + color: var(--code-text); +} + +.description a { + color: var(--accent); +} + +/* Constraints */ +.constraints { + display: flex; + flex-wrap: wrap; + gap: 0.5rem 1rem; + margin: 0.5rem 0; + font-size: 0.875rem; +} + +.constraint { + color: var(--text-secondary); +} + +.constraint .label { + font-weight: 500; +} + +.constraint code { + background-color: var(--code-bg); + padding: 0.125rem 0.375rem; + border-radius: 3px; + font-family: var(--font-mono); + font-size: 0.875em; +} + +/* Default and const values - unified styling */ +.default-value, +.const-value { + margin: 0.5rem 0; + font-size: 0.875rem; + color: var(--text-secondary); +} + +.default-value .label, +.const-value .label { + font-weight: 500; +} + +.default-value code, +.const-value code { + background-color: var(--code-bg); + padding: 0.125rem 0.375rem; + border-radius: 3px; + font-family: var(--font-mono); + font-size: 0.875em; + color: var(--code-text); +} + +/* Nested schemas (details/summary) */ +.nested-schema { + margin: 0.75rem 0; + border: 1px solid var(--border-light); + border-radius: 6px; + overflow: hidden; +} + +.nested-schema > summary { + padding: 0.5rem 1rem; + background-color: var(--bg-tertiary); + cursor: pointer; + font-weight: 500; + font-size: 0.9rem; + user-select: none; + list-style: none; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.nested-schema > summary::-webkit-details-marker { + display: none; +} + +.nested-schema > summary::before { + content: "▶"; + font-size: 0.7rem; + transition: transform 0.15s; +} + +.nested-schema[open] > summary::before { + transform: rotate(90deg); +} + +.nested-schema > summary:hover { + background-color: var(--border-light); +} + +.properties-container, +.array-items, +.tuple-items, +.variants { + padding: 0.5rem 1rem; +} + +/* Depth-based indentation */ +.depth-1 { + margin-left: 0.5rem; +} +.depth-2 { + margin-left: 1rem; +} +.depth-3 { + margin-left: 1.5rem; +} +.depth-4 { + margin-left: 2rem; +} +.depth-5 { + margin-left: 2.5rem; +} + +@media (max-width: 767px) { + .depth-1, + .depth-2, + .depth-3, + .depth-4, + .depth-5 { + margin-left: 0; + } +} + +/* Variants */ +.variant { + padding: 0.75rem 0; + border-bottom: 1px solid var(--border-light); +} + +.variant:last-child { + border-bottom: none; +} + +/* Tuple items */ +.tuple-item { + padding: 0.5rem 0; + border-bottom: 1px solid var(--border-light); +} + +.tuple-item:last-child { + border-bottom: none; +} + +.tuple-index { + display: inline-block; + font-family: var(--font-mono); + font-size: 0.875rem; + font-weight: 500; + color: var(--text-secondary); + margin-right: 0.5rem; +} + +/* Enum values */ +.enum-values { + list-style: none; + margin: 0; + padding: 0.5rem 1rem; + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.enum-values li { + margin: 0; +} + +.enum-values code { + display: inline-block; + padding: 0.25rem 0.5rem; + background-color: var(--code-bg); + border-radius: 3px; + font-family: var(--font-mono); + font-size: 0.875rem; +} + +/* Large enum - show as list */ +.enum-details .enum-values { + max-height: 300px; + overflow-y: auto; + flex-direction: column; + gap: 0.25rem; +} + +/* Usages section */ +.usages { + margin: 1rem 0; + padding: 0.75rem 1rem; + background-color: var(--bg-secondary); + border-radius: 6px; + border-left: 3px solid var(--accent); +} + +.usage-section { + margin-bottom: 0.75rem; +} + +.usage-section:last-child { + margin-bottom: 0; +} + +.usage-section h4 { + margin: 0 0 0.25rem 0; + font-size: 0.8rem; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.usage-list { + margin: 0; + padding: 0; + list-style: none; +} + +.usage-list li { + padding: 0.25rem 0; +} + +.usage-list code { + font-size: 0.8125rem; + background-color: var(--code-bg); + padding: 0.125rem 0.375rem; + border-radius: 3px; +} + +.usage-list a { + color: var(--accent); + text-decoration: none; +} + +.usage-list a:hover { + text-decoration: underline; +} + +/* Schema inline (for nested schemas without property wrapper) */ +.schema-inline { + margin: 0.5rem 0; +} + +/* Code blocks in descriptions */ +.description pre { + background-color: var(--code-bg); + padding: 1rem; + border-radius: 6px; + overflow-x: auto; + margin: 0.75rem 0; +} + +.description pre code { + background: none; + padding: 0; + color: var(--text-primary); +} + +/* Scrollbar styling for dark mode */ +@media (prefers-color-scheme: dark) { + ::-webkit-scrollbar { + width: 8px; + height: 8px; + } + + ::-webkit-scrollbar-track { + background: var(--bg-secondary); + } + + ::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 4px; + } + + ::-webkit-scrollbar-thumb:hover { + background: var(--text-muted); + } +} + +/* Print styles */ +@media print { + .header { + position: static; + } + + .sidebar { + display: none; + } + + .layout { + grid-template-columns: 1fr; + } + + .search-container { + display: none; + } + + details { + display: block !important; + } + + details > summary { + display: none; + } + + details > *:not(summary) { + display: block !important; + } +} From 7d4b7857bbbe73cd72fe0c146fcd53e5951b665a Mon Sep 17 00:00:00 2001 From: LGUG2Z Date: Sun, 21 Dec 2025 17:50:50 -0800 Subject: [PATCH 02/19] feat(docgen): add wrapper type detection and improved tuple/float display Implement intelligent detection of "wrapper types" (anyOf patterns with map variant and inner type) to render them more meaningfully in documentation. Wrapper Type Detection: - Detect anyOf patterns like `[{object with additionalProperties: T}, T]` - Mark detected wrappers with `_wrapper_type` and `_wrapper_inner` metadata - Display inner type directly instead of showing misleading "enum" badge - Link wrapper types to their inner type definitions - Add `_display_name` field for wrapper types with numeric suffixes Float Display Improvements: - Display "float" instead of "number" for JSON Schema number types in tuples - Use `type-float` CSS class for consistent styling - Homogeneous tuples now show as "float; N" for number arrays Navigation Enhancements: - Build navigation from prepared schema (includes wrapper display names) - Add `display_names` map to template context for type name resolution - Track type usages for "Usages" section in definitions Test Coverage: - Add comprehensive tests for wrapper type rendering - Test tuple float/integer display formatting - Verify wrapper types don't show "Enum variants" dropdown - Test non-wrapper anyOf still correctly shows enum badge --- Cargo.lock | 1 + Cargo.toml | 1 + src/generator.rs | 481 ++++++++++++++++++++++++++++++++++- src/prepare.rs | 520 ++++++++++++++++++++++++++++++++++---- src/templates/base.html | 13 +- src/templates/macros.html | 22 +- src/templates/style.css | 4 + 7 files changed, 988 insertions(+), 54 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b3a10fbc..bd0633e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -740,6 +740,7 @@ dependencies = [ "clap", "pretty_assertions", "pulldown-cmark", + "regex", "schemars", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 3690b7e9..04881edc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,3 +24,4 @@ thiserror = "2" [dev-dependencies] pretty_assertions = "1.4" +regex = "1" diff --git a/src/generator.rs b/src/generator.rs index a2d0a25d..37bb61ec 100644 --- a/src/generator.rs +++ b/src/generator.rs @@ -36,8 +36,8 @@ impl Generator { // Prepare the schema for template rendering let prepared_schema = prepare_for_template(schema.as_value()); - // Build navigation data - let navigation = build_navigation(schema.as_value()); + // Build navigation data from the prepared schema (includes _display_name for wrapper types) + let navigation = build_navigation(&prepared_schema); // Build the template context let context = self.build_context(&prepared_schema, &navigation, options)?; @@ -93,6 +93,9 @@ impl Generator { // Type usages for "Usages" section in type definitions context.insert("usages", &navigation.usages); + // Display names for wrapper types (type_name -> display_name) + context.insert("display_names", &navigation.display_names); + // Build required set for the root schema let required_set: Vec = schema .get("required") @@ -1201,4 +1204,478 @@ mod tests { ); assert!(html.contains("Max length:"), "Should render maxLength"); } + + // Test: Homogeneous tuple with floats should display "float" instead of "number" + #[test] + fn test_tuple_float_display() { + let generator = Generator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "properties": { + "point": { + "type": "array", + "prefixItems": [ + { "type": "number" }, + { "type": "number" } + ] + } + } + })); + + let options = Options::default(); + let html = generator.generate(&schema, &options).unwrap(); + + // Should show "float" instead of "number" for tuple display + assert!( + html.contains("float; 2"), + "Should display 'float; 2' for number tuples" + ); + assert!( + !html.contains("number; 2"), + "Should NOT display 'number; 2' for float tuples" + ); + assert!( + html.contains("type-float"), + "Should use type-float CSS class" + ); + } + + // Test: Homogeneous tuple with integers should still display "integer" + #[test] + fn test_tuple_integer_display_unchanged() { + let generator = Generator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "properties": { + "coords": { + "type": "array", + "prefixItems": [ + { "type": "integer" }, + { "type": "integer" }, + { "type": "integer" } + ] + } + } + })); + + let options = Options::default(); + let html = generator.generate(&schema, &options).unwrap(); + + // Should still show "integer" for integer tuples + assert!( + html.contains("integer; 3"), + "Should display 'integer; 3' for integer tuples" + ); + assert!( + html.contains("type-integer"), + "Should use type-integer CSS class" + ); + } + + // Test: Float tuple with format (e.g., format: "double") still displays "float" + #[test] + fn test_tuple_float_with_format() { + let generator = Generator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "properties": { + "bezier": { + "type": "array", + "prefixItems": [ + { "type": "number", "format": "double" }, + { "type": "number", "format": "double" }, + { "type": "number", "format": "double" }, + { "type": "number", "format": "double" } + ] + } + } + })); + + let options = Options::default(); + let html = generator.generate(&schema, &options).unwrap(); + + // Should show "float" for number tuples regardless of format + assert!( + html.contains("float; 4"), + "Should display 'float; 4' for number tuples with format" + ); + } + + // ==================== Wrapper Type Rendering Tests ==================== + + // Test: Wrapper type with $ref renders inner type as link + #[test] + fn test_wrapper_type_renders_inner_ref() { + let generator = Generator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "$defs": { + "InnerType": { + "type": "string", + "description": "The inner type" + }, + "WrapperType": { + "anyOf": [ + { + "type": "object", + "additionalProperties": { "$ref": "#/$defs/InnerType" } + }, + { "$ref": "#/$defs/InnerType" } + ] + } + }, + "properties": { + "config": { "$ref": "#/$defs/WrapperType" } + } + })); + + let options = Options::default(); + let html = generator.generate(&schema, &options).unwrap(); + + // WrapperType definition should show InnerType as a link + assert!( + html.contains("href=\"#innertype\""), + "Should link to inner type definition" + ); + // Should NOT show "enum" badge for wrapper type + // (Note: The wrapper type is referenced via $ref, so we check the definition section) + } + + // Test: Wrapper type with primitive renders primitive badge + #[test] + fn test_wrapper_type_renders_primitive_badge() { + let generator = Generator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "$defs": { + "BoolWrapper": { + "anyOf": [ + { + "type": "object", + "additionalProperties": { "type": "boolean" } + }, + { "type": "boolean" } + ] + } + }, + "properties": { + "enabled": { "$ref": "#/$defs/BoolWrapper" } + } + })); + + let options = Options::default(); + let html = generator.generate(&schema, &options).unwrap(); + + // Should show boolean type badge in the BoolWrapper definition + assert!( + html.contains("type-boolean"), + "Should show boolean type badge for wrapper with primitive inner type" + ); + } + + // Test: Wrapper type with integer constraints renders integer badge + #[test] + fn test_wrapper_type_with_integer_renders_correctly() { + let generator = Generator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "$defs": { + "IntWrapper": { + "anyOf": [ + { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + { + "type": "integer", + "format": "uint64", + "minimum": 0 + } + ] + } + }, + "properties": { + "duration": { "$ref": "#/$defs/IntWrapper" } + } + })); + + let options = Options::default(); + let html = generator.generate(&schema, &options).unwrap(); + + // Should show integer type badge + assert!( + html.contains("type-integer"), + "Should show integer type badge for wrapper with integer inner type" + ); + } + + // Test: Non-wrapper anyOf still shows enum badge + #[test] + fn test_non_wrapper_anyof_still_shows_enum() { + let generator = Generator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "$defs": { + "NotWrapper": { + "anyOf": [ + { + "type": "object", + "additionalProperties": { "type": "string" } + }, + { "type": "integer" } + ] + } + }, + "properties": { + "value": { "$ref": "#/$defs/NotWrapper" } + } + })); + + let options = Options::default(); + let html = generator.generate(&schema, &options).unwrap(); + + // Should show enum badge since inner types don't match + assert!( + html.contains(">enum<"), + "Should show enum badge for non-wrapper anyOf" + ); + } + + // Test: Wrapper type does not show "Enum variants" dropdown + #[test] + fn test_wrapper_type_no_enum_variants_dropdown() { + let generator = Generator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "$defs": { + "StringWrapper": { + "anyOf": [ + { + "type": "object", + "additionalProperties": { "type": "string" } + }, + { "type": "string" } + ] + } + }, + "properties": { + "name": { "$ref": "#/$defs/StringWrapper" } + } + })); + + let options = Options::default(); + let html = generator.generate(&schema, &options).unwrap(); + + // Count occurrences of "Enum variants" - should not appear for wrapper types + // (The StringWrapper definition should not have an "Enum variants" dropdown) + let enum_variants_in_stringwrapper = html + .find("StringWrapper") + .map(|pos| { + let section = &html[pos..pos.min(html.len()).saturating_add(500)]; + section.contains("Enum variants") + }) + .unwrap_or(false); + + assert!( + !enum_variants_in_stringwrapper, + "Wrapper type should NOT show 'Enum variants' dropdown" + ); + } + + // Test: Wrapper type with $ref to AnimationStyle (like PerAnimationPrefixConfig3) + #[test] + fn test_wrapper_type_like_per_animation_prefix_config() { + let generator = Generator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "$defs": { + "AnimationStyle": { + "type": "string", + "enum": ["Linear", "EaseIn", "EaseOut"] + }, + "PerAnimationPrefixConfig": { + "anyOf": [ + { + "type": "object", + "additionalProperties": { "$ref": "#/$defs/AnimationStyle" } + }, + { "$ref": "#/$defs/AnimationStyle" } + ] + } + }, + "properties": { + "style": { "$ref": "#/$defs/PerAnimationPrefixConfig" } + } + })); + + let options = Options::default(); + let html = generator.generate(&schema, &options).unwrap(); + + // The PerAnimationPrefixConfig definition should link to AnimationStyle + assert!( + html.contains("href=\"#animationstyle\""), + "Wrapper type should link to inner type (AnimationStyle)" + ); + } + + // Test: Wrapper types display base name without numeric suffix + #[test] + fn test_wrapper_type_display_name() { + let generator = Generator::new().unwrap(); + let schema = make_schema(json!({ + "title": "Test", + "type": "object", + "$defs": { + "PerAnimationPrefixConfig": { + "anyOf": [ + { + "type": "object", + "additionalProperties": { "type": "boolean" } + }, + { "type": "boolean" } + ] + }, + "PerAnimationPrefixConfig2": { + "anyOf": [ + { + "type": "object", + "additionalProperties": { "type": "integer" } + }, + { "type": "integer" } + ] + }, + "PerAnimationPrefixConfig3": { + "anyOf": [ + { + "type": "object", + "additionalProperties": { "$ref": "#/$defs/AnimationStyle" } + }, + { "$ref": "#/$defs/AnimationStyle" } + ] + }, + "AnimationStyle": { + "type": "string", + "enum": ["Linear", "EaseIn"] + } + }, + "properties": { + "config1": { "$ref": "#/$defs/PerAnimationPrefixConfig" }, + "config2": { "$ref": "#/$defs/PerAnimationPrefixConfig2" }, + "config3": { "$ref": "#/$defs/PerAnimationPrefixConfig3" } + } + })); + + let options = Options::default(); + let html = generator.generate(&schema, &options).unwrap(); + + // Definition headers should show base name without numeric suffix + // PerAnimationPrefixConfig2 header should show "PerAnimationPrefixConfig" + + // Count definition-name headers containing "PerAnimationPrefixConfig" (base name) + // The HTML has whitespace/newlines in formatting + let header_pattern = "

    "; + let per_anim_header_count = html + .match_indices(header_pattern) + .filter(|(i, _)| { + let after = &html[*i + header_pattern.len()..]; + after.trim_start().starts_with("PerAnimationPrefixConfig") + }) + .count(); + + assert_eq!( + per_anim_header_count, 3, + "All 3 wrapper types should show 'PerAnimationPrefixConfig' in header (found {})", + per_anim_header_count + ); + + // Verify that PerAnimationPrefixConfig2 and PerAnimationPrefixConfig3 do NOT appear in headers + // (They should only appear in anchor IDs and href links) + assert!( + !html.contains("class=\"definition-name\">PerAnimationPrefixConfig2") && + !html.contains("class=\"definition-name\">\n PerAnimationPrefixConfig2"), + "Should NOT show numeric suffix 2 in definition header" + ); + assert!( + !html.contains("class=\"definition-name\">PerAnimationPrefixConfig3") && + !html.contains("class=\"definition-name\">\n PerAnimationPrefixConfig3"), + "Should NOT show numeric suffix 3 in definition header" + ); + + // Sidebar should also show base names (navigation uses display_name) + let sidebar_base_name_count = html.matches(">PerAnimationPrefixConfig<").count(); + assert!( + sidebar_base_name_count >= 3, + "Sidebar should show base name for wrapper types (found {})", + sidebar_base_name_count + ); + + // Anchors should still use full names for correct linking + assert!( + html.contains("id=\"peranimationprefixconfig\""), + "Anchor should use full name" + ); + assert!( + html.contains("id=\"peranimationprefixconfig2\""), + "Anchor should use full name with suffix" + ); + assert!( + html.contains("id=\"peranimationprefixconfig3\""), + "Anchor should use full name with suffix" + ); + } + + // Test: No broken anchor links in rendered output + #[test] + fn test_no_broken_anchor_links() { + use regex::Regex; + use std::collections::HashSet; + + let generator = Generator::new().unwrap(); + let schema_json = include_str!("../schema.json"); + let schema: Schema = serde_json::from_str(schema_json).unwrap(); + let options = Options::default(); + let html = generator.generate(&schema, &options).unwrap(); + + // Extract all href="#..." targets (excluding JavaScript template literals like ${...}) + let href_re = Regex::new(r##"href="#([^"]+)""##).unwrap(); + let hrefs: HashSet = href_re + .captures_iter(&html) + .map(|c| c[1].to_string()) + .filter(|href| !href.starts_with("${")) // Exclude JS template literals + .collect(); + + // Extract all id="..." values + let id_re = Regex::new(r##"id="([^"]+)""##).unwrap(); + let ids: HashSet = id_re + .captures_iter(&html) + .map(|c| c[1].to_string()) + .collect(); + + // Find broken links + let broken: Vec<&String> = hrefs.iter().filter(|href| !ids.contains(*href)).collect(); + + if !broken.is_empty() { + eprintln!("Broken anchor links found: {:?}", broken); + } + + assert!( + broken.is_empty(), + "Found {} broken anchor links: {:?}", + broken.len(), + broken.iter().take(20).collect::>() + ); + } } diff --git a/src/prepare.rs b/src/prepare.rs index 976fc36c..50dfcd6b 100644 --- a/src/prepare.rs +++ b/src/prepare.rs @@ -22,9 +22,31 @@ use std::collections::HashSet; pub fn prepare_for_template(value: &Value) -> Value { let mut prepared = value.clone(); process_value(&mut prepared); + detect_wrapper_types(&mut prepared); + add_wrapper_display_names(&mut prepared); prepared } +/// Add display names to wrapper types by stripping numeric suffixes +/// +/// For wrapper types like `PerAnimationPrefixConfig2`, this adds a +/// `_display_name` field with the base name `PerAnimationPrefixConfig`. +fn add_wrapper_display_names(schema: &mut Value) { + if let Some(defs) = schema.get_mut("$defs").and_then(|v| v.as_object_mut()) { + for (name, def) in defs.iter_mut() { + if def.get("_wrapper_type") == Some(&Value::Bool(true)) { + let display_name = name.trim_end_matches(|c: char| c.is_ascii_digit()); + if let Some(obj) = def.as_object_mut() { + obj.insert( + "_display_name".to_string(), + Value::String(display_name.to_string()), + ); + } + } + } + } +} + /// Recursively process a value, rendering markdown descriptions fn process_value(value: &mut Value) { match value { @@ -49,6 +71,130 @@ fn process_value(value: &mut Value) { } } +/// Detect and annotate wrapper type patterns in the schema +/// +/// A wrapper type is an anyOf with exactly 2 variants where: +/// - One variant is an object with additionalProperties of type T +/// - The other variant is directly type T +/// +/// This pattern is common for types like `PerAnimationPrefixConfig` which +/// can be either a map of animation prefixes to T, or directly a T value. +fn detect_wrapper_types(value: &mut Value) { + match value { + Value::Object(map) => { + // Check for wrapper type pattern in anyOf + if let Some(any_of) = map.get("anyOf").and_then(|v| v.as_array()) + && let Some(inner) = detect_wrapper_pattern(any_of) { + map.insert("_wrapper_type".to_string(), Value::Bool(true)); + map.insert("_wrapper_inner".to_string(), inner); + } + + // Recurse into all nested values + for v in map.values_mut() { + detect_wrapper_types(v); + } + } + Value::Array(arr) => { + for v in arr.iter_mut() { + detect_wrapper_types(v); + } + } + _ => {} + } +} + +/// Check if anyOf array matches the wrapper type pattern +/// +/// Returns the inner type if the pattern matches, None otherwise. +fn detect_wrapper_pattern(any_of: &[Value]) -> Option { + // Must have exactly 2 variants + if any_of.len() != 2 { + return None; + } + + let first = &any_of[0]; + let second = &any_of[1]; + + // Skip if either variant is null (that's Option, not wrapper) + if is_null_type(first) || is_null_type(second) { + return None; + } + + // Try first as map variant, second as inner type + if let Some(map_inner) = get_map_inner_type(first) + && types_are_equal(map_inner, second) { + return Some(second.clone()); + } + + // Try second as map variant, first as inner type + if let Some(map_inner) = get_map_inner_type(second) + && types_are_equal(map_inner, first) { + return Some(first.clone()); + } + + None +} + +/// Check if a schema represents the null type +fn is_null_type(schema: &Value) -> bool { + schema + .get("type") + .and_then(|t| t.as_str()) + .map(|t| t == "null") + .unwrap_or(false) +} + +/// Extract the inner type from a map schema (object with additionalProperties) +/// +/// Returns the additionalProperties schema if this is an object type with +/// additionalProperties defined, None otherwise. +fn get_map_inner_type(schema: &Value) -> Option<&Value> { + let obj = schema.as_object()?; + + // Must be type: "object" + let type_val = obj.get("type")?; + if type_val.as_str()? != "object" { + return None; + } + + // Must have additionalProperties (and it must be an object, not boolean) + let additional = obj.get("additionalProperties")?; + if additional.is_object() { + Some(additional) + } else { + None + } +} + +/// Check if two type schemas are equivalent +/// +/// Handles: +/// - $ref comparison (by ref string) +/// - Primitive type comparison (by type string) +/// - Deep equality for complex types +fn types_are_equal(a: &Value, b: &Value) -> bool { + // Handle $ref comparison + if let (Some(ref_a), Some(ref_b)) = ( + a.get("$ref").and_then(|v| v.as_str()), + b.get("$ref").and_then(|v| v.as_str()), + ) { + return ref_a == ref_b; + } + + // Handle primitive type comparison + if let (Some(type_a), Some(type_b)) = ( + a.get("type").and_then(|v| v.as_str()), + b.get("type").and_then(|v| v.as_str()), + ) { + // For primitive types, just compare the type string + // (ignoring format, constraints, etc. for matching purposes) + return type_a == type_b; + } + + // Deep equality for complex types + a == b +} + /// Render markdown text to HTML pub fn render_markdown(text: &str) -> String { let parser = Parser::new(text); @@ -76,6 +222,7 @@ pub struct DefinitionInfo { pub name: String, pub anchor: String, pub description: Option, + pub display_name: Option, } /// An outline entry for hierarchical navigation @@ -130,6 +277,8 @@ pub struct NavigationData { pub search_index: Vec, /// Map from type name to its usages pub usages: HashMap, + /// Map from type name to display name (for wrapper types with numeric suffixes) + pub display_names: HashMap, } /// Maximum depth for outline tree to prevent infinite recursion @@ -241,30 +390,32 @@ fn resolve_schema_for_outline<'a>( // Handle array - unwrap to items schema if is_array_type(schema) - && let Some(items) = schema.get("items") { - return resolve_schema_for_outline(items, defs, current_path); - } + && let Some(items) = schema.get("items") + { + return resolve_schema_for_outline(items, defs, current_path); + } // Handle anyOf - only unwrap if it's Option (exactly 2 variants, one null) // For true unions (multiple non-null variants), don't unwrap - treat as leaf if let Some(any_of) = schema.get("anyOf").and_then(|v| v.as_array()) - && any_of.len() == 2 { - let null_count = any_of - .iter() - .filter(|v| v.get("type").and_then(|t| t.as_str()) == Some("null")) - .count(); + && any_of.len() == 2 + { + let null_count = any_of + .iter() + .filter(|v| v.get("type").and_then(|t| t.as_str()) == Some("null")) + .count(); - // Only unwrap if exactly one null variant (Option pattern) - if null_count == 1 { - for variant in any_of { - let is_null = variant.get("type").and_then(|t| t.as_str()) == Some("null"); - if !is_null { - return resolve_schema_for_outline(variant, defs, current_path); - } + // Only unwrap if exactly one null variant (Option pattern) + if null_count == 1 { + for variant in any_of { + let is_null = variant.get("type").and_then(|t| t.as_str()) == Some("null"); + if !is_null { + return resolve_schema_for_outline(variant, defs, current_path); } } } - // For true unions (0 or 2+ null variants, or 3+ total variants), don't unwrap + } + // For true unions (0 or 2+ null variants, or 3+ total variants), don't unwrap // Inline object or leaf type - keep current path (schema, current_path.to_string()) @@ -278,9 +429,10 @@ fn is_array_type(schema: &Value) -> bool { } // Check type: ["array", "null"] (Option>) if let Some(type_arr) = schema.get("type").and_then(|v| v.as_array()) - && type_arr.iter().any(|t| t.as_str() == Some("array")) { - return true; - } + && type_arr.iter().any(|t| t.as_str() == Some("array")) + { + return true; + } false } @@ -324,8 +476,10 @@ fn collect_config_usages( // At config root - anchor points to config section property // e.g., .monitors[] -> "monitors" (template IDs don't have []) // Remove all bracket notation for config paths + // Also remove [*] notation from patternProperties/additionalProperties let clean_path = path .trim_start_matches('.') + .replace("[*]", "") .replace("[]", "") .replace("[", "-") .replace("]", ""); @@ -345,19 +499,20 @@ fn collect_config_usages( // Follow the $ref into the type definition (if not already visited) // When following, we enter the new type with an empty relative path if !visited.contains(&type_name) - && let Some(def) = defs.and_then(|d| d.get(&type_name)) { - visited.insert(type_name.clone()); - collect_config_usages( - def, - path, - usages, - defs, - visited, - Some(&type_name), - String::new(), // Reset relative path when entering new type - ); - visited.remove(&type_name); // Allow revisiting from different paths - } + && let Some(def) = defs.and_then(|d| d.get(&type_name)) + { + visited.insert(type_name.clone()); + collect_config_usages( + def, + path, + usages, + defs, + visited, + Some(&type_name), + String::new(), // Reset relative path when entering new type + ); + visited.remove(&type_name); // Allow revisiting from different paths + } return; // $ref schema has no other content to process } @@ -467,18 +622,19 @@ fn collect_config_usages( // Recurse into additionalProperties if let Some(additional) = schema.get("additionalProperties") - && additional.is_object() { - let new_path = format!("{}[*]", path); - collect_config_usages( - additional, - new_path, - usages, - defs, - visited, - current_type, - type_relative_path.clone(), - ); - } + && additional.is_object() + { + let new_path = format!("{}[*]", path); + collect_config_usages( + additional, + new_path, + usages, + defs, + visited, + current_type, + type_relative_path.clone(), + ); + } } /// Collect references to types from within $defs @@ -561,10 +717,16 @@ pub fn build_navigation(schema: &Value) -> NavigationData { } }); + let display_name = def + .get("_display_name") + .and_then(|v| v.as_str()) + .map(String::from); + definitions.push(DefinitionInfo { name: name.clone(), anchor: anchor.clone(), description, + display_name, }); search_index.push(SearchEntry { @@ -576,9 +738,30 @@ pub fn build_navigation(schema: &Value) -> NavigationData { } } + // Build display_names map (type_name -> display_name for wrapper types) + let mut display_names: HashMap = HashMap::new(); + for def in &definitions { + if let Some(display) = &def.display_name { + display_names.insert(def.name.clone(), display.clone()); + } + } + // Sort definitions alphabetically definitions.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + // Deduplicate definitions by display_name (for wrapper types with same base name) + // Keep only the first occurrence of each display_name + let mut seen_display_names: HashSet = HashSet::new(); + definitions.retain(|def| { + let display = def.display_name.as_ref().unwrap_or(&def.name); + if seen_display_names.contains(display) { + false // Skip duplicate + } else { + seen_display_names.insert(display.clone()); + true // Keep first occurrence + } + }); + // Collect configuration usages (from main schema, following $refs transitively) let mut config_usages: HashMap> = HashMap::new(); let mut visited: HashSet = HashSet::new(); @@ -623,6 +806,7 @@ pub fn build_navigation(schema: &Value) -> NavigationData { definitions, search_index, usages, + display_names, } } @@ -1664,4 +1848,252 @@ mod tests { // Anchor should NOT have brackets assert_eq!(item_usages.config_usages[0].anchor, "items"); } + + // ==================== Wrapper Type Detection Tests ==================== + + #[test] + fn test_wrapper_type_detection_with_ref() { + // Wrapper type pattern: anyOf with object+additionalProperties and direct $ref + let schema = json!({ + "anyOf": [ + { + "type": "object", + "additionalProperties": { "$ref": "#/$defs/InnerType" } + }, + { "$ref": "#/$defs/InnerType" } + ] + }); + + let prepared = prepare_for_template(&schema); + + assert_eq!( + prepared.get("_wrapper_type"), + Some(&Value::Bool(true)), + "Should detect wrapper type pattern" + ); + assert!( + prepared.get("_wrapper_inner").is_some(), + "Should have _wrapper_inner" + ); + assert_eq!( + prepared.get("_wrapper_inner").unwrap().get("$ref"), + Some(&Value::String("#/$defs/InnerType".to_string())), + "Inner type should be the $ref" + ); + } + + #[test] + fn test_wrapper_type_detection_with_primitive() { + // Wrapper type with primitive inner type + let schema = json!({ + "anyOf": [ + { + "type": "object", + "additionalProperties": { "type": "boolean" } + }, + { "type": "boolean" } + ] + }); + + let prepared = prepare_for_template(&schema); + + assert_eq!( + prepared.get("_wrapper_type"), + Some(&Value::Bool(true)), + "Should detect wrapper type with primitive" + ); + assert_eq!( + prepared + .get("_wrapper_inner") + .unwrap() + .get("type") + .unwrap() + .as_str(), + Some("boolean"), + "Inner type should be boolean" + ); + } + + #[test] + fn test_wrapper_type_detection_reversed_order() { + // Wrapper type with map variant second + let schema = json!({ + "anyOf": [ + { "type": "integer" }, + { + "type": "object", + "additionalProperties": { "type": "integer" } + } + ] + }); + + let prepared = prepare_for_template(&schema); + + assert_eq!( + prepared.get("_wrapper_type"), + Some(&Value::Bool(true)), + "Should detect wrapper type regardless of variant order" + ); + } + + #[test] + fn test_non_wrapper_mismatched_types() { + // Not a wrapper: inner types don't match + let schema = json!({ + "anyOf": [ + { + "type": "object", + "additionalProperties": { "type": "string" } + }, + { "type": "integer" } + ] + }); + + let prepared = prepare_for_template(&schema); + + assert!( + prepared.get("_wrapper_type").is_none(), + "Should NOT detect wrapper when inner types don't match" + ); + } + + #[test] + fn test_option_not_detected_as_wrapper() { + // Option pattern (anyOf with null) should NOT be detected as wrapper + let schema = json!({ + "anyOf": [ + { + "type": "object", + "additionalProperties": { "type": "string" } + }, + { "type": "null" } + ] + }); + + let prepared = prepare_for_template(&schema); + + assert!( + prepared.get("_wrapper_type").is_none(), + "Option> should NOT be detected as wrapper type" + ); + } + + #[test] + fn test_regular_anyof_not_wrapper() { + // Regular anyOf (no additionalProperties pattern) + let schema = json!({ + "anyOf": [ + { "type": "string" }, + { "type": "integer" } + ] + }); + + let prepared = prepare_for_template(&schema); + + assert!( + prepared.get("_wrapper_type").is_none(), + "Regular anyOf should NOT be detected as wrapper" + ); + } + + #[test] + fn test_wrapper_detection_nested() { + // Wrapper type nested inside a larger schema + let schema = json!({ + "$defs": { + "WrapperType": { + "anyOf": [ + { + "type": "object", + "additionalProperties": { "$ref": "#/$defs/Inner" } + }, + { "$ref": "#/$defs/Inner" } + ] + }, + "Inner": { "type": "string" } + } + }); + + let prepared = prepare_for_template(&schema); + + let wrapper_def = prepared.get("$defs").unwrap().get("WrapperType").unwrap(); + assert_eq!( + wrapper_def.get("_wrapper_type"), + Some(&Value::Bool(true)), + "Should detect nested wrapper type" + ); + } + + #[test] + fn test_wrapper_with_integer_constraints() { + // Wrapper type where inner type has constraints (should still match by type) + let schema = json!({ + "anyOf": [ + { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + { + "type": "integer", + "format": "uint64", + "minimum": 0 + } + ] + }); + + let prepared = prepare_for_template(&schema); + + assert_eq!( + prepared.get("_wrapper_type"), + Some(&Value::Bool(true)), + "Should detect wrapper even with constraints on inner type" + ); + } + + #[test] + fn test_three_variant_anyof_not_wrapper() { + // anyOf with 3+ variants should NOT be detected as wrapper + let schema = json!({ + "anyOf": [ + { + "type": "object", + "additionalProperties": { "type": "string" } + }, + { "type": "string" }, + { "type": "null" } + ] + }); + + let prepared = prepare_for_template(&schema); + + assert!( + prepared.get("_wrapper_type").is_none(), + "anyOf with 3+ variants should NOT be detected as wrapper" + ); + } + + #[test] + fn test_object_without_additional_properties_not_wrapper() { + // Object without additionalProperties should NOT be detected as wrapper + let schema = json!({ + "anyOf": [ + { + "type": "object", + "properties": { "name": { "type": "string" } } + }, + { "type": "string" } + ] + }); + + let prepared = prepare_for_template(&schema); + + assert!( + prepared.get("_wrapper_type").is_none(), + "Object with properties (not additionalProperties) should NOT be wrapper" + ); + } } diff --git a/src/templates/base.html b/src/templates/base.html index 8bfe364a..9aaf5863 100644 --- a/src/templates/base.html +++ b/src/templates/base.html @@ -58,7 +58,8 @@ href="#{{ def.anchor }}" class="nav-link" data-search-name="{{ def.name | lower }}" - >{{ def.name }}{{ def.display_name | default(value=def.name) + }} {%- endfor -%} @@ -88,7 +89,9 @@ class="definition" data-search-name="{{ def_name | lower }}" > -

    {{ def_name }}

    +

    + {{ def._display_name | default(value=def_name) }} +

    {%- if def.description_html %}
    {{ def.description_html | safe }} @@ -119,10 +122,12 @@

    Referenced by

      {%- for ref in type_usages.type_references - -%} + -%} {%- set ref_display = + display_names[ref.type_name] | + default(value=ref.type_name) -%}
    • {{ ref.type_name }}{{ ref_display }}
    • {%- endfor -%} diff --git a/src/templates/macros.html b/src/templates/macros.html index d9026647..4f0f5fc7 100644 --- a/src/templates/macros.html +++ b/src/templates/macros.html @@ -45,7 +45,13 @@ {% macro render_type_badge(node) %} {%- if node["$ref"] -%} {%- set ref_name = node["$ref"] | replace(from="#/$defs/", to="") -%} - {{ ref_name }} + {%- set display = display_names[ref_name] | default(value=ref_name) -%} + {%- set ref_def = schema["$defs"][ref_name] -%} + {{ display }} + {%- if ref_def._wrapper_inner -%} + {# Show the inner type for wrapper types #} + {{ self::render_type_badge(node=ref_def._wrapper_inner) }} + {%- endif -%} {%- elif node.type -%} {%- if node.type is string -%} {%- if node.type == "array" and node.prefixItems -%} @@ -58,7 +64,7 @@ {%- endif -%} {%- endfor -%} {%- if is_homogeneous and first_type.type -%} - array {{ first_type.type }}; {{ node.prefixItems | length }} + array {%- if first_type.type == "number" -%}float; {{ node.prefixItems | length }}{%- else -%}{{ first_type.type }}; {{ node.prefixItems | length }}{%- endif -%} {%- else -%} array tuple {%- endif -%} @@ -86,8 +92,12 @@ {%- endif -%} {%- endif -%} {%- elif node.anyOf -%} + {# Check for wrapper type pattern first (detected in prepare.rs) #} + {%- if node._wrapper_type -%} + {# Wrapper type: show inner type badge (type name shown separately in type definitions) #} + {{ self::render_type_badge(node=node._wrapper_inner) }} {# Check for Option pattern: anyOf with exactly 2 variants where one is null #} - {%- if node.anyOf | length == 2 -%} + {%- elif node.anyOf | length == 2 -%} {%- set first = node.anyOf | first -%} {%- set second = node.anyOf | last -%} {%- if second.type and second.type == "null" -%} @@ -254,8 +264,12 @@ {%- endif -%} {%- elif node.anyOf -%} + {# Check for wrapper type pattern first (detected in prepare.rs) #} + {%- if node._wrapper_type -%} + {# Wrapper type - render the inner type's body #} + {{ self::render_schema_body(node=node._wrapper_inner, depth=depth, path=path) }} {# Check for Option pattern: anyOf with exactly 2 variants where one is null #} - {%- if node.anyOf | length == 2 -%} + {%- elif node.anyOf | length == 2 -%} {%- set first = node.anyOf | first -%} {%- set second = node.anyOf | last -%} {%- if second.type and second.type == "null" -%} diff --git a/src/templates/style.css b/src/templates/style.css index fefcee81..4b852da5 100644 --- a/src/templates/style.css +++ b/src/templates/style.css @@ -570,6 +570,10 @@ body { background-color: #0a58ca; color: white; } +.type-badge.type-float { + background-color: #0d6efd; + color: white; +} .type-badge.type-boolean { background-color: #fd7e14; color: white; From 8d8780c42716ea9321321f7ad8e4e0defd9966c9 Mon Sep 17 00:00:00 2001 From: LGUG2Z Date: Sun, 21 Dec 2025 19:21:50 -0800 Subject: [PATCH 03/19] fix(css): improve spacing consistency Fix visual spacing issues between type badges, references, and other inline elements in the generated documentation. CSS Changes: - Add `margin-left: 0.25rem` to `.badge`, `.type-badge`, and `.type-ref` - Add `:first-child` selectors to remove left margin on first elements - Ensures consistent spacing when multiple badges appear together Template Fixes: - Remove excess whitespace in Tera template conditionals - Fix spacing around optional badges and type badges in `render_type_badge` - Consolidate inline spacing in `render_schema` macro - Clean up wrapper type inner badge rendering whitespace --- src/prepare.rs | 23 +++++++++++++---------- src/templates/macros.html | 23 +++++++---------------- src/templates/style.css | 15 +++++++++++++++ 3 files changed, 35 insertions(+), 26 deletions(-) diff --git a/src/prepare.rs b/src/prepare.rs index 50dfcd6b..6ec80d2a 100644 --- a/src/prepare.rs +++ b/src/prepare.rs @@ -84,10 +84,11 @@ fn detect_wrapper_types(value: &mut Value) { Value::Object(map) => { // Check for wrapper type pattern in anyOf if let Some(any_of) = map.get("anyOf").and_then(|v| v.as_array()) - && let Some(inner) = detect_wrapper_pattern(any_of) { - map.insert("_wrapper_type".to_string(), Value::Bool(true)); - map.insert("_wrapper_inner".to_string(), inner); - } + && let Some(inner) = detect_wrapper_pattern(any_of) + { + map.insert("_wrapper_type".to_string(), Value::Bool(true)); + map.insert("_wrapper_inner".to_string(), inner); + } // Recurse into all nested values for v in map.values_mut() { @@ -122,15 +123,17 @@ fn detect_wrapper_pattern(any_of: &[Value]) -> Option { // Try first as map variant, second as inner type if let Some(map_inner) = get_map_inner_type(first) - && types_are_equal(map_inner, second) { - return Some(second.clone()); - } + && types_are_equal(map_inner, second) + { + return Some(second.clone()); + } // Try second as map variant, first as inner type if let Some(map_inner) = get_map_inner_type(second) - && types_are_equal(map_inner, first) { - return Some(first.clone()); - } + && types_are_equal(map_inner, first) + { + return Some(first.clone()); + } None } diff --git a/src/templates/macros.html b/src/templates/macros.html index 4f0f5fc7..82a12cad 100644 --- a/src/templates/macros.html +++ b/src/templates/macros.html @@ -48,10 +48,7 @@ {%- set display = display_names[ref_name] | default(value=ref_name) -%} {%- set ref_def = schema["$defs"][ref_name] -%} {{ display }} - {%- if ref_def._wrapper_inner -%} - {# Show the inner type for wrapper types #} - {{ self::render_type_badge(node=ref_def._wrapper_inner) }} - {%- endif -%} + {%- if ref_def._wrapper_inner %} {{ self::render_type_badge(node=ref_def._wrapper_inner) }}{%- endif -%} {%- elif node.type -%} {%- if node.type is string -%} {%- if node.type == "array" and node.prefixItems -%} @@ -64,7 +61,7 @@ {%- endif -%} {%- endfor -%} {%- if is_homogeneous and first_type.type -%} - array {%- if first_type.type == "number" -%}float; {{ node.prefixItems | length }}{%- else -%}{{ first_type.type }}; {{ node.prefixItems | length }}{%- endif -%} + array {% if first_type.type == "number" %}float; {{ node.prefixItems | length }}{% else %}{{ first_type.type }}; {{ node.prefixItems | length }}{% endif %} {%- else -%} array tuple {%- endif -%} @@ -79,16 +76,11 @@ {%- if node.type | length == 2 and "null" in node.type -%} optional {%- for t in node.type -%} - {%- if t != "null" -%} - {%- if t == "array" and node.items -%} - array {{ self::render_type_badge(node=node.items) }} - {%- else -%} - {{ t }} - {%- endif -%} + {%- if t != "null" %} {% if t == "array" and node.items %}array {{ self::render_type_badge(node=node.items) }}{% else %}{{ t }}{% endif %} {%- endif -%} {%- endfor -%} {%- else -%} - {%- for t in node.type -%}{{ t }}{% endfor -%} + {%- for t in node.type %} {{ t }}{%- endfor -%} {%- endif -%} {%- endif -%} {%- elif node.anyOf -%} @@ -101,9 +93,9 @@ {%- set first = node.anyOf | first -%} {%- set second = node.anyOf | last -%} {%- if second.type and second.type == "null" -%} - optional{{ self::render_type_badge(node=first) }} + optional {{ self::render_type_badge(node=first) }} {%- elif first.type and first.type == "null" -%} - optional{{ self::render_type_badge(node=second) }} + optional {{ self::render_type_badge(node=second) }} {%- else -%} {# Check if all variants are string const (enum pattern) #} {%- set_global is_string_enum = true -%} @@ -352,8 +344,7 @@ {% macro render_schema(node, depth, path) %} {%- if depth < 10 -%}
      - {{ self::render_type_badge(node=node) }} - {%- if node.description_html %}
      {{ node.description_html | safe }}
      {% endif -%} + {{ self::render_type_badge(node=node) }}{%- if node.description_html %}
      {{ node.description_html | safe }}
      {% endif -%} {{ self::render_constraints(node=node) }} {%- if node.default %}
      Default: {{ node.default | json_encode() }}
      {% endif -%} {{ self::render_schema_body(node=node, depth=depth, path=path) }} diff --git a/src/templates/style.css b/src/templates/style.css index 4b852da5..a1ddd01d 100644 --- a/src/templates/style.css +++ b/src/templates/style.css @@ -507,6 +507,11 @@ body { text-transform: uppercase; letter-spacing: 0.025em; border-radius: 3px; + margin-left: 0.25rem; +} + +.badge:first-child { + margin-left: 0; } .badge.required { @@ -555,6 +560,11 @@ body { border-radius: 3px; background-color: var(--bg-tertiary); color: var(--text-secondary); + margin-left: 0.25rem; +} + +.type-badge:first-child { + margin-left: 0; } /* Primitive types */ @@ -617,6 +627,11 @@ body { color: white; text-decoration: none; transition: background-color 0.15s; + margin-left: 0.25rem; +} + +.type-ref:first-child { + margin-left: 0; } .type-ref:hover { From 5d606f0d878246755b0b5ad675ece78d85a5a0e1 Mon Sep 17 00:00:00 2001 From: LGUG2Z Date: Sun, 21 Dec 2025 19:47:36 -0800 Subject: [PATCH 04/19] refactor(docgen): centralize css classes and html ids Create a single source of truth for all CSS class names and HTML element IDs used throughout the generated documentation. New Module (`src/identifiers.rs`): - `HtmlIds` struct: Static HTML IDs for JS interaction (search, sidebar, etc.) - `CssClasses` struct: All CSS class names organized by category - `TypeClasses` struct: Type-specific badge classes (type-string, type-integer, etc.) - Constants `IDS`, `CSS`, `TYPE_CLASSES` for compile-time access - Serializable via serde for use in Tera templates Class Categories: - Layout: header, layout, main-content - Sidebar: sidebar, sidebar-open, sidebar-toggle, sidebar-section - Navigation: nav-list, nav-link, outline-list, outline-item - Search: search-container, search-results, search-result-item - Schema: schema-section, properties-container, property, property-header - Badges: badge, required, deprecated, optional - Types: type-badge, type-ref, and all type-specific classes - Nested: nested-schema, array-items, tuple-items, variants - Definition: definition, definition-name, highlight, usages Template Updates: - Replace hardcoded class strings with `{{ css.class_name }}` references - Replace hardcoded IDs with `{{ ids.id_name }}` references - Enables IDE navigation and refactoring support Also adds `schema.json` test fixture (komorebi configuration schema) --- schema.json | 2566 +++++++++++++++++++++++++++++++++++++ src/generator.rs | 8 + src/identifiers.rs | 284 ++++ src/lib.rs | 4 + src/templates/base.html | 69 +- src/templates/macros.html | 152 +-- src/templates/style.css | 1 + 7 files changed, 2978 insertions(+), 106 deletions(-) create mode 100644 schema.json create mode 100644 src/identifiers.rs diff --git a/schema.json b/schema.json new file mode 100644 index 00000000..2fcdffa7 --- /dev/null +++ b/schema.json @@ -0,0 +1,2566 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "StaticConfig", + "description": "The `komorebi.json` static configuration file reference for `v0.1.40`", + "type": "object", + "properties": { + "animation": { + "description": "Animations configuration options", + "anyOf": [ + { + "$ref": "#/$defs/AnimationsConfig" + }, + { + "type": "null" + } + ] + }, + "app_specific_configuration_path": { + "description": "Path to applications.json from komorebi-application-specific-configurations (default: None)", + "anyOf": [ + { + "$ref": "#/$defs/AppSpecificConfigurationPath" + }, + { + "type": "null" + } + ] + }, + "bar_configurations": { + "description": "Komorebi status bar configuration files for multiple instances on different monitors", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/$defs/PathBuf" + } + }, + "border": { + "description": "Display window borders (default: true)", + "type": [ + "boolean", + "null" + ] + }, + "border_colours": { + "description": "Window border colours for different container types (has no effect if a theme is defined)", + "anyOf": [ + { + "$ref": "#/$defs/BorderColours" + }, + { + "type": "null" + } + ] + }, + "border_implementation": { + "description": "Window border implementation (default: Komorebi)", + "anyOf": [ + { + "$ref": "#/$defs/BorderImplementation" + }, + { + "type": "null" + } + ] + }, + "border_offset": { + "description": "Offset of window borders (default: -1)", + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "border_overflow_applications": { + "description": "Identify border overflow applications", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/$defs/MatchingRule" + } + }, + "border_style": { + "description": "Window border style (default: System)", + "anyOf": [ + { + "$ref": "#/$defs/BorderStyle" + }, + { + "type": "null" + } + ] + }, + "border_width": { + "description": "Width of window borders (default: 8)", + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "border_z_order": { + "description": "DEPRECATED from v0.1.31: no longer required", + "anyOf": [ + { + "$ref": "#/$defs/ZOrder" + }, + { + "type": "null" + } + ] + }, + "cross_boundary_behaviour": { + "description": "Determine what happens when an action is called on a window at a monitor boundary (default: Monitor)", + "anyOf": [ + { + "$ref": "#/$defs/CrossBoundaryBehaviour" + }, + { + "type": "null" + } + ] + }, + "cross_monitor_move_behaviour": { + "description": "Determine what happens when a window is moved across a monitor boundary (default: Swap)", + "anyOf": [ + { + "$ref": "#/$defs/MoveBehaviour" + }, + { + "type": "null" + } + ] + }, + "default_container_padding": { + "description": "Global default container padding (default: 10)", + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "default_workspace_padding": { + "description": "Global default workspace padding (default: 10)", + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "display_index_preferences": { + "description": "Set display index preferences", + "type": [ + "object", + "null" + ], + "additionalProperties": false, + "patternProperties": { + "^\\d+$": { + "type": "string" + } + } + }, + "float_override": { + "description": "Enable or disable float override, which makes it so every new window opens in floating mode\n(default: false)", + "type": [ + "boolean", + "null" + ] + }, + "float_override_placement": { + "description": "Determines the `Placement` to be used when spawning a window with float override active\n(default: None)", + "anyOf": [ + { + "$ref": "#/$defs/Placement" + }, + { + "type": "null" + } + ] + }, + "float_rule_placement": { + "description": "Determines the `Placement` to be used when spawning a window that matches a\n'floating_applications' rule (default: None)", + "anyOf": [ + { + "$ref": "#/$defs/Placement" + }, + { + "type": "null" + } + ] + }, + "floating_applications": { + "description": "Identify applications which should be managed as floating windows", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/$defs/MatchingRule" + } + }, + "floating_layer_behaviour": { + "description": "Determines what happens on a new window when on the `FloatingLayer`\n(default: Tile)", + "anyOf": [ + { + "$ref": "#/$defs/FloatingLayerBehaviour" + }, + { + "type": "null" + } + ] + }, + "floating_layer_placement": { + "description": "Determines the `Placement` to be used when spawning a window on the floating layer with the\n`FloatingLayerBehaviour` set to `FloatingLayerBehaviour::Float` (default: Center)", + "anyOf": [ + { + "$ref": "#/$defs/Placement" + }, + { + "type": "null" + } + ] + }, + "floating_window_aspect_ratio": { + "description": "Aspect ratio to resize with when toggling floating mode for a window", + "anyOf": [ + { + "$ref": "#/$defs/AspectRatio" + }, + { + "type": "null" + } + ] + }, + "focus_follows_mouse": { + "description": "END OF LIFE FEATURE: Use https://github.com/LGUG2Z/masir instead", + "anyOf": [ + { + "$ref": "#/$defs/FocusFollowsMouseImplementation" + }, + { + "type": "null" + } + ] + }, + "global_work_area_offset": { + "description": "Global work area (space used for tiling) offset (default: None)", + "anyOf": [ + { + "$ref": "#/$defs/Rect" + }, + { + "type": "null" + } + ] + }, + "ignore_rules": { + "description": "Individual window floating rules", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/$defs/MatchingRule" + } + }, + "invisible_borders": { + "description": "DEPRECATED from v0.1.22: no longer required", + "anyOf": [ + { + "$ref": "#/$defs/Rect" + }, + { + "type": "null" + } + ] + }, + "layered_applications": { + "description": "Identify applications that have the WS_EX_LAYERED extended window style", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/$defs/MatchingRule" + } + }, + "manage_rules": { + "description": "Individual window force-manage rules", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/$defs/MatchingRule" + } + }, + "minimum_window_height": { + "description": "DISCOURAGED: Minimum height for a window to be eligible for tiling", + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "minimum_window_width": { + "description": "DISCOURAGED: Minimum width for a window to be eligible for tiling", + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "monitor_index_preferences": { + "description": "Set monitor index preferences", + "type": [ + "object", + "null" + ], + "additionalProperties": false, + "patternProperties": { + "^\\d+$": { + "$ref": "#/$defs/Rect" + } + } + }, + "monitors": { + "description": "Monitor and workspace configurations", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/$defs/MonitorConfig" + } + }, + "mouse_follows_focus": { + "description": "Enable or disable mouse follows focus (default: true)", + "type": [ + "boolean", + "null" + ] + }, + "object_name_change_applications": { + "description": "Identify applications that send EVENT_OBJECT_NAMECHANGE on launch (very rare)", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/$defs/MatchingRule" + } + }, + "object_name_change_title_ignore_list": { + "description": "Do not process EVENT_OBJECT_NAMECHANGE events as Show events for identified applications matching these title regexes", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "remove_titlebar_applications": { + "description": "HEAVILY DISCOURAGED: Identify applications for which komorebi should forcibly remove title bars", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/$defs/MatchingRule" + } + }, + "resize_delta": { + "description": "Delta to resize windows by (default 50)", + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "slow_application_compensation_time": { + "description": "How long to wait when compensating for slow applications, in milliseconds (default: 20)", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "slow_application_identifiers": { + "description": "Identify applications which are slow to send initial event notifications", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/$defs/MatchingRule" + } + }, + "stackbar": { + "description": "Stackbar configuration options", + "anyOf": [ + { + "$ref": "#/$defs/StackbarConfig" + }, + { + "type": "null" + } + ] + }, + "theme": { + "description": "Theme configuration options", + "anyOf": [ + { + "$ref": "#/$defs/KomorebiTheme" + }, + { + "type": "null" + } + ] + }, + "toggle_float_placement": { + "description": "Determines the placement of a new window when toggling to float (default: CenterAndResize)", + "anyOf": [ + { + "$ref": "#/$defs/Placement" + }, + { + "type": "null" + } + ] + }, + "transparency": { + "description": "Add transparency to unfocused windows (default: false)", + "type": [ + "boolean", + "null" + ] + }, + "transparency_alpha": { + "description": "Alpha value for unfocused window transparency [[0-255]] (default: 200)", + "type": [ + "integer", + "null" + ], + "format": "uint8", + "maximum": 255, + "minimum": 0 + }, + "transparency_ignore_rules": { + "description": "Individual window transparency ignore rules", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/$defs/MatchingRule" + } + }, + "tray_and_multi_window_applications": { + "description": "Identify tray and multi-window applications", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/$defs/MatchingRule" + } + }, + "unmanaged_window_operation_behaviour": { + "description": "Determine what happens when commands are sent while an unmanaged window is in the foreground (default: Op)", + "anyOf": [ + { + "$ref": "#/$defs/OperationBehaviour" + }, + { + "type": "null" + } + ] + }, + "window_container_behaviour": { + "description": "Determine what happens when a new window is opened (default: Create)", + "anyOf": [ + { + "$ref": "#/$defs/WindowContainerBehaviour" + }, + { + "type": "null" + } + ] + }, + "window_handling_behaviour": { + "description": "Which Windows API behaviour to use when manipulating windows (default: Sync)", + "anyOf": [ + { + "$ref": "#/$defs/WindowHandlingBehaviour" + }, + { + "type": "null" + } + ] + }, + "window_hiding_behaviour": { + "description": "Which Windows signal to use when hiding windows (default: Cloak)", + "anyOf": [ + { + "$ref": "#/$defs/HidingBehaviour" + }, + { + "type": "null" + } + ] + } + }, + "$defs": { + "AnimationStyle": { + "oneOf": [ + { + "type": "string", + "enum": [ + "Linear", + "EaseInSine", + "EaseOutSine", + "EaseInOutSine", + "EaseInQuad", + "EaseOutQuad", + "EaseInOutQuad", + "EaseInCubic", + "EaseInOutCubic", + "EaseInQuart", + "EaseOutQuart", + "EaseInOutQuart", + "EaseInQuint", + "EaseOutQuint", + "EaseInOutQuint", + "EaseInExpo", + "EaseOutExpo", + "EaseInOutExpo", + "EaseInCirc", + "EaseOutCirc", + "EaseInOutCirc", + "EaseInBack", + "EaseOutBack", + "EaseInOutBack", + "EaseInElastic", + "EaseOutElastic", + "EaseInOutElastic", + "EaseInBounce", + "EaseOutBounce", + "EaseInOutBounce" + ] + }, + { + "type": "object", + "properties": { + "CubicBezier": { + "type": "array", + "maxItems": 4, + "minItems": 4, + "prefixItems": [ + { + "type": "number", + "format": "double" + }, + { + "type": "number", + "format": "double" + }, + { + "type": "number", + "format": "double" + }, + { + "type": "number", + "format": "double" + } + ] + } + }, + "additionalProperties": false, + "required": [ + "CubicBezier" + ] + } + ] + }, + "AnimationsConfig": { + "type": "object", + "properties": { + "duration": { + "description": "Set the animation duration in ms (default: 250)", + "anyOf": [ + { + "$ref": "#/$defs/PerAnimationPrefixConfig2" + }, + { + "type": "null" + } + ] + }, + "enabled": { + "description": "Enable or disable animations (default: false)", + "$ref": "#/$defs/PerAnimationPrefixConfig" + }, + "fps": { + "description": "Set the animation FPS (default: 60)", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "style": { + "description": "Set the animation style (default: Linear)", + "anyOf": [ + { + "$ref": "#/$defs/PerAnimationPrefixConfig3" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "enabled" + ] + }, + "AppSpecificConfigurationPath": { + "anyOf": [ + { + "description": "A single applications.json file", + "$ref": "#/$defs/PathBuf" + }, + { + "description": "Multiple applications.json files", + "type": "array", + "items": { + "$ref": "#/$defs/PathBuf" + } + } + ] + }, + "ApplicationIdentifier": { + "type": "string", + "enum": [ + "Exe", + "Class", + "Title", + "Path" + ] + }, + "AspectRatio": { + "anyOf": [ + { + "description": "A predefined aspect ratio", + "$ref": "#/$defs/PredefinedAspectRatio" + }, + { + "description": "A custom W:H aspect ratio", + "type": "array", + "maxItems": 2, + "minItems": 2, + "prefixItems": [ + { + "type": "integer", + "format": "int32" + }, + { + "type": "integer", + "format": "int32" + } + ] + } + ] + }, + "Axis": { + "type": "string", + "enum": [ + "Horizontal", + "Vertical", + "HorizontalAndVertical" + ] + }, + "Base16": { + "type": "string", + "enum": [ + "3024", + "Apathy", + "Apprentice", + "Ashes", + "AtelierCaveLight", + "AtelierCave", + "AtelierDuneLight", + "AtelierDune", + "AtelierEstuaryLight", + "AtelierEstuary", + "AtelierForestLight", + "AtelierForest", + "AtelierHeathLight", + "AtelierHeath", + "AtelierLakesideLight", + "AtelierLakeside", + "AtelierPlateauLight", + "AtelierPlateau", + "AtelierSavannaLight", + "AtelierSavanna", + "AtelierSeasideLight", + "AtelierSeaside", + "AtelierSulphurpoolLight", + "AtelierSulphurpool", + "Atlas", + "AyuDark", + "AyuLight", + "AyuMirage", + "Aztec", + "Bespin", + "BlackMetalBathory", + "BlackMetalBurzum", + "BlackMetalDarkFuneral", + "BlackMetalGorgoroth", + "BlackMetalImmortal", + "BlackMetalKhold", + "BlackMetalMarduk", + "BlackMetalMayhem", + "BlackMetalNile", + "BlackMetalVenom", + "BlackMetal", + "Blueforest", + "Blueish", + "Brewer", + "Bright", + "Brogrammer", + "BrushtreesDark", + "Brushtrees", + "Caroline", + "CatppuccinFrappe", + "CatppuccinLatte", + "CatppuccinMacchiato", + "CatppuccinMocha", + "Chalk", + "Circus", + "ClassicDark", + "ClassicLight", + "Codeschool", + "Colors", + "Cupcake", + "Cupertino", + "DaOneBlack", + "DaOneGray", + "DaOneOcean", + "DaOnePaper", + "DaOneSea", + "DaOneWhite", + "DanqingLight", + "Danqing", + "Darcula", + "Darkmoss", + "Darktooth", + "Darkviolet", + "Decaf", + "DefaultDark", + "DefaultLight", + "Dirtysea", + "Dracula", + "EdgeDark", + "EdgeLight", + "Eighties", + "EmbersLight", + "Embers", + "Emil", + "EquilibriumDark", + "EquilibriumGrayDark", + "EquilibriumGrayLight", + "EquilibriumLight", + "Eris", + "Espresso", + "EvaDim", + "Eva", + "EvenokDark", + "EverforestDarkHard", + "Everforest", + "Flat", + "Framer", + "FruitSoda", + "Gigavolt", + "Github", + "GoogleDark", + "GoogleLight", + "Gotham", + "GrayscaleDark", + "GrayscaleLight", + "Greenscreen", + "Gruber", + "GruvboxDarkHard", + "GruvboxDarkMedium", + "GruvboxDarkPale", + "GruvboxDarkSoft", + "GruvboxLightHard", + "GruvboxLightMedium", + "GruvboxLightSoft", + "GruvboxMaterialDarkHard", + "GruvboxMaterialDarkMedium", + "GruvboxMaterialDarkSoft", + "GruvboxMaterialLightHard", + "GruvboxMaterialLightMedium", + "GruvboxMaterialLightSoft", + "Hardcore", + "Harmonic16Dark", + "Harmonic16Light", + "HeetchLight", + "Heetch", + "Helios", + "Hopscotch", + "HorizonDark", + "HorizonLight", + "HorizonTerminalDark", + "HorizonTerminalLight", + "HumanoidDark", + "HumanoidLight", + "IaDark", + "IaLight", + "Icy", + "Irblack", + "Isotope", + "Jabuti", + "Kanagawa", + "Katy", + "Kimber", + "Lime", + "Macintosh", + "Marrakesh", + "Materia", + "MaterialDarker", + "MaterialLighter", + "MaterialPalenight", + "MaterialVivid", + "Material", + "MeasuredDark", + "MeasuredLight", + "MellowPurple", + "MexicoLight", + "Mocha", + "Monokai", + "Moonlight", + "Mountain", + "Nebula", + "NordLight", + "Nord", + "Nova", + "Ocean", + "Oceanicnext", + "OneLight", + "OnedarkDark", + "Onedark", + "OutrunDark", + "OxocarbonDark", + "OxocarbonLight", + "Pandora", + "PapercolorDark", + "PapercolorLight", + "Paraiso", + "Pasque", + "Phd", + "Pico", + "Pinky", + "Pop", + "Porple", + "PreciousDarkEleven", + "PreciousDarkFifteen", + "PreciousLightWarm", + "PreciousLightWhite", + "PrimerDarkDimmed", + "PrimerDark", + "PrimerLight", + "Purpledream", + "Qualia", + "Railscasts", + "Rebecca", + "RosePineDawn", + "RosePineMoon", + "RosePine", + "Saga", + "Sagelight", + "Sakura", + "Sandcastle", + "SelenizedBlack", + "SelenizedDark", + "SelenizedLight", + "SelenizedWhite", + "Seti", + "ShadesOfPurple", + "ShadesmearDark", + "ShadesmearLight", + "Shapeshifter", + "SilkDark", + "SilkLight", + "Snazzy", + "SolarflareLight", + "Solarflare", + "SolarizedDark", + "SolarizedLight", + "Spaceduck", + "Spacemacs", + "Sparky", + "StandardizedDark", + "StandardizedLight", + "Stella", + "StillAlive", + "Summercamp", + "SummerfruitDark", + "SummerfruitLight", + "SynthMidnightDark", + "SynthMidnightLight", + "Tango", + "Tarot", + "Tender", + "TerracottaDark", + "Terracotta", + "TokyoCityDark", + "TokyoCityLight", + "TokyoCityTerminalDark", + "TokyoCityTerminalLight", + "TokyoNightDark", + "TokyoNightLight", + "TokyoNightMoon", + "TokyoNightStorm", + "TokyoNightTerminalDark", + "TokyoNightTerminalLight", + "TokyoNightTerminalStorm", + "TokyodarkTerminal", + "Tokyodark", + "TomorrowNightEighties", + "TomorrowNight", + "Tomorrow", + "Tube", + "Twilight", + "UnikittyDark", + "UnikittyLight", + "UnikittyReversible", + "Uwunicorn", + "Vesper", + "Vice", + "Vulcan", + "Windows10Light", + "Windows10", + "Windows95Light", + "Windows95", + "WindowsHighcontrastLight", + "WindowsHighcontrast", + "WindowsNtLight", + "WindowsNt", + "Woodland", + "XcodeDusk", + "Zenbones", + "Zenburn" + ] + }, + "Base16ColourPalette": { + "type": "object", + "properties": { + "base_00": { + "$ref": "#/$defs/Colour" + }, + "base_01": { + "$ref": "#/$defs/Colour" + }, + "base_02": { + "$ref": "#/$defs/Colour" + }, + "base_03": { + "$ref": "#/$defs/Colour" + }, + "base_04": { + "$ref": "#/$defs/Colour" + }, + "base_05": { + "$ref": "#/$defs/Colour" + }, + "base_06": { + "$ref": "#/$defs/Colour" + }, + "base_07": { + "$ref": "#/$defs/Colour" + }, + "base_08": { + "$ref": "#/$defs/Colour" + }, + "base_09": { + "$ref": "#/$defs/Colour" + }, + "base_0a": { + "$ref": "#/$defs/Colour" + }, + "base_0b": { + "$ref": "#/$defs/Colour" + }, + "base_0c": { + "$ref": "#/$defs/Colour" + }, + "base_0d": { + "$ref": "#/$defs/Colour" + }, + "base_0e": { + "$ref": "#/$defs/Colour" + }, + "base_0f": { + "$ref": "#/$defs/Colour" + } + }, + "required": [ + "base_00", + "base_01", + "base_02", + "base_03", + "base_04", + "base_05", + "base_06", + "base_07", + "base_08", + "base_09", + "base_0a", + "base_0b", + "base_0c", + "base_0d", + "base_0e", + "base_0f" + ] + }, + "Base16Value": { + "type": "string", + "enum": [ + "Base00", + "Base01", + "Base02", + "Base03", + "Base04", + "Base05", + "Base06", + "Base07", + "Base08", + "Base09", + "Base0A", + "Base0B", + "Base0C", + "Base0D", + "Base0E", + "Base0F" + ] + }, + "BorderColours": { + "type": "object", + "properties": { + "floating": { + "description": "Border colour when the container is in floating mode", + "anyOf": [ + { + "$ref": "#/$defs/Colour" + }, + { + "type": "null" + } + ] + }, + "monocle": { + "description": "Border colour when the container is in monocle mode", + "anyOf": [ + { + "$ref": "#/$defs/Colour" + }, + { + "type": "null" + } + ] + }, + "single": { + "description": "Border colour when the container contains a single window", + "anyOf": [ + { + "$ref": "#/$defs/Colour" + }, + { + "type": "null" + } + ] + }, + "stack": { + "description": "Border colour when the container contains multiple windows", + "anyOf": [ + { + "$ref": "#/$defs/Colour" + }, + { + "type": "null" + } + ] + }, + "unfocused": { + "description": "Border colour when the container is unfocused", + "anyOf": [ + { + "$ref": "#/$defs/Colour" + }, + { + "type": "null" + } + ] + }, + "unfocused_locked": { + "description": "Border colour when the container is unfocused and locked", + "anyOf": [ + { + "$ref": "#/$defs/Colour" + }, + { + "type": "null" + } + ] + } + } + }, + "BorderImplementation": { + "oneOf": [ + { + "description": "Use the adjustable komorebi border implementation", + "type": "string", + "const": "Komorebi" + }, + { + "description": "Use the thin Windows accent border implementation", + "type": "string", + "const": "Windows" + } + ] + }, + "BorderStyle": { + "oneOf": [ + { + "description": "Use the system border style", + "type": "string", + "const": "System" + }, + { + "description": "Use the Windows 11-style rounded borders", + "type": "string", + "const": "Rounded" + }, + { + "description": "Use the Windows 10-style square borders", + "type": "string", + "const": "Square" + } + ] + }, + "Catppuccin": { + "type": "string", + "enum": [ + "Frappe", + "Latte", + "Macchiato", + "Mocha" + ] + }, + "CatppuccinValue": { + "type": "string", + "enum": [ + "Rosewater", + "Flamingo", + "Pink", + "Mauve", + "Red", + "Maroon", + "Peach", + "Yellow", + "Green", + "Teal", + "Sky", + "Sapphire", + "Blue", + "Lavender", + "Text", + "Subtext1", + "Subtext0", + "Overlay2", + "Overlay1", + "Overlay0", + "Surface2", + "Surface1", + "Surface0", + "Base", + "Mantle", + "Crust" + ] + }, + "Colour": { + "anyOf": [ + { + "description": "Colour represented as RGB", + "$ref": "#/$defs/Rgb" + }, + { + "description": "Colour represented as Hex", + "$ref": "#/$defs/Hex" + } + ] + }, + "CrossBoundaryBehaviour": { + "oneOf": [ + { + "description": "Attempt to perform actions across a workspace boundary", + "type": "string", + "const": "Workspace" + }, + { + "description": "Attempt to perform actions across a monitor boundary", + "type": "string", + "const": "Monitor" + } + ] + }, + "DefaultLayout": { + "type": "string", + "enum": [ + "BSP", + "Columns", + "Rows", + "VerticalStack", + "HorizontalStack", + "UltrawideVerticalStack", + "Grid", + "RightMainVerticalStack", + "Scrolling" + ] + }, + "FloatingLayerBehaviour": { + "oneOf": [ + { + "description": "Tile new windows (unless they match a float rule or float override is active)", + "type": "string", + "const": "Tile" + }, + { + "description": "Float new windows", + "type": "string", + "const": "Float" + } + ] + }, + "FocusFollowsMouseImplementation": { + "oneOf": [ + { + "description": "A custom FFM implementation (slightly more CPU-intensive)", + "type": "string", + "const": "Komorebi" + }, + { + "description": "The native (legacy) Windows FFM implementation", + "type": "string", + "const": "Windows" + } + ] + }, + "GridLayoutOptions": { + "type": "object", + "properties": { + "rows": { + "description": "Maximum number of rows per grid column", + "type": "integer", + "format": "uint", + "minimum": 0 + } + }, + "required": [ + "rows" + ] + }, + "Hex": { + "type": "string", + "format": "color-hex" + }, + "HidingBehaviour": { + "oneOf": [ + { + "description": "END OF LIFE FEATURE: Use the SW_HIDE flag to hide windows when switching workspaces (has issues with Electron apps)", + "type": "string", + "const": "Hide" + }, + { + "description": "Use the SW_MINIMIZE flag to hide windows when switching workspaces (has issues with frequent workspace switching)", + "type": "string", + "const": "Minimize" + }, + { + "description": "Use the undocumented SetCloak Win32 function to hide windows when switching workspaces", + "type": "string", + "const": "Cloak" + } + ] + }, + "IdWithIdentifier": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "kind": { + "$ref": "#/$defs/ApplicationIdentifier" + }, + "matching_strategy": { + "anyOf": [ + { + "$ref": "#/$defs/MatchingStrategy" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "kind", + "id" + ] + }, + "KomorebiTheme": { + "oneOf": [ + { + "description": "A theme from catppuccin-egui", + "type": "object", + "properties": { + "bar_accent": { + "description": "Komorebi status bar accent (default: Blue)", + "anyOf": [ + { + "$ref": "#/$defs/CatppuccinValue" + }, + { + "type": "null" + } + ] + }, + "floating_border": { + "description": "Border colour when the window is floating (default: Yellow)", + "anyOf": [ + { + "$ref": "#/$defs/CatppuccinValue" + }, + { + "type": "null" + } + ] + }, + "monocle_border": { + "description": "Border colour when the container is in monocle mode (default: Pink)", + "anyOf": [ + { + "$ref": "#/$defs/CatppuccinValue" + }, + { + "type": "null" + } + ] + }, + "name": { + "description": "Name of the Catppuccin theme (theme previews: https://github.com/catppuccin/catppuccin)", + "$ref": "#/$defs/Catppuccin" + }, + "palette": { + "type": "string", + "const": "Catppuccin" + }, + "single_border": { + "description": "Border colour when the container contains a single window (default: Blue)", + "anyOf": [ + { + "$ref": "#/$defs/CatppuccinValue" + }, + { + "type": "null" + } + ] + }, + "stack_border": { + "description": "Border colour when the container contains multiple windows (default: Green)", + "anyOf": [ + { + "$ref": "#/$defs/CatppuccinValue" + }, + { + "type": "null" + } + ] + }, + "stackbar_background": { + "description": "Stackbar tab background colour (default: Base)", + "anyOf": [ + { + "$ref": "#/$defs/CatppuccinValue" + }, + { + "type": "null" + } + ] + }, + "stackbar_focused_text": { + "description": "Stackbar focused tab text colour (default: Green)", + "anyOf": [ + { + "$ref": "#/$defs/CatppuccinValue" + }, + { + "type": "null" + } + ] + }, + "stackbar_unfocused_text": { + "description": "Stackbar unfocused tab text colour (default: Text)", + "anyOf": [ + { + "$ref": "#/$defs/CatppuccinValue" + }, + { + "type": "null" + } + ] + }, + "unfocused_border": { + "description": "Border colour when the container is unfocused (default: Base)", + "anyOf": [ + { + "$ref": "#/$defs/CatppuccinValue" + }, + { + "type": "null" + } + ] + }, + "unfocused_locked_border": { + "description": "Border colour when the container is unfocused and locked (default: Red)", + "anyOf": [ + { + "$ref": "#/$defs/CatppuccinValue" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "palette", + "name" + ] + }, + { + "description": "A theme from base16-egui-themes", + "type": "object", + "properties": { + "bar_accent": { + "description": "Komorebi status bar accent (default: Base0D)", + "anyOf": [ + { + "$ref": "#/$defs/Base16Value" + }, + { + "type": "null" + } + ] + }, + "floating_border": { + "description": "Border colour when the window is floating (default: Base09)", + "anyOf": [ + { + "$ref": "#/$defs/Base16Value" + }, + { + "type": "null" + } + ] + }, + "monocle_border": { + "description": "Border colour when the container is in monocle mode (default: Base0F)", + "anyOf": [ + { + "$ref": "#/$defs/Base16Value" + }, + { + "type": "null" + } + ] + }, + "name": { + "description": "Name of the Base16 theme (theme previews: https://tinted-theming.github.io/tinted-gallery/)", + "$ref": "#/$defs/Base16" + }, + "palette": { + "type": "string", + "const": "Base16" + }, + "single_border": { + "description": "Border colour when the container contains a single window (default: Base0D)", + "anyOf": [ + { + "$ref": "#/$defs/Base16Value" + }, + { + "type": "null" + } + ] + }, + "stack_border": { + "description": "Border colour when the container contains multiple windows (default: Base0B)", + "anyOf": [ + { + "$ref": "#/$defs/Base16Value" + }, + { + "type": "null" + } + ] + }, + "stackbar_background": { + "description": "Stackbar tab background colour (default: Base01)", + "anyOf": [ + { + "$ref": "#/$defs/Base16Value" + }, + { + "type": "null" + } + ] + }, + "stackbar_focused_text": { + "description": "Stackbar focused tab text colour (default: Base0B)", + "anyOf": [ + { + "$ref": "#/$defs/Base16Value" + }, + { + "type": "null" + } + ] + }, + "stackbar_unfocused_text": { + "description": "Stackbar unfocused tab text colour (default: Base05)", + "anyOf": [ + { + "$ref": "#/$defs/Base16Value" + }, + { + "type": "null" + } + ] + }, + "unfocused_border": { + "description": "Border colour when the container is unfocused (default: Base01)", + "anyOf": [ + { + "$ref": "#/$defs/Base16Value" + }, + { + "type": "null" + } + ] + }, + "unfocused_locked_border": { + "description": "Border colour when the container is unfocused and locked (default: Base08)", + "anyOf": [ + { + "$ref": "#/$defs/Base16Value" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "palette", + "name" + ] + }, + { + "description": "A custom Base16 theme", + "type": "object", + "properties": { + "bar_accent": { + "description": "Komorebi status bar accent (default: Base0D)", + "anyOf": [ + { + "$ref": "#/$defs/Base16Value" + }, + { + "type": "null" + } + ] + }, + "colours": { + "description": "Colours of the custom Base16 theme palette", + "$ref": "#/$defs/Base16ColourPalette" + }, + "floating_border": { + "description": "Border colour when the window is floating (default: Base09)", + "anyOf": [ + { + "$ref": "#/$defs/Base16Value" + }, + { + "type": "null" + } + ] + }, + "monocle_border": { + "description": "Border colour when the container is in monocle mode (default: Base0F)", + "anyOf": [ + { + "$ref": "#/$defs/Base16Value" + }, + { + "type": "null" + } + ] + }, + "palette": { + "type": "string", + "const": "Custom" + }, + "single_border": { + "description": "Border colour when the container contains a single window (default: Base0D)", + "anyOf": [ + { + "$ref": "#/$defs/Base16Value" + }, + { + "type": "null" + } + ] + }, + "stack_border": { + "description": "Border colour when the container contains multiple windows (default: Base0B)", + "anyOf": [ + { + "$ref": "#/$defs/Base16Value" + }, + { + "type": "null" + } + ] + }, + "stackbar_background": { + "description": "Stackbar tab background colour (default: Base01)", + "anyOf": [ + { + "$ref": "#/$defs/Base16Value" + }, + { + "type": "null" + } + ] + }, + "stackbar_focused_text": { + "description": "Stackbar focused tab text colour (default: Base0B)", + "anyOf": [ + { + "$ref": "#/$defs/Base16Value" + }, + { + "type": "null" + } + ] + }, + "stackbar_unfocused_text": { + "description": "Stackbar unfocused tab text colour (default: Base05)", + "anyOf": [ + { + "$ref": "#/$defs/Base16Value" + }, + { + "type": "null" + } + ] + }, + "unfocused_border": { + "description": "Border colour when the container is unfocused (default: Base01)", + "anyOf": [ + { + "$ref": "#/$defs/Base16Value" + }, + { + "type": "null" + } + ] + }, + "unfocused_locked_border": { + "description": "Border colour when the container is unfocused and locked (default: Base08)", + "anyOf": [ + { + "$ref": "#/$defs/Base16Value" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "palette", + "colours" + ] + } + ] + }, + "LayoutOptions": { + "type": "object", + "properties": { + "grid": { + "description": "Options related to the Grid layout", + "anyOf": [ + { + "$ref": "#/$defs/GridLayoutOptions" + }, + { + "type": "null" + } + ] + }, + "scrolling": { + "description": "Options related to the Scrolling layout", + "anyOf": [ + { + "$ref": "#/$defs/ScrollingLayoutOptions" + }, + { + "type": "null" + } + ] + } + } + }, + "MatchingRule": { + "anyOf": [ + { + "$ref": "#/$defs/IdWithIdentifier" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/IdWithIdentifier" + } + } + ] + }, + "MatchingStrategy": { + "type": "string", + "enum": [ + "Legacy", + "Equals", + "StartsWith", + "EndsWith", + "Contains", + "Regex", + "DoesNotEndWith", + "DoesNotStartWith", + "DoesNotEqual", + "DoesNotContain" + ] + }, + "MonitorConfig": { + "type": "object", + "properties": { + "container_padding": { + "description": "Container padding (default: global)", + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "floating_layer_behaviour": { + "description": "Determine what happens to a new window when the Floating workspace layer is active (default: Tile)", + "anyOf": [ + { + "$ref": "#/$defs/FloatingLayerBehaviour" + }, + { + "type": "null" + } + ] + }, + "wallpaper": { + "description": "Specify a wallpaper for this monitor", + "anyOf": [ + { + "$ref": "#/$defs/Wallpaper" + }, + { + "type": "null" + } + ] + }, + "window_based_work_area_offset": { + "description": "Window based work area offset (default: None)", + "anyOf": [ + { + "$ref": "#/$defs/Rect" + }, + { + "type": "null" + } + ] + }, + "window_based_work_area_offset_limit": { + "description": "Open window limit after which the window based work area offset will no longer be applied (default: 1)", + "type": [ + "integer", + "null" + ], + "format": "int" + }, + "work_area_offset": { + "description": "Monitor-specific work area offset (default: None)", + "anyOf": [ + { + "$ref": "#/$defs/Rect" + }, + { + "type": "null" + } + ] + }, + "workspace_padding": { + "description": "Workspace padding (default: global)", + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "workspaces": { + "description": "Workspace configurations", + "type": "array", + "items": { + "$ref": "#/$defs/WorkspaceConfig" + } + } + }, + "required": [ + "workspaces" + ] + }, + "MoveBehaviour": { + "oneOf": [ + { + "description": "Swap the window container with the window container at the edge of the adjacent monitor", + "type": "string", + "const": "Swap" + }, + { + "description": "Insert the window container into the focused workspace on the adjacent monitor", + "type": "string", + "const": "Insert" + }, + { + "description": "Do nothing if trying to move a window container in the direction of an adjacent monitor", + "type": "string", + "const": "NoOp" + } + ] + }, + "OperationBehaviour": { + "oneOf": [ + { + "description": "Process komorebic commands on temporarily unmanaged/floated windows", + "type": "string", + "const": "Op" + }, + { + "description": "Ignore komorebic commands on temporarily unmanaged/floated windows", + "type": "string", + "const": "NoOp" + } + ] + }, + "PathBuf": { + "type": "string" + }, + "PerAnimationPrefixConfig": { + "anyOf": [ + { + "type": "object", + "additionalProperties": { + "type": "boolean" + } + }, + { + "type": "boolean" + } + ] + }, + "PerAnimationPrefixConfig2": { + "anyOf": [ + { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + { + "type": "integer", + "format": "uint64", + "minimum": 0 + } + ] + }, + "PerAnimationPrefixConfig3": { + "anyOf": [ + { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/AnimationStyle" + } + }, + { + "$ref": "#/$defs/AnimationStyle" + } + ] + }, + "Placement": { + "oneOf": [ + { + "description": "Does not change the size or position of the window", + "type": "string", + "const": "None" + }, + { + "description": "Center the window without changing the size", + "type": "string", + "const": "Center" + }, + { + "description": "Center the window and resize it according to the `AspectRatio`", + "type": "string", + "const": "CenterAndResize" + } + ] + }, + "PredefinedAspectRatio": { + "oneOf": [ + { + "description": "21:9", + "type": "string", + "const": "Ultrawide" + }, + { + "description": "16:9", + "type": "string", + "const": "Widescreen" + }, + { + "description": "4:3", + "type": "string", + "const": "Standard" + } + ] + }, + "Rect": { + "type": "object", + "properties": { + "bottom": { + "description": "The bottom point in a Win32 Rect", + "type": "integer", + "format": "int32" + }, + "left": { + "description": "The left point in a Win32 Rect", + "type": "integer", + "format": "int32" + }, + "right": { + "description": "The right point in a Win32 Rect", + "type": "integer", + "format": "int32" + }, + "top": { + "description": "The top point in a Win32 Rect", + "type": "integer", + "format": "int32" + } + }, + "required": [ + "left", + "top", + "right", + "bottom" + ] + }, + "Rgb": { + "type": "object", + "properties": { + "b": { + "description": "Blue", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "g": { + "description": "Green", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "r": { + "description": "Red", + "type": "integer", + "format": "uint32", + "minimum": 0 + } + }, + "required": [ + "r", + "g", + "b" + ] + }, + "ScrollingLayoutOptions": { + "type": "object", + "properties": { + "center_focused_column": { + "description": "With an odd number of visible columns, keep the focused window column centered", + "type": [ + "boolean", + "null" + ] + }, + "columns": { + "description": "Desired number of visible columns (default: 3)", + "type": "integer", + "format": "uint", + "minimum": 0 + } + }, + "required": [ + "columns" + ] + }, + "StackbarConfig": { + "type": "object", + "properties": { + "height": { + "description": "Stackbar height", + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "label": { + "description": "Stackbar label", + "anyOf": [ + { + "$ref": "#/$defs/StackbarLabel" + }, + { + "type": "null" + } + ] + }, + "mode": { + "description": "Stackbar mode (default: Never)", + "anyOf": [ + { + "$ref": "#/$defs/StackbarMode" + }, + { + "type": "null" + } + ] + }, + "tabs": { + "description": "Stackbar tab configuration options", + "anyOf": [ + { + "$ref": "#/$defs/TabsConfig" + }, + { + "type": "null" + } + ] + } + } + }, + "StackbarLabel": { + "type": "string", + "enum": [ + "Process", + "Title" + ] + }, + "StackbarMode": { + "type": "string", + "enum": [ + "Always", + "Never", + "OnStack" + ] + }, + "TabsConfig": { + "type": "object", + "properties": { + "background": { + "description": "Tab background colour", + "anyOf": [ + { + "$ref": "#/$defs/Colour" + }, + { + "type": "null" + } + ] + }, + "focused_text": { + "description": "Focused tab text colour", + "anyOf": [ + { + "$ref": "#/$defs/Colour" + }, + { + "type": "null" + } + ] + }, + "font_family": { + "description": "Font family", + "type": [ + "string", + "null" + ] + }, + "font_size": { + "description": "Font size", + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "unfocused_text": { + "description": "Unfocused tab text colour", + "anyOf": [ + { + "$ref": "#/$defs/Colour" + }, + { + "type": "null" + } + ] + }, + "width": { + "description": "Width of a stackbar tab", + "type": [ + "integer", + "null" + ], + "format": "int32" + } + } + }, + "ThemeOptions": { + "type": "object", + "properties": { + "bar_accent": { + "description": "Komorebi status bar accent (default: Base0D)", + "anyOf": [ + { + "$ref": "#/$defs/Base16Value" + }, + { + "type": "null" + } + ] + }, + "floating_border": { + "description": "Border colour when the window is floating (default: Base09)", + "anyOf": [ + { + "$ref": "#/$defs/Base16Value" + }, + { + "type": "null" + } + ] + }, + "monocle_border": { + "description": "Border colour when the container is in monocle mode (default: Base0F)", + "anyOf": [ + { + "$ref": "#/$defs/Base16Value" + }, + { + "type": "null" + } + ] + }, + "single_border": { + "description": "Border colour when the container contains a single window (default: Base0D)", + "anyOf": [ + { + "$ref": "#/$defs/Base16Value" + }, + { + "type": "null" + } + ] + }, + "stack_border": { + "description": "Border colour when the container contains multiple windows (default: Base0B)", + "anyOf": [ + { + "$ref": "#/$defs/Base16Value" + }, + { + "type": "null" + } + ] + }, + "stackbar_background": { + "description": "Stackbar tab background colour (default: Base01)", + "anyOf": [ + { + "$ref": "#/$defs/Base16Value" + }, + { + "type": "null" + } + ] + }, + "stackbar_focused_text": { + "description": "Stackbar focused tab text colour (default: Base0B)", + "anyOf": [ + { + "$ref": "#/$defs/Base16Value" + }, + { + "type": "null" + } + ] + }, + "stackbar_unfocused_text": { + "description": "Stackbar unfocused tab text colour (default: Base05)", + "anyOf": [ + { + "$ref": "#/$defs/Base16Value" + }, + { + "type": "null" + } + ] + }, + "theme_variant": { + "description": "Specify Light or Dark variant for theme generation (default: Dark)", + "anyOf": [ + { + "$ref": "#/$defs/ThemeVariant" + }, + { + "type": "null" + } + ] + }, + "unfocused_border": { + "description": "Border colour when the container is unfocused (default: Base01)", + "anyOf": [ + { + "$ref": "#/$defs/Base16Value" + }, + { + "type": "null" + } + ] + }, + "unfocused_locked_border": { + "description": "Border colour when the container is unfocused and locked (default: Base08)", + "anyOf": [ + { + "$ref": "#/$defs/Base16Value" + }, + { + "type": "null" + } + ] + } + } + }, + "ThemeVariant": { + "type": "string", + "enum": [ + "Dark", + "Light" + ] + }, + "Wallpaper": { + "type": "object", + "properties": { + "generate_theme": { + "description": "Generate and apply Base16 theme for this wallpaper (default: true)", + "type": [ + "boolean", + "null" + ] + }, + "path": { + "description": "Path to the wallpaper image file", + "$ref": "#/$defs/PathBuf" + }, + "theme_options": { + "description": "Specify Light or Dark variant for theme generation (default: Dark)", + "anyOf": [ + { + "$ref": "#/$defs/ThemeOptions" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "path" + ] + }, + "WindowContainerBehaviour": { + "oneOf": [ + { + "description": "Create a new container for each new window", + "type": "string", + "const": "Create" + }, + { + "description": "Append new windows to the focused window container", + "type": "string", + "const": "Append" + } + ] + }, + "WindowHandlingBehaviour": { + "type": "string", + "enum": [ + "Sync", + "Async" + ] + }, + "WorkspaceConfig": { + "type": "object", + "properties": { + "apply_window_based_work_area_offset": { + "description": "Apply this monitor's window-based work area offset (default: true)", + "type": [ + "boolean", + "null" + ] + }, + "container_padding": { + "description": "Container padding (default: global)", + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "custom_layout": { + "description": "END OF LIFE FEATURE: Custom Layout (default: None)", + "anyOf": [ + { + "$ref": "#/$defs/PathBuf" + }, + { + "type": "null" + } + ] + }, + "custom_layout_rules": { + "description": "END OF LIFE FEATURE: Custom layout rules (default: None)", + "type": [ + "object", + "null" + ], + "additionalProperties": false, + "patternProperties": { + "^\\d+$": { + "type": "string" + } + } + }, + "float_override": { + "description": "Enable or disable float override, which makes it so every new window opens in floating mode (default: false)", + "type": [ + "boolean", + "null" + ] + }, + "floating_layer_behaviour": { + "description": "Determine what happens to a new window when the Floating workspace layer is active (default: Tile)", + "anyOf": [ + { + "$ref": "#/$defs/FloatingLayerBehaviour" + }, + { + "type": "null" + } + ] + }, + "initial_workspace_rules": { + "description": "Initial workspace application rules", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/$defs/MatchingRule" + } + }, + "layout": { + "description": "Layout (default: BSP)", + "anyOf": [ + { + "$ref": "#/$defs/DefaultLayout" + }, + { + "type": "null" + } + ] + }, + "layout_flip": { + "description": "Specify an axis on which to flip the selected layout (default: None)", + "anyOf": [ + { + "$ref": "#/$defs/Axis" + }, + { + "type": "null" + } + ] + }, + "layout_options": { + "description": "Layout-specific options (default: None)", + "anyOf": [ + { + "$ref": "#/$defs/LayoutOptions" + }, + { + "type": "null" + } + ] + }, + "layout_rules": { + "description": "Layout rules in the format of threshold => layout (default: None)", + "type": [ + "object", + "null" + ], + "additionalProperties": false, + "patternProperties": { + "^\\d+$": { + "$ref": "#/$defs/DefaultLayout" + } + } + }, + "name": { + "description": "Name", + "type": "string" + }, + "tile": { + "description": "Enable or disable tiling for the workspace (default: true)", + "type": [ + "boolean", + "null" + ] + }, + "wallpaper": { + "description": "Specify a wallpaper for this workspace", + "anyOf": [ + { + "$ref": "#/$defs/Wallpaper" + }, + { + "type": "null" + } + ] + }, + "window_container_behaviour": { + "description": "Determine what happens when a new window is opened (default: Create)", + "anyOf": [ + { + "$ref": "#/$defs/WindowContainerBehaviour" + }, + { + "type": "null" + } + ] + }, + "window_container_behaviour_rules": { + "description": "Window container behaviour rules in the format of threshold => behaviour (default: None)", + "type": [ + "object", + "null" + ], + "additionalProperties": false, + "patternProperties": { + "^\\d+$": { + "$ref": "#/$defs/WindowContainerBehaviour" + } + } + }, + "work_area_offset": { + "description": "Workspace specific work area offset (default: None)", + "anyOf": [ + { + "$ref": "#/$defs/Rect" + }, + { + "type": "null" + } + ] + }, + "workspace_padding": { + "description": "Workspace padding (default: global)", + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "workspace_rules": { + "description": "Permanent workspace application rules", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/$defs/MatchingRule" + } + } + }, + "required": [ + "name" + ] + }, + "ZOrder": { + "type": "string", + "enum": [ + "Top", + "NoTopMost", + "Bottom", + "TopMost" + ] + } + } +} diff --git a/src/generator.rs b/src/generator.rs index 37bb61ec..29a608fa 100644 --- a/src/generator.rs +++ b/src/generator.rs @@ -2,6 +2,9 @@ use crate::Error; use crate::Options; +use crate::identifiers::CSS; +use crate::identifiers::IDS; +use crate::identifiers::TYPE_CLASSES; use crate::prepare::NavigationData; use crate::prepare::build_navigation; use crate::prepare::prepare_for_template; @@ -108,6 +111,11 @@ impl Generator { .unwrap_or_default(); context.insert("required_set", &required_set); + // Add CSS classes and HTML IDs from the single source of truth + context.insert("ids", &IDS); + context.insert("css", &CSS); + context.insert("type_classes", &TYPE_CLASSES); + Ok(context) } } diff --git a/src/identifiers.rs b/src/identifiers.rs new file mode 100644 index 00000000..a2c0a70b --- /dev/null +++ b/src/identifiers.rs @@ -0,0 +1,284 @@ +//! Single source of truth for all CSS classes and HTML IDs +//! +//! This module centralizes all identifier strings used in the generated HTML output. +//! By defining them here, we ensure consistency between Rust code, HTML templates, +//! CSS styles, and JavaScript. + +use serde::Serialize; + +/// Static HTML element IDs used for JavaScript interaction +#[derive(Debug, Clone, Serialize)] +pub struct HtmlIds { + pub search: &'static str, + pub search_results: &'static str, + pub sidebar: &'static str, + pub sidebar_toggle: &'static str, + pub root_schema: &'static str, + pub definitions: &'static str, +} + +/// All static HTML IDs +pub const IDS: HtmlIds = HtmlIds { + search: "search", + search_results: "search-results", + sidebar: "sidebar", + sidebar_toggle: "sidebar-toggle", + root_schema: "root-schema", + definitions: "definitions", +}; + +/// CSS class names organized by category +#[derive(Debug, Clone, Serialize)] +pub struct CssClasses { + // Layout + pub header: &'static str, + pub header_content: &'static str, + pub layout: &'static str, + pub main_content: &'static str, + + // Sidebar + pub sidebar: &'static str, + pub sidebar_open: &'static str, + pub sidebar_header: &'static str, + pub sidebar_toggle: &'static str, + pub sidebar_section: &'static str, + pub sidebar_section_title: &'static str, + + // Navigation + pub nav_list: &'static str, + pub nav_link: &'static str, + pub outline_list: &'static str, + pub outline_item: &'static str, + pub outline_children: &'static str, + + // Search + pub search_container: &'static str, + pub search_results: &'static str, + pub search_results_active: &'static str, + pub search_result_item: &'static str, + pub no_results: &'static str, + pub result_name: &'static str, + pub result_path: &'static str, + pub result_type: &'static str, + + // Schema sections + pub schema_section: &'static str, + pub schema_inline: &'static str, + pub properties_container: &'static str, + pub map_entries: &'static str, + + // Property + pub property: &'static str, + pub property_header: &'static str, + pub property_name: &'static str, + pub pattern: &'static str, + pub too_deep: &'static str, + + // Badges + pub badge: &'static str, + pub required: &'static str, + pub deprecated: &'static str, + pub optional: &'static str, + + // Type badges + pub type_badge: &'static str, + pub type_ref: &'static str, + + // Map display + pub map_key: &'static str, + pub key_type: &'static str, + pub map_arrow: &'static str, + + // Nested schema + pub nested_schema: &'static str, + pub enum_details: &'static str, + pub array_items: &'static str, + pub tuple_items: &'static str, + pub tuple_item: &'static str, + pub tuple_index: &'static str, + + // Constraints + pub constraints: &'static str, + pub constraint: &'static str, + pub label: &'static str, + + // Variants + pub variants: &'static str, + pub variant: &'static str, + pub enum_values: &'static str, + + // Values + pub default_value: &'static str, + pub const_value: &'static str, + + // Definition + pub definition: &'static str, + pub definition_name: &'static str, + pub highlight: &'static str, + + // Description + pub description: &'static str, + + // Usages + pub usages: &'static str, + pub usage_section: &'static str, + pub usage_list: &'static str, +} + +/// All CSS class names +pub const CSS: CssClasses = CssClasses { + // Layout + header: "header", + header_content: "header-content", + layout: "layout", + main_content: "main-content", + + // Sidebar + sidebar: "sidebar", + sidebar_open: "open", + sidebar_header: "sidebar-header", + sidebar_toggle: "sidebar-toggle", + sidebar_section: "sidebar-section", + sidebar_section_title: "sidebar-section-title", + + // Navigation + nav_list: "nav-list", + nav_link: "nav-link", + outline_list: "outline-list", + outline_item: "outline-item", + outline_children: "outline-children", + + // Search + search_container: "search-container", + search_results: "search-results", + search_results_active: "active", + search_result_item: "search-result-item", + no_results: "no-results", + result_name: "result-name", + result_path: "result-path", + result_type: "result-type", + + // Schema sections + schema_section: "schema-section", + schema_inline: "schema-inline", + properties_container: "properties-container", + map_entries: "map-entries", + + // Property + property: "property", + property_header: "property-header", + property_name: "property-name", + pattern: "pattern", + too_deep: "too-deep", + + // Badges + badge: "badge", + required: "required", + deprecated: "deprecated", + optional: "optional", + + // Type badges + type_badge: "type-badge", + type_ref: "type-ref", + + // Map display + map_key: "map-key", + key_type: "key-type", + map_arrow: "map-arrow", + + // Nested schema + nested_schema: "nested-schema", + enum_details: "enum-details", + array_items: "array-items", + tuple_items: "tuple-items", + tuple_item: "tuple-item", + tuple_index: "tuple-index", + + // Constraints + constraints: "constraints", + constraint: "constraint", + label: "label", + + // Variants + variants: "variants", + variant: "variant", + enum_values: "enum-values", + + // Values + default_value: "default-value", + const_value: "const-value", + + // Definition + definition: "definition", + definition_name: "definition-name", + highlight: "highlight", + + // Description + description: "description", + + // Usages + usages: "usages", + usage_section: "usage-section", + usage_list: "usage-list", +}; + +/// Type-specific badge class suffixes (used with type-badge prefix) +#[derive(Debug, Clone, Serialize)] +pub struct TypeClasses { + pub string: &'static str, + pub number: &'static str, + pub integer: &'static str, + pub float: &'static str, + pub boolean: &'static str, + pub null: &'static str, + pub object: &'static str, + pub array: &'static str, + pub r#enum: &'static str, + pub union: &'static str, + pub r#const: &'static str, +} + +/// Type-specific badge classes +pub const TYPE_CLASSES: TypeClasses = TypeClasses { + string: "type-string", + number: "type-number", + integer: "type-integer", + float: "type-float", + boolean: "type-boolean", + null: "type-null", + object: "type-object", + array: "type-array", + r#enum: "type-enum", + union: "type-union", + r#const: "type-const", +}; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_ids_are_valid_html_ids() { + // HTML IDs should not contain spaces and should be lowercase + assert!(!IDS.search.contains(' ')); + assert!(!IDS.search_results.contains(' ')); + assert!(!IDS.sidebar.contains(' ')); + assert!(!IDS.sidebar_toggle.contains(' ')); + } + + #[test] + fn test_css_classes_are_valid() { + // CSS classes should not contain spaces (multiple classes are separate fields) + assert!(!CSS.header.contains(' ')); + assert!(!CSS.property.contains(' ')); + assert!(!CSS.type_badge.contains(' ')); + } + + #[test] + fn test_type_classes_have_prefix() { + // All type classes should start with "type-" + assert!(TYPE_CLASSES.string.starts_with("type-")); + assert!(TYPE_CLASSES.integer.starts_with("type-")); + assert!(TYPE_CLASSES.array.starts_with("type-")); + } +} diff --git a/src/lib.rs b/src/lib.rs index c0badde4..0e6eb485 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -29,9 +29,13 @@ //! ``` mod generator; +pub mod identifiers; mod prepare; pub use generator::Generator; +pub use identifiers::CSS; +pub use identifiers::IDS; +pub use identifiers::TYPE_CLASSES; use schemars::Schema; use serde_json::Value; diff --git a/src/templates/base.html b/src/templates/base.html index 9aaf5863..5072bf06 100644 --- a/src/templates/base.html +++ b/src/templates/base.html @@ -10,53 +10,56 @@ -
      -
      +
      +

      {{ title_html | default(value="Schema Documentation") | safe }}

      -
      +
      -
      +
      -
      -