From ebd419da43e65ca8573548a2a78268ee37057d95 Mon Sep 17 00:00:00 2001 From: tkcd Date: Sun, 5 Jul 2026 02:07:05 +0900 Subject: [PATCH 01/19] feat(layer3): Generative UI foundation (IR, generator, cache, web) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the Layer 3 core from AGENTS.md §10 as additive modules on the existing crate, without workspace restructuring or touching parser/analyzer/ model (owned by the in-flight Layer 1/2 worktrees). Rust core: - ir/: UiNode (12 core + 6 domain), SourceRange+LineIndex, registry allowlist, validate (schema + allowlist + sourceRange bounds + low-confidence flag) - generator/: Generator trait + GenInput, deterministic RulesGenerator (task lists→Checklist, tables→DataTable, mermaid→Diagram, config→ConfigViewer, GFM alerts→Callout), ClaudeGenerator scaffold behind feature="llm" (offline fallback to rules) - cache/: content-hash key (markdown + generator + schema version) + store - gui.rs: generate→validate→cache facade; `mdpeek gen ` CLI subcommand Web (Preact): - 2-layer component registry + Render dispatcher, all 18 node kinds, 3-pane layout (Outline/Content/Generated UI) with SourceRangeLink jump, hand-maintained ir.ts mirror. tsc + vite build clean. Tests: 18 unit + tests/gen_output.rs integration; clippy clean (default + llm). Deferred integration points (server /api/gui, ts-rs, #16 diff) documented in docs/layer3.md to avoid worktree interference. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01HXGWTTALewHoUjGfVkDwEV --- .gitignore | 5 + Cargo.lock | 757 +++++++++++++++++++++++- Cargo.toml | 10 + docs/layer3.md | 73 +++ src/cache/key.rs | 62 ++ src/cache/mod.rs | 11 + src/cache/store.rs | 169 ++++++ src/cli.rs | 17 + src/generator/llm/claude.rs | 108 ++++ src/generator/llm/mod.rs | 11 + src/generator/llm/prompt.rs | 75 +++ src/generator/mod.rs | 21 + src/generator/rules.rs | 465 +++++++++++++++ src/generator/traits.rs | 61 ++ src/gui.rs | 76 +++ src/ir/mod.rs | 18 + src/ir/node.rs | 472 +++++++++++++++ src/ir/range.rs | 116 ++++ src/ir/registry.rs | 57 ++ src/ir/validate.rs | 169 ++++++ src/main.rs | 22 + tests/gen_output.rs | 62 ++ web/index.html | 12 + web/package-lock.json | 1081 ++++++++++++++++++++++++++++++++++ web/package.json | 21 + web/public/gui.sample.json | 191 ++++++ web/src/components/index.tsx | 400 +++++++++++++ web/src/ir.ts | 150 +++++ web/src/layout/ThreePane.tsx | 84 +++ web/src/main.tsx | 40 ++ web/src/registry.tsx | 63 ++ web/src/styles.css | 121 ++++ web/tsconfig.json | 16 + web/vite.config.ts | 21 + 34 files changed, 5026 insertions(+), 11 deletions(-) create mode 100644 docs/layer3.md create mode 100644 src/cache/key.rs create mode 100644 src/cache/mod.rs create mode 100644 src/cache/store.rs create mode 100644 src/generator/llm/claude.rs create mode 100644 src/generator/llm/mod.rs create mode 100644 src/generator/llm/prompt.rs create mode 100644 src/generator/mod.rs create mode 100644 src/generator/rules.rs create mode 100644 src/generator/traits.rs create mode 100644 src/gui.rs create mode 100644 src/ir/mod.rs create mode 100644 src/ir/node.rs create mode 100644 src/ir/range.rs create mode 100644 src/ir/registry.rs create mode 100644 src/ir/validate.rs create mode 100644 tests/gen_output.rs create mode 100644 web/index.html create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/public/gui.sample.json create mode 100644 web/src/components/index.tsx create mode 100644 web/src/ir.ts create mode 100644 web/src/layout/ThreePane.tsx create mode 100644 web/src/main.tsx create mode 100644 web/src/registry.tsx create mode 100644 web/src/styles.css create mode 100644 web/tsconfig.json create mode 100644 web/vite.config.ts diff --git a/.gitignore b/.gitignore index cd30465..6cc9c2c 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,8 @@ .serena PLAN.md .claude +# Generative-UI cache (Layer 3, design §6): regenerated on demand. +.cache +# Web frontend (Layer 3): deps + build output are reproducible via `npm run build`. +web/node_modules +web/dist diff --git a/Cargo.lock b/Cargo.lock index 63e56f1..68fe384 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -214,6 +214,12 @@ dependencies = [ "serde", ] +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + [[package]] name = "bytes" version = "1.11.0" @@ -236,6 +242,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "clap" version = "4.5.53" @@ -342,6 +354,17 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "emojis" version = "0.6.4" @@ -530,6 +553,19 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -537,9 +573,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", "wasip2", + "wasm-bindgen", ] [[package]] @@ -618,6 +656,23 @@ dependencies = [ "pin-utils", "smallvec", "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", ] [[package]] @@ -626,14 +681,124 @@ version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" dependencies = [ + "base64", "bytes", + "futures-channel", "futures-core", + "futures-util", "http", "http-body", "hyper", + "ipnet", + "libc", + "percent-encoding", "pin-project-lite", + "socket2", "tokio", "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", ] [[package]] @@ -666,6 +831,12 @@ dependencies = [ "libc", ] +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -678,6 +849,17 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + [[package]] name = "kqueue" version = "1.1.1" @@ -716,12 +898,24 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + [[package]] name = "log" version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "markdown-peek" version = "0.0.0" @@ -740,7 +934,10 @@ dependencies = [ "pulldown-cmark", "pulldown-cmark-escape", "regex", + "reqwest", "serde", + "serde_json", + "sha2", "tempfile", "terminal_size", "thiserror", @@ -944,6 +1141,15 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1012,6 +1218,61 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.52.0", +] + [[package]] name = "quote" version = "1.0.42" @@ -1053,7 +1314,7 @@ version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" dependencies = [ - "getrandom", + "getrandom 0.3.4", ] [[package]] @@ -1085,6 +1346,64 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + [[package]] name = "rustix" version = "1.1.3" @@ -1098,6 +1417,47 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + [[package]] name = "ryu" version = "1.0.22" @@ -1199,6 +1559,17 @@ dependencies = [ "digest", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -1248,12 +1619,24 @@ dependencies = [ "windows-sys 0.60.2", ] +[[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 = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.112" @@ -1270,6 +1653,20 @@ name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "syntect" @@ -1296,7 +1693,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.3.4", "once_cell", "rustix", "windows-sys 0.61.2", @@ -1347,6 +1744,31 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.48.0" @@ -1373,6 +1795,16 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.17" @@ -1465,6 +1897,24 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.10.0", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + [[package]] name = "tower-layer" version = "0.3.3" @@ -1539,6 +1989,12 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "tungstenite" version = "0.28.0" @@ -1591,12 +2047,36 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "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 = "utf-8" version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +[[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" @@ -1634,6 +2114,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1649,6 +2138,90 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "winapi-util" version = "0.1.11" @@ -1664,13 +2237,22 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets", + "windows-targets 0.53.5", ] [[package]] @@ -1682,6 +2264,22 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + [[package]] name = "windows-targets" version = "0.53.5" @@ -1689,58 +2287,106 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ "windows-link", - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + [[package]] name = "windows_aarch64_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + [[package]] name = "windows_aarch64_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + [[package]] name = "windows_i686_gnu" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + [[package]] name = "windows_i686_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + [[package]] name = "windows_i686_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + [[package]] name = "windows_x86_64_gnu" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + [[package]] name = "windows_x86_64_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "windows_x86_64_msvc" version = "0.53.1" @@ -1759,6 +2405,35 @@ version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.8.31" @@ -1779,6 +2454,66 @@ dependencies = [ "syn", ] +[[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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zmij" version = "1.0.5" diff --git a/Cargo.toml b/Cargo.toml index 0501f34..2120354 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,16 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } walkdir = "2.5" serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +# LLM (Claude) generator backend, opt-in to keep the default build offline. +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"], optional = true } + +[features] +default = [] +# Enables the Claude-backed UI IR generator (generator::llm). Requires network +# + ANTHROPIC_API_KEY at runtime; falls back to RulesGenerator when unset. +llm = ["dep:reqwest"] [[bin]] name = "mdpeek" diff --git a/docs/layer3.md b/docs/layer3.md new file mode 100644 index 0000000..aad0575 --- /dev/null +++ b/docs/layer3.md @@ -0,0 +1,73 @@ +# Layer 3 — Generated UI (implementation notes) + +This document tracks the Layer 3 implementation (design: [`AGENTS.md`](../AGENTS.md) +§10 "Layer 3 — Generated UI"). It records what shipped, the deliberate scope +boundaries chosen to avoid colliding with the in-flight Layer 1 / Layer 2 +worktrees, and what remains. + +## What shipped + +Layer 3 is built as **additive modules** on the existing single-binary crate — +no workspace restructuring (per 論点 B, that happens with Layer 2) and no edits +to the `parser` / `analyzer` / `model` areas that Layer 1 / 2 own. + +| Design section | Module | Status | +|---|---|---| +| §4.1 UI IR (source of truth) | `src/ir/node.rs` | ✅ all 12 core + 6 domain nodes, `NodeMeta` flattened, `Quantity`/`Visibility`/`Origin` | +| §1 sourceRange | `src/ir/range.rs` | ✅ `SourceRange` + `LineIndex` (byte offset → line/col) | +| §3.5 / §8 allowlist | `src/ir/registry.rs` | ✅ 2-layer allowlist (core + domain) | +| §3.5 validation | `src/ir/validate.rs` | ✅ schema (serde) + allowlist + sourceRange bounds + low-confidence flagging | +| §3.4 generator | `src/generator/rules.rs` | ✅ `RulesGenerator`: task lists→`Checklist`, tables→`DataTable`, mermaid→`Diagram`, json/yaml/toml/env→`ConfigViewer`, GFM alerts→`Callout` | +| §7 LLM | `src/generator/llm/` (`feature = "llm"`) | ✅ `ClaudeGenerator` + prompt; offline fallback to rules; **not yet driven** (see below) | +| §6 cache | `src/cache/` | ✅ content-hash key (markdown + generator + schema version) + `.cache/mdpeek/*.gui.json` store | +| §1 pipeline | `src/gui.rs` | ✅ generate → validate → cache facade | +| CLI | `mdpeek gen ` | ✅ emits validated IR JSON; `--no-cache` | +| §5.1 web registry | `web/src/registry.tsx` | ✅ 2-layer registry + `Render` dispatcher | +| §5.1 components | `web/src/components/` | ✅ all 18 node kinds | +| §5.3 layout | `web/src/layout/ThreePane.tsx` | ✅ Outline / Content / Generated UI, SourceRangeLink jump | +| §4.1 TS types | `web/src/ir.ts` | ✅ hand-maintained mirror of Rust IR | + +Tests: `cargo test` (18 Layer-3 unit tests + `tests/gen_output.rs` integration) +and `cd web && npm run build` (tsc + vite) both pass. + +## Deliberately deferred (to avoid worktree interference) + +These Layer 3 items touch files owned by other in-flight worktrees, or depend on +their outputs, so they are left as clean integration points: + +- **Server `/api/gui` route + Preact island mount** (論点 A). Wiring the + Generated UI pane into the live server edits `src/server.rs` and the static + HTML — shared with Layer 1 (#12/#16). `web/dist` embedding via `include_bytes!` + (論点 C) waits on that. +- **Layer 2 `DocumentModel` / `planner`.** `generator::traits::GenInput` is a + lightweight stand-in (raw markdown + `DocType` hint). When Layer 2 lands, + `GenInput` becomes a thin adapter over `DocumentModel` — the `Generator` + contract (`-> Vec`) and everything downstream are unchanged. +- **`ts-rs` auto-generation of `ir.ts`.** Needs the workspace split; `ir.ts` is + hand-kept in lockstep meanwhile. +- **#16 live diff regeneration.** Depends on the watcher channelization from + Layer 1. + +## Usage + +```sh +# Deterministic, offline IR generation: +mdpeek gen README.md # prints validated UI IR JSON, caches under .cache/mdpeek/ +mdpeek gen README.md --no-cache # always regenerate + +# LLM-backed generation (opt-in; falls back to rules if ANTHROPIC_API_KEY unset): +cargo build --features llm + +# Web frontend (Generated UI island): +cd web && npm install && npm run dev # dev harness with a bundled fixture +cd web && npm run build # → web/dist (embedded by the server later) +``` + +## Security invariants (design §8) + +- LLM output is **UI IR only** — enforced structurally by serde types + the + registry allowlist + sourceRange verification in `ir::validate`. An LLM cannot + introduce a component outside the registry or a fabricated range. +- Renderers select from a **fixed registry**; unknown `kind` renders nothing. +- No `dangerouslySetInnerHTML`; code/config render as escaped `
` text.
+- Low-confidence / LLM-origin nodes are badged in the UI (judgement stays human).
diff --git a/src/cache/key.rs b/src/cache/key.rs
new file mode 100644
index 0000000..67e5da1
--- /dev/null
+++ b/src/cache/key.rs
@@ -0,0 +1,62 @@
+//! Cache key derivation (design doc §6).
+//!
+//! Key = `hash(normalized markdown) + generator id + schema version`. Any change
+//! to the document body, the generator, or the IR schema misses the cache and
+//! forces regeneration.
+
+use sha2::{Digest, Sha256};
+
+/// Bump when `ir::node` types change shape — invalidates all cached entries.
+pub const SCHEMA_VERSION: u32 = 1;
+
+/// Normalize markdown before hashing so cosmetic churn (CRLF, trailing spaces)
+/// doesn't needlessly bust the cache.
+fn normalize(markdown: &str) -> String {
+    markdown
+        .replace("\r\n", "\n")
+        .lines()
+        .map(|l| l.trim_end())
+        .collect::>()
+        .join("\n")
+}
+
+/// Content hash used as the cache filename stem and stored in the entry.
+pub fn content_hash(markdown: &str, model_id: &str) -> String {
+    let mut hasher = Sha256::new();
+    hasher.update(normalize(markdown).as_bytes());
+    hasher.update([0u8]); // domain separator
+    hasher.update(model_id.as_bytes());
+    hasher.update([0u8]);
+    hasher.update(SCHEMA_VERSION.to_le_bytes());
+    let digest = hasher.finalize();
+    // 32 hex chars is plenty to avoid collisions for a local cache.
+    hex16(&digest)
+}
+
+fn hex16(bytes: &[u8]) -> String {
+    let mut s = String::with_capacity(32);
+    for b in bytes.iter().take(16) {
+        s.push_str(&format!("{b:02x}"));
+    }
+    s
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn stable_and_normalized() {
+        let a = content_hash("# Hi\n\n- [ ] x\n", "rules");
+        let b = content_hash("# Hi  \r\n\r\n- [ ] x  \r\n", "rules");
+        assert_eq!(a, b, "CRLF/trailing space must normalize equal");
+        assert_eq!(a.len(), 32);
+    }
+
+    #[test]
+    fn model_and_schema_affect_key() {
+        let a = content_hash("# Hi\n", "rules");
+        let b = content_hash("# Hi\n", "claude-x");
+        assert_ne!(a, b);
+    }
+}
diff --git a/src/cache/mod.rs b/src/cache/mod.rs
new file mode 100644
index 0000000..95b49c2
--- /dev/null
+++ b/src/cache/mod.rs
@@ -0,0 +1,11 @@
+//! Generated-UI cache (design doc §6).
+//!
+//! - [`key`]   — content hash = `hash(markdown) + generator + schema version`.
+//! - [`store`] — read/write `GuiCacheEntry` under `.cache/mdpeek/`.
+
+pub mod key;
+pub mod store;
+
+#[allow(unused_imports)]
+pub use key::{SCHEMA_VERSION, content_hash};
+pub use store::{CacheStore, GuiCacheEntry};
diff --git a/src/cache/store.rs b/src/cache/store.rs
new file mode 100644
index 0000000..cae2cc0
--- /dev/null
+++ b/src/cache/store.rs
@@ -0,0 +1,169 @@
+//! Generated-UI cache store (design doc §6): `.cache/mdpeek/.gui.json`.
+//!
+//! The store is a thin content-addressed layer: key by [`content_hash`], write
+//! a [`GuiCacheEntry`] as JSON, read it back on a hit. Invalidation is implicit
+//! — a changed document / generator / schema produces a different key (see
+//! `key.rs`), so stale entries are simply never looked up again.
+
+use std::path::{Path, PathBuf};
+
+use anyhow::{Context, Result};
+use serde::{Deserialize, Serialize};
+
+use crate::ir::{SourceRange, UiNode};
+
+use super::key::{SCHEMA_VERSION, content_hash};
+
+/// A cached generation result (design §4.3). `block_classification` from the
+/// design lives in Layer 2's `model`; it's added here once that lands.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct GuiCacheEntry {
+    /// Document type as classified upstream (`"generic"` until Layer 2 lands).
+    pub document_type: String,
+    pub ui_ir: Vec,
+    /// Flattened list of every verified source range, for quick lookup.
+    pub source_ranges: Vec,
+    /// Overall confidence (min of node confidences; 1.0 for pure rules).
+    pub confidence: f32,
+    /// Generator id: `"rules"` | `"claude-…"`.
+    pub model: String,
+    /// Unix epoch seconds when generated. (RFC3339 formatting deferred to avoid
+    /// a date dependency; this is an internal cache field.)
+    pub generated_at: u64,
+    pub content_hash: String,
+    pub schema_version: u32,
+}
+
+impl GuiCacheEntry {
+    pub fn new(document_type: String, ui_ir: Vec, model: String, content_hash: String) -> Self {
+        let source_ranges = collect_ranges(&ui_ir);
+        let confidence = overall_confidence(&ui_ir);
+        GuiCacheEntry {
+            document_type,
+            ui_ir,
+            source_ranges,
+            confidence,
+            model,
+            generated_at: now_unix(),
+            content_hash,
+            schema_version: SCHEMA_VERSION,
+        }
+    }
+}
+
+fn now_unix() -> u64 {
+    std::time::SystemTime::now()
+        .duration_since(std::time::UNIX_EPOCH)
+        .map(|d| d.as_secs())
+        .unwrap_or(0)
+}
+
+fn collect_ranges(nodes: &[UiNode]) -> Vec {
+    let mut out = Vec::new();
+    for n in nodes {
+        if let Some(r) = n.meta().source_range {
+            out.push(r);
+        }
+        if let UiNode::Tabs(t) = n {
+            for tab in &t.tabs {
+                out.extend(collect_ranges(&tab.children));
+            }
+        }
+    }
+    out
+}
+
+fn overall_confidence(nodes: &[UiNode]) -> f32 {
+    nodes
+        .iter()
+        .filter_map(|n| n.meta().confidence)
+        .fold(1.0f32, f32::min)
+}
+
+/// On-disk cache rooted at `/.cache/mdpeek`.
+pub struct CacheStore {
+    dir: PathBuf,
+}
+
+impl CacheStore {
+    /// Create a store under `root` (typically the current working directory).
+    pub fn new(root: impl AsRef) -> Self {
+        CacheStore {
+            dir: root.as_ref().join(".cache").join("mdpeek"),
+        }
+    }
+
+    fn path_for(&self, hash: &str) -> PathBuf {
+        self.dir.join(format!("{hash}.gui.json"))
+    }
+
+    /// Look up a cached entry for `markdown` generated by `model_id`.
+    pub fn get(&self, markdown: &str, model_id: &str) -> Option {
+        let hash = content_hash(markdown, model_id);
+        let bytes = std::fs::read(self.path_for(&hash)).ok()?;
+        let entry: GuiCacheEntry = serde_json::from_slice(&bytes).ok()?;
+        // Guard against a schema bump slipping through (belt-and-braces; the key
+        // already encodes SCHEMA_VERSION).
+        if entry.schema_version == SCHEMA_VERSION {
+            Some(entry)
+        } else {
+            None
+        }
+    }
+
+    /// Persist `entry`, returning its content hash / filename stem.
+    pub fn put(&self, entry: &GuiCacheEntry) -> Result {
+        std::fs::create_dir_all(&self.dir)
+            .with_context(|| format!("creating cache dir {}", self.dir.display()))?;
+        let path = self.path_for(&entry.content_hash);
+        let json = serde_json::to_vec_pretty(entry).context("serializing cache entry")?;
+        std::fs::write(&path, json).with_context(|| format!("writing {}", path.display()))?;
+        Ok(entry.content_hash.clone())
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::ir::node::*;
+
+    fn sample_nodes() -> Vec {
+        vec![UiNode::Callout(CalloutNode {
+            meta: NodeMeta {
+                source_range: Some(SourceRange {
+                    start_line: 1,
+                    start_column: 1,
+                    end_line: 2,
+                    end_column: 1,
+                }),
+                confidence: Some(0.9),
+                ..Default::default()
+            },
+            severity: Severity::Info,
+            title: None,
+            body: "hi".into(),
+        })]
+    }
+
+    #[test]
+    fn roundtrip_put_get() {
+        let tmp = tempfile::tempdir().unwrap();
+        let store = CacheStore::new(tmp.path());
+        let md = "# Doc\n\ncontent\n";
+        let hash = content_hash(md, "rules");
+        let entry = GuiCacheEntry::new("generic".into(), sample_nodes(), "rules".into(), hash);
+        store.put(&entry).unwrap();
+
+        let got = store.get(md, "rules").expect("cache hit");
+        assert_eq!(got.ui_ir.len(), 1);
+        assert_eq!(got.source_ranges.len(), 1);
+        assert!((got.confidence - 0.9).abs() < 1e-6);
+    }
+
+    #[test]
+    fn miss_on_different_content() {
+        let tmp = tempfile::tempdir().unwrap();
+        let store = CacheStore::new(tmp.path());
+        assert!(store.get("nothing cached", "rules").is_none());
+    }
+}
diff --git a/src/cli.rs b/src/cli.rs
index 8952c07..6b04ec0 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -34,6 +34,17 @@ pub enum Commands {
     Serve(ServeArg),
     /// Display pretty rendered markdown on your terminal
     Term(TermArg),
+    /// Generate Generative-UI IR (JSON) from a markdown file (Layer 3)
+    Gen(GenArg),
+}
+
+#[derive(Debug, Args)]
+pub struct GenArg {
+    #[arg(value_name = "FILE")]
+    pub file: Option,
+    /// Skip the on-disk cache and always regenerate.
+    #[arg(long)]
+    pub no_cache: bool,
 }
 
 // Subcommand arguments are optional so that an unset flag can fall back to
@@ -85,6 +96,8 @@ pub enum Mode {
         /// disables paging, `Some(cmd)` runs `cmd`.
         pager: Option,
     },
+    /// Generate Generative-UI IR JSON (Layer 3) and print it to stdout.
+    Gen { file: PathBuf, no_cache: bool },
 }
 
 impl Cli {
@@ -131,6 +144,10 @@ impl Cli {
                 theme: arg.theme.or(config.term.theme).unwrap_or(ThemeChoice::Glow),
                 pager,
             }),
+            Some(Commands::Gen(arg)) => Ok(Mode::Gen {
+                file: arg.file.unwrap_or_else(|| PathBuf::from(DEFAULT_ROOT)),
+                no_cache: arg.no_cache,
+            }),
             None => {
                 let root = self.root.unwrap_or_else(|| PathBuf::from(DEFAULT_ROOT));
                 let host = self
diff --git a/src/generator/llm/claude.rs b/src/generator/llm/claude.rs
new file mode 100644
index 0000000..faaa716
--- /dev/null
+++ b/src/generator/llm/claude.rs
@@ -0,0 +1,108 @@
+//! Anthropic Claude adapter (design doc §7), behind `feature = "llm"`.
+//!
+//! Contract: send the document + schema constraints, receive **UI IR JSON
+//! only**, then run it through [`crate::ir::validate_json`] (schema + registry
+//! allowlist + sourceRange bounds). Anything that fails validation is dropped —
+//! an LLM can never introduce a component outside the registry or a fabricated
+//! range.
+//!
+//! Offline-safe: when `ANTHROPIC_API_KEY` is unset, [`ClaudeGenerator`] falls
+//! back to `RulesGenerator` so the default experience never depends on network
+//! or credentials (design §7 "未設定なら自動で rules-only にフォールバック").
+
+use anyhow::{Context, Result};
+
+use crate::generator::rules::RulesGenerator;
+use crate::generator::traits::{GenInput, Generator};
+use crate::ir::{LineIndex, UiNode, validate_json};
+
+use super::prompt;
+
+const API_URL: &str = "https://api.anthropic.com/v1/messages";
+const API_VERSION: &str = "2023-06-01";
+/// Overridable via `MDPEEK_LLM_MODEL`; defaults to a current Claude model.
+const DEFAULT_MODEL: &str = "claude-sonnet-5";
+
+pub struct ClaudeGenerator {
+    model: String,
+    /// Node kinds the planner wants the LLM to fill. Empty = model's discretion.
+    requested_kinds: Vec,
+}
+
+impl Default for ClaudeGenerator {
+    fn default() -> Self {
+        ClaudeGenerator {
+            model: std::env::var("MDPEEK_LLM_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.to_string()),
+            requested_kinds: Vec::new(),
+        }
+    }
+}
+
+impl ClaudeGenerator {
+    pub fn with_requested_kinds(mut self, kinds: Vec) -> Self {
+        self.requested_kinds = kinds;
+        self
+    }
+
+    /// Generate UI IR via Claude, validating the result. Falls back to rules on
+    /// missing key. Async because the server drives it inside tokio (design §7).
+    pub async fn generate_async(&self, input: &GenInput<'_>) -> Result> {
+        let Ok(api_key) = std::env::var("ANTHROPIC_API_KEY") else {
+            // Offline fallback: deterministic rules output.
+            return RulesGenerator.generate(input);
+        };
+
+        let total_lines = LineIndex::new(input.markdown).line_count();
+        let asks: Vec<&str> = self.requested_kinds.iter().map(String::as_str).collect();
+
+        let body = serde_json::json!({
+            "model": self.model,
+            "max_tokens": 4096,
+            "system": prompt::system_prompt(),
+            "messages": [{
+                "role": "user",
+                "content": prompt::user_prompt(input.markdown, &asks),
+            }],
+        });
+
+        let client = reqwest::Client::new();
+        let resp = client
+            .post(API_URL)
+            .header("x-api-key", api_key)
+            .header("anthropic-version", API_VERSION)
+            .header("content-type", "application/json")
+            .json(&body)
+            .send()
+            .await
+            .context("Claude request failed")?
+            .error_for_status()
+            .context("Claude returned an error status")?;
+
+        let json: serde_json::Value = resp.json().await.context("invalid Claude response")?;
+        let text = json["content"][0]["text"]
+            .as_str()
+            .context("Claude response missing content text")?;
+
+        let cleaned = prompt::strip_code_fence(text);
+        // The security boundary: schema + allowlist + range verification.
+        let nodes = validate_json(cleaned, total_lines).context("LLM output failed validation")?;
+        Ok(nodes)
+    }
+}
+
+/// Blocking `Generator` impl so `ClaudeGenerator` can be used from sync call
+/// sites; it just fronts [`ClaudeGenerator::generate_async`] on a scoped
+/// runtime. Server code should prefer `generate_async` directly.
+impl Generator for ClaudeGenerator {
+    fn generate(&self, input: &GenInput<'_>) -> Result> {
+        let rt = tokio::runtime::Builder::new_current_thread()
+            .enable_all()
+            .build()
+            .context("failed to build runtime for ClaudeGenerator")?;
+        rt.block_on(self.generate_async(input))
+    }
+
+    fn model_id(&self) -> String {
+        format!("claude-{}", self.model)
+    }
+}
diff --git a/src/generator/llm/mod.rs b/src/generator/llm/mod.rs
new file mode 100644
index 0000000..8f85452
--- /dev/null
+++ b/src/generator/llm/mod.rs
@@ -0,0 +1,11 @@
+//! LLM-backed generation (`feature = "llm"`), design doc §7.
+//!
+//! Only nodes that rules can't produce are delegated here, and every result is
+//! re-validated by `ir::validate_json` before use. Falls back to rules when no
+//! API key is configured.
+
+pub mod claude;
+pub mod prompt;
+
+#[allow(unused_imports)]
+pub use claude::ClaudeGenerator;
diff --git a/src/generator/llm/prompt.rs b/src/generator/llm/prompt.rs
new file mode 100644
index 0000000..3f58e6b
--- /dev/null
+++ b/src/generator/llm/prompt.rs
@@ -0,0 +1,75 @@
+//! Prompt construction for the Claude generator (design doc §7).
+//!
+//! The contract is strict: the model may return **only UI IR JSON** — an array
+//! of nodes whose `kind` is in the registry allowlist — and every node must
+//! carry a `sourceRange` into the original document. No prose, no HTML, no code.
+//! Validation (`ir::validate_json`) enforces this after the fact; the prompt
+//! just makes compliance likely.
+
+use crate::ir::registry;
+
+/// System prompt: role, hard constraints, and the allowed component list.
+pub fn system_prompt() -> String {
+    let kinds = registry::all_kinds().collect::>().join(", ");
+    format!(
+        "You convert a Markdown document into a Generative UI intermediate \
+representation (UI IR). Output ONLY a JSON array of UI nodes and nothing else — \
+no explanation, no markdown fences, no prose.\n\n\
+Hard rules:\n\
+1. Each node MUST have a `kind` field, and `kind` MUST be one of: {kinds}.\n\
+2. Never invent components outside that list.\n\
+3. Every node MUST include a `sourceRange` object {{startLine, startColumn, \
+endLine, endColumn}} (1-based) pointing at the exact lines it summarises. Do \
+NOT fabricate ranges — they are verified against the source and rejected if out \
+of bounds.\n\
+4. Only add nodes that require interpretation (risk extraction, decision \
+graphs, section classification). Do not restate tables/checklists that simple \
+rules already handle.\n\
+5. Set `origin` to \"llm\" and `confidence` (0.0-1.0) on every node you emit."
+    )
+}
+
+/// User prompt: the document (with line numbers) plus the node kinds the planner
+/// asked us to fill.
+pub fn user_prompt(markdown: &str, requested_kinds: &[&str]) -> String {
+    let numbered = markdown
+        .lines()
+        .enumerate()
+        .map(|(i, l)| format!("{:>4}  {l}", i + 1))
+        .collect::>()
+        .join("\n");
+    let asks = if requested_kinds.is_empty() {
+        "any interpretive nodes that help the reader".to_string()
+    } else {
+        requested_kinds.join(", ")
+    };
+    format!(
+        "Produce UI IR nodes of these kinds where the document supports them: \
+{asks}.\n\nDocument (line-numbered):\n---\n{numbered}\n---"
+    )
+}
+
+/// Strip an accidental ```json … ``` fence the model may wrap the array in.
+pub fn strip_code_fence(text: &str) -> &str {
+    let t = text.trim();
+    let t = t.strip_prefix("```json").or_else(|| t.strip_prefix("```")).unwrap_or(t);
+    t.trim().strip_suffix("```").unwrap_or(t).trim()
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn strips_fence() {
+        assert_eq!(strip_code_fence("```json\n[]\n```"), "[]");
+        assert_eq!(strip_code_fence("[]"), "[]");
+    }
+
+    #[test]
+    fn system_prompt_lists_allowed_kinds() {
+        let s = system_prompt();
+        assert!(s.contains("Checklist"));
+        assert!(s.contains("ObligationMatrix"));
+    }
+}
diff --git a/src/generator/mod.rs b/src/generator/mod.rs
new file mode 100644
index 0000000..6cb5d8f
--- /dev/null
+++ b/src/generator/mod.rs
@@ -0,0 +1,21 @@
+//! Generator layer (design doc §3.4 / §7): UI plan + document → UI IR.
+//!
+//! - [`rules`] — `RulesGenerator`, the deterministic offline default.
+//! - [`llm`]   — `ClaudeGenerator` (`feature = "llm"`), fills only what rules
+//!   can't and falls back to rules when `ANTHROPIC_API_KEY` is unset.
+//!
+//! The [`traits`] module defines the `Generator` contract and the lightweight
+//! [`traits::GenInput`] stand-in for Layer 2's `DocumentModel`.
+
+pub mod rules;
+pub mod traits;
+
+// Scaffolding for the deferred server integration (design §7): constructed once
+// `/api/gui` drives it. `allow(dead_code)` until that wiring lands.
+#[cfg(feature = "llm")]
+#[allow(dead_code)]
+pub mod llm;
+
+pub use rules::RulesGenerator;
+#[allow(unused_imports)]
+pub use traits::{DocType, GenInput, Generator};
diff --git a/src/generator/rules.rs b/src/generator/rules.rs
new file mode 100644
index 0000000..575e6c6
--- /dev/null
+++ b/src/generator/rules.rs
@@ -0,0 +1,465 @@
+//! `RulesGenerator` — the offline, deterministic default (design doc §3.4).
+//!
+//! Walks the `pulldown-cmark` event stream (`into_offset_iter`, so every block
+//! carries a byte range) and extracts the UI nodes that can be produced *without
+//! an LLM*: task lists → `Checklist`, tables → `DataTable`, mermaid fences →
+//! `Diagram`, config fences (json/yaml/toml/env) → `ConfigViewer`, and GFM alert
+//! blockquotes → `Callout`. Each node is anchored to its `sourceRange`.
+//!
+//! Anything requiring judgement (risk extraction, doctype-specific layout, prose
+//! summarisation) is deliberately *not* done here — that is the LLM generator's
+//! job (`feature = "llm"`). Rules first keeps the default build offline and
+//! reproducible (design §0 "rules 優先").
+
+use anyhow::Result;
+use pulldown_cmark::{
+    BlockQuoteKind, CodeBlockKind, Event, Parser, Tag, TagEnd,
+};
+
+use crate::gfm::parser_options;
+use crate::ir::node::*;
+use crate::ir::range::{LineIndex, SourceRange};
+
+use super::traits::{GenInput, Generator};
+
+/// Deterministic, offline UI IR generator.
+#[derive(Debug, Default, Clone, Copy)]
+pub struct RulesGenerator;
+
+impl Generator for RulesGenerator {
+    fn generate(&self, input: &GenInput<'_>) -> Result> {
+        Ok(extract(input.markdown))
+    }
+
+    fn model_id(&self) -> String {
+        "rules".to_string()
+    }
+}
+
+/// Which buffer a `Text`/`Code` event should be routed to, innermost first.
+fn extract(markdown: &str) -> Vec {
+    let line_index = LineIndex::new(markdown);
+    let mut out: Vec = Vec::new();
+
+    // Accumulators shared across the single pass.
+    let mut heading_buf = String::new();
+    let mut in_heading = false;
+    let mut last_heading: Option = None;
+
+    // Code block.
+    let mut in_code = false;
+    let mut code_lang = String::new();
+    let mut code_buf = String::new();
+    let mut code_range: Option = None;
+
+    // Task-list checklist (one node for the whole document).
+    let mut checklist: Vec = Vec::new();
+    let mut item_stack: Vec = Vec::new();
+
+    // Table.
+    let mut table: Option = None;
+
+    // Blockquote alert (GFM `> [!WARNING]`).
+    let mut alert: Option = None;
+
+    for (ev, span) in Parser::new_ext(markdown, parser_options()).into_offset_iter() {
+        match ev {
+            Event::Start(Tag::Heading { .. }) => {
+                in_heading = true;
+                heading_buf.clear();
+            }
+            Event::End(TagEnd::Heading(_)) => {
+                in_heading = false;
+                let h = heading_buf.trim().to_string();
+                if !h.is_empty() {
+                    last_heading = Some(h);
+                }
+            }
+
+            Event::Start(Tag::CodeBlock(kind)) => {
+                in_code = true;
+                code_buf.clear();
+                code_range = Some(line_index.range(span.clone()));
+                code_lang = match kind {
+                    CodeBlockKind::Fenced(lang) => lang.split_whitespace().next().unwrap_or("").to_string(),
+                    CodeBlockKind::Indented => String::new(),
+                };
+            }
+            Event::End(TagEnd::CodeBlock) => {
+                in_code = false;
+                if let Some(node) = code_block_node(&code_lang, &code_buf, code_range) {
+                    out.push(node);
+                }
+                code_buf.clear();
+            }
+
+            Event::Start(Tag::Item) => {
+                item_stack.push(ItemState {
+                    is_task: false,
+                    checked: false,
+                    text: String::new(),
+                    range: line_index.range(span.clone()),
+                });
+            }
+            Event::TaskListMarker(checked) => {
+                if let Some(item) = item_stack.last_mut() {
+                    item.is_task = true;
+                    item.checked = checked;
+                }
+            }
+            Event::End(TagEnd::Item) => {
+                if let Some(item) = item_stack.pop()
+                    && item.is_task
+                {
+                    let title = item.text.trim().to_string();
+                    if !title.is_empty() {
+                        checklist.push(ChecklistItem {
+                            title,
+                            checked: item.checked,
+                            category: last_heading.clone(),
+                            source_range: Some(item.range),
+                        });
+                    }
+                }
+            }
+
+            Event::Start(Tag::Table(_)) => {
+                table = Some(TableState::new(line_index.range(span.clone())));
+            }
+            Event::Start(Tag::TableHead) => {
+                if let Some(t) = table.as_mut() {
+                    t.in_head = true;
+                }
+            }
+            Event::End(TagEnd::TableHead) => {
+                if let Some(t) = table.as_mut() {
+                    t.in_head = false;
+                }
+            }
+            Event::Start(Tag::TableRow) => {
+                if let Some(t) = table.as_mut() {
+                    t.current_row.clear();
+                }
+            }
+            Event::End(TagEnd::TableRow) => {
+                if let Some(t) = table.as_mut()
+                    && !t.in_head
+                {
+                    let row = std::mem::take(&mut t.current_row);
+                    t.rows.push(row);
+                }
+            }
+            Event::Start(Tag::TableCell) => {
+                if let Some(t) = table.as_mut() {
+                    t.cell_buf.clear();
+                    t.in_cell = true;
+                }
+            }
+            Event::End(TagEnd::TableCell) => {
+                if let Some(t) = table.as_mut() {
+                    t.in_cell = false;
+                    let cell = t.cell_buf.trim().to_string();
+                    if t.in_head {
+                        t.headers.push(cell);
+                    } else {
+                        t.current_row.push(cell);
+                    }
+                }
+            }
+            Event::End(TagEnd::Table) => {
+                if let Some(t) = table.take()
+                    && let Some(node) = t.into_node()
+                {
+                    out.push(node);
+                }
+            }
+
+            Event::Start(Tag::BlockQuote(Some(kind))) => {
+                alert = Some(AlertState {
+                    severity: alert_severity(kind),
+                    title: alert_title(kind).to_string(),
+                    body: String::new(),
+                    range: line_index.range(span.clone()),
+                });
+            }
+            Event::End(TagEnd::BlockQuote(_)) => {
+                if let Some(a) = alert.take() {
+                    let body = a.body.trim().to_string();
+                    out.push(UiNode::Callout(CalloutNode {
+                        meta: NodeMeta {
+                            source_range: Some(a.range),
+                            ..Default::default()
+                        },
+                        severity: a.severity,
+                        title: Some(a.title),
+                        body,
+                    }));
+                }
+            }
+
+            Event::Text(text) | Event::Code(text) => {
+                // Route to the innermost active collector.
+                if in_code {
+                    code_buf.push_str(&text);
+                } else if let Some(t) = table.as_mut().filter(|t| t.in_cell) {
+                    t.cell_buf.push_str(&text);
+                } else if in_heading {
+                    heading_buf.push_str(&text);
+                } else if let Some(a) = alert.as_mut() {
+                    a.body.push_str(&text);
+                } else if let Some(item) = item_stack.last_mut() {
+                    item.text.push_str(&text);
+                }
+            }
+
+            _ => {}
+        }
+    }
+
+    // Emit the aggregated checklist (if any tasks were found), spanning all items.
+    if !checklist.is_empty() {
+        let range = checklist_span(&checklist);
+        out.insert(
+            checklist_insert_pos(&out),
+            UiNode::Checklist(ChecklistNode {
+                meta: NodeMeta {
+                    source_range: range,
+                    ..Default::default()
+                },
+                items: checklist,
+            }),
+        );
+    }
+
+    out
+}
+
+/// Keep the checklist near the top but after any leading node — simple and
+/// deterministic. (Design leaves ordering to the planner; rules picks front.)
+fn checklist_insert_pos(_out: &[UiNode]) -> usize {
+    0
+}
+
+fn checklist_span(items: &[ChecklistItem]) -> Option {
+    let ranges: Vec = items.iter().filter_map(|i| i.source_range).collect();
+    let first = ranges.first()?;
+    let last = ranges.last()?;
+    Some(SourceRange {
+        start_line: first.start_line,
+        start_column: first.start_column,
+        end_line: last.end_line,
+        end_column: last.end_column,
+    })
+}
+
+fn code_block_node(lang: &str, code: &str, range: Option) -> Option {
+    let meta = NodeMeta {
+        source_range: range,
+        ..Default::default()
+    };
+    let trimmed = code.trim_end_matches('\n').to_string();
+    match lang.to_ascii_lowercase().as_str() {
+        "mermaid" => Some(UiNode::Diagram(DiagramNode {
+            meta,
+            format: DiagramFormat::Mermaid,
+            code: trimmed,
+            title: None,
+        })),
+        "json" => cfg(meta, ConfigFormat::Json, trimmed),
+        "yaml" | "yml" => cfg(meta, ConfigFormat::Yaml, trimmed),
+        "toml" => cfg(meta, ConfigFormat::Toml, trimmed),
+        "env" | "dotenv" => cfg(meta, ConfigFormat::Env, trimmed),
+        _ => None,
+    }
+}
+
+fn cfg(meta: NodeMeta, format: ConfigFormat, content: String) -> Option {
+    Some(UiNode::ConfigViewer(ConfigViewerNode {
+        meta,
+        format,
+        content,
+        title: None,
+    }))
+}
+
+fn alert_severity(kind: BlockQuoteKind) -> Severity {
+    match kind {
+        BlockQuoteKind::Warning | BlockQuoteKind::Caution => Severity::Warning,
+        BlockQuoteKind::Important => Severity::Error,
+        BlockQuoteKind::Note | BlockQuoteKind::Tip => Severity::Info,
+    }
+}
+
+fn alert_title(kind: BlockQuoteKind) -> &'static str {
+    match kind {
+        BlockQuoteKind::Note => "Note",
+        BlockQuoteKind::Tip => "Tip",
+        BlockQuoteKind::Important => "Important",
+        BlockQuoteKind::Warning => "Warning",
+        BlockQuoteKind::Caution => "Caution",
+    }
+}
+
+struct ItemState {
+    is_task: bool,
+    checked: bool,
+    text: String,
+    range: SourceRange,
+}
+
+struct AlertState {
+    severity: Severity,
+    title: String,
+    body: String,
+    range: SourceRange,
+}
+
+struct TableState {
+    range: SourceRange,
+    in_head: bool,
+    in_cell: bool,
+    cell_buf: String,
+    headers: Vec,
+    current_row: Vec,
+    rows: Vec>,
+}
+
+impl TableState {
+    fn new(range: SourceRange) -> Self {
+        TableState {
+            range,
+            in_head: false,
+            in_cell: false,
+            cell_buf: String::new(),
+            headers: Vec::new(),
+            current_row: Vec::new(),
+            rows: Vec::new(),
+        }
+    }
+
+    fn into_node(self) -> Option {
+        if self.headers.is_empty() {
+            return None;
+        }
+        let columns: Vec = self
+            .headers
+            .iter()
+            .enumerate()
+            .map(|(i, label)| Column {
+                key: column_key(label, i),
+                label: label.clone(),
+                col_type: None,
+            })
+            .collect();
+        let rows = self
+            .rows
+            .iter()
+            .map(|cells| {
+                let mut map = serde_json::Map::new();
+                for (col, cell) in columns.iter().zip(cells.iter()) {
+                    map.insert(col.key.clone(), serde_json::Value::String(cell.clone()));
+                }
+                map
+            })
+            .collect();
+        Some(UiNode::DataTable(DataTableNode {
+            meta: NodeMeta {
+                source_range: Some(self.range),
+                ..Default::default()
+            },
+            columns,
+            rows,
+        }))
+    }
+}
+
+/// Deterministic column key from a header label (lowercase, ascii-alnum),
+/// falling back to `col{i}` when the label yields nothing usable.
+fn column_key(label: &str, i: usize) -> String {
+    let key: String = label
+        .chars()
+        .map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '_' })
+        .collect();
+    let key = key.trim_matches('_').to_string();
+    if key.is_empty() {
+        format!("col{i}")
+    } else {
+        key
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn run_gen(md: &str) -> Vec {
+        RulesGenerator.generate(&GenInput::new(md)).unwrap()
+    }
+
+    #[test]
+    fn extracts_task_list_into_checklist() {
+        let md = "## Todo\n\n- [ ] first\n- [x] second\n";
+        let nodes = run_gen(md);
+        let cl = nodes
+            .iter()
+            .find_map(|n| match n {
+                UiNode::Checklist(c) => Some(c),
+                _ => None,
+            })
+            .expect("checklist");
+        assert_eq!(cl.items.len(), 2);
+        assert_eq!(cl.items[0].title, "first");
+        assert!(!cl.items[0].checked);
+        assert!(cl.items[1].checked);
+        assert_eq!(cl.items[0].category.as_deref(), Some("Todo"));
+        assert!(cl.items[0].source_range.is_some());
+    }
+
+    #[test]
+    fn extracts_table_into_datatable() {
+        let md = "| Name | Status |\n|------|--------|\n| a | ok |\n| b | fail |\n";
+        let nodes = run_gen(md);
+        let dt = nodes
+            .iter()
+            .find_map(|n| match n {
+                UiNode::DataTable(d) => Some(d),
+                _ => None,
+            })
+            .expect("datatable");
+        assert_eq!(dt.columns.len(), 2);
+        assert_eq!(dt.columns[0].key, "name");
+        assert_eq!(dt.rows.len(), 2);
+        assert_eq!(dt.rows[0].get("status").unwrap(), "ok");
+    }
+
+    #[test]
+    fn mermaid_and_config_fences() {
+        let md = "```mermaid\ngraph TD; A-->B;\n```\n\n```json\n{\"a\":1}\n```\n";
+        let nodes = run_gen(md);
+        assert!(nodes.iter().any(|n| matches!(n, UiNode::Diagram(_))));
+        assert!(nodes.iter().any(
+            |n| matches!(n, UiNode::ConfigViewer(c) if matches!(c.format, ConfigFormat::Json))
+        ));
+    }
+
+    #[test]
+    fn gfm_alert_into_callout() {
+        let md = "> [!WARNING]\n> be careful here\n";
+        let nodes = run_gen(md);
+        let c = nodes
+            .iter()
+            .find_map(|n| match n {
+                UiNode::Callout(c) => Some(c),
+                _ => None,
+            })
+            .expect("callout");
+        assert_eq!(c.severity, Severity::Warning);
+        assert!(c.body.contains("careful"));
+    }
+
+    #[test]
+    fn plain_prose_yields_nothing() {
+        let nodes = run_gen("Just a paragraph of text.\n");
+        assert!(nodes.is_empty());
+    }
+}
diff --git a/src/generator/traits.rs b/src/generator/traits.rs
new file mode 100644
index 0000000..bf89d3b
--- /dev/null
+++ b/src/generator/traits.rs
@@ -0,0 +1,61 @@
+//! Generator trait + input contract (design doc §3.4).
+//!
+//! In the full design a `Generator` consumes a `UiPlan` + `DocumentModel`
+//! produced by Layer 2 (`analyzer`/`planner`). Those modules are being built in
+//! separate worktrees, so Layer 3 defines a **lightweight input** ([`GenInput`])
+//! here: raw markdown + a document-type hint. When Layer 2 lands, `GenInput`
+//! becomes a thin adapter over `DocumentModel` — no renderer/IR changes needed,
+//! because the contract below (`-> Vec`) is what the rest of Layer 3
+//! depends on.
+
+use anyhow::Result;
+
+use crate::ir::UiNode;
+
+/// Coarse document-type hint. A stand-in for Layer 2's `DocumentType`
+/// classification; `RulesGenerator` works for any value (falls back to generic
+/// structural extraction), so callers may pass [`DocType::Generic`].
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+#[allow(dead_code)] // non-Generic variants are consumed once Layer 2 doctype classification lands
+pub enum DocType {
+    #[default]
+    Generic,
+    Readme,
+    DesignDoc,
+    Runbook,
+    Changelog,
+    Recipe,
+}
+
+/// Input to a [`Generator`]. Deliberately minimal; see module docs.
+pub struct GenInput<'a> {
+    pub markdown: &'a str,
+    pub doc_type: DocType,
+}
+
+impl<'a> GenInput<'a> {
+    pub fn new(markdown: &'a str) -> Self {
+        GenInput {
+            markdown,
+            doc_type: DocType::Generic,
+        }
+    }
+
+    #[allow(dead_code)]
+    pub fn with_doc_type(mut self, doc_type: DocType) -> Self {
+        self.doc_type = doc_type;
+        self
+    }
+}
+
+/// Produces UI IR from a document. Rules implementation is the offline default;
+/// the LLM implementation (`feature = "llm"`) only fills nodes rules can't.
+///
+/// Output is *unvalidated*; callers must run [`crate::ir::validate_nodes`]
+/// before caching or rendering.
+pub trait Generator {
+    fn generate(&self, input: &GenInput<'_>) -> Result>;
+
+    /// Short identifier recorded in the cache key (`"rules"`, `"claude-…"`).
+    fn model_id(&self) -> String;
+}
diff --git a/src/gui.rs b/src/gui.rs
new file mode 100644
index 0000000..f7f60c6
--- /dev/null
+++ b/src/gui.rs
@@ -0,0 +1,76 @@
+//! Generative-UI pipeline facade (design doc §1): document → generator →
+//! validate → cache → UI IR. This is the single entry point the CLI (`gen`
+//! subcommand) and, later, the server (`/api/gui`) call.
+//!
+//! Flow (design §1 pipeline): parse+generate (`RulesGenerator`) → `validate`
+//! (schema + allowlist + sourceRange) → `cache`. LLM generation plugs in at the
+//! generator step behind `feature = "llm"` without changing this facade.
+
+use std::path::Path;
+
+use anyhow::{Context, Result};
+
+use crate::cache::{CacheStore, GuiCacheEntry, content_hash};
+use crate::generator::{GenInput, Generator, RulesGenerator};
+use crate::ir::{LineIndex, UiNode, validate_nodes};
+
+/// Generate validated UI IR for `markdown`, using the on-disk cache under
+/// `cache_root` when provided. Uses the deterministic [`RulesGenerator`].
+pub fn generate(markdown: &str, cache_root: Option<&Path>) -> Result {
+    let generator = RulesGenerator;
+    let model_id = generator.model_id();
+
+    // Cache hit?
+    if let Some(root) = cache_root
+        && let Some(entry) = CacheStore::new(root).get(markdown, &model_id)
+    {
+        return Ok(entry);
+    }
+
+    // Generate → validate (the security boundary).
+    let mut nodes: Vec = generator
+        .generate(&GenInput::new(markdown))
+        .context("rules generation failed")?;
+    let total_lines = LineIndex::new(markdown).line_count();
+    validate_nodes(&mut nodes, total_lines).context("generated IR failed validation")?;
+
+    let hash = content_hash(markdown, &model_id);
+    let entry = GuiCacheEntry::new("generic".to_string(), nodes, model_id, hash);
+
+    // Best-effort persist; a cache write failure must not fail the request.
+    if let Some(root) = cache_root {
+        let _ = CacheStore::new(root).put(&entry);
+    }
+
+    Ok(entry)
+}
+
+/// Convenience: pretty-printed UI IR JSON for the `gen` CLI command.
+pub fn generate_json(markdown: &str, cache_root: Option<&Path>) -> Result {
+    let entry = generate(markdown, cache_root)?;
+    serde_json::to_string_pretty(&entry.ui_ir).context("serializing UI IR")
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn end_to_end_generates_and_caches() {
+        let tmp = tempfile::tempdir().unwrap();
+        let md = "## Tasks\n\n- [ ] a\n- [x] b\n\n> [!WARNING]\n> danger\n";
+        let first = generate(md, Some(tmp.path())).unwrap();
+        assert!(!first.ui_ir.is_empty());
+        // Second call must hit the cache (same content_hash written to disk).
+        let second = generate(md, Some(tmp.path())).unwrap();
+        assert_eq!(first.content_hash, second.content_hash);
+        assert_eq!(first.ui_ir.len(), second.ui_ir.len());
+    }
+
+    #[test]
+    fn produces_valid_json() {
+        let md = "| a | b |\n|---|---|\n| 1 | 2 |\n";
+        let json = generate_json(md, None).unwrap();
+        assert!(json.contains("DataTable"));
+    }
+}
diff --git a/src/ir/mod.rs b/src/ir/mod.rs
new file mode 100644
index 0000000..b284340
--- /dev/null
+++ b/src/ir/mod.rs
@@ -0,0 +1,18 @@
+//! UI IR — the canonical wire format for Generative UI (design doc §4.1).
+//!
+//! Layout mirrors the design's `mdpeek-core::ir`:
+//! - [`node`]     — `UiNode` enum + all node payload types (source of truth).
+//! - [`range`]    — `SourceRange` + `LineIndex` (byte offset → line/col).
+//! - [`registry`] — component allowlist (security boundary).
+//! - [`validate`] — schema + allowlist + sourceRange verification.
+
+pub mod node;
+pub mod range;
+pub mod registry;
+pub mod validate;
+
+#[allow(unused_imports)]
+pub use node::{Origin, Quantity, Severity, UiNode, Visibility};
+pub use range::{LineIndex, SourceRange};
+#[allow(unused_imports)]
+pub use validate::{ValidateError, validate_json, validate_nodes};
diff --git a/src/ir/node.rs b/src/ir/node.rs
new file mode 100644
index 0000000..563d224
--- /dev/null
+++ b/src/ir/node.rs
@@ -0,0 +1,472 @@
+//! UI IR node types — the canonical (source-of-truth) wire format between the
+//! Rust core and the web / TUI renderers (design doc §4.1).
+//!
+//! `#[serde(tag = "kind")]` gives a TypeScript-style discriminated union so the
+//! same JSON is consumed by the Preact registry (`web/src/registry.ts`) keyed on
+//! `node.kind`. `NodeMeta` is flattened into every node (design §4.1 論点 D:
+//! flatten chosen) so `sourceRange` / `confidence` / `origin` / `visibility`
+//! ride along uniformly.
+
+use serde::{Deserialize, Serialize};
+
+use super::range::SourceRange;
+
+/// Where a node came from. Renderers badge `Llm` nodes as "generated / verify".
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
+#[serde(rename_all = "snake_case")]
+pub enum Origin {
+    #[default]
+    Rules,
+    Llm,
+}
+
+/// Reading-position-aware visibility (design §9.3). Novels etc. hide content
+/// past the reader's current position to avoid spoilers.
+#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)]
+#[serde(rename_all = "snake_case")]
+pub enum Visibility {
+    #[default]
+    Always,
+    /// Only revealed once the reader has read past `reveal_after_line`.
+    UntilRead { reveal_after_line: u32 },
+}
+
+/// Common metadata carried by every node (design §4.1).
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
+pub struct NodeMeta {
+    #[serde(rename = "sourceRange", skip_serializing_if = "Option::is_none")]
+    pub source_range: Option,
+    /// 0.0–1.0, present for LLM-generated nodes.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub confidence: Option,
+    #[serde(default)]
+    pub origin: Origin,
+    #[serde(default)]
+    pub visibility: Visibility,
+    /// Set by the validator when `confidence` is below threshold (design §3.5).
+    /// The renderer shows an explicit "low confidence" badge.
+    #[serde(rename = "lowConfidence", default, skip_serializing_if = "is_false")]
+    pub low_confidence: bool,
+}
+
+fn is_false(b: &bool) -> bool {
+    !*b
+}
+
+/// Numbers made *operable* rather than just readable (design §9.3): tolerance
+/// meters, ingredient scaling and charts all consume this.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct Quantity {
+    pub value: f64,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub unit: Option,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub min: Option,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub max: Option,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub nominal: Option,
+    #[serde(default)]
+    pub scalable: bool,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum Severity {
+    Info,
+    Warning,
+    Error,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum ColumnType {
+    Text,
+    Number,
+    Status,
+    Link,
+    Code,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct Column {
+    pub key: String,
+    pub label: String,
+    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
+    pub col_type: Option,
+}
+
+/// A single generated UI node. Renderers dispatch on `kind` via the registry.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(tag = "kind")]
+pub enum UiNode {
+    // --- core registry (design §5.1, always available) ---
+    Tabs(TabsNode),
+    Timeline(TimelineNode),
+    Checklist(ChecklistNode),
+    DataTable(DataTableNode),
+    Diagram(DiagramNode),
+    Callout(CalloutNode),
+    RiskPanel(RiskPanelNode),
+    ApiExplorer(ApiExplorerNode),
+    ConfigViewer(ConfigViewerNode),
+    DependencyGraph(DependencyGraphNode),
+    LogTimeline(LogTimelineNode),
+    CommitGraph(CommitGraphNode),
+
+    // --- domain primitives (design §5.1 outer layer / §9.3) ---
+    Glossary(GlossaryNode),
+    CharacterRoster(CharacterRosterNode),
+    StepNavigator(StepNavigatorNode),
+    ToleranceMeter(ToleranceMeterNode),
+    ScalableTable(ScalableTableNode),
+    ObligationMatrix(ObligationMatrixNode),
+}
+
+impl UiNode {
+    /// The registry key / discriminant string. Matches the serde `tag` value.
+    pub fn kind(&self) -> &'static str {
+        match self {
+            UiNode::Tabs(_) => "Tabs",
+            UiNode::Timeline(_) => "Timeline",
+            UiNode::Checklist(_) => "Checklist",
+            UiNode::DataTable(_) => "DataTable",
+            UiNode::Diagram(_) => "Diagram",
+            UiNode::Callout(_) => "Callout",
+            UiNode::RiskPanel(_) => "RiskPanel",
+            UiNode::ApiExplorer(_) => "ApiExplorer",
+            UiNode::ConfigViewer(_) => "ConfigViewer",
+            UiNode::DependencyGraph(_) => "DependencyGraph",
+            UiNode::LogTimeline(_) => "LogTimeline",
+            UiNode::CommitGraph(_) => "CommitGraph",
+            UiNode::Glossary(_) => "Glossary",
+            UiNode::CharacterRoster(_) => "CharacterRoster",
+            UiNode::StepNavigator(_) => "StepNavigator",
+            UiNode::ToleranceMeter(_) => "ToleranceMeter",
+            UiNode::ScalableTable(_) => "ScalableTable",
+            UiNode::ObligationMatrix(_) => "ObligationMatrix",
+        }
+    }
+
+    pub fn meta(&self) -> &NodeMeta {
+        match self {
+            UiNode::Tabs(n) => &n.meta,
+            UiNode::Timeline(n) => &n.meta,
+            UiNode::Checklist(n) => &n.meta,
+            UiNode::DataTable(n) => &n.meta,
+            UiNode::Diagram(n) => &n.meta,
+            UiNode::Callout(n) => &n.meta,
+            UiNode::RiskPanel(n) => &n.meta,
+            UiNode::ApiExplorer(n) => &n.meta,
+            UiNode::ConfigViewer(n) => &n.meta,
+            UiNode::DependencyGraph(n) => &n.meta,
+            UiNode::LogTimeline(n) => &n.meta,
+            UiNode::CommitGraph(n) => &n.meta,
+            UiNode::Glossary(n) => &n.meta,
+            UiNode::CharacterRoster(n) => &n.meta,
+            UiNode::StepNavigator(n) => &n.meta,
+            UiNode::ToleranceMeter(n) => &n.meta,
+            UiNode::ScalableTable(n) => &n.meta,
+            UiNode::ObligationMatrix(n) => &n.meta,
+        }
+    }
+
+    pub fn meta_mut(&mut self) -> &mut NodeMeta {
+        match self {
+            UiNode::Tabs(n) => &mut n.meta,
+            UiNode::Timeline(n) => &mut n.meta,
+            UiNode::Checklist(n) => &mut n.meta,
+            UiNode::DataTable(n) => &mut n.meta,
+            UiNode::Diagram(n) => &mut n.meta,
+            UiNode::Callout(n) => &mut n.meta,
+            UiNode::RiskPanel(n) => &mut n.meta,
+            UiNode::ApiExplorer(n) => &mut n.meta,
+            UiNode::ConfigViewer(n) => &mut n.meta,
+            UiNode::DependencyGraph(n) => &mut n.meta,
+            UiNode::LogTimeline(n) => &mut n.meta,
+            UiNode::CommitGraph(n) => &mut n.meta,
+            UiNode::Glossary(n) => &mut n.meta,
+            UiNode::CharacterRoster(n) => &mut n.meta,
+            UiNode::StepNavigator(n) => &mut n.meta,
+            UiNode::ToleranceMeter(n) => &mut n.meta,
+            UiNode::ScalableTable(n) => &mut n.meta,
+            UiNode::ObligationMatrix(n) => &mut n.meta,
+        }
+    }
+
+}
+
+// ---------------------------------------------------------------------------
+// Core registry nodes
+// ---------------------------------------------------------------------------
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct TabsNode {
+    #[serde(flatten)]
+    pub meta: NodeMeta,
+    pub tabs: Vec,
+}
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct Tab {
+    pub title: String,
+    pub children: Vec,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct TimelineNode {
+    #[serde(flatten)]
+    pub meta: NodeMeta,
+    pub events: Vec,
+}
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct TimelineEvent {
+    pub title: String,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub timestamp: Option,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub description: Option,
+    #[serde(rename = "sourceRange", skip_serializing_if = "Option::is_none")]
+    pub source_range: Option,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct ChecklistNode {
+    #[serde(flatten)]
+    pub meta: NodeMeta,
+    pub items: Vec,
+}
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct ChecklistItem {
+    pub title: String,
+    pub checked: bool,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub category: Option,
+    #[serde(rename = "sourceRange", skip_serializing_if = "Option::is_none")]
+    pub source_range: Option,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct DataTableNode {
+    #[serde(flatten)]
+    pub meta: NodeMeta,
+    pub columns: Vec,
+    pub rows: Vec>,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum DiagramFormat {
+    Mermaid,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct DiagramNode {
+    #[serde(flatten)]
+    pub meta: NodeMeta,
+    pub format: DiagramFormat,
+    /// Diagram source (e.g. mermaid). Rendered client-side in a sandbox (§8).
+    pub code: String,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub title: Option,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct CalloutNode {
+    #[serde(flatten)]
+    pub meta: NodeMeta,
+    pub severity: Severity,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub title: Option,
+    pub body: String,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct RiskPanelNode {
+    #[serde(flatten)]
+    pub meta: NodeMeta,
+    pub risks: Vec,
+}
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct RiskItem {
+    pub title: String,
+    pub severity: Severity,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub note: Option,
+    #[serde(rename = "sourceRange", skip_serializing_if = "Option::is_none")]
+    pub source_range: Option,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct ApiExplorerNode {
+    #[serde(flatten)]
+    pub meta: NodeMeta,
+    pub endpoints: Vec,
+}
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct ApiEndpoint {
+    pub method: String,
+    pub path: String,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub description: Option,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum ConfigFormat {
+    Json,
+    Yaml,
+    Toml,
+    Env,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct ConfigViewerNode {
+    #[serde(flatten)]
+    pub meta: NodeMeta,
+    pub format: ConfigFormat,
+    /// Raw config text; renderer escapes into `
` (never eval'd, §8).
+    pub content: String,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub title: Option,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct DependencyGraphNode {
+    #[serde(flatten)]
+    pub meta: NodeMeta,
+    pub nodes: Vec,
+    pub edges: Vec,
+}
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct GraphNode {
+    pub id: String,
+    pub label: String,
+}
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct GraphEdge {
+    pub from: String,
+    pub to: String,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub label: Option,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct LogTimelineNode {
+    #[serde(flatten)]
+    pub meta: NodeMeta,
+    pub entries: Vec,
+}
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct LogEntry {
+    pub severity: Severity,
+    pub message: String,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub timestamp: Option,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct CommitGraphNode {
+    #[serde(flatten)]
+    pub meta: NodeMeta,
+    pub commits: Vec,
+}
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct Commit {
+    pub hash: String,
+    pub subject: String,
+    /// rules-classified intent: feat | fix | refactor | docs | ...
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub kind: Option,
+}
+
+// ---------------------------------------------------------------------------
+// Domain primitives
+// ---------------------------------------------------------------------------
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct GlossaryNode {
+    #[serde(flatten)]
+    pub meta: NodeMeta,
+    pub terms: Vec,
+}
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct GlossaryTerm {
+    pub term: String,
+    pub definition: String,
+    #[serde(rename = "sourceRange", skip_serializing_if = "Option::is_none")]
+    pub source_range: Option,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct CharacterRosterNode {
+    #[serde(flatten)]
+    pub meta: NodeMeta,
+    pub characters: Vec,
+}
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct Character {
+    pub name: String,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub summary: Option,
+    #[serde(rename = "firstSeen", skip_serializing_if = "Option::is_none")]
+    pub first_seen: Option,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct StepNavigatorNode {
+    #[serde(flatten)]
+    pub meta: NodeMeta,
+    pub steps: Vec,
+}
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct Step {
+    pub title: String,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub body: Option,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub duration: Option,
+    #[serde(default, skip_serializing_if = "Vec::is_empty")]
+    pub prerequisites: Vec,
+    #[serde(rename = "sourceRange", skip_serializing_if = "Option::is_none")]
+    pub source_range: Option,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct ToleranceMeterNode {
+    #[serde(flatten)]
+    pub meta: NodeMeta,
+    pub label: String,
+    pub quantity: Quantity,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct ScalableTableNode {
+    #[serde(flatten)]
+    pub meta: NodeMeta,
+    /// Base quantity the amounts below are expressed for (e.g. servings=2).
+    #[serde(rename = "baseScale")]
+    pub base_scale: f64,
+    pub columns: Vec,
+    /// Each row: label + a scalable [`Quantity`].
+    pub rows: Vec,
+}
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct ScalableRow {
+    pub label: String,
+    pub quantity: Quantity,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct ObligationMatrixNode {
+    #[serde(flatten)]
+    pub meta: NodeMeta,
+    pub parties: Vec,
+    pub obligations: Vec,
+}
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct Obligation {
+    pub party: String,
+    pub duty: String,
+    #[serde(rename = "sourceRange", skip_serializing_if = "Option::is_none")]
+    pub source_range: Option,
+}
diff --git a/src/ir/range.rs b/src/ir/range.rs
new file mode 100644
index 0000000..5f72933
--- /dev/null
+++ b/src/ir/range.rs
@@ -0,0 +1,116 @@
+//! Source ranges and byte-offset → (line, column) conversion.
+//!
+//! Every generated UI node is anchored back to the original Markdown via a
+//! [`SourceRange`] (design doc §1 "全 UI は sourceRange に紐づく"). `pulldown-cmark`
+//! yields byte offsets, so [`LineIndex`] maps those offsets to 1-based
+//! line / column positions in a single pass.
+//!
+//! NOTE: Layer 1 (`parser` module) will eventually own the canonical
+//! `SourceRange`/`LineIndex`. This module keeps a self-contained copy so Layer 3
+//! can be built and tested without waiting for the Layer 1 merge; the types are
+//! deliberately kept minimal and compatible with the design doc §4.1.
+
+use serde::{Deserialize, Serialize};
+
+/// A 1-based, inclusive-start / exclusive-end span into the source document.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub struct SourceRange {
+    pub start_line: u32,
+    pub start_column: u32,
+    pub end_line: u32,
+    pub end_column: u32,
+}
+
+impl SourceRange {
+    /// True when both endpoints fall within a document of `total_lines` lines.
+    /// Used by the validator to reject hallucinated ranges (design §3.5).
+    pub fn within(&self, total_lines: u32) -> bool {
+        self.start_line >= 1
+            && self.end_line >= self.start_line
+            && self.end_line <= total_lines
+            && self.start_column >= 1
+            && self.end_column >= 1
+    }
+}
+
+/// Maps byte offsets to (line, column). Built once per document.
+#[derive(Debug, Clone)]
+pub struct LineIndex {
+    /// Byte offset of the first character of each line (0-based line → offset).
+    line_starts: Vec,
+    len: usize,
+}
+
+impl LineIndex {
+    pub fn new(source: &str) -> Self {
+        let mut line_starts = vec![0usize];
+        for (i, b) in source.bytes().enumerate() {
+            if b == b'\n' {
+                line_starts.push(i + 1);
+            }
+        }
+        LineIndex {
+            line_starts,
+            len: source.len(),
+        }
+    }
+
+    /// Total number of lines in the document (>= 1).
+    pub fn line_count(&self) -> u32 {
+        self.line_starts.len() as u32
+    }
+
+    /// Convert a byte offset to a 1-based (line, column) pair. Offsets past the
+    /// end clamp to the final position.
+    fn line_col(&self, offset: usize) -> (u32, u32) {
+        let offset = offset.min(self.len);
+        // Largest line_start <= offset.
+        let line = match self.line_starts.binary_search(&offset) {
+            Ok(idx) => idx,
+            Err(idx) => idx.saturating_sub(1),
+        };
+        let col = offset - self.line_starts[line];
+        ((line as u32) + 1, (col as u32) + 1)
+    }
+
+    /// Convert a byte range into a [`SourceRange`].
+    pub fn range(&self, span: std::ops::Range) -> SourceRange {
+        let (start_line, start_column) = self.line_col(span.start);
+        let (end_line, end_column) = self.line_col(span.end);
+        SourceRange {
+            start_line,
+            start_column,
+            end_line,
+            end_column,
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn maps_offsets_to_line_col() {
+        let src = "abc\ndef\n";
+        let idx = LineIndex::new(src);
+        assert_eq!(idx.line_count(), 3); // "abc", "def", ""
+        assert_eq!(idx.range(0..1).start_line, 1);
+        assert_eq!(idx.range(0..1).start_column, 1);
+        // 'd' is at byte offset 4 → line 2, col 1.
+        let r = idx.range(4..5);
+        assert_eq!((r.start_line, r.start_column), (2, 1));
+    }
+
+    #[test]
+    fn within_bounds_check() {
+        let r = SourceRange {
+            start_line: 1,
+            start_column: 1,
+            end_line: 3,
+            end_column: 2,
+        };
+        assert!(r.within(3));
+        assert!(!r.within(2));
+    }
+}
diff --git a/src/ir/registry.rs b/src/ir/registry.rs
new file mode 100644
index 0000000..bea75c2
--- /dev/null
+++ b/src/ir/registry.rs
@@ -0,0 +1,57 @@
+//! Component allowlist (design §3.5 / §8): the single place that decides which
+//! `kind` values a renderer is permitted to receive. Any node whose `kind` is
+//! not in this list is rejected before it reaches the client — this is the
+//! structural guarantee that an LLM can never smuggle in arbitrary components.
+//!
+//! Mirrors the two-layer registry in `web/src/registry.ts`.
+
+/// Core registry: generic components usable by any document type (design §5.1).
+pub const CORE_KINDS: &[&str] = &[
+    "Tabs",
+    "Timeline",
+    "Checklist",
+    "DataTable",
+    "Diagram",
+    "Callout",
+    "RiskPanel",
+    "ApiExplorer",
+    "ConfigViewer",
+    "DependencyGraph",
+    "LogTimeline",
+    "CommitGraph",
+];
+
+/// Domain primitives: added per-domain (design §5.1 outer layer / §9.3).
+pub const DOMAIN_KINDS: &[&str] = &[
+    "Glossary",
+    "CharacterRoster",
+    "StepNavigator",
+    "ToleranceMeter",
+    "ScalableTable",
+    "ObligationMatrix",
+];
+
+/// True if `kind` is an allowed component name.
+pub fn is_allowed(kind: &str) -> bool {
+    CORE_KINDS.contains(&kind) || DOMAIN_KINDS.contains(&kind)
+}
+
+/// All allowed kinds (core + domain), for diagnostics / TS generation checks.
+#[allow(dead_code)]
+pub fn all_kinds() -> impl Iterator {
+    CORE_KINDS.iter().chain(DOMAIN_KINDS.iter()).copied()
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn allowlist_matches_enum_variants() {
+        // Guards against a new UiNode variant being added without registering it.
+        assert!(is_allowed("Tabs"));
+        assert!(is_allowed("ObligationMatrix"));
+        assert!(!is_allowed("ArbitraryScript"));
+        assert_eq!(all_kinds().count(), CORE_KINDS.len() + DOMAIN_KINDS.len());
+    }
+}
diff --git a/src/ir/validate.rs b/src/ir/validate.rs
new file mode 100644
index 0000000..b79b122
--- /dev/null
+++ b/src/ir/validate.rs
@@ -0,0 +1,169 @@
+//! UI IR validation (design doc §3.5): the security boundary that every node —
+//! whether from `RulesGenerator` or a future `ClaudeGenerator` — must pass
+//! before it can be cached or sent to a renderer.
+//!
+//! Three checks:
+//! 1. **Schema** — enforced upstream by serde deserialization (unknown `kind`
+//!    / wrong shape fails to parse). [`validate_json`] re-runs it explicitly.
+//! 2. **Registry allowlist** — reject any `kind` not in [`super::registry`].
+//! 3. **sourceRange bounds** — every range must fall inside the document
+//!    (`total_lines`); fabricated ranges are how hallucinations are caught.
+//!
+//! Nodes whose `confidence` is below [`CONFIDENCE_THRESHOLD`] are *not* rejected
+//! but flagged (`low_confidence = true`) so the renderer can badge them.
+
+use super::node::UiNode;
+use super::range::SourceRange;
+use super::registry;
+
+/// Below this confidence a node is passed through but flagged for the UI.
+pub const CONFIDENCE_THRESHOLD: f32 = 0.5;
+
+#[derive(Debug, thiserror::Error, PartialEq)]
+pub enum ValidateError {
+    #[error("unknown component kind `{0}` is not in the registry allowlist")]
+    UnknownKind(String),
+    #[error("sourceRange {0:?} is outside the document (1..={1} lines)")]
+    RangeOutOfBounds(SourceRange, u32),
+    #[allow(dead_code)]
+    #[error("invalid IR JSON: {0}")]
+    Schema(String),
+}
+
+/// Validate a slice of nodes in place: registry allowlist + range bounds, and
+/// set the `low_confidence` flag. `total_lines` is the document length used for
+/// bounds checking (obtain from `LineIndex::line_count`).
+pub fn validate_nodes(nodes: &mut [UiNode], total_lines: u32) -> Result<(), ValidateError> {
+    for node in nodes.iter_mut() {
+        validate_node(node, total_lines)?;
+    }
+    Ok(())
+}
+
+fn validate_node(node: &mut UiNode, total_lines: u32) -> Result<(), ValidateError> {
+    // (2) registry allowlist — belt-and-braces with serde's tag parsing.
+    if !registry::is_allowed(node.kind()) {
+        return Err(ValidateError::UnknownKind(node.kind().to_string()));
+    }
+
+    // (3) sourceRange bounds on the node's own meta.
+    check_range(node.meta().source_range, total_lines)?;
+
+    // Recurse into any ranges nested inside node payloads and into Tabs children.
+    for r in nested_ranges(node) {
+        check_range(Some(r), total_lines)?;
+    }
+    if let UiNode::Tabs(tabs) = node {
+        for tab in tabs.tabs.iter_mut() {
+            validate_nodes(&mut tab.children, total_lines)?;
+        }
+    }
+
+    // confidence flagging (design §3.5): flag, don't reject.
+    let low = node
+        .meta()
+        .confidence
+        .map(|c| c < CONFIDENCE_THRESHOLD)
+        .unwrap_or(false);
+    node.meta_mut().low_confidence = low;
+
+    Ok(())
+}
+
+fn check_range(range: Option, total_lines: u32) -> Result<(), ValidateError> {
+    if let Some(r) = range
+        && !r.within(total_lines)
+    {
+        return Err(ValidateError::RangeOutOfBounds(r, total_lines));
+    }
+    Ok(())
+}
+
+/// Collect ranges embedded inside node payloads (list items etc.) for bounds
+/// checking. Returned by value since they are `Copy`.
+fn nested_ranges(node: &UiNode) -> Vec {
+    match node {
+        UiNode::Checklist(n) => n.items.iter().filter_map(|i| i.source_range).collect(),
+        UiNode::Timeline(n) => n.events.iter().filter_map(|e| e.source_range).collect(),
+        UiNode::RiskPanel(n) => n.risks.iter().filter_map(|r| r.source_range).collect(),
+        UiNode::Glossary(n) => n.terms.iter().filter_map(|t| t.source_range).collect(),
+        UiNode::StepNavigator(n) => n.steps.iter().filter_map(|s| s.source_range).collect(),
+        UiNode::CharacterRoster(n) => n.characters.iter().filter_map(|c| c.first_seen).collect(),
+        UiNode::ObligationMatrix(n) => {
+            n.obligations.iter().filter_map(|o| o.source_range).collect()
+        }
+        _ => Vec::new(),
+    }
+}
+
+/// Parse untrusted JSON (e.g. LLM output) into validated nodes. Combines schema
+/// (serde) + allowlist + bounds. This is the entry point for `ClaudeGenerator`.
+#[allow(dead_code)]
+pub fn validate_json(json: &str, total_lines: u32) -> Result, ValidateError> {
+    let mut nodes: Vec =
+        serde_json::from_str(json).map_err(|e| ValidateError::Schema(e.to_string()))?;
+    validate_nodes(&mut nodes, total_lines)?;
+    Ok(nodes)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::ir::node::*;
+
+    fn meta_with_range(r: Option) -> NodeMeta {
+        NodeMeta {
+            source_range: r,
+            ..Default::default()
+        }
+    }
+
+    #[test]
+    fn rejects_out_of_bounds_range() {
+        let mut nodes = vec![UiNode::Callout(CalloutNode {
+            meta: meta_with_range(Some(SourceRange {
+                start_line: 1,
+                start_column: 1,
+                end_line: 99,
+                end_column: 1,
+            })),
+            severity: Severity::Warning,
+            title: None,
+            body: "x".into(),
+        })];
+        let err = validate_nodes(&mut nodes, 10).unwrap_err();
+        assert!(matches!(err, ValidateError::RangeOutOfBounds(_, 10)));
+    }
+
+    #[test]
+    fn rejects_unknown_kind_json() {
+        let json = r#"[{"kind":"EvilScript","code":"alert(1)"}]"#;
+        let err = validate_json(json, 100).unwrap_err();
+        // serde fails first because the tag is not a known variant.
+        assert!(matches!(err, ValidateError::Schema(_)));
+    }
+
+    #[test]
+    fn flags_low_confidence() {
+        let mut nodes = vec![UiNode::Callout(CalloutNode {
+            meta: NodeMeta {
+                confidence: Some(0.2),
+                origin: Origin::Llm,
+                ..Default::default()
+            },
+            severity: Severity::Info,
+            title: None,
+            body: "maybe".into(),
+        })];
+        validate_nodes(&mut nodes, 10).unwrap();
+        assert!(nodes[0].meta().low_confidence);
+    }
+
+    #[test]
+    fn roundtrips_valid_json() {
+        let json = r#"[{"kind":"Checklist","items":[{"title":"do it","checked":false}]}]"#;
+        let nodes = validate_json(json, 100).unwrap();
+        assert_eq!(nodes.len(), 1);
+        assert_eq!(nodes[0].kind(), "Checklist");
+    }
+}
diff --git a/src/main.rs b/src/main.rs
index 83a4e54..33f6dd3 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,7 +1,11 @@
+mod cache;
 mod cli;
 mod config;
 mod emitter;
+mod generator;
 mod gfm;
+mod gui;
+mod ir;
 mod server;
 mod watcher;
 
@@ -38,10 +42,28 @@ fn main() -> Result<()> {
             theme,
             pager,
         } => handle_term(file, watch, theme, pager),
+        Mode::Gen { file, no_cache } => handle_gen(file, no_cache)?,
     }
     Ok(())
 }
 
+/// Generate Generative-UI IR (Layer 3) for `root` and print the JSON to stdout.
+/// The cache lives under `.cache/mdpeek/` in the current directory.
+fn handle_gen(root: PathBuf, no_cache: bool) -> Result<()> {
+    if !root.is_file() {
+        anyhow::bail!("'{}' is not a file.", root.display());
+    }
+    let markdown = std::fs::read_to_string(&root)?;
+    let cache_root = if no_cache {
+        None
+    } else {
+        Some(std::path::Path::new("."))
+    };
+    let json = gui::generate_json(&markdown, cache_root)?;
+    println!("{json}");
+    Ok(())
+}
+
 fn handle_serve(root: PathBuf, host: String, port: String, theme: BrowserTheme) {
     init_tracing();
     if root.exists() {
diff --git a/tests/gen_output.rs b/tests/gen_output.rs
new file mode 100644
index 0000000..6cf779e
--- /dev/null
+++ b/tests/gen_output.rs
@@ -0,0 +1,62 @@
+//! Integration tests for the `mdpeek gen` subcommand (Layer 3 Generative UI).
+
+use assert_cmd::Command;
+use predicates::prelude::*;
+use std::io::Write;
+
+fn write_md(dir: &tempfile::TempDir, name: &str, body: &str) -> std::path::PathBuf {
+    let path = dir.path().join(name);
+    let mut f = std::fs::File::create(&path).unwrap();
+    f.write_all(body.as_bytes()).unwrap();
+    path
+}
+
+#[test]
+fn gen_emits_ir_for_tasks_and_tables() {
+    let dir = tempfile::tempdir().unwrap();
+    let md = "## Todo\n\n- [ ] first\n- [x] second\n\n| Name | Status |\n|------|--------|\n| a | ok |\n";
+    let file = write_md(&dir, "doc.md", md);
+
+    Command::cargo_bin("mdpeek")
+        .unwrap()
+        .arg("gen")
+        .arg(&file)
+        .arg("--no-cache")
+        .assert()
+        .success()
+        .stdout(predicate::str::contains("\"kind\": \"Checklist\""))
+        .stdout(predicate::str::contains("\"kind\": \"DataTable\""))
+        .stdout(predicate::str::contains("sourceRange"));
+}
+
+#[test]
+fn gen_writes_cache_file() {
+    let dir = tempfile::tempdir().unwrap();
+    let md = "> [!WARNING]\n> danger\n";
+    let file = write_md(&dir, "warn.md", md);
+
+    // Run inside the temp dir so `.cache/mdpeek` is created there.
+    Command::cargo_bin("mdpeek")
+        .unwrap()
+        .current_dir(dir.path())
+        .arg("gen")
+        .arg(&file)
+        .assert()
+        .success()
+        .stdout(predicate::str::contains("Callout"));
+
+    let cache_dir = dir.path().join(".cache").join("mdpeek");
+    let entries: Vec<_> = std::fs::read_dir(&cache_dir).unwrap().collect();
+    assert_eq!(entries.len(), 1, "expected one cached .gui.json file");
+}
+
+#[test]
+fn gen_rejects_directory() {
+    let dir = tempfile::tempdir().unwrap();
+    Command::cargo_bin("mdpeek")
+        .unwrap()
+        .arg("gen")
+        .arg(dir.path())
+        .assert()
+        .failure();
+}
diff --git a/web/index.html b/web/index.html
new file mode 100644
index 0000000..a539acc
--- /dev/null
+++ b/web/index.html
@@ -0,0 +1,12 @@
+
+
+  
+    
+    
+    markdown-peek · Generative UI
+  
+  
+    
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..2f556cc --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,1081 @@ +{ + "name": "mdpeek-web", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mdpeek-web", + "version": "0.0.0", + "dependencies": { + "@preact/signals": "^1.3.0", + "preact": "^10.24.0" + }, + "devDependencies": { + "typescript": "^5.6.0", + "vite": "^5.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@preact/signals": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@preact/signals/-/signals-1.3.4.tgz", + "integrity": "sha512-TPMkStdT0QpSc8FpB63aOwXoSiZyIrPsP9Uj347KopdS6olZdAYeeird/5FZv/M1Yc1ge5qstub2o8VDbvkT4g==", + "license": "MIT", + "dependencies": { + "@preact/signals-core": "^1.7.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + }, + "peerDependencies": { + "preact": "10.x" + } + }, + "node_modules/@preact/signals-core": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.14.3.tgz", + "integrity": "sha512-m0K3vnbSLC5rHs2ZVfeAMvBtT1zIyq4mxx5OlNncSgMj5Iz6W5Rn3kPrDxAC+iIKmiVe0lSl6U37t5ZkEWoVAw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/preact": { + "version": "10.29.4", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.4.tgz", + "integrity": "sha512-GMpwh9+NJ8tSmqwIaVyFRQkiKfBEzQ+k7r7tle4W+kaJ+7wJiB9hFz9BixAomMtenPPSBfM4bZhXozGxhf0uFQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..113d428 --- /dev/null +++ b/web/package.json @@ -0,0 +1,21 @@ +{ + "name": "mdpeek-web", + "private": true, + "version": "0.0.0", + "type": "module", + "description": "Generative-UI Preact frontend for markdown-peek (Layer 3)", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "typecheck": "tsc --noEmit", + "preview": "vite preview" + }, + "dependencies": { + "preact": "^10.24.0", + "@preact/signals": "^1.3.0" + }, + "devDependencies": { + "typescript": "^5.6.0", + "vite": "^5.4.0" + } +} diff --git a/web/public/gui.sample.json b/web/public/gui.sample.json new file mode 100644 index 0000000..4d29db6 --- /dev/null +++ b/web/public/gui.sample.json @@ -0,0 +1,191 @@ +{ + "nodes": [ + { + "kind": "Checklist", + "sourceRange": { + "start_line": 9, + "start_column": 1, + "end_line": 15, + "end_column": 1 + }, + "origin": "rules", + "visibility": "always", + "items": [ + { + "title": "Task1", + "checked": false, + "category": "TaskList", + "sourceRange": { + "start_line": 9, + "start_column": 1, + "end_line": 10, + "end_column": 1 + } + }, + { + "title": "detail task1.1", + "checked": false, + "category": "TaskList", + "sourceRange": { + "start_line": 11, + "start_column": 3, + "end_line": 12, + "end_column": 1 + } + }, + { + "title": "detail task1.2", + "checked": false, + "category": "TaskList", + "sourceRange": { + "start_line": 12, + "start_column": 3, + "end_line": 13, + "end_column": 1 + } + }, + { + "title": "Task2", + "checked": false, + "category": "TaskList", + "sourceRange": { + "start_line": 10, + "start_column": 1, + "end_line": 13, + "end_column": 1 + } + }, + { + "title": "Task3", + "checked": false, + "category": "TaskList", + "sourceRange": { + "start_line": 13, + "start_column": 1, + "end_line": 15, + "end_column": 1 + } + } + ] + }, + { + "kind": "DataTable", + "sourceRange": { + "start_line": 2, + "start_column": 1, + "end_line": 7, + "end_column": 1 + }, + "origin": "rules", + "visibility": "always", + "columns": [ + { + "key": "level", + "label": "Level" + }, + { + "key": "description", + "label": "Description" + } + ], + "rows": [ + { + "description": "Gold Gym", + "level": "S Tier" + }, + { + "description": "Anytime Fitness", + "level": "A Tier" + }, + { + "description": "Jexer", + "level": "B Tier" + } + ] + }, + { + "kind": "Callout", + "sourceRange": { + "start_line": 82, + "start_column": 1, + "end_line": 84, + "end_column": 1 + }, + "origin": "rules", + "visibility": "always", + "severity": "info", + "title": "Note", + "body": "Useful information that users should know, even when skimming content." + }, + { + "kind": "Callout", + "sourceRange": { + "start_line": 85, + "start_column": 1, + "end_line": 87, + "end_column": 1 + }, + "origin": "rules", + "visibility": "always", + "severity": "info", + "title": "Tip", + "body": "Helpful advice for doing things better or more easily." + }, + { + "kind": "Callout", + "sourceRange": { + "start_line": 88, + "start_column": 1, + "end_line": 90, + "end_column": 1 + }, + "origin": "rules", + "visibility": "always", + "severity": "error", + "title": "Important", + "body": "Key information users need to know to achieve their goal." + }, + { + "kind": "Callout", + "sourceRange": { + "start_line": 91, + "start_column": 1, + "end_line": 93, + "end_column": 1 + }, + "origin": "rules", + "visibility": "always", + "severity": "warning", + "title": "Warning", + "body": "Urgent info that needs immediate user attention to avoid problems." + }, + { + "kind": "Callout", + "sourceRange": { + "start_line": 94, + "start_column": 1, + "end_line": 96, + "end_column": 1 + }, + "origin": "rules", + "visibility": "always", + "severity": "warning", + "title": "Caution", + "body": "Advises about risks or negative outcomes of certain actions." + }, + { + "kind": "Diagram", + "sourceRange": { + "start_line": 136, + "start_column": 1, + "end_line": 140, + "end_column": 4 + }, + "origin": "rules", + "visibility": "always", + "format": "mermaid", + "code": "graph TD\n A-->B\n B-->C" + } + ], + "markdown": "# Table\n| Level | Description |\n|--------|-----------------|\n| S Tier | Gold Gym |\n| A Tier | Anytime Fitness |\n| B Tier | Jexer |\n\n# TaskList\n- [ ] Task1\n- [ ] Task2\n - [ ] detail task1.1\n - [ ] detail task1.2\n- [ ] Task3\n\n# Strike through\n~~Hi~~ Hello, ~there~ world!\n\nThis ~~has a\n\nnew paragraph~~.\n\nThis will ~~~not~~~ strike.\n\n\n# Fenced Code\n```\n<\n>\n```\n\n```rust\nfn main() {\n println!(\"hello world!\");\n}\n```\n\n# AutoLink\nwww.commonmark.org\n\nVisit www.commonmark.org/help for more information.\n\n\nVisit www.commonmark.org.\n\nVisit www.commonmark.org/a.b.\n\nwww.google.com/search?q=Markup+(business)\n\nwww.google.com/search?q=Markup+(business)))\n\n(www.google.com/search?q=Markup+(business))\n\n(www.google.com/search?q=Markup+(business)\n\n# In Page Link\n## Example headings\n\n### Sample Section\n\n### This'll be a _Helpful_ Section About the Greek Letter Θ!\nA heading containing characters not allowed in fragments, UTF-8 characters, two consecutive spaces between the first and second words, and formatting.\n\n### This heading is not unique in the file\n\nTEXT 1\n\n### This heading is not unique in the file\n\nTEXT 2\n\n## Links to the example headings above\n\nLink to the sample section: [Link Text](#sample-section).\n\nLink to the helpful section: [Link Text](#thisll-be-a-helpful-section-about-the-greek-letter-Θ).\n\nLink to the first non-unique section: [Link Text](#this-heading-is-not-unique-in-the-file).\n\nLink to the second non-unique section: [Link Text](#this-heading-is-not-unique-in-the-file-1).\n\n# Alert\n> [!NOTE]\n> Useful information that users should know, even when skimming content.\n\n> [!TIP]\n> Helpful advice for doing things better or more easily.\n\n> [!IMPORTANT]\n> Key information users need to know to achieve their goal.\n\n> [!WARNING]\n> Urgent info that needs immediate user attention to avoid problems.\n\n> [!CAUTION]\n> Advises about risks or negative outcomes of certain actions.\n\n# Color model\n#0969DA\n\n`rgb(9, 105, 218)`\n\n`hsl(212, 92%, 45%)`\n\n# Emoji\n\n@octocat :+1: This PR looks great - it's ready to merge! :shipit:\n\n# Footnote\nHere is a simple footnote[^1].\n\nA footnote can also have multiple lines[^2].\n\n[^1]: My reference.\n[^2]: To add line breaks within a footnote, add 2 spaces to the end of a line. \nThis is a second line.\n\n# MathJax\n## Inline Math\nthis sentence is uses `$` delimiters to show math inline: $\\sqrt{3x-1}+{1+x}^2$\n\nThis sentence uses $\\` and \\`$ delimiters to show math inline: $`\\sqrt{3x-1}+(1+x)^2`$\n\n\n## Block Math\n**The Cauchy-Schwarz Inequality**\\\n$$\\left( \\sum_{k=1}^n a_k b_k \\right)^2 \\leq \\left( \\sum_{k=1}^n a_k^2 \\right) \\left( \\sum_{k=1}^n b_k^2 \\right)$$\n\n\n**The Cauchy-Schwarz Inequality**\n```math\n\\left( \\sum_{k=1}^n a_k b_k \\right)^2 \\leq \\left( \\sum_{k=1}^n a_k^2 \\right) \\left( \\sum_{k=1}^n b_k^2 \\right)\n```\n\n# Mermaid\n\n```mermaid\ngraph TD\n A-->B\n B-->C\n```\n" +} \ No newline at end of file diff --git a/web/src/components/index.tsx b/web/src/components/index.tsx new file mode 100644 index 0000000..d1aef5a --- /dev/null +++ b/web/src/components/index.tsx @@ -0,0 +1,400 @@ +// UI IR component implementations (design doc §5.1). +// +// One component per `UiNode` kind. Security invariants (§8): never use +// `dangerouslySetInnerHTML`; all model-provided strings render as JSX text +// nodes (auto-escaped) and code/config renders inside
. Nothing here
+// evaluates content.
+
+import type {
+  UiNode,
+  NodeMeta,
+  SourceRange,
+  Severity,
+} from "../ir";
+
+/** Emitted when a node (or item) wants to scroll the Content pane to a range. */
+export type OnJump = (range: SourceRange) => void;
+
+interface NodeProps {
+  node: T;
+  onJump?: OnJump;
+}
+
+// --- shared bits ---------------------------------------------------------
+
+/** "generated / verify" + "low confidence" badges (design §5.1). */
+function MetaBadges({ meta }: { meta: NodeMeta }) {
+  return (
+    
+      {meta.origin === "llm" && generated · verify}
+      {meta.lowConfidence && low confidence}
+    
+  );
+}
+
+/** Jump-to-source affordance (design §5.1 SourceRangeLink). */
+function SourceLink({ range, onJump }: { range?: SourceRange; onJump?: OnJump }) {
+  if (!range) return null;
+  return (
+    
+  );
+}
+
+function Panel({
+  title,
+  meta,
+  children,
+}: {
+  title: string;
+  meta: NodeMeta;
+  children: preact.ComponentChildren;
+}) {
+  return (
+    
+
+

{title}

+ +
+
{children}
+
+ ); +} + +function sevClass(s: Severity): string { + return `gui-sev gui-sev--${s}`; +} + +// --- core registry ------------------------------------------------------- + +export function Tabs({ node, onJump }: NodeProps>) { + // Signals could drive the active tab; kept as details/summary for zero-state. + return ( + + {node.tabs.map((t, i) => ( +
+ {t.title} +
+ {t.children.map((child, j) => ( + + ))} +
+
+ ))} +
+ ); +} + +export function Checklist({ node, onJump }: NodeProps>) { + const done = node.items.filter((i) => i.checked).length; + return ( + +
    + {node.items.map((item, i) => ( +
  • + + {item.title} + {item.category && {item.category}} + +
  • + ))} +
+
+ ); +} + +export function DataTable({ node, onJump }: NodeProps>) { + return ( + +
+ + + + {node.columns.map((c) => ( + + ))} + + + + {node.rows.map((row, i) => ( + + {node.columns.map((c) => ( + + ))} + + ))} + +
{c.label}
{String(row[c.key] ?? "")}
+
+ +
+ ); +} + +export function Callout({ node, onJump }: NodeProps>) { + return ( +
+
+

{node.title ?? node.severity}

+ + +
+

{node.body}

+
+ ); +} + +export function Diagram({ node, onJump }: NodeProps>) { + // Mermaid is rendered in a sandbox by the live layer; the safe zero-state is + // the escaped source (design §8: embedded content is sandboxed, not eval'd). + return ( + +
+        {node.code}
+      
+ +
+ ); +} + +export function ConfigViewer({ node, onJump }: NodeProps>) { + return ( + +
{node.content}
+ +
+ ); +} + +export function RiskPanel({ node, onJump }: NodeProps>) { + return ( + +
    + {node.risks.map((r, i) => ( +
  • + {r.title} + {r.note && — {r.note}} + +
  • + ))} +
+
+ ); +} + +export function Timeline({ node, onJump }: NodeProps>) { + return ( + +
    + {node.events.map((e, i) => ( +
  1. + {e.timestamp && } + {e.title} + {e.description &&

    {e.description}

    } + +
  2. + ))} +
+
+ ); +} + +export function ApiExplorer({ node }: NodeProps>) { + return ( + +
    + {node.endpoints.map((e, i) => ( +
  • + {e.method} + {e.path} + {e.description && — {e.description}} +
  • + ))} +
+
+ ); +} + +export function DependencyGraph({ node }: NodeProps>) { + return ( + +
    + {node.edges.map((e, i) => ( +
  • + {e.from} → {e.to} + {e.label && ({e.label})} +
  • + ))} +
+
+ ); +} + +export function LogTimeline({ node }: NodeProps>) { + return ( + +
    + {node.entries.map((e, i) => ( +
  • + {e.timestamp && } + {e.message} +
  • + ))} +
+
+ ); +} + +export function CommitGraph({ node }: NodeProps>) { + return ( + +
    + {node.commits.map((c, i) => ( +
  • + {c.hash.slice(0, 7)} + {c.kind && {c.kind}} + {c.subject} +
  • + ))} +
+
+ ); +} + +// --- domain primitives --------------------------------------------------- + +export function Glossary({ node, onJump }: NodeProps>) { + return ( + +
+ {node.terms.map((t, i) => ( +
+
+ {t.term} +
+
{t.definition}
+
+ ))} +
+
+ ); +} + +export function CharacterRoster({ node, onJump }: NodeProps>) { + return ( + +
    + {node.characters.map((c, i) => ( +
  • + {c.name} + {c.summary && — {c.summary}} + +
  • + ))} +
+
+ ); +} + +export function StepNavigator({ node, onJump }: NodeProps>) { + return ( + +
    + {node.steps.map((s, i) => ( +
  1. + {s.title} + {s.duration && {s.duration}} + {s.body &&

    {s.body}

    } + {s.prerequisites && s.prerequisites.length > 0 && ( + prereq: {s.prerequisites.join(", ")} + )} + +
  2. + ))} +
+
+ ); +} + +export function ToleranceMeter({ node }: NodeProps>) { + const { min, max, nominal, value, unit } = node.quantity; + let pct = 50; + if (min != null && max != null && max > min) { + pct = Math.max(0, Math.min(100, ((value - min) / (max - min)) * 100)); + } + return ( + +
+
+
+
+
+ {min != null ? `${min}${unit ?? ""}` : ""} + + {value} + {unit ?? ""} + {nominal != null && ` (nom ${nominal})`} + + {max != null ? `${max}${unit ?? ""}` : ""} +
+
+ + ); +} + +export function ScalableTable({ node }: NodeProps>) { + // Scaling is interactive in the full build (@preact/signals); zero-state + // shows base quantities. + return ( + + + + {node.rows.map((r, i) => ( + + + + + ))} + +
{r.label} + {r.quantity.value} + {r.quantity.unit ?? ""} +
+
+ ); +} + +export function ObligationMatrix({ node, onJump }: NodeProps>) { + return ( + + + + + + + + + + {node.obligations.map((o, i) => ( + + + + + + ))} + +
PartyDuty +
{o.party}{o.duty} + +
+
+ ); +} + +// Re-import Render lazily to avoid a cycle in module init order. +import { Render } from "../registry"; diff --git a/web/src/ir.ts b/web/src/ir.ts new file mode 100644 index 0000000..057d02e --- /dev/null +++ b/web/src/ir.ts @@ -0,0 +1,150 @@ +// UI IR wire-format types (design doc §4.1). +// +// This mirrors the Rust source of truth in `src/ir/node.rs`. The design calls +// for auto-generating this file via `ts-rs`; until the workspace split lands it +// is hand-maintained and kept in lockstep with the Rust `#[serde]` layout +// (discriminated union on `kind`, `NodeMeta` flattened onto every node). + +export interface SourceRange { + start_line: number; + start_column: number; + end_line: number; + end_column: number; +} + +export type Origin = "rules" | "llm"; + +export type Visibility = "always" | { reveal_after_line: number }; + +export type Severity = "info" | "warning" | "error"; + +export type ColumnType = "text" | "number" | "status" | "link" | "code"; + +export interface Column { + key: string; + label: string; + type?: ColumnType; +} + +export interface Quantity { + value: number; + unit?: string; + min?: number; + max?: number; + nominal?: number; + scalable?: boolean; +} + +// Flattened NodeMeta fields present on every node. +export interface NodeMeta { + sourceRange?: SourceRange; + confidence?: number; + origin?: Origin; + visibility?: Visibility; + lowConfidence?: boolean; +} + +export interface ChecklistItem { + title: string; + checked: boolean; + category?: string; + sourceRange?: SourceRange; +} + +export interface TimelineEvent { + title: string; + timestamp?: string; + description?: string; + sourceRange?: SourceRange; +} + +export interface RiskItem { + title: string; + severity: Severity; + note?: string; + sourceRange?: SourceRange; +} + +export interface ApiEndpoint { + method: string; + path: string; + description?: string; +} + +export interface GraphNode { + id: string; + label: string; +} +export interface GraphEdge { + from: string; + to: string; + label?: string; +} + +export interface LogEntryT { + severity: Severity; + message: string; + timestamp?: string; +} + +export interface Commit { + hash: string; + subject: string; + kind?: string; +} + +export interface GlossaryTerm { + term: string; + definition: string; + sourceRange?: SourceRange; +} + +export interface CharacterT { + name: string; + summary?: string; + firstSeen?: SourceRange; +} + +export interface StepT { + title: string; + body?: string; + duration?: string; + prerequisites?: string[]; + sourceRange?: SourceRange; +} + +export interface ScalableRow { + label: string; + quantity: Quantity; +} + +export interface ObligationT { + party: string; + duty: string; + sourceRange?: SourceRange; +} + +// Discriminated union — `kind` selects the component in the registry. +export type UiNode = NodeMeta & + ( + | { kind: "Tabs"; tabs: { title: string; children: UiNode[] }[] } + | { kind: "Timeline"; events: TimelineEvent[] } + | { kind: "Checklist"; items: ChecklistItem[] } + | { kind: "DataTable"; columns: Column[]; rows: Record[] } + | { kind: "Diagram"; format: "mermaid"; code: string; title?: string } + | { kind: "Callout"; severity: Severity; title?: string; body: string } + | { kind: "RiskPanel"; risks: RiskItem[] } + | { kind: "ApiExplorer"; endpoints: ApiEndpoint[] } + | { kind: "ConfigViewer"; format: "json" | "yaml" | "toml" | "env"; content: string; title?: string } + | { kind: "DependencyGraph"; nodes: GraphNode[]; edges: GraphEdge[] } + | { kind: "LogTimeline"; entries: LogEntryT[] } + | { kind: "CommitGraph"; commits: Commit[] } + | { kind: "Glossary"; terms: GlossaryTerm[] } + | { kind: "CharacterRoster"; characters: CharacterT[] } + | { kind: "StepNavigator"; steps: StepT[] } + | { kind: "ToleranceMeter"; label: string; quantity: Quantity } + | { kind: "ScalableTable"; baseScale: number; columns: Column[]; rows: ScalableRow[] } + | { kind: "ObligationMatrix"; parties: string[]; obligations: ObligationT[] } + ); + +export type UiNodeKind = UiNode["kind"]; diff --git a/web/src/layout/ThreePane.tsx b/web/src/layout/ThreePane.tsx new file mode 100644 index 0000000..fff2da3 --- /dev/null +++ b/web/src/layout/ThreePane.tsx @@ -0,0 +1,84 @@ +// Three-pane layout (design doc §5.3 / DESIGN.md "複数ビュー表示"). +// +// Outline | Content | Generated UI. Per 論点 A the Content pane is the existing +// Layer-1 SSR HTML and the Generated UI pane is this Preact island; in the +// standalone dev harness Content falls back to the raw markdown source so the +// island can be exercised without the server. + +import { useSignal } from "@preact/signals"; +import type { UiNode, SourceRange } from "../ir"; +import { RenderList } from "../registry"; + +interface Props { + nodes: UiNode[]; + markdown: string; +} + +interface OutlineItem { + label: string; + line: number; +} + +/** Derive a lightweight outline from markdown ATX headings. */ +function outline(markdown: string): OutlineItem[] { + const items: OutlineItem[] = []; + markdown.split("\n").forEach((raw, i) => { + const m = /^(#{1,6})\s+(.*)$/.exec(raw); + if (m) items.push({ label: `${"·".repeat(m[1].length - 1)}${m[2]}`, line: i + 1 }); + }); + return items; +} + +export function ThreePane({ nodes, markdown }: Props) { + const activeLine = useSignal(null); + const lines = markdown.split("\n"); + const items = outline(markdown); + + const jump = (r: SourceRange) => { + activeLine.value = r.start_line; + const el = document.getElementById(`ln-${r.start_line}`); + el?.scrollIntoView({ behavior: "smooth", block: "center" }); + }; + + return ( +
+ + +
+

Content

+
+          {lines.map((l, i) => (
+            
+ {i + 1} + {l || " "} +
+ ))} +
+
+ + +
+ ); +} diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..7ed7b0e --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,40 @@ +// Preact island entry point (design doc §1 / §5). +// +// The server injects the validated UI IR + source markdown into the page (per +// 論点 A, as the Generated UI island alongside the Layer-1 SSR content). In the +// standalone dev harness we fall back to a bundled fixture so `vite dev` works +// without the Rust server. + +import { render } from "preact"; +import type { UiNode } from "./ir"; +import { ThreePane } from "./layout/ThreePane"; +import "./styles.css"; + +declare global { + interface Window { + __MDPEEK_GUI__?: { nodes: UiNode[]; markdown: string }; + } +} + +async function bootstrap() { + const root = document.getElementById("app"); + if (!root) return; + + let data = window.__MDPEEK_GUI__; + if (!data) { + // Dev fallback fixture. + try { + const res = await fetch("/gui.sample.json"); + if (res.ok) data = await res.json(); + } catch { + /* offline dev: leave empty */ + } + } + + render( + , + root, + ); +} + +bootstrap(); diff --git a/web/src/registry.tsx b/web/src/registry.tsx new file mode 100644 index 0000000..77f10f8 --- /dev/null +++ b/web/src/registry.tsx @@ -0,0 +1,63 @@ +// Component registry + dispatcher (design doc §5.1). +// +// Two layers: `coreRegistry` (12 generic components, used by any document type) +// and `domainRegistry` (per-domain primitives). A node whose `kind` is not in +// the merged registry is not rendered — the client-side half of the security +// boundary (the Rust validator already rejected it; this is defence in depth). + +import type { UiNode, UiNodeKind } from "./ir"; +import type { OnJump } from "./components"; +import * as C from "./components"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type NodeComponent = (props: { node: any; onJump?: OnJump }) => preact.ComponentChild; + +const coreRegistry: Partial> = { + Tabs: C.Tabs, + Timeline: C.Timeline, + Checklist: C.Checklist, + DataTable: C.DataTable, + Diagram: C.Diagram, + Callout: C.Callout, + RiskPanel: C.RiskPanel, + ApiExplorer: C.ApiExplorer, + ConfigViewer: C.ConfigViewer, + DependencyGraph: C.DependencyGraph, + LogTimeline: C.LogTimeline, + CommitGraph: C.CommitGraph, +}; + +const domainRegistry: Partial> = { + Glossary: C.Glossary, + CharacterRoster: C.CharacterRoster, + StepNavigator: C.StepNavigator, + ToleranceMeter: C.ToleranceMeter, + ScalableTable: C.ScalableTable, + ObligationMatrix: C.ObligationMatrix, +}; + +export const registry: Partial> = { + ...coreRegistry, + ...domainRegistry, +}; + +/** Render a single UI IR node via the registry. Unknown kinds render nothing. */ +export function Render({ node, onJump }: { node: UiNode; onJump?: OnJump }) { + const Component = registry[node.kind]; + if (!Component) { + // Should be unreachable (validator rejects unknown kinds), but never fail. + return null; + } + return ; +} + +/** Render an ordered list of nodes (the Generated UI pane content). */ +export function RenderList({ nodes, onJump }: { nodes: UiNode[]; onJump?: OnJump }) { + return ( + <> + {nodes.map((n, i) => ( + + ))} + + ); +} diff --git a/web/src/styles.css b/web/src/styles.css new file mode 100644 index 0000000..435f145 --- /dev/null +++ b/web/src/styles.css @@ -0,0 +1,121 @@ +/* Generative-UI island styles (design §5). Theme-aware (light/dark). */ +:root { + --bg: #ffffff; + --fg: #1f2328; + --muted: #656d76; + --border: #d0d7de; + --panel: #f6f8fa; + --accent: #0969da; + --info: #0969da; + --warning: #9a6700; + --error: #cf222e; + --code-bg: #f6f8fa; +} +@media (prefers-color-scheme: dark) { + :root { + --bg: #0d1117; + --fg: #e6edf3; + --muted: #8b949e; + --border: #30363d; + --panel: #161b22; + --accent: #2f81f7; + --info: #2f81f7; + --warning: #d29922; + --error: #f85149; + --code-bg: #161b22; + } +} + +* { box-sizing: border-box; } +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; + background: var(--bg); + color: var(--fg); +} + +.gui-3pane { + display: grid; + grid-template-columns: 220px 1fr 1fr; + gap: 0; + height: 100vh; +} +.gui-3pane > * { overflow: auto; padding: 12px 16px; } +.gui-outline { border-right: 1px solid var(--border); } +.gui-content { border-right: 1px solid var(--border); } +.gui-3pane h2 { + font-size: 12px; text-transform: uppercase; letter-spacing: .05em; + color: var(--muted); margin: 0 0 12px; +} + +.gui-outline ul { list-style: none; margin: 0; padding: 0; } +.gui-outline button { + background: none; border: none; color: var(--fg); cursor: pointer; + padding: 3px 0; text-align: left; width: 100%; font-size: 13px; +} +.gui-outline button:hover { color: var(--accent); } + +.gui-source { margin: 0; font-size: 12px; line-height: 1.5; } +.gui-line { white-space: pre-wrap; padding: 0 4px; border-radius: 3px; } +.gui-line.is-active { background: color-mix(in srgb, var(--accent) 18%, transparent); } +.gui-lineno { color: var(--muted); display: inline-block; width: 3ch; margin-right: 8px; user-select: none; } + +.gui-panel { + border: 1px solid var(--border); border-radius: 8px; + background: var(--panel); margin-bottom: 14px; overflow: hidden; +} +.gui-panel__head { + display: flex; align-items: center; gap: 8px; + padding: 8px 12px; border-bottom: 1px solid var(--border); +} +.gui-panel__head h3 { margin: 0; font-size: 14px; flex: 0 0 auto; } +.gui-panel__body { padding: 12px; } + +.gui-badges { margin-left: auto; display: flex; gap: 6px; } +.gui-badge { font-size: 10px; padding: 2px 6px; border-radius: 10px; border: 1px solid var(--border); } +.gui-badge--llm { color: var(--accent); border-color: var(--accent); } +.gui-badge--low { color: var(--warning); border-color: var(--warning); } + +.gui-srclink { + font-size: 10px; color: var(--accent); background: none; + border: 1px solid var(--border); border-radius: 4px; cursor: pointer; + padding: 1px 5px; margin-left: 6px; +} +.gui-tag { + font-size: 10px; color: var(--muted); border: 1px solid var(--border); + border-radius: 4px; padding: 1px 5px; margin-left: 6px; +} + +.gui-checklist, .gui-risklist, .gui-api, .gui-edges, .gui-log, +.gui-commits, .gui-roster { list-style: none; margin: 0; padding: 0; } +.gui-checklist li { display: flex; align-items: center; gap: 6px; padding: 3px 0; } +.gui-checklist li.is-checked span { color: var(--muted); text-decoration: line-through; } + +.gui-tablewrap { overflow-x: auto; } +.gui-table { border-collapse: collapse; width: 100%; font-size: 13px; } +.gui-table th, .gui-table td { border: 1px solid var(--border); padding: 5px 8px; text-align: left; } +.gui-table th { background: color-mix(in srgb, var(--fg) 6%, transparent); } + +.gui-code { + background: var(--code-bg); border: 1px solid var(--border); + border-radius: 6px; padding: 10px; font-size: 12px; overflow-x: auto; + white-space: pre; +} + +.gui-callout .gui-panel__body, .gui-callout p { margin: 8px 12px; } +.gui-sev--info { border-left: 3px solid var(--info); } +.gui-sev--warning { border-left: 3px solid var(--warning); } +.gui-sev--error { border-left: 3px solid var(--error); } + +.gui-method { font-size: 11px; font-weight: 700; padding: 1px 6px; border-radius: 4px; color: #fff; } +.gui-method--get { background: #1a7f37; } +.gui-method--post { background: var(--accent); } +.gui-method--put, .gui-method--patch { background: var(--warning); } +.gui-method--delete { background: var(--error); } + +.gui-timeline, .gui-steps { padding-left: 18px; } +.gui-meter__track { height: 10px; background: var(--border); border-radius: 5px; overflow: hidden; } +.gui-meter__fill { height: 100%; background: var(--accent); } +.gui-meter__labels { display: flex; justify-content: space-between; font-size: 12px; margin-top: 4px; } + +.gui-empty { color: var(--muted); font-style: italic; } diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..0f41d14 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "jsxImportSource": "preact", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "skipLibCheck": true, + "types": ["vite/client"] + }, + "include": ["src"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..ecc91e0 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from "vite"; + +// Preact via aliasing react/jsx-runtime to preact (no @preact/preset-vite dep +// needed; keeps the toolchain minimal). Build emits to `dist/`, which the +// server embeds with `include_bytes!` (design 論点 C: commit dist in-tree). +export default defineConfig({ + esbuild: { + jsx: "automatic", + jsxImportSource: "preact", + }, + resolve: { + alias: { + "react/jsx-runtime": "preact/jsx-runtime", + }, + }, + build: { + outDir: "dist", + emptyOutDir: true, + target: "es2020", + }, +}); From 3fcf5292b1939061c5f6fda1ed000ab6f89fe0ec Mon Sep 17 00:00:00 2001 From: tkcd Date: Sun, 5 Jul 2026 14:21:04 +0900 Subject: [PATCH 02/19] feat(layer3): multi-backend LLM (claude_code, codex, anthropic_api) + model/effort MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds selectable LLM backends to the Layer 3 generator, configurable via [llm] in config.toml or `mdpeek gen` CLI flags: - provider: "claude_code" (shells `claude` CLI) and "codex" (shells `codex` CLI) work in the default build (std::process, no extra crates); "anthropic_api" (direct HTTP) stays behind feature="llm". Default provider is anthropic_api. - model: backend-specific model id (--model / [llm] model). - effort: low|medium|high — codex -> model_reasoning_effort; claude_code -> thinking keyword (think/ultrathink); anthropic advisory. Wiring: - src/generator/llm/: LlmProvider/Effort/LlmBackendConfig + build() factory, claude_code.rs, codex.rs, anthropic.rs (renamed from claude.rs); prompt gains extract_json_array for noisy CLI stdout. llm module no longer feature-gated. - config.rs: [llm] gains provider/model/effort; llm_backend_config()/llm_enabled(). - cli.rs: `gen` gains --llm/--provider/--model/--effort (override config). - gui.rs: generate_with_llm() with rules fallback on any backend failure. - config.example.toml + docs/layer3.md updated. Tests: config parsing for provider/model/effort, prompt extraction, and a CLI integration test that --llm falls back to rules when the backend is unavailable. cargo test + clippy (default and --features llm) + web build all clean. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01HXGWTTALewHoUjGfVkDwEV --- config.example.toml | 35 ++++- docs/layer3.md | 24 ++- src/cli.rs | 42 +++++- src/config.rs | 61 ++++++++ src/generator/llm/{claude.rs => anthropic.rs} | 70 ++++----- src/generator/llm/claude_code.rs | 60 ++++++++ src/generator/llm/codex.rs | 62 ++++++++ src/generator/llm/mod.rs | 138 +++++++++++++++++- src/generator/llm/prompt.rs | 19 +++ src/generator/mod.rs | 7 +- src/gui.rs | 62 +++++++- src/ir/registry.rs | 1 - src/ir/validate.rs | 4 +- src/main.rs | 17 ++- tests/gen_output.rs | 22 +++ 15 files changed, 545 insertions(+), 79 deletions(-) rename src/generator/llm/{claude.rs => anthropic.rs} (53%) create mode 100644 src/generator/llm/claude_code.rs create mode 100644 src/generator/llm/codex.rs diff --git a/config.example.toml b/config.example.toml index 7d1e55e..f05f733 100644 --- a/config.example.toml +++ b/config.example.toml @@ -31,12 +31,13 @@ theme = "glow" pager = "less -R" [llm] -# Generated-UI generation policy: choose between rules-based and LLM-based -# inference. Read at startup and consulted by the generator. +# Generated-UI (Layer 3) generation settings: the rules-vs-LLM policy plus the +# LLM backend selection. Read at startup and consulted by the generator. # Whether the LLM may be used at all. When false, generation is strictly -# rules-based regardless of `strategy`. (Even when true, mdpeek degrades to -# rules-only automatically if no API key is available.) +# rules-based regardless of `strategy`. `mdpeek gen --llm` overrides this for a +# single run. (Even when enabled, mdpeek degrades to rules automatically if the +# chosen backend is unavailable.) enabled = false # Which source wins when both rules and the LLM could produce a result: @@ -48,3 +49,29 @@ strategy = "rules_first" # Under "rules_first", a rules result below this confidence (0.0-1.0) is # escalated to the LLM. Defaults to 0.6 when omitted. confidence_threshold = 0.6 + +# LLM backend to drive when the LLM is used: +# "anthropic_api" - call the Anthropic API over HTTP. Requires building with +# `--features llm` and an ANTHROPIC_API_KEY in the env. +# "claude_code" - shell out to the `claude` CLI (Claude Code). No extra +# build features; needs `claude` installed + authenticated. +# "codex" - shell out to the `codex` CLI (OpenAI Codex). No extra +# build features; needs `codex` installed + authenticated. +provider = "claude_code" + +# Model id passed to the backend (backend-specific). Omit for the backend's +# default. Examples: +# anthropic_api: "claude-sonnet-5", "claude-opus-4-8" +# claude_code: "claude-sonnet-5" (passed as `claude --model`) +# codex: "gpt-5-codex" (passed as `codex --model`) +model = "claude-sonnet-5" + +# Reasoning effort: "low" | "medium" | "high". Mapped per backend: +# codex -> `-c model_reasoning_effort=""` +# claude_code -> thinking keyword appended to the prompt (medium=think, +# high=ultrathink; low adds nothing) +# anthropic_api-> currently advisory only +effort = "medium" + +# Override for a single run from the CLI, without editing this file: +# mdpeek gen doc.md --llm --provider codex --model gpt-5-codex --effort high diff --git a/docs/layer3.md b/docs/layer3.md index aad0575..0f685af 100644 --- a/docs/layer3.md +++ b/docs/layer3.md @@ -18,10 +18,10 @@ to the `parser` / `analyzer` / `model` areas that Layer 1 / 2 own. | §3.5 / §8 allowlist | `src/ir/registry.rs` | ✅ 2-layer allowlist (core + domain) | | §3.5 validation | `src/ir/validate.rs` | ✅ schema (serde) + allowlist + sourceRange bounds + low-confidence flagging | | §3.4 generator | `src/generator/rules.rs` | ✅ `RulesGenerator`: task lists→`Checklist`, tables→`DataTable`, mermaid→`Diagram`, json/yaml/toml/env→`ConfigViewer`, GFM alerts→`Callout` | -| §7 LLM | `src/generator/llm/` (`feature = "llm"`) | ✅ `ClaudeGenerator` + prompt; offline fallback to rules; **not yet driven** (see below) | +| §7 LLM | `src/generator/llm/` | ✅ 3 backends: `claude_code` (`claude` CLI) + `codex` (`codex` CLI) in the default build, `anthropic_api` (HTTP) behind `feature = "llm"`; model + effort per backend; validates output; rules fallback | | §6 cache | `src/cache/` | ✅ content-hash key (markdown + generator + schema version) + `.cache/mdpeek/*.gui.json` store | -| §1 pipeline | `src/gui.rs` | ✅ generate → validate → cache facade | -| CLI | `mdpeek gen ` | ✅ emits validated IR JSON; `--no-cache` | +| §1 pipeline | `src/gui.rs` | ✅ generate → validate → cache facade (rules or LLM) | +| CLI | `mdpeek gen ` | ✅ emits validated IR JSON; `--no-cache`, `--llm`, `--provider`, `--model`, `--effort` | | §5.1 web registry | `web/src/registry.tsx` | ✅ 2-layer registry + `Render` dispatcher | | §5.1 components | `web/src/components/` | ✅ all 18 node kinds | | §5.3 layout | `web/src/layout/ThreePane.tsx` | ✅ Outline / Content / Generated UI, SourceRangeLink jump | @@ -51,18 +51,32 @@ their outputs, so they are left as clean integration points: ## Usage ```sh -# Deterministic, offline IR generation: +# Deterministic, offline IR generation (rules): mdpeek gen README.md # prints validated UI IR JSON, caches under .cache/mdpeek/ mdpeek gen README.md --no-cache # always regenerate -# LLM-backed generation (opt-in; falls back to rules if ANTHROPIC_API_KEY unset): +# LLM-backed generation. Backend + model + effort come from [llm] in config.toml, +# or from CLI flags (which override config). Falls back to rules on any failure. +mdpeek gen README.md --llm --provider claude_code --model claude-sonnet-5 --effort high +mdpeek gen README.md --llm --provider codex --model gpt-5-codex --effort medium + +# The `anthropic_api` backend (direct HTTP) needs a feature build + API key: cargo build --features llm +ANTHROPIC_API_KEY=... mdpeek gen README.md --llm --provider anthropic_api # Web frontend (Generated UI island): cd web && npm install && npm run dev # dev harness with a bundled fixture cd web && npm run build # → web/dist (embedded by the server later) ``` +### LLM backends + +| provider | build | needs | model flag | effort mapping | +|---|---|---|---|---| +| `claude_code` | default | `claude` CLI on PATH | `claude --model` | prompt keyword (`think`/`ultrathink`) | +| `codex` | default | `codex` CLI on PATH | `codex --model` | `-c model_reasoning_effort="…"` | +| `anthropic_api` | `--features llm` | `ANTHROPIC_API_KEY` | request `model` | advisory only | + ## Security invariants (design §8) - LLM output is **UI IR only** — enforced structurally by serde types + the diff --git a/src/cli.rs b/src/cli.rs index cf375d7..865029d 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,4 +1,5 @@ use crate::config::{BrowserTheme, Config, DefaultMode}; +use crate::generator::llm::{Effort, LlmBackendConfig, LlmProvider}; use anyhow::Result; use clap::{Args, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum}; use serde::Deserialize; @@ -49,6 +50,18 @@ pub struct GenArg { /// Skip the on-disk cache and always regenerate. #[arg(long)] pub no_cache: bool, + /// Use the LLM backend (overrides `[llm] enabled = false`). + #[arg(long)] + pub llm: bool, + /// LLM backend override: anthropic_api | claude_code | codex. + #[arg(long, value_enum)] + pub provider: Option, + /// Model id override (backend-specific). + #[arg(long)] + pub model: Option, + /// Reasoning effort override: low | medium | high. + #[arg(long, value_enum)] + pub effort: Option, } // Subcommand arguments are optional so that an unset flag can fall back to @@ -101,7 +114,12 @@ pub enum Mode { pager: Option, }, /// Generate Generative-UI IR JSON (Layer 3) and print it to stdout. - Gen { file: PathBuf, no_cache: bool }, + Gen { + file: PathBuf, + no_cache: bool, + /// Resolved LLM backend to use, or `None` for rules-only generation. + llm: Option, + }, } impl Cli { @@ -158,10 +176,24 @@ impl Cli { theme: arg.theme.or(config.term.theme).unwrap_or(ThemeChoice::Glow), pager, }), - Some(Commands::Gen(arg)) => Ok(Mode::Gen { - file: arg.file.unwrap_or_else(|| PathBuf::from(DEFAULT_ROOT)), - no_cache: arg.no_cache, - }), + Some(Commands::Gen(arg)) => { + // Use the LLM when `--llm` is passed or `[llm] enabled = true`. + // CLI flags override the corresponding config fields. + let use_llm = arg.llm || config.llm_enabled(); + let llm = use_llm.then(|| { + let base = config.llm_backend_config(); + LlmBackendConfig { + provider: arg.provider.unwrap_or(base.provider), + model: arg.model.or(base.model), + effort: arg.effort.or(base.effort), + } + }); + Ok(Mode::Gen { + file: arg.file.unwrap_or_else(|| PathBuf::from(DEFAULT_ROOT)), + no_cache: arg.no_cache, + llm, + }) + } None => { let root = self.root.unwrap_or_else(|| PathBuf::from(DEFAULT_ROOT)); let host = self diff --git a/src/config.rs b/src/config.rs index b5169c9..c068bbc 100644 --- a/src/config.rs +++ b/src/config.rs @@ -10,6 +10,7 @@ //! ``` use crate::cli::ThemeChoice; +use crate::generator::llm::{Effort, LlmBackendConfig, LlmProvider}; use mdpeek_analyzer::generation::DEFAULT_CONFIDENCE_THRESHOLD; use mdpeek_analyzer::{GenerationConfig, GenerationStrategy}; use serde::Deserialize; @@ -69,6 +70,15 @@ pub struct LlmConfig { /// Confidence below which a rules result is escalated to the LLM under /// `rules_first`. Defaults to 0.6 when unset. pub confidence_threshold: Option, + /// Which LLM backend to drive (Layer 3): `"anthropic_api"` (default), + /// `"claude_code"`, or `"codex"`. See [`LlmProvider`]. + pub provider: LlmProvider, + /// Model id passed to the backend (backend-specific). Omit for the + /// backend's default. + pub model: Option, + /// Reasoning effort: `"low"` | `"medium"` | `"high"`. Mapped per backend + /// (Codex → `model_reasoning_effort`, Claude Code → thinking keyword). + pub effort: Option, } /// Mode selected when no subcommand is given. @@ -124,6 +134,22 @@ impl Config { } } + /// Resolve the LLM backend selection (provider + model + effort) from the + /// `[llm]` config. Independent of `enabled`; the caller decides whether to + /// use it (see `mdpeek gen --llm`). + pub fn llm_backend_config(&self) -> LlmBackendConfig { + LlmBackendConfig { + provider: self.llm.provider, + model: self.llm.model.clone(), + effort: self.llm.effort, + } + } + + /// Whether the `[llm]` section opted into LLM generation. + pub fn llm_enabled(&self) -> bool { + self.llm.enabled + } + fn load_from(path: &Path) -> Self { let content = match std::fs::read_to_string(path) { Ok(content) => content, @@ -251,6 +277,41 @@ mod tests { assert!(toml::from_str::("[llm]\nbogus = 1").is_err()); } + #[test] + fn llm_backend_defaults_to_anthropic_api() { + let config: Config = toml::from_str("").unwrap(); + let backend = config.llm_backend_config(); + assert_eq!(backend.provider, LlmProvider::AnthropicApi); + assert!(backend.model.is_none()); + assert!(backend.effort.is_none()); + } + + #[test] + fn llm_backend_provider_model_effort_parse() { + let toml = r#" + [llm] + enabled = true + provider = "codex" + model = "gpt-5-codex" + effort = "high" + "#; + let config: Config = toml::from_str(toml).unwrap(); + assert!(config.llm_enabled()); + let backend = config.llm_backend_config(); + assert_eq!(backend.provider, LlmProvider::Codex); + assert_eq!(backend.model.as_deref(), Some("gpt-5-codex")); + assert_eq!(backend.effort, Some(Effort::High)); + } + + #[test] + fn llm_claude_code_provider_parses() { + let config: Config = + toml::from_str("[llm]\nprovider = \"claude_code\"\neffort = \"medium\"").unwrap(); + let backend = config.llm_backend_config(); + assert_eq!(backend.provider, LlmProvider::ClaudeCode); + assert_eq!(backend.effort, Some(Effort::Medium)); + } + #[test] fn explicit_missing_path_falls_back_to_defaults() { let config = Config::load_explicit(Path::new("/no/such/mdpeek-config.toml")); diff --git a/src/generator/llm/claude.rs b/src/generator/llm/anthropic.rs similarity index 53% rename from src/generator/llm/claude.rs rename to src/generator/llm/anthropic.rs index faaa716..d463827 100644 --- a/src/generator/llm/claude.rs +++ b/src/generator/llm/anthropic.rs @@ -1,4 +1,4 @@ -//! Anthropic Claude adapter (design doc §7), behind `feature = "llm"`. +//! Anthropic API adapter (design doc §7), behind `feature = "llm"`. //! //! Contract: send the document + schema constraints, receive **UI IR JSON //! only**, then run it through [`crate::ir::validate_json`] (schema + registry @@ -6,54 +6,45 @@ //! an LLM can never introduce a component outside the registry or a fabricated //! range. //! -//! Offline-safe: when `ANTHROPIC_API_KEY` is unset, [`ClaudeGenerator`] falls -//! back to `RulesGenerator` so the default experience never depends on network -//! or credentials (design §7 "未設定なら自動で rules-only にフォールバック"). +//! Offline-safe: when `ANTHROPIC_API_KEY` is unset, generation falls back to +//! `RulesGenerator` so the experience never hard-depends on network or +//! credentials (design §7 "未設定なら自動で rules-only にフォールバック"). use anyhow::{Context, Result}; +use super::prompt; use crate::generator::rules::RulesGenerator; use crate::generator::traits::{GenInput, Generator}; use crate::ir::{LineIndex, UiNode, validate_json}; -use super::prompt; - const API_URL: &str = "https://api.anthropic.com/v1/messages"; const API_VERSION: &str = "2023-06-01"; -/// Overridable via `MDPEEK_LLM_MODEL`; defaults to a current Claude model. +/// Used when no model is configured; a current Claude model. const DEFAULT_MODEL: &str = "claude-sonnet-5"; -pub struct ClaudeGenerator { +pub struct AnthropicApiGenerator { model: String, - /// Node kinds the planner wants the LLM to fill. Empty = model's discretion. - requested_kinds: Vec, -} - -impl Default for ClaudeGenerator { - fn default() -> Self { - ClaudeGenerator { - model: std::env::var("MDPEEK_LLM_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.to_string()), - requested_kinds: Vec::new(), - } - } } -impl ClaudeGenerator { - pub fn with_requested_kinds(mut self, kinds: Vec) -> Self { - self.requested_kinds = kinds; - self +impl AnthropicApiGenerator { + /// Create with an explicit model, or `None` to use `MDPEEK_LLM_MODEL` / + /// the built-in default. + pub fn new(model: Option) -> Self { + let model = model + .or_else(|| std::env::var("MDPEEK_LLM_MODEL").ok()) + .unwrap_or_else(|| DEFAULT_MODEL.to_string()); + AnthropicApiGenerator { model } } - /// Generate UI IR via Claude, validating the result. Falls back to rules on - /// missing key. Async because the server drives it inside tokio (design §7). + /// Generate UI IR via the Anthropic API, validating the result. Falls back + /// to rules when no API key is set. Async because the server drives it + /// inside tokio (design §7). pub async fn generate_async(&self, input: &GenInput<'_>) -> Result> { let Ok(api_key) = std::env::var("ANTHROPIC_API_KEY") else { - // Offline fallback: deterministic rules output. return RulesGenerator.generate(input); }; let total_lines = LineIndex::new(input.markdown).line_count(); - let asks: Vec<&str> = self.requested_kinds.iter().map(String::as_str).collect(); let body = serde_json::json!({ "model": self.model, @@ -61,7 +52,7 @@ impl ClaudeGenerator { "system": prompt::system_prompt(), "messages": [{ "role": "user", - "content": prompt::user_prompt(input.markdown, &asks), + "content": prompt::user_prompt(input.markdown, &[]), }], }); @@ -74,35 +65,34 @@ impl ClaudeGenerator { .json(&body) .send() .await - .context("Claude request failed")? + .context("Anthropic request failed")? .error_for_status() - .context("Claude returned an error status")?; + .context("Anthropic returned an error status")?; - let json: serde_json::Value = resp.json().await.context("invalid Claude response")?; + let json: serde_json::Value = resp.json().await.context("invalid Anthropic response")?; let text = json["content"][0]["text"] .as_str() - .context("Claude response missing content text")?; + .context("Anthropic response missing content text")?; let cleaned = prompt::strip_code_fence(text); // The security boundary: schema + allowlist + range verification. - let nodes = validate_json(cleaned, total_lines).context("LLM output failed validation")?; - Ok(nodes) + validate_json(cleaned, total_lines).context("LLM output failed validation") } } -/// Blocking `Generator` impl so `ClaudeGenerator` can be used from sync call -/// sites; it just fronts [`ClaudeGenerator::generate_async`] on a scoped -/// runtime. Server code should prefer `generate_async` directly. -impl Generator for ClaudeGenerator { +/// Blocking `Generator` impl so the API backend can be used from sync call sites +/// (fronts [`AnthropicApiGenerator::generate_async`] on a scoped runtime). +/// Server code should prefer `generate_async` directly. +impl Generator for AnthropicApiGenerator { fn generate(&self, input: &GenInput<'_>) -> Result> { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() - .context("failed to build runtime for ClaudeGenerator")?; + .context("failed to build runtime for AnthropicApiGenerator")?; rt.block_on(self.generate_async(input)) } fn model_id(&self) -> String { - format!("claude-{}", self.model) + format!("anthropic-{}", self.model) } } diff --git a/src/generator/llm/claude_code.rs b/src/generator/llm/claude_code.rs new file mode 100644 index 0000000..0d9c563 --- /dev/null +++ b/src/generator/llm/claude_code.rs @@ -0,0 +1,60 @@ +//! Claude Code backend: drive the local `claude` CLI in headless print mode +//! (design §7). No network crate needed — this works in the default build as +//! long as the `claude` CLI is installed and authenticated. +//! +//! Invocation: `claude -p "" --output-format text [--model ]`. +//! Effort maps to a thinking-budget keyword appended to the prompt +//! (`think` / `ultrathink`), since Claude Code has no reasoning-effort flag. + +use std::process::Command; + +use anyhow::{Context, Result}; + +use super::{Effort, build_cli_prompt, parse_and_validate}; +use crate::generator::traits::{GenInput, Generator}; +use crate::ir::UiNode; + +pub struct ClaudeCodeGenerator { + model: Option, + effort: Option, +} + +impl ClaudeCodeGenerator { + pub fn new(model: Option, effort: Option) -> Self { + ClaudeCodeGenerator { model, effort } + } +} + +impl Generator for ClaudeCodeGenerator { + fn generate(&self, input: &GenInput<'_>) -> Result> { + let hint = self.effort.map(Effort::claude_think_hint).unwrap_or(""); + let prompt = build_cli_prompt(input.markdown, hint); + + let mut cmd = Command::new("claude"); + cmd.arg("-p") + .arg(&prompt) + .arg("--output-format") + .arg("text"); + if let Some(model) = &self.model { + cmd.arg("--model").arg(model); + } + + let output = cmd + .output() + .context("failed to run `claude` (Claude Code CLI); is it installed and on PATH?")?; + if !output.status.success() { + anyhow::bail!( + "claude exited with {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + ); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + parse_and_validate(&stdout, input.markdown) + } + + fn model_id(&self) -> String { + format!("claude-code-{}", self.model.as_deref().unwrap_or("default")) + } +} diff --git a/src/generator/llm/codex.rs b/src/generator/llm/codex.rs new file mode 100644 index 0000000..af42563 --- /dev/null +++ b/src/generator/llm/codex.rs @@ -0,0 +1,62 @@ +//! Codex backend: drive the local `codex` CLI non-interactively (design §7). +//! No network crate needed — works in the default build when the `codex` CLI is +//! installed and authenticated. +//! +//! Invocation: `codex exec [--model ] [-c model_reasoning_effort=""] +//! ""`. Codex supports a reasoning-effort config key, so `effort` maps +//! to it directly. + +use std::process::Command; + +use anyhow::{Context, Result}; + +use super::{Effort, build_cli_prompt, parse_and_validate}; +use crate::generator::traits::{GenInput, Generator}; +use crate::ir::UiNode; + +pub struct CodexGenerator { + model: Option, + effort: Option, +} + +impl CodexGenerator { + pub fn new(model: Option, effort: Option) -> Self { + CodexGenerator { model, effort } + } +} + +impl Generator for CodexGenerator { + fn generate(&self, input: &GenInput<'_>) -> Result> { + // Codex takes reasoning effort as a config key, not a prompt hint. + let prompt = build_cli_prompt(input.markdown, ""); + + let mut cmd = Command::new("codex"); + cmd.arg("exec"); + if let Some(model) = &self.model { + cmd.arg("--model").arg(model); + } + if let Some(effort) = self.effort { + cmd.arg("-c") + .arg(format!("model_reasoning_effort=\"{}\"", effort.as_str())); + } + cmd.arg(&prompt); + + let output = cmd + .output() + .context("failed to run `codex` (Codex CLI); is it installed and on PATH?")?; + if !output.status.success() { + anyhow::bail!( + "codex exited with {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + ); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + parse_and_validate(&stdout, input.markdown) + } + + fn model_id(&self) -> String { + format!("codex-{}", self.model.as_deref().unwrap_or("default")) + } +} diff --git a/src/generator/llm/mod.rs b/src/generator/llm/mod.rs index 8f85452..c913ced 100644 --- a/src/generator/llm/mod.rs +++ b/src/generator/llm/mod.rs @@ -1,11 +1,135 @@ -//! LLM-backed generation (`feature = "llm"`), design doc §7. +//! LLM-backed generation (design doc §7). //! -//! Only nodes that rules can't produce are delegated here, and every result is -//! re-validated by `ir::validate_json` before use. Falls back to rules when no -//! API key is configured. +//! Three interchangeable backends, selected by `[llm] provider` in config: +//! +//! - [`claude_code`] — shells out to the `claude` CLI (Claude Code). +//! - [`codex`] — shells out to the `codex` CLI (OpenAI Codex). +//! - [`anthropic`] — calls the Anthropic API directly (`feature = "llm"`, +//! needs `reqwest`/`tokio` + `ANTHROPIC_API_KEY`). +//! +//! The two CLI backends need no extra crates (just `std::process`), so they are +//! available in the default build; only the HTTP backend is feature-gated. +//! Every backend returns **UI IR only**, re-validated by [`crate::ir`] before +//! use — an LLM can never introduce a component outside the registry or a +//! fabricated range (§8). -pub mod claude; +pub mod claude_code; +pub mod codex; pub mod prompt; -#[allow(unused_imports)] -pub use claude::ClaudeGenerator; +#[cfg(feature = "llm")] +pub mod anthropic; + +use anyhow::{Context, Result}; +use clap::ValueEnum; +use serde::Deserialize; + +use crate::generator::traits::Generator; +use crate::ir::{LineIndex, UiNode, validate_json}; + +/// Build the single prompt string CLI backends receive (system + user prompt, +/// plus an optional trailing effort hint). `requested_kinds` empty = model's +/// discretion. +pub(crate) fn build_cli_prompt(markdown: &str, effort_hint: &str) -> String { + let mut p = format!( + "{}\n\n{}", + prompt::system_prompt(), + prompt::user_prompt(markdown, &[]) + ); + if !effort_hint.is_empty() { + p.push_str(&format!("\n\n{effort_hint}.")); + } + p +} + +/// Parse CLI stdout into validated UI IR (the §8 security boundary). +pub(crate) fn parse_and_validate(stdout: &str, markdown: &str) -> Result> { + let total_lines = LineIndex::new(markdown).line_count(); + let json = prompt::extract_json_array(stdout); + validate_json(json, total_lines).context("LLM output failed IR validation") +} + +/// Which LLM backend to drive. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, ValueEnum)] +#[serde(rename_all = "snake_case")] +#[clap(rename_all = "snake_case")] +pub enum LlmProvider { + /// Anthropic API over HTTP (default; requires `--features llm`). + #[default] + AnthropicApi, + /// The `claude` CLI (Claude Code), run in headless print mode. + ClaudeCode, + /// The `codex` CLI (OpenAI Codex), run via `codex exec`. + Codex, +} + +/// Reasoning effort, mapped per-backend. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, ValueEnum)] +#[serde(rename_all = "lowercase")] +#[clap(rename_all = "lowercase")] +pub enum Effort { + Low, + Medium, + High, +} + +impl Effort { + /// Canonical string (used for Codex `model_reasoning_effort`). + pub fn as_str(self) -> &'static str { + match self { + Effort::Low => "low", + Effort::Medium => "medium", + Effort::High => "high", + } + } + + /// Claude Code has no effort flag; steer its thinking budget with a prompt + /// keyword instead (empty = no hint). + pub fn claude_think_hint(self) -> &'static str { + match self { + Effort::Low => "", + Effort::Medium => "think", + Effort::High => "ultrathink", + } + } +} + +/// Resolved LLM backend selection (provider + optional model + effort). Built +/// from `[llm]` config merged with any `mdpeek gen` CLI overrides. +#[derive(Debug, Clone)] +pub struct LlmBackendConfig { + pub provider: LlmProvider, + pub model: Option, + pub effort: Option, +} + +impl LlmBackendConfig { + /// Instantiate the concrete [`Generator`] for this configuration. + pub fn build(&self) -> Result> { + match self.provider { + LlmProvider::ClaudeCode => Ok(Box::new(claude_code::ClaudeCodeGenerator::new( + self.model.clone(), + self.effort, + ))), + LlmProvider::Codex => Ok(Box::new(codex::CodexGenerator::new( + self.model.clone(), + self.effort, + ))), + LlmProvider::AnthropicApi => { + #[cfg(feature = "llm")] + { + Ok(Box::new(anthropic::AnthropicApiGenerator::new( + self.model.clone(), + ))) + } + #[cfg(not(feature = "llm"))] + { + anyhow::bail!( + "provider \"anthropic_api\" needs a build with `--features llm`; \ + use provider \"claude_code\" or \"codex\" for a default build" + ) + } + } + } + } +} diff --git a/src/generator/llm/prompt.rs b/src/generator/llm/prompt.rs index 3f58e6b..ff9df5e 100644 --- a/src/generator/llm/prompt.rs +++ b/src/generator/llm/prompt.rs @@ -56,6 +56,18 @@ pub fn strip_code_fence(text: &str) -> &str { t.trim().strip_suffix("```").unwrap_or(t).trim() } +/// Extract the JSON array from noisy CLI output (Claude Code / Codex may print +/// preamble, logs or a trailing summary around the payload). Strips a code fence +/// first, then narrows to the outermost `[` … `]`. Falls back to the fence- +/// stripped text so the validator produces a clear error if nothing matches. +pub fn extract_json_array(text: &str) -> &str { + let t = strip_code_fence(text.trim()); + match (t.find('['), t.rfind(']')) { + (Some(start), Some(end)) if end > start => &t[start..=end], + _ => t, + } +} + #[cfg(test)] mod tests { use super::*; @@ -66,6 +78,13 @@ mod tests { assert_eq!(strip_code_fence("[]"), "[]"); } + #[test] + fn extracts_array_from_noise() { + let out = "Thinking...\nHere is the IR:\n```json\n[{\"kind\":\"Callout\"}]\n```\nDone."; + assert_eq!(extract_json_array(out), "[{\"kind\":\"Callout\"}]"); + assert_eq!(extract_json_array("prefix [1,2] suffix"), "[1,2]"); + } + #[test] fn system_prompt_lists_allowed_kinds() { let s = system_prompt(); diff --git a/src/generator/mod.rs b/src/generator/mod.rs index 6cb5d8f..8402e2c 100644 --- a/src/generator/mod.rs +++ b/src/generator/mod.rs @@ -7,15 +7,10 @@ //! The [`traits`] module defines the `Generator` contract and the lightweight //! [`traits::GenInput`] stand-in for Layer 2's `DocumentModel`. +pub mod llm; pub mod rules; pub mod traits; -// Scaffolding for the deferred server integration (design §7): constructed once -// `/api/gui` drives it. `allow(dead_code)` until that wiring lands. -#[cfg(feature = "llm")] -#[allow(dead_code)] -pub mod llm; - pub use rules::RulesGenerator; #[allow(unused_imports)] pub use traits::{DocType, GenInput, Generator}; diff --git a/src/gui.rs b/src/gui.rs index f7f60c6..8556bf0 100644 --- a/src/gui.rs +++ b/src/gui.rs @@ -11,6 +11,7 @@ use std::path::Path; use anyhow::{Context, Result}; use crate::cache::{CacheStore, GuiCacheEntry, content_hash}; +use crate::generator::llm::LlmBackendConfig; use crate::generator::{GenInput, Generator, RulesGenerator}; use crate::ir::{LineIndex, UiNode, validate_nodes}; @@ -45,9 +46,62 @@ pub fn generate(markdown: &str, cache_root: Option<&Path>) -> Result) -> Result { - let entry = generate(markdown, cache_root)?; +/// Generate validated UI IR using the configured LLM [`backend`], with the +/// deterministic [`RulesGenerator`] as a fallback when the backend fails +/// (missing CLI, no API key, network error, invalid output). Uses the on-disk +/// cache keyed by the backend's model id. +pub fn generate_with_llm( + markdown: &str, + cache_root: Option<&Path>, + backend: &LlmBackendConfig, +) -> Result { + let generator = match backend.build() { + Ok(g) => g, + Err(e) => { + eprintln!("mdpeek: LLM backend unavailable ({e}); using rules"); + return generate(markdown, cache_root); + } + }; + let model_id = generator.model_id(); + + if let Some(root) = cache_root + && let Some(entry) = CacheStore::new(root).get(markdown, &model_id) + { + return Ok(entry); + } + + let total_lines = LineIndex::new(markdown).line_count(); + let nodes = match generator.generate(&GenInput::new(markdown)) { + Ok(mut nodes) => { + // Backends validate internally; re-run for defence in depth. + validate_nodes(&mut nodes, total_lines).context("LLM IR failed validation")?; + nodes + } + Err(e) => { + eprintln!("mdpeek: LLM generation failed ({e}); falling back to rules"); + return generate(markdown, cache_root); + } + }; + + let hash = content_hash(markdown, &model_id); + let entry = GuiCacheEntry::new("generic".to_string(), nodes, model_id, hash); + if let Some(root) = cache_root { + let _ = CacheStore::new(root).put(&entry); + } + Ok(entry) +} + +/// Convenience: pretty-printed UI IR JSON for the `gen` CLI command. When +/// `backend` is `Some`, uses the configured LLM; otherwise rules only. +pub fn generate_json( + markdown: &str, + cache_root: Option<&Path>, + backend: Option<&LlmBackendConfig>, +) -> Result { + let entry = match backend { + Some(b) => generate_with_llm(markdown, cache_root, b)?, + None => generate(markdown, cache_root)?, + }; serde_json::to_string_pretty(&entry.ui_ir).context("serializing UI IR") } @@ -70,7 +124,7 @@ mod tests { #[test] fn produces_valid_json() { let md = "| a | b |\n|---|---|\n| 1 | 2 |\n"; - let json = generate_json(md, None).unwrap(); + let json = generate_json(md, None, None).unwrap(); assert!(json.contains("DataTable")); } } diff --git a/src/ir/registry.rs b/src/ir/registry.rs index bea75c2..c3c4618 100644 --- a/src/ir/registry.rs +++ b/src/ir/registry.rs @@ -37,7 +37,6 @@ pub fn is_allowed(kind: &str) -> bool { } /// All allowed kinds (core + domain), for diagnostics / TS generation checks. -#[allow(dead_code)] pub fn all_kinds() -> impl Iterator { CORE_KINDS.iter().chain(DOMAIN_KINDS.iter()).copied() } diff --git a/src/ir/validate.rs b/src/ir/validate.rs index b79b122..d014b98 100644 --- a/src/ir/validate.rs +++ b/src/ir/validate.rs @@ -25,7 +25,6 @@ pub enum ValidateError { UnknownKind(String), #[error("sourceRange {0:?} is outside the document (1..={1} lines)")] RangeOutOfBounds(SourceRange, u32), - #[allow(dead_code)] #[error("invalid IR JSON: {0}")] Schema(String), } @@ -97,8 +96,7 @@ fn nested_ranges(node: &UiNode) -> Vec { } /// Parse untrusted JSON (e.g. LLM output) into validated nodes. Combines schema -/// (serde) + allowlist + bounds. This is the entry point for `ClaudeGenerator`. -#[allow(dead_code)] +/// (serde) + allowlist + bounds. Entry point for the LLM backends. pub fn validate_json(json: &str, total_lines: u32) -> Result, ValidateError> { let mut nodes: Vec = serde_json::from_str(json).map_err(|e| ValidateError::Schema(e.to_string()))?; diff --git a/src/main.rs b/src/main.rs index bcfd24e..492ea99 100644 --- a/src/main.rs +++ b/src/main.rs @@ -44,14 +44,23 @@ fn main() -> Result<()> { theme, pager, } => handle_term(file, watch, theme, pager), - Mode::Gen { file, no_cache } => handle_gen(file, no_cache)?, + Mode::Gen { + file, + no_cache, + llm, + } => handle_gen(file, no_cache, llm)?, } Ok(()) } /// Generate Generative-UI IR (Layer 3) for `root` and print the JSON to stdout. -/// The cache lives under `.cache/mdpeek/` in the current directory. -fn handle_gen(root: PathBuf, no_cache: bool) -> Result<()> { +/// The cache lives under `.cache/mdpeek/` in the current directory. When `llm` +/// is set, the configured LLM backend is used (with a rules fallback). +fn handle_gen( + root: PathBuf, + no_cache: bool, + llm: Option, +) -> Result<()> { if !root.is_file() { anyhow::bail!("'{}' is not a file.", root.display()); } @@ -61,7 +70,7 @@ fn handle_gen(root: PathBuf, no_cache: bool) -> Result<()> { } else { Some(std::path::Path::new(".")) }; - let json = gui::generate_json(&markdown, cache_root)?; + let json = gui::generate_json(&markdown, cache_root, llm.as_ref())?; println!("{json}"); Ok(()) } diff --git a/tests/gen_output.rs b/tests/gen_output.rs index 6cf779e..a982eaa 100644 --- a/tests/gen_output.rs +++ b/tests/gen_output.rs @@ -50,6 +50,28 @@ fn gen_writes_cache_file() { assert_eq!(entries.len(), 1, "expected one cached .gui.json file"); } +#[test] +fn gen_llm_falls_back_to_rules_when_backend_unavailable() { + // In a default build the `anthropic_api` backend needs `--features llm`, so + // `build()` errors and generation must fall back to deterministic rules + // rather than failing the command. + let dir = tempfile::tempdir().unwrap(); + let md = "- [ ] task a\n- [x] task b\n"; + let file = write_md(&dir, "tasks.md", md); + + Command::cargo_bin("mdpeek") + .unwrap() + .arg("gen") + .arg(&file) + .arg("--no-cache") + .arg("--llm") + .arg("--provider") + .arg("anthropic_api") + .assert() + .success() + .stdout(predicate::str::contains("Checklist")); +} + #[test] fn gen_rejects_directory() { let dir = tempfile::tempdir().unwrap(); From d1ebb422abfb2ab20a83aa64e7f1b37b1a779680 Mon Sep 17 00:00:00 2001 From: tkcd Date: Sun, 5 Jul 2026 14:32:35 +0900 Subject: [PATCH 03/19] style(fonts): add explicit CJK fallback fonts to the UI font stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TOC sidebar (and the whole page) inherited the body font stack, which had no Japanese font — so CJK text fell back to whatever the OS/browser substituted. Add Hiragino Sans / Yu Gothic / Meiryo / Noto Sans CJK JP before the generic `sans-serif` in both server themes (static/css/github-{light,dark}.css) and the Layer 3 web island (web/src/styles.css). Latin still prefers the system UI font; only CJK glyphs pick the new fonts. Verified: rebuilt server serves the updated embedded CSS for both themes. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01HXGWTTALewHoUjGfVkDwEV --- static/css/github-dark.css | 2 +- static/css/github-light.css | 2 +- web/src/styles.css | 4 +++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/static/css/github-dark.css b/static/css/github-dark.css index c9f9489..3e8b720 100644 --- a/static/css/github-dark.css +++ b/static/css/github-dark.css @@ -6,7 +6,7 @@ margin: 0; color: #e6edf3; background-color: #0d1117; - font-family: -apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji"; + font-family: -apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans",Helvetica,Arial,"Hiragino Sans","Hiragino Kaku Gothic ProN","Yu Gothic",YuGothic,Meiryo,"Noto Sans CJK JP","Noto Sans JP",sans-serif,"Apple Color Emoji","Segoe UI Emoji"; font-size: 16px; line-height: 1.5; word-wrap: break-word; diff --git a/static/css/github-light.css b/static/css/github-light.css index d65fee2..8eb5b56 100644 --- a/static/css/github-light.css +++ b/static/css/github-light.css @@ -6,7 +6,7 @@ margin: 0; color: #1f2328; background-color: #ffffff; - font-family: -apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji"; + font-family: -apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans",Helvetica,Arial,"Hiragino Sans","Hiragino Kaku Gothic ProN","Yu Gothic",YuGothic,Meiryo,"Noto Sans CJK JP","Noto Sans JP",sans-serif,"Apple Color Emoji","Segoe UI Emoji"; font-size: 16px; line-height: 1.5; word-wrap: break-word; diff --git a/web/src/styles.css b/web/src/styles.css index 435f145..fb14314 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -29,7 +29,9 @@ * { box-sizing: border-box; } body { margin: 0; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, + "Hiragino Sans", "Hiragino Kaku Gothic ProN", "Yu Gothic", YuGothic, Meiryo, + "Noto Sans CJK JP", "Noto Sans JP", sans-serif; background: var(--bg); color: var(--fg); } From 655470da68732f69321f39c6b50bb7ffb5936ff7 Mon Sep 17 00:00:00 2001 From: tkcd Date: Sun, 5 Jul 2026 14:36:33 +0900 Subject: [PATCH 04/19] style(fonts): unify TOC/sidebar/toolbar onto the main content font MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The font stack lives on `.markdown-body` (the content
), but the TOC, file sidebar, toolbar and front-matter panel are siblings outside it, so they fell back to the browser default (serif) — English and Japanese both looked different from the content. Set the same stack on `body` (inherited by all the chrome) and add `input,button,select,textarea { font-family: inherit }` so the TOC search box matches too. Placed in the index.html inline style (theme- independent), kept identical to `.markdown-body`. Verified: served page exposes the body font rule for both themes. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01HXGWTTALewHoUjGfVkDwEV --- static/index.html | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/static/index.html b/static/index.html index 0c0c718..cb8ed72 100644 --- a/static/index.html +++ b/static/index.html @@ -11,6 +11,19 @@