diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index c6cde3f..04c9cd6 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -2,6 +2,10 @@ name: Pages # Publishes the viewer on GitHub Pages: # https://matdata-eu.github.io/schema-explorer/ +# with an examples catalog of freely redistributable schemas only: NeTEx, +# ERA TAF/TAP TSI, OASIS UBL 2.4 and the W3C schema for schemas are fetched +# from their publishers and pre-indexed here (see scripts/build-examples.sh). railML and other non-redistributable schemas are +# never published. on: push: @@ -25,6 +29,15 @@ jobs: steps: - uses: actions/checkout@v4 - uses: ./.github/actions/build-web + - name: Cache the example downloads + uses: actions/cache@v4 + with: + path: tests/fixtures/downloads + key: examples-${{ hashFiles('scripts/build-examples.sh') }} + - name: Build the examples (fetched at build time) + run: | + cargo build --release --locked -p schema-explorer + scripts/build-examples.sh ./target/release/schema-explorer web/dist - name: Add the notices run: | cp THIRD_PARTY_NOTICES.md web/dist/ diff --git a/Cargo.lock b/Cargo.lock index 8a281b6..f45b704 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -58,12 +58,76 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + [[package]] name = "bumpalo" version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + [[package]] name = "cfg-if" version = "1.0.5" @@ -101,7 +165,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 3.0.9", ] [[package]] @@ -116,18 +180,68 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "combine" +version = "4.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "equivalent" version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + [[package]] name = "foldhash" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "futures-core" version = "0.3.34" @@ -164,6 +278,189 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "include_dir" version = "0.7.4" @@ -205,6 +502,55 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + [[package]] name = "js-sys" version = "0.3.106" @@ -216,12 +562,107 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "memchr" version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "objc2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -234,12 +675,27 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -267,20 +723,42 @@ dependencies = [ "memchr", ] +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustversion" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[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 = "schema-explorer" version = "0.1.0" dependencies = [ "anyhow", + "axum", "clap", "include_dir", "schema-explorer-core", + "serde_json", + "tokio", + "webbrowser", ] [[package]] @@ -298,9 +776,16 @@ version = "0.1.0" dependencies = [ "js-sys", "schema-explorer-core", + "serde_json", "wasm-bindgen", ] +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.229" @@ -328,7 +813,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.9", ] [[package]] @@ -346,18 +831,77 @@ dependencies = [ "zmij", ] +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "slab" version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[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.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "3.0.9" @@ -369,18 +913,152 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0c168a474b95bfecfe908a65d4bdffc523144b649a74c09c286afb35d53e0bc" +dependencies = [ + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "unicode-ident" version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[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.129" @@ -413,7 +1091,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 3.0.9", "wasm-bindgen-shared", ] @@ -426,6 +1104,41 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webbrowser" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94" +dependencies = [ + "jni", + "log", + "ndk-context", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "url", + "web-sys", +] + +[[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-link" version = "0.2.1" @@ -441,6 +1154,89 @@ dependencies = [ "windows-link", ] +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "zmij" version = "1.0.23" diff --git a/README.md b/README.md index 301ef85..03f6e85 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,80 @@ # schema-explorer -A visual explorer for XML Schema (XSD), and later JSON Schema, that runs in the -browser and can be published as a static site (GitHub Pages). It is the -schema counterpart of [ontology-explorer](https://github.com/Matdata-eu/ontology-explorer). +XML Schema (XSD) as UML-like diagrams, readable type pages and version diffs. + +Large schema sets such as NeTEx or railML are hard to read as hundreds of +`.xsd` files. schema-explorer reads a whole set, follows its imports and +includes, and shows it the way people are used to: class diagrams, content +trees, and one page per type and element with every child, attribute, +cardinality and allowed value. It also compares two versions of a set and +says what changed and what breaks. It is the schema counterpart of +[ontology-explorer](https://github.com/Matdata-eu/ontology-explorer). + +It runs entirely in the browser, so it can be published on GitHub Pages, and +a small command-line tool pre-indexes large sets, serves the viewer locally +and writes diff reports. JSON Schema support is planned. + +**Try it:** with NeTEx, +ERA TAF and TAP TSI, OASIS UBL 2.4 and the W3C schema for schemas, or drop +your own files on it. + +![The page of a NeTEx complex type: derivation chain and class diagram](docs/screenshot-type.png) + +## Features + +- **Open** `.xsd` files or whole folders by drag and drop or the file picker: + they are indexed in the browser (WebAssembly, in a worker), nothing is + uploaded. Or open a pre-built bundle by its address (`?bundle=`), or + pick an example. +- **Sidebar**: complex types, simple types, global elements, groups and + attribute groups, and files, per namespace; search over names, + documentation and enumeration values. Lists are virtualised, so NeTEx's + 2,600 complex types scroll smoothly. +- **Type pages**: documentation, the derivation chain and derived types, the + effective content model (child elements with cardinality and type, + `sequence` / `choice` / `all` and groups, inherited rows grouped per base + type and foldable), attributes (type, use, default or fixed), facets and + enumerations with their documentation, identity constraints, where used, + the source file and line, and the XSD of the component with linked names. +- **Element pages** (global and local): type, substitution group head and + members, nillable, abstract, default or fixed, where used. +- **Simple type pages**: restriction facets, enumeration table, list item and + union member types. +- **Class diagrams** (laid out with ELK, drawn as SVG): a type with its + attributes and simple-typed children, generalisation arrows for extension + and restriction, composition arrows with multiplicities for complex-typed + child elements, «enumeration» boxes, the types and elements that use it. + Double-click to add neighbours; depth, direction, full screen, export to + SVG, PNG, PlantUML and Mermaid. +- **Content-model trees** in the style of XML editors, expanded on demand. +- **Overview map** of the complex types of a namespace or a folder of files. +- **Version comparison**: load two versions of a schema set and see every + added, removed and changed type, element, attribute, cardinality and + enumeration value, rated breaking or not, with likely renames and + namespace changes detected; changes are highlighted in the diagrams and + trees, and exported as Markdown, HTML or JSON. +- **Share links** that restore the page, the diagram's options, added boxes, + open tree nodes and zoom; light and dark themes; a short manual (Help). + +![The content tree of a NeTEx type](docs/screenshot-tree.png) + +![The overview map of the NeTEx IFOPT part](docs/screenshot-overview.png) + +## Running it locally -**Status: early development.** Nothing usable yet; the parser and viewer are -being built. - -Website: +```sh +schema-explorer serve path/to/schemas/ # every .xsd file in the folder +schema-explorer serve main.xsd --bind 0.0.0.0:8080 --no-open +``` -## Planned features +`serve` indexes the files once, opens the embedded viewer on them and serves +their source for the XSD view. Without files, the viewer opens empty and +indexes what you drop on it. -- UML-like diagrams of complex types, elements and their relations -- A readable page per type, element, attribute group and model group -- Content-model trees (sequence / choice / all, cardinalities, substitution groups) -- Search across all components -- Following `include`, `import` and `ref` across files and namespaces -- Comparing two versions of a schema set -- A command-line tool (`schema-explorer serve` / `index`), a Docker image and - an embeddable `` web component (`@matdata/schema-explorer`) +Binaries for Linux, macOS and Windows, `cargo install schema-explorer`, the +Docker image `ghcr.io/matdata-eu/schema-explorer` and the `` +web component on npm (`@matdata/schema-explorer`) come with the first tagged +release. Until then, build from source with `cargo build --release -p schema-explorer`. ## Indexing a schema set @@ -41,6 +97,36 @@ bundle. The bundle format is versioned (`format`, see can recompute. In the browser, the WebAssembly build exposes `index(names, contents, compact)` with the same output. +## Comparing versions + +`diff` compares two versions of a schema set: global complex and simple +types, elements, attributes, groups and attribute groups added, removed or +changed, down to child elements (added, removed, retyped, cardinality, +order, compositor structure), attributes (use, type, default, fixed), +enumeration values, facets, base types, substitution groups and +documentation. Each change is classified as breaking (documents valid +against the old version may not be valid against the new one), +non-breaking or documentation only; removed and added components with +near-identical content are reported as possibly renamed. Components are +matched by qualified name and, when the target namespace changed between +versions (railML 3.2 → 3.3), by kind and local name (`--strict-namespaces` +turns that off). + +```sh +schema-explorer diff railml-3.2/schema/ railml-3.3/schema/ --format md -o CHANGES.md +schema-explorer diff old.json new.json -o diff.json # bundles work too +schema-explorer diff a.xsd,b.xsd c.xsd,d.xsd --format html -o diff.html --fail-on-breaking +``` + +Several paths for one side are joined with commas. The JSON diff format is +versioned (`format`, see `crates/core/src/diff.rs`). In the viewer, "Compare +two versions" on the open page takes two schema sets; a link +`?bundle=&compare=` opens the comparison directly. The overview +lists the changes with filters, each changed component gets a page with +both versions side by side, and diagrams and content trees of the later +version mark what was added (+, green), removed (−, red, struck through) +and changed (~, amber). + ## Layout - `crates/core`: Rust library that reads schema sets into an indexed model @@ -48,6 +134,21 @@ can recompute. In the browser, the WebAssembly build exposes - `crates/wasm`: the core compiled to WebAssembly for the browser - `web/`: the TypeScript/Vite viewer +## Publishing a schema set + +`scripts/build-examples.sh ` shows how the website is built: it +pre-indexes a schema set into `bundle.json` and puts its `.xsd` files next +to it (for the source view). Any static host works: +`index.html?bundle=examples/netex/bundle.json&sources=examples/netex/xsd/`. +Only publish schemas whose licence allows it. + +The website's examples are fetched from their publishers at build time and +keep their own terms: NeTEx (GPL-3.0, NeTEx-CEN); TAF TSI and TAP TSI B.10 +schemas, source: European Union Agency for Railways (reproduction authorised, +source acknowledged); UBL 2.4, copyright © OASIS Open 2024 (OASIS IPR +Policy); XMLSchema.xsd and xml.xsd, copyright © W3C (W3C Document License). +See [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). + ## Development ```sh diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 4eb0bc0..bad4f41 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -9,3 +9,35 @@ This file is a placeholder during early development. It is regenerated from before releases: cargo about generate about.hbs -o THIRD_PARTY_NOTICES.md + +## Web viewer + +The web viewer (in the binary, on the website and in the npm package) +includes: + +| Component | Version | Licence | Used for | +|---|---|---|---| +| [ELK.js](https://github.com/kieler/elkjs), the JavaScript build of the [Eclipse Layout Kernel](https://eclipse.dev/elk/) | 0.12.0 | EPL-2.0 | the diagram layout worker | +| [Barlow](https://github.com/jpt/barlow) font, latin subset via [Fontsource](https://fontsource.org) | 5.3.0 | OFL-1.1 | the viewer's text | +| [JetBrains Mono](https://github.com/JetBrains/JetBrainsMono) font, latin subset via [Fontsource](https://fontsource.org) | 5.3.0 | OFL-1.1 | names and code in the viewer | + +ELK.js is distributed unmodified; its source code is available at + and . + +The example schemas on the website are fetched from their publishers at build +time (see `scripts/build-examples.sh`) and published with their own terms; +they are not part of this repository or the binary: + +- **NeTEx**, from , GPL-3.0. +- **TAF TSI** (technical documents baseline 3.5.0) and **TAP TSI B.10** (1.4) + XML schemas. Source: European Union Agency for Railways, + . "Reproduction is authorised, provided the + source is acknowledged" ([ERA disclaimer and copyright notice](https://www.era.europa.eu/content/disclaimer-and-copyright-notice)). +- **UBL 2.4**, Universal Business Language Version 2.4, OASIS Standard, + . Copyright © OASIS Open 2024. + All Rights Reserved. Distributed under the + [OASIS IPR Policy](https://www.oasis-open.org/policies-guidelines/ipr/). +- **XML Schema** schema for schemas, + with . Copyright © World Wide Web + Consortium. Used under the + [W3C Document License](https://www.w3.org/copyright/document-license-2023/). diff --git a/about.hbs b/about.hbs index 9051391..9a6146d 100644 --- a/about.hbs +++ b/about.hbs @@ -8,10 +8,43 @@ reports and feature requests: and . + +The example schemas on the website are fetched from their publishers at build +time (see `scripts/build-examples.sh`) and published with their own terms; +they are not part of this repository or the binary: + +- **NeTEx**, from , GPL-3.0. +- **TAF TSI** (technical documents baseline 3.5.0) and **TAP TSI B.10** (1.4) + XML schemas. Source: European Union Agency for Railways, + . "Reproduction is authorised, provided the + source is acknowledged" ([ERA disclaimer and copyright notice](https://www.era.europa.eu/content/disclaimer-and-copyright-notice)). +- **UBL 2.4**, Universal Business Language Version 2.4, OASIS Standard, + . Copyright © OASIS Open 2024. + All Rights Reserved. Distributed under the + [OASIS IPR Policy](https://www.oasis-open.org/policies-guidelines/ipr/). +- **XML Schema** schema for schemas, + with . Copyright © World Wide Web + Consortium. Used under the + [W3C Document License](https://www.w3.org/copyright/document-license-2023/). + ## Data -- Schemas shown on the project website (such as NeTEx, from - ) are fetched at build time under their +- Schemas shown on the project website (NeTEx, ERA TAF/TAP TSI, OASIS UBL, + W3C XML Schema; see above) are fetched at build time under their own licences. They are not part of the schema-explorer software. ## Rust crates diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 073281e..0d2df63 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -23,3 +23,7 @@ schema-explorer-core.workspace = true anyhow.workspace = true clap = { version = "4", features = ["derive", "env"] } include_dir = "0.7" +axum = { version = "0.8", default-features = false, features = ["http1", "tokio"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "signal", "fs"] } +serde_json.workspace = true +webbrowser = "1" diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 3636171..fd41988 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -1,7 +1,7 @@ //! Command-line tool of schema-explorer: serves the viewer with local schemas //! and pre-indexes schema sets into bundles for static hosting. -//! -//! Early development: the commands are stubs. + +mod serve; use std::path::{Path, PathBuf}; use std::time::Instant; @@ -10,12 +10,12 @@ use anyhow::{Context, Result, bail}; use clap::{Parser, Subcommand}; use include_dir::{Dir, include_dir}; use schema_explorer_core::bundle::Level; -use schema_explorer_core::{Options, Source}; +use schema_explorer_core::{Bundle, Options, Source}; /// The web viewer, embedded at build time (see build.rs). -static WEB: Dir<'static> = include_dir!("$OUT_DIR/web"); +pub(crate) static WEB: Dir<'static> = include_dir!("$OUT_DIR/web"); /// The third-party notices, embedded at build time. -static NOTICES: &str = include_str!(concat!(env!("OUT_DIR"), "/THIRD_PARTY_NOTICES.md")); +pub(crate) static NOTICES: &str = include_str!(concat!(env!("OUT_DIR"), "/THIRD_PARTY_NOTICES.md")); #[derive(Parser)] #[command(name = "schema-explorer", version, about)] @@ -30,8 +30,12 @@ struct Cli { #[derive(Subcommand)] enum Command { /// Serve the viewer with the given schema files. + /// + /// The schemas are indexed once at start; the viewer opens on them and + /// shows their source. Without files, the viewer opens empty and indexes + /// what is dropped on it in the browser. Serve { - /// Schema files or directories. + /// Schema files or directories (every .xsd file in them). files: Vec, /// Address to listen on. #[arg(long, env = "SCHEMA_EXPLORER_BIND", default_value = "127.0.0.1:8080")] @@ -59,6 +63,44 @@ enum Command { #[arg(long)] pretty: bool, }, + /// Compare two versions of a schema set. + /// + /// Each side is a schema file, a directory (every .xsd file in it) or a + /// bundle made with `index`; join several paths for one side with + /// commas. Components are matched by qualified name, and by kind and + /// local name when the target namespace changed between the versions. + Diff { + /// The earlier version. + old: String, + /// The later version. + new: String, + /// Output file (default: standard output). + #[arg(short, long)] + output: Option, + /// Report format: json (the diff model), md (release notes) or html. + #[arg(long, value_enum, default_value_t = Format::Json)] + format: Format, + /// Match components only by qualified name (no fallback to local + /// names when the namespace changed). + #[arg(long)] + strict_namespaces: bool, + /// Leave out documentation changes. + #[arg(long)] + no_docs: bool, + /// Titles of the two versions in reports (default: from the schemas' version attributes). + #[arg(long, num_args = 2, value_names = ["OLD", "NEW"])] + titles: Option>, + /// Exit with status 2 when there are breaking changes. + #[arg(long)] + fail_on_breaking: bool, + }, +} + +#[derive(Clone, Copy, PartialEq, Eq, clap::ValueEnum)] +enum Format { + Json, + Md, + Html, } fn main() -> Result<()> { @@ -72,19 +114,34 @@ fn main() -> Result<()> { files, bind, no_open, - }) => { - let _ = (files, no_open); - bail!( - "serve is not implemented yet (would listen on {bind}; viewer has {} embedded entries)", - WEB.entries().len() - ) - } + }) => serve::run(&files, &bind, no_open), Some(Command::Index { files, output, compact, pretty, }) => index(&files, &output, compact, pretty), + Some(Command::Diff { + old, + new, + output, + format, + strict_namespaces, + no_docs, + titles, + fail_on_breaking, + }) => { + let opts = schema_explorer_core::DiffOptions { + match_local_names: !strict_namespaces, + docs: !no_docs, + ..Default::default() + }; + let breaking = diff(&old, &new, output.as_deref(), format, &opts, titles)?; + if fail_on_breaking && breaking { + std::process::exit(2); + } + Ok(()) + } None => { println!("schema-explorer {}", schema_explorer_core::version()); Ok(()) @@ -113,8 +170,8 @@ fn collect(path: &Path, out: &mut Vec) -> Result<()> { Ok(()) } -fn index(inputs: &[PathBuf], output: &Path, compact: bool, pretty: bool) -> Result<()> { - let start = Instant::now(); +/// Read and index the schema files; also returns every file read from disk. +pub(crate) fn build(inputs: &[PathBuf], compact: bool) -> Result<(Bundle, Vec)> { let mut paths = Vec::new(); for p in inputs { collect(p, &mut paths)?; @@ -126,23 +183,25 @@ fn index(inputs: &[PathBuf], output: &Path, compact: bool, pretty: bool) -> Resu Ok(Source::new(p.to_string_lossy(), bytes)) }) .collect::>>()?; + let mut read = paths.clone(); let mut resolver = |path: &str| -> Option> { if path.contains("://") { return None; } - std::fs::read(path).ok() + let bytes = std::fs::read(path).ok()?; + read.push(PathBuf::from(path)); + Some(bytes) }; let opts = Options { generator: format!("schema-explorer {}", env!("CARGO_PKG_VERSION")), compact, }; let bundle = schema_explorer_core::index(sources, Some(&mut resolver), &opts); - let json = if pretty { - schema_explorer_core::to_json_pretty(&bundle)? - } else { - schema_explorer_core::to_json(&bundle)? - }; - std::fs::write(output, &json).with_context(|| format!("writing {}", output.display()))?; + Ok((bundle, read)) +} + +/// Print the warnings and errors of a bundle. +pub(crate) fn report(bundle: &Bundle) { for d in &bundle.diagnostics { if d.level == Level::Info { continue; @@ -155,6 +214,18 @@ fn index(inputs: &[PathBuf], output: &Path, compact: bool, pretty: bool) -> Resu d.level, d.line, d.message, d.code ); } +} + +fn index(inputs: &[PathBuf], output: &Path, compact: bool, pretty: bool) -> Result<()> { + let start = Instant::now(); + let (bundle, _) = build(inputs, compact)?; + let json = if pretty { + schema_explorer_core::to_json_pretty(&bundle)? + } else { + schema_explorer_core::to_json(&bundle)? + }; + std::fs::write(output, &json).with_context(|| format!("writing {}", output.display()))?; + report(&bundle); let s = &bundle.stats; eprintln!( "{} files, {} global and {} local components, {} unresolved imports, {} unresolved references; wrote {} ({} KB) in {:.2?}", @@ -169,3 +240,69 @@ fn index(inputs: &[PathBuf], output: &Path, compact: bool, pretty: bool) -> Resu ); Ok(()) } + +/// Load one side of a comparison: a bundle (.json) or schema paths. +fn load_side(spec: &str) -> Result { + let paths: Vec = spec + .split(',') + .filter(|s| !s.is_empty()) + .map(PathBuf::from) + .collect(); + if let [p] = paths.as_slice() + && p.is_file() + && p.extension() + .is_some_and(|x| x.eq_ignore_ascii_case("json")) + { + let text = + std::fs::read_to_string(p).with_context(|| format!("reading {}", p.display()))?; + let mut b: Bundle = serde_json::from_str(&text) + .with_context(|| format!("{} is not a schema-explorer bundle", p.display()))?; + schema_explorer_core::expand(&mut b); + return Ok(b); + } + let (b, _) = build(&paths, false)?; + report(&b); + Ok(b) +} + +/// Compare two schema sets; true when there are breaking changes. +fn diff( + old: &str, + new: &str, + output: Option<&Path>, + format: Format, + opts: &schema_explorer_core::DiffOptions, + titles: Option>, +) -> Result { + let start = Instant::now(); + let a = load_side(old)?; + let b = load_side(new)?; + let mut d = schema_explorer_core::diff(&a, &b, opts); + d.generator = format!("schema-explorer {}", env!("CARGO_PKG_VERSION")); + if let Some(t) = titles { + d.old.title = t.first().cloned(); + d.new.title = t.get(1).cloned(); + } + let text = match format { + Format::Json => serde_json::to_string_pretty(&d)?, + Format::Md => schema_explorer_core::report::markdown(&d), + Format::Html => schema_explorer_core::report::html(&d), + }; + match output { + Some(p) => std::fs::write(p, &text).with_context(|| format!("writing {}", p.display()))?, + None => println!("{text}"), + } + let t = &d.summary.total; + eprintln!( + "{} added, {} removed, {} changed ({} breaking, {} documentation only), {} possibly renamed; {} breaking changes in all, in {:.2?}", + t.added, + t.removed, + t.changed, + t.breaking, + t.docs_only, + d.summary.possibly_renamed, + d.summary.breaking_changes, + start.elapsed() + ); + Ok(t.breaking > 0) +} diff --git a/crates/cli/src/serve.rs b/crates/cli/src/serve.rs new file mode 100644 index 0000000..0eaca51 --- /dev/null +++ b/crates/cli/src/serve.rs @@ -0,0 +1,175 @@ +//! `serve`: the embedded viewer in a local web server, with the bundle of +//! the schemas given on the command line and their source files. + +use std::path::PathBuf; +use std::sync::Arc; + +use anyhow::{Context, Result}; +use axum::{ + Router, + extract::{Path, State}, + http::{HeaderValue, StatusCode, Uri, header}, + response::{IntoResponse, Response}, + routing::get, +}; + +use crate::{NOTICES, WEB}; + +/// What the server hands out. +struct Loaded { + /// The bundle as JSON, when schemas were given. + bundle: Option, + /// The file on disk behind each file of the bundle. + files: Vec>, + title: Option, +} + +pub fn run(inputs: &[PathBuf], bind: &str, no_open: bool) -> Result<()> { + let loaded = if inputs.is_empty() { + Loaded { + bundle: None, + files: Vec::new(), + title: None, + } + } else { + let started = std::time::Instant::now(); + let (bundle, read) = crate::build(inputs, false)?; + crate::report(&bundle); + let files = bundle + .files + .iter() + .map(|f| match_file(&f.name, &read)) + .collect(); + let title = (inputs.len() == 1).then(|| { + inputs[0] + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default() + }); + let json = schema_explorer_core::to_json(&bundle)?; + eprintln!( + "indexed {} files ({} components) in {:.2?}", + bundle.files.len(), + bundle.components.len(), + started.elapsed() + ); + Loaded { + bundle: Some(json), + files, + title, + } + }; + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()?; + rt.block_on(serve(Arc::new(loaded), bind, no_open)) +} + +/// The path read from disk whose name ends with the bundle's (relative) file name. +fn match_file(name: &str, read: &[PathBuf]) -> Option { + let want = name.replace('\\', "/"); + read.iter() + .find(|p| { + let s = p.to_string_lossy().replace('\\', "/"); + s == want || s.ends_with(&format!("/{want}")) + }) + .cloned() +} + +async fn serve(state: Arc, bind: &str, no_open: bool) -> Result<()> { + let app = Router::new() + .route("/api/status", get(status)) + .route("/api/bundle", get(bundle)) + .route("/api/source/{file}", get(source)) + .route("/licenses", get(licenses)) + .fallback(get(static_file)) + .with_state(state); + let listener = tokio::net::TcpListener::bind(bind) + .await + .with_context(|| format!("cannot listen on {bind}"))?; + let url = format!("http://{}/", listener.local_addr()?); + eprintln!("schema-explorer listening on {url} (Ctrl+C to stop)"); + if !no_open && webbrowser::open(&url).is_err() { + eprintln!("open {url} in your browser"); + } + axum::serve(listener, app) + .with_graceful_shutdown(async { + let _ = tokio::signal::ctrl_c().await; + }) + .await?; + Ok(()) +} + +async fn status(State(s): State>) -> Response { + let body = serde_json::json!({ + "server": true, + "version": env!("CARGO_PKG_VERSION"), + "loaded": s.bundle.is_some(), + "title": s.title, + }); + with_type( + body.to_string().into_response(), + "application/json", + "no-store", + ) +} + +async fn bundle(State(s): State>) -> Response { + match &s.bundle { + Some(json) => with_type(json.clone().into_response(), "application/json", "no-store"), + None => (StatusCode::NOT_FOUND, "no schemas loaded").into_response(), + } +} + +/// The text of one schema file of the bundle, for the viewer's source view. +async fn source(State(s): State>, Path(file): Path) -> Response { + let Some(Some(path)) = s.files.get(file) else { + return (StatusCode::NOT_FOUND, "no such file").into_response(); + }; + match tokio::fs::read(path).await { + Ok(bytes) => with_type(bytes.into_response(), "application/xml", "no-cache"), + Err(e) => (StatusCode::NOT_FOUND, e.to_string()).into_response(), + } +} + +async fn licenses() -> Response { + with_type( + NOTICES.into_response(), + "text/plain; charset=utf-8", + "no-cache", + ) +} + +fn with_type(mut r: Response, mime: &'static str, cache: &'static str) -> Response { + r.headers_mut() + .insert(header::CONTENT_TYPE, HeaderValue::from_static(mime)); + r.headers_mut() + .insert(header::CACHE_CONTROL, HeaderValue::from_static(cache)); + r +} + +async fn static_file(uri: Uri) -> Response { + let path = uri.path().trim_start_matches('/'); + let path = if path.is_empty() { "index.html" } else { path }; + let Some(file) = WEB.get_file(path) else { + return (StatusCode::NOT_FOUND, "not found").into_response(); + }; + let mime = match path.rsplit('.').next().unwrap_or("") { + "html" => "text/html; charset=utf-8", + "js" | "mjs" => "text/javascript", + "css" => "text/css", + "json" => "application/json", + "wasm" => "application/wasm", + "svg" => "image/svg+xml", + "png" => "image/png", + "woff2" => "font/woff2", + "txt" | "md" => "text/plain; charset=utf-8", + _ => "application/octet-stream", + }; + let cache = if path.starts_with("assets/") { + "public, max-age=31536000, immutable" + } else { + "no-cache" + }; + with_type(file.contents().into_response(), mime, cache) +} diff --git a/crates/core/src/diff.rs b/crates/core/src/diff.rs new file mode 100644 index 0000000..58122fe --- /dev/null +++ b/crates/core/src/diff.rs @@ -0,0 +1,2233 @@ +//! Comparison of two versions of a schema set. +//! +//! [`diff`] matches the global components of two bundles (complex and +//! simple types, elements, attributes, model groups and attribute groups) by +//! qualified name and, when a version moved to another target namespace (as +//! railML does with every minor version), by kind and local name. It then +//! compares each matched pair and lists fine-grained changes, each with its +//! impact on instance documents: breaking (documents valid against the old +//! version may be invalid against the new one), non-breaking, or +//! informational (documentation). +//! +//! The result is a [`SchemaDiff`], serialized with serde like the bundle and +//! versioned by [`DIFF_FORMAT`]. + +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; + +use serde::{Deserialize, Serialize}; + +use crate::bundle::*; + +/// Version of the diff format; changes when the JSON changes incompatibly. +pub const DIFF_FORMAT: u32 = 1; + +/// How deep anonymous types nested in anonymous types are compared. +const MAX_DEPTH: u32 = 24; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct DiffOptions { + /// Match components that have no counterpart with the same qualified + /// name by kind and local name, when their target namespaces differ + /// (a namespace that changes between versions). + pub match_local_names: bool, + /// Report documentation changes. + pub docs: bool, + /// Pair removed and added components with near-identical content as + /// possible renames. + pub renames: bool, +} + +impl Default for DiffOptions { + fn default() -> Self { + DiffOptions { + match_local_names: true, + docs: true, + renames: true, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Status { + Added, + Removed, + Changed, +} + +/// The effect of a change on instance documents. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Impact { + /// Documents valid against the old version may be invalid against the new one. + Breaking, + /// Documents valid against the old version stay valid. + NonBreaking, + /// No effect on validation (documentation). + Info, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ChangeKind { + /// A complex type became a simple type or the reverse. + KindChanged, + /// Base type of a derivation. + BaseChanged, + /// Extension / restriction, simple / complex content. + DerivationChanged, + AbstractChanged, + FinalChanged, + BlockChanged, + MixedChanged, + NillableChanged, + /// Type of an element or attribute declaration. + TypeChanged, + /// `default` / `fixed` of an element declaration. + ValueConstraintChanged, + /// The head(s) an element substitutes for. + SubstitutionGroupChanged, + SubstitutionMemberAdded, + SubstitutionMemberRemoved, + /// A child particle (element, group reference, wildcard) of a content model. + ChildAdded, + ChildRemoved, + ChildRetyped, + ChildCardinalityChanged, + /// Children of a sequence in another order. + ChildrenReordered, + /// A child moved into another compositor (e.g. from a sequence into a choice). + ChildMoved, + /// The compositors (sequence, choice, all) of a content model. + StructureChanged, + AttributeAdded, + AttributeRemoved, + AttributeRetyped, + AttributeUseChanged, + AttributeDefaultChanged, + AttributeFixedChanged, + AnyAttributeChanged, + EnumerationAdded, + EnumerationRemoved, + FacetAdded, + FacetRemoved, + FacetChanged, + /// Atomic / list / union. + VarietyChanged, + ItemTypeChanged, + MemberTypeAdded, + MemberTypeRemoved, + AssertAdded, + AssertRemoved, + DocsChanged, +} + +/// One change in a component. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Change { + pub kind: ChangeKind, + pub impact: Impact, + /// Where in the component: the names of the nested local declarations + /// leading to it, `/`-separated (`@name` for attributes); empty for the + /// component itself. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub path: String, + /// What changed: the child, attribute, enumeration value or facet. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub subject: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub old: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub new: Option, +} + +/// A component in one of the two bundles. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Side { + pub id: Id, + /// `prefix:name` in that bundle. + pub qname: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub file: Option, + #[serde(default, skip_serializing_if = "is_zero")] + pub line: u32, +} + +fn is_zero(n: &u32) -> bool { + *n == 0 +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ComponentDiff { + /// The kind in the new version (old for a removed component). + pub kind: Kind, + pub status: Status, + pub name: String, + /// Target namespace URI (new version's, old for a removed component). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub namespace: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub old: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub new: Option, + /// Matched by kind and local name only (the namespace differs). + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub by_local_name: bool, + /// The worst impact: removed is breaking, added non-breaking. + pub impact: Impact, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub changes: Vec, + /// For a removed or added component that looks like a rename: the + /// qualified name of the other one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub possibly_renamed: Option, +} + +impl ComponentDiff { + pub fn breaking(&self) -> bool { + self.impact == Impact::Breaking + } + /// The qualified name: the new one, or the old one for a removed component. + pub fn qname(&self) -> &str { + self.new + .as_ref() + .or(self.old.as_ref()) + .map_or("", |s| s.qname.as_str()) + } + pub fn file(&self) -> Option<&str> { + self.new + .as_ref() + .or(self.old.as_ref()) + .and_then(|s| s.file.as_deref()) + } +} + +/// A removed and an added component with near-identical content. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Rename { + pub kind: Kind, + pub old: Side, + pub new: Side, + /// Similarity of the content, in percent. + pub score: u32, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Counts { + pub added: u32, + pub removed: u32, + pub changed: u32, + /// Added, removed or changed components with a breaking change. + pub breaking: u32, + /// Changed components whose only changes are documentation. + pub docs_only: u32, +} + +impl Counts { + fn add(&mut self, d: &ComponentDiff) { + match d.status { + Status::Added => self.added += 1, + Status::Removed => self.removed += 1, + Status::Changed => self.changed += 1, + } + if d.breaking() { + self.breaking += 1; + } + if d.status == Status::Changed && d.impact == Impact::Info { + self.docs_only += 1; + } + } + pub fn total(&self) -> u32 { + self.added + self.removed + self.changed + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Summary { + pub total: Counts, + pub by_kind: BTreeMap, + /// By target namespace URI (empty string: no namespace); removed + /// components count under the new URI of a namespace that changed. + pub by_namespace: BTreeMap, + pub by_file: BTreeMap, + /// Number of changes of each kind. + pub changes: BTreeMap, + pub breaking_changes: u32, + pub possibly_renamed: u32, +} + +/// What was compared on one side. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SetInfo { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option, + pub files: u32, + /// Target namespaces of the files. + pub namespaces: Vec, + /// The `version` attribute of the schema file with the most components. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub version: Option, + /// The `version` attributes of the schema files. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub versions: Vec, + /// Global components compared. + pub components: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SchemaDiff { + pub format: u32, + pub generator: String, + pub options: DiffOptions, + pub old: SetInfo, + pub new: SetInfo, + /// Namespaces that changed between the versions, old URI to new URI. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub namespace_map: BTreeMap, + pub summary: Summary, + /// Added, removed and changed global components (unchanged ones are left out). + pub components: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub renames: Vec, +} + +/// Compare two bundles: `old` is the earlier version. Compact bundles are +/// fine (the effective content models are not used). +pub fn diff(old: &Bundle, new: &Bundle, opts: &DiffOptions) -> SchemaDiff { + let mut cx = Cx { + a: old, + b: new, + fwd: HashMap::new(), + ns_map: HashMap::new(), + opts, + }; + let ga = globals(old); + let gb = globals(new); + let pairs = cx.match_globals(&ga, &gb); + let matched_b: HashSet = pairs.iter().map(|p| p.1).collect(); + let mut out = Vec::new(); + for &(o, n, by_local) in &pairs { + let changes = cx.compare(o, n); + if changes.is_empty() { + continue; + } + let impact = changes.iter().map(|c| c.impact).min().unwrap(); + let c = &new.components[n as usize]; + out.push(ComponentDiff { + kind: c.kind, + status: Status::Changed, + name: c.name.clone(), + namespace: ns_uri(new, c.ns).map(str::to_string), + old: Some(side(old, o)), + new: Some(side(new, n)), + by_local_name: by_local, + impact, + changes, + possibly_renamed: None, + }); + } + let mut removed = Vec::new(); + for &o in &ga { + if !cx.fwd.contains_key(&o) { + removed.push(o); + let c = &old.components[o as usize]; + out.push(ComponentDiff { + kind: c.kind, + status: Status::Removed, + name: c.name.clone(), + namespace: ns_uri(old, c.ns).map(str::to_string), + old: Some(side(old, o)), + new: None, + by_local_name: false, + impact: Impact::Breaking, + changes: Vec::new(), + possibly_renamed: None, + }); + } + } + let mut added = Vec::new(); + for &n in &gb { + if !matched_b.contains(&n) { + added.push(n); + let c = &new.components[n as usize]; + out.push(ComponentDiff { + kind: c.kind, + status: Status::Added, + name: c.name.clone(), + namespace: ns_uri(new, c.ns).map(str::to_string), + old: None, + new: Some(side(new, n)), + by_local_name: false, + impact: Impact::NonBreaking, + changes: Vec::new(), + possibly_renamed: None, + }); + } + } + let renames = if opts.renames { + cx.renames(&removed, &added) + } else { + Vec::new() + }; + let old_names: HashMap = renames + .iter() + .map(|r| (r.old.id, r.new.qname.as_str())) + .collect(); + let new_names: HashMap = renames + .iter() + .map(|r| (r.new.id, r.old.qname.as_str())) + .collect(); + for d in &mut out { + let found = match d.status { + Status::Removed => old_names.get(&d.old.as_ref().unwrap().id), + Status::Added => new_names.get(&d.new.as_ref().unwrap().id), + Status::Changed => None, + }; + d.possibly_renamed = found.map(|s| s.to_string()); + } + out.sort_by(|x, y| { + (x.qname().to_lowercase(), x.kind, x.status).cmp(&( + y.qname().to_lowercase(), + y.kind, + y.status, + )) + }); + // A reference to a type that was renamed does not change documents. + let pairs: HashSet<(&str, &str)> = renames + .iter() + .map(|r| (r.old.qname.as_str(), r.new.qname.as_str())) + .collect(); + for d in &mut out { + let mut any = false; + for c in &mut d.changes { + if matches!( + c.kind, + ChangeKind::TypeChanged + | ChangeKind::ChildRetyped + | ChangeKind::AttributeRetyped + | ChangeKind::BaseChanged + | ChangeKind::ItemTypeChanged + ) && let (Some(a), Some(b)) = (&c.old, &c.new) + && pairs.contains(&(a.as_str(), b.as_str())) + { + c.impact = Impact::NonBreaking; + any = true; + } + } + if any { + d.impact = d.changes.iter().map(|c| c.impact).min().unwrap(); + } + } + let mut summary = Summary::default(); + for d in &out { + summary.total.add(d); + summary.by_kind.entry(d.kind).or_default().add(d); + summary + .by_namespace + .entry( + d.namespace + .as_ref() + .map(|u| cx.ns_map.get(u).unwrap_or(u).clone()) + .unwrap_or_default(), + ) + .or_default() + .add(d); + summary + .by_file + .entry(d.file().unwrap_or("").to_string()) + .or_default() + .add(d); + for c in &d.changes { + *summary.changes.entry(c.kind).or_default() += 1; + if c.impact == Impact::Breaking { + summary.breaking_changes += 1; + } + } + if d.status == Status::Removed { + summary.breaking_changes += 1; + } + } + summary.possibly_renamed = renames.len() as u32; + let namespace_map = cx + .ns_map + .iter() + .filter(|(k, v)| k != v) + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + SchemaDiff { + format: DIFF_FORMAT, + generator: format!("schema-explorer-core {}", crate::version()), + options: opts.clone(), + old: set_info(old, ga.len()), + new: set_info(new, gb.len()), + namespace_map, + summary, + components: out, + renames, + } +} + +const COMPARED: [Kind; 6] = [ + Kind::ComplexType, + Kind::SimpleType, + Kind::Element, + Kind::Attribute, + Kind::Group, + Kind::AttributeGroup, +]; + +fn globals(b: &Bundle) -> Vec { + b.components + .iter() + .enumerate() + .filter(|(_, c)| { + c.parent.is_none() && !c.builtin && c.file.is_some() && COMPARED.contains(&c.kind) + }) + .map(|(i, _)| i as Id) + .collect() +} + +fn ns_uri(b: &Bundle, ns: Option) -> Option<&str> { + ns.map(|n| b.namespaces[n as usize].uri.as_str()) +} + +fn side(b: &Bundle, id: Id) -> Side { + let c = &b.components[id as usize]; + Side { + id, + qname: b.qname(id), + file: c.file.map(|f| b.files[f as usize].name.clone()), + line: c.line, + } +} + +fn set_info(b: &Bundle, components: usize) -> SetInfo { + let mut namespaces = BTreeSet::new(); + let mut versions = BTreeSet::new(); + let version = b + .files + .iter() + .filter(|f| f.version.is_some()) + .max_by_key(|f| f.components) + .and_then(|f| f.version.clone()); + for f in &b.files { + if let Some(ns) = &f.target_namespace { + namespaces.insert(ns.clone()); + } + if let Some(v) = &f.version { + versions.insert(v.clone()); + } + } + SetInfo { + title: None, + version, + files: b.files.len() as u32, + namespaces: namespaces.into_iter().collect(), + versions: versions.into_iter().collect(), + components: components as u32, + } +} + +/// Types share one symbol space. +fn space(k: Kind) -> u8 { + match k { + Kind::ComplexType | Kind::SimpleType => 0, + Kind::Element => 1, + Kind::Attribute => 2, + Kind::Group => 3, + Kind::AttributeGroup => 4, + _ => 5, + } +} + +fn docs_text(docs: &[Doc]) -> String { + docs.iter() + .map(|d| d.text.trim()) + .filter(|t| !t.is_empty()) + .collect::>() + .join("\n\n") +} + +fn normalize(s: &str) -> String { + s.split_whitespace().collect::>().join(" ") +} + +fn occurs_str(o: Occurs) -> String { + match o.max { + Some(m) => format!("{}..{m}", o.min), + None => format!("{}..*", o.min), + } +} + +/// The occurrence range of a particle inside a compositor that occurs `outer` times. +fn times(outer: Occurs, inner: Occurs) -> Occurs { + Occurs { + min: outer.min.saturating_mul(inner.min), + max: match (outer.max, inner.max) { + (Some(a), Some(b)) => Some(a.saturating_mul(b)), + _ => None, + }, + } +} + +/// True when `new` admits fewer occurrences than `old`. +fn tighter(old: Occurs, new: Occurs) -> bool { + let max_lower = match (old.max, new.max) { + (None, Some(_)) => true, + (Some(a), Some(b)) => b < a, + _ => false, + }; + new.min > old.min || max_lower +} + +struct Cx<'a> { + a: &'a Bundle, + b: &'a Bundle, + /// Matched global components, old id to new id. + fwd: HashMap, + /// Old namespace URI to new namespace URI. + ns_map: HashMap, + opts: &'a DiffOptions, +} + +/// One child particle of a flattened content model. +struct Slot { + key: String, + label: String, + el: Option, + /// In effect: multiplied by the compositors around it. + occurs: Occurs, + /// Optional in effect: itself, a compositor around it, or a choice. + optional: bool, + /// The compositors from the top, e.g. `sequence/choice`. + cpath: String, +} + +#[derive(Clone)] +struct AttrSlot { + label: String, + decl: Option, + group: bool, + r#use: AttrUse, + default: Option, + fixed: Option, +} + +impl<'a> Cx<'a> { + fn bundle(&self, old: bool) -> &'a Bundle { + if old { self.a } else { self.b } + } + + fn comp(&self, old: bool, id: Id) -> &'a Component { + &self.bundle(old).components[id as usize] + } + + /// The namespace URI as the new version names it. + fn uri(&self, old: bool, ns: Option) -> String { + let u = ns_uri(self.bundle(old), ns).unwrap_or(""); + if old { + self.ns_map.get(u).cloned().unwrap_or_else(|| u.to_string()) + } else { + u.to_string() + } + } + + fn match_globals(&mut self, ga: &[Id], gb: &[Id]) -> Vec<(Id, Id, bool)> { + let mut by_qname: HashMap<(u8, &str, &str), Id> = HashMap::new(); + for &n in gb { + let c = self.comp(false, n); + by_qname.insert( + (space(c.kind), ns_uri(self.b, c.ns).unwrap_or(""), &c.name), + n, + ); + } + let mut pairs = Vec::new(); + let mut used: HashSet = HashSet::new(); + for &o in ga { + let c = self.comp(true, o); + let k = ( + space(c.kind), + ns_uri(self.a, c.ns).unwrap_or(""), + c.name.as_str(), + ); + if let Some(&n) = by_qname.get(&k) + && used.insert(n) + { + pairs.push((o, n, false)); + } + } + for &(o, n, _) in &pairs { + self.fwd.insert(o, n); + } + if self.opts.match_local_names { + // Unique local names among the unmatched components on both sides. + let mut left: HashMap<(u8, &str), Vec> = HashMap::new(); + for &o in ga { + if !self.fwd.contains_key(&o) { + let c = self.comp(true, o); + left.entry((space(c.kind), &c.name)).or_default().push(o); + } + } + let mut right: HashMap<(u8, &str), Vec> = HashMap::new(); + for &n in gb { + if !used.contains(&n) { + let c = self.comp(false, n); + right.entry((space(c.kind), &c.name)).or_default().push(n); + } + } + let mut extra = Vec::new(); + for (k, os) in &left { + if let (Some(ns), [o]) = (right.get(k), os.as_slice()) + && let [n] = ns.as_slice() + && self + .comp(true, *o) + .ns + .map(|x| &self.a.namespaces[x as usize].uri) + != self + .comp(false, *n) + .ns + .map(|x| &self.b.namespaces[x as usize].uri) + { + extra.push((*o, *n, true)); + } + } + extra.sort(); + // The namespace map: the most frequent counterpart of each old namespace. + let mut votes: HashMap<(String, String), u32> = HashMap::new(); + for &(o, n, _) in &extra { + let ou = ns_uri(self.a, self.comp(true, o).ns) + .unwrap_or("") + .to_string(); + let nu = ns_uri(self.b, self.comp(false, n).ns) + .unwrap_or("") + .to_string(); + *votes.entry((ou, nu)).or_default() += 1; + } + let mut best: HashMap = HashMap::new(); + for ((ou, nu), v) in votes { + let e = best.entry(ou).or_insert((0, String::new())); + if v > e.0 || (v == e.0 && nu < e.1) { + *e = (v, nu); + } + } + self.ns_map = best.into_iter().map(|(k, (_, v))| (k, v)).collect(); + for &(o, n, _) in &extra { + self.fwd.insert(o, n); + } + pairs.extend(extra); + } + pairs + } + + /// A key for a reference, the same on both sides when it names the same + /// (matched) component. Anonymous types give `None`. + fn ref_key(&self, old: bool, r: &Ref) -> Option { + match r { + Ref::Id(id) => { + let c = self.comp(old, *id); + if c.parent.is_some() { + return None; + } + if old && let Some(&n) = self.fwd.get(id) { + return self.ref_key(false, &Ref::Id(n)); + } + Some(format!("{{{}}}{}", self.uri(old, c.ns), c.name)) + } + Ref::Missing(s) => { + if old + && let Some(rest) = s.strip_prefix('{') + && let Some((u, local)) = rest.split_once('}') + && let Some(m) = self.ns_map.get(u) + { + return Some(format!("{{{m}}}{local}")); + } + Some(s.clone()) + } + } + } + + fn label(&self, old: bool, r: &Ref) -> String { + match r { + Ref::Id(id) => { + let c = self.comp(old, *id); + if c.parent.is_some() && c.kind.is_type() { + "(anonymous type)".to_string() + } else { + self.bundle(old).qname(*id) + } + } + Ref::Missing(s) => s.clone(), + } + } + + /// The key of an element or attribute declaration (local or global) by + /// its name and namespace. + fn decl_key(&self, old: bool, id: Id) -> String { + let c = self.comp(old, id); + if c.parent.is_none() { + return self.ref_key(old, &Ref::Id(id)).unwrap_or_default(); + } + format!("{{{}}}{}", self.uri(old, c.ns), c.name) + } + + fn decl_label(&self, old: bool, id: Id) -> String { + let c = self.comp(old, id); + if c.parent.is_none() { + self.bundle(old).qname(id) + } else { + c.name.clone() + } + } + + fn compare(&self, o: Id, n: Id) -> Vec { + let mut out = Vec::new(); + let (co, cn) = (self.comp(true, o), self.comp(false, n)); + self.docs(&co.docs, &cn.docs, "", "", &mut out); + match cn.kind { + Kind::ComplexType | Kind::SimpleType => self.types(o, n, "", 0, &mut out), + Kind::Element => self.element(o, n, "", 0, &mut out), + Kind::Attribute => self.attribute(o, n, "", 0, &mut out), + Kind::Group => self.particles(co.group.as_ref(), cn.group.as_ref(), "", 0, &mut out), + Kind::AttributeGroup => { + let (x, y) = ( + co.attribute_group.clone().unwrap_or_default(), + cn.attribute_group.clone().unwrap_or_default(), + ); + self.attrs( + (&x.attributes, &x.any_attribute), + (&y.attributes, &y.any_attribute), + "", + 0, + &mut out, + ); + } + _ => {} + } + out + } + + fn docs(&self, o: &[Doc], n: &[Doc], path: &str, subject: &str, out: &mut Vec) { + if !self.opts.docs { + return; + } + let (x, y) = (docs_text(o), docs_text(n)); + if normalize(&x) != normalize(&y) { + out.push(Change { + kind: ChangeKind::DocsChanged, + impact: Impact::Info, + path: path.to_string(), + subject: subject.to_string(), + old: (!x.is_empty()).then_some(x), + new: (!y.is_empty()).then_some(y), + }); + } + } + + #[allow(clippy::too_many_arguments)] + fn push( + &self, + out: &mut Vec, + kind: ChangeKind, + impact: Impact, + path: &str, + subject: &str, + old: Option, + new: Option, + ) { + out.push(Change { + kind, + impact, + path: path.to_string(), + subject: subject.to_string(), + old, + new, + }); + } + + /// Compare type references; anonymous types on both sides are compared + /// in depth under `path`. + #[allow(clippy::too_many_arguments)] + fn type_ref( + &self, + o: Option<&Ref>, + n: Option<&Ref>, + kind: ChangeKind, + path: &str, + subject: &str, + depth: u32, + out: &mut Vec, + ) { + let any = Ref::Missing("{http://www.w3.org/2001/XMLSchema}anyType".into()); + let (o, n) = (o.unwrap_or(&any), n.unwrap_or(&any)); + let (ko, kn) = (self.ref_key(true, o), self.ref_key(false, n)); + match (ko, kn) { + (None, None) => { + if let (Some(a), Some(b)) = (o.id(), n.id()) { + let sub = if subject.is_empty() { + path.to_string() + } else if path.is_empty() { + subject.to_string() + } else { + format!("{path}/{subject}") + }; + self.types(a, b, &sub, depth + 1, out); + } + } + (x, y) if x == y => {} + _ => self.push( + out, + kind, + Impact::Breaking, + path, + subject, + Some(self.label(true, o)), + Some(self.label(false, n)), + ), + } + } + + fn types(&self, o: Id, n: Id, path: &str, depth: u32, out: &mut Vec) { + if depth > MAX_DEPTH { + return; + } + let (co, cn) = (self.comp(true, o), self.comp(false, n)); + if depth > 0 { + self.docs(&co.docs, &cn.docs, path, "", out); + } + match ( + &co.complex_type, + &cn.complex_type, + &co.simple_type, + &cn.simple_type, + ) { + (Some(x), Some(y), _, _) => self.complex(x, y, path, depth, out), + (_, _, Some(x), Some(y)) => self.simple(x, y, path, depth, out), + _ => self.push( + out, + ChangeKind::KindChanged, + Impact::Breaking, + path, + "", + Some(kind_name(co.kind).into()), + Some(kind_name(cn.kind).into()), + ), + } + } + + fn complex( + &self, + x: &ComplexInfo, + y: &ComplexInfo, + path: &str, + depth: u32, + out: &mut Vec, + ) { + match (&x.derivation, &y.derivation) { + (None, None) => {} + (Some(a), Some(b)) => { + if (a.method, a.simple) != (b.method, b.simple) { + self.push( + out, + ChangeKind::DerivationChanged, + Impact::Breaking, + path, + "", + Some(derivation_name(a)), + Some(derivation_name(b)), + ); + } + if self.ref_key(true, &a.base) != self.ref_key(false, &b.base) { + self.push( + out, + ChangeKind::BaseChanged, + Impact::Breaking, + path, + "", + Some(self.label(true, &a.base)), + Some(self.label(false, &b.base)), + ); + } + } + (a, b) => self.push( + out, + ChangeKind::BaseChanged, + Impact::Breaking, + path, + "", + a.as_ref().map(|d| self.label(true, &d.base)), + b.as_ref().map(|d| self.label(false, &d.base)), + ), + } + self.flag( + out, + ChangeKind::AbstractChanged, + path, + x.r#abstract, + y.r#abstract, + true, + ); + self.flag(out, ChangeKind::MixedChanged, path, x.mixed, y.mixed, false); + self.final_block(out, ChangeKind::FinalChanged, path, &x.r#final, &y.r#final); + self.final_block(out, ChangeKind::BlockChanged, path, &x.block, &y.block); + self.particles(x.content.as_ref(), y.content.as_ref(), path, depth, out); + self.attrs( + (&x.attributes, &x.any_attribute), + (&y.attributes, &y.any_attribute), + path, + depth, + out, + ); + self.facets(&x.facets, &y.facets, path, out); + self.enums(&x.enumerations, &y.enumerations, path, out); + for a in x.asserts.iter().filter(|a| !y.asserts.contains(a)) { + self.push( + out, + ChangeKind::AssertRemoved, + Impact::NonBreaking, + path, + a, + None, + None, + ); + } + for a in y.asserts.iter().filter(|a| !x.asserts.contains(a)) { + self.push( + out, + ChangeKind::AssertAdded, + Impact::Breaking, + path, + a, + None, + None, + ); + } + } + + /// A boolean flag; `breaking_on` is the new value that breaks documents. + fn flag( + &self, + out: &mut Vec, + kind: ChangeKind, + path: &str, + x: bool, + y: bool, + breaking_on: bool, + ) { + if x != y { + let impact = if y == breaking_on { + Impact::Breaking + } else { + Impact::NonBreaking + }; + self.push( + out, + kind, + impact, + path, + "", + Some(x.to_string()), + Some(y.to_string()), + ); + } + } + + fn final_block( + &self, + out: &mut Vec, + kind: ChangeKind, + path: &str, + x: &Option, + y: &Option, + ) { + if x != y { + self.push( + out, + kind, + Impact::NonBreaking, + path, + "", + x.clone(), + y.clone(), + ); + } + } + + fn simple( + &self, + x: &SimpleInfo, + y: &SimpleInfo, + path: &str, + depth: u32, + out: &mut Vec, + ) { + if x.variety != y.variety { + self.push( + out, + ChangeKind::VarietyChanged, + Impact::Breaking, + path, + "", + Some(format!("{:?}", x.variety).to_lowercase()), + Some(format!("{:?}", y.variety).to_lowercase()), + ); + return; + } + if x.base.is_some() || y.base.is_some() { + self.type_ref( + x.base.as_ref(), + y.base.as_ref(), + ChangeKind::BaseChanged, + path, + "", + depth, + out, + ); + } + if x.item.is_some() || y.item.is_some() { + self.type_ref( + x.item.as_ref(), + y.item.as_ref(), + ChangeKind::ItemTypeChanged, + path, + "", + depth, + out, + ); + } + let ko: Vec<(Option, &Ref)> = x + .members + .iter() + .map(|r| (self.ref_key(true, r), r)) + .collect(); + let kn: Vec<(Option, &Ref)> = y + .members + .iter() + .map(|r| (self.ref_key(false, r), r)) + .collect(); + for (k, r) in &ko { + if k.is_some() && !kn.iter().any(|(j, _)| j == k) { + self.push( + out, + ChangeKind::MemberTypeRemoved, + Impact::Breaking, + path, + &self.label(true, r), + None, + None, + ); + } + } + for (k, r) in &kn { + if k.is_some() && !ko.iter().any(|(j, _)| j == k) { + self.push( + out, + ChangeKind::MemberTypeAdded, + Impact::NonBreaking, + path, + &self.label(false, r), + None, + None, + ); + } + } + let anon_o: Vec = ko + .iter() + .filter(|(k, _)| k.is_none()) + .filter_map(|(_, r)| r.id()) + .collect(); + let anon_n: Vec = kn + .iter() + .filter(|(k, _)| k.is_none()) + .filter_map(|(_, r)| r.id()) + .collect(); + for (i, (a, b)) in anon_o.iter().zip(&anon_n).enumerate() { + self.types( + *a, + *b, + &join(path, &format!("member {}", i + 1)), + depth + 1, + out, + ); + } + self.facets(&x.facets, &y.facets, path, out); + self.enums(&x.enumerations, &y.enumerations, path, out); + self.final_block(out, ChangeKind::FinalChanged, path, &x.r#final, &y.r#final); + } + + fn enums(&self, x: &[Enumeration], y: &[Enumeration], path: &str, out: &mut Vec) { + let nv: HashMap<&str, &Enumeration> = y.iter().map(|e| (e.value.as_str(), e)).collect(); + let ov: HashMap<&str, &Enumeration> = x.iter().map(|e| (e.value.as_str(), e)).collect(); + for e in x { + match nv.get(e.value.as_str()) { + None => self.push( + out, + ChangeKind::EnumerationRemoved, + Impact::Breaking, + path, + &e.value, + None, + None, + ), + Some(f) => self.docs(&e.docs, &f.docs, path, &e.value, out), + } + } + for e in y { + if !ov.contains_key(e.value.as_str()) { + // Adding values to an unrestricted type would restrict it. + let impact = if x.is_empty() { + Impact::Breaking + } else { + Impact::NonBreaking + }; + self.push( + out, + ChangeKind::EnumerationAdded, + impact, + path, + &e.value, + None, + None, + ); + } + } + } + + fn facets(&self, x: &[Facet], y: &[Facet], path: &str, out: &mut Vec) { + let group = |fs: &[Facet]| { + let mut m: BTreeMap> = BTreeMap::new(); + for f in fs { + m.entry(f.kind.clone()).or_default().push(f.value.clone()); + } + m + }; + let (gx, gy) = (group(x), group(y)); + let kinds: BTreeSet<&String> = gx.keys().chain(gy.keys()).collect(); + for k in kinds { + match (gx.get(k), gy.get(k)) { + (Some(a), None) => self.push( + out, + ChangeKind::FacetRemoved, + Impact::NonBreaking, + path, + k, + Some(a.join(" | ")), + None, + ), + (None, Some(b)) => self.push( + out, + ChangeKind::FacetAdded, + Impact::Breaking, + path, + k, + None, + Some(b.join(" | ")), + ), + (Some(a), Some(b)) if a != b => { + let impact = facet_impact(k, a, b); + self.push( + out, + ChangeKind::FacetChanged, + impact, + path, + k, + Some(a.join(" | ")), + Some(b.join(" | ")), + ); + } + _ => {} + } + } + } + + fn element(&self, o: Id, n: Id, path: &str, depth: u32, out: &mut Vec) { + let (co, cn) = (self.comp(true, o), self.comp(false, n)); + let (Some(x), Some(y)) = (&co.element, &cn.element) else { + return; + }; + self.type_ref( + x.r#type.as_ref(), + y.r#type.as_ref(), + ChangeKind::TypeChanged, + path, + "", + depth, + out, + ); + self.flag( + out, + ChangeKind::AbstractChanged, + path, + x.r#abstract, + y.r#abstract, + true, + ); + self.flag( + out, + ChangeKind::NillableChanged, + path, + x.nillable, + y.nillable, + false, + ); + if x.default != y.default { + self.push( + out, + ChangeKind::ValueConstraintChanged, + Impact::NonBreaking, + path, + "default", + x.default.clone(), + y.default.clone(), + ); + } + if x.fixed != y.fixed { + let impact = if y.fixed.is_some() { + Impact::Breaking + } else { + Impact::NonBreaking + }; + self.push( + out, + ChangeKind::ValueConstraintChanged, + impact, + path, + "fixed", + x.fixed.clone(), + y.fixed.clone(), + ); + } + self.final_block(out, ChangeKind::FinalChanged, path, &x.r#final, &y.r#final); + self.final_block(out, ChangeKind::BlockChanged, path, &x.block, &y.block); + if co.parent.is_some() { + return; + } + // Substitution groups. + let heads = |old: bool, e: &ElementInfo| -> BTreeMap { + e.substitution_group + .iter() + .map(|r| (self.ref_key(old, r).unwrap_or_default(), self.label(old, r))) + .collect() + }; + let (ho, hn) = (heads(true, x), heads(false, y)); + if ho.keys().ne(hn.keys()) { + let lost = ho.keys().any(|k| !hn.contains_key(k)); + let impact = if lost { + Impact::Breaking + } else { + Impact::NonBreaking + }; + let j = |m: &BTreeMap| { + (!m.is_empty()).then(|| m.values().cloned().collect::>().join(", ")) + }; + self.push( + out, + ChangeKind::SubstitutionGroupChanged, + impact, + path, + "", + j(&ho), + j(&hn), + ); + } + let members = |old: bool, e: &ElementInfo| -> BTreeMap { + e.members + .iter() + .map(|&m| (self.decl_key(old, m), self.bundle(old).qname(m))) + .collect() + }; + let (mo, mn) = (members(true, x), members(false, y)); + for (k, l) in &mo { + if !mn.contains_key(k) { + self.push( + out, + ChangeKind::SubstitutionMemberRemoved, + Impact::Breaking, + path, + l, + None, + None, + ); + } + } + for (k, l) in &mn { + if !mo.contains_key(k) { + self.push( + out, + ChangeKind::SubstitutionMemberAdded, + Impact::NonBreaking, + path, + l, + None, + None, + ); + } + } + } + + fn attribute(&self, o: Id, n: Id, path: &str, depth: u32, out: &mut Vec) { + let (co, cn) = (self.comp(true, o), self.comp(false, n)); + let x = co.attribute.clone().unwrap_or_default(); + let y = cn.attribute.clone().unwrap_or_default(); + self.type_ref( + x.r#type.as_ref(), + y.r#type.as_ref(), + ChangeKind::TypeChanged, + path, + "", + depth, + out, + ); + if x.default != y.default { + self.push( + out, + ChangeKind::AttributeDefaultChanged, + Impact::NonBreaking, + path, + "", + x.default, + y.default, + ); + } + if x.fixed != y.fixed { + let impact = if y.fixed.is_some() { + Impact::Breaking + } else { + Impact::NonBreaking + }; + self.push( + out, + ChangeKind::AttributeFixedChanged, + impact, + path, + "", + x.fixed, + y.fixed, + ); + } + } + + fn flatten( + &self, + old: bool, + p: &Particle, + cpath: &str, + mul: Occurs, + out: &mut Vec, + seen: &mut HashMap, + ) { + let mut slot = |key: String, label: String, el: Option, occurs: Occurs| { + let n = seen.entry(key.clone()).or_default(); + *n += 1; + let key = if *n > 1 { format!("{key}#{n}") } else { key }; + out.push(Slot { + key, + label, + el, + occurs: times(mul, occurs), + optional: mul.min == 0 || occurs.min == 0, + cpath: cpath.to_string(), + }); + }; + match p { + Particle::Element { el, occurs } => { + let (key, label, id) = match el { + Ref::Id(id) => ( + self.decl_key(old, *id), + self.decl_label(old, *id), + Some(*id), + ), + Ref::Missing(_) => ( + self.ref_key(old, el).unwrap_or_default(), + self.label(old, el), + None, + ), + }; + slot(key, label, id, *occurs); + } + Particle::Group { group, occurs } => { + let key = format!("group {}", self.ref_key(old, group).unwrap_or_default()); + slot( + key, + format!("group {}", self.label(old, group)), + None, + *occurs, + ); + } + Particle::Any { wildcard, occurs } => { + let ns = wildcard.namespace.clone().unwrap_or_else(|| "##any".into()); + slot(format!("any {ns}"), format!("any ({ns})"), None, *occurs); + } + Particle::Sequence { items, occurs } + | Particle::Choice { items, occurs } + | Particle::All { items, occurs } => { + let name = compositor(p); + let sub = if cpath.is_empty() { + name.to_string() + } else { + format!("{cpath}/{name}") + }; + let mut m = times(mul, *occurs); + if matches!(p, Particle::Choice { .. }) && items.len() > 1 { + m.min = 0; + } + for i in items { + self.flatten(old, i, &sub, m, out, seen); + } + } + } + } + + fn particles( + &self, + x: Option<&Particle>, + y: Option<&Particle>, + path: &str, + depth: u32, + out: &mut Vec, + ) { + let mut so = Vec::new(); + let mut sn = Vec::new(); + if let Some(p) = x { + self.flatten(true, p, "", Occurs::default(), &mut so, &mut HashMap::new()); + } + if let Some(p) = y { + self.flatten( + false, + p, + "", + Occurs::default(), + &mut sn, + &mut HashMap::new(), + ); + } + let (sko, skn) = ( + x.map(skeleton).unwrap_or_default(), + y.map(skeleton).unwrap_or_default(), + ); + let mn: HashMap<&str, &Slot> = sn.iter().map(|s| (s.key.as_str(), s)).collect(); + let mo: HashMap<&str, &Slot> = so.iter().map(|s| (s.key.as_str(), s)).collect(); + let mut moved = false; + let mut items = Vec::new(); + for s in &so { + let Some(t) = mn.get(s.key.as_str()) else { + items.push(Change { + kind: ChangeKind::ChildRemoved, + impact: Impact::Breaking, + path: path.to_string(), + subject: s.label.clone(), + old: Some(occurs_str(s.occurs)), + new: None, + }); + continue; + }; + if s.cpath != t.cpath { + moved = true; + self.push( + &mut items, + ChangeKind::ChildMoved, + Impact::Breaking, + path, + &t.label, + Some(s.cpath.clone()), + Some(t.cpath.clone()), + ); + } + if s.occurs != t.occurs { + let impact = if tighter(s.occurs, t.occurs) { + Impact::Breaking + } else { + Impact::NonBreaking + }; + self.push( + &mut items, + ChangeKind::ChildCardinalityChanged, + impact, + path, + &t.label, + Some(occurs_str(s.occurs)), + Some(occurs_str(t.occurs)), + ); + } + if let (Some(a), Some(b)) = (s.el, t.el) { + self.child(a, b, path, &t.label, depth, &mut items); + } + } + for t in &sn { + if !mo.contains_key(t.key.as_str()) { + let impact = if t.optional { + Impact::NonBreaking + } else { + Impact::Breaking + }; + items.push(Change { + kind: ChangeKind::ChildAdded, + impact, + path: path.to_string(), + subject: t.label.clone(), + old: None, + new: Some(occurs_str(t.occurs)), + }); + } + } + // Order within each sequence. + let mut seqs: BTreeMap<&str, (Vec<&str>, Vec<&str>)> = BTreeMap::new(); + for s in &so { + if let Some(t) = mn.get(s.key.as_str()) + && t.cpath == s.cpath + && s.cpath.ends_with("sequence") + { + seqs.entry(&s.cpath).or_default().0.push(&s.key); + } + } + for t in &sn { + if let Some(s) = mo.get(t.key.as_str()) + && t.cpath == s.cpath + && t.cpath.ends_with("sequence") + { + seqs.entry(&t.cpath).or_default().1.push(&t.key); + } + } + for (a, b) in seqs.values() { + if a != b { + let keep = lcs(a, b); + let label = |k: &str| mn[k].label.clone(); + let off: Vec = b + .iter() + .filter(|k| !keep.contains(*k)) + .map(|k| label(k)) + .collect(); + let lo: Vec = a.iter().map(|k| label(k)).collect(); + let ln: Vec = b.iter().map(|k| label(k)).collect(); + self.push( + &mut items, + ChangeKind::ChildrenReordered, + Impact::Breaking, + path, + &off.join(", "), + Some(lo.join(", ")), + Some(ln.join(", ")), + ); + } + } + if sko != skn { + let impact = if moved + || top_compositor(&sko) != top_compositor(&skn) && !so.is_empty() && !sn.is_empty() + { + Impact::Breaking + } else { + Impact::NonBreaking + }; + out.push(Change { + kind: ChangeKind::StructureChanged, + impact, + path: path.to_string(), + subject: String::new(), + old: (!sko.is_empty()).then_some(sko), + new: (!skn.is_empty()).then_some(skn), + }); + } + out.extend(items); + } + + /// Compare the declarations behind a child present in both versions. + fn child(&self, a: Id, b: Id, path: &str, label: &str, depth: u32, out: &mut Vec) { + let (ca, cb) = (self.comp(true, a), self.comp(false, b)); + // A reference to a global element on both sides: compared on its own. + if ca.parent.is_none() && cb.parent.is_none() { + return; + } + let sub = join(path, label); + if ca.parent.is_some() && cb.parent.is_some() { + self.docs(&ca.docs, &cb.docs, path, label, out); + } + let (Some(x), Some(y)) = (&ca.element, &cb.element) else { + return; + }; + self.type_ref( + x.r#type.as_ref(), + y.r#type.as_ref(), + ChangeKind::ChildRetyped, + path, + label, + depth, + out, + ); + self.flag( + out, + ChangeKind::NillableChanged, + &sub, + x.nillable, + y.nillable, + false, + ); + if x.fixed != y.fixed { + let impact = if y.fixed.is_some() { + Impact::Breaking + } else { + Impact::NonBreaking + }; + self.push( + out, + ChangeKind::ValueConstraintChanged, + impact, + &sub, + "fixed", + x.fixed.clone(), + y.fixed.clone(), + ); + } + if x.default != y.default { + self.push( + out, + ChangeKind::ValueConstraintChanged, + Impact::NonBreaking, + &sub, + "default", + x.default.clone(), + y.default.clone(), + ); + } + } + + fn attr_slots(&self, old: bool, items: &[AttrItem]) -> Vec<(String, AttrSlot)> { + items + .iter() + .map(|i| match i { + AttrItem::Attribute { + attr, + r#use, + default, + fixed, + } => { + let (key, label, decl) = match attr { + Ref::Id(id) => ( + format!("@{}", self.decl_key(old, *id)), + format!("@{}", self.decl_label(old, *id)), + Some(*id), + ), + Ref::Missing(_) => ( + format!("@{}", self.ref_key(old, attr).unwrap_or_default()), + format!("@{}", self.label(old, attr)), + None, + ), + }; + let dd = decl.and_then(|d| self.comp(old, d).attribute.as_ref()); + ( + key, + AttrSlot { + label, + decl, + group: false, + r#use: *r#use, + default: default + .clone() + .or_else(|| dd.and_then(|a| a.default.clone())), + fixed: fixed.clone().or_else(|| dd.and_then(|a| a.fixed.clone())), + }, + ) + } + AttrItem::Group { group } => ( + format!("group {}", self.ref_key(old, group).unwrap_or_default()), + AttrSlot { + label: format!("attributeGroup {}", self.label(old, group)), + decl: group.id(), + group: true, + r#use: AttrUse::Optional, + default: None, + fixed: None, + }, + ), + }) + .collect() + } + + /// Whether an attribute group (new version) has a required attribute. + fn group_required(&self, id: Id) -> bool { + self.comp(false, id) + .attribute_group + .as_ref() + .is_some_and(|g| { + g.attributes.iter().any(|a| { + matches!( + a, + AttrItem::Attribute { + r#use: AttrUse::Required, + .. + } + ) + }) + }) + } + + #[allow(clippy::type_complexity)] + fn attrs( + &self, + x: (&[AttrItem], &Option), + y: (&[AttrItem], &Option), + path: &str, + depth: u32, + out: &mut Vec, + ) { + let so = self.attr_slots(true, x.0); + let sn = self.attr_slots(false, y.0); + let mn: HashMap<&str, &AttrSlot> = sn.iter().map(|(k, s)| (k.as_str(), s)).collect(); + let mo: HashMap<&str, &AttrSlot> = so.iter().map(|(k, s)| (k.as_str(), s)).collect(); + for (k, s) in &so { + let Some(t) = mn.get(k.as_str()) else { + self.push( + out, + ChangeKind::AttributeRemoved, + Impact::Breaking, + path, + &s.label, + Some(use_name(s.r#use).into()), + None, + ); + continue; + }; + if s.group { + continue; + } + let label = &t.label; + if s.r#use != t.r#use { + let impact = if t.r#use == AttrUse::Optional { + Impact::NonBreaking + } else { + Impact::Breaking + }; + self.push( + out, + ChangeKind::AttributeUseChanged, + impact, + path, + label, + Some(use_name(s.r#use).into()), + Some(use_name(t.r#use).into()), + ); + } + if s.default != t.default { + self.push( + out, + ChangeKind::AttributeDefaultChanged, + Impact::NonBreaking, + path, + label, + s.default.clone(), + t.default.clone(), + ); + } + if s.fixed != t.fixed { + let impact = if t.fixed.is_some() { + Impact::Breaking + } else { + Impact::NonBreaking + }; + self.push( + out, + ChangeKind::AttributeFixedChanged, + impact, + path, + label, + s.fixed.clone(), + t.fixed.clone(), + ); + } + if let (Some(a), Some(b)) = (s.decl, t.decl) { + let (ca, cb) = (self.comp(true, a), self.comp(false, b)); + if ca.parent.is_some() || cb.parent.is_some() { + if ca.parent.is_some() && cb.parent.is_some() { + self.docs(&ca.docs, &cb.docs, path, label, out); + } + let (ta, tb) = ( + ca.attribute.as_ref().and_then(|i| i.r#type.as_ref()), + cb.attribute.as_ref().and_then(|i| i.r#type.as_ref()), + ); + self.type_ref( + ta, + tb, + ChangeKind::AttributeRetyped, + path, + label, + depth, + out, + ); + } + } + } + for (k, t) in &sn { + if !mo.contains_key(k.as_str()) { + let required = if t.group { + t.decl.is_some_and(|g| self.group_required(g)) + } else { + t.r#use == AttrUse::Required + }; + let impact = if required { + Impact::Breaking + } else { + Impact::NonBreaking + }; + self.push( + out, + ChangeKind::AttributeAdded, + impact, + path, + &t.label, + None, + Some(use_name(t.r#use).into()), + ); + } + } + if x.1 != y.1 { + let impact = if y.1.is_none() { + Impact::Breaking + } else if x.1.is_none() { + Impact::NonBreaking + } else { + Impact::Breaking + }; + let w = |w: &Option| w.as_ref().map(wildcard_str); + self.push( + out, + ChangeKind::AnyAttributeChanged, + impact, + path, + "", + w(x.1), + w(y.1), + ); + } + } + + /// Pair removed and added components with near-identical content. + fn renames(&self, removed: &[Id], added: &[Id]) -> Vec { + let tokens = |old: bool, id: Id| -> (Kind, BTreeSet, String) { + let c = self.comp(old, id); + let mut t = BTreeSet::new(); + let local = |r: &Ref, pre: &str| { + let s = self.label(old, r); + let n = s.rsplit(':').next().unwrap_or(&s).to_string(); + format!("{pre}{n}") + }; + if let Some(ct) = &c.complex_type { + if let Some(d) = &ct.derivation { + t.insert(local(&d.base, "base ")); + } + let mut slots = Vec::new(); + if let Some(p) = &ct.content { + self.flatten( + old, + p, + "", + Occurs::default(), + &mut slots, + &mut HashMap::new(), + ); + } + for s in slots { + t.insert(format!( + "child {}", + s.label.rsplit(':').next().unwrap_or(&s.label) + )); + } + for (_, s) in self.attr_slots(old, &ct.attributes) { + t.insert(s.label.rsplit(':').next().unwrap_or(&s.label).to_string()); + } + for e in &ct.enumerations { + t.insert(format!("enum {}", e.value)); + } + } + if let Some(st) = &c.simple_type { + if let Some(b) = &st.base { + t.insert(local(b, "base ")); + } + for e in &st.enumerations { + t.insert(format!("enum {}", e.value)); + } + for f in &st.facets { + t.insert(format!("{} {}", f.kind, f.value)); + } + } + if let Some(g) = &c.group { + let mut slots = Vec::new(); + self.flatten( + old, + g, + "", + Occurs::default(), + &mut slots, + &mut HashMap::new(), + ); + for s in slots { + t.insert(format!( + "child {}", + s.label.rsplit(':').next().unwrap_or(&s.label) + )); + } + } + if let Some(ag) = &c.attribute_group { + for (_, s) in self.attr_slots(old, &ag.attributes) { + t.insert(s.label.rsplit(':').next().unwrap_or(&s.label).to_string()); + } + } + if let Some(e) = &c.element + && let Some(r) = &e.r#type + { + t.insert(local(r, "type ")); + } + if let Some(a) = &c.attribute + && let Some(r) = &a.r#type + { + t.insert(local(r, "type ")); + } + (c.kind, t, normalize(&docs_text(&c.docs))) + }; + let ra: Vec<(Id, Tokens)> = removed.iter().map(|&o| (o, tokens(true, o))).collect(); + let rb: Vec<(Id, Tokens)> = added.iter().map(|&n| (n, tokens(false, n))).collect(); + let mut cands = Vec::new(); + for (o, (ko, to, dox)) in &ra { + for (n, (kn, tn, doy)) in &rb { + if ko != kn { + continue; + } + let inter = to.intersection(tn).count(); + let uni = to.union(tn).count(); + let score = (100 * inter).checked_div(uni).unwrap_or(0) as u32; + let same_docs = !dox.is_empty() && dox == doy; + let ok = (score >= 80 && to.len().min(tn.len()) >= 3) + || (same_docs && score >= 50) + || (same_docs && uni == 0); + if ok { + let s = if same_docs { score.max(90) } else { score }; + cands.push((s, *o, *n)); + } + } + } + cands.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)).then(a.2.cmp(&b.2))); + let (mut uo, mut un) = (HashSet::new(), HashSet::new()); + let mut out = Vec::new(); + for (score, o, n) in cands { + if uo.contains(&o) || un.contains(&n) { + continue; + } + uo.insert(o); + un.insert(n); + out.push(Rename { + kind: self.comp(false, n).kind, + old: side(self.a, o), + new: side(self.b, n), + score, + }); + } + out.sort_by(|a, b| a.old.qname.cmp(&b.old.qname)); + out + } +} + +/// Kind, content tokens and documentation of a component, to spot renames. +type Tokens = (Kind, BTreeSet, String); + +fn join(path: &str, name: &str) -> String { + if path.is_empty() { + name.to_string() + } else { + format!("{path}/{name}") + } +} + +fn compositor(p: &Particle) -> &'static str { + match p { + Particle::Sequence { .. } => "sequence", + Particle::Choice { .. } => "choice", + Particle::All { .. } => "all", + _ => "", + } +} + +/// The compositors of a content model, leaves left out: +/// `sequence(choice[0..1](), sequence())`. +fn skeleton(p: &Particle) -> String { + match p { + Particle::Sequence { items, occurs } + | Particle::Choice { items, occurs } + | Particle::All { items, occurs } => { + let mut leaves = 0; + p.walk(&mut |q| { + if matches!( + q, + Particle::Element { .. } | Particle::Group { .. } | Particle::Any { .. } + ) { + leaves += 1; + } + }); + if leaves == 0 { + return String::new(); + } + let inner: Vec = items + .iter() + .map(skeleton) + .filter(|s| !s.is_empty()) + .collect(); + let occ = if *occurs == Occurs::default() { + String::new() + } else { + format!("[{}]", occurs_str(*occurs)) + }; + format!("{}{occ}({})", compositor(p), inner.join(", ")) + } + _ => String::new(), + } +} + +fn top_compositor(s: &str) -> &str { + s.split(['(', '[']).next().unwrap_or("") +} + +fn lcs<'a>(a: &[&'a str], b: &[&'a str]) -> HashSet<&'a str> { + let (n, m) = (a.len(), b.len()); + let mut t = vec![vec![0u32; m + 1]; n + 1]; + for i in (0..n).rev() { + for j in (0..m).rev() { + t[i][j] = if a[i] == b[j] { + t[i + 1][j + 1] + 1 + } else { + t[i + 1][j].max(t[i][j + 1]) + }; + } + } + let (mut i, mut j) = (0, 0); + let mut out = HashSet::new(); + while i < n && j < m { + if a[i] == b[j] { + out.insert(a[i]); + i += 1; + j += 1; + } else if t[i + 1][j] >= t[i][j + 1] { + i += 1; + } else { + j += 1; + } + } + out +} + +fn facet_impact(kind: &str, a: &[String], b: &[String]) -> Impact { + let num = |v: &[String]| -> Option { + match v { + [x] => x.trim().parse().ok(), + _ => None, + } + }; + let (Some(x), Some(y)) = (num(a), num(b)) else { + return Impact::Breaking; + }; + let tighter = match kind { + "minLength" | "minInclusive" | "minExclusive" => y > x, + "maxLength" | "maxInclusive" | "maxExclusive" | "totalDigits" | "fractionDigits" => y < x, + _ => true, + }; + if tighter { + Impact::Breaking + } else { + Impact::NonBreaking + } +} + +fn wildcard_str(w: &Wildcard) -> String { + let mut s = w.namespace.clone().unwrap_or_else(|| "##any".into()); + if let Some(n) = &w.not_namespace { + s = format!("not {n}"); + } + if let Some(p) = &w.process_contents { + s.push_str(&format!(" ({p})")); + } + s +} + +fn use_name(u: AttrUse) -> &'static str { + match u { + AttrUse::Optional => "optional", + AttrUse::Required => "required", + AttrUse::Prohibited => "prohibited", + } +} + +fn derivation_name(d: &Derivation) -> String { + let m = match d.method { + Method::Extension => "extension", + Method::Restriction => "restriction", + }; + format!( + "{m} ({} content)", + if d.simple { "simple" } else { "complex" } + ) +} + +/// The XSD name of a kind: `complexType`, `element`, ... +pub fn kind_name(k: Kind) -> &'static str { + match k { + Kind::Element => "element", + Kind::ComplexType => "complexType", + Kind::SimpleType => "simpleType", + Kind::Attribute => "attribute", + Kind::AttributeGroup => "attributeGroup", + Kind::Group => "group", + Kind::Key => "key", + Kind::Keyref => "keyref", + Kind::Unique => "unique", + } +} + +/// A readable label of a change kind: "child added", ... +pub fn change_label(k: ChangeKind) -> &'static str { + use ChangeKind::*; + match k { + KindChanged => "kind changed", + BaseChanged => "base type changed", + DerivationChanged => "derivation changed", + AbstractChanged => "abstract changed", + FinalChanged => "final changed", + BlockChanged => "block changed", + MixedChanged => "mixed changed", + NillableChanged => "nillable changed", + TypeChanged => "type changed", + ValueConstraintChanged => "value constraint changed", + SubstitutionGroupChanged => "substitution group changed", + SubstitutionMemberAdded => "substitution member added", + SubstitutionMemberRemoved => "substitution member removed", + ChildAdded => "child added", + ChildRemoved => "child removed", + ChildRetyped => "child retyped", + ChildCardinalityChanged => "cardinality changed", + ChildrenReordered => "children reordered", + ChildMoved => "child moved", + StructureChanged => "structure changed", + AttributeAdded => "attribute added", + AttributeRemoved => "attribute removed", + AttributeRetyped => "attribute retyped", + AttributeUseChanged => "attribute use changed", + AttributeDefaultChanged => "attribute default changed", + AttributeFixedChanged => "attribute fixed value changed", + AnyAttributeChanged => "anyAttribute changed", + EnumerationAdded => "enumeration value added", + EnumerationRemoved => "enumeration value removed", + FacetAdded => "facet added", + FacetRemoved => "facet removed", + FacetChanged => "facet changed", + VarietyChanged => "variety changed", + ItemTypeChanged => "list item type changed", + MemberTypeAdded => "union member added", + MemberTypeRemoved => "union member removed", + AssertAdded => "assertion added", + AssertRemoved => "assertion removed", + DocsChanged => "documentation changed", + } +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index bc1d457..c7544d2 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -9,11 +9,14 @@ //! [diagnostics](bundle::Diagnostic) in the [`Bundle`]; indexing never fails. pub mod bundle; +pub mod diff; pub mod index; pub mod input; +pub mod report; mod xsd; pub use bundle::{Bundle, FORMAT, Id, Kind}; +pub use diff::{DiffOptions, SchemaDiff, diff}; pub use input::{Resolver, Source}; /// Indexing options. @@ -114,6 +117,18 @@ pub fn index(sources: Vec, resolver: Option<&mut Resolver<'_>>, opts: &O bundle } +/// Restore what [`Bundle::make_compact`] stripped: the effective content +/// models and the search index. Does nothing to a full bundle. +pub fn expand(bundle: &mut Bundle) { + if !bundle.compact { + return; + } + let mut diagnostics = Vec::new(); + index::effective(&mut bundle.components, &mut diagnostics); + bundle.search = index::search(&bundle.components); + bundle.compact = false; +} + /// Serialize a bundle as JSON (no whitespace). pub fn to_json(bundle: &Bundle) -> serde_json::Result { serde_json::to_string(bundle) diff --git a/crates/core/src/report.rs b/crates/core/src/report.rs new file mode 100644 index 0000000..08e1259 --- /dev/null +++ b/crates/core/src/report.rs @@ -0,0 +1,483 @@ +//! Readable reports of a [`SchemaDiff`]: Markdown for release notes and a +//! standalone HTML page. + +use std::collections::BTreeMap; +use std::fmt::Write; + +use crate::bundle::Kind; +use crate::diff::{ + ChangeKind, ComponentDiff, Counts, Impact, SchemaDiff, Status, change_label, kind_name, +}; + +/// Inline text: `code` and **bold** spans are rendered by each backend. +enum Block { + H(u8, String), + P(String), + List(Vec), + Table(Vec, Vec>), +} + +/// `+` for additions, `−` for removals, `~` for other changes. +pub fn marker(k: ChangeKind) -> &'static str { + use ChangeKind::*; + match k { + SubstitutionMemberAdded + | ChildAdded + | AttributeAdded + | EnumerationAdded + | FacetAdded + | MemberTypeAdded + | AssertAdded => "+", + SubstitutionMemberRemoved + | ChildRemoved + | AttributeRemoved + | EnumerationRemoved + | FacetRemoved + | MemberTypeRemoved + | AssertRemoved => "−", + _ => "~", + } +} + +fn plural(k: Kind) -> &'static str { + match k { + Kind::ComplexType => "Complex types", + Kind::SimpleType => "Simple types", + Kind::Element => "Elements", + Kind::Attribute => "Attributes", + Kind::Group => "Groups", + Kind::AttributeGroup => "Attribute groups", + _ => "Other", + } +} + +/// Backticks inside code spans are replaced: they cannot be escaped portably. +fn code(s: &str) -> String { + format!("`{}`", s.replace('`', "'")) +} + +fn short(s: &str, max: usize) -> String { + let one = s.split_whitespace().collect::>().join(" "); + if one.chars().count() > max { + format!("{}…", one.chars().take(max).collect::()) + } else { + one + } +} + +fn change_line(c: &crate::diff::Change) -> String { + let mut s = format!("{} {}", marker(c.kind), change_label(c.kind)); + if !c.path.is_empty() { + let _ = write!(s, " in {}", code(&c.path)); + } + if !c.subject.is_empty() { + let _ = write!(s, ": {}", code(&short(&c.subject, 120))); + } + if c.kind != ChangeKind::DocsChanged && (c.old.is_some() || c.new.is_some()) { + let v = |x: &Option| { + x.as_deref() + .map_or("(none)".to_string(), |v| code(&short(v, 120))) + }; + match c.kind { + ChangeKind::ChildAdded | ChangeKind::AttributeAdded | ChangeKind::FacetAdded => { + let _ = write!(s, " ({})", c.new.as_deref().unwrap_or("")); + } + ChangeKind::ChildRemoved | ChangeKind::AttributeRemoved | ChangeKind::FacetRemoved => { + let _ = write!(s, " (was {})", c.old.as_deref().unwrap_or("")); + } + _ => { + let _ = write!(s, " ({} → {})", v(&c.old), v(&c.new)); + } + } + } + if c.impact == Impact::Breaking { + s.push_str(" **breaking**"); + } + s +} + +fn heading(d: &ComponentDiff) -> String { + let mut s = format!("{} {}", kind_name(d.kind), code(d.qname())); + if let Some(o) = &d.old + && d.new.is_some() + && o.qname != d.qname() + { + let _ = write!(s, " (was {})", code(&o.qname)); + } + if let Some(f) = d.file() { + s.push_str(&format!(" · {f}")); + } + s +} + +fn counts_row(label: String, c: &Counts) -> Vec { + vec![ + label, + c.added.to_string(), + c.removed.to_string(), + c.changed.to_string(), + c.breaking.to_string(), + ] +} + +fn set_label(s: &crate::diff::SetInfo) -> String { + if let Some(t) = &s.title { + return t.clone(); + } + if let Some(v) = &s.version { + return format!("version {v}"); + } + format!("{} files", s.files) +} + +fn blocks(d: &SchemaDiff) -> Vec { + let mut out = Vec::new(); + let title = format!( + "Schema changes: {} → {}", + set_label(&d.old), + set_label(&d.new) + ); + out.push(Block::H(1, title)); + let t = &d.summary.total; + out.push(Block::P(format!( + "{} added, {} removed and {} changed global components; {} breaking changes in {} components. {} components changed only in their documentation. Compared {} files with {} files.", + t.added, t.removed, t.changed, d.summary.breaking_changes, t.breaking, t.docs_only, d.old.files, d.new.files + ))); + if !d.namespace_map.is_empty() { + out.push(Block::P( + "The target namespace changed; components were matched by kind and local name:".into(), + )); + out.push(Block::List( + d.namespace_map + .iter() + .map(|(a, b)| format!("{} → {}", code(a), code(b))) + .collect(), + )); + } + let head = || { + vec![ + "".to_string(), + "Added".into(), + "Removed".into(), + "Changed".into(), + "Breaking".into(), + ] + }; + out.push(Block::H(2, "Summary".into())); + let mut rows: Vec> = d + .summary + .by_kind + .iter() + .map(|(k, c)| counts_row(plural(*k).into(), c)) + .collect(); + rows.push(counts_row("**Total**".into(), t)); + out.push(Block::Table(head(), rows)); + if d.summary.by_namespace.len() > 1 { + out.push(Block::H(3, "By namespace".into())); + let rows = d + .summary + .by_namespace + .iter() + .map(|(k, c)| { + counts_row( + if k.is_empty() { + "(none)".into() + } else { + code(k) + }, + c, + ) + }) + .collect(); + out.push(Block::Table(head(), rows)); + } + if d.summary.by_file.len() > 1 { + out.push(Block::H(3, "By file".into())); + let rows = d + .summary + .by_file + .iter() + .map(|(k, c)| counts_row(code(k), c)) + .collect(); + out.push(Block::Table(head(), rows)); + } + + let by_kind = |status: Status| { + let mut m: BTreeMap> = BTreeMap::new(); + for c in d.components.iter().filter(|c| c.status == status) { + let mut s = code(c.qname()); + if let Some(r) = &c.possibly_renamed { + let _ = write!( + s, + " (possibly renamed {} {})", + if status == Status::Added { + "from" + } else { + "to" + }, + code(r) + ); + } + m.entry(c.kind).or_default().push(s); + } + m + }; + let breaking: Vec<&ComponentDiff> = d + .components + .iter() + .filter(|c| c.status == Status::Changed && c.impact == Impact::Breaking) + .collect(); + out.push(Block::H( + 2, + format!("Breaking changes ({})", breaking.len() + t.removed as usize), + )); + let removed = by_kind(Status::Removed); + if !removed.is_empty() { + out.push(Block::H(3, "Removed".into())); + for (k, list) in &removed { + out.push(Block::P(format!( + "**{}** ({}): {}", + plural(*k), + list.len(), + list.join(", ") + ))); + } + } + for c in &breaking { + out.push(Block::H(3, heading(c))); + out.push(Block::List( + c.changes + .iter() + .filter(|x| x.impact == Impact::Breaking) + .map(change_line) + .collect(), + )); + let other: Vec = c + .changes + .iter() + .filter(|x| x.impact == Impact::NonBreaking) + .map(change_line) + .collect(); + if !other.is_empty() { + out.push(Block::P("Also:".into())); + out.push(Block::List(other)); + } + } + let added = by_kind(Status::Added); + out.push(Block::H(2, format!("Added ({})", t.added))); + for (k, list) in &added { + out.push(Block::P(format!( + "**{}** ({}): {}", + plural(*k), + list.len(), + list.join(", ") + ))); + } + if !d.renames.is_empty() { + out.push(Block::H( + 2, + format!("Possibly renamed ({})", d.renames.len()), + )); + out.push(Block::Table( + vec![ + "Kind".into(), + "Old".into(), + "New".into(), + "Similarity".into(), + ], + d.renames + .iter() + .map(|r| { + vec![ + kind_name(r.kind).into(), + code(&r.old.qname), + code(&r.new.qname), + format!("{}%", r.score), + ] + }) + .collect(), + )); + } + let other: Vec<&ComponentDiff> = d + .components + .iter() + .filter(|c| c.status == Status::Changed && c.impact == Impact::NonBreaking) + .collect(); + out.push(Block::H(2, format!("Other changes ({})", other.len()))); + for c in &other { + out.push(Block::H(3, heading(c))); + out.push(Block::List( + c.changes + .iter() + .filter(|x| x.impact != Impact::Info) + .map(change_line) + .collect(), + )); + } + let docs: Vec = d + .components + .iter() + .filter(|c| c.changes.iter().any(|x| x.kind == ChangeKind::DocsChanged)) + .map(|c| code(c.qname())) + .collect(); + if !docs.is_empty() { + out.push(Block::H( + 2, + format!("Documentation changes ({})", docs.len()), + )); + out.push(Block::P(docs.join(", "))); + } + out.push(Block::P(format!("Generated by {}.", d.generator))); + out +} + +/// A Markdown report, for release notes. +pub fn markdown(d: &SchemaDiff) -> String { + let mut s = String::new(); + for b in blocks(d) { + match b { + Block::H(l, t) => { + let _ = writeln!(s, "{} {t}\n", "#".repeat(l as usize)); + } + Block::P(t) => { + let _ = writeln!(s, "{t}\n"); + } + Block::List(items) => { + for i in items { + let _ = writeln!(s, "- {}", i.replace('\n', " ")); + } + s.push('\n'); + } + Block::Table(head, rows) => { + let cell = |c: &String| c.replace('|', "\\|"); + let _ = writeln!( + s, + "| {} |", + head.iter().map(cell).collect::>().join(" | ") + ); + let numeric = |i: usize| { + rows.iter().all(|r| { + r.get(i) + .is_some_and(|c| c.trim_end_matches('%').parse::().is_ok()) + }) + }; + let _ = writeln!( + s, + "|{}", + (0..head.len()) + .map(|i| if numeric(i) { "---:|" } else { "---|" }) + .collect::() + ); + for r in rows { + let _ = writeln!( + s, + "| {} |", + r.iter().map(cell).collect::>().join(" | ") + ); + } + s.push('\n'); + } + } + } + s +} + +fn esc(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +/// Render the inline `code` and **bold** spans of a text as HTML. +fn inline(s: &str) -> String { + let mut out = String::new(); + let mut code = false; + let mut bold = false; + let mut rest = s; + while !rest.is_empty() { + if let Some(r) = rest.strip_prefix('`') { + out.push_str(if code { "" } else { "" }); + code = !code; + rest = r; + } else if !code && let Some(r) = rest.strip_prefix("**") { + out.push_str(if bold { "" } else { "" }); + bold = !bold; + rest = r; + } else { + let ch = rest.chars().next().unwrap(); + out.push_str(&esc(&ch.to_string())); + rest = &rest[ch.len_utf8()..]; + } + } + if code { + out.push_str(""); + } + if bold { + out.push_str(""); + } + out +} + +const CSS: &str = ":root{color-scheme:light dark;--bg:#fff;--fg:#1d2330;--muted:#5b6577;--line:#d9dee7;--code:#f2f4f8;--add:#1a7f37;--del:#c62828;--chg:#9a6700}@media (prefers-color-scheme:dark){:root{--bg:#14171d;--fg:#e4e8ef;--muted:#9aa4b5;--line:#2c323d;--code:#1f242d;--add:#56d364;--del:#ff7b72;--chg:#e3b341}}body{background:var(--bg);color:var(--fg);font:15px/1.5 system-ui,sans-serif;max-width:960px;margin:0 auto;padding:16px}h1{font-size:1.6em}h2{border-bottom:1px solid var(--line);padding-bottom:4px;margin-top:2em}h3{font-size:1em;margin:1.4em 0 .4em}code{background:var(--code);padding:1px 4px;border-radius:3px;font-size:.9em;overflow-wrap:anywhere}table{border-collapse:collapse;margin:8px 0;max-width:100%;display:block;overflow-x:auto}th,td{border:1px solid var(--line);padding:4px 10px;text-align:right}th:first-child,td:first-child{text-align:left}ul{padding-left:1.2em}li{margin:2px 0}li.mk{list-style:none;margin-left:-1.2em}.mk b{display:inline-block;width:1.2em}.m-add{color:var(--add)}.m-del{color:var(--del)}.m-chg{color:var(--chg)}strong{color:inherit}li strong{color:var(--del)}"; + +/// A standalone HTML report (light and dark theme). +pub fn html(d: &SchemaDiff) -> String { + let bl = blocks(d); + let title = match bl.first() { + Some(Block::H(_, t)) => t.clone(), + _ => "Schema changes".into(), + }; + let mut s = format!( + "\n{}\n", + esc(&title) + ); + for b in bl { + match b { + Block::H(l, t) => { + let _ = writeln!(s, "{}", inline(&t)); + } + Block::P(t) => { + let _ = writeln!(s, "

{}

", inline(&t)); + } + Block::List(items) => { + s.push_str("
    \n"); + for i in items { + let (cls, mark, text) = match i.split_once(' ') { + Some(("+", r)) => ("m-add", "+", r), + Some(("−", r)) => ("m-del", "−", r), + Some(("~", r)) => ("m-chg", "~", r), + _ => ("", "", i.as_str()), + }; + if cls.is_empty() { + let _ = writeln!(s, "
  • {}
  • ", inline(text)); + } else { + let _ = writeln!( + s, + "
  • {mark}{}
  • ", + inline(text) + ); + } + } + s.push_str("
\n"); + } + Block::Table(head, rows) => { + s.push_str(""); + for h in head { + let _ = write!(s, "", inline(&h)); + } + s.push_str("\n"); + for r in rows { + s.push_str(""); + for c in r { + let _ = write!(s, "", inline(&c)); + } + s.push_str("\n"); + } + s.push_str("
{}
{}
\n"); + } + } + } + s.push_str("\n"); + s +} diff --git a/crates/core/tests/data/diff/v1/common.xsd b/crates/core/tests/data/diff/v1/common.xsd new file mode 100644 index 0000000..cfef16e --- /dev/null +++ b/crates/core/tests/data/diff/v1/common.xsd @@ -0,0 +1,19 @@ + + + + A sum of money. + + + + + + + + + + + + + + + diff --git a/crates/core/tests/data/diff/v1/shop.xsd b/crates/core/tests/data/diff/v1/shop.xsd new file mode 100644 index 0000000..7c76166 --- /dev/null +++ b/crates/core/tests/data/diff/v1/shop.xsd @@ -0,0 +1,71 @@ + + + + + + + + + + + + An order. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/core/tests/data/diff/v2/common.xsd b/crates/core/tests/data/diff/v2/common.xsd new file mode 100644 index 0000000..1fe51d5 --- /dev/null +++ b/crates/core/tests/data/diff/v2/common.xsd @@ -0,0 +1,20 @@ + + + + A sum of money. + + + + + + + + + + + + + + + + diff --git a/crates/core/tests/data/diff/v2/shop.xsd b/crates/core/tests/data/diff/v2/shop.xsd new file mode 100644 index 0000000..3df3a14 --- /dev/null +++ b/crates/core/tests/data/diff/v2/shop.xsd @@ -0,0 +1,85 @@ + + + + + + + + + + + + An order placed by a customer. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/core/tests/diff.rs b/crates/core/tests/diff.rs new file mode 100644 index 0000000..ebc0711 --- /dev/null +++ b/crates/core/tests/diff.rs @@ -0,0 +1,225 @@ +//! Tests of the comparison of two versions, on the hand-written pair in +//! tests/data/diff (v1 → v2, whose target namespace changes too). + +use schema_explorer_core::diff::*; +use schema_explorer_core::{Bundle, Kind, Options, Source, index, report}; +use std::path::Path; + +fn load(version: &str) -> Bundle { + let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/data/diff"); + let files = ["shop.xsd", "common.xsd"]; + index( + files + .iter() + .map(|f| { + Source::new( + format!("{version}/{f}"), + std::fs::read(dir.join(version).join(f)).unwrap(), + ) + }) + .collect(), + None, + &Options::default(), + ) +} + +fn run(opts: &DiffOptions) -> SchemaDiff { + diff(&load("v1"), &load("v2"), opts) +} + +fn comp<'a>(d: &'a SchemaDiff, kind: Kind, name: &str) -> &'a ComponentDiff { + d.components + .iter() + .find(|c| c.kind == kind && c.name == name) + .unwrap_or_else(|| panic!("{kind:?} {name} not in the diff")) +} + +/// The changes of a component as `kind subject impact` lines. +fn changes(c: &ComponentDiff) -> Vec { + c.changes + .iter() + .map(|x| { + let mut s = format!("{:?}", x.kind); + if !x.path.is_empty() { + s.push_str(&format!(" {}:", x.path)); + } + if !x.subject.is_empty() { + s.push_str(&format!(" {}", x.subject)); + } + s.push_str(&format!(" {:?}", x.impact)); + s + }) + .collect() +} + +fn has(c: &ComponentDiff, line: &str) { + let list = changes(c); + assert!(list.iter().any(|l| l == line), "{line:?} not in {list:#?}"); +} + +#[test] +fn matches_across_namespaces() { + let d = run(&DiffOptions::default()); + assert_eq!(d.format, DIFF_FORMAT); + assert_eq!( + d.namespace_map.get("urn:test:shop:1").map(String::as_str), + Some("urn:test:shop:2") + ); + let order = comp(&d, Kind::ComplexType, "Order"); + assert_eq!(order.status, Status::Changed); + assert!(order.by_local_name); + // Unchanged components are left out, even across the namespace change. + assert!(!d.components.iter().any(|c| c.name == "book")); + assert_eq!(d.old.version.as_deref(), Some("1.0")); + assert_eq!(d.new.version.as_deref(), Some("2.0")); +} + +#[test] +fn strict_namespaces() { + let d = run(&DiffOptions { + match_local_names: false, + ..Default::default() + }); + assert!(d.namespace_map.is_empty()); + // Everything in the target namespace is removed and added again. + let order: Vec = d + .components + .iter() + .filter(|c| c.name == "Order") + .map(|c| c.status) + .collect(); + assert_eq!(order.len(), 2, "{order:?}"); + assert!(order.contains(&Status::Removed) && order.contains(&Status::Added)); +} + +#[test] +fn added_removed_renamed() { + let d = run(&DiffOptions::default()); + assert_eq!(comp(&d, Kind::Element, "legacy").status, Status::Removed); + assert!(comp(&d, Kind::Element, "legacy").breaking()); + let dvd = comp(&d, Kind::Element, "dvd"); + assert_eq!(dvd.status, Status::Added); + assert_eq!(dvd.impact, Impact::NonBreaking); + assert_eq!(comp(&d, Kind::ComplexType, "Base").status, Status::Added); + let addr = comp(&d, Kind::ComplexType, "Address"); + assert_eq!(addr.status, Status::Removed); + assert_eq!(addr.possibly_renamed.as_deref(), Some("s:PostalAddress")); + assert_eq!(d.renames.len(), 1); + assert_eq!(d.renames[0].score, 100); + let t = d.summary.total; + assert_eq!((t.added, t.removed), (3, 2)); + assert_eq!(d.summary.by_kind[&Kind::Element].removed, 1); + assert_eq!(d.summary.by_file["shop.xsd"].added, 3); +} + +#[test] +fn content_model() { + let d = run(&DiffOptions::default()); + let order = comp(&d, Kind::ComplexType, "Order"); + assert!(order.breaking()); + has(order, "ChildCardinalityChanged lines Breaking"); + has(order, "ChildAdded discount NonBreaking"); + has(order, "ChildAdded store NonBreaking"); + has(order, "ChildMoved channel Breaking"); + has(order, "ChildrenReordered note Breaking"); + has(order, "StructureChanged Breaking"); + // In the anonymous type of the local element `lines`. + has(order, "AttributeRetyped lines: @count Breaking"); + let cust = comp(&d, Kind::ComplexType, "Customer"); + has(cust, "ChildCardinalityChanged email NonBreaking"); + has(cust, "ChildCardinalityChanged phone Breaking"); + let group = comp(&d, Kind::Group, "gContact"); + has(group, "ChildRemoved fax Breaking"); + has(group, "ChildAdded phone NonBreaking"); +} + +#[test] +fn attributes() { + let d = run(&DiffOptions::default()); + let order = comp(&d, Kind::ComplexType, "Order"); + has(order, "AttributeRemoved @legacyCode Breaking"); + has(order, "AttributeAdded @currency Breaking"); + has(order, "AttributeDefaultChanged @status NonBreaking"); + has( + comp(&d, Kind::ComplexType, "Customer"), + "AttributeAdded @vip NonBreaking", + ); + has( + comp(&d, Kind::AttributeGroup, "aAudit"), + "AttributeAdded @by Breaking", + ); +} + +#[test] +fn types_and_facets() { + let d = run(&DiffOptions::default()); + let item = comp(&d, Kind::ComplexType, "Item"); + has(item, "BaseChanged Breaking"); + has(item, "AbstractChanged Breaking"); + let st = comp(&d, Kind::SimpleType, "tStatus"); + has(st, "EnumerationAdded new NonBreaking"); + has(st, "EnumerationRemoved cancelled Breaking"); + let code = comp(&d, Kind::SimpleType, "tCode"); + has(code, "FacetChanged maxLength Breaking"); + has(code, "FacetAdded minLength Breaking"); + let amount = comp(&d, Kind::SimpleType, "tAmount"); + has(amount, "FacetChanged fractionDigits NonBreaking"); + assert_eq!(amount.impact, Impact::NonBreaking); +} + +#[test] +fn substitution_groups() { + let d = run(&DiffOptions::default()); + let head = comp(&d, Kind::Element, "item"); + has(head, "SubstitutionMemberRemoved s:cd Breaking"); + has(head, "SubstitutionMemberAdded s:dvd NonBreaking"); + has( + comp(&d, Kind::Element, "cd"), + "SubstitutionGroupChanged Breaking", + ); +} + +#[test] +fn documentation() { + let d = run(&DiffOptions::default()); + let order = comp(&d, Kind::ComplexType, "Order"); + let doc = order + .changes + .iter() + .find(|c| c.kind == ChangeKind::DocsChanged) + .unwrap(); + assert_eq!(doc.impact, Impact::Info); + assert_eq!(doc.old.as_deref(), Some("An order.")); + assert_eq!(doc.new.as_deref(), Some("An order placed by a customer.")); + let d = run(&DiffOptions { + docs: false, + ..Default::default() + }); + assert!( + d.components + .iter() + .all(|c| c.changes.iter().all(|x| x.kind != ChangeKind::DocsChanged)) + ); +} + +#[test] +fn identical_sets() { + let d = diff(&load("v1"), &load("v1"), &DiffOptions::default()); + assert!(d.components.is_empty(), "{:#?}", d.components); + assert_eq!(d.summary.total.total(), 0); +} + +#[test] +fn json_and_reports() { + let d = run(&DiffOptions::default()); + let json = serde_json::to_string(&d).unwrap(); + let back: SchemaDiff = serde_json::from_str(&json).unwrap(); + assert_eq!(back, d); + let md = report::markdown(&d); + assert!(md.starts_with("# Schema changes: version 1.0 → version 2.0")); + assert!(md.contains("- − enumeration value removed: `cancelled` **breaking**")); + assert!(md.contains("| complexType | `s:Address` | `s:PostalAddress` | 100% |")); + let html = report::html(&d); + assert!(html.contains("s:PostalAddress")); + assert!(!html.contains(" Option { + let a = run(&format!("{label} (old)"), old)?; + let b = run(&format!("{label} (new)"), new)?; + let start = Instant::now(); + let d = diff(&a.bundle, &b.bundle, &DiffOptions::default()); + let t = &d.summary.total; + eprintln!( + "{label}: {} added, {} removed, {} changed ({} breaking, {} documentation only), {} possibly renamed, {} breaking changes; compared in {:.2?}", + t.added, + t.removed, + t.changed, + t.breaking, + t.docs_only, + d.summary.possibly_renamed, + d.summary.breaking_changes, + start.elapsed() + ); + Some(d) +} + +fn find<'a>(d: &'a SchemaDiff, kind: Kind, name: &str) -> Option<&'a ComponentDiff> { + d.components + .iter() + .find(|c| c.kind == kind && c.name == name) +} + +fn railml_dirs(v: &str) -> [String; 2] { + [ + format!("railml/{v}/schema"), + format!("railml/{v}/codelists"), + ] +} + +#[test] +fn diff_railml_3_1_to_3_2() { + let [a, _] = railml_dirs("3.1"); + let [b, c] = railml_dirs("3.2"); + let Some(d) = compare("railML 3.1 → 3.2", &[&a], &[&b, &c]) else { + return; + }; + assert_eq!( + d.namespace_map + .get("https://www.railml.org/schemas/3.1") + .map(String::as_str), + Some("https://www.railml.org/schemas/3.2") + ); + // Most components survive the namespace change. + assert!(d.summary.total.changed > 100); + assert!(d.summary.total.removed < d.old.components / 4); +} + +#[test] +fn diff_railml_3_2_to_3_3() { + let [a, b] = railml_dirs("3.2"); + let [c, e] = railml_dirs("3.3"); + let Some(d) = compare("railML 3.2 → 3.3", &[&a, &b], &[&c, &e]) else { + return; + }; + assert_eq!( + d.namespace_map + .get("https://www.railml.org/schemas/3.2") + .map(String::as_str), + Some("https://www.railml.org/schemas/3.3") + ); + // "tons" became "tonnes". + let w = find(&d, Kind::SimpleType, "tWeightTons").expect("tWeightTons"); + assert_eq!(w.status, Status::Removed); + assert_eq!(w.possibly_renamed.as_deref(), Some("rail3:tWeightTonnes")); + // Service section facilities moved out of the attribute group. + let ss = find(&d, Kind::AttributeGroup, "aServiceSection").expect("aServiceSection"); + assert!(ss.changes.iter().any(|c| c.subject == "@allowsCleaning")); + // Container elements must now hold at least one item. + let bal = find(&d, Kind::ComplexType, "Balises").expect("Balises"); + assert!(bal.breaking()); + assert!(d.summary.total.added > 100); +} + +#[test] +fn diff_taf() { + let Some(d) = compare( + "TAF 3.5.1 → current", + &["telematics/taf-v3.5.1/taf_cat_complete_fixed.xsd"], + &["telematics/taf/taf_cat_complete_fixed.xsd"], + ) else { + return; + }; + assert!(d.namespace_map.is_empty()); + let ri = find(&d, Kind::Element, "RouteInformation").expect("RouteInformation"); + assert_eq!(ri.status, Status::Removed); + let hb = find(&d, Kind::Element, "HandBrake").expect("HandBrake"); + assert!(hb.breaking()); +} diff --git a/crates/core/tests/model.rs b/crates/core/tests/model.rs index 349ebcf..7e2b93b 100644 --- a/crates/core/tests/model.rs +++ b/crates/core/tests/model.rs @@ -400,6 +400,10 @@ fn json_round_trip_and_compact() { assert!(cjson.len() < json.len()); let v: serde_json::Value = serde_json::from_str(&json).unwrap(); assert_eq!(v["format"], FORMAT); + // Expanding the compact form gives the full bundle back. + let mut expanded: Bundle = serde_json::from_str(&cjson).unwrap(); + schema_explorer_core::expand(&mut expanded); + assert_eq!(to_json(&expanded).unwrap(), json); } #[test] diff --git a/crates/wasm/Cargo.toml b/crates/wasm/Cargo.toml index 3b19261..52fd84b 100644 --- a/crates/wasm/Cargo.toml +++ b/crates/wasm/Cargo.toml @@ -15,4 +15,5 @@ crate-type = ["cdylib", "rlib"] [dependencies] js-sys = "0.3.106" schema-explorer-core = { path = "../core", version = "0.1.0" } +serde_json.workspace = true wasm-bindgen = "0.2" diff --git a/crates/wasm/src/lib.rs b/crates/wasm/src/lib.rs index c4ca565..4b850f3 100644 --- a/crates/wasm/src/lib.rs +++ b/crates/wasm/src/lib.rs @@ -31,6 +31,51 @@ pub fn index( schema_explorer_core::to_json(&bundle).map_err(|e| JsValue::from_str(&e.to_string())) } +/// Expand a compact bundle (JSON) into the full form: effective content +/// models and the search index are recomputed. A full bundle comes back as is. +#[wasm_bindgen] +pub fn expand(json: &str) -> Result { + let mut bundle: schema_explorer_core::Bundle = + serde_json::from_str(json).map_err(|e| JsValue::from_str(&e.to_string()))?; + if !bundle.compact { + return Ok(json.to_string()); + } + schema_explorer_core::expand(&mut bundle); + schema_explorer_core::to_json(&bundle).map_err(|e| JsValue::from_str(&e.to_string())) +} + +fn err(e: impl ToString) -> JsValue { + JsValue::from_str(&e.to_string()) +} + +/// Compare two bundles (JSON, full or compact; `old` is the earlier +/// version) and return the diff as JSON. `options` is a JSON object of +/// `DiffOptions` fields, or empty for the defaults. +#[wasm_bindgen] +pub fn diff(old: &str, new: &str, options: &str) -> Result { + let a: schema_explorer_core::Bundle = serde_json::from_str(old).map_err(err)?; + let b: schema_explorer_core::Bundle = serde_json::from_str(new).map_err(err)?; + let opts: schema_explorer_core::DiffOptions = if options.trim().is_empty() { + Default::default() + } else { + serde_json::from_str(options).map_err(err)? + }; + let mut d = schema_explorer_core::diff(&a, &b, &opts); + d.generator = format!("schema-explorer-wasm {}", env!("CARGO_PKG_VERSION")); + serde_json::to_string(&d).map_err(err) +} + +/// Render a diff (JSON) as a report: `md` or `html`. +#[wasm_bindgen] +pub fn diff_report(diff: &str, format: &str) -> Result { + let d: schema_explorer_core::SchemaDiff = serde_json::from_str(diff).map_err(err)?; + match format { + "md" => Ok(schema_explorer_core::report::markdown(&d)), + "html" => Ok(schema_explorer_core::report::html(&d)), + _ => Err(err(format!("unknown report format {format}"))), + } +} + /// The version of the core library. #[wasm_bindgen] pub fn version() -> String { diff --git a/docs/screenshot-overview.png b/docs/screenshot-overview.png new file mode 100644 index 0000000..601f690 Binary files /dev/null and b/docs/screenshot-overview.png differ diff --git a/docs/screenshot-tree.png b/docs/screenshot-tree.png new file mode 100644 index 0000000..c2dc346 Binary files /dev/null and b/docs/screenshot-tree.png differ diff --git a/docs/screenshot-type.png b/docs/screenshot-type.png new file mode 100644 index 0000000..c114372 Binary files /dev/null and b/docs/screenshot-type.png differ diff --git a/scripts/build-examples.sh b/scripts/build-examples.sh new file mode 100755 index 0000000..d0614bb --- /dev/null +++ b/scripts/build-examples.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# Builds the examples catalog of the Pages site into /examples/: +# a pre-indexed bundle per schema set, its .xsd files (for the source view) +# and examples.json, which the open page lists. +# +# Only freely redistributable schema sets go here, each fetched from its +# publisher at build time (pinned URLs, cached in $WORK/downloads): +# - NeTEx (GPL-3.0), from its public repository +# - ERA TAF TSI and TAP TSI B.10 schemas (ERA: reproduction authorised, +# source acknowledged) +# - OASIS UBL 2.4 (OASIS IPR Policy) +# - W3C XML Schema for schemas (W3C document licence) +# Never add railML (CC BY-NC-ND) or other schemas whose licence forbids +# redistribution. An example whose download fails is left out with a warning. +# +# scripts/build-examples.sh +set -euo pipefail +CLI=$(realpath "$1") +DIST=$(realpath "$2") +cd "$(dirname "$0")/.." +WORK="${SCHEMA_FIXTURES:-tests/fixtures}" +DL="$WORK/downloads" +mkdir -p "$WORK" "$DL" "$DIST/examples" +ENTRIES=() + +# fetch : download once (kept as a build cache), with retries. +fetch() { + [ -s "$DL/$2" ] && return 0 + curl -fsSL --retry 4 --retry-delay 2 --retry-all-errors -o "$DL/$2.part" "$1" && mv "$DL/$2.part" "$DL/$2" +} + +# entry <description> <version> <publisher> <licence> <link> [focus] +entry() { + local focus="" + [ -n "${8:-}" ] && focus=$(printf ',\n "focus": "%s"' "$8") + ENTRIES+=("$(printf ' {\n "id": "%s",\n "title": "%s",\n "description": "%s",\n "version": "%s",\n "publisher": "%s",\n "licence": "%s",\n "link": "%s",\n "src": "%s/bundle.json",\n "sources": "%s/xsd/"%s\n }' "$1" "$2" "$3" "$4" "$5" "$6" "$7" "$1" "$1" "$focus")") +} + +# start <id>: a fresh output directory for an example. +start() { + OUT="$DIST/examples/$1" + rm -rf "$OUT" + mkdir -p "$OUT/xsd" +} + +if [ ! -d "$WORK/NeTEx/.git" ]; then + git clone --depth 1 https://github.com/NeTEx-CEN/NeTEx "$WORK/NeTEx" +fi +NETEX_REV=$(git -C "$WORK/NeTEx" rev-parse --short HEAD) +start netex +rmdir "$OUT/xsd" +cp -r "$WORK/NeTEx/xsd" "$OUT/xsd" +cp "$WORK/NeTEx/LICENSE" "$OUT/LICENSE" +"$CLI" index "$OUT/xsd/NeTEx_publication.xsd" -o "$OUT/bundle.json" + +entry netex "NeTEx" "Network Timetable Exchange, the CEN standard for public transport data (NeTEx_publication.xsd)" "$NETEX_REV" \ + "CEN TC278 · NeTEx-CEN" "GPL-3.0" "https://github.com/NeTEx-CEN/NeTEx" "ct/netex:StopPlace_VersionStructure" + +# ERA TAF TSI, technical documents baseline 3.5.0 (message catalogue XML schema). +ERA="Source: European Union Agency for Railways (https://www.era.europa.eu). Reproduction is authorised, provided the source is acknowledged." +TAF=https://www.era.europa.eu/system/files/2024-06 +if fetch $TAF/taf_cat_complete.xsd taf-3.5.0-taf_cat_complete.xsd && + fetch $TAF/taf_cat_codelists_0.xsd taf-3.5.0-taf_cat_codelists.xsd && + fetch $TAF/TAP_TSI_codelist.xsd taf-3.5.0-TAP_TSI_codelist.xsd; then + start taf-tsi + for f in taf_cat_complete taf_cat_codelists TAP_TSI_codelist; do cp "$DL/taf-3.5.0-$f.xsd" "$OUT/xsd/$f.xsd"; done + echo "$ERA" > "$OUT/NOTICE" + "$CLI" index "$OUT/xsd/taf_cat_complete.xsd" -o "$OUT/bundle.json" + entry taf-tsi "TAF TSI" "Telematics Applications for Freight: the common message catalogue of the ERA technical documents (taf_cat_complete.xsd)" "3.5.0" \ + "European Union Agency for Railways" "ERA, source acknowledged" "https://www.era.europa.eu/node/2591" +else echo "warning: TAF TSI schemas not downloaded, example left out" >&2; fi + +# ERA TAP TSI B.10 (assistance for persons with reduced mobility), the latest published XSD (archive baseline 1.4.2). +if fetch https://www.era.europa.eu/system/files/2022-11/tap_tsi_b.10_1.4_1.xsd tap-b10-1.4.xsd; then + start tap-tsi-b10 + cp "$DL/tap-b10-1.4.xsd" "$OUT/xsd/tap_tsi_b.10.xsd" + echo "$ERA" > "$OUT/NOTICE" + "$CLI" index "$OUT/xsd/tap_tsi_b.10.xsd" -o "$OUT/bundle.json" + entry tap-tsi-b10 "TAP TSI B.10" "Telematics Applications for Passenger services: electronic reservation of assistance for persons with reduced mobility (technical document B.10)" "1.4" \ + "European Union Agency for Railways" "ERA, source acknowledged" "https://www.era.europa.eu/era-folder/tap-baseline-142-archive" +else echo "warning: TAP TSI B.10 schema not downloaded, example left out" >&2; fi + +# OASIS UBL 2.4: every document schema of the release (xsd/maindoc) and the common modules. +if fetch https://docs.oasis-open.org/ubl/os-UBL-2.4/UBL-2.4.zip UBL-2.4.zip; then + start ubl + TMP=$(mktemp -d) + unzip -q "$DL/UBL-2.4.zip" -d "$TMP" + MAINDOC=$(find "$TMP" -type d -path '*/xsd/maindoc' | head -n 1) + cp -r "$(dirname "$MAINDOC")"/. "$OUT/xsd/" + rm -rf "$TMP" + cat > "$OUT/NOTICE" <<'TXT' +Universal Business Language Version 2.4. OASIS Standard, 20 June 2024. +https://docs.oasis-open.org/ubl/os-UBL-2.4/ +Copyright (c) OASIS Open 2024. All Rights Reserved. +Distributed under the OASIS Intellectual Property Rights Policy: +https://www.oasis-open.org/policies-guidelines/ipr/ +TXT + "$CLI" index "$OUT/xsd/maindoc" -o "$OUT/bundle.json" + entry ubl "UBL" "OASIS Universal Business Language: all business document schemas (Invoice, Order, DespatchAdvice, ...) and their common components" "2.4" \ + "OASIS UBL TC" "OASIS IPR Policy" "https://docs.oasis-open.org/ubl/os-UBL-2.4/" "ct/invoice-2:InvoiceType" +else echo "warning: UBL 2.4 not downloaded, example left out" >&2; fi + +# W3C XML Schema 1.0 schema for schemas, with the xml: namespace schema it imports. +if fetch https://www.w3.org/2001/XMLSchema.xsd w3c-XMLSchema.xsd && fetch https://www.w3.org/2001/xml.xsd w3c-xml.xsd; then + start xmlschema + cp "$DL/w3c-XMLSchema.xsd" "$OUT/xsd/XMLSchema.xsd" + cp "$DL/w3c-xml.xsd" "$OUT/xsd/xml.xsd" + cat > "$OUT/NOTICE" <<'TXT' +XML Schema Part 1: Structures / Part 2: Datatypes, schema for schemas. +https://www.w3.org/2001/XMLSchema.xsd and https://www.w3.org/2001/xml.xsd +Copyright (c) World Wide Web Consortium. Used under the W3C Document License: +https://www.w3.org/copyright/document-license-2023/ +TXT + "$CLI" index "$OUT/xsd/XMLSchema.xsd" "$OUT/xsd/xml.xsd" -o "$OUT/bundle.json" + entry xmlschema "XML Schema (schema for schemas)" "The W3C XML Schema 1.0 definition of XSD itself (XMLSchema.xsd), with xml.xsd" "1.0 (2004)" \ + "W3C" "W3C Document License" "https://www.w3.org/TR/xmlschema-1/" "ct/xs:complexType" +else echo "warning: W3C XMLSchema.xsd not downloaded, example left out" >&2; fi + +{ + echo "[" + for i in "${!ENTRIES[@]}"; do + [ "$i" -gt 0 ] && echo "," + printf '%s' "${ENTRIES[$i]}" + done + echo + echo "]" +} > "$DIST/examples/examples.json" +echo "examples ready in $DIST/examples" diff --git a/web/index.html b/web/index.html index c90317d..e9f79a2 100644 --- a/web/index.html +++ b/web/index.html @@ -6,15 +6,16 @@ <meta name="color-scheme" content="light dark" /> <title>schema-explorer + - + diff --git a/web/package-lock.json b/web/package-lock.json index 7efd89a..a7f9a3f 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8,7 +8,12 @@ "name": "@matdata/schema-explorer", "version": "0.1.0", "license": "Apache-2.0", + "dependencies": { + "elkjs": "^0.12.0" + }, "devDependencies": { + "@fontsource/barlow": "^5.3.0", + "@fontsource/jetbrains-mono": "^5.3.0", "typescript": "^5.6.0", "vite": "^6.0.0" } @@ -455,6 +460,26 @@ "node": ">=18" } }, + "node_modules/@fontsource/barlow": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/barlow/-/barlow-5.3.0.tgz", + "integrity": "sha512-tGR0y6QObRtKv71MOth44222+mdNFM/ZWFWDV1VLzNbG4oK9PNRlyJzmkck4vHAi9ibHBXgt4SlY51amFP55CA==", + "dev": true, + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/jetbrains-mono": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.3.0.tgz", + "integrity": "sha512-fqDfB5I9f1p1TV486aUgB9t8zP84P0O1FtQR5Ol9vjwPy+S+EIGlVYm1cvj2W5shcZMTg2nZFdVMoH5wFu8a1A==", + "dev": true, + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@napi-rs/lzma-linux-x64-gnu": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", @@ -829,6 +854,12 @@ "dev": true, "license": "MIT" }, + "node_modules/elkjs": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.12.0.tgz", + "integrity": "sha512-YZcKynxVxYoKIOEpywEPwCFdg+BTbxQRNf3pbwdDCvc8O3kQD8bmIwSxKU1eOTVc4Xo+VG9Te+575mlfvOrhEQ==", + "license": "EPL-2.0 OR GPL-3.0-or-later" + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", diff --git a/web/package.json b/web/package.json index a1bdc2b..85fddcd 100644 --- a/web/package.json +++ b/web/package.json @@ -12,9 +12,21 @@ "bugs": { "url": "https://github.com/Matdata-eu/schema-explorer/issues" }, - "keywords": ["xsd", "xml-schema", "json-schema", "schema", "uml", "web-component"], + "keywords": [ + "xsd", + "xml-schema", + "json-schema", + "schema", + "uml", + "web-component" + ], "type": "module", - "files": ["dist-lib", "README.md", "LICENSE", "THIRD_PARTY_NOTICES.md"], + "files": [ + "dist-lib", + "README.md", + "LICENSE", + "THIRD_PARTY_NOTICES.md" + ], "main": "./dist-lib/schema-explorer.js", "module": "./dist-lib/schema-explorer.js", "types": "./dist-lib/lib.d.ts", @@ -33,7 +45,12 @@ "preview": "vite preview" }, "devDependencies": { + "@fontsource/barlow": "^5.3.0", + "@fontsource/jetbrains-mono": "^5.3.0", "typescript": "^5.6.0", "vite": "^6.0.0" + }, + "dependencies": { + "elkjs": "^0.12.0" } } diff --git a/web/scripts/build-wasm.mjs b/web/scripts/build-wasm.mjs index d807635..87597fd 100644 --- a/web/scripts/build-wasm.mjs +++ b/web/scripts/build-wasm.mjs @@ -47,7 +47,12 @@ if (!existsSync(resolve(out, "schema_explorer_wasm.js"))) { writeFileSync( resolve(out, "schema_explorer_wasm.d.ts"), `export default function init(input?: { module_or_path?: string | URL | Request | BufferSource | WebAssembly.Module } | string | URL): Promise; +export function index(names: string[], contents: Uint8Array[], compact: boolean): string; +export function expand(json: string): string; +export function diff(old: string, cur: string, options: string): string; +export function diff_report(diff: string, format: string): string; export function version(): string; +export function format(): number; ` ); console.log("wasm ready in web/src/wasm/"); diff --git a/web/src/app.ts b/web/src/app.ts new file mode 100644 index 0000000..a9f4201 --- /dev/null +++ b/web/src/app.ts @@ -0,0 +1,755 @@ +// The application: state, routing, the shell around the views. +import type { AppCtx, Prefs, Route } from "./ctx"; +import { button, copyText, h, icon, plural, type Child } from "./dom"; +import { Compare } from "./diff"; +import { diffBundles, fetchBundleFromServer, indexFiles, loadUrl, memorySources, readDataTransfer, readFiles, serverSources, serverStatus, urlSources, type LoadProgress, type NamedBytes, type ServerStatus, type SourceProvider } from "./loader"; +import { KIND_SLUG, Model, type Bundle, type Id } from "./model"; +import { matdataMark } from "./logo"; +import { openAbout, openHelp } from "./views/dialogs"; +import { homePage } from "./views/home"; +import { overviewPage } from "./views/overview"; +import { diffBanner, diffComponentPage, diffOverviewPage } from "./views/compare"; +import { componentPage, filePage } from "./views/pages"; +import { sidebar, type Sidebar } from "./views/sidebar"; + +export interface AppOptions { + /** Route through location.hash (standalone app) or keep routes internal (embedded component). */ + routing: boolean; + embed: boolean; + theme?: Prefs["theme"]; + onNavigate?: (name: string | null, route: Route) => void; + onLoaded?: (bundle: Bundle) => void; + onError?: (err: Error) => void; + /** Where example bundles are listed (a JSON file next to the app). */ + examplesUrl?: string; + /** Hide the sidebar and top bar; show only the page. */ + minimal?: boolean; +} + +/** One side of a comparison: an address, or files read from the computer. */ +export type CompareSide = { url: string; sources?: string } | { files: NamedBytes[] }; + +const DEFAULT_PREFS: Prefs = { + theme: "auto", + showInherited: true, + depth: 1, + direction: "DOWN", + enums: true, + incoming: true, +}; + +interface Example { + id?: string; + title: string; + src: string; + sources?: string; + description?: string; + focus?: string; + publisher?: string; + licence?: string; + /** Version or revision of the schema set. */ + version?: string; + /** The publisher's page for the schema set. */ + link?: string; +} + +export class App implements AppCtx { + readonly root: HTMLElement; + private opts: AppOptions; + private shellEl: HTMLElement; + private mainEl: HTMLElement; + private topbar: HTMLElement; + private side: Sidebar | null = null; + private bodyEl: HTMLElement; + private model: Model | null = null; + private _route: Route = { view: "home", params: {} }; + private _prefs: Prefs; + private server: ServerStatus | null = null; + private toastEl: HTMLElement | null = null; + /** Where the schemas came from: a URL (shareable), local files, or the server. */ + private source: { kind: "url"; url: string; sources?: string } | { kind: "files" } | { kind: "server" } | null = null; + private sources: SourceProvider | null = null; + private sourceCache = new Map>(); + private _cmp: Compare | null = null; + private onHash = () => this.readHash(); + + constructor(root: HTMLElement, opts: AppOptions) { + this.root = root; + this.opts = opts; + this._prefs = { ...DEFAULT_PREFS, ...loadPrefs() }; + if (opts.theme) this._prefs.theme = opts.theme; + this.topbar = h("div", { class: "topbar" }); + this.mainEl = h("div", { class: "main" }, h("div", { class: "main-inner" })); + this.bodyEl = h("div", { class: "body no-sidebar" }, this.mainEl); + this.shellEl = h("div", { class: `shell${opts.embed ? " embed" : ""}` }, opts.minimal ? null : this.topbar, this.bodyEl); + root.appendChild(this.shellEl); + this.applyTheme(); + if (opts.routing) window.addEventListener("hashchange", this.onHash); + root.addEventListener("keydown", (ev) => { + if (ev.key === "/" && !(ev.target instanceof HTMLInputElement) && !(ev.target instanceof HTMLTextAreaElement)) { + ev.preventDefault(); + this.side?.focusSearch(); + } + }); + this.wireDrop(); + } + + destroy(): void { + window.removeEventListener("hashchange", this.onHash); + } + + // ---- AppCtx ------------------------------------------------------------- + get m(): Model { + if (!this.model) throw new Error("nothing loaded"); + return this.model; + } + get route(): Route { + return this._route; + } + get prefs(): Prefs { + return this._prefs; + } + get embed(): boolean { + return this.opts.embed; + } + get cmp(): Compare | null { + return this._cmp; + } + get bundle(): Bundle | null { + return this.model?.b ?? null; + } + + go(id: Id, extra: Partial = {}): void { + this.goRoute({ view: "comp", id, tab: extra.tab, params: extra.params ?? {} }); + } + + goRoute(r: Partial): void { + const route: Route = { view: r.view ?? "home", id: r.id, tab: r.tab, query: r.query, params: r.params ?? {} }; + if (this.opts.routing) { + const hash = this.hashFor(route); + if (location.hash !== hash) { + location.hash = hash; // hashchange → readHash → render + return; + } + } + this.setRoute(route); + } + + href(id: Id, extra: Partial = {}): string { + return this.hrefRoute({ view: "comp", id, tab: extra.tab, params: extra.params }); + } + + hrefRoute(r: Partial): string { + return this.hashFor({ view: r.view ?? "home", id: r.id, tab: r.tab, query: r.query, params: r.params ?? {} }); + } + + setPrefs(p: Partial): void { + Object.assign(this._prefs, p); + savePrefs(this._prefs); + if (p.theme !== undefined) this.applyTheme(); + } + + setParams(p: Record): void { + const params = { ...this._route.params }; + for (const [k, v] of Object.entries(p)) { + if (v === undefined || v === "") delete params[k]; + else params[k] = v; + } + this._route = { ...this._route, params }; + if (this.opts.routing) history.replaceState(history.state, "", this.hashFor(this._route)); + } + + shareLink(): { url: string; complete: boolean } { + const u = new URL(location.href); + u.hash = this.hashFor(this._route); + u.searchParams.delete("src"); + if (this.source?.kind === "url") { + u.searchParams.set("bundle", this.source.url); + if (this.source.sources) u.searchParams.set("sources", this.source.sources); + else u.searchParams.delete("sources"); + } else { + u.searchParams.delete("bundle"); + u.searchParams.delete("sources"); + } + if (this._cmp?.oldUrl) u.searchParams.set("compare", this._cmp.oldUrl); + else u.searchParams.delete("compare"); + const complete = this.source?.kind !== "files" && (!this._cmp || !!this._cmp.oldUrl); + return { url: u.toString(), complete }; + } + + private async share(): Promise { + const { url, complete } = this.shareLink(); + const ok = await copyText(url); + if (!ok) { + window.prompt("Copy this link", url); + return; + } + this.toast(complete ? "Link to this view copied" : "Link copied. It opens this view only where the same files are loaded: files from your computer are not part of it."); + } + + toast(msg: string): void { + this.toastEl?.remove(); + this.toastEl = h("div", { class: "toast" }, msg); + this.shellEl.appendChild(this.toastEl); + setTimeout(() => this.toastEl?.remove(), msg.length > 60 ? 5000 : 2200); + } + + refresh(): void { + const top = this.mainEl.scrollTop; + this.render(); + this.mainEl.scrollTop = top; + } + + sourceText(file: number): Promise { + if (!this.sources || !this.model) return Promise.resolve(null); + let p = this.sourceCache.get(file); + if (!p) { + p = this.sources(file, this.model.b.files[file]?.name ?? ""); + this.sourceCache.set(file, p); + } + return p; + } + + // ---- routing ------------------------------------------------------------ + private hashFor(r: Route): string { + const q = new URLSearchParams(); + if (r.tab) q.set("tab", r.tab); + for (const [k, v] of Object.entries(r.params)) if (v) q.set(k, v); + const qs = q.toString(); + let path = "#/"; + if (r.view === "comp" && r.id !== undefined && this.model) { + const c = this.model.comp(r.id); + path = c.parent === undefined && c.name ? `#/${KIND_SLUG[c.kind]}/${encodeURIComponent(this.model.qname(r.id))}` : `#/id/${r.id}`; + } else if (r.view === "file" && r.id !== undefined) path = `#/file/${r.id}`; + else if (r.view === "diffc" && r.query) { + const i = r.query.indexOf("/"); + path = `#/diff/${r.query.slice(0, i)}/${encodeURIComponent(r.query.slice(i + 1))}`; + } + else if (r.view !== "home") path = "#/" + r.view; + return path + (qs ? "?" + qs : ""); + } + + private parseHash(hash: string): Route { + const [path, qs] = hash.replace(/^#/, "").split("?"); + const params: Record = {}; + let tab: string | undefined; + for (const [k, v] of new URLSearchParams(qs ?? "")) { + if (k === "tab") tab = v; + else params[k] = v; + } + const parts = path.split("/").filter(Boolean); + const m = this.model; + if (!m) return { view: "home", params }; + if (parts[0] === "overview") return { view: "overview", params }; + if (parts[0] === "diff") { + if (parts.length >= 3) return { view: "diffc", query: `${parts[1]}/${decodeURIComponent(parts.slice(2).join("/"))}`, params }; + return { view: "diff", params }; + } + if (parts[0] === "file" && parts[1] !== undefined) { + const n = Number(parts[1]); + if (Number.isInteger(n) && m.b.files[n]) return { view: "file", id: n, params }; + } + if (parts[0] === "id" && parts[1] !== undefined) { + const n = Number(parts[1]); + if (Number.isInteger(n) && m.c[n]) return { view: "comp", id: n, tab, params }; + } + if (parts.length >= 2) { + const qn = decodeURIComponent(parts.slice(1).join("/")); + const id = m.find(parts[0], qn); + if (id !== undefined) return { view: "comp", id, tab, params }; + return { view: "missing", query: qn, params }; + } + return { view: "home", params }; + } + + readHash(): void { + if (!this.model) return; + this.setRoute(this.parseHash(location.hash)); + } + + private setRoute(r: Route): void { + this._route = r; + this.render(); + const name = r.view === "comp" && r.id !== undefined && this.model ? this.model.qname(r.id) : null; + this.opts.onNavigate?.(name, r); + } + + /** Public: open a global component by `kind/prefix:name` or `prefix:name`. */ + goTo(name: string, tab?: string): boolean { + if (!this.model) return false; + const m = this.model; + let id: Id | undefined; + const slash = name.indexOf("/"); + if (slash > 0) id = m.find(name.slice(0, slash), name.slice(slash + 1)); + for (const k of ["ct", "el", "st", "gr", "ag", "at"]) if (id === undefined) id = m.find(k, name); + if (id === undefined) return false; + this.go(id, { tab }); + return true; + } + + // ---- loading ------------------------------------------------------------ + async start(src?: string | null, focus?: string | null, sources?: string | null, compare?: string | null): Promise { + this.server = await serverStatus(); + if (src && compare) { + await this.openCompare({ url: compare }, { url: src, sources: sources ?? undefined }); + return; + } + if (src) { + await this.openUrl(src, focus ?? undefined, sources ?? undefined); + return; + } + if (this.server?.loaded) { + try { + this.showProgress({ stage: "download", detail: "the schemas from the server" }); + const b = await fetchBundleFromServer((p) => this.showProgress(p)); + if (this.server.title) b.title = this.server.title; + this.source = { kind: "server" }; + this.sources = serverSources(); + this.setBundle(b); + if (focus) this.goTo(focus); + return; + } catch (e) { + this.showError(e as Error); + return; + } + } + this.showOpen(); + } + + async openUrl(url: string, focus?: string, sources?: string): Promise { + try { + this.showProgress({ stage: "download", detail: url }); + const b = await loadUrl(url, (p) => this.showProgress(p)); + this.source = { kind: "url", url, sources }; + const abs = new URL(url, document.baseURI).href; + this.sources = urlSources(sources ? new URL(sources, document.baseURI).href : abs); + if (this.opts.routing) { + const u = new URL(location.href); + u.searchParams.delete("src"); + u.searchParams.set("bundle", url); + if (sources) u.searchParams.set("sources", sources); + else u.searchParams.delete("sources"); + history.replaceState(history.state, "", u.toString()); + } + this.setBundle(b); + if (focus) this.goTo(focus); + } catch (e) { + this.showError(e as Error, () => this.showOpen()); + this.opts.onError?.(e as Error); + } + } + + async openFiles(files: NamedBytes[] | FileList | File[]): Promise { + try { + const named = Array.isArray(files) && files.length && "bytes" in (files[0] as object) ? (files as NamedBytes[]) : await readFiles(files as FileList | File[]); + if (this.opts.routing) { + const u = new URL(location.href); + for (const k of ["src", "bundle", "sources"]) u.searchParams.delete(k); + history.replaceState(history.state, "", u.toString()); + } + if (named.length === 0) throw new Error("none of the files is an XML Schema (.xsd) or a bundle (.json)"); + // A single bundle file loads directly. + if (named.length === 1 && /\.json$/i.test(named[0].name)) { + const b = JSON.parse(new TextDecoder().decode(named[0].bytes)) as Bundle; + if (!b || !Array.isArray(b.components)) throw new Error("this JSON file is not a schema-explorer bundle"); + this.source = { kind: "files" }; + this.sources = null; + this.setBundle(b); + return; + } + const xsd = named.filter((f) => /\.xsd$/i.test(f.name)); + if (xsd.length === 0) throw new Error("no .xsd files among the files"); + // Folders keep their relative paths, so imports and includes resolve. + this.showProgress({ stage: "index", detail: xsd.length === 1 ? xsd[0].name : `${xsd.length} files` }); + const started = performance.now(); + const b = await indexFiles(xsd, (p) => this.showProgress(p)); + // A dropped folder names the set. + const top = new Set(xsd.map((f) => (f.name.includes("/") ? f.name.split("/")[0] : ""))); + if (top.size === 1 && !top.has("")) b.title = [...top][0]; + console.info(`indexed ${xsd.length} files in ${Math.round(performance.now() - started)} ms`); + this.source = { kind: "files" }; + this.sources = memorySources(xsd); + this.setBundle(b); + } catch (e) { + this.showError(e as Error, () => this.showOpen()); + this.opts.onError?.(e as Error); + } + } + + /** Read picked or dropped files into a bundle: one bundle file, or .xsd files indexed here. */ + private async bundleFromFiles(named: NamedBytes[]): Promise { + if (named.length === 1 && /\.json$/i.test(named[0].name)) { + const b = JSON.parse(new TextDecoder().decode(named[0].bytes)) as Bundle; + if (!b || !Array.isArray(b.components)) throw new Error("this JSON file is not a schema-explorer bundle"); + return b; + } + const xsd = named.filter((f) => /\.xsd$/i.test(f.name)); + if (xsd.length === 0) throw new Error("no .xsd files among the files"); + const b = await indexFiles(xsd, (p) => this.showProgress(p)); + const top = new Set(xsd.map((f) => (f.name.includes("/") ? f.name.split("/")[0] : ""))); + if (top.size === 1 && !top.has("")) b.title = [...top][0]; + return b; + } + + /** Compare two versions: the later one is explored, with what changed since the earlier one highlighted. */ + async openCompare(older: CompareSide, newer: CompareSide): Promise { + try { + const load = async (side: CompareSide, which: string): Promise => { + if ("url" in side) { + this.showProgress({ stage: "download", detail: `${which}: ${side.url}` }); + return loadUrl(side.url, (p) => this.showProgress(p)); + } + this.showProgress({ stage: "index", detail: which }); + return this.bundleFromFiles(side.files); + }; + const a = await load(older, "the earlier version"); + const b = await load(newer, "the later version"); + this.showProgress({ stage: "index", detail: "comparing the versions" }); + const started = performance.now(); + const diff = await diffBundles(a, b); + console.info(`compared in ${Math.round(performance.now() - started)} ms: ${diff.components.length} differences`); + if ("url" in newer) { + this.source = { kind: "url", url: newer.url, sources: newer.sources }; + const abs = new URL(newer.url, document.baseURI).href; + this.sources = urlSources(newer.sources ? new URL(newer.sources, document.baseURI).href : abs); + } else { + this.source = { kind: "files" }; + this.sources = memorySources(newer.files.filter((f) => /\.xsd$/i.test(f.name))); + } + const oldUrl = "url" in older ? older.url : undefined; + if (this.opts.routing) { + const u = new URL(location.href); + u.searchParams.delete("src"); + if ("url" in newer) u.searchParams.set("bundle", newer.url); + else u.searchParams.delete("bundle"); + if (oldUrl && "url" in newer) u.searchParams.set("compare", oldUrl); + else u.searchParams.delete("compare"); + history.replaceState(history.state, "", u.toString()); + } + const home = !location.hash || location.hash === "#/" || !this.opts.routing; + this.setBundle(b, { old: a, diff, oldUrl }); + if (home) this.goRoute({ view: "diff" }); + } catch (e) { + this.showError(e as Error, () => this.showOpen()); + this.opts.onError?.(e as Error); + } + } + + /** The card of the open page that loads two versions to compare. */ + private compareCard(): HTMLElement { + const sides: (CompareSide | null)[] = [null, null]; + const go = h("button", { type: "button", class: "btn primary", disabled: true, onClick: () => { + if (sides[0] && sides[1]) void this.openCompare(sides[0], sides[1]); + } }, "Compare") as HTMLButtonElement; + const zone = (i: number, title: string) => { + const status = h("span", { class: "muted small" }, "nothing yet"); + const set = (side: CompareSide | null, label: string) => { + sides[i] = side; + status.textContent = label; + z.classList.toggle("set", !!side); + go.disabled = !(sides[0] && sides[1]); + }; + const pick = (files: FileList | null) => { + if (files?.length) void readFiles(files).then((f) => set(f.length ? { files: f } : null, f.length ? plural(f.length, "file") : "no .xsd files")); + }; + const fileInput = h("input", { type: "file", multiple: true, accept: ".xsd,.json", hidden: true, onChange: (ev: Event) => pick((ev.target as HTMLInputElement).files) }) as HTMLInputElement; + const dirInput = h("input", { type: "file", multiple: true, hidden: true, webkitdirectory: true, onChange: (ev: Event) => pick((ev.target as HTMLInputElement).files) }) as HTMLInputElement; + const url = h("input", { class: "input", type: "url", placeholder: "or the address of a bundle", "aria-label": `${title}: address of a bundle or an XSD file`, onInput: (ev: Event) => { + const v = (ev.target as HTMLInputElement).value.trim(); + set(v ? { url: v } : null, v ? "from the web" : "nothing yet"); + } }) as HTMLInputElement; + const z = h( + "div", + { class: "dropzone compare-zone" }, + h("p", null, h("strong", null, title)), + h("p", { class: "small" }, "Drop files or a folder, ", h("button", { type: "button", class: "linkish", onClick: () => fileInput.click() }, "choose files"), " · ", h("button", { type: "button", class: "linkish", onClick: () => dirInput.click() }, "a folder")), + url, + status, + fileInput, + dirInput + ); + z.addEventListener("dragover", (ev) => { + ev.preventDefault(); + z.classList.add("over"); + }); + z.addEventListener("dragleave", () => z.classList.remove("over")); + z.addEventListener("drop", (ev) => { + if (!ev.dataTransfer?.files.length) return; + ev.preventDefault(); + ev.stopPropagation(); + z.classList.remove("over"); + status.textContent = "reading…"; + void readDataTransfer(ev.dataTransfer).then((f) => set(f.length ? { files: f } : null, f.length ? plural(f.length, "file") : "no .xsd files")); + }); + return z; + }; + return h( + "section", + { class: "card open-card compare-card" }, + h("h2", null, "Compare two versions"), + h("p", { class: "muted" }, "What was added, removed or changed between two versions of a schema set, with breaking changes flagged. Matching follows a namespace that changed between the versions."), + h("div", { class: "compare-zones" }, zone(0, "Earlier version"), zone(1, "Later version")), + h("p", null, go), + h("p", { class: "muted small" }, "Links: ", h("code", null, "?bundle="), "later version", h("code", null, "&compare="), "earlier version.") + ); + } + + setBundle(b: Bundle, cmp: { old: Bundle; diff: import("./diff").SchemaDiff; oldUrl?: string } | null = null): void { + const t0 = performance.now(); + this.model = new Model(b); + this._cmp = cmp ? new Compare(new Model(cmp.old), this.model, cmp.diff, cmp.oldUrl) : null; + if (this.opts.routing && !cmp) { + const u = new URL(location.href); + if (u.searchParams.has("compare")) { + u.searchParams.delete("compare"); + history.replaceState(history.state, "", u.toString()); + } + } + this.sourceCache.clear(); + this.rebuildSidebar(); + this.renderTopbar(); + console.info(`model ready in ${Math.round(performance.now() - t0)} ms: ${b.components.length} components`); + this.opts.onLoaded?.(b); + if (this.opts.routing) this.readHash(); + else this.goRoute({ view: "home" }); + } + + private rebuildSidebar(): void { + this.side?.el.remove(); + this.side = null; + if (!this.model || this.opts.minimal) { + this.bodyEl.classList.add("no-sidebar"); + return; + } + this.side = sidebar(this); + this.bodyEl.insertBefore(this.side.el, this.mainEl); + this.bodyEl.classList.remove("no-sidebar"); + } + + // ---- rendering ---------------------------------------------------------- + private setMain(...children: Child[]): void { + const inner = this.mainEl.firstElementChild as HTMLElement; + inner.replaceChildren(); + for (const c of children) if (c) inner.appendChild(c instanceof Node ? c : document.createTextNode(String(c))); + this.mainEl.scrollTop = 0; + } + + private render(): void { + if (!this.model) return; + const r = this._route; + this.side?.select(r.view === "comp" ? r.id : undefined, r.view === "file" ? r.id : undefined); + this.side?.el.classList.remove("open"); + let page: HTMLElement; + const title = this.model.title; + if (r.view === "comp" && r.id !== undefined) { + page = componentPage(this, r.id); + const banner = this._cmp ? diffBanner(this, this._cmp, r.id) : null; + if (banner) page.insertBefore(banner, page.firstChild); + if (this.opts.routing) document.title = `${this.model.label(r.id)} · ${title}`; + } else if (r.view === "file" && r.id !== undefined) { + page = filePage(this, r.id); + if (this.opts.routing) document.title = `${this.model.b.files[r.id]?.name} · ${title}`; + } else if (r.view === "diff" && this._cmp) { + page = diffOverviewPage(this, this._cmp); + if (this.opts.routing) document.title = `Changes · ${title}`; + } else if (r.view === "diffc" && this._cmp && r.query) { + const i = r.query.indexOf("/"); + const cd = this._cmp.find(r.query.slice(0, i), r.query.slice(i + 1)); + page = cd ? diffComponentPage(this, this._cmp, cd) : h("div", null, h("h1", null, "Not changed"), h("p", null, h("code", null, r.query), " did not change between the versions."), button("All changes", () => this.goRoute({ view: "diff" }))); + if (this.opts.routing) document.title = `${r.query.slice(i + 1)} (changes) · ${title}`; + } else if (r.view === "overview") { + page = overviewPage(this); + if (this.opts.routing) document.title = `Overview · ${title}`; + } else if (r.view === "missing") { + page = h("div", null, h("h1", null, "Not found"), h("p", null, "Nothing in the loaded schemas is called ", h("code", null, r.query ?? ""), "."), button("Back to the start", () => this.goRoute({ view: "home" }))); + } else { + page = homePage(this); + if (this.opts.routing) document.title = `${title} · schema-explorer`; + } + this.setMain(page, this.opts.minimal ? null : this.footer()); + } + + private footer(): HTMLElement { + return h("div", { class: "about-foot" }, "schema-explorer · ", h("a", { href: "https://github.com/Matdata-eu/schema-explorer", target: "_blank", rel: "noopener" }, "source"), " · ", h("a", { href: "https://github.com/Matdata-eu/schema-explorer/issues", target: "_blank", rel: "noopener" }, "questions, bugs and feature requests"), " · ", h("a", { href: "#", onClick: (ev: Event) => { + ev.preventDefault(); + this.about(); + } }, "about")); + } + + private renderTopbar(): void { + const t = this.topbar; + t.replaceChildren(); + const m = this.model; + if (m) t.appendChild(button("", () => this.side?.el.classList.toggle("open"), { icon: "menu", cls: "ghost icon-only menu-btn", title: "Types, elements and search" })); + t.appendChild(h("a", { class: "title", href: this.hrefRoute({ view: "home" }), onClick: (ev: Event) => { + ev.preventDefault(); + if (this.model) this.goRoute({ view: "home" }); + else this.showOpen(); + } }, matdataMark(), h("span", { class: "title-text" }, m?.title ?? "schema-explorer"), m ? h("small", { class: "hide-mobile" }, `${(m.b.stats.global.complexType ?? 0).toLocaleString()} complex types`) : null)); + t.appendChild(h("span", { class: "spacer" })); + if (m && this._cmp) t.appendChild(button("Changes", () => this.goRoute({ view: "diff" }), { icon: "table", cls: "small", title: `What changed since ${this._cmp.oldTitle}` })); + if (m) t.appendChild(button("Map", () => this.goRoute({ view: "overview" }), { icon: "map", cls: "small hide-mobile", title: "Overview map of the complex types" })); + if (m) t.appendChild(button("Share", () => void this.share(), { icon: "share", cls: "small share-btn", title: "Copy a link to exactly this view" })); + if (m) t.appendChild(button("Open…", () => this.showOpen(), { icon: "folder", cls: "small hide-mobile", title: "Load other schemas" })); + const dark = this.isDark(); + t.appendChild(button("", () => this.setPrefs({ theme: this.isDark() ? "light" : "dark" }), { icon: dark ? "sun" : "moon", cls: "ghost icon-only", title: "Switch theme" })); + t.appendChild(button("", () => openHelp(this.shellEl), { icon: "help", cls: "ghost icon-only", title: "Help" })); + t.appendChild(button("", () => this.about(), { icon: "info", cls: "ghost icon-only about-btn", title: "About" })); + } + + private isDark(): boolean { + const t = this._prefs.theme; + return t === "dark" || (t === "auto" && window.matchMedia?.("(prefers-color-scheme: dark)").matches); + } + + private applyTheme(): void { + const host = this.root.getRootNode() instanceof ShadowRoot ? (this.root.getRootNode() as ShadowRoot).host : this.root; + host.setAttribute("theme", this.isDark() ? "dark" : "light"); + this.renderTopbar(); + // Diagrams take their colours from the theme when drawn. + if (this.model && this._route.view !== "home") this.refresh(); + } + + private about(): void { + const lic = this.server ? new URL("licenses", document.baseURI).href : "https://github.com/Matdata-eu/schema-explorer/blob/main/THIRD_PARTY_NOTICES.md"; + openAbout(this.shellEl, { licensesUrl: lic, generator: this.model?.b.generator }); + } + + // ---- open / progress / errors -------------------------------------------- + showOpen(): void { + const fileInput = h("input", { type: "file", multiple: true, accept: ".xsd,.json", hidden: true, onChange: (ev: Event) => { + const files = (ev.target as HTMLInputElement).files; + if (files?.length) void this.openFiles(files); + } }) as HTMLInputElement; + const dirInput = h("input", { type: "file", multiple: true, hidden: true, webkitdirectory: true, onChange: (ev: Event) => { + const files = (ev.target as HTMLInputElement).files; + if (files?.length) void this.openFiles(files); + } }) as HTMLInputElement; + const drop = h( + "div", + { class: "dropzone" }, + icon("folder", "drop-icon"), + h("p", null, h("strong", null, "Drop .xsd files or folders here"), h("br"), "or ", h("button", { type: "button", class: "linkish", onClick: () => fileInput.click() }, "choose files"), " · ", h("button", { type: "button", class: "linkish", onClick: () => dirInput.click() }, "choose a folder")), + h("p", { class: "muted small" }, "Include the files the schema imports and includes. A bundle made with ", h("code", null, "schema-explorer index"), " (.json) works too."), + fileInput, + dirInput + ); + drop.addEventListener("click", (ev) => { + if (ev.target === drop) fileInput.click(); + }); + const urlInput = h("input", { class: "input", type: "url", placeholder: "https://example.org/schemas/bundle.json", "aria-label": "Address of a bundle or an XSD file", required: true }) as HTMLInputElement; + const form = h("form", { class: "url-form", onSubmit: (ev: Event) => { + ev.preventDefault(); + if (urlInput.value.trim()) void this.openUrl(urlInput.value.trim()); + } }, urlInput, h("button", { type: "submit", class: "btn primary" }, "Open")); + const examples = h("div", { class: "examples" }); + const page = h( + "div", + { class: "open-page" }, + this.model ? h("p", null, button("Back", () => this.render(), { icon: "back", cls: "small ghost" })) : null, + h("header", { class: "open-hero" }, h("h1", null, this.model ? "Open other schemas" : "Explore an XML Schema"), h("p", { class: "lead" }, "Types, elements and groups as UML-like diagrams, content-model trees and readable pages. The files are indexed in your browser; nothing is uploaded.")), + h( + "div", + { class: "open-grid" }, + h("section", { class: "card open-card" }, h("h2", null, "From your computer"), drop), + h("section", { class: "card open-card" }, h("h2", null, "From the web"), h("p", { class: "muted" }, "The address of a bundle made with ", h("code", null, "schema-explorer index"), ", or of a single .xsd file."), form, h("p", { class: "muted small" }, "Add ", h("code", null, "?bundle="), " and the address to this page's link to open it straight away. The site must allow cross-origin requests.")) + ), + this.compareCard(), + examples + ); + this.setMain(page, this.footer()); + void this.loadExamples(examples); + } + + private async loadExamples(host: HTMLElement): Promise { + const url = this.opts.examplesUrl ?? (this.opts.routing ? new URL("examples/examples.json", document.baseURI).href : null); + if (!url) return; + try { + const r = await fetch(url, { cache: "no-cache" }); + if (!r.ok || !(r.headers.get("content-type") ?? "").includes("json")) return; + const list = (await r.json()) as Example[]; + if (!Array.isArray(list) || list.length === 0) return; + const base = new URL(url, document.baseURI); + host.append( + h("h2", null, "Examples"), + h( + "div", + { class: "example-grid" }, + list.map((ex) => + h( + "div", + { class: "example-card" }, + h( + "button", + { type: "button", class: "example", onClick: () => { + // Links stay short and portable when the examples sit next to the app. + const here = new URL(".", document.baseURI).href; + const rel = (u: string) => (u.startsWith(here) ? u.slice(here.length) : u); + const src = rel(new URL(ex.src, base).href); + const sources = ex.sources ? rel(new URL(ex.sources, base).href) : undefined; + void this.openUrl(src, ex.focus, sources); + } }, + h("strong", null, ex.title, ex.version ? h("span", { class: "example-version" }, ex.version) : null), + ex.description ? h("span", { class: "muted" }, ex.description) : null, + ex.publisher ? h("span", { class: "faint small" }, ex.publisher, ex.licence ? ` · ${ex.licence}` : "") : null + ), + ex.link && /^https?:/.test(ex.link) + ? h("a", { class: "example-source small", href: ex.link, target: "_blank", rel: "noopener" }, "Source ↗") + : null + ) + ) + ) + ); + } catch { + /* no examples */ + } + } + + showProgress(p: LoadProgress): void { + const label = p.stage === "download" ? "Downloading" : p.stage === "read" ? "Reading" : "Indexing"; + const det = p.loaded !== undefined ? `${(p.loaded / 1024 / 1024).toFixed(1)} MB${p.total ? ` of ${(p.total / 1024 / 1024).toFixed(1)} MB` : ""}` : p.detail ?? ""; + const pct = p.loaded !== undefined && p.total ? Math.min(100, (100 * p.loaded) / p.total) : undefined; + const bar = h("i", { class: pct !== undefined ? "det" : "", style: pct !== undefined ? { width: pct + "%" } : {} }); + this.setMain(h("div", { class: "progress" }, h("div", null, h("strong", null, label), " ", h("span", { class: "muted" }, det)), h("div", { class: "bar" }, bar))); + } + + showError(e: Error, back?: () => void): void { + this.setMain(h("div", null, h("div", { class: "error" }, String(e.message || e)), back ? button("Try something else", back) : null)); + } + + private wireDrop(): void { + const root = this.root; + let over: HTMLElement | null = null; + root.addEventListener("dragover", (ev) => { + if (!ev.dataTransfer?.types.includes("Files")) return; + ev.preventDefault(); + over = root.querySelector(".dropzone"); + over?.classList.add("over"); + }); + root.addEventListener("dragleave", () => over?.classList.remove("over")); + root.addEventListener("drop", (ev) => { + if (!ev.dataTransfer?.files.length) return; + ev.preventDefault(); + over?.classList.remove("over"); + const dt = ev.dataTransfer; + this.showProgress({ stage: "read", detail: "the dropped files" }); + void readDataTransfer(dt).then((files) => this.openFiles(files.length ? files : []), (e) => this.showError(e as Error, () => this.showOpen())); + }); + } +} + +const PREFS_KEY = "schema-explorer.prefs"; +function loadPrefs(): Partial { + try { + return JSON.parse(localStorage.getItem(PREFS_KEY) ?? "{}"); + } catch { + return {}; + } +} +function savePrefs(p: Prefs): void { + try { + localStorage.setItem(PREFS_KEY, JSON.stringify(p)); + } catch { + /* private mode */ + } +} diff --git a/web/src/ctx.ts b/web/src/ctx.ts new file mode 100644 index 0000000..b21c813 --- /dev/null +++ b/web/src/ctx.ts @@ -0,0 +1,46 @@ +// What every view gets: the model, navigation, and small shared UI pieces. +import type { Compare } from "./diff"; +import type { Id, Model } from "./model"; + +export interface Route { + /** "home" | "comp" | "file" | "overview" | "missing" | "diff" | "diffc" (query: `slug/qname`) */ + view: string; + id?: Id; + /** Sub view of a page: "diagram" | "tree" */ + tab?: string; + query?: string; + /** Extra state in the URL (depth, direction...). */ + params: Record; +} + +export interface Prefs { + theme: "light" | "dark" | "auto"; + showInherited: boolean; + depth: 1 | 2; + direction: "DOWN" | "RIGHT"; + enums: boolean; + incoming: boolean; +} + +export interface AppCtx { + readonly m: Model; + readonly route: Route; + readonly prefs: Prefs; + readonly embed: boolean; + /** Compare mode: the earlier version and the diff to it (the model is the later version). */ + readonly cmp: Compare | null; + go(id: Id, extra?: Partial): void; + goRoute(r: Partial): void; + href(id: Id, extra?: Partial): string; + hrefRoute(r: Partial): string; + setPrefs(p: Partial): void; + toast(msg: string): void; + /** Change the current route's parameters in place (no navigation), so the link always matches the view. */ + setParams(p: Record): void; + /** A link to the schema set and the current view; `complete` is false for local files. */ + shareLink(): { url: string; complete: boolean }; + /** Re-render the current page (after a preference change). */ + refresh(): void; + /** The text of a schema file of the bundle, when it can be had. */ + sourceText(file: number): Promise; +} diff --git a/web/src/diagram.ts b/web/src/diagram.ts new file mode 100644 index 0000000..7a1dfe8 --- /dev/null +++ b/web/src/diagram.ts @@ -0,0 +1,696 @@ +// Draws a GraphSpec as a UML-style SVG: ELK for layout, hand-made rendering, +// pan and zoom, highlighting, SVG and PNG export. The content-model tree +// reuses the canvas (pan, zoom, export) with its own drawing, see drawRaw. +import { clear, downloadBlob, icon, s } from "./dom"; +import type { AttrLine, EdgeSpec, GraphSpec, NodeSpec } from "./graph"; +import { layout, type ElkNode } from "./layout"; + +export const SANS = 'Barlow, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif'; +export const MONO = '"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace'; +const FONT = `12.5px ${SANS}`; +const NAME_FONT = `600 14px ${SANS}`; +const STEREO_FONT = `11px ${SANS}`; +const LINE = 17; +const PAD_X = 12; +const HEAD_H = 30; +const STEREO_H = 14; +const MIN_W = 120; +const MAX_W = 320; + +let ctx: CanvasRenderingContext2D | null = null; +export function measure(text: string, font = FONT): number { + if (!ctx) { + const c = document.createElement("canvas"); + ctx = c.getContext("2d"); + } + if (!ctx) return text.length * 7; + ctx.font = font; + return ctx.measureText(text).width; +} + +const EDGE_FONT = `11px ${MONO}`; +/** Long property names would stretch the layout; the full name shows on hover. */ +function edgeLabel(text: string): string { + return ellipsize(text, 170, EDGE_FONT); +} + +function ellipsize(text: string, max: number, font = FONT): string { + if (measure(text, font) <= max) return text; + let lo = 0; + let hi = text.length; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (measure(text.slice(0, mid) + "…", font) <= max) lo = mid; + else hi = mid - 1; + } + return text.slice(0, Math.max(1, lo)) + "…"; +} + +interface Sized { + w: number; + h: number; + /** Rows of (group header | attribute) in display order. */ + rows: ({ header: string } | AttrLine)[]; +} + +function attrText(a: AttrLine): string { + return `${a.text}${a.type ? " : " + a.type : ""}${a.mult ? " [" + a.mult + "]" : ""}`; +} + +function size(n: NodeSpec): Sized { + const rows: ({ header: string } | AttrLine)[] = []; + let group: string | undefined; + for (const a of n.attrs) { + if (a.groupKey !== group) { + group = a.groupKey; + if (a.group) rows.push({ header: a.group }); + } + rows.push(a); + } + let w = Math.max(MIN_W, measure(n.name, NAME_FONT) + PAD_X * 2 + 8); + if (n.stereotype) w = Math.max(w, measure(n.stereotype, STEREO_FONT) + PAD_X * 2); + for (const r of rows) w = Math.max(w, measure("header" in r ? r.header : attrText(r)) + PAD_X * 2 + 4); + if (n.hiddenAttrs) w = Math.max(w, measure(`+${n.hiddenAttrs} more…`) + PAD_X * 2); + w = Math.min(MAX_W, Math.ceil(w)); + let h = HEAD_H + (n.stereotype ? STEREO_H : 0); + const body = rows.length + (n.hiddenAttrs ? 1 : 0); + if (body > 0) h += body * LINE + 8; + else if (n.kind !== "more") h += 6; + return { w, h: Math.ceil(h), rows }; +} + +export interface DiagramCallbacks { + onNavigate?: (id: number) => void; + onExpand?: (id: number) => void; + onToggleAttrs?: (id: number) => void; + onMore?: (ids: number[], relation: string) => void; + onStatus?: (text: string) => void; + /** Pan and zoom changed by the user (null: back to "fit"), for links to the view. */ + onViewChange?: (view: View | null) => void; + /** Actions offered when a box is clicked. Without them, a click opens the entity. */ + nodeMenu?: (id: number) => NodeAction[]; +} + +export interface View { + tx: number; + ty: number; + scale: number; +} + +export interface NodeAction { + label: string; + icon?: string; + run: () => void; +} + +export interface RenderOptions { + direction: "DOWN" | "RIGHT"; + /** Keep the current pan/zoom when re-rendering. */ + keepView?: boolean; + /** Start from this pan and zoom instead of fitting the diagram. */ + view?: View; + /** Use ELK's stress layout (for maps) instead of layered. */ + algorithm?: "layered" | "stress"; +} + +export class Diagram { + readonly svg: SVGSVGElement; + protected viewport: SVGGElement; + protected host: HTMLElement; + protected cb: DiagramCallbacks; + protected scale = 1; + protected tx = 0; + protected ty = 0; + protected content = { w: 0, h: 0 }; + protected spec: GraphSpec | null = null; + protected seq = 0; + + constructor(host: HTMLElement, cb: DiagramCallbacks) { + this.host = host; + this.cb = cb; + this.svg = s("svg", { class: "canvas uml", role: "img" }); + this.svg.appendChild(this.defs()); + this.viewport = s("g", { class: "viewport" }); + this.svg.appendChild(this.viewport); + host.appendChild(this.svg); + this.wireInteraction(); + } + + private defs(): SVGDefsElement { + const d = s("defs"); + const marker = (id: string, path: SVGPathElement, w = 12, h = 12, refX = 11, refY = 6) => { + const m = s("marker", { id, markerWidth: w, markerHeight: h, refX, refY, orient: "auto-start-reverse", markerUnits: "userSpaceOnUse" }); + m.appendChild(path); + d.appendChild(m); + }; + marker("m-tri", s("path", { class: "tri", d: "M1 1L11 6L1 11z" }), 13, 13, 11, 6); + marker("m-arrow", s("path", { class: "arrow", d: "M2 2L10 6L2 10" }), 12, 12, 10, 6); + marker("m-arrow-dep", s("path", { class: "arrow-dep", d: "M2 2L10 6L2 10" }), 12, 12, 10, 6); + marker("m-diamond", s("path", { class: "diamond", d: "M1 6L7 1L13 6L7 11z" }), 14, 12, 1, 6); + return d; + } + + get graph(): GraphSpec | null { + return this.spec; + } + + async render(spec: GraphSpec, opts: RenderOptions): Promise { + const my = ++this.seq; + this.spec = spec; + this.closeMenu(); + // Boxes are sized by measuring text, so wait for the web fonts. + await document.fonts?.ready; + if (my !== this.seq) return; + const sizes = new Map(); + for (const n of spec.nodes) sizes.set(n.key, size(n)); + const algorithm = opts.algorithm ?? "layered"; + const elkGraph: ElkNode = { + id: "root", + layoutOptions: { + "elk.algorithm": algorithm, + "elk.direction": opts.direction, + "elk.spacing.nodeNode": algorithm === "stress" ? "60" : "36", + "elk.layered.spacing.nodeNodeBetweenLayers": "70", + "elk.layered.spacing.edgeNodeBetweenLayers": "30", + "elk.spacing.edgeNode": "24", + "elk.spacing.edgeEdge": "16", + "elk.spacing.edgeLabel": "6", + "elk.edgeRouting": algorithm === "stress" ? "POLYLINE" : "ORTHOGONAL", + "elk.layered.nodePlacement.strategy": "BRANDES_KOEPF", + "elk.layered.considerModelOrder.strategy": "NODES_AND_EDGES", + "elk.layered.crossingMinimization.semiInteractive": "true", + "elk.layered.mergeEdges": "false", + "elk.edgeLabels.inline": "false", + "elk.stress.desiredEdgeLength": "180", + "elk.padding": "[top=20,left=20,bottom=20,right=20]", + }, + children: spec.nodes.map((n) => { + const sz = sizes.get(n.key)!; + return { id: n.key, width: sz.w, height: sz.h }; + }), + edges: spec.edges.map((e) => ({ + id: e.key, + // Generalisations are drawn from the super class (source) to the sub + // class so the hierarchy reads top-down; the triangle sits at the source. + sources: [e.from], + targets: [e.to], + labels: e.label ? [{ text: edgeLabel(e.label), width: Math.ceil(measure(edgeLabel(e.label), EDGE_FONT)) + 6, height: 14 }] : undefined, + layoutOptions: e.kind === "gen" || e.kind === "restr" ? { "elk.layered.priority.direction": "10" } : undefined, + })), + }; + let laid: ElkNode; + try { + laid = await layout(elkGraph); + } catch (e) { + this.cb.onStatus?.(`layout failed: ${(e as Error).message}`); + return; + } + if (my !== this.seq) return; + this.draw(spec, laid, sizes); + if (opts.view) { + ({ tx: this.tx, ty: this.ty, scale: this.scale } = opts.view); + this.applyTransform(false); + } else if (!opts.keepView) this.fit(); + else this.applyTransform(false); + } + + private draw(spec: GraphSpec, laid: ElkNode, sizes: Map): void { + clear(this.viewport); + this.focusAt = null; + const pos = new Map(); + let maxX = 0; + let maxY = 0; + for (const c of laid.children ?? []) { + pos.set(c.id, { x: c.x ?? 0, y: c.y ?? 0 }); + maxX = Math.max(maxX, (c.x ?? 0) + (c.width ?? 0)); + maxY = Math.max(maxY, (c.y ?? 0) + (c.height ?? 0)); + } + const edgesG = s("g", { class: "edges" }); + const nodesG = s("g", { class: "nodes" }); + this.viewport.append(edgesG, nodesG); + const byKey = new Map(spec.edges.map((e) => [e.key, e])); + for (const le of laid.edges ?? []) { + const e = byKey.get(le.id); + if (!e) continue; + const g = this.drawEdge(e, le); + if (g) edgesG.appendChild(g); + for (const l of le.labels ?? []) { + maxX = Math.max(maxX, (l.x ?? 0) + (l.width ?? 0)); + maxY = Math.max(maxY, (l.y ?? 0) + (l.height ?? 0)); + } + } + for (const n of spec.nodes) { + const p = pos.get(n.key); + const sz = sizes.get(n.key); + if (!p || !sz) continue; + nodesG.appendChild(this.drawNode(n, p.x, p.y, sz)); + if (n.focus) this.focusAt = { x: p.x + sz.w / 2, y: p.y + sz.h / 2 }; + } + this.content = { w: maxX + 20, h: maxY + 20 }; + this.cb.onStatus?.(`${spec.nodes.length} boxes, ${spec.edges.length} arrows${spec.omitted ? `, ${spec.omitted} neighbours not shown` : ""}`); + } + + private drawEdge(e: EdgeSpec, le: NonNullable[number]): SVGGElement | null { + const sec = le.sections?.[0]; + if (!sec) return null; + const pts = [sec.startPoint, ...(sec.bendPoints ?? []), sec.endPoint]; + const d = pts.map((p, i) => `${i === 0 ? "M" : "L"}${p.x.toFixed(1)} ${p.y.toFixed(1)}`).join(" "); + const g = s("g", { class: `edge ${e.kind}${e.diff ? ` d-${e.diff}` : ""}`, "data-from": e.from, "data-to": e.to }); + const path = s("path", { d }); + switch (e.kind) { + case "gen": + case "restr": + path.setAttribute("marker-start", "url(#m-tri)"); + break; + case "comp": + path.setAttribute("marker-start", "url(#m-diamond)"); + path.setAttribute("marker-end", "url(#m-arrow)"); + break; + case "dep": + case "typed": + path.setAttribute("marker-end", "url(#m-arrow-dep)"); + break; + default: + path.setAttribute("marker-end", "url(#m-arrow)"); + } + g.appendChild(path); + // A wider invisible path makes hovering easier. + g.appendChild(s("path", { d, style: { stroke: "transparent", strokeWidth: "10", fill: "none" } })); + if (e.label) { + const l = le.labels?.[0]; + const x = l ? (l.x ?? 0) + (l.width ?? 0) / 2 : (pts[0].x + pts[pts.length - 1].x) / 2; + const y = l ? (l.y ?? 0) + (l.height ?? 0) / 2 : (pts[0].y + pts[pts.length - 1].y) / 2; + const t = s("text", { x, y, "text-anchor": "middle" }, edgeLabel(e.label)); + if (t.textContent !== e.label) t.appendChild(s("title", null, e.label)); + g.appendChild(t); + } + if (e.mult) { + // Multiplicity near the target end. + const end = pts[pts.length - 1]; + const prev = pts[pts.length - 2] ?? end; + const dx = Math.sign(end.x - prev.x); + const dy = Math.sign(end.y - prev.y); + const x = end.x - dx * 14 + (dx === 0 ? 8 : 0); + const y = end.y - dy * 14 + (dy === 0 ? -8 : 0); + g.appendChild(s("text", { class: "mult", x, y, "text-anchor": dx === 0 ? "start" : dx > 0 ? "end" : "start" }, e.mult)); + } + if (e.id !== undefined) { + g.style.cursor = "pointer"; + g.addEventListener("click", (ev) => { + ev.stopPropagation(); + this.cb.onNavigate?.(e.id as number); + }); + g.appendChild(s("title", null, `${e.label ?? ""} ${e.mult ?? ""}`.trim())); + } + return g; + } + + private drawNode(n: NodeSpec, x: number, y: number, sz: Sized): SVGGElement { + const cls = ["node", n.kind, n.focus ? "focus" : "", n.external ? "external" : "", n.diff ? `d-${n.diff}` : ""].filter(Boolean).join(" "); + const g = s("g", { class: cls, transform: `translate(${x} ${y})`, "data-key": n.key }); + g.appendChild(s("rect", { class: "box", width: sz.w, height: sz.h, rx: 10 })); + const headH = HEAD_H + (n.stereotype ? STEREO_H : 0); + g.appendChild(s("rect", { class: "head", width: sz.w, height: headH, rx: 10 })); + // Square off the bottom of the head when there is a body. + if (sz.rows.length || n.hiddenAttrs) g.appendChild(s("rect", { class: "head", y: headH - 10, width: sz.w, height: 10 })); + let ty = 0; + if (n.stereotype) { + g.appendChild(s("text", { class: "stereo", x: sz.w / 2, y: 11 }, ellipsize(n.stereotype, sz.w - 10, STEREO_FONT))); + ty = STEREO_H; + } + const nameCls = ["name", n.abstract ? "abstract" : ""].filter(Boolean).join(" "); + g.appendChild(s("text", { class: nameCls, x: sz.w / 2, y: ty + HEAD_H / 2 + 1 }, ellipsize(n.name, sz.w - PAD_X * 2, NAME_FONT))); + if (sz.rows.length || n.hiddenAttrs) { + g.appendChild(s("line", { class: "sep", x1: 0, y1: headH, x2: sz.w, y2: headH })); + let yy = headH + 4 + LINE / 2; + for (const r of sz.rows) { + if ("header" in r) { + g.appendChild(s("text", { class: "attr", x: sz.w / 2, y: yy, "text-anchor": "middle", style: { fontStyle: "italic", fill: "var(--se-text-3)" } }, ellipsize(r.header, sz.w - PAD_X * 2))); + } else { + const t = s("text", { class: `attr${r.inherited ? " inh" : ""}${r.diff ? ` d-${r.diff}` : ""}`, x: PAD_X, y: yy }); + const full = attrText(r); + if (measure(full) > sz.w - PAD_X * 2) { + t.textContent = ellipsize(full, sz.w - PAD_X * 2); + } else { + t.appendChild(document.createTextNode(r.text)); + if (r.type) t.appendChild(s("tspan", { class: "type" }, ` : ${r.type}`)); + if (r.mult) t.appendChild(s("tspan", { class: "mult" }, ` [${r.mult}]`)); + } + if (r.id !== undefined) { + t.style.cursor = "pointer"; + t.addEventListener("click", (ev) => { + ev.stopPropagation(); + this.cb.onNavigate?.(r.id as number); + }); + } + t.appendChild(s("title", null, full)); + g.appendChild(t); + } + yy += LINE; + } + if (n.hiddenAttrs) { + const more = s("text", { class: "more", x: PAD_X, y: yy }, `+${n.hiddenAttrs} more…`); + more.addEventListener("click", (ev) => { + ev.stopPropagation(); + if (n.id !== undefined) this.cb.onToggleAttrs?.(n.id); + }); + g.appendChild(more); + } + } + g.appendChild(s("title", null, `${n.name}${n.stereotype ? " " + n.stereotype : ""}\n${this.cb.nodeMenu ? "click: options" : "click: open"}${this.cb.onExpand ? " · double-click: expand neighbours" : ""}`)); + if (n.kind === "more" && n.moreOf) { + g.addEventListener("click", () => this.cb.onMore?.(n.moreOf!.ids, n.moreOf!.relation)); + } else if (n.id !== undefined) { + let clickTimer: ReturnType | undefined; + g.addEventListener("click", (ev) => { + ev.stopPropagation(); + if (clickTimer) clearTimeout(clickTimer); + const { clientX, clientY } = ev; + clickTimer = setTimeout(() => { + const actions = this.cb.nodeMenu?.(n.id as number); + if (actions?.length) this.showMenu(n.name, actions, clientX, clientY); + else this.cb.onNavigate?.(n.id as number); + }, 220); + }); + g.addEventListener("dblclick", (ev) => { + ev.stopPropagation(); + if (clickTimer) clearTimeout(clickTimer); + this.cb.onExpand?.(n.id as number); + }); + } + g.addEventListener("pointerenter", () => this.highlight(n.key)); + g.addEventListener("pointerleave", () => this.highlight(null)); + return g; + } + + private menu: HTMLElement | null = null; + + /** The small menu a click on a box opens, at the click position. */ + protected showMenu(title: string, actions: NodeAction[], x: number, y: number): void { + this.closeMenu(); + const menu = document.createElement("div"); + menu.className = "menu node-menu"; + menu.setAttribute("role", "menu"); + const head = document.createElement("div"); + head.className = "mh"; + head.textContent = title; + menu.appendChild(head); + for (const a of actions) { + const b = document.createElement("button"); + b.type = "button"; + b.setAttribute("role", "menuitem"); + if (a.icon) b.appendChild(icon(a.icon)); + b.append(a.label); + b.addEventListener("click", (ev) => { + ev.stopPropagation(); + this.closeMenu(); + a.run(); + }); + menu.appendChild(b); + } + this.host.appendChild(menu); + const box = this.host.getBoundingClientRect(); + const left = Math.max(4, Math.min(x - box.left + 4, box.width - menu.offsetWidth - 4)); + const top = Math.max(4, Math.min(y - box.top + 4, box.height - menu.offsetHeight - 4)); + menu.style.left = `${left}px`; + menu.style.top = `${top}px`; + this.menu = menu; + (menu.querySelector("button") as HTMLButtonElement | null)?.focus(); + const away = (ev: Event) => { + if (ev instanceof KeyboardEvent && ev.key !== "Escape") return; + if (ev.type === "pointerdown" && ev.composedPath().includes(menu)) return; + this.closeMenu(); + }; + const root = this.host.getRootNode() as Document | ShadowRoot; + root.addEventListener("pointerdown", away, true); + root.addEventListener("keydown", away, true); + this.svg.addEventListener("wheel", away, { passive: true }); + this.unbindMenu = () => { + root.removeEventListener("pointerdown", away, true); + root.removeEventListener("keydown", away, true); + this.svg.removeEventListener("wheel", away); + }; + } + + private unbindMenu: (() => void) | null = null; + + closeMenu(): void { + this.unbindMenu?.(); + this.unbindMenu = null; + this.menu?.remove(); + this.menu = null; + } + + private highlight(key: string | null): void { + const edges = this.viewport.querySelectorAll(".edge"); + const nodes = this.viewport.querySelectorAll(".node"); + if (!key) { + edges.forEach((e) => e.classList.remove("dim")); + nodes.forEach((n) => n.classList.remove("dim")); + return; + } + const linked = new Set([key]); + edges.forEach((e) => { + const hit = e.dataset.from === key || e.dataset.to === key; + e.classList.toggle("dim", !hit); + if (hit) { + linked.add(e.dataset.from!); + linked.add(e.dataset.to!); + } + }); + nodes.forEach((n) => n.classList.toggle("dim", !linked.has(n.dataset.key!))); + } + + /** Draw something else than a graph (the content-model tree) on the same canvas. */ + drawRaw(draw: (viewport: SVGGElement) => { w: number; h: number }, opts: { keepView?: boolean; view?: View } = {}): void { + this.seq++; + this.closeMenu(); + clear(this.viewport); + const size = draw(this.viewport); + this.content = { w: size.w + 20, h: size.h + 20 }; + if (opts.view) { + ({ tx: this.tx, ty: this.ty, scale: this.scale } = opts.view); + this.applyTransform(false); + } else if (!opts.keepView) this.fit(); + else this.applyTransform(false); + } + + // ---- pan & zoom --------------------------------------------------------- + + private viewTimer: ReturnType | undefined; + + /** `byUser`: a pan or zoom worth remembering in the link to this view. */ + protected applyTransform(byUser = true): void { + this.viewport.setAttribute("transform", `translate(${this.tx} ${this.ty}) scale(${this.scale})`); + if (byUser && this.cb.onViewChange) { + clearTimeout(this.viewTimer); + this.viewTimer = setTimeout(() => this.cb.onViewChange?.({ tx: this.tx, ty: this.ty, scale: this.scale }), 400); + } + } + + /** Centre of the focus box, to centre on when the whole diagram would be too small to read. */ + protected focusAt: { x: number; y: number } | null = null; + /** The smallest scale "fit" goes down to; beyond it the view centres on the focus. */ + minFitScale = 0.45; + + fit(): void { + const box = this.svg.getBoundingClientRect(); + if (!box.width || !this.content.w) return; + // Keep clear of the toolbar on top and the status line at the bottom. + const top = 48; + const bottom = 30; + const sx = (box.width - 24) / this.content.w; + const sy = (box.height - top - bottom) / this.content.h; + const want = Math.min(sx, sy); + this.scale = Math.min(1.1, Math.max(this.focusAt ? this.minFitScale : 0.1, want)); + this.tx = (box.width - this.content.w * this.scale) / 2; + this.ty = top + Math.max(0, (box.height - top - bottom - this.content.h * this.scale) / 2); + if (this.focusAt && want < this.scale) { + // Too big to read whole: keep the focus in view, pan for the rest. + if (sx < this.scale) this.tx = Math.min(12, Math.max(box.width - 12 - this.content.w * this.scale, box.width / 2 - this.focusAt.x * this.scale)); + if (sy < this.scale) this.ty = Math.min(top, Math.max(box.height - bottom - this.content.h * this.scale, (box.height + top - bottom) / 2 - this.focusAt.y * this.scale)); + } + this.applyTransform(false); + clearTimeout(this.viewTimer); + this.cb.onViewChange?.(null); + } + + zoomBy(f: number): void { + const box = this.svg.getBoundingClientRect(); + this.zoomAt(f, box.width / 2, box.height / 2); + } + + private zoomAt(f: number, px: number, py: number): void { + const ns = Math.min(4, Math.max(0.1, this.scale * f)); + const k = ns / this.scale; + this.tx = px - (px - this.tx) * k; + this.ty = py - (py - this.ty) * k; + this.scale = ns; + this.applyTransform(); + } + + private wireInteraction(): void { + let dragging: { x: number; y: number; tx: number; ty: number; moved: boolean } | null = null; + const pointers = new Map(); + let pinch: { d: number; scale: number } | null = null; + this.svg.addEventListener("pointerdown", (ev) => { + pointers.set(ev.pointerId, { x: ev.clientX, y: ev.clientY }); + if (pointers.size === 2) { + const [a, b] = [...pointers.values()]; + pinch = { d: Math.hypot(a.x - b.x, a.y - b.y), scale: this.scale }; + dragging = null; + return; + } + dragging = { x: ev.clientX, y: ev.clientY, tx: this.tx, ty: this.ty, moved: false }; + }); + this.svg.addEventListener("pointermove", (ev) => { + if (pointers.has(ev.pointerId)) pointers.set(ev.pointerId, { x: ev.clientX, y: ev.clientY }); + if (pinch && pointers.size === 2) { + const [a, b] = [...pointers.values()]; + const d = Math.hypot(a.x - b.x, a.y - b.y); + const box = this.svg.getBoundingClientRect(); + const f = (pinch.scale * (d / pinch.d)) / this.scale; + this.zoomAt(f, (a.x + b.x) / 2 - box.left, (a.y + b.y) / 2 - box.top); + return; + } + if (!dragging) return; + const dx = ev.clientX - dragging.x; + const dy = ev.clientY - dragging.y; + if (!dragging.moved && Math.abs(dx) + Math.abs(dy) > 3) { + dragging.moved = true; + // Capture only once it is a drag: capturing on pointerdown sends the + // click to the canvas instead of the box under the pointer. + this.svg.setPointerCapture(ev.pointerId); + } + if (!dragging.moved) return; + this.tx = dragging.tx + dx; + this.ty = dragging.ty + dy; + this.applyTransform(); + }); + const up = (ev: PointerEvent) => { + pointers.delete(ev.pointerId); + if (pointers.size < 2) pinch = null; + if (dragging?.moved) { + // Swallow the click that follows a drag. + const stop = (e: Event) => { + e.stopPropagation(); + this.svg.removeEventListener("click", stop, true); + }; + this.svg.addEventListener("click", stop, true); + setTimeout(() => this.svg.removeEventListener("click", stop, true), 0); + } + dragging = null; + }; + this.svg.addEventListener("pointerup", up); + this.svg.addEventListener("pointercancel", up); + this.svg.addEventListener( + "wheel", + (ev) => { + ev.preventDefault(); + const box = this.svg.getBoundingClientRect(); + const f = Math.exp(-ev.deltaY * (ev.deltaMode === 1 ? 0.05 : 0.0015)); + this.zoomAt(f, ev.clientX - box.left, ev.clientY - box.top); + }, + { passive: false } + ); + } + + // ---- export --------------------------------------------------------- + + /** A standalone SVG document with the theme's colours inlined. */ + exportSvg(): string { + const clone = this.svg.cloneNode(true) as SVGSVGElement; + clone.removeAttribute("class"); + clone.setAttribute("xmlns", "http://www.w3.org/2000/svg"); + const vp = clone.querySelector(".viewport"); + vp?.removeAttribute("transform"); + clone.setAttribute("viewBox", `0 0 ${Math.ceil(this.content.w)} ${Math.ceil(this.content.h)}`); + clone.setAttribute("width", String(Math.ceil(this.content.w))); + clone.setAttribute("height", String(Math.ceil(this.content.h))); + clone.querySelectorAll(".dim").forEach((e) => e.classList.remove("dim")); + const style = document.createElementNS("http://www.w3.org/2000/svg", "style"); + style.textContent = this.inlineCss(); + clone.insertBefore(style, clone.firstChild); + const bg = s("rect", { width: "100%", height: "100%", fill: this.cssVar("--se-panel") }); + clone.insertBefore(bg, style.nextSibling); + return '\n' + new XMLSerializer().serializeToString(clone); + } + + protected cssVar(name: string): string { + const v = getComputedStyle(this.host).getPropertyValue(name).trim(); + return v || "#000"; + } + + private inlineCss(): string { + const v = (n: string) => this.cssVar(n); + return ` +.node rect.box{fill:${v("--se-panel")};stroke:${v("--se-border-strong")};stroke-width:1} +.node.focus rect.box{stroke:${v("--se-highlight")};stroke-width:3} +.node.external rect.box{stroke-dasharray:4 3} +.node .head{fill:${v("--se-class")}} +.node .head ~ text.name,.node .head ~ text.stereo{fill:${v("--se-on-accent")}} +.node.external .head,.node.enum .head{fill:${v("--se-accent-bg")}} +.node.external .head ~ text.name,.node.enum .head ~ text.name{fill:${v("--se-accent-strong")}} +.node.external .head ~ text.stereo,.node.enum .head ~ text.stereo{fill:${v("--se-accent-strong")}} +.node.element .head{fill:${v("--se-panel-2")}} +.node.element .head ~ text.name{fill:${v("--se-text")}} +.node.element .head ~ text.stereo{fill:${v("--se-text-2")}} +text{font-family:Barlow,system-ui,-apple-system,"Segoe UI",Roboto,Arial,sans-serif;font-size:12.5px;fill:${v("--se-text")};dominant-baseline:middle} +text.name{font-weight:600;font-size:14px;text-anchor:middle} +text.name.abstract{font-style:italic} +text.stereo{font-size:11px;text-anchor:middle} +text.attr .type{fill:${v("--se-text-2")}} +text.attr .mult{fill:${v("--se-text-2")}} +text.attr.inh{fill:${v("--se-text-2")}} +text.more{fill:${v("--se-accent")};font-size:12px} +text.deprecated{text-decoration:line-through} +line.sep{stroke:${v("--se-border")};stroke-width:1} +.edge path{fill:none;stroke:${v("--se-border-strong")};stroke-width:1.5} +.edge.gen path{stroke:${v("--se-text")}} +.edge.dep path,.edge.restr path{stroke-dasharray:5 3} +.edge.restr path{stroke:${v("--se-text")}} +.edge.typed path{stroke-dasharray:2 3} +.edge text{font-family:"JetBrains Mono",ui-monospace,Menlo,Consolas,monospace;font-size:11px;fill:${v("--se-text-2")};paint-order:stroke;stroke:${v("--se-panel")};stroke-width:3px;stroke-linejoin:round} +marker path.tri{fill:${v("--se-panel")};stroke:${v("--se-text")};stroke-width:1.5} +marker path.arrow,marker path.arrow-dep{fill:none;stroke:${v("--se-border-strong")};stroke-width:1.5} +marker path.diamond{fill:${v("--se-text")};stroke:${v("--se-text")}} +.node.d-added rect.box{stroke:${v("--se-add")};stroke-width:2.5} +.node.d-changed rect.box{stroke:${v("--se-chg")};stroke-width:2.5} +text.attr.d-added{fill:${v("--se-add")}} +text.attr.d-removed{fill:${v("--se-del")};text-decoration:line-through} +text.attr.d-changed{fill:${v("--se-chg")}} +.edge.d-added path{stroke:${v("--se-add")};stroke-width:2} +.edge.d-changed path{stroke:${v("--se-chg")};stroke-width:2} +${this.extraCss()}`; + } + + /** Styles of what drawRaw draws, for the export. */ + protected extraCss(): string { + return ""; + } + + downloadSvg(name: string): void { + downloadBlob(new Blob([this.exportSvg()], { type: "image/svg+xml" }), `${name}.svg`); + } + + async downloadPng(name: string, scale = 2): Promise { + const svg = this.exportSvg(); + const img = new Image(); + const url = URL.createObjectURL(new Blob([svg], { type: "image/svg+xml" })); + await new Promise((resolve, reject) => { + img.onload = () => resolve(); + img.onerror = () => reject(new Error("cannot render SVG")); + img.src = url; + }); + const canvas = document.createElement("canvas"); + canvas.width = Math.ceil(this.content.w * scale); + canvas.height = Math.ceil(this.content.h * scale); + const c = canvas.getContext("2d"); + if (!c) return; + c.fillStyle = this.cssVar("--se-panel"); + c.fillRect(0, 0, canvas.width, canvas.height); + c.drawImage(img, 0, 0, canvas.width, canvas.height); + URL.revokeObjectURL(url); + canvas.toBlob((blob) => blob && downloadBlob(blob, `${name}.png`), "image/png"); + } +} diff --git a/web/src/diff.ts b/web/src/diff.ts new file mode 100644 index 0000000..422f343 --- /dev/null +++ b/web/src/diff.ts @@ -0,0 +1,272 @@ +// Comparison of two versions of a schema set: the diff made by the core +// (see crates/core/src/diff.rs) and lookups for the views that highlight it. +import type { GraphSpec } from "./graph"; +import { KIND_SLUG, type Id, type Kind, type Model } from "./model"; + +export type Status = "added" | "removed" | "changed"; +export type Impact = "breaking" | "nonBreaking" | "info"; +/** How a box, line or tree node differs from the earlier version. */ +export type Mark = Status; + +export interface Change { + kind: string; + impact: Impact; + path?: string; + subject?: string; + old?: string; + new?: string; +} + +export interface Side { + id: Id; + qname: string; + file?: string; + line?: number; +} + +export interface ComponentDiff { + kind: Kind; + status: Status; + name: string; + namespace?: string; + old?: Side; + new?: Side; + byLocalName?: boolean; + impact: Impact; + changes?: Change[]; + possiblyRenamed?: string; +} + +export interface Counts { + added: number; + removed: number; + changed: number; + breaking: number; + docsOnly: number; +} + +export interface SetInfo { + title?: string; + version?: string; + versions?: string[]; + files: number; + namespaces: string[]; + components: number; +} + +export interface SchemaDiff { + format: number; + generator: string; + options: { matchLocalNames: boolean; docs: boolean; renames: boolean }; + old: SetInfo; + new: SetInfo; + namespaceMap?: Record; + summary: { + total: Counts; + byKind: Partial>; + byNamespace: Record; + byFile: Record; + changes: Record; + breakingChanges: number; + possiblyRenamed: number; + }; + components: ComponentDiff[]; + renames?: { kind: Kind; old: Side; new: Side; score: number }[]; +} + +export const MARKER: Record = { added: "+", removed: "−", changed: "~" }; +export const STATUS_LABEL: Record = { added: "Added", removed: "Removed", changed: "Changed" }; + +const LABELS: Record = { + kindChanged: "kind changed", + baseChanged: "base type changed", + derivationChanged: "derivation changed", + abstractChanged: "abstract changed", + finalChanged: "final changed", + blockChanged: "block changed", + mixedChanged: "mixed changed", + nillableChanged: "nillable changed", + typeChanged: "type changed", + valueConstraintChanged: "value constraint changed", + substitutionGroupChanged: "substitution group changed", + substitutionMemberAdded: "substitution member added", + substitutionMemberRemoved: "substitution member removed", + childAdded: "child added", + childRemoved: "child removed", + childRetyped: "child retyped", + childCardinalityChanged: "cardinality changed", + childrenReordered: "children reordered", + childMoved: "child moved", + structureChanged: "structure changed", + attributeAdded: "attribute added", + attributeRemoved: "attribute removed", + attributeRetyped: "attribute retyped", + attributeUseChanged: "attribute use changed", + attributeDefaultChanged: "attribute default changed", + attributeFixedChanged: "attribute fixed value changed", + anyAttributeChanged: "anyAttribute changed", + enumerationAdded: "enumeration value added", + enumerationRemoved: "enumeration value removed", + facetAdded: "facet added", + facetRemoved: "facet removed", + facetChanged: "facet changed", + varietyChanged: "variety changed", + itemTypeChanged: "list item type changed", + memberTypeAdded: "union member added", + memberTypeRemoved: "union member removed", + assertAdded: "assertion added", + assertRemoved: "assertion removed", + docsChanged: "documentation changed", +}; + +export const changeLabel = (k: string): string => LABELS[k] ?? k; + +/** The marker of a change: + for additions, − for removals, ~ otherwise. */ +export function changeMark(k: string): Status { + if (/Added$/.test(k)) return "added"; + if (/Removed$/.test(k)) return "removed"; + return "changed"; +} + +/** The local part of a subject: `@rail3:foo` → `foo`. */ +const local = (s: string) => { + const t = s.replace(/^@/, ""); + return t.slice(t.lastIndexOf(":") + 1); +}; + +export interface ChildMarks { + children: Map; + removed: { name: string; mult?: string }[]; + attrs: Map; + removedAttrs: { name: string; use?: string }[]; +} + +const NONE: ChildMarks = { children: new Map(), removed: [], attrs: new Map(), removedAttrs: [] }; + +/** Both versions and the diff between them; `cur` is the later version, the one the viewer shows. */ +export class Compare { + readonly byNew = new Map(); + readonly byOld = new Map(); + private marks = new Map(); + + constructor( + readonly old: Model, + readonly cur: Model, + readonly d: SchemaDiff, + /** Where the earlier version came from, for share links. */ + readonly oldUrl?: string + ) { + for (const c of d.components) { + if (c.new) this.byNew.set(c.new.id, c); + if (c.old) this.byOld.set(c.old.id, c); + } + } + + get oldTitle(): string { + return this.d.old.title ?? (this.d.old.version ? `version ${this.d.old.version}` : this.old.title); + } + + get newTitle(): string { + return this.d.new.title ?? (this.d.new.version ? `version ${this.d.new.version}` : this.cur.title); + } + + /** The mark of a global component of the later version. */ + mark(id: Id): Mark | undefined { + return this.byNew.get(id)?.status; + } + + /** The diff entry for a link `#/diff//`. */ + find(slug: string, qname: string): ComponentDiff | undefined { + return this.d.components.find((c) => KIND_SLUG[c.kind] === slug && (c.new?.qname ?? c.old?.qname) === qname); + } + + /** The global component and the path of local names that lead to a (possibly anonymous) type of the later version. */ + private locate(id: Id): { cd: ComponentDiff; path: string } | undefined { + const m = this.cur; + const names: string[] = []; + let cur: Id | undefined = id; + for (let i = 0; cur !== undefined && i < 30; i++) { + const c = m.comp(cur); + if (c.parent === undefined) { + const cd = this.byNew.get(cur); + return cd ? { cd, path: names.join("/") } : undefined; + } + if (c.kind === "element" && c.name) names.unshift(c.name); + cur = c.parent; + } + return undefined; + } + + /** What changed among the children and attributes a type (or group) declares itself. */ + childMarks(owner: Id): ChildMarks { + let r = this.marks.get(owner); + if (r) return r; + const at = this.locate(owner); + if (!at || at.cd.status !== "changed") { + r = NONE; + } else { + r = { children: new Map(), removed: [], attrs: new Map(), removedAttrs: [] }; + const prefix = at.path ? at.path + "/" : ""; + for (const ch of at.cd.changes ?? []) { + const p = ch.path ?? ""; + const subj = ch.subject ?? ""; + if (p !== at.path) { + // A change deeper down marks the child it is under. + if (p.startsWith(prefix) && ch.impact !== "info") { + const first = p.slice(prefix.length).split("/")[0]; + if (first && !r.children.has(first)) r.children.set(first, "changed"); + } + continue; + } + if (ch.impact === "info" || !subj || subj.startsWith("group ") || subj.startsWith("attributeGroup ")) continue; + if (subj.startsWith("@")) { + const n = local(subj); + if (ch.kind === "attributeRemoved") r.removedAttrs.push({ name: n, use: ch.old }); + else if (ch.kind === "attributeAdded") r.attrs.set(n, "added"); + else if (!r.attrs.has(n)) r.attrs.set(n, "changed"); + } else if (ch.kind.startsWith("child") && ch.kind !== "childrenReordered") { + const n = local(subj); + if (ch.kind === "childRemoved") r.removed.push({ name: n, mult: ch.old }); + else if (ch.kind === "childAdded") r.children.set(n, "added"); + else if (!r.children.has(n)) r.children.set(n, "changed"); + } + } + } + this.marks.set(owner, r); + return r; + } +} + +/** Mark what changed in a diagram of the later version: boxes, lines and arrows get a class and a +/−/~ prefix. */ +export function annotateGraph(g: GraphSpec, cmp: Compare): void { + const m = cmp.cur; + const prefix = (d: Mark | undefined, t: string) => (d ? `${MARKER[d]} ${t}` : t); + for (const n of g.nodes) { + if (n.id === undefined) continue; + n.diff = cmp.mark(n.id); + if (n.diff) n.name = prefix(n.diff, n.name); + if (n.kind === "enum") { + const cd = cmp.byNew.get(n.id); + if (cd?.status !== "changed") continue; + const added = new Set((cd.changes ?? []).filter((c) => c.kind === "enumerationAdded" && !c.path).map((c) => c.subject)); + for (const a of n.attrs) if (added.has(a.text)) Object.assign(a, { diff: "added", text: prefix("added", a.text) }); + for (const c of cd.changes ?? []) if (c.kind === "enumerationRemoved" && !c.path && c.subject) n.attrs.push({ text: prefix("removed", c.subject), diff: "removed" }); + continue; + } + if (n.kind !== "class") continue; + const cm = cmp.childMarks(n.id); + for (const a of n.attrs) { + if (a.inherited) continue; + const d = a.text.startsWith("@") ? cm.attrs.get(a.text.slice(1)) : cm.children.get(a.text); + if (d) Object.assign(a, { diff: d, text: prefix(d, a.text) }); + } + for (const r of cm.removedAttrs) n.attrs.push({ text: prefix("removed", "@" + r.name), diff: "removed" }); + for (const r of cm.removed) n.attrs.push({ text: prefix("removed", r.name), mult: r.mult, diff: "removed" }); + } + for (const e of g.edges) { + if (e.kind !== "comp" || e.id === undefined || !e.from.startsWith("c")) continue; + const from = Number(e.from.slice(1)); + const d = cmp.childMarks(from).children.get(m.comp(e.id).name ?? ""); + if (d) Object.assign(e, { diff: d, label: prefix(d, e.label ?? "") }); + } +} diff --git a/web/src/dom.ts b/web/src/dom.ts new file mode 100644 index 0000000..e263d00 --- /dev/null +++ b/web/src/dom.ts @@ -0,0 +1,141 @@ +// Tiny DOM helpers: no framework, just functions that build elements. + +export type Child = Node | string | number | null | undefined | false | Child[]; + +type Attrs = Record>; + +export function h(tag: K, attrs?: Attrs | null, ...children: Child[]): HTMLElementTagNameMap[K]; +export function h(tag: string, attrs?: Attrs | null, ...children: Child[]): HTMLElement; +export function h(tag: string, attrs?: Attrs | null, ...children: Child[]): HTMLElement { + const el = document.createElement(tag); + apply(el, attrs); + append(el, children); + return el; +} + +const SVG_NS = "http://www.w3.org/2000/svg"; + +export function s(tag: K, attrs?: Attrs | null, ...children: Child[]): SVGElementTagNameMap[K] { + const el = document.createElementNS(SVG_NS, tag); + apply(el, attrs); + append(el, children); + return el; +} + +function apply(el: Element, attrs?: Attrs | null): void { + if (!attrs) return; + for (const [k, v] of Object.entries(attrs)) { + if (v === null || v === undefined || v === false) continue; + if (k.startsWith("on") && typeof v === "function") { + el.addEventListener(k.slice(2).toLowerCase(), v as EventListener); + } else if (k === "style" && typeof v === "object") { + Object.assign((el as HTMLElement).style, v); + } else if (k === "class" || k === "className") { + el.setAttribute("class", String(v)); + } else if (k === "dataset" && typeof v === "object") { + Object.assign((el as HTMLElement).dataset, v); + } else if (v === true) { + el.setAttribute(k, ""); + } else { + el.setAttribute(k, String(v)); + } + } +} + +export function append(el: Node, children: Child[]): void { + for (const c of children) { + if (c === null || c === undefined || c === false) continue; + if (Array.isArray(c)) append(el, c); + else if (c instanceof Node) el.appendChild(c); + else el.appendChild(document.createTextNode(String(c))); + } +} + +export function clear(el: Element): void { + while (el.firstChild) el.removeChild(el.firstChild); +} + +export function replace(el: Element, ...children: Child[]): void { + clear(el); + append(el, children); +} + +export function frag(...children: Child[]): DocumentFragment { + const f = document.createDocumentFragment(); + append(f, children); + return f; +} + +const ICONS: Record = { + search: '', + close: '', + chevron: '', + down: '', + copy: '', + link: '', + sun: '', + moon: '', + menu: '', + diagram: '', + table: '', + tree: '', + map: '', + download: '', + info: '', + plus: '', + minus: '', + fit: '', + folder: '', + back: '', + home: '', + external: '', + warning: '', + check: '', + share: '', + help: '', + maximize: '', + minimize: '', + code: '', + settings: '', +}; + +export function icon(name: string, cls = ""): SVGSVGElement { + const el = s("svg", { viewBox: "0 0 24 24", class: `icon ${cls}`.trim(), "aria-hidden": "true" }); + el.innerHTML = ICONS[name] ?? ""; + return el; +} + +export function button(label: string, onClick: (ev: MouseEvent) => void, opts: { icon?: string; cls?: string; title?: string; pressed?: boolean } = {}): HTMLButtonElement { + const b = h("button", { type: "button", class: `btn ${opts.cls ?? ""}`.trim(), title: opts.title, "aria-pressed": opts.pressed === undefined ? undefined : String(opts.pressed), onClick: onClick as EventListener }, opts.icon ? icon(opts.icon) : null, label ? h("span", null, label) : null); + return b; +} + +export function plural(n: number, one: string, many = one + "s"): string { + return `${n.toLocaleString()} ${n === 1 ? one : many}`; +} + +export function debounce void>(fn: T, ms: number): T { + let t: ReturnType | undefined; + return ((...a: Parameters) => { + if (t) clearTimeout(t); + t = setTimeout(() => fn(...a), ms); + }) as T; +} + +export async function copyText(text: string): Promise { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + return false; + } +} + +export function downloadBlob(blob: Blob, name: string): void { + const url = URL.createObjectURL(blob); + const a = h("a", { href: url, download: name }); + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 2000); +} diff --git a/web/src/element.ts b/web/src/element.ts new file mode 100644 index 0000000..0e83858 --- /dev/null +++ b/web/src/element.ts @@ -0,0 +1,92 @@ +// : the viewer as a web component. +import { App } from "./app"; +import type { Bundle } from "./model"; +import css from "./styles.css?inline"; +import { addFonts } from "./fonts"; + +let sheet: CSSStyleSheet | null = null; +function styles(): CSSStyleSheet { + if (!sheet) { + sheet = new CSSStyleSheet(); + sheet.replaceSync(css); + } + return sheet; +} + +/** + * Attributes: + * - `src` URL of a bundle.json (or of a single .xsd file) + * - `sources` base URL of the .xsd files, for the source view (default: next to the bundle) + * - `focus` component to open first: `prefix:Name` or `ct/prefix:Name` + * - `theme` light | dark | auto + * - `routing` present: use location.hash for navigation (the standalone app) + * - `minimal` present: no sidebar or top bar, only the page + * - `examples` URL of an examples.json list for the open screen + * Events: `se-navigate` {name, route}, `se-loaded` {bundle}, `se-error` {error} + */ +export class SchemaExplorerElement extends HTMLElement { + static observedAttributes = ["src", "focus", "theme"]; + private app: App | null = null; + private started = false; + + connectedCallback(): void { + if (this.app) return; + addFonts(); + const shadow = this.shadowRoot ?? this.attachShadow({ mode: "open" }); + shadow.adoptedStyleSheets = [styles()]; + const root = document.createElement("div"); + root.style.height = "100%"; + shadow.appendChild(root); + if (!this.style.height && !this.hasAttribute("routing")) this.style.minHeight = "480px"; + const routing = this.hasAttribute("routing"); + this.app = new App(root, { + routing, + embed: !routing, + minimal: this.hasAttribute("minimal"), + theme: (this.getAttribute("theme") as "light" | "dark" | "auto" | null) ?? undefined, + examplesUrl: this.getAttribute("examples") ?? undefined, + onNavigate: (name, route) => this.dispatchEvent(new CustomEvent("se-navigate", { detail: { name, route }, bubbles: true, composed: true })), + onLoaded: (bundle) => this.dispatchEvent(new CustomEvent("se-loaded", { detail: { bundle }, bubbles: true, composed: true })), + onError: (error) => this.dispatchEvent(new CustomEvent("se-error", { detail: { error }, bubbles: true, composed: true })), + }); + this.started = true; + const q = routing ? new URLSearchParams(location.search) : null; + const src = this.getAttribute("src") ?? q?.get("bundle") ?? q?.get("src") ?? null; + const focus = this.getAttribute("focus") ?? q?.get("focus") ?? null; + const sources = this.getAttribute("sources") ?? q?.get("sources") ?? null; + const compare = this.getAttribute("compare") ?? q?.get("compare") ?? null; + void this.app.start(src, focus, sources, compare); + } + + disconnectedCallback(): void { + this.app?.destroy(); + } + + attributeChangedCallback(name: string, old: string | null, value: string | null): void { + if (!this.started || !this.app || old === value) return; + if (name === "src" && value) void this.app.openUrl(value, this.getAttribute("focus") ?? undefined, this.getAttribute("sources") ?? undefined); + else if (name === "focus" && value) this.app.goTo(value); + else if (name === "theme" && value) this.app.setPrefs({ theme: value as "light" | "dark" | "auto" }); + } + + /** Load a bundle object, a URL, or .xsd files. */ + async load(input: string | Bundle | FileList | File[]): Promise { + if (!this.app) throw new Error("element not connected"); + if (typeof input === "string") await this.app.openUrl(input); + else if (input instanceof FileList || Array.isArray(input)) await this.app.openFiles(input); + else this.app.setBundle(input); + } + + /** Open a global component by `prefix:Name` (or `ct/prefix:Name`); false when it is not there. */ + goTo(name: string, tab?: "diagram" | "tree"): boolean { + return this.app?.goTo(name, tab) ?? false; + } + + get bundle(): Bundle | null { + return this.app?.bundle ?? null; + } +} + +export function define(tag = "schema-explorer"): void { + if (!customElements.get(tag)) customElements.define(tag, SchemaExplorerElement); +} diff --git a/web/src/elkjs.d.ts b/web/src/elkjs.d.ts new file mode 100644 index 0000000..54f29c3 --- /dev/null +++ b/web/src/elkjs.d.ts @@ -0,0 +1,29 @@ +declare module "elkjs/lib/elk-api.js" { + export interface ElkNode { + id: string; + width?: number; + height?: number; + x?: number; + y?: number; + children?: ElkNode[]; + edges?: ElkEdge[]; + layoutOptions?: Record; + labels?: { text: string; width?: number; height?: number; x?: number; y?: number }[]; + } + export interface ElkEdge { + id: string; + sources: string[]; + targets: string[]; + sections?: { startPoint: { x: number; y: number }; endPoint: { x: number; y: number }; bendPoints?: { x: number; y: number }[] }[]; + labels?: { text: string; width?: number; height?: number; x?: number; y?: number }[]; + layoutOptions?: Record; + } + export default class ELK { + constructor(opts?: unknown); + layout(graph: ElkNode): Promise; + } +} +declare module "elkjs/lib/elk-worker.min.js?url" { + const url: string; + export default url; +} diff --git a/web/src/fonts.ts b/web/src/fonts.ts new file mode 100644 index 0000000..a6e8199 --- /dev/null +++ b/web/src/fonts.ts @@ -0,0 +1,31 @@ +// Barlow and JetBrains Mono, self-hosted (SIL OFL 1.1). @font-face does not work +// inside a shadow root's stylesheet, so the faces are added to the document once. +import barlow400 from "@fontsource/barlow/files/barlow-latin-400-normal.woff2?url"; +import barlow500 from "@fontsource/barlow/files/barlow-latin-500-normal.woff2?url"; +import barlow600 from "@fontsource/barlow/files/barlow-latin-600-normal.woff2?url"; +import barlow700 from "@fontsource/barlow/files/barlow-latin-700-normal.woff2?url"; +import mono400 from "@fontsource/jetbrains-mono/files/jetbrains-mono-latin-400-normal.woff2?url"; +import mono500 from "@fontsource/jetbrains-mono/files/jetbrains-mono-latin-500-normal.woff2?url"; + +const FACES: [string, number, string][] = [ + ["Barlow", 400, barlow400], + ["Barlow", 500, barlow500], + ["Barlow", 600, barlow600], + ["Barlow", 700, barlow700], + ["JetBrains Mono", 400, mono400], + ["JetBrains Mono", 500, mono500], +]; + +let added = false; +export function addFonts(): void { + if (added || typeof document === "undefined") return; + added = true; + if (document.querySelector("style[data-schema-explorer-fonts]")) return; + const style = document.createElement("style"); + style.dataset.schemaExplorerFonts = ""; + style.textContent = FACES.map( + ([family, weight, url]) => + `@font-face{font-family:"${family}";font-style:normal;font-display:swap;font-weight:${weight};src:url(${JSON.stringify(url)}) format("woff2");}`, + ).join("\n"); + document.head.appendChild(style); +} diff --git a/web/src/graph.ts b/web/src/graph.ts new file mode 100644 index 0000000..2c9d9d8 --- /dev/null +++ b/web/src/graph.ts @@ -0,0 +1,447 @@ +// Builds the graph a diagram shows: UML-like boxes for complex types, with +// generalisation (extension, restriction), composition for child elements and +// «enumeration» boxes for enumerated simple types. +import { isId, occursText, type ContentRow, type Id, type Model, type Ref } from "./model"; + +export type NodeKind = "class" | "enum" | "element" | "more"; + +export interface AttrLine { + text: string; + type?: string; + mult?: string; + inherited?: boolean; + /** Header shown above the line when it changes (e.g. "choice"). */ + group?: string; + groupKey?: string; + /** Component to open on click. */ + id?: Id; + /** Compare mode: how the line differs from the earlier version. */ + diff?: "added" | "removed" | "changed"; +} + +export interface NodeSpec { + key: string; + id?: Id; + kind: NodeKind; + name: string; + stereotype?: string; + abstract?: boolean; + external?: boolean; + focus?: boolean; + attrs: AttrLine[]; + /** Lines not shown (collapsed). */ + hiddenAttrs: number; + moreOf?: { from: string; relation: string; ids: Id[] }; + diff?: "added" | "removed" | "changed"; +} + +export type EdgeKind = "gen" | "restr" | "comp" | "dep" | "incoming" | "typed"; + +export interface EdgeSpec { + key: string; + from: string; + to: string; + kind: EdgeKind; + label?: string; + mult?: string; + /** Component behind the edge, for navigation. */ + id?: Id; + diff?: "added" | "removed" | "changed"; +} + +export interface GraphSpec { + nodes: NodeSpec[]; + edges: EdgeSpec[]; + /** Nodes left out because of the cap. */ + omitted: number; +} + +export interface NeighborhoodOptions { + focus: Id; + depth: 1 | 2; + inherited: boolean; + incoming: boolean; + derived: boolean; + enums: boolean; + maxNeighbors: number; + maxAttrs: number; + expanded: Set; + openAttrs: Set; +} + +export const defaultNeighborhood = (focus: Id): NeighborhoodOptions => ({ + focus, + depth: 1, + inherited: false, + incoming: true, + derived: true, + enums: true, + maxNeighbors: 30, + maxAttrs: 16, + expanded: new Set(), + openAttrs: new Set(), +}); + +const mult = (min: number, max: number | null) => occursText(min, max); + +/** The type of an element row: its declared type (named or anonymous). */ +export function rowType(m: Model, r: ContentRow): Ref | undefined { + return r.id !== undefined ? m.elementType(r.id) : undefined; +} + +/** Lines inside a type's box: attributes, simple content and simple-typed child elements. */ +function lines(m: Model, tid: Id, inherited: boolean): AttrLine[] { + const out: AttrLine[] = []; + const c = m.comp(tid); + for (const a of m.attributes(tid)) { + if (!inherited && a.from !== tid) continue; + if (a.use === "prohibited") continue; + const t = m.attrType(a.attr); + out.push({ text: "@" + (isId(a.attr) ? m.comp(a.attr).name ?? "" : m.refName(a.attr)), type: m.typeName(t), mult: a.use === "required" ? "1" : "0..1", inherited: a.from !== tid, id: isId(a.attr) ? a.attr : undefined }); + } + const simple = c.complexType?.effective?.simple ?? (c.complexType?.derivation?.simple ? c.complexType.derivation.base : undefined); + if (simple !== undefined) out.push({ text: "(value)", type: m.typeName(simple), id: isId(simple) ? simple : undefined }); + let n = 0; + let key = ""; + for (const r of m.contentRows(tid, { inherited })) { + if (r.kind === "compositor" && r.compositor !== "sequence") key = `${r.compositor}${++n}`; + else if (r.kind === "compositor" && r.depth === 0) key = ""; + if (r.kind === "any") { + out.push({ text: "any", type: r.wildcard?.namespace ?? "##any", mult: mult(r.min, r.max), inherited: r.from !== tid, group: r.inChoice ? "choice" : undefined, groupKey: r.inChoice ? key : undefined }); + continue; + } + if (r.kind !== "element") continue; + const t = rowType(m, r); + if (m.isComplex(t)) continue; // drawn as an arrow + const name = r.id !== undefined ? m.comp(r.id).name ?? "" : m.refName(r.missing); + out.push({ text: name, type: m.typeName(t), mult: mult(r.min, r.max), inherited: r.from !== tid, group: r.inChoice ? "choice" : undefined, groupKey: r.inChoice ? key : undefined, id: r.id }); + } + return out; +} + +function stereo(m: Model, id: Id): string | undefined { + const c = m.comp(id); + const s: string[] = []; + if (c.complexType?.abstract) s.push("abstract"); + if (!c.name) s.push("anonymous"); + if (c.complexType?.mixed) s.push("mixed"); + return s.length ? `«${s.join(", ")}»` : undefined; +} + +export function typeNode(m: Model, id: Id, o: { focus?: boolean; attrs: boolean; inherited: boolean; open: boolean; cap: number }): NodeSpec { + const c = m.comp(id); + const all = o.attrs ? lines(m, id, o.inherited) : []; + const shown = o.open || all.length <= o.cap ? all : all.slice(0, o.cap); + return { + key: `c${id}`, + id, + kind: "class", + name: c.name ? c.name : m.label(id), + stereotype: stereo(m, id), + abstract: c.complexType?.abstract, + external: c.builtin, + focus: o.focus, + attrs: shown, + hiddenAttrs: all.length - shown.length, + }; +} + +export function enumNode(m: Model, id: Id, cap = 10): NodeSpec { + const values = m.enumerations(id); + return { + key: `e${id}`, + id, + kind: "enum", + name: m.label(id), + stereotype: "«enumeration»", + attrs: values.slice(0, cap).map((v) => ({ text: v.value })), + hiddenAttrs: Math.max(0, values.length - cap), + }; +} + +/** The neighbourhood of a complex type. */ +export function buildNeighborhood(m: Model, o: NeighborhoodOptions): GraphSpec { + const nodes = new Map(); + const edges = new Map(); + let omitted = 0; + let budget = o.maxNeighbors; + const take = (key: string, make: () => NodeSpec, force = false): boolean => { + if (nodes.has(key)) return true; + if (!force && budget <= 0) { + omitted++; + return false; + } + if (!force) budget--; + nodes.set(key, make()); + return true; + }; + const addType = (id: Id, focus = false) => + take(`c${id}`, () => typeNode(m, id, { focus, attrs: focus || o.expanded.has(id), inherited: focus && o.inherited, open: o.openAttrs.has(id), cap: focus ? o.maxAttrs * 2 : o.maxAttrs }), focus); + const edge = (e: EdgeSpec) => { + if (!edges.has(e.key)) edges.set(e.key, e); + }; + + const walk = (tid: Id, isFocus: boolean, level: number) => { + const c = m.comp(tid); + const ct = c.complexType; + if (!ct) return; + const inh = isFocus && o.inherited; + // Base type. + const base = ct.derivation?.base; + if (m.isComplex(base) && addType(base)) edge({ key: `g${base}-${tid}`, from: `c${base}`, to: `c${tid}`, kind: ct.derivation!.method === "restriction" ? "restr" : "gen", label: ct.derivation!.method === "restriction" ? "«restriction»" : undefined }); + // Derived types. + if (o.derived && (isFocus || o.expanded.has(tid))) { + for (const d of m.derived(tid)) { + if (!m.isComplex(d)) continue; + const r = m.comp(d).complexType?.derivation?.method === "restriction"; + if (addType(d)) edge({ key: `g${tid}-${d}`, from: `c${tid}`, to: `c${d}`, kind: r ? "restr" : "gen", label: r ? "«restriction»" : undefined }); + } + } + // Child elements of a complex type: composition. + for (const r of m.contentRows(tid, { inherited: inh })) { + if (r.kind !== "element" || r.id === undefined) continue; + const t = rowType(m, r); + const name = m.comp(r.id).name ?? ""; + if (m.isComplex(t)) { + if (addType(t)) edge({ key: `a${tid}-${r.id}-${t}`, from: `c${tid}`, to: `c${t}`, kind: "comp", label: r.inChoice ? `${name} ⊕` : name, mult: mult(r.min, r.max), id: r.id }); + } else if (o.enums && isId(t) && m.isEnum(t) && (isFocus || o.expanded.has(tid) || level < o.depth)) { + if (take(`e${t}`, () => enumNode(m, t))) edge({ key: `d${tid}-${t}`, from: `c${tid}`, to: `e${t}`, kind: "dep" }); + } + } + // Enumerated attribute types. + if (o.enums && (isFocus || o.expanded.has(tid) || level < o.depth)) { + for (const a of m.attributes(tid)) { + if (!inh && a.from !== tid) continue; + const t = m.attrType(a.attr); + if (isId(t) && m.isEnum(t) && take(`e${t}`, () => enumNode(m, t))) edge({ key: `d${tid}-${t}`, from: `c${tid}`, to: `e${t}`, kind: "dep" }); + } + } + // Who uses this type: containing types (composition) and global elements. + if (o.incoming && (isFocus || o.expanded.has(tid))) { + for (const u of m.usedBy(tid)) { + if (u.how !== "type") continue; + const by = m.comp(u.by); + if (by.kind !== "element") continue; + if (by.parent === undefined) { + if (take(`x${u.by}`, () => ({ key: `x${u.by}`, id: u.by, kind: "element", name: by.name ?? "", stereotype: by.element?.abstract ? "«element, abstract»" : "«element»", attrs: [], hiddenAttrs: 0 }))) edge({ key: `t${u.by}-${tid}`, from: `x${u.by}`, to: `c${tid}`, kind: "typed" }); + continue; + } + const holder = containingType(m, u.by); + if (holder === undefined || holder === tid) continue; + if (addType(holder)) edge({ key: `a${holder}-${u.by}-${tid}`, from: `c${holder}`, to: `c${tid}`, kind: "comp", label: by.name ?? "", id: u.by }); + } + } + }; + + addType(o.focus, true); + walk(o.focus, true, 1); + for (const x of o.expanded) if (x !== o.focus && m.isComplex(x) && addType(x)) walk(x, false, 1); + if (o.depth >= 2) { + for (const n of [...nodes.values()]) if (n.kind === "class" && !n.focus && n.id !== undefined) walk(n.id, false, 2); + } + // Generalisations between boxes that are there anyway. + for (const n of [...nodes.values()]) { + if (n.kind !== "class" || n.id === undefined) continue; + const d = m.comp(n.id).complexType?.derivation; + if (d && isId(d.base) && nodes.has(`c${d.base}`)) edge({ key: `g${d.base}-${n.id}`, from: `c${d.base}`, to: `c${n.id}`, kind: d.method === "restriction" ? "restr" : "gen", label: d.method === "restriction" ? "«restriction»" : undefined }); + } + return { nodes: [...nodes.values()], edges: [...edges.values()], omitted }; +} + +/** The complex type (named or anonymous) a local declaration sits in. */ +export function containingType(m: Model, id: Id): Id | undefined { + let cur = m.comp(id).parent; + const seen = new Set(); + while (cur !== undefined && !seen.has(cur)) { + seen.add(cur); + if (m.comp(cur).kind === "complexType") return cur; + cur = m.comp(cur).parent; + } + return undefined; +} + +/** A simple type with its base and derived types and the types that use it. */ +export function buildSimpleGraph(m: Model, sid: Id, max = 30): GraphSpec { + const nodes = new Map(); + const edges: EdgeSpec[] = []; + let omitted = 0; + const st = (id: Id, focus = false): NodeSpec => { + const c = m.comp(id); + if (m.isEnum(id)) return { ...enumNode(m, id, focus ? 30 : 8), focus }; + const facets = (c.simpleType?.facets ?? []).map((f) => ({ text: f.kind, type: f.value })); + const v = c.simpleType?.variety; + return { key: `e${id}`, id, kind: "enum", name: m.label(id), stereotype: v === "list" ? "«list»" : v === "union" ? "«union»" : "«simple type»", attrs: facets.slice(0, 8), hiddenAttrs: Math.max(0, facets.length - 8), focus, external: c.builtin }; + }; + const add = (key: string, make: () => NodeSpec) => { + if (nodes.has(key)) return true; + if (nodes.size >= max) { + omitted++; + return false; + } + nodes.set(key, make()); + return true; + }; + add(`e${sid}`, () => st(sid, true)); + const c = m.comp(sid).simpleType; + const base = c?.base; + if (isId(base) && add(`e${base}`, () => st(base))) edges.push({ key: `g${base}`, from: `e${base}`, to: `e${sid}`, kind: "restr", label: "«restriction»" }); + const item = c?.item; + if (isId(item) && add(`e${item}`, () => st(item))) edges.push({ key: `i${item}`, from: `e${sid}`, to: `e${item}`, kind: "dep", label: "item" }); + for (const mb of c?.members ?? []) if (isId(mb) && add(`e${mb}`, () => st(mb))) edges.push({ key: `u${mb}`, from: `e${sid}`, to: `e${mb}`, kind: "dep", label: "member" }); + for (const d of m.derived(sid)) if (add(`e${d}`, () => st(d))) edges.push({ key: `g${sid}-${d}`, from: `e${sid}`, to: `e${d}`, kind: "restr" }); + for (const u of m.usedBy(sid)) { + if (u.how !== "type") continue; + const holder = m.comp(u.by).kind === "attribute" || m.comp(u.by).kind === "element" ? containingType(m, u.by) : undefined; + if (holder === undefined) continue; + if (add(`c${holder}`, () => typeNode(m, holder, { attrs: false, inherited: false, open: false, cap: 0 }))) edges.push({ key: `d${holder}-${u.by}`, from: `c${holder}`, to: `e${sid}`, kind: "dep", label: m.comp(u.by).kind === "attribute" ? "@" + (m.comp(u.by).name ?? "") : m.comp(u.by).name, id: u.by }); + } + return { nodes: [...nodes.values()], edges: dedupe(edges), omitted }; +} + +function dedupe(edges: EdgeSpec[]): EdgeSpec[] { + const seen = new Set(); + return edges.filter((e) => !seen.has(e.key) && !!seen.add(e.key)); +} + +export interface OverviewOptions { + ns?: number; + folder?: string; + associations: boolean; + maxNodes: number; +} + +/** Top-level folders of the files, for narrowing the map. */ +export function folders(m: Model): { folder: string; count: number }[] { + const counts = new Map(); + for (const id of m.lists.complexType) { + const f = m.fileName(id); + if (!f) continue; + const i = f.lastIndexOf("/"); + const dir = i < 0 ? "." : f.slice(0, i); + counts.set(dir, (counts.get(dir) ?? 0) + 1); + } + return [...counts.entries()].map(([folder, count]) => ({ folder, count })).sort((a, b) => a.folder.localeCompare(b.folder)); +} + +export function buildOverview(m: Model, o: OverviewOptions): GraphSpec { + let ids = m.lists.complexType.filter((id) => { + const c = m.comp(id); + if (o.ns !== undefined && c.ns !== o.ns) return false; + if (o.folder) { + const f = m.fileName(id) ?? ""; + const dir = f.includes("/") ? f.slice(0, f.lastIndexOf("/")) : "."; + if (dir !== o.folder && !dir.startsWith(o.folder + "/")) return false; + } + return true; + }); + const total = ids.length; + if (ids.length > o.maxNodes) { + const score = (id: Id) => (m.derived(id).length * 3 + (m.comp(id).usedBy?.length ?? 0) + (m.comp(id).complexType?.derivation ? 1 : 0)); + ids = [...ids].sort((a, b) => score(b) - score(a)).slice(0, o.maxNodes); + } + const keep = new Set(ids); + const nodes: NodeSpec[] = ids.map((id) => ({ key: `c${id}`, id, kind: "class", name: m.comp(id).name ?? "", abstract: m.comp(id).complexType?.abstract, attrs: [], hiddenAttrs: 0 })); + const edges: EdgeSpec[] = []; + for (const id of ids) { + const d = m.comp(id).complexType?.derivation; + if (d && isId(d.base) && keep.has(d.base)) edges.push({ key: `g${d.base}-${id}`, from: `c${d.base}`, to: `c${id}`, kind: d.method === "restriction" ? "restr" : "gen" }); + if (o.associations) { + const seen = new Set(); + for (const r of m.contentRows(id, { inherited: false })) { + if (r.kind !== "element") continue; + const t = rowType(m, r); + if (!isId(t) || !keep.has(t) || t === id || seen.has(t)) continue; + seen.add(t); + edges.push({ key: `a${id}-${t}`, from: `c${id}`, to: `c${t}`, kind: "comp" }); + } + } + } + return { nodes, edges, omitted: total - ids.length }; +} + +// ---- text exports --------------------------------------------------------- + +function safeId(key: string): string { + return key.replace(/[^A-Za-z0-9_]/g, "_"); +} + +export function toPlantUml(g: GraphSpec): string { + const out: string[] = ["@startuml", "hide empty members", "skinparam classAttributeIconSize 0"]; + for (const n of g.nodes) { + if (n.kind === "more") continue; + const id = safeId(n.key); + const kw = n.kind === "enum" ? "enum" : n.abstract ? "abstract class" : "class"; + const st = n.kind === "element" ? " <>" : ""; + if (n.attrs.length === 0) out.push(`${kw} "${n.name}" as ${id}${st}`); + else { + out.push(`${kw} "${n.name}" as ${id}${st} {`); + let group: string | undefined; + for (const a of n.attrs) { + if (a.groupKey !== group) { + group = a.groupKey; + if (a.group) out.push(` .. ${a.group} ..`); + } + out.push(` ${a.text}${a.type ? " : " + a.type : ""}${a.mult ? " [" + a.mult + "]" : ""}`); + } + if (n.hiddenAttrs) out.push(` .. ${n.hiddenAttrs} more ..`); + out.push("}"); + } + } + for (const e of g.edges) { + const a = safeId(e.from); + const b = safeId(e.to); + const lbl = e.label ? ` : ${e.label}` : ""; + switch (e.kind) { + case "gen": + out.push(`${a} <|-- ${b}`); + break; + case "restr": + out.push(`${a} <|.. ${b} : restriction`); + break; + case "comp": + out.push(`${a} *--> ${e.mult ? `"${e.mult}" ` : ""}${b}${lbl}`); + break; + default: + out.push(`${a} ..> ${b}${lbl}`); + } + } + out.push("@enduml"); + return out.join("\n"); +} + +export function toMermaid(g: GraphSpec): string { + const out: string[] = ["classDiagram"]; + const q = (s: string) => s.replace(/[`"]/g, "'"); + for (const n of g.nodes) { + if (n.kind === "more") continue; + const id = safeId(n.key); + out.push(` class ${id}["${q(n.name)}"]`); + if (n.kind === "enum") out.push(` <> ${id}`); + else if (n.kind === "element") out.push(` <> ${id}`); + else if (n.abstract) out.push(` <> ${id}`); + for (const a of n.attrs) { + const type = a.type ? a.type.replace(/[^A-Za-z0-9_:]/g, "_") : ""; + out.push(` ${id} : ${type ? type + " " : ""}${q(a.text).replace(/[^A-Za-z0-9_@ ]/g, "_")}${a.mult ? " [" + a.mult.replace(/\.\./g, "_") + "]" : ""}`); + } + if (n.hiddenAttrs) out.push(` ${id} : ... ${n.hiddenAttrs} more`); + } + for (const e of g.edges) { + const a = safeId(e.from); + const b = safeId(e.to); + const lbl = e.label ? ` : ${q(e.label).replace(/[«»⊕]/g, "")}${e.mult ? " " + e.mult : ""}` : ""; + switch (e.kind) { + case "gen": + out.push(` ${a} <|-- ${b}`); + break; + case "restr": + out.push(` ${a} <|.. ${b}`); + break; + case "comp": + out.push(` ${a} *-- ${b}${lbl}`); + break; + default: + out.push(` ${a} ..> ${b}${lbl}`); + } + } + return out.join("\n"); +} diff --git a/web/src/layout.ts b/web/src/layout.ts new file mode 100644 index 0000000..e9a35ca --- /dev/null +++ b/web/src/layout.ts @@ -0,0 +1,12 @@ +// ELK (Eclipse Layout Kernel) runs in its own web worker; the main thread only holds the small API. +import ELK, { type ElkNode } from "elkjs/lib/elk-api.js"; +import workerUrl from "elkjs/lib/elk-worker.min.js?url"; + +let elk: ELK | null = null; + +export function layout(graph: ElkNode): Promise { + if (!elk) elk = new ELK({ workerFactory: () => new Worker(workerUrl) }); + return elk.layout(graph); +} + +export type { ElkNode }; diff --git a/web/src/lib.ts b/web/src/lib.ts index fe0343a..df7e14d 100644 --- a/web/src/lib.ts +++ b/web/src/lib.ts @@ -1,30 +1,7 @@ -// The web component. Early development: a placeholder that -// loads the WebAssembly core and shows its version. -import init, { version } from "./wasm/schema_explorer_wasm.js"; - -export class SchemaExplorer extends HTMLElement { - async connectedCallback(): Promise { - const root = this.shadowRoot ?? this.attachShadow({ mode: "open" }); - root.innerHTML = ` -
-

schema-explorer

-

A visual explorer for XML Schema (XSD), and later JSON Schema. Early development: the viewer is not ready yet.

-

Viewer ${__APP_VERSION__} · core … · - source

-
`; - try { - await init(); - root.getElementById("core")!.textContent = version(); - } catch (e) { - root.getElementById("core")!.textContent = "unavailable"; - console.error(e); - } - } -} - -if (!customElements.get("schema-explorer")) { - customElements.define("schema-explorer", SchemaExplorer); -} +// Library entry: import to register , or use the exports directly. +export { SchemaExplorerElement, define } from "./element"; +export type { Bundle, Component, Kind } from "./model"; +export { Model } from "./model"; +export { loadUrl, indexFiles } from "./loader"; +import { define } from "./element"; +define(); diff --git a/web/src/loader.ts b/web/src/loader.ts new file mode 100644 index 0000000..cf77cf7 --- /dev/null +++ b/web/src/loader.ts @@ -0,0 +1,223 @@ +// Getting a bundle from somewhere: a bundle URL, dropped .xsd files and +// folders (indexed in a WebAssembly worker), or the local `serve` server. +import type { SchemaDiff } from "./diff"; +import type { Bundle } from "./model"; + +export interface LoadProgress { + stage: "download" | "read" | "index"; + detail?: string; + loaded?: number; + total?: number; +} + +export interface ServerStatus { + server: boolean; + version?: string; + loaded?: boolean; + title?: string; +} + +export interface NamedBytes { + name: string; + bytes: Uint8Array; +} + +/** Where the viewer is served from; API calls are relative to it. */ +export function baseUrl(): string { + return new URL(".", document.baseURI).href; +} + +export async function serverStatus(): Promise { + try { + const r = await fetch(new URL("api/status", baseUrl()), { cache: "no-store" }); + if (!r.ok || !(r.headers.get("content-type") ?? "").includes("json")) return null; + const j = (await r.json()) as ServerStatus; + return j.server ? j : null; + } catch { + return null; + } +} + +export async function fetchBundleFromServer(progress?: (p: LoadProgress) => void): Promise { + const r = await fetch(new URL("api/bundle", baseUrl()), { cache: "no-store" }); + if (!r.ok) throw new Error(`the server has no schemas loaded (${r.status})`); + return checkBundle(JSON.parse(new TextDecoder().decode(await readWithProgress(r, progress))), progress); +} + +/** Load a URL: a bundle (JSON) or a single .xsd file. */ +export async function loadUrl(url: string, progress?: (p: LoadProgress) => void): Promise { + const abs = new URL(url, document.baseURI).href; + progress?.({ stage: "download", detail: abs }); + let resp: Response; + try { + resp = await fetch(abs); + } catch (e) { + throw new Error(`cannot fetch ${abs}: ${String((e as Error).message || e)}. The host may not allow cross-origin requests; download the files and drop them here instead.`); + } + if (!resp.ok) throw new Error(`cannot fetch ${abs}: HTTP ${resp.status}`); + const bytes = await readWithProgress(resp, progress); + const name = new URL(abs).pathname.split("/").pop() || "schema.xsd"; + if (/\.xsd$/i.test(name) || bytes[0] === 0x3c || (bytes[0] === 0xef && bytes[3] === 0x3c)) { + return indexFiles([{ name, bytes }], progress); + } + let parsed: unknown; + try { + parsed = JSON.parse(new TextDecoder().decode(bytes)); + } catch { + throw new Error(`${abs} is neither a schema-explorer bundle (JSON) nor an XSD file`); + } + return checkBundle(parsed, progress); +} + +async function readWithProgress(resp: Response, progress?: (p: LoadProgress) => void): Promise { + const total = Number(resp.headers.get("content-length") ?? 0) || undefined; + if (!resp.body || !progress) return new Uint8Array(await resp.arrayBuffer()); + const reader = resp.body.getReader(); + const chunks: Uint8Array[] = []; + let loaded = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + loaded += value.length; + progress({ stage: "download", loaded, total }); + } + const out = new Uint8Array(loaded); + let o = 0; + for (const c of chunks) { + out.set(c, o); + o += c.length; + } + return out; +} + +let wasmWorker: Worker | null = null; +let seq = 0; +const pending = new Map void; reject: (e: Error) => void }>(); + +function worker(): Worker { + if (!wasmWorker) { + wasmWorker = new Worker(new URL("./wasm.worker.ts", import.meta.url), { type: "module" }); + wasmWorker.onmessage = (ev: MessageEvent<{ id: number; json?: string; error?: string }>) => { + const p = pending.get(ev.data.id); + if (!p) return; + pending.delete(ev.data.id); + if (ev.data.error) p.reject(new Error(ev.data.error)); + else p.resolve(ev.data.json as string); + }; + wasmWorker.onerror = (e) => { + for (const p of pending.values()) p.reject(new Error(e.message || "the indexer could not start")); + pending.clear(); + wasmWorker = null; + }; + } + return wasmWorker; +} + +function call(msg: Record, transfer: Transferable[] = []): Promise { + const id = ++seq; + return new Promise((resolve, reject) => { + pending.set(id, { resolve, reject }); + worker().postMessage({ id, ...msg }, transfer); + }); +} + +/** Index XSD files in the browser (WebAssembly, in a worker). */ +export async function indexFiles(files: NamedBytes[], progress?: (p: LoadProgress) => void): Promise { + progress?.({ stage: "index", detail: files.length === 1 ? files[0].name : `${files.length} files` }); + // Copies go to the worker; the originals stay here for the source view. + const contents = files.map((f) => f.bytes.slice()); + const json = await call({ op: "index", names: files.map((f) => f.name), contents }, contents.map((c) => c.buffer as ArrayBuffer)); + return checkBundle(JSON.parse(json)); +} + +/** Compare two bundles (WebAssembly, in a worker); `old` is the earlier version. */ +export async function diffBundles(old: Bundle, cur: Bundle, options: Record = {}): Promise { + const strip = (b: Bundle) => JSON.stringify({ ...b, search: {} }); + return JSON.parse(await call({ op: "diff", old: strip(old), new: strip(cur), options: JSON.stringify(options) })) as SchemaDiff; +} + +/** A Markdown or HTML report of a diff. */ +export function diffReport(d: SchemaDiff, format: "md" | "html"): Promise { + return call({ op: "report", json: JSON.stringify(d), format }); +} + +/** Read dropped or picked files; directories are walked for .xsd files. */ +export async function readDataTransfer(dt: DataTransfer): Promise { + const entries = Array.from(dt.items) + .map((i) => (i.kind === "file" ? i.webkitGetAsEntry?.() : null)) + .filter((e): e is FileSystemEntry => !!e); + if (entries.length === 0) return readFiles(dt.files); + const out: NamedBytes[] = []; + const walk = async (e: FileSystemEntry, path: string): Promise => { + if (e.isFile) { + if (!/\.(xsd|json)$/i.test(e.name)) return; + const file = await new Promise((res, rej) => (e as FileSystemFileEntry).file(res, rej)); + out.push({ name: path + e.name, bytes: new Uint8Array(await file.arrayBuffer()) }); + } else if (e.isDirectory) { + const reader = (e as FileSystemDirectoryEntry).createReader(); + for (;;) { + const batch = await new Promise((res, rej) => reader.readEntries(res, rej)); + if (batch.length === 0) break; + for (const c of batch) await walk(c, path + e.name + "/"); + } + } + }; + for (const e of entries) await walk(e, ""); + return out; +} + +export async function readFiles(list: FileList | File[]): Promise { + const out: NamedBytes[] = []; + for (const f of Array.from(list)) { + if (!/\.(xsd|json)$/i.test(f.name)) continue; + out.push({ name: f.webkitRelativePath || f.name, bytes: new Uint8Array(await f.arrayBuffer()) }); + } + return out; +} + +async function checkBundle(b: unknown, progress?: (p: LoadProgress) => void): Promise { + const x = b as Bundle; + if (!x || typeof x !== "object" || !Array.isArray(x.components)) throw new Error("this is not a schema-explorer bundle"); + if (x.format !== 1) throw new Error(`bundle format ${x.format} is not supported by this viewer (expected 1)`); + if (x.compact) { + progress?.({ stage: "index", detail: "content models and search index" }); + return JSON.parse(await call({ op: "expand", json: JSON.stringify(x) })) as Bundle; + } + return x; +} + +/** The text of the files of a bundle, from wherever it came from. */ +export type SourceProvider = (file: number, name: string) => Promise; + +export function memorySources(files: NamedBytes[]): SourceProvider { + const dec = new TextDecoder(); + return async (_file, name) => { + const hit = files.find((f) => f.name === name || f.name.endsWith("/" + name)); + return hit ? dec.decode(hit.bytes) : null; + }; +} + +export function urlSources(base: string): SourceProvider { + return async (_file, name) => { + try { + const r = await fetch(new URL(name.split("/").map(encodeURIComponent).join("/"), base)); + if (!r.ok) return null; + const text = await r.text(); + return text.trimStart().startsWith("<") ? text : null; + } catch { + return null; + } + }; +} + +export function serverSources(): SourceProvider { + return async (file) => { + try { + const r = await fetch(new URL(`api/source/${file}`, baseUrl())); + return r.ok ? await r.text() : null; + } catch { + return null; + } + }; +} diff --git a/web/src/logo.ts b/web/src/logo.ts new file mode 100644 index 0000000..5f55412 --- /dev/null +++ b/web/src/logo.ts @@ -0,0 +1,11 @@ +// The MatData mark, from the MatData design system (logo blue is for the logo only). +export const MATDATA_MARK = ''; + +export function matdataMark(cls = "mark"): SVGSVGElement { + const tpl = document.createElement("template"); + tpl.innerHTML = MATDATA_MARK; + const svg = tpl.content.firstElementChild as SVGSVGElement; + svg.setAttribute("class", cls); + svg.setAttribute("aria-hidden", "true"); + return svg; +} diff --git a/web/src/main.ts b/web/src/main.ts index 1646702..7fe2fe9 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -1,2 +1,3 @@ -// Entry point of the static site and the viewer served by the CLI. -import "./lib"; +// The standalone app: one filling the page. +import { define } from "./element"; +define(); diff --git a/web/src/model.ts b/web/src/model.ts new file mode 100644 index 0000000..639a853 --- /dev/null +++ b/web/src/model.ts @@ -0,0 +1,582 @@ +// The bundle written by the Rust core (format 1) and a Model with the lookups +// the views need. See crates/core/src/bundle.rs for the authoritative types. + +export type Id = number; + +export type Kind = "element" | "complexType" | "simpleType" | "attribute" | "attributeGroup" | "group" | "key" | "keyref" | "unique"; + +/** A component id, or `{ns}local` when the reference could not be resolved. */ +export type Ref = Id | string; + +export interface Doc { + lang?: string; + source?: string; + text: string; +} + +export interface Occurs { + min?: number; + /** Absent: 1; null: unbounded. */ + max?: number | null; +} + +export interface Wildcard { + namespace?: string; + notNamespace?: string; + processContents?: string; +} + +export type Particle = + | ({ t: "element"; el: Ref } & Occurs) + | ({ t: "group"; group: Ref } & Occurs) + | ({ t: "sequence" | "choice" | "all"; items: Particle[] } & Occurs) + | ({ t: "any" } & Wildcard & Occurs); + +export type AttrUse = "optional" | "required" | "prohibited"; + +export type AttrItem = { t: "attribute"; attr: Ref; use?: AttrUse; default?: string; fixed?: string } | { t: "group"; group: Ref }; + +export interface Facet { + kind: string; + value: string; + fixed?: boolean; +} + +export interface Enumeration { + value: string; + docs?: Doc[]; + appinfo?: string[]; + line?: number; +} + +export interface EffectiveAttr { + attr: Ref; + use?: AttrUse; + default?: string; + fixed?: string; + from: Id; + via?: Id; +} + +export interface Effective { + content?: Id[]; + attributes?: EffectiveAttr[]; + anyAttribute?: Wildcard; + simple?: Ref; + mixed?: boolean; +} + +export interface ComplexInfo { + derivation?: { method: "extension" | "restriction"; base: Ref; simple?: boolean }; + content?: Particle; + attributes?: AttrItem[]; + anyAttribute?: Wildcard; + facets?: Facet[]; + enumerations?: Enumeration[]; + mixed?: boolean; + abstract?: boolean; + final?: string; + block?: string; + asserts?: string[]; + derived?: Id[]; + effective?: Effective; +} + +export interface SimpleInfo { + variety: "atomic" | "list" | "union"; + base?: Ref; + item?: Ref; + members?: Ref[]; + facets?: Facet[]; + enumerations?: Enumeration[]; + final?: string; + derived?: Id[]; +} + +export interface ElementInfo { + type?: Ref; + substitutionGroup?: Ref[]; + members?: Id[]; + abstract?: boolean; + nillable?: boolean; + default?: string; + fixed?: string; + final?: string; + block?: string; + identity?: Id[]; +} + +export interface AttributeInfo { + type?: Ref; + default?: string; + fixed?: string; + inheritable?: boolean; +} + +export type UseKind = "type" | "base" | "item" | "member" | "element" | "attribute" | "group" | "attributeGroup" | "substitution" | "refer"; + +export interface Usage { + by: Id; + how: UseKind; +} + +export interface Component { + kind: Kind; + name?: string; + ns?: number; + parent?: Id; + file?: number; + line?: number; + docs?: Doc[]; + appinfo?: string[]; + redefines?: Id; + usedBy?: Usage[]; + element?: ElementInfo; + complexType?: ComplexInfo; + simpleType?: SimpleInfo; + attribute?: AttributeInfo; + group?: Particle; + attributeGroup?: { attributes?: AttrItem[]; anyAttribute?: Wildcard }; + identity?: { selector: string; fields: string[]; refer?: Ref }; + builtin?: boolean; +} + +export interface Directive { + kind: "import" | "include" | "redefine" | "override"; + namespace?: string; + location?: string; + file?: number; + via?: string; + line: number; +} + +export interface FileInfo { + name: string; + targetNamespace?: string; + namespace?: number; + prefixes?: Record; + version?: string; + elementFormQualified?: boolean; + attributeFormQualified?: boolean; + input?: boolean; + directives?: Directive[]; + docs?: Doc[]; + components: number; +} + +export interface Diagnostic { + level: "info" | "warning" | "error"; + code: string; + message: string; + file?: number; + line?: number; +} + +export interface Bundle { + format: number; + generator: string; + compact?: boolean; + files: FileInfo[]; + namespaces: { uri: string; prefix: string }[]; + components: Component[]; + diagnostics?: Diagnostic[]; + stats: { + files: number; + global: Partial>; + local: Partial>; + enumerations: number; + unresolvedImports: number; + unresolvedRefs: number; + }; + search?: Record; + /** Added by the viewer: a title for the top bar. */ + title?: string; +} + +export const KIND_LABEL: Record = { + element: "Element", + complexType: "Complex type", + simpleType: "Simple type", + attribute: "Attribute", + attributeGroup: "Attribute group", + group: "Group", + key: "Key", + keyref: "Key reference", + unique: "Unique", +}; + +/** Short kind names used in links (#/ct/prefix:Name). */ +export const KIND_SLUG: Record = { + element: "el", + complexType: "ct", + simpleType: "st", + attribute: "at", + attributeGroup: "ag", + group: "gr", + key: "key", + keyref: "keyref", + unique: "unique", +}; + +export const isId = (r: Ref | undefined): r is Id => typeof r === "number"; + +/** A row of the content model table: a compositor, an element or a wildcard. */ +export interface ContentRow { + kind: "compositor" | "element" | "any" | "group"; + depth: number; + /** sequence | choice | all for compositors. */ + compositor?: "sequence" | "choice" | "all"; + /** The element declaration (local or global) or the group. */ + id?: Id; + /** Unresolved reference text. */ + missing?: string; + min: number; + max: number | null; + /** The type whose own content the row comes from. */ + from: Id; + /** Directly inside a choice. */ + inChoice: boolean; + wildcard?: Wildcard; +} + +export interface SearchHit { + id: Id; + score: number; +} + +const XS = "http://www.w3.org/2001/XMLSchema"; + +export class Model { + readonly b: Bundle; + readonly c: Component[]; + /** Global, non built-in components per kind, sorted by name. */ + readonly lists: Record<"complexType" | "simpleType" | "element" | "group" | "attributeGroup" | "attribute", Id[]>; + private byQname = new Map(); + private lowerNames: string[]; + private searchKeys: string[] | null = null; + readonly xsNs: number | undefined; + + constructor(b: Bundle) { + this.b = b; + this.c = b.components; + this.xsNs = b.namespaces.findIndex((n) => n.uri === XS); + if (this.xsNs < 0) this.xsNs = undefined; + const lists: Model["lists"] = { complexType: [], simpleType: [], element: [], group: [], attributeGroup: [], attribute: [] }; + this.lowerNames = new Array(this.c.length); + for (let i = 0; i < this.c.length; i++) { + const c = this.c[i]; + this.lowerNames[i] = (c.name ?? "").toLowerCase(); + if (c.parent !== undefined) continue; + this.byQname.set(`${KIND_SLUG[c.kind]}/${this.qname(i)}`, i); + if (c.builtin) continue; + const l = (lists as Record)[c.kind]; + if (l) l.push(i); + } + const cmp = (a: Id, z: Id) => { + const x = this.c[a].name ?? ""; + const y = this.c[z].name ?? ""; + return x < y ? -1 : x > y ? 1 : (this.c[a].ns ?? -1) - (this.c[z].ns ?? -1); + }; + for (const l of Object.values(lists)) l.sort(cmp); + this.lists = lists; + } + + get title(): string { + if (this.b.title) return this.b.title; + const input = this.b.files.find((f) => f.input); + return input ? input.name.split("/").pop()! : "XML Schema"; + } + + comp(id: Id): Component { + return this.c[id]; + } + + prefix(ns: number | undefined): string { + return ns === undefined ? "" : this.b.namespaces[ns]?.prefix ?? ""; + } + + /** `prefix:name` of a global component, the bare name of a local one. */ + qname(id: Id): string { + const c = this.c[id]; + if (c.parent !== undefined) return c.name ?? ""; + const p = this.prefix(c.ns); + return p ? `${p}:${c.name ?? ""}` : c.name ?? ""; + } + + /** Name for display: anonymous types are named after their element. */ + label(id: Id): string { + const c = this.c[id]; + if (c.name) return c.name; + if (c.parent !== undefined) return `${this.label(c.parent)} (anonymous)`; + return "(anonymous)"; + } + + /** The text of a reference (resolved or not). */ + refName(r: Ref | undefined): string { + if (r === undefined) return ""; + if (isId(r)) return this.qname(r); + // {ns}local → local with a prefix when known + const m = /^\{([^}]*)\}(.*)$/.exec(r); + if (!m) return r; + const ns = this.b.namespaces.findIndex((n) => n.uri === m[1]); + return ns >= 0 ? `${this.prefix(ns)}:${m[2]}` : m[2]; + } + + /** Short type name for diagrams: no xs: prefix on built-ins. */ + typeName(r: Ref | undefined): string { + if (r === undefined) return ""; + if (isId(r)) { + const c = this.c[r]; + if (c.builtin || (c.ns !== undefined && c.ns === this.xsNs)) return c.name ?? ""; + if (!c.name) return "(anonymous)"; + return c.name; + } + return this.refName(r); + } + + find(slug: string, qname: string): Id | undefined { + return this.byQname.get(`${slug}/${qname}`); + } + + /** The global component a local one is declared in. */ + owner(id: Id): Id { + let cur = id; + const seen = new Set(); + while (this.c[cur].parent !== undefined && !seen.has(cur)) { + seen.add(cur); + cur = this.c[cur].parent!; + } + return cur; + } + + isGlobal(id: Id): boolean { + return this.c[id].parent === undefined; + } + + docText(id: Id): string { + return docsText(this.c[id].docs); + } + + fileName(id: Id): string | undefined { + const f = this.c[id].file; + return f === undefined ? undefined : this.b.files[f]?.name; + } + + /** The element's declared type, through a reference to a global element. */ + elementType(id: Id): Ref | undefined { + const e = this.c[id].element; + if (!e) return undefined; + if (e.type !== undefined) return e.type; + // No type: that of the substitution group head. + const head = e.substitutionGroup?.[0]; + if (isId(head) && head !== id) return this.elementType(head); + return undefined; + } + + isComplex(r: Ref | undefined): r is Id { + return isId(r) && this.c[r].kind === "complexType" && !this.c[r].builtin; + } + + /** Base types from the type itself up to the root. */ + baseChain(id: Id): Ref[] { + const out: Ref[] = []; + const seen = new Set([id]); + let cur: Ref | undefined = this.baseOf(id); + while (cur !== undefined) { + out.push(cur); + if (!isId(cur) || seen.has(cur)) break; + seen.add(cur); + cur = this.baseOf(cur); + } + return out; + } + + baseOf(id: Id): Ref | undefined { + const c = this.c[id]; + return c.complexType?.derivation?.base ?? c.simpleType?.base; + } + + derived(id: Id): Id[] { + const c = this.c[id]; + return c.complexType?.derived ?? c.simpleType?.derived ?? []; + } + + enumerations(id: Id): Enumeration[] { + const c = this.c[id]; + return c.simpleType?.enumerations ?? c.complexType?.enumerations ?? []; + } + + /** An enumeration simple type (directly or by restricting one). */ + isEnum(id: Id): boolean { + return this.c[id].kind === "simpleType" && (this.c[id].simpleType?.enumerations?.length ?? 0) > 0; + } + + /** The content model as table rows, base types first. */ + contentRows(typeId: Id, opts: { inherited: boolean } = { inherited: true }): ContentRow[] { + const c = this.c[typeId]; + const ct = c.complexType; + if (!ct) return []; + const parts = ct.effective?.content ?? (ct.content ? [typeId] : []); + const rows: ContentRow[] = []; + for (const from of parts) { + if (!opts.inherited && from !== typeId) continue; + const p = this.c[from].complexType?.content; + if (p) this.particleRows(p, from, 0, false, rows, new Set()); + } + return rows; + } + + particleRows(p: Particle, from: Id, depth: number, inChoice: boolean, out: ContentRow[], groups: Set): void { + const min = p.min ?? 1; + const max = p.max === undefined ? 1 : p.max; + switch (p.t) { + case "element": + out.push({ kind: "element", depth, id: isId(p.el) ? p.el : undefined, missing: isId(p.el) ? undefined : p.el, min, max, from, inChoice }); + break; + case "any": + out.push({ kind: "any", depth, min, max, from, inChoice, wildcard: p }); + break; + case "group": { + // A group reference is shown expanded, with its name on the compositor row. + const gid = isId(p.group) ? p.group : undefined; + const gp = gid !== undefined ? this.c[gid].group : undefined; + out.push({ kind: "group", depth, id: gid, missing: gid === undefined ? String(p.group) : undefined, min, max, from, inChoice }); + if (gp && gid !== undefined && !groups.has(gid)) { + groups.add(gid); + this.particleRows(gp, from, depth + 1, false, out, groups); + groups.delete(gid); + } + break; + } + default: { + // A plain sequence of one occurrence directly in a type adds nothing to read. + const skip = depth === 0 && p.t === "sequence" && min === 1 && max === 1; + if (!skip) out.push({ kind: "compositor", compositor: p.t, depth, min, max, from, inChoice }); + for (const it of p.items) this.particleRows(it, from, skip ? depth : depth + 1, p.t === "choice", out, groups); + } + } + } + + /** All attributes of a complex type (inherited and from groups), or of an attribute group. */ + attributes(id: Id): EffectiveAttr[] { + const c = this.c[id]; + if (c.complexType?.effective?.attributes) return c.complexType.effective.attributes; + const items = c.complexType?.attributes ?? c.attributeGroup?.attributes ?? []; + const out: EffectiveAttr[] = []; + const walk = (list: AttrItem[], via: Id | undefined, seen: Set) => { + for (const a of list) { + if (a.t === "attribute") out.push({ attr: a.attr, use: a.use, default: a.default, fixed: a.fixed, from: id, via }); + else if (isId(a.group) && !seen.has(a.group)) { + seen.add(a.group); + walk(this.c[a.group].attributeGroup?.attributes ?? [], via ?? a.group, seen); + } + } + }; + walk(items, undefined, new Set()); + return out; + } + + /** The attribute's type, through a reference. */ + attrType(r: Ref): Ref | undefined { + if (!isId(r)) return undefined; + return this.c[r].attribute?.type; + } + + /** Who uses a component, grouped by the global component the use is in. */ + usedBy(id: Id): { owner: Id; by: Id; how: string }[] { + const out: { owner: Id; by: Id; how: string }[] = []; + const seen = new Set(); + for (const u of this.c[id].usedBy ?? []) { + const owner = this.owner(u.by); + const key = `${owner}/${u.by}/${u.how}`; + if (seen.has(key)) continue; + seen.add(key); + out.push({ owner, by: u.by, how: u.how }); + } + return out; + } + + /** Global elements that nothing refers to: likely document roots. */ + rootElements(): Id[] { + return this.lists.element.filter((id) => !(this.c[id].usedBy ?? []).some((u) => u.how === "element" || u.how === "substitution") && !this.c[id].element?.abstract); + } + + /** Complex types per namespace, for the overview map. */ + namespacesOf(kind: "complexType" | "simpleType" | "element"): { ns: number | undefined; count: number }[] { + const counts = new Map(); + for (const id of this.lists[kind]) counts.set(this.c[id].ns, (counts.get(this.c[id].ns) ?? 0) + 1); + return [...counts.entries()].map(([ns, count]) => ({ ns, count })).sort((a, b) => b.count - a.count); + } + + /** Name search with a ranking, plus words of the documentation. */ + search(q: string, limit = 200): Id[] { + const query = q.trim().toLowerCase(); + if (!query) return []; + const words = query.split(/\s+/).filter(Boolean); + const first = words[0].includes(":") ? words[0].slice(words[0].indexOf(":") + 1) : words[0]; + const scores = new Map(); + const add = (id: Id, s: number) => { + if ((scores.get(id) ?? -1) < s) scores.set(id, s); + }; + if (words.length === 1) { + for (let i = 0; i < this.lowerNames.length; i++) { + const n = this.lowerNames[i]; + if (!n) continue; + const at = n.indexOf(first); + if (at < 0) continue; + const c = this.c[i]; + let s = n === first ? 100 : at === 0 ? 70 : 40; + if (c.parent === undefined) s += 20; + if (c.builtin) s -= 30; + if (c.kind === "complexType" || c.kind === "element") s += 4; + s -= Math.min(10, (n.length - first.length) / 4); + add(i, s); + } + } + // Documentation words (and name parts) from the bundle's index. + const idx = this.b.search; + if (idx) { + if (!this.searchKeys) this.searchKeys = Object.keys(idx).sort(); + let acc: Set | null = null; + for (const w of words) { + const hits = new Set(); + for (const k of this.prefixKeys(w, 40)) for (const id of idx[k]) hits.add(id); + const prev: Set | null = acc; + acc = prev === null ? hits : new Set([...prev].filter((x: Id) => hits.has(x))); + } + for (const id of acc ?? []) { + const c = this.c[id]; + add(id, 10 + (c.parent === undefined ? 10 : 0)); + } + } + return [...scores.entries()] + .sort((a, b) => b[1] - a[1] || (this.c[a[0]].name ?? "").localeCompare(this.c[b[0]].name ?? "")) + .slice(0, limit) + .map(([id]) => id); + } + + private prefixKeys(w: string, max: number): string[] { + const keys = this.searchKeys!; + let lo = 0; + let hi = keys.length; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (keys[mid] < w) lo = mid + 1; + else hi = mid; + } + const out: string[] = []; + for (let i = lo; i < keys.length && out.length < max && keys[i].startsWith(w); i++) out.push(keys[i]); + return out; + } +} + +export function docsText(docs: Doc[] | undefined): string { + if (!docs?.length) return ""; + const en = docs.filter((d) => !d.lang || d.lang.startsWith("en")); + return (en.length ? en : docs).map((d) => d.text).join("\n\n"); +} + +export function occursText(min: number, max: number | null): string { + if (min === 1 && max === 1) return "1"; + return `${min}..${max === null ? "*" : max}`; +} diff --git a/web/src/source.ts b/web/src/source.ts new file mode 100644 index 0000000..2b92d29 --- /dev/null +++ b/web/src/source.ts @@ -0,0 +1,131 @@ +// The raw XSD of one component: cut out of its file from the declaration's +// line to its end tag, then highlighted with the type names linked. +import type { Id, Model } from "./model"; + +/** The element that starts on `line` (1-based), with its end tag. */ +export function snippet(text: string, line: number): { text: string; startLine: number } | null { + let pos = 0; + for (let l = 1; l < line && pos >= 0; l++) pos = text.indexOf("\n", pos) + (pos >= 0 ? 1 : 0); + if (pos < 0) return null; + const lineStart = pos; + // The first start tag at or after the line start. + let start = -1; + for (let i = pos; i < text.length; i++) { + if (text[i] !== "<") continue; + const n = text[i + 1]; + if (n === "!" || n === "?" || n === "/") continue; + start = i; + break; + } + if (start < 0) return null; + let depth = 0; + let i = start; + while (i < text.length) { + const lt = text.indexOf("<", i); + if (lt < 0) return null; + if (text.startsWith("", lt + 4); + i = e < 0 ? text.length : e + 3; + continue; + } + if (text.startsWith("", lt); + i = e < 0 ? text.length : e + 3; + continue; + } + if (text[lt + 1] === "?" || text[lt + 1] === "!") { + i = text.indexOf(">", lt) + 1 || text.length; + continue; + } + // Find the end of the tag, skipping quoted attribute values. + let j = lt + 1; + let q = ""; + for (; j < text.length; j++) { + const ch = text[j]; + if (q) { + if (ch === q) q = ""; + } else if (ch === '"' || ch === "'") q = ch; + else if (ch === ">") break; + } + const closing = text[lt + 1] === "/"; + const selfClosing = text[j - 1] === "/"; + if (closing) depth--; + else if (!selfClosing) depth++; + i = j + 1; + if (depth <= 0) break; + } + // Keep the indentation of the first line, then remove what all lines share. + const lines = text.slice(lineStart, i).split("\n"); + const indent = Math.min(...lines.filter((l) => l.trim()).map((l) => /^\s*/.exec(l)![0].length)); + const out = lines.map((l) => l.slice(Math.min(indent, /^\s*/.exec(l)![0].length))).join("\n"); + return { text: out.replace(/\s+$/, ""), startLine: line }; +} + +const LINKED: Record = { + type: ["ct", "st"], + base: ["ct", "st"], + itemType: ["st"], + memberTypes: ["st"], + substitutionGroup: ["el"], + refer: [], +}; + +const TOKEN = /()|(<\/?)([\w.:-]+)|([\w.:-]+)(\s*=\s*)("[^"]*"|'[^']*')|(\/?>)/g; + +/** Highlighted XSD with QName values linked to their pages. */ +export function highlight(m: Model, file: number, text: string, link: (id: Id, text: string) => HTMLElement): HTMLElement { + const pre = document.createElement("pre"); + pre.className = "xml"; + const prefixes = m.b.files[file]?.prefixes ?? {}; + const span = (cls: string, t: string) => { + const s = document.createElement("span"); + s.className = cls; + s.textContent = t; + return s; + }; + let tag = ""; + let at = 0; + for (const t of text.matchAll(TOKEN)) { + if (t.index > at) pre.append(text.slice(at, t.index)); + at = t.index + t[0].length; + if (t[1]) pre.append(span("t-com", t[1])); + else if (t[3]) { + tag = t[3].replace(/^.*:/, ""); + pre.append(span("t-kw", t[2]), span("t-tag", t[3])); + } else if (t[4]) { + const attr = t[4]; + pre.append(span("t-attr", attr), t[5]); + const quote = t[6][0]; + const value = t[6].slice(1, -1); + const kinds = attr === "ref" ? [tag === "element" ? "el" : tag === "attribute" ? "at" : tag === "group" ? "gr" : tag === "attributeGroup" ? "ag" : ""] : LINKED[attr]; + if (kinds && kinds.length && kinds[0]) { + pre.append(span("t-str", quote)); + value.split(/(\s+)/).forEach((part) => { + const id = part.trim() ? resolve(m, prefixes, part, kinds) : undefined; + if (id !== undefined) { + const a = link(id, part); + a.classList.add("t-iri"); + pre.append(a); + } else pre.append(span("t-str", part)); + }); + pre.append(span("t-str", quote)); + } else pre.append(span("t-str", t[6])); + } else if (t[7]) pre.append(span("t-kw", t[7])); + } + pre.append(text.slice(at)); + return pre; +} + +function resolve(m: Model, prefixes: Record, qname: string, kinds: string[]): Id | undefined { + const i = qname.indexOf(":"); + const p = i < 0 ? "" : qname.slice(0, i); + const local = i < 0 ? qname : qname.slice(i + 1); + const uri = prefixes[p]; + const ns = uri === undefined ? -1 : m.b.namespaces.findIndex((n) => n.uri === uri); + const display = ns >= 0 ? m.b.namespaces[ns].prefix : ""; + for (const k of kinds) { + const id = m.find(k, display ? `${display}:${local}` : local); + if (id !== undefined) return id; + } + return undefined; +} diff --git a/web/src/styles.css b/web/src/styles.css new file mode 100644 index 0000000..06c8b33 --- /dev/null +++ b/web/src/styles.css @@ -0,0 +1,2275 @@ +/* schema-explorer: styles for the shadow root. */ +/* Colours, type and shape follow the MatData design system. */ +:host { + --se-bg: #f5f7fa; + --se-panel: #ffffff; + --se-panel-2: #edf0f4; + --se-border: #e3e7ec; + --se-border-strong: #808893; + --se-text: #1c2633; + --se-text-2: #56606c; + --se-text-3: #56606c; + --se-accent: #0563bb; + --se-accent-strong: #044a8c; + --se-on-accent: #ffffff; + --se-accent-bg: #e3eefa; + --se-highlight: #f2b300; + --se-highlight-bg: #fbefc7; + --se-highlight-ink: #6e4d00; + --se-focus: #044a8c; + --se-danger: #b42318; + --se-danger-bg: #fbe4e1; + --se-warn: #6e4d00; + --se-ok: #0563bb; + --se-add: #13773a; + --se-add-bg: #ddf3e4; + --se-del: #b42318; + --se-del-bg: #fbe4e1; + --se-chg: #8a5a00; + --se-chg-bg: #fbefc7; + --se-class: #0563bb; + --se-class-bg: #e3eefa; + --se-prop: #1c2633; + --se-prop-bg: #edf0f4; + --se-dtprop: #56606c; + --se-concept: #044a8c; + --se-concept-bg: #e3eefa; + --se-scheme: #044a8c; + --se-scheme-bg: #ffffff; + --se-shape: #6e4d00; + --se-shape-bg: #fbefc7; + --se-other: #56606c; + --se-other-bg: #edf0f4; + --se-shadow: 0 1px 2px rgba(28, 38, 51, 0.04), 0 4px 16px rgba(28, 38, 51, 0.05); + --se-shadow-raised: 0 2px 4px rgba(28, 38, 51, 0.05), 0 12px 32px rgba(28, 38, 51, 0.08); + --se-radius: 10px; + --se-radius-sm: 6px; + --se-radius-btn: 8px; + --se-radius-lg: 16px; + --se-font: Barlow, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + --se-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + --se-ease: 150ms ease-out; + --se-sidebar-w: 344px; + color: var(--se-text); + background: var(--se-bg); + font-family: var(--se-font); + font-size: 15px; + line-height: 1.5; + font-variant-numeric: tabular-nums; + display: block; + height: 100%; + min-height: 0; + color-scheme: light; +} +:host([theme="dark"]) { + --se-bg: #0d141c; + --se-panel: #16202b; + --se-panel-2: #0a1016; + --se-border: #26323e; + --se-border-strong: #6f7e8c; + --se-text: #eef1ef; + --se-text-2: #a3afba; + --se-text-3: #a3afba; + --se-accent: #5aa8f2; + --se-accent-strong: #93c5f6; + --se-on-accent: #04172b; + --se-accent-bg: #0e2a48; + --se-highlight: #f5c242; + --se-highlight-bg: #3a2f0c; + --se-highlight-ink: #f5c242; + --se-focus: #f5c242; + --se-danger: #ff8f80; + --se-danger-bg: #3d1512; + --se-warn: #f5c242; + --se-ok: #5aa8f2; + --se-add: #5fd38a; + --se-add-bg: #0f2e1b; + --se-del: #ff8f80; + --se-del-bg: #3d1512; + --se-chg: #f5c242; + --se-chg-bg: #3a2f0c; + --se-class: #5aa8f2; + --se-class-bg: #0e2a48; + --se-prop: #eef1ef; + --se-prop-bg: #0a1016; + --se-dtprop: #a3afba; + --se-concept: #93c5f6; + --se-concept-bg: #0e2a48; + --se-scheme: #93c5f6; + --se-scheme-bg: #16202b; + --se-shape: #f5c242; + --se-shape-bg: #3a2f0c; + --se-other: #a3afba; + --se-other-bg: #0a1016; + --se-shadow: 0 1px 3px rgba(0, 0, 0, 0.4); + --se-shadow-raised: 0 2px 8px rgba(0, 0, 0, 0.5); + color-scheme: dark; +} +*, +*::before, +*::after { + box-sizing: border-box; +} +a { + color: var(--se-accent); + text-decoration: none; +} +a:hover { + text-decoration: underline; +} +button, +input, +select { + font: inherit; + color: inherit; +} +code, +.mono { + font-family: var(--se-mono); + font-size: 0.92em; +} +.icon { + width: 18px; + height: 18px; + fill: none; + stroke: currentColor; + stroke-width: 1.5; + stroke-linecap: round; + stroke-linejoin: round; + flex: none; +} +.sr-only { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); +} + +/* ---- shell ---------------------------------------------------------- */ +.shell { + display: grid; + grid-template-rows: auto 1fr; + height: 100%; + min-height: 0; +} +.shell.embed { + grid-template-rows: 1fr; +} +.topbar { + display: flex; + align-items: center; + gap: 8px; + padding: 0 16px; + background: var(--se-panel); + border-bottom: 3px solid var(--se-highlight); + min-height: 64px; + min-width: 0; + position: relative; + z-index: 2; +} +.topbar .title { + display: inline-flex; + align-items: center; + gap: 12px; + color: var(--se-text); + font-size: 18px; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} +.topbar .title:hover { + text-decoration: none; +} +.topbar .title-text { + overflow: hidden; + text-overflow: ellipsis; +} +.topbar .title small { + color: var(--se-text-2); + font-size: 14px; + font-weight: 400; +} +.mark { + width: 32px; + height: 32px; + flex: none; +} +.topbar .spacer { + flex: 1; +} +.body { + display: grid; + grid-template-columns: var(--se-sidebar-w) 1fr; + min-height: 0; + height: 100%; +} +.body.no-sidebar { + grid-template-columns: 1fr; +} +.sidebar { + background: var(--se-panel); + border-right: 1px solid var(--se-border); + display: flex; + flex-direction: column; + min-height: 0; +} +.main { + min-width: 0; + min-height: 0; + overflow: auto; + position: relative; +} +.main-inner { + padding: 18px 22px 40px; + max-width: 1200px; +} +.topbar > .btn, +.topbar > select { + flex-shrink: 0; +} +.btn.menu-btn { + display: none; +} +@media (max-width: 860px) { + .btn.menu-btn { + display: inline-flex; + } + /* Room for the title on a phone: the share button keeps its icon, About moves to the footer. */ + .topbar .share-btn span, + .topbar .btn.about-btn { + display: none; + } + .topbar .btn.share-btn { + padding: 0; + width: 32px; + } + .topbar select.btn.select { + max-width: 64px; + } + .body { + grid-template-columns: 1fr; + } + .sidebar { + position: absolute; + inset: 64px 0 0 0; + z-index: 20; + width: min(90vw, 360px); + box-shadow: var(--se-shadow); + transform: translateX(-105%); + transition: transform var(--se-ease); + } + .sidebar.open { + transform: none; + } + .main-inner { + padding: 12px 14px 40px; + } + .hide-mobile { + display: none !important; + } +} + +/* ---- buttons, inputs, badges --------------------------------------- */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 36px; + padding: 0 14px; + border: 1px solid var(--se-border); + border-radius: var(--se-radius-btn); + background: var(--se-panel); + font-weight: 500; + cursor: pointer; + white-space: nowrap; + line-height: 1.2; + transition: background-color var(--se-ease), border-color var(--se-ease), color var(--se-ease); +} +.btn:hover { + border-color: var(--se-border-strong); + background: var(--se-panel-2); +} +.btn.icon-only { + padding: 0; + width: 36px; +} +.btn.primary { + min-height: 42px; + padding: 0 20px; + background: var(--se-accent); + border-color: var(--se-accent); + color: var(--se-on-accent); +} +.btn.primary:hover { + background: var(--se-accent-strong); + border-color: var(--se-accent-strong); +} +.btn[aria-pressed="true"] { + background: var(--se-accent-bg); + border-color: var(--se-accent); + color: var(--se-accent-strong); +} +.btn.small { + min-height: 30px; + padding: 0 10px; + font-size: 14px; +} +.btn.small.icon-only { + width: 30px; + padding: 0; +} +.btn:focus-visible, +a:focus-visible, +summary:focus-visible, +[tabindex]:focus-visible { + outline: 2px solid var(--se-focus); + outline-offset: 2px; +} +.btn.ghost { + border-color: transparent; + background: transparent; +} +.btn.ghost:hover { + background: var(--se-panel-2); +} +.btn-group { + display: inline-flex; +} +.btn-group .btn { + border-radius: 0; + margin-left: -1px; +} +.btn-group .btn:first-child { + border-radius: var(--se-radius-btn) 0 0 var(--se-radius-btn); + margin-left: 0; +} +.btn-group .btn:last-child { + border-radius: 0 var(--se-radius-btn) var(--se-radius-btn) 0; +} +.input { + width: 100%; + min-height: 36px; + padding: 6px 12px; + border: 1px solid var(--se-border-strong); + border-radius: var(--se-radius-sm); + background: var(--se-panel); +} +.input:focus { + outline: 2px solid var(--se-focus); + outline-offset: 2px; +} +select.input { + width: auto; +} +.badge { + display: inline-flex; + align-items: center; + gap: 4px; + min-height: 22px; + padding: 0 10px; + border-radius: 999px; + font-size: 13px; + font-weight: 500; + background: var(--se-other-bg); + color: var(--se-other); + white-space: nowrap; + vertical-align: middle; +} +.badge.k-complexType { + background: var(--se-class-bg); + color: var(--se-class); +} +.badge.k-element, +.badge.k-property, +.badge.k-annotationProperty { + background: var(--se-prop-bg); + color: var(--se-prop); +} +.badge.k-attribute { + background: var(--se-prop-bg); + color: var(--se-dtprop); +} +.badge.k-enum { + background: var(--se-concept-bg); + color: var(--se-concept); +} +.badge.k-simpleType, +.badge.k-collection { + background: var(--se-scheme-bg); + color: var(--se-scheme); + box-shadow: inset 0 0 0 1px var(--se-scheme); +} +.badge.k-group, +.badge.k-attributeGroup, +.badge.k-key { + background: var(--se-shape-bg); + color: var(--se-shape); +} +.badge.warn { + background: var(--se-highlight-bg); + color: var(--se-highlight-ink); +} +.badge.danger { + background: var(--se-danger-bg); + color: var(--se-danger); +} +.badge.src { + font-family: var(--se-mono); + font-weight: 400; + font-size: 11px; +} +/* Kind markers follow the MatData graph grammar: a class is a solid pill, a property a + ring, a datatype property a square plate, a concept a soft dot, a scheme a square ring + and a shape the amber diamond. */ +.kind-dot { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 999px; + margin-right: 6px; + background: var(--se-other); + flex: none; +} +.kind-dot.k-complexType { + width: 14px; + background: var(--se-class); +} +.kind-dot.k-element, +.kind-dot.k-property, +.kind-dot.k-annotationProperty { + background: transparent; + box-shadow: inset 0 0 0 2px var(--se-class); +} +.kind-dot.k-attribute { + border-radius: 2px; + background: var(--se-prop); +} +.kind-dot.k-enum { + background: var(--se-concept-bg); + box-shadow: inset 0 0 0 2px var(--se-concept); +} +.kind-dot.k-simpleType, +.kind-dot.k-collection { + border-radius: 3px; + background: transparent; + box-shadow: inset 0 0 0 2px var(--se-scheme); +} +.kind-dot.k-group, +.kind-dot.k-attributeGroup, +.kind-dot.k-key { + border-radius: 2px; + background: var(--se-highlight); + transform: rotate(45deg) scale(0.8); +} +.deprecated { + text-decoration: line-through; + opacity: 0.75; +} +.external { + opacity: 0.7; +} +.muted { + color: var(--se-text-2); +} +.faint { + color: var(--se-text-3); +} +.nowrap { + white-space: nowrap; +} + +/* ---- sidebar -------------------------------------------------------- */ +.search-box { + position: relative; + padding: 10px 10px 6px; +} +.search-box .icon { + position: absolute; + left: 18px; + top: 18px; + color: var(--se-text-3); +} +.search-box .input { + padding-left: 32px; + padding-right: 30px; +} +.search-box .clear { + position: absolute; + right: 14px; + top: 14px; +} +.search-box kbd { + position: absolute; + right: 18px; + top: 19px; + font: 11px var(--se-mono); + color: var(--se-text-3); + border: 1px solid var(--se-border); + border-radius: 4px; + padding: 0 4px; +} +.tabs { + display: flex; + justify-content: space-between; + overflow-x: auto; + scrollbar-width: none; + padding: 0 10px; + border-bottom: 1px solid var(--se-border); +} +.tabs button { + border: 0; + background: none; + padding: 10px 2px; + cursor: pointer; + color: var(--se-text-2); + border-bottom: 2px solid transparent; + margin-bottom: -1px; + white-space: nowrap; + font-size: 13px; + font-weight: 500; + transition: color var(--se-ease), border-color var(--se-ease); +} +.tabs button:hover { + color: var(--se-text); +} +.tabs button[aria-selected="true"] { + color: var(--se-text); + border-bottom-color: var(--se-accent); + font-weight: 600; +} +.tabs button .count { + color: var(--se-text-3); + font-weight: 400; + margin-left: 2px; + font-size: 10.5px; +} +.tree { + overflow: auto; + flex: 1; + padding: 6px 4px 20px; + min-height: 0; +} +.tree .toolbar { + display: flex; + gap: 6px; + padding: 2px 6px 8px; + flex-wrap: wrap; + align-items: center; + font-size: 12px; + color: var(--se-text-2); +} +.tree .toolbar label { + display: inline-flex; + gap: 4px; + align-items: center; + cursor: pointer; +} +.tnode { + list-style: none; + margin: 0; + padding: 0; +} +.tnode .row { + display: flex; + align-items: center; + gap: 2px; + padding: 2px 4px 2px 0; + border-radius: var(--se-radius-sm); + cursor: pointer; + min-height: 28px; + user-select: none; + color: var(--se-text); + text-decoration: none; +} +.tnode .row:hover { + background: var(--se-panel-2); +} +.tnode .row.selected { + background: var(--se-accent-bg); + color: var(--se-accent-strong); + font-weight: 500; +} +.tnode .row .twisty { + width: 20px; + height: 20px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 0; + background: none; + cursor: pointer; + padding: 0; + color: var(--se-text-3); + flex: none; +} +.tnode .row .twisty .icon { + width: 14px; + height: 14px; + transition: transform 0.12s; +} +.tnode .row .twisty.open .icon { + transform: rotate(90deg); +} +.tnode .row .twisty.leaf { + visibility: hidden; +} +.tnode .row .lbl { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.tnode .row .count { + color: var(--se-text-3); + font-size: 11.5px; + padding: 0 4px; +} +.tnode ul { + list-style: none; + margin: 0; + padding: 0 0 0 16px; +} +.tree .section { + font-size: 11.5px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--se-text-3); + padding: 10px 8px 4px; +} +.results .hit { + display: flex; + align-items: center; + gap: 6px; + padding: 5px 8px; + border-radius: var(--se-radius-sm); + cursor: pointer; + color: var(--se-text); + text-decoration: none; +} +.results .hit:hover, +.results .hit.active { + background: var(--se-panel-2); +} +.results .hit .lbl { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.results .hit .ctx { + color: var(--se-text-3); + font-size: 11.5px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 45%; +} +.empty { + padding: 20px 12px; + color: var(--se-text-3); + text-align: center; +} + +/* ---- detail page ---------------------------------------------------- */ +.page-head { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 8px 12px; + margin-bottom: 4px; +} +.page-head h1 { + font-size: 28px; + margin: 0; + font-weight: 600; + line-height: 34px; +} +.page-head h1.abstract { + font-style: italic; +} +.iri-line { + display: flex; + align-items: center; + gap: 6px; + color: var(--se-text-2); + font-family: var(--se-mono); + font-size: 12.5px; + flex-wrap: wrap; + margin-bottom: 10px; +} +.iri-line .btn { + padding: 2px; +} +.lead { + font-size: 16px; + line-height: 26px; + max-width: 80ch; + margin: 6px 0 14px; +} +.crumbs { + display: flex; + flex-wrap: wrap; + gap: 4px; + align-items: center; + color: var(--se-text-2); + font-size: 13px; + margin-bottom: 8px; +} +.crumbs .sep { + color: var(--se-text-3); +} +.section-title { + display: flex; + align-items: center; + gap: 10px; + margin: 32px 0 12px; + font-size: 20px; + line-height: 28px; + font-weight: 600; +} +.section-title .count { + color: var(--se-text-3); + font-weight: 400; +} +.section-title .tools { + margin-left: auto; + display: flex; + gap: 6px; + align-items: center; + flex-wrap: wrap; + font-weight: 400; + font-size: 14px; + line-height: 22px; +} +.section-title .tools label { + display: inline-flex; + align-items: center; + gap: 4px; + color: var(--se-text-2); + cursor: pointer; +} +.chips { + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.chip { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 26px; + padding: 0 12px; + border: 0; + border-radius: 999px; + background: var(--se-accent-bg); + color: var(--se-accent-strong); + font-size: 14px; + font-weight: 500; + cursor: pointer; + max-width: 100%; + transition: background-color var(--se-ease), color var(--se-ease); +} +.chip:hover { + text-decoration: none; + background: var(--se-accent); + color: var(--se-on-accent); +} +.chip .lbl { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.chip.more { + background: transparent; + color: var(--se-text-2); + box-shadow: inset 0 0 0 1px var(--se-border-strong); +} +.card { + background: var(--se-panel); + border-radius: var(--se-radius-lg); + box-shadow: var(--se-shadow); + padding: 16px 24px; +} +.grid-2 { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 12px; +} +.kv { + display: grid; + grid-template-columns: max-content 1fr; + gap: 4px 16px; + font-size: 14px; +} +.kv dt { + color: var(--se-text-2); + margin: 0; +} +.kv dd { + margin: 0; + min-width: 0; + overflow-wrap: anywhere; +} + +/* property table */ +.ptable { + width: 100%; + border-collapse: collapse; + font-size: 14px; + line-height: 22px; + background: var(--se-panel); + border-radius: var(--se-radius); + box-shadow: var(--se-shadow); + overflow: hidden; +} +.ptable th, +.ptable td { + text-align: left; + padding: 8px 12px; + border-bottom: 1px solid var(--se-border); + vertical-align: top; +} +.ptable th { + background: var(--se-panel-2); + font-weight: 600; + font-size: 13px; + color: var(--se-text-2); + white-space: nowrap; +} +.ptable tr:last-child td { + border-bottom: 0; +} +.ptable tr.group-row td { + background: var(--se-panel-2); + font-weight: 600; + color: var(--se-text-2); + font-size: 13px; +} +.ptable tr.inherited td { + color: var(--se-text-2); +} +.ptable tr.hidden-row td { + opacity: 0.6; +} +.ptable .pname { + font-weight: 500; +} +.ptable .pname .path { + display: block; + font-family: var(--se-mono); + font-size: 11.5px; + color: var(--se-text-3); + font-weight: 400; +} +.ptable .mult { + font-family: var(--se-mono); + white-space: nowrap; +} +.ptable .desc { + color: var(--se-text-2); + font-size: 12.5px; + margin-top: 2px; +} +.ptable .cons { + margin: 4px 0 0; + padding: 0; + list-style: none; + font-size: 12.5px; + color: var(--se-text-2); +} +.cons li { + margin: 1px 0; +} +.cons code { + white-space: pre-wrap; +} +.cons .ck { + color: var(--se-text-3); + font-family: var(--se-mono); + font-size: 11px; + margin-right: 4px; +} +.ptable .msg { + font-style: italic; +} +.ptable td.rng { + min-width: 140px; +} +.ptable .role { + font-size: 12px; + color: var(--se-accent-strong); + background: var(--se-accent-bg); + border-radius: 999px; + padding: 0 8px; + margin-left: 4px; +} +details.sparql { + margin-top: 4px; +} +details.sparql pre { + margin: 4px 0 0; + padding: 8px; + background: var(--se-panel-2); + border-radius: var(--se-radius-sm); + font-size: 13px; + overflow: auto; + max-height: 240px; + white-space: pre; +} + +/* annotations */ +.ann { + font-size: 13px; +} +.ann .kv dt { + font-family: var(--se-mono); + font-size: 12px; +} + +/* ---- diagram -------------------------------------------------------- */ +.diagram { + position: relative; + background: var(--se-panel); + border-radius: var(--se-radius-lg); + box-shadow: var(--se-shadow); + overflow: hidden; + height: 520px; + min-height: 240px; + resize: vertical; +} +.diagram.tall { + height: calc(100vh - 160px); +} +.diagram svg.canvas { + width: 100%; + height: 100%; + display: block; + cursor: grab; + touch-action: none; +} +.diagram svg.canvas:active { + cursor: grabbing; +} +.diagram .dtools { + position: absolute; + top: 8px; + left: 8px; + right: 8px; + display: flex; + gap: 6px; + flex-wrap: wrap; + align-items: center; + pointer-events: none; + z-index: 2; +} +.diagram .dtools > * { + pointer-events: auto; +} +.diagram .dtools .spacer { + flex: 1; +} +.diagram .dtools .btn { + box-shadow: var(--se-shadow); +} +.diagram .status { + position: absolute; + left: 0; + right: 0; + bottom: 0; + padding: 4px 10px; + font-size: 12px; + color: var(--se-text-3); + display: flex; + gap: 12px; + pointer-events: none; +} +.diagram .status .legend { + margin-left: auto; + display: flex; + gap: 10px; +} +.diagram .status .legend span::before { + content: ""; + display: inline-block; + width: 18px; + height: 0; + border-top: 2px solid var(--se-text-3); + vertical-align: middle; + margin-right: 4px; +} +.diagram .status .legend span.gen::before { + border-top-style: solid; +} +.diagram .status .legend span.gen::before { + border-top-color: var(--se-text); +} +.diagram .status .legend span.assoc::before, +.diagram .status .legend span.comp::before { + border-top-color: var(--se-border-strong); +} +.diagram .overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + color: var(--se-text-3); + background: color-mix(in srgb, var(--se-panel) 70%, transparent); + z-index: 1; + font-size: 13px; +} +.diagram .menu { + position: absolute; + background: var(--se-panel); + border-radius: var(--se-radius); + box-shadow: var(--se-shadow-raised); + padding: 4px; + z-index: 5; + min-width: 190px; +} +.diagram .menu button { + display: block; + width: 100%; + text-align: left; + border: 0; + background: none; + padding: 6px 10px; + border-radius: 5px; + cursor: pointer; +} +.diagram .menu button:hover { + background: var(--se-panel-2); +} +.diagram .menu .mh { + padding: 4px 10px; + font-size: 12px; + color: var(--se-text-3); + border-bottom: 1px solid var(--se-border); + margin-bottom: 4px; +} + +/* SVG node styling, after the MatData graph grammar: a class is a solid brand head, + a value set or outside class a soft one, the focus the diagram's one amber + highlight. Also exported, see diagram.ts for the inline copy. */ +.uml .node rect.box { + fill: var(--se-panel); + stroke: var(--se-border-strong); + stroke-width: 1; +} +.uml .node.focus rect.box { + stroke: var(--se-highlight); + stroke-width: 3; +} +.uml .node.external rect.box { + stroke-dasharray: 4 3; +} +.uml .node .head { + fill: var(--se-class); +} +.uml .node .head ~ text.name, +.uml .node .head ~ text.stereo { + fill: var(--se-on-accent); +} +.uml .node.external .head, +.uml .node.enum .head { + fill: var(--se-accent-bg); +} +.uml .node.external .head ~ text.name, +.uml .node.external .head ~ text.stereo, +.uml .node.enum .head ~ text.name, +.uml .node.enum .head ~ text.stereo { + fill: var(--se-accent-strong); +} +.uml .node.element .head, +.uml .node.datatype .head { + fill: var(--se-panel-2); +} +.uml .node.element .head ~ text.name, +.uml .node.datatype .head ~ text.name { + fill: var(--se-text); +} +.uml .node.element .head ~ text.stereo, +.uml .node.datatype .head ~ text.stereo { + fill: var(--se-text-2); +} +.uml text { + font-family: var(--se-font); + font-size: 12.5px; + fill: var(--se-text); + dominant-baseline: middle; +} +.uml text.name { + font-weight: 600; + font-size: 14px; + text-anchor: middle; +} +.uml text.name.abstract { + font-style: italic; +} +.uml text.stereo { + font-size: 11px; + text-anchor: middle; +} +.uml text.attr .type, +.uml text.attr .mult, +.uml text.attr.inh { + fill: var(--se-text-2); +} +.uml text.more { + fill: var(--se-accent); + font-size: 12px; + cursor: pointer; +} +.uml text.deprecated { + text-decoration: line-through; +} +.uml line.sep { + stroke: var(--se-border); + stroke-width: 1; +} +.uml .edge path { + fill: none; + stroke: var(--se-border-strong); + stroke-width: 1.5; + transition: stroke-width var(--se-ease); +} +.uml .edge.gen path { + stroke: var(--se-text); +} +.uml .edge.restr path { + stroke: var(--se-text); + stroke-dasharray: 5 3; +} +.uml .edge.dep path { + stroke-dasharray: 5 3; +} +.uml .edge.typed path { + stroke-dasharray: 2 3; +} +.uml .edge text { + font-family: var(--se-mono); + font-size: 11px; + fill: var(--se-text-2); + paint-order: stroke; + stroke: var(--se-panel); + stroke-width: 3px; + stroke-linejoin: round; +} +.uml .edge:hover path { + stroke-width: 2.5; + stroke: var(--se-accent); +} +.uml .edge.dim { + opacity: 0.25; +} +.uml .node.dim { + opacity: 0.35; +} +.uml .node { + cursor: pointer; +} +.uml .node:hover rect.box { + stroke: var(--se-accent); +} +.uml .node.focus:hover rect.box { + stroke: var(--se-highlight); +} +.uml marker path.tri { + fill: var(--se-panel); + stroke: var(--se-text); + stroke-width: 1.5; +} +.uml marker path.arrow, +.uml marker path.arrow-dep { + fill: none; + stroke: var(--se-border-strong); + stroke-width: 1.5; +} +.uml marker path.diamond { + fill: var(--se-text); + stroke: var(--se-text); +} +.uml .cluster rect { + fill: none; + stroke: var(--se-border); + stroke-dasharray: 6 4; + rx: 10; +} +.uml .cluster text { + fill: var(--se-text-3); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +/* ---- home / overview ------------------------------------------------ */ +.hero { + display: grid; + grid-template-columns: 1fr; + gap: 16px; + margin-bottom: 12px; +} +.stats { + display: flex; + flex-wrap: wrap; + gap: 8px; +} +.stat { + border: 0; + text-align: left; + color: var(--se-text); + background: var(--se-panel); + border-radius: var(--se-radius-lg); + box-shadow: var(--se-shadow); + padding: 12px 20px; + min-width: 132px; + cursor: pointer; + transition: box-shadow var(--se-ease); +} +.stat:hover { + box-shadow: var(--se-shadow-raised); +} +.stat .n { + font-size: 28px; + font-weight: 600; + line-height: 34px; +} +.stat .l { + color: var(--se-text-2); + font-size: 14px; +} +.progress { + padding: 40px 20px; + text-align: center; + color: var(--se-text-2); +} +.progress .bar { + height: 6px; + background: var(--se-panel-2); + border-radius: 3px; + overflow: hidden; + max-width: 360px; + margin: 12px auto 0; +} +.progress .bar i { + display: block; + height: 100%; + background: var(--se-accent); + width: 30%; + animation: slide 1.1s ease-in-out infinite alternate; +} +.progress .bar i.det { + animation: none; +} +@keyframes slide { + from { + margin-left: 0; + } + to { + margin-left: 70%; + } +} +.error { + background: var(--se-danger-bg); + color: var(--se-danger); + border-radius: var(--se-radius); + padding: 12px 16px; + margin: 12px 0; + white-space: pre-wrap; +} +.toast { + position: absolute; + bottom: 16px; + left: 50%; + transform: translateX(-50%); + background: var(--se-text); + color: var(--se-bg); + padding: 8px 16px; + border-radius: var(--se-radius-btn); + font-size: 14px; + z-index: 30; + box-shadow: var(--se-shadow-raised); +} + +/* concept tree in scheme pages */ +.ctree ul { + list-style: none; + padding-left: 18px; + margin: 0; +} +.ctree > ul { + padding-left: 0; +} +.ctree li { + margin: 2px 0; +} +.ctree .row { + display: flex; + align-items: center; + gap: 4px; +} +.ctree .twisty { + width: 18px; + height: 18px; + border: 0; + background: none; + padding: 0; + cursor: pointer; + color: var(--se-text-3); + display: inline-flex; + align-items: center; + justify-content: center; +} +.ctree .twisty .icon { + width: 13px; + height: 13px; +} +.ctree .twisty.open .icon { + transform: rotate(90deg); +} +.ctree .twisty.leaf { + visibility: hidden; +} +.ctree .notation { + font-family: var(--se-mono); + font-size: 11.5px; + color: var(--se-text-3); + margin-left: 6px; +} +.ctree .count { + color: var(--se-text-3); + font-size: 11.5px; + margin-left: 4px; +} +.about-foot { + margin-top: 40px; + padding-top: 12px; + border-top: 1px solid var(--se-border); + color: var(--se-text-3); + font-size: 12px; +} +.about-foot a { + color: var(--se-text-2); +} + +.lead.clamp { + display: -webkit-box; + -webkit-line-clamp: 4; + -webkit-box-orient: vertical; + overflow: hidden; +} +button.linkish { + background: none; + border: 0; + padding: 0; + color: var(--se-accent); + cursor: pointer; + font: inherit; +} +.cons .ck { + display: inline-block; + padding: 0 5px; + border-radius: 4px; + background: var(--se-panel-2); +} +.cons details.sparql pre { + white-space: pre-wrap; + font-size: 11.5px; + max-height: 260px; + overflow: auto; +} + +/* the menu a click on a diagram box opens */ +.diagram .node-menu { + min-width: 220px; + max-width: 320px; +} +.diagram .node-menu .mh { + font-weight: 600; + color: var(--se-text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.diagram .node-menu button { + display: flex; + align-items: center; + gap: 8px; +} +.diagram .node-menu button .icon { + width: 16px; + height: 16px; + color: var(--se-text-2); +} +.diagram .menu button:focus-visible { + outline: 2px solid var(--se-focus); + outline-offset: -2px; +} + +/* the language list in the top bar matches the buttons next to it */ +select.btn.select { + appearance: none; + padding: 0 28px 0 10px; + background: var(--se-panel) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2380889c' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E") no-repeat right 9px center; + max-width: 96px; +} + +/* long texts keep the line breaks they were written with */ +.lead, +.kv dd, +.ptable .desc, +.ptable .msg, +.ann dd { + white-space: pre-line; +} + +/* ---- dialogs (about, help) ----------------------------------------- */ +dialog.se-dialog { + width: min(600px, calc(100vw - 32px)); + max-height: min(84vh, 760px); + padding: 0; + border: 0; + border-radius: var(--se-radius-lg); + background: var(--se-panel); + color: var(--se-text); + box-shadow: var(--se-shadow-raised); + font-family: var(--se-font); +} +dialog.se-dialog[open] { + display: flex; + flex-direction: column; +} +dialog.se-dialog::backdrop { + background: rgba(13, 20, 28, 0.45); +} +.se-dialog .dlg-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 16px 16px 8px 24px; +} +.se-dialog .dlg-head h2 { + margin: 0; + font-size: 20px; + line-height: 28px; + font-weight: 600; +} +.se-dialog .dlg-body { + padding: 0 24px 24px; + overflow: auto; +} +.se-dialog .dlg-body h3 { + margin: 20px 0 4px; + font-size: 15px; + font-weight: 600; +} +.se-dialog p { + margin: 8px 0; +} +.se-dialog .credits { + margin: 4px 0; + padding-left: 18px; +} +.se-dialog .credits li { + margin: 4px 0; +} +.small { + font-size: 13px; +} +.help-sec { + border-top: 1px solid var(--se-border); +} +.help-sec:last-child { + border-bottom: 1px solid var(--se-border); +} +.help-sec summary { + cursor: pointer; + font-weight: 600; + padding: 12px 0; +} +.help-sec ul { + margin: 0 0 12px; + padding-left: 18px; +} +.help-sec li { + margin: 6px 0; +} +kbd { + font: 12px var(--se-mono); + padding: 1px 5px; + border: 1px solid var(--se-border-strong); + border-bottom-width: 2px; + border-radius: 4px; + background: var(--se-panel-2); +} + +/* ---- the open page --------------------------------------------------- */ +.open-page { + max-width: 960px; + margin: 0 auto; + padding: 16px 0 8px; +} +.open-hero { + margin: 24px 0 32px; +} +.open-hero h1 { + margin: 0 0 8px; + font-size: 40px; + line-height: 48px; + font-weight: 600; +} +.open-hero .lead { + max-width: 62ch; + color: var(--se-text-2); + margin: 0; +} +.open-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 16px; +} +.open-card { + display: flex; + flex-direction: column; + gap: 8px; + padding: 24px; +} +.open-card h2, +.open-page > .examples h2 { + margin: 0; + font-size: 20px; + line-height: 28px; + font-weight: 600; +} +.open-card p { + margin: 0; +} +.dropzone { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + min-height: 150px; + padding: 24px; + text-align: center; + border: 1.5px dashed var(--se-border-strong); + border-radius: var(--se-radius); + background: var(--se-panel-2); + color: var(--se-text-2); + cursor: pointer; + transition: border-color var(--se-ease), background-color var(--se-ease); +} +.dropzone:hover, +.dropzone.over { + border-color: var(--se-accent); + background: var(--se-accent-bg); +} +.dropzone p { + margin: 0; +} +.dropzone strong { + color: var(--se-text); +} +.dropzone .drop-icon { + width: 28px; + height: 28px; + color: var(--se-accent); + margin-bottom: 4px; +} +.dropzone button.linkish { + font-size: inherit; +} +.url-form { + display: flex; + gap: 8px; + margin-top: 8px; +} +.url-form .input { + flex: 1; + min-width: 0; +} +.open-page > .examples { + margin-top: 48px; +} +.example-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 12px; + margin-top: 12px; +} +.example-card { + display: flex; + flex-direction: column; + border-radius: var(--se-radius-lg); + background: var(--se-panel); + box-shadow: var(--se-shadow); + transition: box-shadow var(--se-ease); +} +.example-card:hover { + box-shadow: var(--se-shadow-raised); +} +button.example { + display: flex; + flex: 1; + flex-direction: column; + align-items: flex-start; + gap: 4px; + padding: 16px 20px 8px; + border: 0; + border-radius: var(--se-radius-lg) var(--se-radius-lg) 0 0; + background: none; + color: var(--se-text); + font: inherit; + text-align: left; + cursor: pointer; +} +button.example strong { + font-size: 16px; + font-weight: 600; +} +button.example .muted { + font-size: 14px; + line-height: 22px; +} +.example-version { + margin-left: 8px; + font-family: var(--se-mono); + font-size: 12px; + font-weight: 400; + color: var(--se-text-2); +} +.example-source { + align-self: flex-start; + margin: 0 20px 14px; + font-size: 13px; +} + +/* ---- full screen diagrams --------------------------------------------- */ +.diagram:fullscreen { + height: 100vh; + border-radius: 0; + resize: none; +} +.diagram.fs { + position: fixed; + inset: 0; + z-index: 50; + height: auto; + border-radius: 0; + resize: none; +} + +/* ---- RDF of an entity ------------------------------------------------ */ +.xsd-src { + margin-top: 8px; +} +pre.xml { + margin: 0; + max-height: 480px; + overflow: auto; + padding: 16px 20px; + border-radius: var(--se-radius); + background: var(--se-panel-2); + border: 1px solid var(--se-border); + font: 12.5px/1.6 var(--se-mono); + white-space: pre; + tab-size: 4; +} +pre.xml .t-str { + color: var(--se-shape); + white-space: pre-wrap; +} +pre.xml .t-com { + color: var(--se-text-3); + font-style: italic; +} +pre.xml .t-kw { + color: var(--se-text-3); +} +pre.xml .t-iri { + color: var(--se-text); +} +pre.xml a.t-iri { + color: var(--se-accent); + text-decoration: none; +} +pre.xml a.t-iri:hover { + text-decoration: underline; +} +.diagram.small-diagram { + height: 440px; +} + +/* ---- schema-explorer additions --------------------------------------- */ +.badge.k-element, +.badge.k-attribute { + background: var(--se-prop-bg); + color: var(--se-prop); +} +.badge.k-group, +.badge.k-attributeGroup, +.badge.k-key, +.badge.k-keyref, +.badge.k-unique { + background: var(--se-shape-bg); + color: var(--se-shape); +} +.kind-dot.k-element { + background: transparent; + box-shadow: inset 0 0 0 2px var(--se-class); +} +.kind-dot.k-attribute { + border-radius: 2px; + background: var(--se-prop); +} +.kind-dot.k-key, +.kind-dot.k-keyref, +.kind-dot.k-unique { + border-radius: 2px; + background: var(--se-highlight); +} +.kind-dot.k-file { + border-radius: 2px; + width: 9px; + height: 11px; + background: transparent; + box-shadow: inset 0 0 0 1.5px var(--se-text-3); +} +/* virtualised lists in the sidebar */ +.ns-select { + margin: 8px 10px 0; + width: calc(100% - 20px); + padding: 4px 8px; + font-size: 13px; +} +.list-info { + padding: 6px 14px 2px; + font-size: 12px; +} +.vlist { + flex: 1; + min-height: 0; + overflow: auto; + position: relative; + padding: 0 4px; +} +.vinner { + position: relative; +} +.vrow { + position: absolute; + left: 4px; + right: 4px; + height: 30px; + display: flex; + align-items: center; + gap: 2px; + padding: 0 8px; + border-radius: var(--se-radius-sm); + color: var(--se-text); + text-decoration: none; + font-size: 14px; +} +.vrow:hover { + background: var(--se-panel-2); + text-decoration: none; +} +.vrow.selected { + background: var(--se-accent-bg); + color: var(--se-accent-strong); + font-weight: 500; +} +.vrow.active { + outline: 2px solid var(--se-focus); + outline-offset: -2px; +} +.vrow .lbl { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.vrow .ctx { + color: var(--se-text-3); + font-size: 11.5px; + max-width: 45%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +/* pages */ +.pre-line { + white-space: pre-line; +} +.clamp { + display: -webkit-box; + -webkit-line-clamp: 6; + -webkit-box-orient: vertical; + overflow: hidden; +} +.small { + font-size: 12.5px; +} +.hint { + font-size: 12px; + margin-top: 6px; +} +.chain { + line-height: 1.9; +} +.missing { + color: var(--se-danger); + text-decoration: underline dotted; +} +.builtin { + color: var(--se-text-2); +} +ul.plain { + margin: 0; + padding-left: 18px; +} +.ptable.compact td, +.ptable.compact th { + padding-top: 5px; + padding-bottom: 5px; +} +.ptable.cm .compositor { + font-family: var(--se-mono); + font-size: 12px; + color: var(--se-text-2); + font-style: italic; +} +.ptable.cm .compositor.c-choice { + color: var(--se-shape); +} +.ptable.cm tr.comp-row td { + background: color-mix(in srgb, var(--se-panel-2) 55%, transparent); +} +.ptable.cm tr.in-choice td.pname { + box-shadow: inset 3px 0 0 var(--se-highlight); +} +.ptable tbody.folded tr:not(.group-row) { + display: none; +} +.ptable .fold { + display: inline-block; + transition: transform var(--se-ease); +} +.ptable tbody.folded .fold { + transform: rotate(-90deg); +} +.ptable tr.prohibited td { + text-decoration: line-through; + opacity: 0.6; +} +.input.sel { + padding: 2px 6px; + font-size: 12px; + width: auto; + max-width: 220px; +} +.diags { + margin: 8px 0 0; + padding-left: 18px; + font-size: 13px; +} +.diags li.error .badge { + background: var(--se-danger-bg); + color: var(--se-danger); +} +.diags-box summary { + cursor: pointer; +} +.xsd-src { + margin-top: 22px; +} +.xsd-src > summary { + cursor: pointer; + font-weight: 600; + font-size: 16px; + padding: 6px 0; +} +.xsd-tools { + display: flex; + align-items: center; + justify-content: space-between; + margin: 4px 0 6px; +} +pre.xml .t-tag { + color: var(--se-accent-strong); +} +pre.xml .t-attr { + color: var(--se-text-2); +} +pre.xml .t-str { + color: var(--se-shape); + white-space: pre; +} +.diagram .status .legend span.restr::before, +.diagram .status .legend span.dep::before { + border-top-style: dashed; +} +.diagram .status .legend span.restr::before { + border-top-color: var(--se-text); +} +.diagram .status .legend span.dashed::before { + width: 14px; + height: 10px; + border: 1.5px dashed var(--se-class); + border-radius: 2px; +} +.diagram .status .legend span.stacked::before { + width: 14px; + height: 10px; + border: 1.5px solid var(--se-class); + border-radius: 2px; + box-shadow: 3px 3px 0 -1px var(--se-panel), 3px 3px 0 0 var(--se-class); +} +/* the content-model tree */ +.cm-tree .edges path { + fill: none; + stroke: var(--se-border-strong); + stroke-width: 1.2; +} +.cm-tree .tn { + cursor: pointer; +} +.cm-tree .tn rect.box { + fill: var(--se-panel); + stroke: var(--se-class); + stroke-width: 1.4; +} +.cm-tree .tn:hover rect.box { + stroke-width: 2.2; +} +.cm-tree .tn.optional rect.box, +.cm-tree .tn.optional rect.comp { + stroke-dasharray: 4 3; +} +.cm-tree .tn rect.stack { + fill: var(--se-panel); +} +.cm-tree .tn.group rect.box { + stroke: var(--se-shape); +} +.cm-tree .tn.any rect.box { + stroke: var(--se-border-strong); +} +.cm-tree .tn.type rect.box { + fill: var(--se-class-bg); +} +.cm-tree .tn rect.comp { + fill: var(--se-panel-2); + stroke: var(--se-border-strong); + stroke-width: 1.2; +} +.cm-tree .tn .glyph { + stroke: var(--se-text-2); + stroke-width: 1.4; + fill: none; +} +.cm-tree .tn .dot { + fill: var(--se-text); +} +.cm-tree .tn rect.attrs { + fill: var(--se-panel-2); + stroke: var(--se-border); +} +.cm-tree .tn text { + font-family: var(--se-font); + dominant-baseline: middle; + fill: var(--se-text); +} +.cm-tree .tn text.tname { + font-weight: 600; + font-size: 13px; +} +.cm-tree .tn text.ttype, +.cm-tree .tn text.attr-line { + font-family: var(--se-mono); + font-size: 11px; + fill: var(--se-text-2); +} +.cm-tree .tn text.attrs-head, +.cm-tree .tn text.note, +.cm-tree .tn text.occ, +.cm-tree .tn text.all { + font-size: 11px; + fill: var(--se-text-2); +} +.cm-tree .tn text.all { + text-anchor: middle; +} +.cm-tree .tn .expander circle { + fill: var(--se-panel); + stroke: var(--se-class); +} +.cm-tree .tn .expander path { + stroke: var(--se-class); + stroke-width: 1.5; +} +.cm-tree .tn .expander:hover circle { + fill: var(--se-accent-bg); +} + +/* ---- compare mode ---------------------------------------------------- */ +.dmark { + display: inline-block; + min-width: 1.2em; + font-family: var(--se-mono); + font-weight: 700; + text-align: center; +} +.dmark.d-added, +.d-added-text { + color: var(--se-add); +} +.dmark.d-removed, +.d-removed-text { + color: var(--se-del); +} +.dmark.d-changed, +.d-changed-text { + color: var(--se-chg); +} +a.d-removed-text, +h1.d-removed-text { + text-decoration: line-through; +} +.badge.d-badge.d-added { + background: var(--se-add-bg); + color: var(--se-add); +} +.badge.d-badge.d-removed { + background: var(--se-del-bg); + color: var(--se-del); +} +.badge.d-badge.d-changed { + background: var(--se-chg-bg); + color: var(--se-chg); +} +.diff-page .stat .n, +.diff-page .stat .l { + display: block; +} +.diff-page .page-head { + display: block; +} +.diff-page .badges { + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.diff-sum > div { + overflow-x: auto; + min-width: 0; +} +.stat-breaking .n { + color: var(--se-del); +} +.note-card { + margin: 12px 0; + padding: 12px 16px; + overflow-wrap: anywhere; +} +.diff-sum { + align-items: start; +} +.ptable.counts td.num, +.ptable.counts th.num { + text-align: right; + white-space: nowrap; +} +.by-file { + margin-top: 12px; + padding: 12px 16px; +} +.by-file summary { + cursor: pointer; + font-weight: 600; +} +.filters { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 10px; +} +.filters .input { + width: auto; + flex: 1 1 160px; + min-width: 0; +} +.diff-list, +.sbs > .side { + overflow-x: auto; +} +.ptable tr.d-row.d-added td { + background: var(--se-add-bg); +} +.ptable tr.d-row.d-removed td { + background: var(--se-del-bg); +} +.ptable tr.d-row.d-changed td { + background: var(--se-chg-bg); +} +.side-table tr.d-removed td.pre, +.diff-table tr.d-removed td a { + text-decoration: line-through; +} +.diff-table td, +.change-table td { + vertical-align: top; +} +.ptable td.mk { + width: 1.6em; + padding-right: 0; +} +.ptable td.pre { + white-space: pre; +} +.sbs { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + margin-bottom: 14px; +} +.side-head { + font-weight: 600; + padding: 4px 0 6px; + color: var(--se-text-2); +} +.sbs > .side.d-added .doc-text { + border-left: 3px solid var(--se-add); +} +.sbs > .side.d-removed .doc-text { + border-left: 3px solid var(--se-del); +} +.doc-text { + margin: 0; + padding: 8px 12px; + background: var(--se-panel); + border-radius: var(--se-radius-sm); + white-space: pre-wrap; +} +.doc-sub { + font-size: 14px; + margin: 12px 0 4px; +} +.diff-banner { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + padding: 8px 14px; + margin-bottom: 12px; + border-radius: var(--se-radius); + background: var(--se-panel); + border-left: 4px solid var(--se-border-strong); +} +.diff-banner.d-added { + border-left-color: var(--se-add); +} +.diff-banner.d-changed { + border-left-color: var(--se-chg); +} +.diff-banner .legend-diff { + margin-left: auto; +} +.compare-card { + margin-top: 16px; +} +.compare-zones { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 12px; +} +.dropzone.compare-zone { + min-height: 120px; + cursor: default; +} +.dropzone.compare-zone.set { + border-style: solid; + border-color: var(--se-add); +} +.compare-zone .input { + width: 100%; +} +.uml .node.d-added rect.box { + stroke: var(--se-add); + stroke-width: 2.5; +} +.uml .node.d-changed rect.box { + stroke: var(--se-chg); + stroke-width: 2.5; +} +.uml text.attr.d-added { + fill: var(--se-add); +} +.uml text.attr.d-changed { + fill: var(--se-chg); +} +.uml text.attr.d-removed { + fill: var(--se-del); + text-decoration: line-through; +} +.uml .edge.d-added path { + stroke: var(--se-add); + stroke-width: 2; +} +.uml .edge.d-changed path { + stroke: var(--se-chg); + stroke-width: 2; +} +.uml .edge.d-added text { + fill: var(--se-add); +} +.uml .edge.d-changed text { + fill: var(--se-chg); +} +.cm-tree .tn.d-added rect.box { + stroke: var(--se-add); + stroke-width: 2.2; + fill: var(--se-add-bg); +} +.cm-tree .tn.d-changed rect.box { + stroke: var(--se-chg); + stroke-width: 2.2; + fill: var(--se-chg-bg); +} +.cm-tree .tn.d-removed rect.box { + stroke: var(--se-del); + stroke-dasharray: 4 3; + fill: var(--se-del-bg); +} +.cm-tree .tn.d-removed text.tname { + fill: var(--se-del); + text-decoration: line-through; +} +.cm-tree .tn text.attr-line.d-added { + fill: var(--se-add); +} +.cm-tree .tn text.attr-line.d-changed { + fill: var(--se-chg); +} +.cm-tree .tn text.attr-line.d-removed { + fill: var(--se-del); + text-decoration: line-through; +} +@media (max-width: 860px) { + .sbs { + grid-template-columns: 1fr; + } + .diff-banner .legend-diff { + margin-left: 0; + } +} diff --git a/web/src/tree.ts b/web/src/tree.ts new file mode 100644 index 0000000..10731b8 --- /dev/null +++ b/web/src/tree.ts @@ -0,0 +1,348 @@ +// The content-model tree of a type or element, drawn left to right in the +// style of XML editors: element boxes (dashed when optional, stacked when +// repeated), sequence / choice / all compositors, group references and +// attribute lists. Nodes expand on demand, so deep and recursive models stay cheap. +import { Diagram, MONO, SANS, measure, type DiagramCallbacks, type NodeAction, type View } from "./diagram"; +import { s } from "./dom"; +import { MARKER, type Compare, type Mark } from "./diff"; +import { isId, occursText, type Id, type Model, type Particle, type Ref } from "./model"; + +/** Compare mode: the diff whose changes the tree marks. */ +let diffHook: Compare | null = null; +export function setTreeDiff(c: Compare | null): void { + diffHook = c; +} + +const markLine = (d: Mark | undefined, l: string) => (d ? `${MARKER[d]} ${l}` : l); + +/** The child elements removed from what `owner` declares, as nodes to show after its content. */ +function removedNodes(owner: Id | undefined, key: string): TNode[] { + if (!diffHook || owner === undefined) return []; + return diffHook.childMarks(owner).removed.map((r, i) => { + const [a, b] = (r.mult ?? "1..1").split(".."); + return { key: `${key}.x${i}`, kind: "element" as const, label: r.name, min: Number(a) || 0, max: b === "*" ? null : Number(b) || 1, diff: "removed" as const, expandable: false, children: () => [] }; + }); +} + +function withRemoved(n: TNode, owner: Id | undefined): TNode { + const extra = removedNodes(owner, n.key); + if (!extra.length) return n; + const kids = n.children; + return { ...n, expandable: true, children: () => [...kids(), ...extra] }; +} + +export interface TNode { + key: string; + kind: "element" | "type" | "sequence" | "choice" | "all" | "any" | "group" | "attrs"; + label: string; + /** Element declaration, group or type behind the node. */ + id?: Id; + typeName?: string; + typeId?: Id; + min: number; + max: number | null; + /** Caption above the node (e.g. "from Base"). */ + note?: string; + lines?: string[]; + expandable: boolean; + children: () => TNode[]; + /** Compare mode: how it differs from the earlier version. */ + diff?: Mark; +} + +const NAME_FONT = `600 13px ${SANS}`; +const TYPE_FONT = `11px ${MONO}`; +const SMALL_FONT = `11px ${SANS}`; +const GAP_X = 34; +const GAP_Y = 14; +const COMP_W = 40; +const COMP_H = 24; + +function occ(p: { min?: number; max?: number | null }): { min: number; max: number | null } { + return { min: p.min ?? 1, max: p.max === undefined ? 1 : p.max }; +} + +/** Children of something with a complex type: its attributes, then its content. */ +function typeChildren(m: Model, typeId: Ref | undefined, key: string): TNode[] { + if (!isId(typeId) || m.comp(typeId).kind !== "complexType") return []; + const out: TNode[] = []; + const attrs = m.attributes(typeId).filter((a) => a.use !== "prohibited"); + if (attrs.length) { + const lines = attrs.map((a) => { + const name = isId(a.attr) ? m.comp(a.attr).name ?? "" : m.refName(a.attr); + const t = m.typeName(m.attrType(a.attr)); + return markLine(diffHook?.childMarks(a.from).attrs.get(name), `@${name}${t ? " : " + t : ""}${a.use === "required" ? "" : " ?"}`); + }); + if (diffHook) for (const r of diffHook.childMarks(typeId).removedAttrs) lines.push(markLine("removed", `@${r.name}`)); + out.push({ key: `${key}.@`, kind: "attrs", label: `attributes (${attrs.length})`, lines, min: 1, max: 1, expandable: false, children: () => [] }); + } + const ct = m.comp(typeId).complexType!; + const parts = (ct.effective?.content ?? (ct.content ? [typeId] : [])).filter((p) => m.comp(p).complexType?.content); + if (parts.length === 1) { + out.push(withRemoved(particleNode(m, m.comp(parts[0]).complexType!.content!, `${key}.0`, parts[0] !== typeId ? `from ${m.label(parts[0])}` : undefined, parts[0]), parts[0])); + } else if (parts.length > 1) { + // Extension: the base content, then what each derived type adds, in a sequence. + out.push({ + key: `${key}.0`, + kind: "sequence", + label: "sequence", + min: 1, + max: 1, + expandable: true, + children: () => parts.map((p, i) => withRemoved(particleNode(m, m.comp(p).complexType!.content!, `${key}.0.${i}`, `from ${m.label(p)}`, p), p)), + }); + } + return out; +} + +export function particleNode(m: Model, p: Particle, key: string, note?: string, owner?: Id): TNode { + const o = occ(p); + switch (p.t) { + case "element": { + const id = isId(p.el) ? p.el : undefined; + const t = id !== undefined ? m.elementType(id) : undefined; + const complex = m.isComplex(t) && (typeChildren(m, t, key).length > 0); + const label = id !== undefined ? m.comp(id).name ?? "" : m.refName(p.el); + const diff = diffHook && owner !== undefined ? diffHook.childMarks(owner).children.get(label) : undefined; + return { key, kind: "element", label, diff, id, typeName: t !== undefined ? m.typeName(t) : undefined, typeId: isId(t) ? t : undefined, ...o, note, expandable: complex, children: () => typeChildren(m, t, key) }; + } + case "group": { + const gid = isId(p.group) ? p.group : undefined; + const gp = gid !== undefined ? m.comp(gid).group : undefined; + return { key, kind: "group", label: m.refName(p.group), id: gid, ...o, note, expandable: !!gp, children: () => (gp ? [withRemoved(particleNode(m, gp, `${key}.0`, undefined, gid), gid)] : []) }; + } + case "any": + return { key, kind: "any", label: "any", typeName: p.namespace ?? "##any", ...o, note, expandable: false, children: () => [] }; + default: + return { key, kind: p.t, label: p.t, ...o, note, expandable: p.items.length > 0, children: () => p.items.map((it, i) => particleNode(m, it, `${key}.${i}`, undefined, owner)) }; + } +} + +/** The root of the tree for a page: an element, a complex type or a group. */ +export function rootNode(m: Model, id: Id): TNode { + const c = m.comp(id); + if (c.kind === "element") { + const t = m.elementType(id); + return { key: "r", kind: "element", label: c.name ?? "", diff: diffHook?.mark(id), id, typeName: t !== undefined ? m.typeName(t) : undefined, typeId: isId(t) ? t : undefined, min: 1, max: 1, expandable: true, children: () => typeChildren(m, t, "r") }; + } + if (c.kind === "group" && c.group) { + return { key: "r", kind: "group", label: m.qname(id), diff: diffHook?.mark(id), id, min: 1, max: 1, expandable: true, children: () => [withRemoved(particleNode(m, c.group!, "r.0", undefined, id), id)] }; + } + return { key: "r", kind: "type", label: m.label(id), diff: diffHook?.mark(id), id, typeId: id, min: 1, max: 1, expandable: true, children: () => typeChildren(m, id, "r") }; +} + +interface Placed { + n: TNode; + x: number; + y: number; + w: number; + h: number; + /** Width reserved before the children (the box or its caption). */ + span: number; + kids: Placed[]; + open: boolean; +} + +function nodeSize(n: TNode): { w: number; h: number } { + if (n.kind === "sequence" || n.kind === "choice" || n.kind === "all") return { w: COMP_W, h: COMP_H }; + if (n.kind === "attrs") { + const lines = n.lines ?? []; + const w = Math.max(measure(n.label, SMALL_FONT), ...lines.slice(0, 14).map((l) => measure(l, TYPE_FONT))) + 20; + return { w: Math.min(360, Math.ceil(w)), h: 22 + Math.min(lines.length, 14) * 15 + (lines.length > 14 ? 15 : 0) + 6 }; + } + const name = shownName(n); + const w = Math.max(measure(name, NAME_FONT), n.typeName ? measure(n.typeName, TYPE_FONT) : 0) + 24 + (n.expandable ? 10 : 0); + return { w: Math.min(340, Math.max(70, Math.ceil(w))), h: n.typeName ? 40 : 28 }; +} + +function shownName(n: TNode): string { + return markLine(n.diff, n.kind === "group" ? `group ${n.label}` : n.label); +} + +/** Compositors open with their parent; elements and groups when asked. */ +function isOpen(n: TNode, open: Set): boolean { + if (!n.expandable) return false; + if (n.kind === "sequence" || n.kind === "choice" || n.kind === "all" || n.kind === "type") return !open.has("-" + n.key); + return open.has(n.key) || n.key === "r"; +} + +function place(n: TNode, x: number, y: number, open: Set, budget: { n: number }): Placed { + const { w, h } = nodeSize(n); + const extra = n.note ? 14 : 0; + const o = isOpen(n, open) && budget.n > 0; + const span = Math.max(w, n.note ? Math.ceil(measure(n.note, SMALL_FONT)) + 6 : 0); + const p: Placed = { n, x, y: y + extra, w, h, span, kids: [], open: o }; + budget.n--; + if (!o) { + (p as Placed & { total: number }).total = h + extra + (n.min !== 1 || n.max !== 1 ? 12 : 0); + return p; + } + let cy = y; + for (const c of n.children()) { + const k = place(c, x + span + GAP_X, cy, open, budget); + p.kids.push(k); + cy += (k as Placed & { total: number }).total + GAP_Y; + } + const kidsH = Math.max(0, cy - y - GAP_Y); + const own = h + extra + (n.min !== 1 || n.max !== 1 ? 12 : 0); + // Centre the node on its children. + if (kidsH > own) p.y = y + extra + (kidsH - own) / 2; + (p as Placed & { total: number }).total = Math.max(own, kidsH); + return p; +} + +export interface TreeCallbacks extends DiagramCallbacks { + onToggle: (n: TNode, open: boolean) => void; + actions: (n: TNode) => NodeAction[]; +} + +/** The tree on the diagram canvas (pan, zoom, export come from Diagram). */ +export class TreeView extends Diagram { + private tcb: TreeCallbacks; + + constructor(host: HTMLElement, cb: TreeCallbacks) { + super(host, cb); + this.tcb = cb; + this.svg.classList.add("cm-tree"); + } + + show(root: TNode, open: Set, opts: { keepView?: boolean; view?: View } = {}): number { + let count = 0; + this.drawRaw((vp) => { + const budget = { n: 1500 }; + const laid = place(root, 10, 10, open, budget); + const edges = s("g", { class: "edges" }); + const nodes = s("g", { class: "nodes" }); + vp.append(edges, nodes); + let maxX = 0; + let maxY = 0; + const draw = (p: Placed) => { + count++; + maxX = Math.max(maxX, p.x + p.w + 20); + maxY = Math.max(maxY, p.y + p.h + 16); + nodes.appendChild(this.drawTNode(p)); + if (!p.kids.length) return; + const sx = p.x + p.w + (p.n.expandable ? 8 : 0); + const sy = p.y + p.h / 2; + const bx = p.x + p.span + GAP_X / 2; + const ys = p.kids.map((k) => k.y + k.h / 2); + edges.appendChild(s("path", { d: `M${sx} ${sy}H${bx}M${bx} ${Math.min(...ys, sy)}V${Math.max(...ys, sy)}` })); + for (const k of p.kids) { + edges.appendChild(s("path", { d: `M${bx} ${k.y + k.h / 2}H${k.x}` })); + draw(k); + } + }; + draw(laid); + return { w: maxX, h: maxY }; + }, opts); + this.tcb.onStatus?.(`${count} nodes · click a box for options, + to expand`); + return count; + } + + private drawTNode(p: Placed): SVGGElement { + const n = p.n; + const optional = n.min === 0; + const many = n.max === null || n.max > 1; + const g = s("g", { class: `tn ${n.kind}${optional ? " optional" : ""}${n.diff ? ` d-${n.diff}` : ""}`, transform: `translate(${p.x} ${p.y})` }); + if (n.note) g.appendChild(s("text", { class: "note", x: 0, y: -7 }, n.note)); + if (n.kind === "sequence" || n.kind === "choice" || n.kind === "all") { + g.appendChild(s("rect", { class: "comp", width: p.w, height: p.h, rx: 11 })); + const cy = p.h / 2; + if (n.kind === "sequence") { + g.appendChild(s("path", { class: "glyph", d: `M8 ${cy}H${p.w - 8}` })); + for (const x of [13, 20, 27]) g.appendChild(s("circle", { class: "dot", cx: x, cy, r: 2.4 })); + } else if (n.kind === "choice") { + g.appendChild(s("path", { class: "glyph", d: `M7 ${cy}H14L24 ${cy - 6}M24 ${cy - 6}H33M24 ${cy}H33M24 ${cy + 6}H33` })); + for (const y of [cy - 6, cy, cy + 6]) g.appendChild(s("circle", { class: "dot", cx: 28, cy: y, r: 1.8 })); + } else { + g.appendChild(s("text", { class: "all", x: p.w / 2, y: cy }, "all")); + } + g.appendChild(s("title", null, n.kind)); + } else if (n.kind === "attrs") { + g.appendChild(s("rect", { class: "attrs", width: p.w, height: p.h, rx: 6 })); + g.appendChild(s("text", { class: "attrs-head", x: 10, y: 12 }, n.label)); + const lines = n.lines ?? []; + const lineCls = (l: string) => (l.startsWith("+ ") ? " d-added" : l.startsWith("− ") ? " d-removed" : l.startsWith("~ ") ? " d-changed" : ""); + lines.slice(0, 14).forEach((l, i) => g.appendChild(s("text", { class: `attr-line${lineCls(l)}`, x: 10, y: 30 + i * 15 }, l))); + if (lines.length > 14) g.appendChild(s("text", { class: "attr-line more", x: 10, y: 30 + 14 * 15 }, `+${lines.length - 14} more`)); + } else { + if (many) g.appendChild(s("rect", { class: "box stack", x: 4, y: 4, width: p.w, height: p.h, rx: 4 })); + g.appendChild(s("rect", { class: "box", width: p.w, height: p.h, rx: n.kind === "group" ? 10 : 4 })); + const nameY = n.typeName ? 14 : p.h / 2; + g.appendChild(s("text", { class: "tname", x: 10, y: nameY }, shownName(n))); + if (n.typeName) g.appendChild(s("text", { class: "ttype", x: 10, y: 30 }, n.typeName)); + } + if (n.min !== 1 || n.max !== 1) g.appendChild(s("text", { class: "occ", x: p.w, y: p.h + 10, "text-anchor": "end" }, occursText(n.min, n.max))); + if (n.expandable && n.kind !== "sequence" && n.kind !== "choice" && n.kind !== "all") { + const ex = s("g", { class: "expander", transform: `translate(${p.w} ${p.h / 2})` }); + ex.appendChild(s("circle", { r: 7 })); + ex.appendChild(s("path", { d: p.open ? "M-3.5 0H3.5" : "M-3.5 0H3.5M0 -3.5V3.5" })); + ex.appendChild(s("title", null, p.open ? "Collapse" : "Expand")); + ex.addEventListener("click", (ev) => { + ev.stopPropagation(); + this.tcb.onToggle(n, !p.open); + }); + g.appendChild(ex); + } else if (n.expandable) { + g.addEventListener("dblclick", (ev) => { + ev.stopPropagation(); + this.tcb.onToggle(n, !p.open); + }); + } + g.addEventListener("click", (ev) => { + ev.stopPropagation(); + const actions = this.tcb.actions(n); + if (actions.length) this.showMenu(n.kind === "group" ? `group ${n.label}` : n.label, actions, ev.clientX, ev.clientY); + }); + if (n.kind === "element" && n.expandable) { + g.addEventListener("dblclick", (ev) => { + ev.stopPropagation(); + this.tcb.onToggle(n, !p.open); + }); + } + return g; + } + + /** Trees are read at a readable size from the top left, not shrunk to fit. */ + fit(): void { + const box = this.svg.getBoundingClientRect(); + if (!box.width || !this.content.w) return; + const top = 52; + const s = Math.min((box.width - 24) / this.content.w, (box.height - top - 30) / this.content.h); + this.scale = Math.min(1.1, Math.max(0.75, s)); + this.tx = Math.max(12, (box.width - this.content.w * this.scale) / 2); + this.ty = top + Math.max(0, (box.height - top - 30 - this.content.h * this.scale) / 2); + this.applyTransform(false); + this.cb.onViewChange?.(null); + } + + protected extraCss(): string { + const v = (n: string) => this.cssVar(n); + return ` +.edges path{fill:none;stroke:${v("--se-border-strong")};stroke-width:1.2} +.tn rect.box{fill:${v("--se-panel")};stroke:${v("--se-class")};stroke-width:1.4} +.tnode.optional rect.box,.tnode.optional rect.comp{stroke-dasharray:4 3} +.tn rect.stack{fill:${v("--se-panel")}} +.tnode.group rect.box{stroke:${v("--se-shape")}} +.tnode.any rect.box{stroke:${v("--se-border-strong")}} +.tn rect.comp{fill:${v("--se-panel-2")};stroke:${v("--se-border-strong")};stroke-width:1.2} +.tn .glyph{stroke:${v("--se-text-2")};stroke-width:1.4;fill:none} +.tn .dot{fill:${v("--se-text")}} +.tn rect.attrs{fill:${v("--se-panel-2")};stroke:${v("--se-border")}} +.tn text{font-family:Barlow,system-ui,sans-serif;dominant-baseline:middle;fill:${v("--se-text")}} +.tn text.tname{font-weight:600;font-size:13px} +.tn text.ttype,.tn text.attr-line{font-family:"JetBrains Mono",ui-monospace,monospace;font-size:11px;fill:${v("--se-text-2")}} +.tn text.attrs-head,.tn text.note,.tn text.occ,.tn text.all{font-size:11px;fill:${v("--se-text-2")}} +.tn text.all{text-anchor:middle} +.tn .expander circle{fill:${v("--se-panel")};stroke:${v("--se-class")}} +.tn .expander path{stroke:${v("--se-class")};stroke-width:1.5} +.tn.d-added rect.box{stroke:${v("--se-add")};stroke-width:2.2;fill:${v("--se-add-bg")}} +.tn.d-changed rect.box{stroke:${v("--se-chg")};stroke-width:2.2;fill:${v("--se-chg-bg")}} +.tn.d-removed rect.box{stroke:${v("--se-del")};stroke-dasharray:4 3;fill:${v("--se-del-bg")}} +.tn.d-removed text.tname{text-decoration:line-through;fill:${v("--se-del")}} +.tn text.attr-line.d-added{fill:${v("--se-add")}} +.tn text.attr-line.d-changed{fill:${v("--se-chg")}} +.tn text.attr-line.d-removed{fill:${v("--se-del")};text-decoration:line-through} +`; + } +} diff --git a/web/src/views/common.ts b/web/src/views/common.ts new file mode 100644 index 0000000..e3aedb6 --- /dev/null +++ b/web/src/views/common.ts @@ -0,0 +1,209 @@ +// Shared building blocks of the detail pages. +import type { AppCtx } from "../ctx"; +import { button, copyText, h, type Child } from "../dom"; +import { KIND_LABEL, docsText, isId, type Doc, type Id, type Ref } from "../model"; +import { highlight, snippet } from "../source"; + +export function kindBadge(ctx: AppCtx, id: Id): HTMLElement { + const c = ctx.m.comp(id); + return h("span", { class: `badge k-${c.kind}` }, `${c.parent !== undefined && c.kind !== "complexType" && c.kind !== "simpleType" ? "Local " + KIND_LABEL[c.kind].toLowerCase() : c.parent !== undefined ? "Anonymous " + KIND_LABEL[c.kind].toLowerCase() : KIND_LABEL[c.kind]}`); +} + +function tip(ctx: AppCtx, id: Id): string { + const d = ctx.m.docText(id); + return `${ctx.m.qname(id) || ctx.m.label(id)} (${KIND_LABEL[ctx.m.comp(id).kind].toLowerCase()})${d ? "\n" + (d.length > 300 ? d.slice(0, 300) + "…" : d) : ""}`; +} + +/** A clickable chip for a component. */ +export function chip(ctx: AppCtx, id: Id, opts: { text?: string; sub?: string } = {}): HTMLElement { + const c = ctx.m.comp(id); + return h( + "a", + { class: `chip${c.builtin ? " external" : ""}`, href: ctx.href(id), title: tip(ctx, id), onClick: nav(ctx, id) }, + h("span", { class: `kind-dot k-${c.kind}` }), + h("span", { class: "lbl" }, opts.text ?? ctx.m.label(id)), + opts.sub ? h("span", { class: "faint" }, opts.sub) : null + ); +} + +/** A plain text link for a component, for use inside sentences and tables. */ +export function link(ctx: AppCtx, id: Id, text?: string): HTMLElement { + return h("a", { href: ctx.href(id), title: tip(ctx, id), onClick: nav(ctx, id) }, text ?? ctx.m.label(id)); +} + +/** A link to a referenced component, or its name when it was not found. */ +export function refLink(ctx: AppCtx, r: Ref | undefined, short = false): Child { + if (r === undefined) return h("span", { class: "faint" }, "—"); + if (isId(r)) { + const c = ctx.m.comp(r); + if (c.builtin) return h("span", { class: "mono builtin", title: "built-in XML Schema type" }, ctx.m.qname(r)); + return h("a", { class: "mono", href: ctx.href(r), title: tip(ctx, r), onClick: nav(ctx, r) }, short || !c.name ? ctx.m.label(r) : ctx.m.qname(r)); + } + return h("span", { class: "mono missing", title: `not found: ${r}` }, ctx.m.refName(r)); +} + +export function nav(ctx: AppCtx, id: Id): EventListener { + return ((ev: MouseEvent) => { + if (ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.button === 1) return; + ev.preventDefault(); + ctx.go(id); + }) as EventListener; +} + +export function chips(ctx: AppCtx, ids: Id[], opts: { max?: number } = {}): HTMLElement { + const max = opts.max ?? 40; + const wrap = h("div", { class: "chips" }); + const render = (limit: number) => { + wrap.replaceChildren(); + for (const id of ids.slice(0, limit)) wrap.appendChild(chip(ctx, id)); + if (ids.length > limit) wrap.appendChild(h("button", { class: "chip more", type: "button", onClick: () => render(ids.length) }, `+${ids.length - limit} more`)); + }; + render(max); + return wrap; +} + +export function section(title: string, opts: { count?: number; tools?: Child[]; id?: string } = {}): HTMLElement { + return h("div", { class: "section-title", id: opts.id }, h("span", null, title), opts.count !== undefined ? h("span", { class: "count" }, opts.count.toLocaleString()) : null, opts.tools?.length ? h("div", { class: "tools" }, ...opts.tools) : null); +} + +/** Name, badges, namespace and source location of a component. */ +export function pageHead(ctx: AppCtx, id: Id, badges: Child[] = []): HTMLElement { + const m = ctx.m; + const c = m.comp(id); + const crumbs: Child[] = []; + if (c.parent !== undefined) { + const chain: Id[] = []; + let cur: Id | undefined = c.parent; + while (cur !== undefined && chain.length < 12) { + chain.unshift(cur); + cur = m.comp(cur).parent; + } + crumbs.push(h("div", { class: "crumbs" }, chain.map((p, i) => [i ? h("span", { class: "sep" }, "›") : null, link(ctx, p, m.label(p))]), h("span", { class: "sep" }, "›"), h("span", null, m.label(id)))); + } + const ns = c.ns !== undefined ? m.b.namespaces[c.ns] : undefined; + const file = m.fileName(id); + const qn = m.qname(id); + return h( + "div", + null, + ...crumbs, + h("div", { class: "page-head" }, h("h1", { class: c.complexType?.abstract || c.element?.abstract ? "abstract" : "" }, m.label(id)), kindBadge(ctx, id), ...badges), + h( + "div", + { class: "iri-line" }, + qn ? h("span", { class: "mono", title: ns ? `{${ns.uri}}${c.name}` : "no namespace" }, qn) : null, + qn ? button("", () => copyText(qn).then((ok) => ctx.toast(ok ? "Name copied" : "Could not copy")), { icon: "copy", cls: "ghost icon-only", title: "Copy the qualified name" }) : null, + ns ? h("span", { class: "faint mono small" }, ns.uri) : h("span", { class: "faint small" }, c.builtin ? "built in" : "no namespace"), + file !== undefined ? h("a", { class: "badge src", href: ctx.hrefRoute({ view: "file", id: c.file }), onClick: (ev: Event) => { + ev.preventDefault(); + ctx.goRoute({ view: "file", id: c.file }); + }, title: "The file it is declared in" }, `${file}${c.line ? ":" + c.line : ""}`) : null + ), + docBlock(c.docs) + ); +} + +/** Documentation, with "read more" for long texts. */ +export function docBlock(docs: Doc[] | undefined, cls = "lead"): HTMLElement | null { + const text = docsText(docs); + if (!text) return null; + const p = h("p", { class: `${cls} pre-line${text.length > 600 ? " clamp" : ""}` }, text); + if (text.length <= 600) return p; + const more = h("button", { class: "linkish small", type: "button", onClick: () => { + p.classList.toggle("clamp"); + more.textContent = p.classList.contains("clamp") ? "Read more" : "Show less"; + } }, "Read more"); + return h("div", null, p, more); +} + +/** A short one-line documentation for table cells. */ +export function shortDoc(docs: Doc[] | undefined, max = 220): HTMLElement | null { + const t = docsText(docs); + if (!t) return null; + return h("div", { class: "desc", title: t.length > max ? t : undefined }, t.length > max ? t.slice(0, max) + "…" : t); +} + +export function emptyNote(text: string): HTMLElement { + return h("p", { class: "muted" }, text); +} + +/** "Where used": the global components that refer to this one, with how. */ +export function whereUsed(ctx: AppCtx, id: Id): HTMLElement | null { + const m = ctx.m; + const uses = m.usedBy(id); + if (!uses.length) return null; + const HOW: Record = { type: "as type", base: "as base", item: "as list item", member: "in union", element: "by reference", attribute: "by reference", group: "by reference", attributeGroup: "by reference", substitution: "substitutes", refer: "referred by" }; + const byOwner = new Map(); + for (const u of uses) { + const l = byOwner.get(u.owner) ?? []; + l.push(u); + byOwner.set(u.owner, l); + } + const owners = [...byOwner.keys()].sort((a, b) => m.label(a).localeCompare(m.label(b))); + const table = h("table", { class: "ptable compact" }, h("thead", null, h("tr", null, h("th", null, "In"), h("th", null, "Where"), h("th", null, "How")))); + const tbody = h("tbody"); + const render = (limit: number) => { + tbody.replaceChildren(); + for (const o of owners.slice(0, limit)) { + for (const u of byOwner.get(o)!) { + const where = u.by === o ? h("span", { class: "faint" }, "itself") : localPath(ctx, u.by, o); + tbody.appendChild(h("tr", null, h("td", null, chip(ctx, o)), h("td", null, where), h("td", { class: "faint" }, HOW[u.how] ?? u.how))); + } + } + if (owners.length > limit) tbody.appendChild(h("tr", null, h("td", { colspan: 3 }, h("button", { class: "linkish", type: "button", onClick: () => render(owners.length) }, `Show all ${owners.length}`)))); + }; + render(60); + table.appendChild(tbody); + return h("div", null, section("Where used", { count: owners.length }), table); +} + +/** The path of a local declaration inside its global component: a / b / c. */ +export function localPath(ctx: AppCtx, id: Id, owner: Id): HTMLElement { + const parts: Id[] = []; + let cur: Id | undefined = id; + while (cur !== undefined && cur !== owner && parts.length < 10) { + const c = ctx.m.comp(cur); + if (c.name) parts.unshift(cur); + cur = c.parent; + } + return h("span", { class: "mono small" }, parts.map((p, i) => [i ? " / " : "", link(ctx, p, (ctx.m.comp(p).kind === "attribute" ? "@" : "") + (ctx.m.comp(p).name ?? ""))])); +} + +/** The component's XSD, cut from its file, loaded when opened. */ +export function sourceSection(ctx: AppCtx, id: Id): HTMLElement | null { + const c = ctx.m.comp(id); + if (c.file === undefined || !c.line) return null; + const file = c.file; + const body = h("div", { class: "xsd-body" }, h("p", { class: "faint small" }, "Loading…")); + const det = h("details", { class: "xsd-src", open: ctx.route.params.src === "1" || undefined }, h("summary", null, h("span", null, "XSD source"), h("span", { class: "faint small" }, ` ${ctx.m.b.files[file].name}:${c.line}`)), body); + let loaded = false; + const load = async () => { + if (loaded) return; + loaded = true; + const text = await ctx.sourceText(file); + if (text === null) { + body.replaceChildren(h("p", { class: "muted small" }, "The schema file is not available here: the bundle was loaded without its .xsd files.")); + return; + } + const sn = snippet(text, c.line!); + if (!sn) { + body.replaceChildren(h("p", { class: "muted small" }, "Could not find the declaration in the file.")); + return; + } + const copy = button("Copy", () => copyText(sn.text).then((ok) => ctx.toast(ok ? "XSD copied" : "Could not copy")), { icon: "copy", cls: "small" }); + body.replaceChildren(h("div", { class: "xsd-tools" }, h("span", { class: "faint small" }, `lines ${sn.startLine}–${sn.startLine + sn.text.split("\n").length - 1}`), copy), highlight(ctx.m, file, sn.text, (target, text) => link(ctx, target, text))); + }; + det.addEventListener("toggle", () => { + ctx.setParams({ src: det.open ? "1" : undefined }); + if (det.open) void load(); + }); + if (det.open) void load(); + return det; +} + +/** Facets as a definition list. */ +export function facetList(facets: { kind: string; value: string; fixed?: boolean }[]): HTMLElement { + const dl = h("dl", { class: "kv" }); + for (const f of facets) dl.append(h("dt", null, f.kind), h("dd", null, h("code", null, f.value), f.fixed ? h("span", { class: "faint" }, " (fixed)") : null)); + return dl; +} diff --git a/web/src/views/compare.ts b/web/src/views/compare.ts new file mode 100644 index 0000000..3c7a64b --- /dev/null +++ b/web/src/views/compare.ts @@ -0,0 +1,359 @@ +// Compare mode: the overview of what changed between two versions of a +// schema set, and a page per changed component with both versions side by side. +import type { AppCtx } from "../ctx"; +import { button, downloadBlob, h, plural, type Child } from "../dom"; +import { MARKER, STATUS_LABEL, changeLabel, changeMark, type Change, type Compare, type ComponentDiff, type Counts, type Impact, type Mark, type Status } from "../diff"; +import { diffReport } from "../loader"; +import { KIND_LABEL, KIND_SLUG, docsText, isId, occursText, type Id, type Kind, type Model } from "../model"; +import { section } from "./common"; + +const KINDS: Kind[] = ["complexType", "simpleType", "element", "attribute", "group", "attributeGroup"]; + +/** A +/−/~ marker; colour is a second cue, never the only one. */ +export function mark(m: Mark, title?: string): HTMLElement { + return h("span", { class: `dmark d-${m}`, title: title ?? STATUS_LABEL[m], "aria-label": STATUS_LABEL[m] }, MARKER[m]); +} + +function impactBadge(i: Impact): HTMLElement | null { + if (i === "breaking") return h("span", { class: "badge danger" }, "breaking"); + if (i === "info") return h("span", { class: "badge" }, "docs only"); + return null; +} + +const qnameOf = (c: ComponentDiff) => c.new?.qname ?? c.old?.qname ?? c.name; + +export function diffHref(ctx: AppCtx, c: ComponentDiff): string { + return ctx.hrefRoute({ view: "diffc", query: `${KIND_SLUG[c.kind]}/${qnameOf(c)}` }); +} + +function diffLink(ctx: AppCtx, c: ComponentDiff, text?: string): HTMLElement { + return h("a", { class: text ? "" : `mono d-${c.status}-text`, href: diffHref(ctx, c), onClick: (ev: Event) => { + const me = ev as MouseEvent; + if (me.metaKey || me.ctrlKey || me.shiftKey) return; + ev.preventDefault(); + ctx.goRoute({ view: "diffc", query: `${KIND_SLUG[c.kind]}/${qnameOf(c)}` }); + } }, text ?? qnameOf(c)); +} + +function countsTable(rows: [string, Counts][]): HTMLElement { + const n = (v: number, m?: Mark) => h("td", { class: "num" }, v ? [m ? mark(m) : null, " ", v.toLocaleString()] : h("span", { class: "faint" }, "0")); + return h( + "table", + { class: "ptable counts" }, + h("thead", null, h("tr", null, h("th", null, ""), h("th", { class: "num" }, "Added"), h("th", { class: "num" }, "Removed"), h("th", { class: "num" }, "Changed"), h("th", { class: "num" }, "Breaking"))), + h("tbody", null, rows.map(([label, c]) => h("tr", null, h("td", { class: "mono" }, label), n(c.added, "added"), n(c.removed, "removed"), n(c.changed, "changed"), h("td", { class: "num" }, c.breaking ? h("strong", { class: "d-removed-text" }, c.breaking.toLocaleString()) : h("span", { class: "faint" }, "0"))))) + ); +} + +/** The namespace a component counts under: a removed one's old namespace maps to the new one. */ +function nsOf(cmp: Compare, c: ComponentDiff): string { + const u = c.namespace ?? ""; + return c.status === "removed" ? cmp.d.namespaceMap?.[u] ?? u : u; +} + +function changeSummary(c: ComponentDiff): Child { + if (c.status === "added") return h("span", { class: "faint" }, "new"); + if (c.status === "removed") return c.possiblyRenamed ? h("span", null, "possibly renamed to ", h("code", null, c.possiblyRenamed)) : h("span", { class: "faint" }, "removed"); + const ch = c.changes ?? []; + const kinds = new Map(); + for (const x of ch) kinds.set(x.kind, (kinds.get(x.kind) ?? 0) + 1); + const parts = [...kinds].slice(0, 4).map(([k, n]) => `${changeLabel(k)}${n > 1 ? ` ×${n}` : ""}`); + return h("span", { class: "muted small" }, parts.join(" · ") + (kinds.size > 4 ? " …" : "")); +} + +async function download(cmp: Compare, format: "md" | "html" | "json"): Promise { + const name = `schema-diff.${format}`; + const text = format === "json" ? JSON.stringify(cmp.d, null, 2) : await diffReport({ ...cmp.d, old: { ...cmp.d.old, title: cmp.oldTitle }, new: { ...cmp.d.new, title: cmp.newTitle } }, format); + const type = format === "json" ? "application/json" : format === "md" ? "text/markdown" : "text/html"; + downloadBlob(new Blob([text], { type }), name); +} + +/** The overview of all changes, with filters kept in the link. */ +export function diffOverviewPage(ctx: AppCtx, cmp: Compare): HTMLElement { + const d = cmp.d; + const P = ctx.route.params; + const t = d.summary.total; + const page = h("div", { class: "diff-page" }); + page.appendChild(h("header", { class: "page-head" }, h("h1", null, "Changes from ", h("span", { class: "d-removed-text" }, cmp.oldTitle), " to ", h("span", { class: "d-added-text" }, cmp.newTitle)), h("p", { class: "lead" }, `${plural(t.added + t.removed + t.changed, "global component")} differ: ${t.added.toLocaleString()} added, ${t.removed.toLocaleString()} removed, ${t.changed.toLocaleString()} changed. ${plural(d.summary.breakingChanges, "breaking change")} in ${plural(t.breaking, "component")}; ${plural(t.docsOnly, "component")} changed only in their documentation.`))); + const filter = { st: P.st ?? "", k: P.k ?? "", imp: P.imp ?? "", ns: P.ns ?? "", q: P.q ?? "" }; + const stat = (label: string, value: number, m: Mark | null, set: Partial, cls = "") => + h("button", { type: "button", class: `stat ${cls}`, onClick: () => { + Object.assign(filter, { st: "", imp: "" }, set); + apply(); + } }, h("span", { class: "n" }, m ? mark(m) : null, " ", value.toLocaleString()), h("span", { class: "l" }, label)); + page.appendChild( + h( + "div", + { class: "stats" }, + stat("added", t.added, "added", { st: "added" }), + stat("removed", t.removed, "removed", { st: "removed" }), + stat("changed", t.changed, "changed", { st: "changed" }), + stat("breaking", t.breaking, null, { imp: "breaking" }, "stat-breaking"), + stat("docs only", t.docsOnly, null, { imp: "info" }), + stat("possibly renamed", d.summary.possiblyRenamed, null, { st: "renamed" }) + ) + ); + const nsMap = Object.entries(d.namespaceMap ?? {}); + if (nsMap.length) page.appendChild(h("div", { class: "card note-card" }, h("strong", null, "The target namespace changed. "), "Components were matched by kind and local name: ", nsMap.map(([a, b], i) => [i ? "; " : "", h("code", null, a), " → ", h("code", null, b)]))); + const tools = h("div", { class: "btn-group" }, button("Markdown", () => void download(cmp, "md"), { icon: "download", cls: "small", title: "Release notes as Markdown" }), button("HTML", () => void download(cmp, "html"), { cls: "small", title: "A standalone HTML report" }), button("JSON", () => void download(cmp, "json"), { cls: "small", title: "The diff model" })); + page.appendChild(section("Summary", { tools: [tools] })); + const kindRows: [string, Counts][] = KINDS.filter((k) => d.summary.byKind[k]).map((k) => [KIND_LABEL[k], d.summary.byKind[k]!]); + kindRows.push(["Total", t]); + const grid = h("div", { class: "grid-2 diff-sum" }, h("div", null, countsTable(kindRows))); + const nsRows = Object.entries(d.summary.byNamespace); + if (nsRows.length > 1) grid.appendChild(h("div", null, countsTable(nsRows.map(([k, c]) => [k || "(no namespace)", c])))); + page.appendChild(grid); + const files = Object.entries(d.summary.byFile); + if (files.length > 1) page.appendChild(h("details", { class: "card by-file" }, h("summary", null, `By file (${files.length})`), countsTable(files.map(([k, c]) => [k || "(none)", c])))); + + // Filters. + const opt = (v: string, label: string, cur: string) => h("option", { value: v, selected: v === cur }, label); + const q = h("input", { class: "input", type: "search", placeholder: "Filter by name…", value: filter.q, "aria-label": "Filter by name" }) as HTMLInputElement; + const st = h("select", { class: "input", "aria-label": "Status" }, opt("", "Any status", filter.st), (["added", "removed", "changed", "renamed"] as const).map((s) => opt(s, s === "renamed" ? "Possibly renamed" : STATUS_LABEL[s as Status], filter.st))) as HTMLSelectElement; + const k = h("select", { class: "input", "aria-label": "Kind" }, opt("", "Any kind", filter.k), KINDS.filter((x) => d.summary.byKind[x]).map((x) => opt(KIND_SLUG[x], KIND_LABEL[x], filter.k))) as HTMLSelectElement; + const imp = h("select", { class: "input", "aria-label": "Impact" }, opt("", "Any impact", filter.imp), opt("breaking", "Breaking", filter.imp), opt("nonBreaking", "Non-breaking", filter.imp), opt("info", "Documentation only", filter.imp)) as HTMLSelectElement; + const nsSel = h("select", { class: "input", "aria-label": "Namespace" }, opt("", "Any namespace", filter.ns), nsRows.map(([u]) => opt(u, u || "(no namespace)", filter.ns))) as HTMLSelectElement; + const count = h("span", { class: "count" }); + page.appendChild(h("div", { class: "section-title" }, h("span", null, "Changes"), count)); + page.appendChild(h("div", { class: "filters" }, q, st, k, imp, nsRows.length > 1 ? nsSel : null)); + const list = h("div", { class: "diff-list" }); + page.appendChild(list); + let limit = 400; + const render = () => { + const needle = filter.q.trim().toLowerCase(); + const rows = d.components.filter( + (c) => + (!filter.st || (filter.st === "renamed" ? !!c.possiblyRenamed : c.status === filter.st)) && + (!filter.k || KIND_SLUG[c.kind] === filter.k) && + (!filter.imp || c.impact === filter.imp) && + (!filter.ns || nsOf(cmp, c) === filter.ns) && + (!needle || qnameOf(c).toLowerCase().includes(needle) || (c.old?.qname ?? "").toLowerCase().includes(needle)) + ); + count.textContent = rows.length.toLocaleString(); + const table = h("table", { class: "ptable diff-table" }, h("thead", null, h("tr", null, h("th", null, h("span", { class: "sr-only" }, "Status")), h("th", null, "Component"), h("th", null, "Kind"), h("th", null, "What changed"), h("th", null, "Impact")))); + const body = h("tbody"); + for (const c of rows.slice(0, limit)) { + body.appendChild(h("tr", { class: `d-${c.status}` }, h("td", null, mark(c.status)), h("td", null, diffLink(ctx, c), c.byLocalName && c.old && c.old.qname !== c.new?.qname ? h("div", { class: "faint small" }, "was ", c.old.qname) : null), h("td", null, h("span", { class: `badge k-${c.kind}` }, KIND_LABEL[c.kind])), h("td", null, changeSummary(c)), h("td", null, impactBadge(c.impact) ?? h("span", { class: "faint small" }, "non-breaking")))); + } + table.appendChild(body); + list.replaceChildren(rows.length ? table : h("p", { class: "muted" }, "No change matches the filters.")); + if (rows.length > limit) list.appendChild(button(`Show all ${rows.length.toLocaleString()}`, () => { + limit = rows.length; + render(); + }, { cls: "small" })); + }; + const apply = () => { + q.value = filter.q; + st.value = filter.st; + k.value = filter.k; + imp.value = filter.imp; + nsSel.value = filter.ns; + ctx.setParams({ ...filter }); + render(); + }; + q.addEventListener("input", () => { + filter.q = q.value; + apply(); + }); + for (const [el, key] of [[st, "st"], [k, "k"], [imp, "imp"], [nsSel, "ns"]] as const) + el.addEventListener("change", () => { + filter[key] = el.value; + apply(); + }); + render(); + return page; +} + +// ---- one component --------------------------------------------------------- + +interface Line { + key: string; + name: string; + detail: string; + /** Section header (inherited from …). */ + group?: string; +} + +/** The type whose content and attributes describe a component. */ +function typeOf(m: Model, id: Id): Id | undefined { + const c = m.comp(id); + if (c.kind === "complexType" || c.kind === "simpleType") return id; + if (c.kind === "element") { + const t = m.elementType(id); + return isId(t) ? t : undefined; + } + if (c.kind === "attribute") { + const t = c.attribute?.type; + return isId(t) ? t : undefined; + } + return undefined; +} + +function contentLines(m: Model, id: Id): Line[] { + const c = m.comp(id); + const out: Line[] = []; + if (c.kind === "group" && c.group) { + const rows: ReturnType = []; + m.particleRows(c.group, id, 0, false, rows, new Set()); + return rowsToLines(m, rows, id); + } + const t = typeOf(m, id); + if (t === undefined || m.comp(t).kind !== "complexType") return out; + return rowsToLines(m, m.contentRows(t, { inherited: true }), t); +} + +function rowsToLines(m: Model, rows: ReturnType, own: Id): Line[] { + const out: Line[] = []; + const seen = new Map(); + for (const r of rows) { + const indent = " ".repeat(r.depth); + let name: string; + let detail: string; + if (r.kind === "compositor") { + name = `${indent}${r.compositor}`; + detail = occursText(r.min, r.max); + } else if (r.kind === "any") { + name = `${indent}any`; + detail = `${r.wildcard?.namespace ?? "##any"} ${occursText(r.min, r.max)}`; + } else if (r.kind === "group") { + name = `${indent}group ${r.id !== undefined ? m.qname(r.id) : m.refName(r.missing)}`; + detail = occursText(r.min, r.max); + } else { + const local = r.id !== undefined ? m.comp(r.id).name ?? "" : m.refName(r.missing); + name = `${indent}${local}`; + const t = r.id !== undefined ? m.elementType(r.id) : undefined; + detail = `${m.typeName(t) || ""} ${occursText(r.min, r.max)}`.trim(); + } + const base = r.kind === "element" ? name.trim() : name; + const n = (seen.get(base) ?? 0) + 1; + seen.set(base, n); + out.push({ key: n > 1 ? `${base}#${n}` : base, name, detail, group: r.from !== own ? `from ${m.label(r.from)}` : undefined }); + } + return out; +} + +function attrLines(m: Model, id: Id): Line[] { + const c = m.comp(id); + if (c.kind === "attributeGroup") { + return (c.attributeGroup?.attributes ?? []).map((a) => { + if (a.t === "group") return { key: `group ${m.refName(a.group)}`, name: `attributeGroup ${m.refName(a.group)}`, detail: "" }; + const name = isId(a.attr) ? m.comp(a.attr).name ?? "" : m.refName(a.attr); + const t = m.attrType(a.attr); + return { key: name, name: `@${name}`, detail: `${m.typeName(t)} ${a.use ?? "optional"}${a.default !== undefined ? ` = ${a.default}` : ""}${a.fixed !== undefined ? ` fixed ${a.fixed}` : ""}` }; + }); + } + const t = typeOf(m, id); + if (t === undefined || m.comp(t).kind !== "complexType") return []; + return m.attributes(t).map((a) => { + const name = isId(a.attr) ? m.comp(a.attr).name ?? "" : m.refName(a.attr); + return { key: name, name: `@${name}`, detail: `${m.typeName(m.attrType(a.attr))} ${a.use ?? "optional"}${a.default !== undefined ? ` = ${a.default}` : ""}${a.fixed !== undefined ? ` fixed ${a.fixed}` : ""}`, group: a.from !== t ? `from ${m.label(a.from)}` : undefined }; + }); +} + +function enumLines(m: Model, id: Id): Line[] { + const t = typeOf(m, id); + return t === undefined ? [] : m.enumerations(t).map((e) => ({ key: e.value, name: e.value, detail: "" })); +} + +function facetLines(m: Model, id: Id): Line[] { + const t = typeOf(m, id); + if (t === undefined) return []; + const c = m.comp(t); + const out: Line[] = []; + const base = c.simpleType?.base ?? c.complexType?.derivation?.base; + if (base !== undefined) out.push({ key: "base", name: "base", detail: m.refName(base) }); + for (const f of c.simpleType?.facets ?? c.complexType?.facets ?? []) out.push({ key: `${f.kind} ${f.value}`, name: f.kind, detail: f.value }); + return out; +} + +/** Two columns of lines; what differs is marked on both sides. */ +function sideBySide(title: string, a: Line[], b: Line[], labels: [string, string]): HTMLElement | null { + if (!a.length && !b.length) return null; + const ma = new Map(a.map((l) => [l.key, l])); + const mb = new Map(b.map((l) => [l.key, l])); + const col = (lines: Line[], other: Map, gone: Mark, head: string) => { + const body = h("tbody"); + let group: string | undefined; + for (const l of lines) { + if (l.group !== group) { + group = l.group; + if (group) body.appendChild(h("tr", { class: "group-row" }, h("td", { colspan: 3 }, group))); + } + const o = other.get(l.key); + const st: Mark | undefined = !o ? gone : o.detail !== l.detail ? "changed" : undefined; + body.appendChild(h("tr", { class: st ? `d-row d-${st}` : "" }, h("td", { class: "mk" }, st ? mark(st) : null), h("td", { class: "mono pre" }, l.name), h("td", { class: "mono faint" }, l.detail))); + } + if (!lines.length) body.appendChild(h("tr", null, h("td", { colspan: 3, class: "faint" }, "(none)"))); + return h("div", { class: "side" }, h("div", { class: "side-head" }, head), h("table", { class: "ptable side-table" }, body)); + }; + const n = b.filter((l) => !ma.has(l.key)).length + a.filter((l) => !mb.has(l.key)).length + b.filter((l) => ma.has(l.key) && ma.get(l.key)!.detail !== l.detail).length; + return h("div", null, section(title, { count: n || undefined }), h("div", { class: "sbs" }, col(a, mb, "removed", labels[0]), col(b, ma, "added", labels[1]))); +} + +function changeRow(c: Change): HTMLElement { + const m = changeMark(c.kind); + const val = (v: string | undefined) => (v === undefined ? h("span", { class: "faint" }, "—") : h("code", null, v.length > 160 ? v.slice(0, 160) + "…" : v)); + const values = c.kind === "docsChanged" ? h("span", { class: "faint small" }, "see Documentation below") : c.old !== undefined || c.new !== undefined ? h("span", null, val(c.old), " → ", val(c.new)) : null; + return h("tr", { class: `d-row d-${m}` }, h("td", { class: "mk" }, mark(m)), h("td", null, changeLabel(c.kind)), h("td", { class: "mono" }, c.path ? h("span", { class: "faint" }, c.path + " › ") : null, c.subject ?? ""), h("td", null, values), h("td", null, impactBadge(c.impact) ?? h("span", { class: "faint small" }, "non-breaking"))); +} + +/** One changed, added or removed component with both versions side by side. */ +export function diffComponentPage(ctx: AppCtx, cmp: Compare, c: ComponentDiff): HTMLElement { + const page = h("div", { class: "diff-page" }); + const back = h("a", { href: ctx.hrefRoute({ view: "diff" }), onClick: (ev: Event) => { + ev.preventDefault(); + ctx.goRoute({ view: "diff" }); + } }, "All changes"); + page.appendChild(h("div", { class: "crumbs" }, back, h("span", { class: "sep" }, "›"), h("span", null, qnameOf(c)))); + const open = c.new ? button(`Open in ${cmp.newTitle}`, () => ctx.go(c.new!.id), { icon: "external", cls: "small" }) : null; + page.appendChild( + h( + "header", + { class: "page-head" }, + h("div", { class: "head-main" }, h("div", { class: "badges" }, h("span", { class: `badge k-${c.kind}` }, KIND_LABEL[c.kind]), h("span", { class: `badge d-badge d-${c.status}` }, MARKER[c.status], " ", STATUS_LABEL[c.status]), impactBadge(c.impact), c.byLocalName ? h("span", { class: "badge", title: "matched by local name: the namespace changed" }, "matched by name") : null), + h("h1", { class: `mono d-${c.status}-text` }, qnameOf(c)), + c.old && c.new && c.old.qname !== c.new.qname ? h("p", { class: "muted" }, "Was ", h("code", null, c.old.qname), ` in ${cmp.oldTitle}.`) : null, + c.possiblyRenamed ? h("p", null, c.status === "removed" ? "Possibly renamed to " : "Possibly renamed from ", diffTarget(ctx, cmp, c), " (near-identical content).") : null, + h("p", { class: "muted small" }, [c.old ? `${cmp.oldTitle}: ${c.old.file ?? ""}${c.old.line ? `:${c.old.line}` : ""}` : null, c.old && c.new ? " · " : null, c.new ? `${cmp.newTitle}: ${c.new.file ?? ""}${c.new.line ? `:${c.new.line}` : ""}` : null]), + open) + ) + ); + const ch = c.changes ?? []; + if (ch.length) { + const sorted = [...ch].sort((a, b) => (a.impact === b.impact ? 0 : a.impact === "breaking" ? -1 : b.impact === "breaking" ? 1 : a.impact === "info" ? 1 : -1)); + page.append(section("Changes", { count: ch.length }), h("table", { class: "ptable change-table" }, h("thead", null, h("tr", null, h("th", null, h("span", { class: "sr-only" }, "Kind")), h("th", null, "Change"), h("th", null, "Where"), h("th", null, "Old → new"), h("th", null, "Impact"))), h("tbody", null, sorted.map(changeRow)))); + } + const labels: [string, string] = [cmp.oldTitle, cmp.newTitle]; + const o = c.old ? c.old.id : undefined; + const n = c.new ? c.new.id : undefined; + const lines = (f: (m: Model, id: Id) => Line[], m: Model, id: Id | undefined) => (id === undefined ? [] : f(m, id)); + for (const [title, f] of [["Content model", contentLines], ["Attributes", attrLines], ["Base and facets", facetLines], ["Enumeration", enumLines]] as const) { + const el = sideBySide(title, lines(f, cmp.old, o), lines(f, cmp.cur, n), labels); + if (el) page.appendChild(el); + } + const da = o !== undefined ? docsText(cmp.old.comp(o).docs) : ""; + const db = n !== undefined ? docsText(cmp.cur.comp(n).docs) : ""; + const docChanges = ch.filter((x) => x.kind === "docsChanged" && (x.path || x.subject)); + if (da || db || docChanges.length) { + const differs = da.replace(/\s+/g, " ").trim() !== db.replace(/\s+/g, " ").trim(); + const doc = (text: string, st: Mark | undefined, head: string) => h("div", { class: `side${st ? ` d-${st}` : ""}` }, h("div", { class: "side-head" }, st ? mark(st) : null, " ", head), text ? h("p", { class: "doc-text" }, text) : h("p", { class: "faint" }, "(none)")); + page.append(section("Documentation"), h("div", { class: "sbs" }, doc(da, differs ? "removed" : undefined, labels[0]), doc(db, differs ? "added" : undefined, labels[1]))); + for (const x of docChanges) { + page.append(h("h3", { class: "doc-sub mono" }, [x.path, x.subject].filter(Boolean).join(" › ")), h("div", { class: "sbs" }, doc(x.old ?? "", "removed", labels[0]), doc(x.new ?? "", "added", labels[1]))); + } + } + return page; +} + +function diffTarget(ctx: AppCtx, cmp: Compare, c: ComponentDiff): Child { + const other = cmp.d.components.find((x) => x.kind === c.kind && x !== c && qnameOf(x) === c.possiblyRenamed); + return other ? diffLink(ctx, other) : h("code", null, c.possiblyRenamed ?? ""); +} + +/** The banner on a normal component page while comparing. */ +export function diffBanner(ctx: AppCtx, cmp: Compare, id: Id): HTMLElement | null { + const c = cmp.byNew.get(id); + if (!c) return cmp.d.components.length && ctx.m.comp(id).parent === undefined && !ctx.m.comp(id).builtin ? h("div", { class: "diff-banner" }, h("span", { class: "faint" }, `Unchanged since ${cmp.oldTitle}.`)) : null; + const n = c.changes?.length ?? 0; + return h("div", { class: `diff-banner d-${c.status}` }, mark(c.status), " ", c.status === "added" ? `New in ${cmp.newTitle}.` : `${plural(n, "change")} since ${cmp.oldTitle}${c.impact === "breaking" ? ", some breaking" : ""}.`, " ", diffLink(ctx, c, "Compare the versions"), h("span", { class: "faint small legend-diff" }, mark("added"), " added ", mark("removed"), " removed ", mark("changed"), " changed")); +} diff --git a/web/src/views/diagramPanel.ts b/web/src/views/diagramPanel.ts new file mode 100644 index 0000000..1bd581e --- /dev/null +++ b/web/src/views/diagramPanel.ts @@ -0,0 +1,333 @@ +// The diagram and the content-model tree, each with its toolbar: options, +// zoom, full screen, export. Layout runs only once the panel is on screen. +import type { AppCtx } from "../ctx"; +import { Diagram, type NodeAction, type View } from "../diagram"; +import { button, copyText, h, icon } from "../dom"; +import { buildNeighborhood, defaultNeighborhood, toMermaid, toPlantUml, type GraphSpec, type NeighborhoodOptions } from "../graph"; +import type { Id } from "../model"; +import { TreeView, rootNode, setTreeDiff, type TNode } from "../tree"; +import { annotateGraph } from "../diff"; + +/** What a click on a box in a diagram offers. */ +export function boxActions(ctx: AppCtx, id: Id, expand?: { expanded: boolean; toggle: () => void }): NodeAction[] { + const actions: NodeAction[] = []; + const c = ctx.m.comp(id); + if (expand) actions.push({ label: expand.expanded ? "Remove its neighbours" : "Add its neighbours to this diagram", icon: expand.expanded ? "minus" : "plus", run: expand.toggle }); + if (c.kind === "complexType") { + actions.push({ label: "Open its diagram", icon: "diagram", run: () => ctx.go(id, { tab: "diagram" }) }); + actions.push({ label: "Open its content tree", icon: "tree", run: () => ctx.go(id, { tab: "tree" }) }); + } else actions.push({ label: "Open its page", icon: "external", run: () => ctx.go(id) }); + return actions; +} + +/** A button that gives the panel the whole screen, and back. */ +export function fullscreenButton(wrap: HTMLElement): HTMLButtonElement { + const isFull = () => wrap.matches(":fullscreen") || wrap.classList.contains("fs"); + const sync = () => { + const full = isFull(); + btn.replaceChildren(icon(full ? "minimize" : "maximize")); + btn.title = full ? "Leave full screen (Esc)" : "Full screen"; + btn.setAttribute("aria-pressed", String(full)); + }; + const btn = button("", () => { + if (isFull()) { + if (wrap.classList.contains("fs")) wrap.classList.remove("fs"); + else void document.exitFullscreen(); + } else if (wrap.requestFullscreen) { + wrap.requestFullscreen().catch(() => wrap.classList.add("fs")); + } else wrap.classList.add("fs"); + setTimeout(sync, 100); + }, { cls: "small icon-only", icon: "maximize", title: "Full screen" }); + wrap.addEventListener("fullscreenchange", sync); + wrap.addEventListener("keydown", (ev) => { + if (ev.key === "Escape" && wrap.classList.contains("fs")) { + wrap.classList.remove("fs"); + sync(); + } + }); + return btn; +} + +/** The pan and zoom kept in a link ("tx,ty,scale"). */ +export const viewParam = (v: View | null): string | undefined => (v ? [v.tx, v.ty, v.scale].map((n) => +n.toFixed(3)).join(",") : undefined); +export function parseView(s: string | undefined): View | undefined { + const n = (s ?? "").split(",").map(Number); + return n.length === 3 && n.every(Number.isFinite) && n[2] > 0 ? { tx: n[0], ty: n[1], scale: n[2] } : undefined; +} + +/** Run `fn` once the element is (nearly) visible: big pages lay out only what is seen. */ +export function whenVisible(el: HTMLElement, fn: () => void): void { + if (typeof IntersectionObserver === "undefined") { + queueMicrotask(fn); + return; + } + const io = new IntersectionObserver((entries) => { + if (entries.some((e) => e.isIntersecting)) { + io.disconnect(); + fn(); + } + }, { rootMargin: "200px" }); + io.observe(el); +} + +const zoomButtons = (d: Diagram) => + h("div", { class: "btn-group" }, button("", () => d.zoomBy(1.25), { cls: "small icon-only", icon: "plus", title: "Zoom in" }), button("", () => d.zoomBy(0.8), { cls: "small icon-only", icon: "minus", title: "Zoom out" }), button("", () => d.fit(), { cls: "small icon-only", icon: "fit", title: "Fit" })); + +const idsParam = (set: Set) => [...set].join(" "); +const parseIds = (s: string | undefined, max: number) => new Set((s ?? "").split(" ").map(Number).filter((n) => Number.isInteger(n) && n >= 0 && n < max)); + +/** The class diagram of a complex type and its neighbours. */ +export function diagramPanel(ctx: AppCtx, focus: Id, opts: { tall?: boolean } = {}): HTMLElement { + const m = ctx.m; + const p = ctx.prefs; + const P = ctx.route.params; + const flag = (k: string, def: boolean) => (P[k] === undefined ? def : P[k] === "1"); + const base = defaultNeighborhood(focus); + const o: NeighborhoodOptions = { + ...base, + depth: P.depth === "2" ? 2 : P.depth === "1" ? 1 : p.depth, + inherited: flag("inh", false), + incoming: flag("inc", p.incoming), + derived: flag("sub", base.derived), + enums: flag("enum", p.enums), + expanded: parseIds(P.x, m.c.length), + openAttrs: parseIds(P.open, m.c.length), + }; + let direction: "DOWN" | "RIGHT" = P.dir === "right" ? "RIGHT" : P.dir === "down" ? "DOWN" : p.direction; + let view: View | undefined = parseView(P.view); + let restoreView = view !== undefined; + const bit = (v: boolean) => (v ? "1" : "0"); + const sync = () => + ctx.setParams({ depth: String(o.depth), dir: direction.toLowerCase(), inh: bit(o.inherited), inc: bit(o.incoming), sub: bit(o.derived), enum: bit(o.enums), x: idsParam(o.expanded), open: idsParam(o.openAttrs), view: viewParam(view ?? null) }); + const wrap = h("div", { class: `diagram${opts.tall ? " tall" : ""}` }); + const statusText = h("span"); + const status = h("div", { class: "status" }, statusText, h("div", { class: "legend hide-mobile" }, h("span", { class: "gen" }, "extends"), h("span", { class: "restr" }, "restricts"), h("span", { class: "comp" }, "contains"), h("span", { class: "dep" }, "uses"))); + const overlay = h("div", { class: "overlay" }, "Laying out…"); + const expand = (id: Id) => { + if (o.expanded.has(id)) o.expanded.delete(id); + else o.expanded.add(id); + run(true); + }; + const d = new Diagram(wrap, { + onNavigate: (id) => ctx.go(id), + onExpand: (id) => (m.comp(id).kind === "complexType" ? expand(id) : ctx.go(id)), + nodeMenu: (id) => boxActions(ctx, id, id === focus || m.comp(id).kind !== "complexType" ? undefined : { expanded: o.expanded.has(id), toggle: () => expand(id) }), + onToggleAttrs: (id) => { + if (o.openAttrs.has(id)) o.openAttrs.delete(id); + else o.openAttrs.add(id); + run(true); + }, + onStatus: (t) => (statusText.textContent = t), + onViewChange: (v) => { + view = v ?? undefined; + sync(); + }, + }); + let graph: GraphSpec | null = null; + let started = false; + const run = (keepView = false) => { + if (!started) return; + wrap.appendChild(overlay); + graph = buildNeighborhood(m, o); + if (ctx.cmp) annotateGraph(graph, ctx.cmp); + const start = restoreView ? view : undefined; + restoreView = false; + sync(); + d.render(graph, { direction, keepView, view: start }).finally(() => overlay.remove()); + }; + const toggle = (label: string, get: () => boolean, set: (v: boolean) => void, title?: string) => { + const b = button(label, () => { + set(!get()); + b.setAttribute("aria-pressed", String(get())); + run(true); + }, { cls: "small", pressed: get(), title }); + return b; + }; + const depthBtn = button(`Depth ${o.depth}`, () => { + o.depth = o.depth === 1 ? 2 : 1; + depthBtn.querySelector("span")!.textContent = `Depth ${o.depth}`; + ctx.setPrefs({ depth: o.depth }); + run(false); + }, { cls: "small", title: "How many steps away from this type to show" }); + const dirBtn = button("", () => { + direction = direction === "DOWN" ? "RIGHT" : "DOWN"; + ctx.setPrefs({ direction }); + run(false); + }, { cls: "small icon-only", icon: "tree", title: "Flip the layout direction" }); + const { exportBtn, exportMenu } = exportControls(ctx, d, m.label(focus), () => (graph ? { plantuml: toPlantUml(graph), mermaid: toMermaid(graph) } : null), wrap); + const tools = h( + "div", + { class: "dtools" }, + depthBtn, + toggle("Inherited", () => o.inherited, (v) => { + o.inherited = v; + }, "List inherited attributes and child elements in this type's box"), + toggle("Used by", () => o.incoming, (v) => { + o.incoming = v; + ctx.setPrefs({ incoming: v }); + }, "Show the types and elements that use this type"), + toggle("Derived", () => o.derived, (v) => (o.derived = v), "Show the types derived from this one"), + toggle("Enumerations", () => o.enums, (v) => { + o.enums = v; + ctx.setPrefs({ enums: v }); + }, "Show enumerated simple types as boxes"), + dirBtn, + h("span", { class: "spacer" }), + zoomButtons(d), + fullscreenButton(wrap), + exportBtn + ); + wrap.append(tools, exportMenu, status, overlay); + refitOnResize(wrap, d); + whenVisible(wrap, () => { + started = true; + run(false); + }); + return wrap; +} + +/** The content-model tree of an element, a complex type or a group. */ +export function treePanel(ctx: AppCtx, focus: Id): HTMLElement { + const m = ctx.m; + const P = ctx.route.params; + const open = new Set((P.tree ?? "").split(" ").filter(Boolean)); + let view: View | undefined = parseView(P.view); + let restoreView = view !== undefined; + const sync = () => ctx.setParams({ tree: [...open].join(" ") || undefined, view: viewParam(view ?? null) }); + const wrap = h("div", { class: "diagram tall" }); + const statusText = h("span"); + const status = h("div", { class: "status" }, statusText, h("div", { class: "legend hide-mobile" }, h("span", { class: "dashed" }, "optional"), h("span", { class: "stacked" }, "repeated"))); + setTreeDiff(ctx.cmp); + const root = rootNode(m, focus); + let started = false; + const toggleNode = (n: TNode, isOpen: boolean) => { + const compositor = n.kind === "sequence" || n.kind === "choice" || n.kind === "all" || n.kind === "type"; + if (compositor) { + if (isOpen) open.delete("-" + n.key); + else open.add("-" + n.key); + } else if (isOpen) open.add(n.key); + else open.delete(n.key); + run(true); + }; + const t: TreeView = new TreeView(wrap, { + onToggle: toggleNode, + actions: (n: TNode) => { + const a: NodeAction[] = []; + if (n.expandable && n.kind !== "type") { + const isOpen = n.kind === "sequence" || n.kind === "choice" || n.kind === "all" ? !open.has("-" + n.key) : open.has(n.key); + a.push({ label: isOpen ? "Collapse" : "Expand", icon: isOpen ? "minus" : "plus", run: () => toggleNode(n, !isOpen) }); + } + if (n.kind === "element" && n.expandable) a.push({ label: "Expand all below (3 levels)", icon: "plus", run: () => { + expandBelow(n, 3); + run(true); + } }); + if (n.id !== undefined && n.id !== focus) a.push({ label: n.kind === "group" ? "Open the group" : "Open the element", icon: "external", run: () => ctx.go(n.id!) }); + if (n.typeId !== undefined && n.typeId !== focus && !m.comp(n.typeId).builtin) a.push({ label: `Open the type ${m.label(n.typeId)}`, icon: "external", run: () => ctx.go(n.typeId!) }); + if (n.typeId !== undefined && m.comp(n.typeId).kind === "complexType" && n.typeId !== focus) a.push({ label: "Its content tree", icon: "tree", run: () => ctx.go(n.typeId!, { tab: "tree" }) }); + return a; + }, + onStatus: (s) => (statusText.textContent = s), + onViewChange: (v) => { + view = v ?? undefined; + sync(); + }, + }); + const expandBelow = (n: TNode, levels: number) => { + if (levels <= 0 || !n.expandable) return; + if (n.kind === "element" || n.kind === "group") open.add(n.key); + for (const c of n.children()) expandBelow(c, c.kind === "element" ? levels - 1 : levels); + }; + const run = (keepView = false) => { + if (!started) return; + const start = restoreView ? view : undefined; + restoreView = false; + t.show(root, open, { keepView, view: start }); + sync(); + }; + const collapse = button("Collapse all", () => { + open.clear(); + run(false); + }, { cls: "small", title: "Back to the first level" }); + const expandAll = button("Expand 2 levels", () => { + expandBelow(root, 2); + run(false); + }, { cls: "small", title: "Open the child elements two levels deep" }); + const { exportBtn, exportMenu } = exportControls(ctx, t, `${m.label(focus)}-tree`, null, wrap); + wrap.append(h("div", { class: "dtools" }, expandAll, collapse, h("span", { class: "spacer" }), zoomButtons(t), fullscreenButton(wrap), exportBtn), exportMenu, status); + refitOnResize(wrap, t); + whenVisible(wrap, () => { + started = true; + run(false); + }); + return wrap; +} + +/** The Export button and its menu: images, PlantUML, Mermaid and the link to the view. */ +export function exportControls(ctx: AppCtx, d: Diagram, label: string, text: (() => { plantuml: string; mermaid: string } | null) | null, wrap: HTMLElement) { + const exportMenu = h("div", { class: "menu", hidden: true }); + const exportBtn = button("Export", () => { + exportMenu.hidden = !exportMenu.hidden; + exportMenu.style.top = "44px"; + exportMenu.style.right = "8px"; + }, { cls: "small", icon: "download" }); + const name = label.replace(/[^\w.-]+/g, "_"); + const item = (t: string, fn: () => void) => + h("button", { type: "button", onClick: () => { + exportMenu.hidden = true; + fn(); + } }, t); + const copy = (what: string, t: string | undefined) => { + if (t !== undefined) void copyText(t).then((ok) => ctx.toast(ok ? `${what} copied` : "Could not copy")); + }; + exportMenu.append( + h("div", { class: "mh" }, "Export"), + item("SVG image", () => d.downloadSvg(name)), + item("PNG image (2×)", () => d.downloadPng(name)), + item("Copy link to this view", () => copy("Link", ctx.shareLink().url)) + ); + if (text) exportMenu.insertBefore(item("Copy PlantUML", () => copy("PlantUML", text()?.plantuml)), exportMenu.lastChild); + if (text) exportMenu.insertBefore(item("Copy Mermaid", () => copy("Mermaid", text()?.mermaid)), exportMenu.lastChild); + wrap.addEventListener("click", (ev) => { + if (!exportMenu.hidden && !exportMenu.contains(ev.target as Node) && !exportBtn.contains(ev.target as Node)) exportMenu.hidden = true; + }); + return { exportBtn, exportMenu }; +} + +/** Re-fit when the panel is resized by the user (CSS resize handle). */ +export function refitOnResize(wrap: HTMLElement, d: Diagram): void { + let last = 0; + new ResizeObserver(() => { + const now = wrap.clientHeight; + if (Math.abs(now - last) > 4) { + if (last !== 0) d.fit(); + last = now; + } + }).observe(wrap); +} + +/** A small diagram built by `build` (simple types): layout, zoom, full screen and export. */ +export function smallDiagram(ctx: AppCtx, focus: Id, build: () => GraphSpec, legend: string): HTMLElement { + const P = ctx.route.params; + let direction: "DOWN" | "RIGHT" = P.dir === "right" ? "RIGHT" : P.dir === "down" ? "DOWN" : ctx.prefs.direction; + const wrap = h("div", { class: "diagram small-diagram" }); + const statusText = h("span"); + const status = h("div", { class: "status" }, statusText, h("div", { class: "legend hide-mobile" }, legend)); + const overlay = h("div", { class: "overlay" }, "Laying out…"); + let graph: GraphSpec | null = null; + const d = new Diagram(wrap, { onNavigate: (id) => id !== focus && ctx.go(id), nodeMenu: (id) => (id === focus ? [] : boxActions(ctx, id)), onStatus: (t) => (statusText.textContent = t) }); + const run = () => { + wrap.appendChild(overlay); + graph = build(); + ctx.setParams({ dir: direction.toLowerCase() }); + d.render(graph, { direction }).finally(() => overlay.remove()); + }; + const dirBtn = button("", () => { + direction = direction === "DOWN" ? "RIGHT" : "DOWN"; + ctx.setPrefs({ direction }); + run(); + }, { cls: "small icon-only", icon: "tree", title: "Flip the layout direction" }); + const { exportBtn, exportMenu } = exportControls(ctx, d, ctx.m.label(focus), () => (graph ? { plantuml: toPlantUml(graph), mermaid: toMermaid(graph) } : null), wrap); + wrap.append(h("div", { class: "dtools" }, dirBtn, h("span", { class: "spacer" }), zoomButtons(d), fullscreenButton(wrap), exportBtn), exportMenu, status, overlay); + whenVisible(wrap, run); + return wrap; +} diff --git a/web/src/views/dialogs.ts b/web/src/views/dialogs.ts new file mode 100644 index 0000000..55925c3 --- /dev/null +++ b/web/src/views/dialogs.ts @@ -0,0 +1,110 @@ +// The "About" and "Help" dialogs. The help is a short manual, one foldable section +// per feature; controls are named by their label, not by where they sit on screen. +import { h, icon, type Child } from "../dom"; + +const REPO = "https://github.com/Matdata-eu/schema-explorer"; + +const link = (href: string, text: string) => h("a", { href, target: "_blank", rel: "noopener" }, text); +const b = (text: string) => h("strong", null, text); +const k = (text: string) => h("kbd", null, text); +const code = (text: string) => h("code", null, text); + +/** A modal dialog with a title bar and a close button, added to `host`. */ +function dialog(host: HTMLElement, title: string, cls: string, ...body: Child[]): HTMLDialogElement { + const dlg = h("dialog", { class: `se-dialog ${cls}`, "aria-label": title }) as HTMLDialogElement; + dlg.append( + h("div", { class: "dlg-head" }, h("h2", null, title), h("button", { type: "button", class: "btn ghost icon-only", "aria-label": "Close", title: "Close", onClick: () => dlg.close() }, icon("close"))), + h("div", { class: "dlg-body" }, ...body) + ); + dlg.addEventListener("click", (ev) => { + if (ev.target === dlg) dlg.close(); + }); + dlg.addEventListener("close", () => dlg.remove()); + host.appendChild(dlg); + dlg.showModal(); + return dlg; +} + +export function openAbout(host: HTMLElement, opts: { licensesUrl: string; generator?: string }): void { + const dlg = dialog( + host, + "About schema-explorer", + "about", + h("p", null, "XML Schema (XSD) sets as UML-like diagrams, content-model trees and readable pages. The files are read and indexed in your browser; nothing is uploaded."), + h("p", null, "New here? Read the ", h("button", { type: "button", class: "linkish", onClick: () => { + dlg.close(); + openHelp(host); + } }, "short guide"), "."), + h("h3", null, "Feedback"), + h("p", null, "Found a bug, missing a feature or have a question? Open an issue on ", link(`${REPO}/issues`, "GitHub"), ". Source code and documentation: ", link(REPO, REPO.replace(/^https:\/\//, "")), "."), + h("p", { class: "muted small" }, "What a schema says comes from its authors and is shown as it is."), + h("h3", null, "Credits"), + h( + "ul", + { class: "credits" }, + h("li", null, "Schemas are read with ", link("https://github.com/RazrFalcon/roxmltree", "roxmltree"), " in Rust and WebAssembly; diagrams are laid out by the ", link("https://eclipse.dev/elk/", "Eclipse Layout Kernel")), + h("li", null, "Fonts: ", link("https://github.com/jpt/barlow", "Barlow"), " and ", link("https://github.com/JetBrains/JetBrainsMono", "JetBrains Mono"), " (SIL Open Font License)"), + h("li", null, "Example schemas belong to their publishers and keep their own licences"), + h("li", null, "Full list: ", link(opts.licensesUrl, "third-party licences")), + h("li", null, "Made by Mathias Vanden Auweele, ", link("https://matdata.eu", "MatData"), " · Apache License 2.0") + ), + h("p", { class: "muted small" }, `Version ${__APP_VERSION__}`, opts.generator ? ` · schemas indexed by ${opts.generator}` : "") + ); +} + +const section = (title: string, open: boolean, ...items: Child[][]) => h("details", { class: "help-sec", open: open || undefined }, h("summary", null, title), h("ul", null, ...items.map((parts) => h("li", null, ...parts)))); + +export function openHelp(host: HTMLElement): void { + dialog( + host, + "How to use schema-explorer", + "help", + h("p", { class: "muted" }, "Read an XML Schema set as diagrams, content-model trees and one page per type, element and group. Open a topic below."), + section( + "Getting started", + true, + ["Drop ", code(".xsd"), " files or whole folders on the open page, or ", b("choose files"), " or ", b("choose a folder"), ". Give every file the schema imports or includes: they are found by their relative path, or else by file name or namespace."], + ["Files are indexed in your browser (WebAssembly); nothing is uploaded. A bundle made with ", code("schema-explorer index"), " opens by its address, or with ", code("?bundle="), " added to this page's link."], + [code("schema-explorer serve "), " opens the viewer on your own computer with the schemas already loaded."] + ), + section( + "Finding things", + false, + ["Type in the ", b("search"), " box (", k("/"), " jumps to it) to match names of types, elements, attributes and groups, and words of their documentation and enumeration values. Use ", k("↑"), " ", k("↓"), " and ", k("Enter"), " to open a result."], + ["The tabs under the search box list ", b("Types"), " (complex types), ", b("Simple"), " types, global ", b("Elements"), ", ", b("Groups"), " (model groups, attribute groups, global attributes) and ", b("Files"), ". The namespace list narrows them to one namespace."] + ), + section( + "Type pages", + false, + ["The top card shows the ", b("derivation"), " chain (extension or restriction) up to the root, the derived types and the global elements of this type."], + [b("Content model"), " lists the child elements in order with their type and cardinality. ", code("sequence"), ", ", code("choice"), " and ", code("all"), " appear as indented rows; group references are expanded under their name. Rows inherited from base types are grouped per base type and can be folded; ", b("inherited"), " shows or hides them."], + [b("Attributes"), " lists every attribute, inherited and from attribute groups, with its type, use and default or fixed value."], + [b("Where used"), " lists the types, elements and groups that refer to the type, and ", b("XSD source"), " shows the declaration as written, with links on the type names."] + ), + section( + "Diagrams", + false, + ["The ", b("Diagram"), " draws the type as a UML class with its attributes (", code("@name"), ") and simple-typed child elements; complex-typed child elements are composition arrows (diamond) with their multiplicity. A hollow triangle is extension, a dashed one restriction. «enumeration» boxes list the values of enumerated types."], + ["Click a box for options, double-click to add its neighbours. ", b("Depth"), " shows one or two steps around the type; ", b("Inherited"), ", ", b("Used by"), ", ", b("Derived"), " and ", b("Enumerations"), " add more."], + ["The tree button flips the layout, ", b("Full screen"), " gives the diagram the whole screen (", k("Esc"), " to leave), and ", b("Export"), " saves SVG or PNG, or copies PlantUML or Mermaid."] + ), + section( + "Content tree", + false, + ["The ", b("Content tree"), " shows the structure of an element or type as in XML editors: boxes are elements (dashed when optional, stacked when they repeat), the small three-dot node is a ", code("sequence"), " and the forked one a ", code("choice"), ". The occurrence is written under the box."], + ["Click ", b("+"), " to open an element's content, or click the box to open its page or type. ", b("Expand 2 levels"), " and ", b("Collapse all"), " open and close many at once."] + ), + section( + "Overview map", + false, + [b("Overview map"), " on the start page draws the complex types of a namespace by derivation. Narrow it to a folder of files, allow more boxes, or add ", b("Containment"), " arrows. Big namespaces show the most connected types first."] + ), + section( + "Sharing and display", + false, + [b("Share"), " copies a link to exactly this view: the schemas (when loaded from an address), the page, the tab, the diagram's options, the boxes you added, the open tree nodes and the zoom. The address bar always holds the same link."], + ["Files opened from your computer are not part of the link; the link still opens the same page where the same files are loaded."], + ["The moon and sun button switches between light and dark."] + ) + ); +} diff --git a/web/src/views/home.ts b/web/src/views/home.ts new file mode 100644 index 0000000..fb65109 --- /dev/null +++ b/web/src/views/home.ts @@ -0,0 +1,78 @@ +// The start page of a schema set: size, namespaces, where to begin, problems. +import type { AppCtx } from "../ctx"; +import { button, h, plural } from "../dom"; +import type { Id } from "../model"; +import { chips, section } from "./common"; + +export function homePage(ctx: AppCtx): HTMLElement { + const m = ctx.m; + const b = m.b; + const st = b.stats; + const page = h("div"); + page.appendChild(h("div", { class: "page-head" }, h("h1", null, m.title), h("span", { class: "badge" }, "XML Schema"))); + const inputs = b.files.filter((f) => f.input).map((f) => f.name); + page.appendChild(h("p", { class: "muted small" }, `${plural(b.files.length, "file")}${inputs.length && inputs.length < b.files.length ? `, from ${inputs.slice(0, 3).join(", ")}${inputs.length > 3 ? ` and ${inputs.length - 3} more` : ""}` : ""} · indexed by ${b.generator}`)); + const g = st.global; + const stat = (n: number | undefined, label: string, onClick?: () => void) => (n ? h("button", { class: "stat", type: "button", onClick }, h("div", { class: "n" }, n.toLocaleString()), h("div", { class: "l" }, label)) : null); + page.appendChild( + h( + "div", + { class: "stats" }, + stat(g.complexType, "complex types", () => ctx.goRoute({ view: "overview" })), + stat(g.simpleType, "simple types"), + stat(g.element, "global elements"), + stat((g.group ?? 0) + (g.attributeGroup ?? 0), "groups"), + stat(st.local.element, "local elements"), + stat(st.enumerations, "enumeration values"), + stat(b.files.length, "files") + ) + ); + page.appendChild(section("Start here", { tools: [button("Overview map", () => ctx.goRoute({ view: "overview" }), { icon: "map", cls: "small" })] })); + const roots = m.rootElements(); + if (roots.length) { + page.appendChild(h("p", { class: "muted small" }, "Global elements nothing else refers to: the likely document roots.")); + page.appendChild(chips(ctx, roots, { max: 24 })); + } + const popular = m.lists.complexType + .map((id) => ({ id, s: (m.c[id].usedBy?.length ?? 0) + (m.c[id].complexType?.derived?.length ?? 0) * 2 })) + .sort((a, z) => z.s - a.s) + .slice(0, 24) + .map((x) => x.id as Id); + if (popular.length) { + page.appendChild(h("p", { class: "muted small" }, "The most used and most extended complex types.")); + page.appendChild(chips(ctx, popular, { max: 24 })); + } + // Namespaces. + const counts = b.namespaces.map(() => ({ ct: 0, st: 0, el: 0 })); + for (const id of m.lists.complexType) if (m.c[id].ns !== undefined) counts[m.c[id].ns!].ct++; + for (const id of m.lists.simpleType) if (m.c[id].ns !== undefined) counts[m.c[id].ns!].st++; + for (const id of m.lists.element) if (m.c[id].ns !== undefined) counts[m.c[id].ns!].el++; + const nsRows = b.namespaces.map((n, i) => ({ n, i, c: counts[i] })).filter((x) => x.c.ct + x.c.st + x.c.el > 0); + if (nsRows.length) { + page.appendChild(section("Namespaces", { count: nsRows.length })); + page.appendChild( + h( + "table", + { class: "ptable compact" }, + h("thead", null, h("tr", null, h("th", null, "Prefix"), h("th", null, "Namespace"), h("th", null, "Complex"), h("th", null, "Simple"), h("th", null, "Elements"))), + h("tbody", null, nsRows.map((x) => h("tr", null, h("td", { class: "mono" }, x.n.prefix), h("td", { class: "mono small" }, x.n.uri), h("td", null, h("a", { href: ctx.hrefRoute({ view: "overview", params: { ns: String(x.i) } }), onClick: (ev: Event) => { + ev.preventDefault(); + ctx.goRoute({ view: "overview", params: { ns: String(x.i) } }); + }, title: "Map of this namespace" }, x.c.ct.toLocaleString())), h("td", null, x.c.st.toLocaleString()), h("td", null, x.c.el.toLocaleString())))) + ) + ); + } + const diags = (b.diagnostics ?? []).filter((d) => d.level !== "info"); + if (diags.length) { + page.appendChild(section("Problems found while reading", { count: diags.length })); + page.appendChild( + h( + "details", + { class: "diags-box" }, + h("summary", { class: "muted" }, `${diags.filter((d) => d.level === "error").length} errors, ${diags.filter((d) => d.level === "warning").length} warnings`), + h("ul", { class: "diags" }, diags.slice(0, 300).map((d) => h("li", { class: d.level }, h("span", { class: "badge" }, d.code), " ", d.file !== undefined ? h("span", { class: "mono small" }, `${b.files[d.file]?.name}:${d.line ?? ""} `) : null, d.message))) + ) + ); + } + return page; +} diff --git a/web/src/views/overview.ts b/web/src/views/overview.ts new file mode 100644 index 0000000..8ca9286 --- /dev/null +++ b/web/src/views/overview.ts @@ -0,0 +1,83 @@ +// The map of all complex types of a namespace (or a folder of files). +import type { AppCtx } from "../ctx"; +import { Diagram } from "../diagram"; +import { button, h } from "../dom"; +import { buildOverview, folders, toMermaid, toPlantUml, type GraphSpec, type OverviewOptions } from "../graph"; +import { boxActions, exportControls, fullscreenButton, parseView, viewParam } from "./diagramPanel"; + +export function overviewPage(ctx: AppCtx): HTMLElement { + const m = ctx.m; + const page = h("div"); + const nsList = m.namespacesOf("complexType").filter((x) => x.ns !== undefined || x.count > 0); + const folderList = folders(m); + const P = ctx.route.params; + const nsParam = P.ns === undefined ? nsList[0]?.ns : P.ns === "" || P.ns === "all" ? undefined : Number(P.ns); + const o: OverviewOptions = { ns: nsParam, folder: P.folder || undefined, associations: P.assoc === "1", maxNodes: Number(P.max) || 250 }; + let view = parseView(P.view); + let restoreView = view !== undefined; + const sync = () => ctx.setParams({ ns: o.ns === undefined ? "all" : String(o.ns), folder: o.folder, assoc: o.associations ? "1" : undefined, max: o.maxNodes !== 250 ? String(o.maxNodes) : undefined, view: viewParam(view ?? null) }); + page.appendChild(h("div", { class: "page-head" }, h("h1", null, "Overview map"), h("span", { class: "muted" }, "complex types and how they derive from each other"))); + const wrap = h("div", { class: "diagram tall" }); + const statusText = h("span"); + const status = h("div", { class: "status" }, statusText, h("div", { class: "legend hide-mobile" }, h("span", { class: "gen" }, "extends"), h("span", { class: "restr" }, "restricts"), h("span", { class: "comp" }, "contains"))); + const overlay = h("div", { class: "overlay" }, "Laying out the map… (large namespaces take a few seconds)"); + const d = new Diagram(wrap, { onNavigate: (id) => ctx.go(id), onExpand: (id) => ctx.go(id), nodeMenu: (id) => boxActions(ctx, id), onStatus: (t) => (statusText.textContent = t), onViewChange: (v) => { + view = v ?? undefined; + sync(); + } }); + let graph: GraphSpec | null = null; + const run = () => { + wrap.appendChild(overlay); + graph = buildOverview(m, o); + const algorithm = o.associations && graph.nodes.length > 80 ? "stress" : "layered"; + const start = restoreView ? view : undefined; + restoreView = false; + sync(); + const omitted = graph.omitted; + d.render(graph, { direction: "DOWN", algorithm, view: start }).finally(() => { + overlay.remove(); + if (omitted) statusText.textContent += ` · the ${o.maxNodes} most connected shown; pick a folder to see the rest`; + }); + }; + const assocBtn = button("Containment", () => { + o.associations = !o.associations; + assocBtn.setAttribute("aria-pressed", String(o.associations)); + run(); + }, { cls: "small", pressed: o.associations, title: "Also draw which types contain which (child elements; slower)" }); + const nsSel = h("select", { class: "input sel" }, h("option", { value: "all" }, "All namespaces"), nsList.map((n) => h("option", { value: String(n.ns) }, `${n.ns === undefined ? "(no namespace)" : m.prefix(n.ns)} (${n.count})`))) as HTMLSelectElement; + nsSel.value = o.ns === undefined ? "all" : String(o.ns); + nsSel.addEventListener("change", () => { + o.ns = nsSel.value === "all" ? undefined : Number(nsSel.value); + run(); + }); + const folderSel = h("select", { class: "input sel" }, h("option", { value: "" }, "All folders"), folderList.map((f) => h("option", { value: f.folder }, `${f.folder} (${f.count})`))) as HTMLSelectElement; + folderSel.value = o.folder ?? ""; + folderSel.addEventListener("change", () => { + o.folder = folderSel.value || undefined; + run(); + }); + const maxSel = h("select", { class: "input sel", title: "At most this many boxes" }, [100, 250, 500, 1000].map((n) => h("option", { value: String(n) }, `≤ ${n} boxes`))) as HTMLSelectElement; + maxSel.value = String(o.maxNodes); + maxSel.addEventListener("change", () => { + o.maxNodes = Number(maxSel.value); + run(); + }); + const { exportBtn, exportMenu } = exportControls(ctx, d, "overview", () => (graph ? { plantuml: toPlantUml(graph), mermaid: toMermaid(graph) } : null), wrap); + const tools = h( + "div", + { class: "dtools" }, + nsList.length > 1 ? nsSel : null, + folderList.length > 1 ? folderSel : null, + maxSel, + assocBtn, + h("span", { class: "spacer" }), + h("div", { class: "btn-group" }, button("", () => d.zoomBy(1.25), { cls: "small icon-only", icon: "plus", title: "Zoom in" }), button("", () => d.zoomBy(0.8), { cls: "small icon-only", icon: "minus", title: "Zoom out" }), button("", () => d.fit(), { cls: "small icon-only", icon: "fit", title: "Fit" })), + fullscreenButton(wrap), + exportBtn + ); + wrap.append(tools, exportMenu, status); + page.appendChild(wrap); + page.appendChild(h("p", { class: "faint hint" }, "Derivation only by default. Click a type for its diagram or content tree. Big namespaces show the most connected types first; narrow the map to a folder of files to see the rest.")); + queueMicrotask(run); + return page; +} diff --git a/web/src/views/pages.ts b/web/src/views/pages.ts new file mode 100644 index 0000000..93144ed --- /dev/null +++ b/web/src/views/pages.ts @@ -0,0 +1,438 @@ +// One page per component kind: complex type, element, simple type, groups, +// attributes, identity constraints, and a page per file. +import type { AppCtx } from "../ctx"; +import { button, h, plural, type Child } from "../dom"; +import { buildSimpleGraph } from "../graph"; +import { KIND_LABEL, isId, occursText, type ContentRow, type Id, type Ref } from "../model"; +import { chip, chips, docBlock, emptyNote, facetList, link, pageHead, refLink, section, shortDoc, sourceSection, whereUsed } from "./common"; +import { diagramPanel, smallDiagram, treePanel } from "./diagramPanel"; + +const card = (...c: Child[]) => h("div", { class: "card", style: { marginBottom: "14px" } }, ...c); + +/** The base chain as links: Type ← Base ← Root. */ +function baseChain(ctx: AppCtx, id: Id): HTMLElement | null { + const chain = ctx.m.baseChain(id); + if (!chain.length) return null; + const method = (x: Id) => ctx.m.comp(x).complexType?.derivation?.method ?? (ctx.m.comp(x).simpleType?.base !== undefined ? "restriction" : undefined); + const parts: Child[] = [h("strong", null, ctx.m.label(id))]; + let prev: Id = id; + for (const r of chain) { + parts.push(h("span", { class: "faint", title: method(prev) }, method(prev) === "extension" ? " ⟵ extends " : " ⟵ restricts "), refLink(ctx, r)); + if (!isId(r)) break; + prev = r; + } + return h("div", { class: "chain" }, ...parts); +} + +/** Tabs between the class diagram and the content tree of a type or element. */ +function viewTabs(ctx: AppCtx, id: Id, diagramFor: Id | undefined, treeFor: Id): HTMLElement[] { + const tab = ctx.route.tab === "tree" || diagramFor === undefined ? "tree" : "diagram"; + const tabs = h( + "div", + { class: "btn-group", style: { marginLeft: "auto" } }, + diagramFor !== undefined ? button("Diagram", () => ctx.go(id, { tab: "diagram" }), { icon: "diagram", cls: "small", pressed: tab === "diagram" }) : null, + button("Content tree", () => ctx.go(id, { tab: "tree" }), { icon: "tree", cls: "small", pressed: tab === "tree" }) + ); + const out = [section(tab === "diagram" ? "Diagram" : "Content tree", { tools: [tabs] })]; + if (tab === "diagram" && diagramFor !== undefined) { + out.push(diagramPanel(ctx, diagramFor)); + out.push(h("p", { class: "faint hint" }, "Click a box for options; double-click adds its neighbours. Drag to pan, scroll to zoom. A hollow triangle is extension (dashed: restriction), a diamond is a child element with its multiplicity, dashed arrows lead to enumerations.")); + } else { + out.push(treePanel(ctx, treeFor)); + out.push(h("p", { class: "faint hint" }, "Click + to open an element's content, click a box for its pages. Dashed boxes are optional, stacked boxes repeat; the three-dot node is a sequence, the forked one a choice.")); + } + return out; +} + +/** The sections every complex type has: content model, attributes, facets. */ +function typeBody(ctx: AppCtx, tid: Id, page: HTMLElement): void { + const m = ctx.m; + const ct = m.comp(tid).complexType!; + const rows = m.contentRows(tid, { inherited: true }); + const own = rows.filter((r) => r.from === tid && r.kind !== "compositor").length; + const inh = rows.filter((r) => r.from !== tid && r.kind !== "compositor").length; + const simple = ct.effective?.simple ?? (ct.derivation?.simple ? ct.derivation.base : undefined); + if (simple !== undefined) { + page.append(section("Text content"), card(h("dl", { class: "kv" }, h("dt", null, "Value"), h("dd", null, refLink(ctx, simple)), ct.facets?.length ? [h("dt", null, "Facets"), h("dd", null, facetList(ct.facets))] : null))); + if (ct.enumerations?.length) page.append(enumTable(ctx, ct.enumerations)); + } + if (rows.length || simple === undefined) { + const inhToggle = inh ? h("label", null, h("input", { type: "checkbox", checked: ctx.prefs.showInherited, onChange: (ev: Event) => { + ctx.setPrefs({ showInherited: (ev.target as HTMLInputElement).checked }); + ctx.refresh(); + } }), `inherited (${inh})`) : null; + page.append(section("Content model", { count: own + (ctx.prefs.showInherited ? inh : 0), tools: [ct.mixed || ct.effective?.mixed ? h("span", { class: "badge" }, "mixed") : null, inhToggle] })); + page.append(rows.length ? contentTable(ctx, tid, rows, ctx.prefs.showInherited) : emptyNote(simple !== undefined ? "" : "Empty content: no child elements.")); + } + const attrs = m.attributes(tid); + const ownA = attrs.filter((a) => a.from === tid).length; + page.append(section("Attributes", { count: attrs.length, tools: attrs.length > ownA ? [h("span", { class: "faint small" }, `${attrs.length - ownA} inherited`)] : [] })); + page.append(attrs.length || ct.effective?.anyAttribute || ct.anyAttribute ? attrTable(ctx, tid, attrs, ct.effective?.anyAttribute ?? ct.anyAttribute) : emptyNote("No attributes.")); + if (ct.asserts?.length) page.append(section("Assertions", { count: ct.asserts.length }), h("ul", { class: "cons" }, ct.asserts.map((a) => h("li", null, h("code", null, a))))); + // Identity constraints on the child elements. + const ids: Id[] = []; + for (const r of rows) if (r.kind === "element" && r.id !== undefined) ids.push(...(m.comp(r.id).element?.identity ?? [])); + if (ids.length) page.append(section("Identity constraints", { count: ids.length }), identityTable(ctx, ids)); +} + +/** The content model as a table, with compositors as indented header rows. */ +export function contentTable(ctx: AppCtx, tid: Id, rows: ContentRow[], showInherited: boolean): HTMLElement { + const m = ctx.m; + const table = h("table", { class: "ptable cm" }); + table.appendChild(h("thead", null, h("tr", null, h("th", null, "Element"), h("th", null, "Type"), h("th", null, "Card."), h("th", null, "Documentation")))); + let body = h("tbody"); + table.appendChild(body); + let from: Id | undefined; + for (const r of rows) { + const inherited = r.from !== tid; + if (inherited && !showInherited) continue; + if (r.from !== from) { + from = r.from; + if (inherited || rows.some((x) => x.from !== tid)) { + // A header per contributing type; inherited groups can be folded. + const n = rows.filter((x) => x.from === r.from && x.kind !== "compositor").length; + body = h("tbody", { class: inherited ? "inh-group" : "" }); + const head = h("tr", { class: "group-row" }, h("td", { colspan: 4 }, inherited ? h("button", { class: "linkish fold", type: "button", "aria-expanded": "true", onClick: (ev: Event) => { + const t = (ev.currentTarget as HTMLElement).closest("tbody")!; + const shut = t.classList.toggle("folded"); + (ev.currentTarget as HTMLElement).setAttribute("aria-expanded", String(!shut)); + } }, "▾ ") : null, inherited ? "Inherited from " : "Declared in ", link(ctx, r.from), h("span", { class: "faint" }, ` · ${plural(n, "element")}`))); + body.appendChild(head); + table.appendChild(body); + } + } + body.appendChild(contentRow(ctx, r, inherited)); + } + void m; + return table; +} + +function contentRow(ctx: AppCtx, r: ContentRow, inherited: boolean): HTMLElement { + const m = ctx.m; + const pad = { paddingLeft: `${10 + r.depth * 18}px` }; + const card = occursText(r.min, r.max); + const cls = `${inherited ? "inherited" : ""}${r.inChoice ? " in-choice" : ""}`; + if (r.kind === "compositor") { + return h("tr", { class: `${cls} comp-row` }, h("td", { style: pad, colspan: 2 }, h("span", { class: `compositor c-${r.compositor}` }, r.compositor)), h("td", { class: "mult" }, card), h("td", { class: "faint small" }, r.compositor === "choice" ? "one of the following" : r.compositor === "all" ? "all, in any order" : "")); + } + if (r.kind === "group") { + return h("tr", { class: `${cls} comp-row` }, h("td", { style: pad, colspan: 2 }, h("span", { class: "compositor c-group" }, "group "), r.id !== undefined ? link(ctx, r.id, m.qname(r.id)) : h("span", { class: "missing" }, m.refName(r.missing))), h("td", { class: "mult" }, card), h("td", null, r.id !== undefined ? shortDoc(m.comp(r.id).docs) : null)); + } + if (r.kind === "any") { + const w = r.wildcard!; + return h("tr", { class: cls }, h("td", { style: pad }, h("span", { class: "mono" }, "any")), h("td", { class: "mono small" }, w.namespace ?? "##any", w.processContents ? h("span", { class: "faint" }, ` (${w.processContents})`) : null), h("td", { class: "mult" }, card), h("td", null)); + } + const id = r.id; + if (id === undefined) return h("tr", { class: cls }, h("td", { style: pad }, h("span", { class: "missing mono" }, m.refName(r.missing))), h("td"), h("td", { class: "mult" }, card), h("td", { class: "faint" }, "not found")); + const el = m.comp(id); + const t = m.elementType(id); + const isRef = el.parent === undefined; + const docs = el.docs?.length ? el.docs : isId(t) && m.comp(t).name === "" ? undefined : undefined; + const typeDoc = !el.docs?.length && isId(t) ? m.comp(t).docs : undefined; + return h( + "tr", + { class: cls }, + h("td", { class: "pname", style: pad }, link(ctx, id, el.name ?? ""), isRef ? h("span", { class: "role", title: "a reference to a global element" }, "ref") : null, el.element?.abstract ? h("span", { class: "role" }, "abstract") : null, (el.element?.members?.length ?? 0) > 0 ? h("span", { class: "role", title: "elements of its substitution group may appear instead" }, `+${el.element!.members!.length} substitutes`) : null), + h("td", { class: "rng" }, t === undefined ? h("span", { class: "faint" }, "anyType") : isId(t) && !m.comp(t).name ? link(ctx, t, "anonymous") : refLink(ctx, t, true)), + h("td", { class: "mult" }, card, el.element?.nillable ? h("div", { class: "faint small" }, "nillable") : null), + h("td", null, docs ? shortDoc(docs) : typeDoc ? h("div", { class: "faint" }, shortDoc(typeDoc)) : null) + ); +} + +function attrTable(ctx: AppCtx, owner: Id, attrs: ReturnType, any?: { namespace?: string; processContents?: string }): HTMLElement { + const m = ctx.m; + const table = h("table", { class: "ptable" }, h("thead", null, h("tr", null, h("th", null, "Attribute"), h("th", null, "Type"), h("th", null, "Use"), h("th", null, "Default / fixed"), h("th", null, "Documentation")))); + const tbody = h("tbody"); + for (const a of attrs) { + const decl = isId(a.attr) ? m.comp(a.attr) : undefined; + const t = m.attrType(a.attr); + const dflt = a.default ?? decl?.attribute?.default; + const fixed = a.fixed ?? decl?.attribute?.fixed; + tbody.appendChild( + h( + "tr", + { class: `${a.from !== owner ? "inherited" : ""}${a.use === "prohibited" ? " prohibited" : ""}` }, + h("td", { class: "pname" }, isId(a.attr) ? link(ctx, a.attr, "@" + (decl?.name ?? "")) : h("span", { class: "missing" }, m.refName(a.attr)), a.from !== owner ? h("div", { class: "faint small" }, "from ", link(ctx, a.from)) : null, a.via !== undefined ? h("div", { class: "faint small" }, "via ", link(ctx, a.via)) : null), + h("td", { class: "rng" }, t === undefined ? h("span", { class: "faint" }, "anySimpleType") : isId(t) && !m.comp(t).name ? link(ctx, t, "anonymous") : refLink(ctx, t, true)), + h("td", null, a.use === "required" ? h("strong", null, "required") : a.use === "prohibited" ? h("span", { class: "faint" }, "prohibited") : h("span", { class: "muted" }, "optional")), + h("td", { class: "mono small" }, fixed !== undefined ? ["fixed ", h("code", null, fixed)] : dflt !== undefined ? h("code", null, dflt) : null), + h("td", null, decl ? shortDoc(decl.docs) : null) + ) + ); + } + if (any) tbody.appendChild(h("tr", null, h("td", { class: "mono" }, "anyAttribute"), h("td", { class: "mono small", colspan: 4 }, any.namespace ?? "##any", any.processContents ? ` (${any.processContents})` : ""))); + table.appendChild(tbody); + return table; +} + +function enumTable(ctx: AppCtx, values: { value: string; docs?: { text: string; lang?: string }[] }[]): HTMLElement { + void ctx; + const withDocs = values.some((v) => v.docs?.length); + const table = h("table", { class: "ptable" }, h("thead", null, h("tr", null, h("th", null, "Value"), withDocs ? h("th", null, "Documentation") : null))); + const tbody = h("tbody"); + const render = (limit: number) => { + tbody.replaceChildren(); + for (const v of values.slice(0, limit)) tbody.appendChild(h("tr", null, h("td", { class: "mono" }, v.value), withDocs ? h("td", null, docBlock(v.docs, "desc")) : null)); + if (values.length > limit) tbody.appendChild(h("tr", null, h("td", { colspan: 2 }, h("button", { type: "button", class: "linkish", onClick: () => render(values.length) }, `Show all ${values.length}`)))); + }; + render(300); + table.appendChild(tbody); + return h("div", null, section("Enumeration", { count: values.length }), table); +} + +function identityTable(ctx: AppCtx, ids: Id[]): HTMLElement { + const m = ctx.m; + return h( + "table", + { class: "ptable" }, + h("thead", null, h("tr", null, h("th", null, "Name"), h("th", null, "Kind"), h("th", null, "On"), h("th", null, "Selector"), h("th", null, "Fields"))), + h( + "tbody", + null, + ids.map((id) => { + const c = m.comp(id); + return h( + "tr", + null, + h("td", { class: "pname" }, link(ctx, id, c.name ?? ""), c.identity?.refer !== undefined ? h("div", { class: "faint small" }, "refers to ", refLink(ctx, c.identity.refer)) : null), + h("td", null, KIND_LABEL[c.kind]), + h("td", null, c.parent !== undefined ? link(ctx, c.parent) : null), + h("td", { class: "mono small" }, c.identity?.selector ?? ""), + h("td", { class: "mono small" }, (c.identity?.fields ?? []).join(", ")) + ); + }) + ) + ); +} + +function flagBadges(flags: [boolean | undefined, string, string?][]): HTMLElement[] { + return flags.filter(([on]) => on).map(([, text, title]) => h("span", { class: "badge", title }, text)); +} + +// ---- pages ---------------------------------------------------------------- + +export function complexTypePage(ctx: AppCtx, id: Id): HTMLElement { + const m = ctx.m; + const c = m.comp(id); + const ct = c.complexType!; + const page = h("div"); + page.appendChild(pageHead(ctx, id, flagBadges([[ct.abstract, "abstract"], [ct.mixed, "mixed"], [ct.derivation?.simple, "simple content"], [ct.final !== undefined, `final ${ct.final}`], [ct.block !== undefined, `block ${ct.block}`], [c.redefines !== undefined, "redefined"]]))); + const derived = ct.derived ?? []; + const elements = m.usedBy(id).filter((u) => u.how === "type" && m.comp(u.by).kind === "element" && m.isGlobal(u.by)).map((u) => u.by); + const chain = baseChain(ctx, id); + if (chain || derived.length || elements.length || c.parent !== undefined) { + const dl = h("dl", { class: "kv" }); + if (c.parent !== undefined) dl.append(h("dt", null, "Type of"), h("dd", null, chip(ctx, c.parent))); + if (chain) dl.append(h("dt", null, "Derivation"), h("dd", null, chain)); + if (derived.length) dl.append(h("dt", null, plural(derived.length, "Derived type")), h("dd", null, chips(ctx, derived, { max: 30 }))); + if (elements.length) dl.append(h("dt", null, plural(elements.length, "Global element")), h("dd", null, chips(ctx, elements, { max: 20 }))); + page.appendChild(card(dl)); + } + page.append(...viewTabs(ctx, id, id, id)); + typeBody(ctx, id, page); + const used = whereUsed(ctx, id); + if (used) page.appendChild(used); + const src = sourceSection(ctx, id); + if (src) page.appendChild(src); + return page; +} + +export function elementPage(ctx: AppCtx, id: Id): HTMLElement { + const m = ctx.m; + const c = m.comp(id); + const e = c.element ?? {}; + const page = h("div"); + page.appendChild(pageHead(ctx, id, flagBadges([[e.abstract, "abstract"], [e.nillable, "nillable"], [e.final !== undefined, `final ${e.final}`], [e.block !== undefined, `block ${e.block}`]]))); + const t = m.elementType(id); + const anon = isId(t) && !m.comp(t).name && m.comp(t).parent === id; + const dl = h("dl", { class: "kv" }); + dl.append(h("dt", null, "Type"), h("dd", null, t === undefined ? h("span", { class: "faint" }, "xs:anyType (none declared)") : anon ? h("span", null, "anonymous ", KIND_LABEL[m.comp(t as Id).kind].toLowerCase(), m.baseOf(t as Id) !== undefined ? [" based on ", refLink(ctx, m.baseOf(t as Id))] : null) : refLink(ctx, t), e.type === undefined && t !== undefined ? h("span", { class: "faint" }, " (from its substitution group head)") : null)); + if (e.default !== undefined) dl.append(h("dt", null, "Default"), h("dd", null, h("code", null, e.default))); + if (e.fixed !== undefined) dl.append(h("dt", null, "Fixed"), h("dd", null, h("code", null, e.fixed))); + if (e.substitutionGroup?.length) dl.append(h("dt", null, "Substitutes for"), h("dd", null, e.substitutionGroup.map((r: Ref) => refLink(ctx, r)))); + if (e.members?.length) dl.append(h("dt", null, plural(e.members.length, "Substitute")), h("dd", null, chips(ctx, e.members, { max: 40 }))); + if (c.parent !== undefined) { + const owner = m.owner(id); + dl.append(h("dt", null, "Declared in"), h("dd", null, chip(ctx, owner))); + } + page.appendChild(card(dl)); + const complex = m.isComplex(t); + if (complex) { + page.append(...viewTabs(ctx, id, t, id)); + if (anon) typeBody(ctx, t, page); + else page.append(h("p", { class: "muted" }, "The content model and attributes are those of ", link(ctx, t), ".")); + } else if (isId(t) && m.comp(t).kind === "simpleType") { + const st = m.comp(t).simpleType!; + if (st.facets?.length) page.append(section("Facets"), card(facetList(st.facets))); + if (st.enumerations?.length) page.append(enumTable(ctx, st.enumerations)); + } + if (e.identity?.length) page.append(section("Identity constraints", { count: e.identity.length }), identityTable(ctx, e.identity)); + const used = whereUsed(ctx, id); + if (used) page.appendChild(used); + const src = sourceSection(ctx, id); + if (src) page.appendChild(src); + return page; +} + +export function simpleTypePage(ctx: AppCtx, id: Id): HTMLElement { + const m = ctx.m; + const c = m.comp(id); + const st = c.simpleType!; + const page = h("div"); + page.appendChild(pageHead(ctx, id, [h("span", { class: "badge" }, st.variety), ...flagBadges([[st.final !== undefined, `final ${st.final}`], [c.builtin, "built in"]])])); + const dl = h("dl", { class: "kv" }); + const chain = baseChain(ctx, id); + if (c.parent !== undefined) dl.append(h("dt", null, "Type of"), h("dd", null, chip(ctx, c.parent))); + if (chain) dl.append(h("dt", null, "Derivation"), h("dd", null, chain)); + if (st.item !== undefined) dl.append(h("dt", null, "List of"), h("dd", null, refLink(ctx, st.item))); + if (st.members?.length) dl.append(h("dt", null, "Union of"), h("dd", null, st.members.map((r, i) => [i ? " | " : "", refLink(ctx, r)]))); + if (st.derived?.length) dl.append(h("dt", null, plural(st.derived.length, "Derived type")), h("dd", null, chips(ctx, st.derived, { max: 30 }))); + if (dl.childElementCount) page.appendChild(card(dl)); + if (st.facets?.length) page.append(section("Facets", { count: st.facets.length }), card(facetList(st.facets))); + if (st.enumerations?.length) page.append(enumTable(ctx, st.enumerations)); + if (!c.builtin && (chain || st.derived?.length || c.usedBy?.length)) { + page.append(section("Diagram"), smallDiagram(ctx, id, () => buildSimpleGraph(m, id), "base, derived types and the types that use it")); + } + const used = whereUsed(ctx, id); + if (used) page.appendChild(used); + const src = sourceSection(ctx, id); + if (src) page.appendChild(src); + return page; +} + +export function groupPage(ctx: AppCtx, id: Id): HTMLElement { + const m = ctx.m; + const c = m.comp(id); + const page = h("div"); + page.appendChild(pageHead(ctx, id)); + page.append(section("Content tree"), treePanel(ctx, id)); + const rows: ContentRow[] = []; + if (c.group) m.particleRows(c.group, id, 0, false, rows, new Set()); + page.append(section("Content model", { count: rows.filter((r) => r.kind === "element").length }), rows.length ? contentTable(ctx, id, rows, true) : emptyNote("Empty group.")); + const used = whereUsed(ctx, id); + if (used) page.appendChild(used); + const src = sourceSection(ctx, id); + if (src) page.appendChild(src); + return page; +} + +export function attributeGroupPage(ctx: AppCtx, id: Id): HTMLElement { + const m = ctx.m; + const c = m.comp(id); + const page = h("div"); + page.appendChild(pageHead(ctx, id)); + const attrs = m.attributes(id); + page.append(section("Attributes", { count: attrs.length }), attrs.length ? attrTable(ctx, id, attrs, c.attributeGroup?.anyAttribute) : emptyNote("No attributes.")); + const used = whereUsed(ctx, id); + if (used) page.appendChild(used); + const src = sourceSection(ctx, id); + if (src) page.appendChild(src); + return page; +} + +export function attributePage(ctx: AppCtx, id: Id): HTMLElement { + const m = ctx.m; + const c = m.comp(id); + const a = c.attribute ?? {}; + const page = h("div"); + page.appendChild(pageHead(ctx, id, flagBadges([[a.inheritable, "inheritable"], [c.builtin, "built in"]]))); + const dl = h("dl", { class: "kv" }); + dl.append(h("dt", null, "Type"), h("dd", null, a.type === undefined ? h("span", { class: "faint" }, "xs:anySimpleType") : refLink(ctx, a.type))); + if (a.default !== undefined) dl.append(h("dt", null, "Default"), h("dd", null, h("code", null, a.default))); + if (a.fixed !== undefined) dl.append(h("dt", null, "Fixed"), h("dd", null, h("code", null, a.fixed))); + if (c.parent !== undefined) dl.append(h("dt", null, "Declared in"), h("dd", null, chip(ctx, m.owner(id)))); + page.appendChild(card(dl)); + if (isId(a.type) && m.comp(a.type).simpleType?.enumerations?.length) page.append(enumTable(ctx, m.comp(a.type).simpleType!.enumerations!)); + const used = whereUsed(ctx, id); + if (used) page.appendChild(used); + const src = sourceSection(ctx, id); + if (src) page.appendChild(src); + return page; +} + +export function identityPage(ctx: AppCtx, id: Id): HTMLElement { + const m = ctx.m; + const c = m.comp(id); + const page = h("div"); + page.appendChild(pageHead(ctx, id)); + const dl = h("dl", { class: "kv" }); + if (c.parent !== undefined) dl.append(h("dt", null, "On element"), h("dd", null, chip(ctx, c.parent))); + dl.append(h("dt", null, "Selector"), h("dd", null, h("code", null, c.identity?.selector ?? ""))); + dl.append(h("dt", null, "Fields"), h("dd", null, (c.identity?.fields ?? []).map((f, i) => [i ? ", " : "", h("code", null, f)]))); + if (c.identity?.refer !== undefined) dl.append(h("dt", null, "Refers to"), h("dd", null, refLink(ctx, c.identity.refer))); + page.appendChild(card(dl)); + const used = whereUsed(ctx, id); + if (used) page.appendChild(used); + const src = sourceSection(ctx, id); + if (src) page.appendChild(src); + return page; +} + +export function componentPage(ctx: AppCtx, id: Id): HTMLElement { + const c = ctx.m.comp(id); + switch (c.kind) { + case "complexType": + return complexTypePage(ctx, id); + case "element": + return elementPage(ctx, id); + case "simpleType": + return simpleTypePage(ctx, id); + case "group": + return groupPage(ctx, id); + case "attributeGroup": + return attributeGroupPage(ctx, id); + case "attribute": + return attributePage(ctx, id); + default: + return identityPage(ctx, id); + } +} + +/** A schema file: namespace, imports and includes, and what it declares. */ +export function filePage(ctx: AppCtx, fid: number): HTMLElement { + const m = ctx.m; + const f = m.b.files[fid]; + const page = h("div"); + if (!f) return h("div", null, h("h1", null, "Not found")); + page.appendChild(h("div", { class: "page-head" }, h("h1", null, f.name.split("/").pop()), h("span", { class: "badge" }, "Schema file"), f.input ? null : h("span", { class: "badge", title: "pulled in through an import or include" }, "imported"))); + page.appendChild(h("div", { class: "iri-line" }, h("span", { class: "mono" }, f.name), f.version ? h("span", { class: "faint" }, `version ${f.version}`) : null)); + const d = docBlock(f.docs); + if (d) page.appendChild(d); + const dl = h("dl", { class: "kv" }); + dl.append(h("dt", null, "Target namespace"), h("dd", { class: "mono" }, f.targetNamespace ?? h("span", { class: "faint" }, "none"))); + if (f.namespace !== undefined && m.b.namespaces[f.namespace]?.uri !== f.targetNamespace) dl.append(h("dt", null, "Components in"), h("dd", { class: "mono" }, m.b.namespaces[f.namespace].uri, h("span", { class: "faint" }, " (chameleon include)"))); + dl.append(h("dt", null, "Element form"), h("dd", null, f.elementFormQualified ? "qualified" : "unqualified")); + page.appendChild(card(dl)); + const dirs = f.directives ?? []; + if (dirs.length) { + page.append( + section("Imports and includes", { count: dirs.length }), + h( + "table", + { class: "ptable compact" }, + h("thead", null, h("tr", null, h("th", null, "Kind"), h("th", null, "Namespace"), h("th", null, "Location"), h("th", null, "File"))), + h("tbody", null, dirs.map((x) => h("tr", null, h("td", null, x.kind), h("td", { class: "mono small" }, x.namespace ?? ""), h("td", { class: "mono small" }, x.location ?? ""), h("td", null, x.file !== undefined ? h("a", { href: ctx.hrefRoute({ view: "file", id: x.file }), onClick: (ev: Event) => { + ev.preventDefault(); + ctx.goRoute({ view: "file", id: x.file }); + } }, m.b.files[x.file]?.name ?? "") : h("span", { class: "missing" }, "not found"))))) + ) + ); + } + const usedBy = m.b.files.map((g, i) => ({ g, i })).filter(({ g }) => (g.directives ?? []).some((x) => x.file === fid)); + if (usedBy.length) page.append(section("Imported or included by", { count: usedBy.length }), h("ul", { class: "plain" }, usedBy.map(({ g, i }) => h("li", null, h("a", { href: ctx.hrefRoute({ view: "file", id: i }), onClick: (ev: Event) => { + ev.preventDefault(); + ctx.goRoute({ view: "file", id: i }); + } }, g.name))))); + const here: Id[] = []; + for (let i = 0; i < m.c.length; i++) if (m.c[i].file === fid && m.c[i].parent === undefined) here.push(i); + here.sort((a, b) => (m.c[a].line ?? 0) - (m.c[b].line ?? 0)); + const byKind = new Map(); + for (const id of here) { + const k = m.c[id].kind; + byKind.set(k, [...(byKind.get(k) ?? []), id]); + } + for (const [k, ids] of byKind) page.append(section(`${KIND_LABEL[k as keyof typeof KIND_LABEL]}s`, { count: ids.length }), chips(ctx, ids, { max: 80 })); + const diags = (m.b.diagnostics ?? []).filter((x) => x.file === fid && x.level !== "info"); + if (diags.length) page.append(section("Problems", { count: diags.length }), h("ul", { class: "diags" }, diags.map((x) => h("li", { class: x.level }, h("span", { class: "badge" }, x.level), ` line ${x.line ?? "?"}: ${x.message}`)))); + return page; +} diff --git a/web/src/views/sidebar.ts b/web/src/views/sidebar.ts new file mode 100644 index 0000000..9922053 --- /dev/null +++ b/web/src/views/sidebar.ts @@ -0,0 +1,238 @@ +// The left panel: search, and lists of types, elements, groups and files. +// Lists are virtualised: only the rows on screen exist, so thousands of +// types (NeTEx has about 2,600 complex types) scroll smoothly. +import type { AppCtx } from "../ctx"; +import { debounce, h, icon, plural } from "../dom"; +import { KIND_LABEL, type Id } from "../model"; + +type Tab = "complexType" | "simpleType" | "element" | "groups" | "files"; + +export interface Sidebar { + el: HTMLElement; + /** Highlight the selected component and scroll it into view. */ + select(id: Id | undefined, file?: number): void; + focusSearch(): void; +} + +const ROW = 30; + +interface Item { + id?: Id; + file?: number; + text: string; + sub?: string; + kind: string; + title?: string; +} + +/** A list that renders only the visible rows. */ +class VList { + readonly el: HTMLElement; + private inner: HTMLElement; + private items: Item[] = []; + private selected: (it: Item) => boolean = () => false; + private active = -1; + private raf = 0; + + constructor(private ctx: AppCtx) { + this.inner = h("div", { class: "vinner" }); + this.el = h("div", { class: "vlist", role: "listbox" }, this.inner); + this.el.addEventListener("scroll", () => { + cancelAnimationFrame(this.raf); + this.raf = requestAnimationFrame(() => this.paint()); + }); + new ResizeObserver(() => this.paint()).observe(this.el); + } + + set(items: Item[], selected: (it: Item) => boolean): void { + this.items = items; + this.selected = selected; + this.active = -1; + this.inner.style.height = `${items.length * ROW}px`; + this.el.scrollTop = 0; + this.paint(); + } + + get length(): number { + return this.items.length; + } + + setSelected(selected: (it: Item) => boolean): void { + this.selected = selected; + this.paint(); + } + + /** Scroll so that the first item matching `pred` is visible. */ + reveal(pred: (it: Item) => boolean): void { + const i = this.items.findIndex(pred); + if (i < 0) return; + const top = i * ROW; + if (top < this.el.scrollTop || top + ROW > this.el.scrollTop + this.el.clientHeight) this.el.scrollTop = top - this.el.clientHeight / 2; + this.paint(); + } + + move(delta: number): void { + if (!this.items.length) return; + this.active = (this.active + delta + this.items.length) % this.items.length; + this.reveal((it) => it === this.items[this.active]); + } + + activeItem(): Item | undefined { + return this.items[this.active >= 0 ? this.active : 0]; + } + + paint(): void { + const h0 = this.el.clientHeight || 600; + const first = Math.max(0, Math.floor(this.el.scrollTop / ROW) - 8); + const last = Math.min(this.items.length, Math.ceil((this.el.scrollTop + h0) / ROW) + 8); + const rows: HTMLElement[] = []; + for (let i = first; i < last; i++) { + const it = this.items[i]; + const href = it.id !== undefined ? this.ctx.href(it.id) : this.ctx.hrefRoute({ view: "file", id: it.file }); + const a = h( + "a", + { class: `row vrow${this.selected(it) ? " selected" : ""}${i === this.active ? " active" : ""}`, href, title: it.title ?? it.text, style: { top: `${i * ROW}px` }, onClick: ((ev: MouseEvent) => { + if (ev.metaKey || ev.ctrlKey || ev.shiftKey) return; + ev.preventDefault(); + if (it.id !== undefined) this.ctx.go(it.id); + else this.ctx.goRoute({ view: "file", id: it.file }); + }) as EventListener }, + h("span", { class: `kind-dot k-${it.kind}` }), + h("span", { class: "lbl" }, it.text), + it.sub ? h("span", { class: "ctx" }, it.sub) : null + ); + rows.push(a); + } + this.inner.replaceChildren(...rows); + } +} + +export function sidebar(ctx: AppCtx): Sidebar { + const m = ctx.m; + const el = h("div", { class: "sidebar" }); + const input = h("input", { class: "input", type: "search", placeholder: "Search names and documentation…", "aria-label": "Search", autocomplete: "off", spellcheck: "false" }) as HTMLInputElement; + const kbd = h("kbd", null, "/"); + const box = h("div", { class: "search-box" }, icon("search"), input, kbd); + const tabs = h("div", { class: "tabs", role: "tablist" }); + const nsList = m.b.namespaces.map((n, i) => ({ i, n, count: 0 })); + for (const k of ["complexType", "simpleType", "element", "group", "attributeGroup"] as const) for (const id of m.lists[k]) if (m.c[id].ns !== undefined) nsList[m.c[id].ns!].count++; + const usedNs = nsList.filter((x) => x.count > 0 && x.n.uri !== "http://www.w3.org/2001/XMLSchema"); + let nsFilter: number | undefined; + try { + const saved = localStorage.getItem("se.ns"); + if (saved !== null && usedNs.some((x) => String(x.i) === saved)) nsFilter = Number(saved); + } catch { + /* no storage */ + } + const nsSel = h("select", { class: "input ns-select", "aria-label": "Namespace", title: "Show one namespace" }, h("option", { value: "" }, "All namespaces"), usedNs.map((x) => h("option", { value: String(x.i), title: x.n.uri }, `${x.n.prefix} · ${x.count}`))) as HTMLSelectElement; + nsSel.value = nsFilter === undefined ? "" : String(nsFilter); + const info = h("div", { class: "list-info faint" }); + const list = new VList(ctx); + el.append(box, tabs, ...(usedNs.length > 1 ? [nsSel] : []), info, list.el); + + const groupsCount = m.lists.group.length + m.lists.attributeGroup.length + m.lists.attribute.length; + const tabDefs: { id: Tab; label: string; count: number; title: string }[] = [ + { id: "complexType", label: "Types", count: m.lists.complexType.length, title: "Complex types" }, + { id: "simpleType", label: "Simple", count: m.lists.simpleType.length, title: "Simple types" }, + { id: "element", label: "Elements", count: m.lists.element.length, title: "Global elements" }, + { id: "groups", label: "Groups", count: groupsCount, title: "Groups, attribute groups and global attributes" }, + { id: "files", label: "Files", count: m.b.files.length, title: "Schema files" }, + ]; + let tab: Tab = "complexType"; + try { + const saved = localStorage.getItem("se.tab") as Tab | null; + if (saved && tabDefs.some((t) => t.id === saved && t.count)) tab = saved; + } catch { + /* no storage */ + } + const setTab = (t: Tab) => { + tab = t; + try { + localStorage.setItem("se.tab", t); + } catch { + /* ignore */ + } + tabs.querySelectorAll("button").forEach((x) => x.setAttribute("aria-selected", String((x as HTMLElement).dataset.tab === t))); + render(); + }; + for (const t of tabDefs) { + if (!t.count) continue; + tabs.appendChild(h("button", { type: "button", role: "tab", title: t.title, dataset: { tab: t.id }, "aria-selected": String(t.id === tab), onClick: () => setTab(t.id) }, t.label, h("span", { class: "count" }, t.count.toLocaleString()))); + } + + let selected: Id | undefined; + let selectedFile: number | undefined; + const isSel = (it: Item) => (it.id !== undefined ? it.id === selected : it.file === selectedFile); + const multiNs = usedNs.length > 1; + const item = (id: Id): Item => { + const c = m.c[id]; + return { id, text: c.parent === undefined ? c.name ?? "" : m.label(id), sub: c.parent !== undefined ? m.label(m.owner(id)) : multiNs ? m.prefix(c.ns) : undefined, kind: c.kind, title: `${m.qname(id) || m.label(id)} · ${KIND_LABEL[c.kind]}${m.fileName(id) ? "\n" + m.fileName(id) : ""}` }; + }; + const byNs = (ids: Id[]) => (nsFilter === undefined ? ids : ids.filter((id) => m.c[id].ns === nsFilter)); + + const render = () => { + const q = input.value.trim(); + kbd.hidden = q.length > 0; + if (q) { + const hits = byNs(m.search(q, 500)); + info.textContent = hits.length >= 500 ? "First 500 matches" : plural(hits.length, "match", "matches"); + list.set(hits.map(item), isSel); + return; + } + let ids: Id[]; + if (tab === "files") { + const files = m.b.files.map((f, i) => ({ f, i })).filter(({ f }) => nsFilter === undefined || f.namespace === nsFilter); + info.textContent = plural(files.length, "file"); + list.set(files.map(({ f, i }) => ({ file: i, text: f.name, sub: f.components ? String(f.components) : undefined, kind: "file", title: `${f.name}\n${f.targetNamespace ?? "no namespace"}` })), isSel); + return; + } + if (tab === "groups") ids = [...m.lists.group, ...m.lists.attributeGroup, ...m.lists.attribute]; + else ids = m.lists[tab]; + ids = byNs(ids); + info.textContent = `${plural(ids.length, tab === "groups" ? "group or attribute" : KIND_LABEL[tab].toLowerCase(), tab === "groups" ? "groups and attributes" : undefined)}${nsFilter !== undefined ? ` in ${m.prefix(nsFilter)}` : ""}`; + list.set(ids.map(item), isSel); + }; + nsSel.addEventListener("change", () => { + nsFilter = nsSel.value === "" ? undefined : Number(nsSel.value); + try { + localStorage.setItem("se.ns", nsSel.value); + } catch { + /* ignore */ + } + render(); + }); + input.addEventListener("input", debounce(render, 90)); + input.addEventListener("keydown", (ev) => { + if (ev.key === "ArrowDown" || ev.key === "ArrowUp") { + ev.preventDefault(); + list.move(ev.key === "ArrowDown" ? 1 : -1); + } else if (ev.key === "Enter") { + const it = list.activeItem(); + if (it?.id !== undefined) ctx.go(it.id); + } else if (ev.key === "Escape") { + input.value = ""; + render(); + input.blur(); + } + }); + render(); + + return { + el, + select(id, file) { + selected = id; + selectedFile = file; + if (!input.value.trim()) { + const kind = id !== undefined ? m.c[id].kind : undefined; + const want: Tab | undefined = file !== undefined ? "files" : id === undefined || m.c[id].parent !== undefined ? undefined : kind === "complexType" || kind === "simpleType" || kind === "element" ? kind : kind === "group" || kind === "attributeGroup" || kind === "attribute" ? "groups" : undefined; + if (want && want !== tab) setTab(want); + } + list.setSelected(isSel); + list.reveal(isSel); + }, + focusSearch() { + input.focus(); + input.select(); + }, + }; +} diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts index 41fad5b..3cc6009 100644 --- a/web/src/vite-env.d.ts +++ b/web/src/vite-env.d.ts @@ -1 +1,6 @@ +/// +declare module "*.css?inline" { + const css: string; + export default css; +} declare const __APP_VERSION__: string; diff --git a/web/src/wasm.worker.ts b/web/src/wasm.worker.ts new file mode 100644 index 0000000..f5fdb71 --- /dev/null +++ b/web/src/wasm.worker.ts @@ -0,0 +1,25 @@ +// Indexes XSD files with the Rust core compiled to WebAssembly, off the main thread. +import init, { diff, diff_report, expand, index } from "./wasm/schema_explorer_wasm.js"; +import wasmUrl from "./wasm/schema_explorer_wasm_bg.wasm?url"; + +type Req = + | { id: number; op: "index"; names: string[]; contents: Uint8Array[] } + | { id: number; op: "expand"; json: string } + | { id: number; op: "diff"; old: string; new: string; options: string } + | { id: number; op: "report"; json: string; format: string }; + +let ready: Promise | null = null; + +self.onmessage = async (ev: MessageEvent) => { + const req = ev.data; + try { + if (!ready) ready = init({ module_or_path: wasmUrl }); + await ready; + const started = performance.now(); + const json = + req.op === "index" ? index(req.names, req.contents, false) : req.op === "expand" ? expand(req.json) : req.op === "diff" ? diff(req.old, req.new, req.options) : diff_report(req.json, req.format); + (self as unknown as Worker).postMessage({ id: req.id, json, ms: performance.now() - started }); + } catch (e) { + (self as unknown as Worker).postMessage({ id: req.id, error: String(e) }); + } +};