From 61601057123e910b0fa11a5d020bb6905a571190 Mon Sep 17 00:00:00 2001 From: Jacob Rothstein Date: Sat, 14 Feb 2026 09:36:00 -0800 Subject: [PATCH] feat: experimental web server --- Cargo.lock | 497 +++++-- ferritin-common/Cargo.toml | 7 +- ferritin/Cargo.toml | 54 +- ferritin/src/color_scheme.rs | 2 +- ferritin/src/commands.rs | 43 +- ferritin/src/commands/get.rs | 24 +- ferritin/src/commands/list.rs | 7 +- ferritin/src/commands/search.rs | 21 +- .../src/{styled_string.rs => document.rs} | 76 +- ferritin/src/format/documentation.rs | 2 +- ferritin/src/format/enum.rs | 2 +- ferritin/src/format/functions.rs | 2 +- ferritin/src/format/impls.rs | 2 +- ferritin/src/format/items.rs | 2 +- ferritin/src/format/mod.rs | 2 +- ferritin/src/format/module.rs | 2 +- ferritin/src/format/source.rs | 2 +- ferritin/src/format/struct.rs | 8 +- ferritin/src/format/trait.rs | 2 +- ferritin/src/format/types.rs | 2 +- ferritin/src/main.rs | 26 +- ferritin/src/markdown.rs | 13 +- ferritin/src/renderer/interactive/channels.rs | 8 +- ferritin/src/renderer/interactive/dev_log.rs | 2 +- ferritin/src/renderer/interactive/events.rs | 4 +- ferritin/src/renderer/interactive/keyboard.rs | 2 +- ferritin/src/renderer/interactive/mod.rs | 9 +- ferritin/src/renderer/interactive/mouse.rs | 2 +- .../renderer/interactive/render_document.rs | 6 +- .../src/renderer/interactive/render_frame.rs | 2 +- .../src/renderer/interactive/render_node.rs | 2 +- .../src/renderer/interactive/render_span.rs | 2 +- .../src/renderer/interactive/render_table.rs | 2 +- .../interactive/render_theme_picker.rs | 2 +- .../renderer/interactive/request_thread.rs | 51 +- ferritin/src/renderer/interactive/response.rs | 9 +- .../src/renderer/interactive/span_style.rs | 2 +- ferritin/src/renderer/interactive/state.rs | 2 +- ferritin/src/renderer/interactive/tests.rs | 184 ++- ferritin/src/renderer/interactive/utils.rs | 2 +- ferritin/src/renderer/mod.rs | 6 +- ferritin/src/renderer/plain.rs | 12 +- ferritin/src/renderer/test_mode.rs | 16 +- ferritin/src/renderer/tty.rs | 59 +- ferritin/src/renderer/tty/tests.rs | 38 + ..._fuzzy_matching_suggestions_test_mode.snap | 2 +- ...uzzy_matching_trait_methods_test_mode.snap | 2 +- ..._tests__fuzzy_matching_typo_test_mode.snap | 2 +- ...in__tests__nonexistent_item_test_mode.snap | 2 +- ferritin/src/tests.rs | 8 +- ferritin/src/web/handlers.rs | 190 +++ ferritin/src/web/json_document.rs | 243 ++++ ferritin/src/web/mod.rs | 6 + ferritin/src/web/server.rs | 64 + ferritin/src/web/transform.rs | 364 +++++ release-plz.toml | 5 + web/.gitignore | 24 + web/README.md | 32 + web/index.html | 12 + web/package.json | 23 + web/pnpm-lock.yaml | 1274 +++++++++++++++++ web/src/App.tsx | 61 + web/src/api/client.ts | 37 + web/src/components/DocumentRenderer.tsx | 12 + web/src/components/NodeRenderer.tsx | 106 ++ web/src/components/SpanRenderer.tsx | 42 + web/src/index.css | 99 ++ web/src/main.tsx | 10 + web/src/types/api.ts | 48 + web/src/vite-env.d.ts | 1 + web/tsconfig.json | 23 + web/tsconfig.node.json | 11 + web/vite.config.ts | 6 + 73 files changed, 3465 insertions(+), 466 deletions(-) rename ferritin/src/{styled_string.rs => document.rs} (91%) create mode 100644 ferritin/src/renderer/tty/tests.rs create mode 100644 ferritin/src/web/handlers.rs create mode 100644 ferritin/src/web/json_document.rs create mode 100644 ferritin/src/web/mod.rs create mode 100644 ferritin/src/web/server.rs create mode 100644 ferritin/src/web/transform.rs create mode 100644 web/.gitignore create mode 100644 web/README.md create mode 100644 web/index.html create mode 100644 web/package.json create mode 100644 web/pnpm-lock.yaml create mode 100644 web/src/App.tsx create mode 100644 web/src/api/client.ts create mode 100644 web/src/components/DocumentRenderer.tsx create mode 100644 web/src/components/NodeRenderer.tsx create mode 100644 web/src/components/SpanRenderer.tsx create mode 100644 web/src/index.css create mode 100644 web/src/main.tsx create mode 100644 web/src/types/api.ts create mode 100644 web/src/vite-env.d.ts create mode 100644 web/tsconfig.json create mode 100644 web/tsconfig.node.json create mode 100644 web/vite.config.ts diff --git a/Cargo.lock b/Cargo.lock index a523b24..6eeef3e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -72,7 +72,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -83,7 +83,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -92,6 +92,17 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "async-channel" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35" +dependencies = [ + "concurrent-queue", + "event-listener 2.5.3", + "futures-core", +] + [[package]] name = "async-channel" version = "2.5.0" @@ -104,16 +115,26 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-dup" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c2886ab563af5038f79ec016dd7b87947ed138b794e8dd64992962c9cca0411" +dependencies = [ + "async-lock", + "futures-io", +] + [[package]] name = "async-executor" -version = "1.13.3" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" dependencies = [ "async-task", "concurrent-queue", - "fastrand", - "futures-lite", + "fastrand 2.3.0", + "futures-lite 2.6.1", "pin-project-lite", "slab", ] @@ -126,7 +147,7 @@ checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" dependencies = [ "async-lock", "blocking", - "futures-lite", + "futures-lite 2.6.1", ] [[package]] @@ -135,12 +156,12 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05b1b633a2115cd122d73b955eadd9916c18c8f510ec9cd1686404c60ad1c29c" dependencies = [ - "async-channel", + "async-channel 2.5.0", "async-executor", "async-io", "async-lock", "blocking", - "futures-lite", + "futures-lite 2.6.1", "once_cell", ] @@ -154,7 +175,7 @@ dependencies = [ "cfg-if", "concurrent-queue", "futures-io", - "futures-lite", + "futures-lite 2.6.1", "parking", "polling", "rustix", @@ -181,7 +202,7 @@ checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" dependencies = [ "async-io", "blocking", - "futures-lite", + "futures-lite 2.6.1", ] [[package]] @@ -249,28 +270,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" -[[package]] -name = "aws-lc-rs" -version = "1.15.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b7b6141e96a8c160799cc2d5adecd5cbbe5054cb8c7c4af53da0f83bb7ad256" -dependencies = [ - "aws-lc-sys", - "zeroize", -] - -[[package]] -name = "aws-lc-sys" -version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c34dda4df7017c8db52132f0f8a2e0f8161649d15723ed63fc00c82d0f2081a" -dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", -] - [[package]] name = "base64" version = "0.22.1" @@ -328,10 +327,10 @@ version = "1.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" dependencies = [ - "async-channel", + "async-channel 2.5.0", "async-task", "futures-io", - "futures-lite", + "futures-lite 2.6.1", "piper", ] @@ -420,9 +419,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.55" +version = "1.2.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" dependencies = [ "find-msvc-tools", "jobserver", @@ -448,6 +447,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "cidr" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bdf600c45bd958cf2945c445264471cca8b6c8e67bc87b71affd6d7e5682621" + [[package]] name = "clap" version = "4.5.60" @@ -488,21 +493,22 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" -[[package]] -name = "cmake" -version = "0.1.57" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" -dependencies = [ - "cc", -] - [[package]] name = "colorchoice" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "colored" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" +dependencies = [ + "lazy_static", + "windows-sys 0.59.0", +] + [[package]] name = "combine" version = "4.6.7" @@ -736,9 +742,9 @@ checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" [[package]] name = "deranged" -version = "0.5.5" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", ] @@ -793,7 +799,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -816,12 +822,6 @@ dependencies = [ "litrs", ] -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - [[package]] name = "dyn-clone" version = "1.0.20" @@ -894,7 +894,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -906,6 +906,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + [[package]] name = "event-listener" version = "4.0.3" @@ -948,6 +954,15 @@ dependencies = [ "regex", ] +[[package]] +name = "fastrand" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be" +dependencies = [ + "instant", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -983,14 +998,23 @@ dependencies = [ "paste", "percent-encoding", "pulldown-cmark", + "querystrong", "ratatui", "regex", "rustdoc-types 0.57.0", "semver", + "serde", + "sonic-rs", "strip-ansi-escapes", "syntect", "terminal_size", "thiserror 2.0.18", + "trillium", + "trillium-frontend", + "trillium-logger", + "trillium-router", + "trillium-smol", + "trillium-testing", "unicode-width", "webbrowser", ] @@ -1112,12 +1136,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "fs_extra" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - [[package]] name = "fsevent-sys" version = "4.1.0" @@ -1127,17 +1145,43 @@ dependencies = [ "libc", ] +[[package]] +name = "full-duplex-async-copy" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb69fcc211c8359cc56981e36370c21f6410d6f9820c68dab273bed4bc58d99e" +dependencies = [ + "fastrand 1.9.0", + "futures-lite 1.13.0", + "pin-project-lite", +] + [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-io" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49a9d51ce47660b1e808d3c990b4709f2f415d928835a17dfd16991515c46bce" +dependencies = [ + "fastrand 1.9.0", + "futures-core", + "futures-io", + "memchr", + "parking", + "pin-project-lite", + "waker-fn", +] [[package]] name = "futures-lite" @@ -1145,7 +1189,7 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" dependencies = [ - "fastrand", + "fastrand 2.3.0", "futures-core", "futures-io", "parking", @@ -1484,6 +1528,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1563,9 +1616,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.85" +version = "0.3.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" dependencies = [ "once_cell", "wasm-bindgen", @@ -1790,6 +1843,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -1900,7 +1963,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1940,9 +2003,9 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" dependencies = [ "objc2-encode", ] @@ -2156,9 +2219,9 @@ dependencies = [ [[package]] name = "pin-project-lite" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "piper" @@ -2167,7 +2230,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" dependencies = [ "atomic-waker", - "fastrand", + "fastrand 2.3.0", "futures-io", ] @@ -2219,6 +2282,15 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "portpicker" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be97d76faf1bfab666e1375477b23fde79eccf0276e9b63b92a39d676a889ba9" +dependencies = [ + "rand", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -2234,6 +2306,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -2292,6 +2373,16 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" +[[package]] +name = "querystrong" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6763dfe63d3d703b2c0fa28abb3bc2955555bccad333bcff115417f143d081df" +dependencies = [ + "memchr", + "thiserror 2.0.18", +] + [[package]] name = "quick-xml" version = "0.38.4" @@ -2331,6 +2422,18 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", "rand_core", ] @@ -2339,6 +2442,9 @@ name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] [[package]] name = "ratatui" @@ -2510,9 +2616,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.9" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "rend" @@ -2576,6 +2682,17 @@ dependencies = [ "libc", ] +[[package]] +name = "routefinder" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0971d3c8943a6267d6bd0d782fdc4afa7593e7381a92a3df950ff58897e066b5" +dependencies = [ + "memchr", + "smartcow", + "smartstring", +] + [[package]] name = "rustc-hash" version = "2.1.1" @@ -2654,18 +2771,18 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] name = "rustls" -version = "0.23.36" +version = "0.23.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ - "aws-lc-rs", "log", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -2684,15 +2801,6 @@ dependencies = [ "security-framework", ] -[[package]] -name = "rustls-pemfile" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "rustls-pki-types" version = "1.14.0" @@ -2720,7 +2828,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2735,7 +2843,6 @@ version = "0.103.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" dependencies = [ - "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -2810,9 +2917,9 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "security-framework" -version = "3.5.1" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags 2.11.0", "core-foundation", @@ -2823,9 +2930,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.15.0" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" dependencies = [ "core-foundation-sys", "libc", @@ -2997,6 +3104,17 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "sluice" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d7400c0eff44aa2fcb5e31a5f24ba9716ed90138769e4977a2ba6014ae63eb5" +dependencies = [ + "async-channel 1.9.0", + "futures-core", + "futures-io", +] + [[package]] name = "smallvec" version = "1.15.1" @@ -3025,9 +3143,9 @@ dependencies = [ [[package]] name = "sonic-number" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8a74044c092f4f43ca7a6cfd62854cf9fb5ac8502b131347c990bf22bef1dfe" +checksum = "5661364b38abad49cf1ade6631fcc35d2ccf882a7d68616b4228b7717feb5fba" dependencies = [ "cfg-if", ] @@ -3055,9 +3173,9 @@ dependencies = [ [[package]] name = "sonic-simd" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5707edbfb34a40c9f2a55fa09a49101d9fec4e0cc171ce386086bd9616f34257" +checksum = "e9f944718c33623919878cf74b4c9361eb3024f635733922b26722b14cd3f8cc" dependencies = [ "cfg-if", ] @@ -3081,7 +3199,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88bc3cb56a90ab1d29db096b9a448424e2c80cb57b4598a392b01bb4e9f5e021" dependencies = [ "event-listener 5.4.1", - "futures-lite", + "futures-lite 2.6.1", "pin-project-lite", ] @@ -3208,11 +3326,11 @@ version = "3.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" dependencies = [ - "fastrand", + "fastrand 2.3.0", "getrandom 0.4.1", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3464,7 +3582,7 @@ dependencies = [ "crossbeam-queue", "dashmap", "encoding_rs", - "futures-lite", + "futures-lite 2.6.1", "httparse", "log", "memchr", @@ -3474,6 +3592,43 @@ dependencies = [ "trillium-server-common", ] +[[package]] +name = "trillium-forwarding" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683796aeee40649ad14ef91d90c4963ffeea3ff283edfb9b0ea750eea03c40c0" +dependencies = [ + "cidr", + "log", + "trillium", +] + +[[package]] +name = "trillium-frontend" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6da23094411724259aa8ccf307ded1913d5445a3aab5cb5d0cacaf6a433aaaf4" +dependencies = [ + "fieldwork", + "log", + "portpicker", + "trillium", + "trillium-client", + "trillium-frontend-macros", + "trillium-proxy", + "trillium-static-compiled", +] + +[[package]] +name = "trillium-frontend-macros" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0f53fdec3eb0d0665632aedb6bbc3668d553ec657d9d899000078f7c086a15" +dependencies = [ + "proc-macro2", + "quote", +] + [[package]] name = "trillium-http" version = "0.3.17" @@ -3481,7 +3636,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd4c063660875422d9f85a334931963fc301a3ede9a5c39de7c968ab0801ce33" dependencies = [ "encoding_rs", - "futures-lite", + "futures-lite 2.6.1", "hashbrown 0.14.5", "httparse", "httpdate", @@ -3496,6 +3651,19 @@ dependencies = [ "trillium-macros", ] +[[package]] +name = "trillium-logger" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da5e9b6c08a27d991b4a9c73dd7276c6f181fd4dee7218723b0fbd7fca3a1659" +dependencies = [ + "colored", + "log", + "size", + "time", + "trillium", +] + [[package]] name = "trillium-macros" version = "0.0.6" @@ -3507,6 +3675,38 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "trillium-proxy" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa129d743f63b831488baf4af0c1ca59d1a2a497ae6e8b37d5a61bc770812941" +dependencies = [ + "event-listener 4.0.3", + "fastrand 2.3.0", + "full-duplex-async-copy", + "futures-lite 2.6.1", + "log", + "size", + "sluice", + "trillium", + "trillium-client", + "trillium-forwarding", + "trillium-http", + "trillium-server-common", + "url", +] + +[[package]] +name = "trillium-router" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a7aed20d63101d7dcd165fd047141423009a7f4ccfc75db5b875312d8127dbe" +dependencies = [ + "log", + "routefinder", + "trillium", +] + [[package]] name = "trillium-rustls" version = "0.9.0" @@ -3515,7 +3715,6 @@ checksum = "0501210eedcb4eae5d4a48c7855632f4be7fac5f57c7c772f5b1e598d6db53ff" dependencies = [ "futures-rustls", "log", - "rustls-pemfile", "rustls-platform-verifier", "trillium-server-common", "webpki-roots", @@ -3530,7 +3729,7 @@ dependencies = [ "async-trait", "async_cell", "event-listener 4.0.3", - "futures-lite", + "futures-lite 2.6.1", "log", "pin-project-lite", "rlimit", @@ -3549,7 +3748,7 @@ dependencies = [ "async-io", "async-net", "async-signal", - "futures-lite", + "futures-lite 2.6.1", "log", "signal-hook", "trillium", @@ -3558,6 +3757,52 @@ dependencies = [ "trillium-server-common", ] +[[package]] +name = "trillium-static-compiled" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b9803b8da1fc54abadc8ebaa81bfaf3458a3d77b101483061134f989f6a49c7" +dependencies = [ + "httpdate", + "log", + "mime", + "mime_guess", + "trillium", + "trillium-static-compiled-macros", +] + +[[package]] +name = "trillium-static-compiled-macros" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f224fd6a9ad6036a0a5c955c1b8289e6e718136cb51c07a36938d1f48fc7e7b1" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "trillium-testing" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6c5d4d9d6f6844131f166cbe4d779894f09f9b846945ab2024272bcd6e198c" +dependencies = [ + "async-channel 2.5.0", + "async-dup", + "cfg-if", + "dashmap", + "fastrand 2.3.0", + "futures-lite 2.6.1", + "once_cell", + "portpicker", + "trillium", + "trillium-http", + "trillium-macros", + "trillium-server-common", + "trillium-smol", + "url", +] + [[package]] name = "typenum" version = "1.19.0" @@ -3683,6 +3928,12 @@ dependencies = [ "utf8parse", ] +[[package]] +name = "waker-fn" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7" + [[package]] name = "walkdir" version = "2.5.0" @@ -3719,9 +3970,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.108" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" dependencies = [ "cfg-if", "once_cell", @@ -3732,9 +3983,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.108" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3742,9 +3993,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.108" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" dependencies = [ "bumpalo", "proc-macro2", @@ -3755,9 +4006,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.108" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" dependencies = [ "unicode-ident", ] @@ -3798,9 +4049,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.85" +version = "0.3.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" dependencies = [ "js-sys", "wasm-bindgen", @@ -3934,7 +4185,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4317,18 +4568,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.39" +version = "0.8.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +checksum = "a789c6e490b576db9f7e6b6d661bcc9799f7c0ac8352f56ea20193b2681532e5" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.39" +version = "0.8.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953" dependencies = [ "proc-macro2", "quote", diff --git a/ferritin-common/Cargo.toml b/ferritin-common/Cargo.toml index 3245dc6..86a5b8c 100644 --- a/ferritin-common/Cargo.toml +++ b/ferritin-common/Cargo.toml @@ -8,7 +8,12 @@ repository.workspace = true license.workspace = true [dependencies] -trillium-rustls = { version = "0.9.0", features = ["client"] } +trillium-rustls = { version = "0.9.0", features = [ + "platform-verifier", + "client", + "tls12", + "ring", +], default-features = false } trillium-smol = "0.4.2" anyhow.workspace = true cargo_metadata.workspace = true diff --git a/ferritin/Cargo.toml b/ferritin/Cargo.toml index 77f8d4f..39f6753 100644 --- a/ferritin/Cargo.toml +++ b/ferritin/Cargo.toml @@ -7,10 +7,11 @@ readme = "../README.md" repository.workspace = true license.workspace = true include = [ - "src/**/*", - "build.rs", - "assets/**/*.tmTheme", - "CHANGELOG.md", + "src/**/*", + "build.rs", + "assets/**/*.tmTheme", + "CHANGELOG.md", + "web/dist", ] [[bin]] @@ -31,19 +32,58 @@ pulldown-cmark = "0.13" ratatui = "0.30" regex = "1.12" rustdoc-types.workspace = true -syntect = { version = "5.3", default-features = false, features = ["parsing", "default-syntaxes", "html", "plist-load", "yaml-load", "dump-load", "dump-create", "regex-onig"] } +syntect = { version = "5.3", default-features = false, features = [ + "parsing", + "default-syntaxes", + "html", + "plist-load", + "yaml-load", + "dump-load", + "dump-create", + "regex-onig", +] } terminal_size = "0.4" thiserror = "2" unicode-width = "0.2.2" webbrowser = "1.1.0" semver = "1.0.27" -percent-encoding = "2.3" +percent-encoding = "2.3.2" mimalloc = "0.1.48" +trillium = { version = "0.2.20", optional = true } +trillium-router = { version = "0.4.1", optional = true } +trillium-smol = { version = "0.4.2", optional = true } +querystrong = { version = "0.4.0", optional = true } +serde = { version = "1.0.228", features = ["derive"], optional = true } +sonic-rs = { version = "0.5.7", optional = true } +trillium-logger = { version = "0.4.5", optional = true } +trillium-frontend = { version = "0.2.0", optional = true } [build-dependencies] -syntect = { version = "5.3", default-features = false, features = ["parsing", "plist-load", "dump-load", "dump-create", "regex-onig"] } +syntect = { version = "5.3", default-features = false, features = [ + "parsing", + "plist-load", + "dump-load", + "dump-create", + "regex-onig", +] } [dev-dependencies] +serde = { version = "1.0.228", features = ["derive"] } insta = { version = "1.46.3", features = ["filters"] } paste = "1.0.15" strip-ansi-escapes = "0.2.1" +trillium-testing = { version = "0.7.0", features = ["smol"] } + +[features] +default = [] +web = ["serve-json", "dep:trillium-frontend"] +dev = ["trillium-frontend/dev-proxy"] +serve-json = [ + "dep:trillium", + "dep:trillium-router", + "dep:trillium-smol", + "dep:serde", + "dep:sonic-rs", + "dep:querystrong", + "dep:trillium-logger", +] diff --git a/ferritin/src/color_scheme.rs b/ferritin/src/color_scheme.rs index 11904c9..c8144f1 100644 --- a/ferritin/src/color_scheme.rs +++ b/ferritin/src/color_scheme.rs @@ -1,4 +1,4 @@ -use crate::styled_string::SpanStyle; +use crate::document::SpanStyle; use syntect::highlighting::{Color, Highlighter, Theme}; use syntect::parsing::{Scope, ScopeStack}; diff --git a/ferritin/src/commands.rs b/ferritin/src/commands.rs index 2bc56f3..5a0d38c 100644 --- a/ferritin/src/commands.rs +++ b/ferritin/src/commands.rs @@ -1,9 +1,8 @@ -use crate::renderer::HistoryEntry; +use crate::document::Document; use crate::request::Request; -use crate::styled_string::Document; use std::fmt::Display; -mod get; +pub(crate) mod get; pub(crate) mod list; pub(crate) mod search; @@ -39,6 +38,13 @@ pub(crate) enum Commands { /// List available crates List, + + #[cfg(feature = "serve-json")] + /// Start JSON API server (uses HOST and PORT env vars, defaults to 127.0.0.1:8080) + Serve { + #[arg(short, long)] + open: bool, + }, } impl Commands { @@ -108,36 +114,25 @@ impl Commands { } } - pub fn execute<'a>( - self, - request: &'a Request, - ) -> (Document<'a>, bool, Option>) { + pub fn execute<'a>(self, request: &'a Request) -> Document<'a> { match self { Commands::Get { path, source, recursive, - } => { - let (doc, is_error, item_ref) = get::execute(request, &path, source, recursive); - let history_entry = item_ref.map(HistoryEntry::Item); - (doc, is_error, history_entry) - } + } => get::execute(request, &path, source, recursive), + Commands::Search { query, limit, crate_, - } => { - let (doc, is_error) = search::execute(request, &query, limit, crate_.as_deref()); - let history_entry = Some(HistoryEntry::Search { - query, - crate_name: crate_, - }); - (doc, is_error, history_entry) - } - Commands::List => { - let (doc, is_error, default_crate) = list::execute(request); - let history_entry = Some(HistoryEntry::List { default_crate }); - (doc, is_error, history_entry) + } => search::execute(request, &query, limit, crate_.as_deref()), + + Commands::List => list::execute(request), + + #[cfg(feature = "serve-json")] + Commands::Serve { .. } => { + unreachable!("Serve command should be handled before execute() is called") } } } diff --git a/ferritin/src/commands/get.rs b/ferritin/src/commands/get.rs index 2f8d628..a427977 100644 --- a/ferritin/src/commands/get.rs +++ b/ferritin/src/commands/get.rs @@ -1,15 +1,15 @@ -use ferritin_common::DocRef; -use rustdoc_types::Item; +use std::time::Instant; +use crate::document::{Document, DocumentNode, ListItem, Span}; +use crate::renderer::HistoryEntry; use crate::request::Request; -use crate::styled_string::{Document, DocumentNode, ListItem, Span}; pub(crate) fn execute<'a>( request: &'a Request, path: &str, source: bool, recursive: bool, -) -> (Document<'a>, bool, Option>) { +) -> Document<'a> { request .format_context() .set_include_source(source) @@ -23,18 +23,22 @@ pub(crate) fn execute<'a>( if let Some(name) = item.name() { log::info!("Resolved {name}"); } - let start = std::time::Instant::now(); + let start = Instant::now(); let doc_nodes = request.format_item(item); let format_elapsed = start.elapsed(); if let Some(name) = item.name() { log::debug!("⏱️ Formatted {name} in {:?}", format_elapsed); } - (Document::from(doc_nodes), false, Some(item)) + Document::from(doc_nodes) + .with_item(item) + .with_history_entry(HistoryEntry::Item(item)) } None => { - let mut nodes = vec![DocumentNode::paragraph(vec![Span::plain(format!( - "Could not find '{path}'", - ))])]; + let mut nodes = vec![DocumentNode::paragraph(vec![ + Span::plain("Could not find '"), + Span::emphasis(path.to_string()), + Span::plain("'"), + ])]; if !suggestions.is_empty() { nodes.push(DocumentNode::paragraph(vec![Span::plain("Did you mean:")])); @@ -51,7 +55,7 @@ pub(crate) fn execute<'a>( nodes.push(DocumentNode::List { items }); } - (Document::from(nodes), true, None) + Document::from(nodes).with_error() } } } diff --git a/ferritin/src/commands/list.rs b/ferritin/src/commands/list.rs index 61283ee..ddabe29 100644 --- a/ferritin/src/commands/list.rs +++ b/ferritin/src/commands/list.rs @@ -1,7 +1,8 @@ +use crate::document::{Document, DocumentNode, HeadingLevel, ListItem, ShowWhen, Span}; +use crate::renderer::HistoryEntry; use crate::request::Request; -use crate::styled_string::{Document, DocumentNode, HeadingLevel, ListItem, ShowWhen, Span}; -pub(crate) fn execute<'a>(request: &'a Request) -> (Document<'a>, bool, Option<&'a str>) { +pub(crate) fn execute<'a>(request: &'a Request) -> Document<'a> { let mut nodes = vec![DocumentNode::Heading { level: HeadingLevel::Title, spans: vec![Span::plain("Available crates:")], @@ -99,5 +100,5 @@ pub(crate) fn execute<'a>(request: &'a Request) -> (Document<'a>, bool, Option<& }); } - (Document::from(nodes), false, default_crate) + Document::from(nodes).with_history_entry(HistoryEntry::List { default_crate }) } diff --git a/ferritin/src/commands/search.rs b/ferritin/src/commands/search.rs index 8ffc3c0..09765a9 100644 --- a/ferritin/src/commands/search.rs +++ b/ferritin/src/commands/search.rs @@ -1,12 +1,13 @@ +use crate::document::{Document, DocumentNode, HeadingLevel, ListItem, Span, TruncationLevel}; +use crate::renderer::HistoryEntry; use crate::request::Request; -use crate::styled_string::{Document, DocumentNode, HeadingLevel, ListItem, Span, TruncationLevel}; pub(crate) fn execute<'a>( request: &'a Request, query: &str, limit: usize, crate_: Option<&str>, -) -> (Document<'a>, bool) { +) -> Document<'a> { log::info!("Searching for {query}"); let crate_names: Vec<_> = match crate_ { @@ -54,7 +55,7 @@ pub(crate) fn execute<'a>( } } - return (Document::from(nodes), true); + return Document::from(nodes).with_error(); } }; @@ -64,7 +65,7 @@ pub(crate) fn execute<'a>( if scored_results.is_empty() { if query.is_empty() { // Empty query - show search instructions - let doc = Document::from(vec![ + return Document::from(vec![ DocumentNode::Heading { level: HeadingLevel::Title, spans: vec![Span::plain("Search")], @@ -73,10 +74,9 @@ pub(crate) fn execute<'a>( "Type to search. Press Tab to toggle between current crate and all crates.", )]), ]); - return (doc, false); } else { // No matches for query - let error_doc = Document::from(vec![ + return Document::from(vec![ DocumentNode::Heading { level: HeadingLevel::Title, spans: vec![Span::plain("No results")], @@ -86,8 +86,8 @@ pub(crate) fn execute<'a>( Span::plain(query.to_string()), Span::plain("'"), ]), - ]); - return (error_doc, false); + ]) + .with_error(); } } @@ -157,5 +157,8 @@ pub(crate) fn execute<'a>( nodes.push(DocumentNode::List { items: list_items }); - (Document::from(nodes), false) + Document::from(nodes).with_history_entry(HistoryEntry::Search { + query: query.into(), + crate_name: crate_.map(String::from), + }) } diff --git a/ferritin/src/styled_string.rs b/ferritin/src/document.rs similarity index 91% rename from ferritin/src/styled_string.rs rename to ferritin/src/document.rs index b689d45..501217f 100644 --- a/ferritin/src/styled_string.rs +++ b/ferritin/src/document.rs @@ -1,8 +1,11 @@ use ferritin_common::DocRef; +use fieldwork::Fieldwork; use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode}; use rustdoc_types::Item; use std::borrow::Cow; +use crate::renderer::HistoryEntry; + /// Interactive action that can be attached to a span #[derive(Debug, Clone)] pub enum TuiAction<'a> { @@ -52,6 +55,23 @@ impl<'a> TuiAction<'a> { TuiAction::SelectTheme(_) => None, } } + + pub fn navigate(doc_ref: DocRef<'a, Item>, url: Option>>) -> Self { + Self::Navigate { + doc_ref, + url: url.map(Into::into), + } + } + + pub fn navigate_to_path( + path: impl Into>, + url: Option>>, + ) -> Self { + Self::NavigateToPath { + path: path.into(), + url: url.map(Into::into), + } + } } /// Generate a heuristic docs.rs URL from a path string @@ -135,9 +155,17 @@ pub enum LinkTarget<'a> { } /// A semantic content tree for Rust documentation -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Fieldwork, Default)] +#[fieldwork(rename_predicates, get, get_mut, take, into_field, set, with, without)] pub struct Document<'a> { - pub nodes: Vec>, + nodes: Vec>, + + error: bool, + + #[field(copy)] + item: Option>, + + history_entry: Option>, } /// Condition for when to show content (used by Conditional node) @@ -222,6 +250,8 @@ pub struct ListItem<'a> { /// Heading level for semantic structure #[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "serve-json", derive(serde::Serialize))] +#[cfg_attr(test, derive(serde::Deserialize))] pub enum HeadingLevel { Title, // Top-level item name: "Item: Vec" Section, // Section header: "Fields:", "Methods:" @@ -254,6 +284,8 @@ impl<'a> Span<'a> { /// Semantic styling categories for Rust code elements #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serve-json", derive(serde::Serialize))] +#[cfg_attr(test, derive(serde::Deserialize))] pub enum SpanStyle { // Rust code semantic elements Keyword, // struct, enum, pub, fn, const, etc. @@ -429,26 +461,42 @@ impl<'a> Span<'a> { } } -impl<'a> Document<'a> { - pub fn new() -> Self { - Self { nodes: Vec::new() } +// Into conversions for ergonomic render() calls +impl<'a> From>> for Document<'a> { + fn from(nodes: Vec>) -> Self { + Self { + nodes, + ..Default::default() + } + } +} + +impl<'a> From> for Document<'a> { + fn from(value: DocumentNode<'a>) -> Self { + Self::from(vec![value]) } +} - pub fn with_nodes(nodes: Vec>) -> Self { - Self { nodes } +impl<'a> From> for Document<'a> { + fn from(value: Span<'a>) -> Self { + DocumentNode::from(value).into() + } +} +impl<'a> From>> for Document<'a> { + fn from(value: Vec>) -> Self { + DocumentNode::from(value).into() } } -impl<'a> Default for Document<'a> { - fn default() -> Self { - Self::new() +impl<'a> From> for DocumentNode<'a> { + fn from(value: Span<'a>) -> Self { + Self::from(vec![value]) } } -// Into conversions for ergonomic render() calls -impl<'a> From>> for Document<'a> { - fn from(nodes: Vec>) -> Self { - Self { nodes } +impl<'a> From>> for DocumentNode<'a> { + fn from(spans: Vec>) -> Self { + Self::Paragraph { spans } } } diff --git a/ferritin/src/format/documentation.rs b/ferritin/src/format/documentation.rs index dc9192c..18541c5 100644 --- a/ferritin/src/format/documentation.rs +++ b/ferritin/src/format/documentation.rs @@ -1,8 +1,8 @@ use std::borrow::Cow; use super::*; +use crate::document::{DocumentNode, LinkTarget, TruncationLevel}; use crate::markdown::MarkdownRenderer; -use crate::styled_string::{DocumentNode, LinkTarget, TruncationLevel}; use rustdoc_types::ItemKind; /// Information about documentation text with truncation details diff --git a/ferritin/src/format/enum.rs b/ferritin/src/format/enum.rs index a06918e..23c3f36 100644 --- a/ferritin/src/format/enum.rs +++ b/ferritin/src/format/enum.rs @@ -1,5 +1,5 @@ use super::*; -use crate::styled_string::{DocumentNode, ListItem, Span}; +use crate::document::{DocumentNode, ListItem, Span}; impl Request { /// Format an enum diff --git a/ferritin/src/format/functions.rs b/ferritin/src/format/functions.rs index 8561d2d..7c4037c 100644 --- a/ferritin/src/format/functions.rs +++ b/ferritin/src/format/functions.rs @@ -1,7 +1,7 @@ use rustdoc_types::{AssocItemConstraint, AssocItemConstraintKind, TraitBoundModifier}; use super::*; -use crate::styled_string::{DocumentNode, Span as StyledSpan}; +use crate::document::{DocumentNode, Span as StyledSpan}; impl Request { /// Format a function signature diff --git a/ferritin/src/format/impls.rs b/ferritin/src/format/impls.rs index 0d2153d..6efc285 100644 --- a/ferritin/src/format/impls.rs +++ b/ferritin/src/format/impls.rs @@ -2,7 +2,7 @@ use ferritin_common::CrateProvenance; use rustdoc_types::ItemKind; use super::*; -use crate::styled_string::{DocumentNode, ListItem, Span}; +use crate::document::{DocumentNode, ListItem, Span}; use semver::VersionReq; use std::cmp::Ordering; diff --git a/ferritin/src/format/items.rs b/ferritin/src/format/items.rs index 057d74e..268a4aa 100644 --- a/ferritin/src/format/items.rs +++ b/ferritin/src/format/items.rs @@ -1,5 +1,5 @@ use super::*; -use crate::styled_string::{DocumentNode, Span}; +use crate::document::{DocumentNode, Span}; impl Request { /// Format a type alias diff --git a/ferritin/src/format/mod.rs b/ferritin/src/format/mod.rs index 3535bbc..1bbe45b 100644 --- a/ferritin/src/format/mod.rs +++ b/ferritin/src/format/mod.rs @@ -1,5 +1,5 @@ +use crate::document::{DocumentNode, Span as StyledSpan, TruncationLevel}; use crate::request::Request; -use crate::styled_string::{DocumentNode, Span as StyledSpan, TruncationLevel}; use ferritin_common::doc_ref::DocRef; use rustdoc_types::{ Abi, Constant, Enum, Function, FunctionPointer, GenericArg, GenericArgs, GenericBound, diff --git a/ferritin/src/format/module.rs b/ferritin/src/format/module.rs index 1a8a40e..61cf0bf 100644 --- a/ferritin/src/format/module.rs +++ b/ferritin/src/format/module.rs @@ -1,7 +1,7 @@ use rustdoc_types::ItemKind; use super::*; -use crate::styled_string::{DocumentNode, ListItem, Span}; +use crate::document::{DocumentNode, ListItem, Span}; // Define display order for groups const GROUP_ORDER: &[(ItemKind, &str)] = &[ diff --git a/ferritin/src/format/source.rs b/ferritin/src/format/source.rs index a2526a3..466b185 100644 --- a/ferritin/src/format/source.rs +++ b/ferritin/src/format/source.rs @@ -1,5 +1,5 @@ use super::*; -use crate::styled_string::{DocumentNode, Span as StyledSpan}; +use crate::document::{DocumentNode, Span as StyledSpan}; /// Format source code pub(crate) fn format_source_code<'a>(request: &'a Request, span: &Span) -> Vec> { diff --git a/ferritin/src/format/struct.rs b/ferritin/src/format/struct.rs index 578c134..59d2b70 100644 --- a/ferritin/src/format/struct.rs +++ b/ferritin/src/format/struct.rs @@ -1,5 +1,5 @@ use super::*; -use crate::styled_string::DocumentNode; +use crate::document::DocumentNode; impl Request { pub(super) fn format_struct<'a>( @@ -44,7 +44,7 @@ impl Request { item: DocRef<'a, Item>, fields: &[Id], ) -> Vec> { - use crate::styled_string::{DocumentNode, ListItem, Span}; + use crate::document::{DocumentNode, ListItem, Span}; let (visible_fields, hidden_count) = self.categorize_fields(item, fields); let struct_name = item.name().unwrap_or(""); @@ -150,7 +150,7 @@ impl Request { item: DocRef<'a, Item>, fields: &[Option], ) -> Vec> { - use crate::styled_string::{DocumentNode, ListItem, Span}; + use crate::document::{DocumentNode, ListItem, Span}; let mut visible_fields = Vec::new(); let mut hidden_count = 0; @@ -258,7 +258,7 @@ impl Request { struct_data: DocRef<'a, Struct>, item: DocRef<'a, Item>, ) -> Vec> { - use crate::styled_string::{DocumentNode, Span}; + use crate::document::{DocumentNode, Span}; let struct_name = item.name().unwrap_or(""); diff --git a/ferritin/src/format/trait.rs b/ferritin/src/format/trait.rs index 5c09464..b1b7441 100644 --- a/ferritin/src/format/trait.rs +++ b/ferritin/src/format/trait.rs @@ -1,5 +1,5 @@ use super::*; -use crate::styled_string::{DocumentNode, ListItem, Span}; +use crate::document::{DocumentNode, ListItem, Span}; impl Request { /// Format a trait diff --git a/ferritin/src/format/types.rs b/ferritin/src/format/types.rs index 44cc8bb..73057e3 100644 --- a/ferritin/src/format/types.rs +++ b/ferritin/src/format/types.rs @@ -1,5 +1,5 @@ use super::*; -use crate::styled_string::Span; +use crate::document::Span; impl Request { /// Enhanced type formatting for signatures diff --git a/ferritin/src/main.rs b/ferritin/src/main.rs index 8534fd4..c3c3c3b 100644 --- a/ferritin/src/main.rs +++ b/ferritin/src/main.rs @@ -20,6 +20,7 @@ use crate::{ mod color_scheme; mod commands; +mod document; mod format; mod format_context; mod generate_docsrs_url; @@ -29,12 +30,14 @@ mod markdown; mod render_context; mod renderer; mod request; -mod styled_string; #[cfg(test)] mod tests; mod traits; mod verbosity; +#[cfg(feature = "serve-json")] +mod web; + #[global_allocator] static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; @@ -153,24 +156,25 @@ fn main() -> ExitCode { let format_context = FormatContext::new(); let request = Request::new(navigator, format_context); + // Check for serve command before non-interactive mode + #[cfg(feature = "serve-json")] + if let Some(Commands::Serve { open }) = cli.command { + web::run_server(&path, open); + return ExitCode::SUCCESS; + } + // One-shot mode: execute command and render to stdout // Use env_logger for CLI mode env_logger::init(); - let (document, is_error, _initial_entry) = - cli.command.unwrap_or_else(Commands::list).execute(&request); + let document = cli.command.unwrap_or_else(Commands::list).execute(&request); + let output = &mut IoFmtWriter(std::io::stdout()); // Render to stdout and exit - if renderer::render( - &document, - &render_context, - &mut IoFmtWriter(std::io::stdout()), - ) - .is_err() - { + if renderer::render(&document, &render_context, output).is_err() { return ExitCode::FAILURE; } - if is_error { + if document.is_error() { ExitCode::FAILURE } else { ExitCode::SUCCESS diff --git a/ferritin/src/markdown.rs b/ferritin/src/markdown.rs index 95abed9..c5caa0a 100644 --- a/ferritin/src/markdown.rs +++ b/ferritin/src/markdown.rs @@ -1,4 +1,4 @@ -use crate::styled_string::{ +use crate::document::{ DocumentNode, HeadingLevel, LinkTarget, ListItem, Span, SpanStyle, TuiAction, }; use pulldown_cmark::{BrokenLink, CodeBlockKind, Event, Options, Parser, Tag, TagEnd}; @@ -50,9 +50,9 @@ impl MarkdownRenderer { // Table state let mut in_table_head = false; - let mut table_header: Option>> = None; - let mut table_rows: Vec>> = Vec::new(); - let mut current_row: Vec> = Vec::new(); + let mut table_header: Option>> = None; + let mut table_rows: Vec>> = Vec::new(); + let mut current_row: Vec> = Vec::new(); for event in parser { match event { @@ -249,9 +249,8 @@ impl MarkdownRenderer { } TagEnd::TableCell => { // Create a table cell from collected spans - let cell = crate::styled_string::TableCell::new(std::mem::take( - &mut current_spans, - )); + let cell = + crate::document::TableCell::new(std::mem::take(&mut current_spans)); current_row.push(cell); } TagEnd::TableHead => { diff --git a/ferritin/src/renderer/interactive/channels.rs b/ferritin/src/renderer/interactive/channels.rs index 5bd69bb..280d657 100644 --- a/ferritin/src/renderer/interactive/channels.rs +++ b/ferritin/src/renderer/interactive/channels.rs @@ -3,8 +3,7 @@ use ferritin_common::DocRef; use rustdoc_types::Item; -use super::history::HistoryEntry; -use crate::styled_string::Document; +use crate::document::Document; use std::borrow::Cow; /// Commands sent from UI thread to Request thread @@ -39,10 +38,7 @@ pub enum UiCommand<'a> { /// Responses sent from Request thread to UI thread pub enum RequestResponse<'a> { /// Successfully loaded a document with optional history entry - Document { - doc: Document<'a>, - entry: Option>, - }, + Document(Document<'a>), /// An error occurred (path not found, etc.) Error(String), diff --git a/ferritin/src/renderer/interactive/dev_log.rs b/ferritin/src/renderer/interactive/dev_log.rs index f98bcfe..a03fe17 100644 --- a/ferritin/src/renderer/interactive/dev_log.rs +++ b/ferritin/src/renderer/interactive/dev_log.rs @@ -1,6 +1,6 @@ +use crate::document::{Document, DocumentNode, HeadingLevel, ListItem, Span}; use crate::logging::LogEntry; use crate::renderer::interactive::InteractiveState; -use crate::styled_string::{Document, DocumentNode, HeadingLevel, ListItem, Span}; use log::Level; use std::fs::File; use std::io::Write; diff --git a/ferritin/src/renderer/interactive/events.rs b/ferritin/src/renderer/interactive/events.rs index 1e90cf1..1bf678c 100644 --- a/ferritin/src/renderer/interactive/events.rs +++ b/ferritin/src/renderer/interactive/events.rs @@ -1,6 +1,6 @@ use super::channels::UiCommand; use super::utils::find_node_at_path_mut; -use crate::styled_string::{Document, DocumentNode, TruncationLevel, TuiAction}; +use crate::document::{Document, DocumentNode, TruncationLevel, TuiAction}; /// Handle a TuiAction, returning a command to send if navigation is needed /// @@ -13,7 +13,7 @@ pub(super) fn handle_action<'a>( match action { TuiAction::ExpandBlock(path) => { // Find the node at this path and expand it - if let Some(node) = find_node_at_path_mut(&mut document.nodes, path.indices()) + if let Some(node) = find_node_at_path_mut(document.nodes_mut(), path.indices()) && let DocumentNode::TruncatedBlock { level, .. } = node { // Cycle through truncation levels: SingleLine -> Full diff --git a/ferritin/src/renderer/interactive/keyboard.rs b/ferritin/src/renderer/interactive/keyboard.rs index 495aaea..5dfe846 100644 --- a/ferritin/src/renderer/interactive/keyboard.rs +++ b/ferritin/src/renderer/interactive/keyboard.rs @@ -573,7 +573,7 @@ impl<'a> InteractiveState<'a> { let action = action.clone(); // Handle SelectTheme specially (same as mouse click) - if let crate::styled_string::TuiAction::SelectTheme(theme_name) = &action { + if let crate::document::TuiAction::SelectTheme(theme_name) = &action { let _ = self.apply_theme(theme_name); if let super::UiMode::ThemePicker { ref mut selected_index, diff --git a/ferritin/src/renderer/interactive/mod.rs b/ferritin/src/renderer/interactive/mod.rs index df797f1..1b2adfd 100644 --- a/ferritin/src/renderer/interactive/mod.rs +++ b/ferritin/src/renderer/interactive/mod.rs @@ -106,11 +106,11 @@ use utils::set_cursor_shape; use crate::{ commands::Commands, + document::{Document, DocumentNode, HeadingLevel, Span}, logging::LogReader, render_context::RenderContext, renderer::interactive::state::{InputMode, InteractiveState, UiMode}, request::Request, - styled_string::{Document, DocumentNode, HeadingLevel, Span}, }; use crossbeam_channel::select; use crossterm::{ @@ -190,14 +190,11 @@ fn render_interactive_impl<'scope, 'env: 'scope>( request.populate(); // Execute initial command and send to UI - let (document, _is_error, initial_entry) = initial_command + let document = initial_command .unwrap_or_else(Commands::list) .execute(request); - let _ = resp_tx.send(RequestResponse::Document { - doc: document, - entry: initial_entry, - }); + let _ = resp_tx.send(RequestResponse::Document(document)); // Run request thread loop request_thread_loop(request, cmd_rx, resp_tx); diff --git a/ferritin/src/renderer/interactive/mouse.rs b/ferritin/src/renderer/interactive/mouse.rs index bf3fcfb..dfb0817 100644 --- a/ferritin/src/renderer/interactive/mouse.rs +++ b/ferritin/src/renderer/interactive/mouse.rs @@ -4,9 +4,9 @@ use crossterm::event::{MouseEvent, MouseEventKind}; use ratatui::{Terminal, layout::Position, prelude::Backend}; use crate::{ + document::TuiAction, render_context::RenderContext, renderer::interactive::{handle_action, set_cursor_shape}, - styled_string::TuiAction, }; use super::UiMode; diff --git a/ferritin/src/renderer/interactive/render_document.rs b/ferritin/src/renderer/interactive/render_document.rs index 4cbce56..7f482cd 100644 --- a/ferritin/src/renderer/interactive/render_document.rs +++ b/ferritin/src/renderer/interactive/render_document.rs @@ -4,7 +4,7 @@ use ratatui::{ }; use super::state::{DocumentLayoutCache, InteractiveState}; -use crate::styled_string::NodePath; +use crate::document::NodePath; // Baseline left margin for all content - provides breathing room and space for outdented borders pub(super) const BASELINE_LEFT_MARGIN: u16 = 3; @@ -30,8 +30,8 @@ impl<'a> InteractiveState<'a> { .unwrap_or(true); // Use raw pointer to avoid borrow checker issues when calling render_node - let nodes_ptr = self.document.document.nodes.as_ptr(); - let node_count = self.document.document.nodes.len(); + let nodes_ptr = self.document.document.nodes().as_ptr(); + let node_count = self.document.document.nodes().len(); for idx in 0..node_count { // Only short-circuit if we have a valid cache (don't need full height calculation) diff --git a/ferritin/src/renderer/interactive/render_frame.rs b/ferritin/src/renderer/interactive/render_frame.rs index 6fba425..3636961 100644 --- a/ferritin/src/renderer/interactive/render_frame.rs +++ b/ferritin/src/renderer/interactive/render_frame.rs @@ -4,7 +4,7 @@ use ratatui::{ }; use super::{InteractiveState, UiMode}; -use crate::styled_string::NodePath; +use crate::document::NodePath; impl<'a> InteractiveState<'a> { pub(super) fn render_frame(&mut self, frame: &mut Frame) { diff --git a/ferritin/src/renderer/interactive/render_node.rs b/ferritin/src/renderer/interactive/render_node.rs index c7bcfd2..7341155 100644 --- a/ferritin/src/renderer/interactive/render_node.rs +++ b/ferritin/src/renderer/interactive/render_node.rs @@ -1,7 +1,7 @@ use ratatui::{buffer::Buffer, layout::Rect, style::Modifier}; use super::{state::InteractiveState, utils::find_paragraph_truncation_point}; -use crate::styled_string::{DocumentNode, HeadingLevel, ShowWhen, TruncationLevel, TuiAction}; +use crate::document::{DocumentNode, HeadingLevel, ShowWhen, TruncationLevel, TuiAction}; // Truncated block borders are outdented (to the left of content) so that content // doesn't shift when expanding/collapsing the block. The border is purely decorative. diff --git a/ferritin/src/renderer/interactive/render_span.rs b/ferritin/src/renderer/interactive/render_span.rs index b72123d..c218df5 100644 --- a/ferritin/src/renderer/interactive/render_span.rs +++ b/ferritin/src/renderer/interactive/render_span.rs @@ -1,7 +1,7 @@ use ratatui::{buffer::Buffer, layout::Rect, style::Modifier}; use super::state::InteractiveState; -use crate::styled_string::Span; +use crate::document::Span; impl<'a> InteractiveState<'a> { /// Render a span with optional action tracking diff --git a/ferritin/src/renderer/interactive/render_table.rs b/ferritin/src/renderer/interactive/render_table.rs index 0dfdc2e..b4fbc2a 100644 --- a/ferritin/src/renderer/interactive/render_table.rs +++ b/ferritin/src/renderer/interactive/render_table.rs @@ -4,7 +4,7 @@ use ratatui::{ }; use super::state::InteractiveState; -use crate::styled_string::TableCell; +use crate::document::TableCell; impl<'a> InteractiveState<'a> { /// Render table with unicode borders diff --git a/ferritin/src/renderer/interactive/render_theme_picker.rs b/ferritin/src/renderer/interactive/render_theme_picker.rs index 30216e8..f21131a 100644 --- a/ferritin/src/renderer/interactive/render_theme_picker.rs +++ b/ferritin/src/renderer/interactive/render_theme_picker.rs @@ -8,8 +8,8 @@ use ratatui::{ use std::borrow::Cow; use super::state::InteractiveState; +use crate::document::TuiAction; use crate::render_context::RenderContext; -use crate::styled_string::TuiAction; impl<'a> InteractiveState<'a> { /// Render theme picker modal overlay diff --git a/ferritin/src/renderer/interactive/request_thread.rs b/ferritin/src/renderer/interactive/request_thread.rs index a94b4d8..fcc5b50 100644 --- a/ferritin/src/renderer/interactive/request_thread.rs +++ b/ferritin/src/renderer/interactive/request_thread.rs @@ -3,7 +3,7 @@ use super::channels::{RequestResponse, UiCommand}; use super::history::HistoryEntry; use crate::commands::{list, search}; -use crate::{request::Request, styled_string::Document}; +use crate::{document::Document, request::Request}; use crossbeam_channel::{Receiver, Sender}; /// Request thread loop - processes commands from UI thread @@ -17,26 +17,22 @@ pub(super) fn request_thread_loop<'a>( UiCommand::Navigate(doc_ref) => { // Format the already-resolved item (e.g., from clicking a link) let doc_nodes = request.format_item(doc_ref); - let doc = Document::from(doc_nodes); - let entry = HistoryEntry::Item(doc_ref); + let doc = Document::from(doc_nodes) + .with_history_entry(HistoryEntry::Item(doc_ref)) + .with_item(doc_ref); - let _ = resp_tx.send(RequestResponse::Document { - doc, - entry: Some(entry), - }); + let _ = resp_tx.send(RequestResponse::Document(doc)); } UiCommand::NavigateToPath(path) => { let mut suggestions = vec![]; if let Some(item) = request.resolve_path(path.as_ref(), &mut suggestions) { let doc_nodes = request.format_item(item); - let doc = Document::from(doc_nodes); - let entry = HistoryEntry::Item(item); + let doc = Document::from(doc_nodes) + .with_item(item) + .with_history_entry(HistoryEntry::Item(item)); - let _ = resp_tx.send(RequestResponse::Document { - doc, - entry: Some(entry), - }); + let _ = resp_tx.send(RequestResponse::Document(doc)); } else { let _ = resp_tx.send(RequestResponse::Error(format!("Not found: {}", path))); } @@ -47,33 +43,19 @@ pub(super) fn request_thread_loop<'a>( crate_name, limit, } => { - let (search_doc, _is_error) = search::execute( + let search_doc = search::execute( request, query.as_ref(), limit, crate_name.as_ref().map(|c| c.as_ref()), ); - // Always create history entry for searches - let entry = HistoryEntry::Search { - query: query.into_owned(), - crate_name: crate_name.map(|c| c.into_owned()), - }; - - let _ = resp_tx.send(RequestResponse::Document { - doc: search_doc, - entry: Some(entry), - }); + let _ = resp_tx.send(RequestResponse::Document(search_doc)); } UiCommand::List => { - let (list_doc, _is_error, default_crate) = list::execute(request); - let entry = HistoryEntry::List { default_crate }; - - let _ = resp_tx.send(RequestResponse::Document { - doc: list_doc, - entry: Some(entry), - }); + let doc = list::execute(request); + let _ = resp_tx.send(RequestResponse::Document(doc)); } UiCommand::ToggleSource { @@ -82,10 +64,9 @@ pub(super) fn request_thread_loop<'a>( } => { request.format_context().set_include_source(include_source); if let Some(current_item) = current_item { - let _ = resp_tx.send(RequestResponse::Document { - doc: Document::from(request.format_item(current_item)), - entry: None, - }); + let _ = resp_tx.send(RequestResponse::Document(Document::from( + request.format_item(current_item), + ))); } } diff --git a/ferritin/src/renderer/interactive/response.rs b/ferritin/src/renderer/interactive/response.rs index 0457b97..d1843a6 100644 --- a/ferritin/src/renderer/interactive/response.rs +++ b/ferritin/src/renderer/interactive/response.rs @@ -24,7 +24,10 @@ impl<'a> InteractiveState<'a> { pub fn handle_response(&mut self, response: RequestResponse<'a>) -> bool { self.loading.pending_request = false; match response { - RequestResponse::Document { doc, entry } => { + RequestResponse::Document(mut doc) => { + if let Some(entry) = doc.take_history_entry() { + self.document.history.push(entry) + } self.document.document = doc; self.set_scroll_offset(0); // Invalidate layout cache when document changes @@ -32,10 +35,6 @@ impl<'a> InteractiveState<'a> { // Reset keyboard cursor to virtual top when navigating to new document self.reset_keyboard_cursor(); - // Add to history if we got an entry - if let Some(new_entry) = entry { - self.document.history.push(new_entry); - } false } diff --git a/ferritin/src/renderer/interactive/span_style.rs b/ferritin/src/renderer/interactive/span_style.rs index 6829a86..3384a6a 100644 --- a/ferritin/src/renderer/interactive/span_style.rs +++ b/ferritin/src/renderer/interactive/span_style.rs @@ -1,6 +1,6 @@ use ratatui::style::{Color, Modifier, Style}; -use crate::styled_string::SpanStyle; +use crate::document::SpanStyle; use super::state::InteractiveState; diff --git a/ferritin/src/renderer/interactive/state.rs b/ferritin/src/renderer/interactive/state.rs index ca4e3ca..60c3d9c 100644 --- a/ferritin/src/renderer/interactive/state.rs +++ b/ferritin/src/renderer/interactive/state.rs @@ -6,9 +6,9 @@ use super::channels::{RequestResponse, UiCommand}; use super::history::{History, HistoryEntry}; use super::theme::InteractiveTheme; use super::utils::supports_cursor_shape; +use crate::document::{Document, NodePath, TuiAction}; use crate::logging::LogReader; use crate::render_context::{RenderContext, ThemeError}; -use crate::styled_string::{Document, NodePath, TuiAction}; use crossbeam_channel::{Receiver, Sender}; /// UI mode - makes the modal structure of the interface explicit diff --git a/ferritin/src/renderer/interactive/tests.rs b/ferritin/src/renderer/interactive/tests.rs index 4b6484d..0db632f 100644 --- a/ferritin/src/renderer/interactive/tests.rs +++ b/ferritin/src/renderer/interactive/tests.rs @@ -1,7 +1,7 @@ use super::*; use crate::{ + document::{Document, DocumentNode, Span}, logging::StatusLogBackend, - styled_string::{Document, DocumentNode, Span, SpanStyle}, }; use crossbeam_channel::unbounded as channel; use ratatui::{Terminal, backend::TestBackend}; @@ -11,13 +11,7 @@ fn create_test_state<'a>() -> InteractiveState<'a> { let (cmd_tx, _cmd_rx) = channel(); let (_resp_tx, resp_rx) = channel(); - let document = Document { - nodes: vec![DocumentNode::paragraph(vec![Span { - text: "Test document".into(), - style: SpanStyle::Plain, - action: None, - }])], - }; + let document = Document::from(Span::plain("Test document")); let render_context = RenderContext::new(); let theme = InteractiveTheme::from_render_context(&render_context); let (_, log_reader) = StatusLogBackend::new(100); @@ -171,14 +165,13 @@ fn test_rendering_to_test_backend() { #[test] fn test_brief_truncation_with_code_block() { - use crate::styled_string::TruncationLevel; + use crate::document::TruncationLevel; let (cmd_tx, _cmd_rx) = channel(); let (_resp_tx, resp_rx) = channel(); // Create a document with a Brief truncated block containing text and a code block - let document = Document { - nodes: vec![DocumentNode::TruncatedBlock { + let document = Document::from(vec![DocumentNode::TruncatedBlock { level: TruncationLevel::Brief, nodes: vec![ DocumentNode::paragraph(vec![Span::plain("First paragraph with some text.")]), @@ -189,8 +182,7 @@ fn test_brief_truncation_with_code_block() { }, DocumentNode::paragraph(vec![Span::plain("Third paragraph after code.")]), ], - }], - }; + }]); let render_context = RenderContext::new(); let theme = InteractiveTheme::from_render_context(&render_context); @@ -248,24 +240,22 @@ fn test_brief_truncation_with_code_block() { #[test] fn test_brief_with_short_code_block() { - use crate::styled_string::TruncationLevel; + use crate::document::TruncationLevel; let (cmd_tx, _cmd_rx) = channel(); let (_resp_tx, resp_rx) = channel(); // Create a simpler case: just one line of text and a small code block - let document = Document { - nodes: vec![DocumentNode::TruncatedBlock { - level: TruncationLevel::Brief, - nodes: vec![ - DocumentNode::paragraph(vec![Span::plain("Some text before code.")]), - DocumentNode::CodeBlock { - lang: Some("rust".into()), - code: "let x = 42;".into(), - }, - ], - }], - }; + let document = Document::from(DocumentNode::TruncatedBlock { + level: TruncationLevel::Brief, + nodes: vec![ + DocumentNode::paragraph(vec![Span::plain("Some text before code.")]), + DocumentNode::CodeBlock { + lang: Some("rust".into()), + code: "let x = 42;".into(), + }, + ], + }); let render_context = RenderContext::new(); let theme = InteractiveTheme::from_render_context(&render_context); @@ -316,7 +306,7 @@ fn test_brief_with_short_code_block() { #[test] fn test_truncated_block_border_on_wrapped_lines() { - use crate::styled_string::TruncationLevel; + use crate::document::TruncationLevel; let (cmd_tx, _cmd_rx) = channel(); let (_resp_tx, resp_rx) = channel(); @@ -325,24 +315,22 @@ fn test_truncated_block_border_on_wrapped_lines() { // Brief mode has an 8-line limit, so we need enough content to exceed that and trigger truncation let long_text = "This is a very long line of text that should wrap across multiple lines when rendered in a narrow terminal window and we want to make sure the border appears on all wrapped lines not just the last one."; - let document = Document { - nodes: vec![DocumentNode::TruncatedBlock { - level: TruncationLevel::Brief, - nodes: vec![ - DocumentNode::paragraph(vec![Span::plain(long_text)]), - DocumentNode::paragraph(vec![Span::plain( - "Second paragraph with additional content.", - )]), - DocumentNode::paragraph(vec![Span::plain( - "Third paragraph to ensure we exceed the 8-line Brief limit.", - )]), - DocumentNode::paragraph(vec![Span::plain( - "Fourth paragraph - this should be truncated.", - )]), - DocumentNode::paragraph(vec![Span::plain("Fifth paragraph - also truncated.")]), - ], - }], - }; + let document = Document::from(DocumentNode::TruncatedBlock { + level: TruncationLevel::Brief, + nodes: vec![ + DocumentNode::paragraph(vec![Span::plain(long_text)]), + DocumentNode::paragraph(vec![Span::plain( + "Second paragraph with additional content.", + )]), + DocumentNode::paragraph(vec![Span::plain( + "Third paragraph to ensure we exceed the 8-line Brief limit.", + )]), + DocumentNode::paragraph(vec![Span::plain( + "Fourth paragraph - this should be truncated.", + )]), + DocumentNode::paragraph(vec![Span::plain("Fifth paragraph - also truncated.")]), + ], + }); let render_context = RenderContext::new(); let theme = InteractiveTheme::from_render_context(&render_context); @@ -398,56 +386,54 @@ fn test_truncated_block_border_on_wrapped_lines() { #[test] #[ignore] // Run with --ignored to update snapshot fn test_std_module_spacing() { - use crate::styled_string::{DocumentNode, ListItem, Span}; + use crate::document::{DocumentNode, ListItem, Span}; let (cmd_tx, _cmd_rx) = channel(); let (_resp_tx, resp_rx) = channel(); // Simulate the structure from std's markdown: paragraph, list, paragraph, list - let document = Document { - nodes: vec![ - // First paragraph - DocumentNode::paragraph(vec![Span::plain( - "The standard library exposes three common ways:", - )]), - // First list - DocumentNode::List { - items: vec![ - ListItem::new(vec![DocumentNode::paragraph(vec![Span::plain( - "Vec - A heap-allocated vector", - )])]), - ListItem::new(vec![DocumentNode::paragraph(vec![Span::plain( - "[T; N] - An inline array", - )])]), - ListItem::new(vec![DocumentNode::paragraph(vec![Span::plain( - "[T] - A dynamically sized slice", - )])]), - ], - }, - // Second paragraph - DocumentNode::paragraph(vec![Span::plain( - "Slices can only be handled through pointers:", - )]), - // Second list - DocumentNode::List { - items: vec![ - ListItem::new(vec![DocumentNode::paragraph(vec![Span::plain( - "&[T] - shared slice", - )])]), - ListItem::new(vec![DocumentNode::paragraph(vec![Span::plain( - "&mut [T] - mutable slice", - )])]), - ListItem::new(vec![DocumentNode::paragraph(vec![Span::plain( - "Box<[T]> - owned slice", - )])]), - ], - }, - // Final paragraph - DocumentNode::paragraph(vec![Span::plain( - "str, a UTF-8 string slice, is a primitive type.", - )]), - ], - }; + let document = Document::from(vec![ + // First paragraph + DocumentNode::paragraph(vec![Span::plain( + "The standard library exposes three common ways:", + )]), + // First list + DocumentNode::List { + items: vec![ + ListItem::new(vec![DocumentNode::paragraph(vec![Span::plain( + "Vec - A heap-allocated vector", + )])]), + ListItem::new(vec![DocumentNode::paragraph(vec![Span::plain( + "[T; N] - An inline array", + )])]), + ListItem::new(vec![DocumentNode::paragraph(vec![Span::plain( + "[T] - A dynamically sized slice", + )])]), + ], + }, + // Second paragraph + DocumentNode::paragraph(vec![Span::plain( + "Slices can only be handled through pointers:", + )]), + // Second list + DocumentNode::List { + items: vec![ + ListItem::new(vec![DocumentNode::paragraph(vec![Span::plain( + "&[T] - shared slice", + )])]), + ListItem::new(vec![DocumentNode::paragraph(vec![Span::plain( + "&mut [T] - mutable slice", + )])]), + ListItem::new(vec![DocumentNode::paragraph(vec![Span::plain( + "Box<[T]> - owned slice", + )])]), + ], + }, + // Final paragraph + DocumentNode::paragraph(vec![Span::plain( + "str, a UTF-8 string slice, is a primitive type.", + )]), + ]); let render_context = RenderContext::new(); let theme = InteractiveTheme::from_render_context(&render_context); @@ -490,16 +476,14 @@ fn test_code_block_spacing() { let (_resp_tx, resp_rx) = channel(); // Simulate paragraph followed by code block (like alloc module docs) - let document = Document { - nodes: vec![ - DocumentNode::paragraph(vec![Span::plain("Here's an example:")]), - DocumentNode::CodeBlock { - lang: Some("rust".into()), - code: "let x = vec![1, 2, 3];".into(), - }, - DocumentNode::paragraph(vec![Span::plain("More content after the code block.")]), - ], - }; + let document = Document::from(vec![ + DocumentNode::paragraph(vec![Span::plain("Here's an example:")]), + DocumentNode::CodeBlock { + lang: Some("rust".into()), + code: "let x = vec![1, 2, 3];".into(), + }, + DocumentNode::paragraph(vec![Span::plain("More content after the code block.")]), + ]); let render_context = RenderContext::new(); let theme = InteractiveTheme::from_render_context(&render_context); diff --git a/ferritin/src/renderer/interactive/utils.rs b/ferritin/src/renderer/interactive/utils.rs index cc90cec..956289c 100644 --- a/ferritin/src/renderer/interactive/utils.rs +++ b/ferritin/src/renderer/interactive/utils.rs @@ -1,4 +1,4 @@ -use crate::styled_string::DocumentNode; +use crate::document::DocumentNode; use crossterm::{queue, style::Print}; use ratatui::prelude::Backend; use std::{env, io}; diff --git a/ferritin/src/renderer/mod.rs b/ferritin/src/renderer/mod.rs index 6f63b81..83d8618 100644 --- a/ferritin/src/renderer/mod.rs +++ b/ferritin/src/renderer/mod.rs @@ -1,4 +1,4 @@ -use crate::{render_context::RenderContext, styled_string::Document}; +use crate::{document::Document, render_context::RenderContext}; use std::{ fmt::Write, io::{self, IsTerminal}, @@ -69,11 +69,11 @@ pub fn render( #[cfg(test)] mod tests { use super::*; - use crate::styled_string::{DocumentNode, HeadingLevel, Span}; + use crate::document::{DocumentNode, HeadingLevel, Span}; #[test] fn test_render_modes() { - let doc = Document::with_nodes(vec![ + let doc = Document::from(vec![ DocumentNode::heading( HeadingLevel::Title, vec![Span::plain("Test"), Span::keyword("struct")], diff --git a/ferritin/src/renderer/plain.rs b/ferritin/src/renderer/plain.rs index a1ab4c7..2efe7f9 100644 --- a/ferritin/src/renderer/plain.rs +++ b/ferritin/src/renderer/plain.rs @@ -17,7 +17,7 @@ use std::fmt::{Result, Write}; -use crate::styled_string::{ +use crate::document::{ Document, DocumentNode, HeadingLevel, ListItem, ShowWhen, Span, TruncationLevel, }; @@ -30,7 +30,7 @@ struct PlainRenderer<'w, W: Write> { /// Render a document as plain text without any styling pub fn render(document: &Document, output: &mut impl Write) -> Result { let mut renderer = PlainRenderer::new(output); - renderer.render_block_sequence(&document.nodes) + renderer.render_block_sequence(document.nodes()) } impl<'w, W: Write> PlainRenderer<'w, W> { @@ -283,10 +283,10 @@ mod tests { #[test] fn test_render_heading() { - let doc = Document::with_nodes(vec![DocumentNode::heading( + let doc = Document::from(DocumentNode::heading( HeadingLevel::Title, vec![Span::plain("Item: "), Span::type_name("Vec")], - )]); + )); let mut output = String::new(); render(&doc, &mut output).unwrap(); assert!(output.contains("Item: Vec")); @@ -295,10 +295,10 @@ mod tests { #[test] fn test_render_list() { - let doc = Document::with_nodes(vec![DocumentNode::list(vec![ + let doc = Document::from(DocumentNode::list(vec![ ListItem::new(vec![DocumentNode::paragraph(vec![Span::plain("First")])]), ListItem::new(vec![DocumentNode::paragraph(vec![Span::plain("Second")])]), - ])]); + ])); let mut output = String::new(); render(&doc, &mut output).unwrap(); diff --git a/ferritin/src/renderer/test_mode.rs b/ferritin/src/renderer/test_mode.rs index 4c1bb79..f4b7ac0 100644 --- a/ferritin/src/renderer/test_mode.rs +++ b/ferritin/src/renderer/test_mode.rs @@ -1,12 +1,12 @@ use std::fmt::{Result, Write}; -use crate::styled_string::{ +use crate::document::{ Document, DocumentNode, HeadingLevel, ListItem, ShowWhen, Span, SpanStyle, TruncationLevel, }; /// Render a document with semantic XML-like tags for testing pub fn render(document: &Document, output: &mut impl Write) -> Result { - render_nodes(&document.nodes, output) + render_nodes(document.nodes(), output) } fn render_nodes(nodes: &[DocumentNode], output: &mut impl Write) -> Result { @@ -321,11 +321,11 @@ mod tests { #[test] fn test_render_paragraph() { - let doc = Document::with_nodes(vec![DocumentNode::paragraph(vec![ + let doc = Document::from(DocumentNode::paragraph(vec![ Span::keyword("struct"), Span::plain(" "), Span::type_name("Foo"), - ])]); + ])); let mut output = String::new(); render(&doc, &mut output).unwrap(); @@ -335,10 +335,10 @@ mod tests { #[test] fn test_render_heading() { - let doc = Document::with_nodes(vec![DocumentNode::heading( + let doc = Document::from(DocumentNode::heading( HeadingLevel::Title, vec![Span::plain("Item: "), Span::type_name("Vec")], - )]); + )); let mut output = String::new(); render(&doc, &mut output).unwrap(); @@ -350,10 +350,10 @@ mod tests { #[test] fn test_render_code_block() { - let doc = Document::with_nodes(vec![DocumentNode::code_block( + let doc = Document::from(DocumentNode::code_block( Some("rust".to_string()), "fn main() {}".to_string(), - )]); + )); let mut output = String::new(); render(&doc, &mut output).unwrap(); diff --git a/ferritin/src/renderer/tty.rs b/ferritin/src/renderer/tty.rs index c4d6ab6..f39231b 100644 --- a/ferritin/src/renderer/tty.rs +++ b/ferritin/src/renderer/tty.rs @@ -14,10 +14,10 @@ use std::fmt::{Result, Write}; -use crate::render_context::RenderContext; -use crate::styled_string::{ +use crate::document::{ Document, DocumentNode, HeadingLevel, ShowWhen, Span, SpanStyle, TruncationLevel, }; +use crate::render_context::RenderContext; use ratatui::{ style::{Color, Modifier, Style}, text::{Line, Span as RatatuiSpan}, @@ -25,6 +25,9 @@ use ratatui::{ use syntect::easy::HighlightLines; use syntect::util::LinesWithEndings; +#[cfg(test)] +mod tests; + /// Render budget for truncation #[derive(Clone)] pub(super) enum RenderBudget { @@ -224,7 +227,7 @@ pub fn render( ) -> Result { // Build ratatui lines from document let mut budget = RenderBudget::Unlimited; - let lines = build_lines(&document.nodes, render_context, &mut budget); + let lines = build_lines(document.nodes(), render_context, &mut budget); // Write lines directly to output for line in lines { @@ -724,8 +727,8 @@ fn build_node_lines<'a>( /// Render table with UTF-8 borders fn render_table<'a>( - header: Option<&[crate::styled_string::TableCell<'a>]>, - rows: &[Vec>], + header: Option<&[crate::document::TableCell<'a>]>, + rows: &[Vec>], render_context: &RenderContext, ) -> Vec> { let mut lines = Vec::new(); @@ -811,7 +814,7 @@ fn render_table<'a>( cell.spans .first() .map(|s| s.style) - .unwrap_or(crate::styled_string::SpanStyle::Plain), + .unwrap_or(crate::document::SpanStyle::Plain), render_context, ); style = style.add_modifier(Modifier::BOLD); @@ -864,7 +867,7 @@ fn render_table<'a>( cell.spans .first() .map(|s| s.style) - .unwrap_or(crate::styled_string::SpanStyle::Plain), + .unwrap_or(crate::document::SpanStyle::Plain), render_context, ); row_spans.push(RatatuiSpan::styled(cell_text, style)); @@ -1007,45 +1010,3 @@ fn span_style_to_ratatui(span_style: SpanStyle, render_context: &RenderContext) } } } - -#[cfg(test)] -mod tests { - use crate::renderer::OutputMode; - - use super::*; - - #[test] - fn test_render_paragraph() { - let doc = Document::with_nodes(vec![DocumentNode::paragraph(vec![ - Span::keyword("struct"), - Span::plain(" "), - Span::type_name("Foo"), - ])]); - let mut output = String::new(); - let render_context = RenderContext::new().with_output_mode(OutputMode::Tty); - render(&doc, &render_context, &mut output).unwrap(); - // Should contain ANSI codes - assert!(output.contains("\x1b")); - // Should contain the actual text - assert!(output.contains("struct")); - assert!(output.contains("Foo")); - } - - #[test] - fn test_render_heading() { - let doc = Document::with_nodes(vec![DocumentNode::heading( - HeadingLevel::Title, - vec![Span::plain("Test")], - )]); - - let mut output = String::new(); - let render_context = RenderContext::new() - .with_output_mode(OutputMode::Tty) - .with_terminal_width(10); - - render(&doc, &render_context, &mut output).unwrap(); - assert!(output.contains("Test")); - // Should have decorative underline - assert!(output.contains("==========")); - } -} diff --git a/ferritin/src/renderer/tty/tests.rs b/ferritin/src/renderer/tty/tests.rs new file mode 100644 index 0000000..34cf31f --- /dev/null +++ b/ferritin/src/renderer/tty/tests.rs @@ -0,0 +1,38 @@ +use crate::renderer::OutputMode; + +use super::*; + +#[test] +fn test_render_paragraph() { + let doc = Document::from(vec![ + Span::keyword("struct"), + Span::plain(" "), + Span::type_name("Foo"), + ]); + let mut output = String::new(); + let render_context = RenderContext::new().with_output_mode(OutputMode::Tty); + render(&doc, &render_context, &mut output).unwrap(); + // Should contain ANSI codes + assert!(output.contains("\x1b")); + // Should contain the actual text + assert!(output.contains("struct")); + assert!(output.contains("Foo")); +} + +#[test] +fn test_render_heading() { + let doc = Document::from(DocumentNode::heading( + HeadingLevel::Title, + vec![Span::plain("Test")], + )); + + let mut output = String::new(); + let render_context = RenderContext::new() + .with_output_mode(OutputMode::Tty) + .with_terminal_width(10); + + render(&doc, &render_context, &mut output).unwrap(); + assert!(output.contains("Test")); + // Should have decorative underline + assert!(output.contains("==========")); +} diff --git a/ferritin/src/snapshots/ferritin__tests__fuzzy_matching_suggestions_test_mode.snap b/ferritin/src/snapshots/ferritin__tests__fuzzy_matching_suggestions_test_mode.snap index c6b42d8..3c536fc 100644 --- a/ferritin/src/snapshots/ferritin__tests__fuzzy_matching_suggestions_test_mode.snap +++ b/ferritin/src/snapshots/ferritin__tests__fuzzy_matching_suggestions_test_mode.snap @@ -3,7 +3,7 @@ source: ferritin/src/tests.rs expression: "render_for_tests(Commands::get(\"crate::TestStruct::incrementCount\"),\nOutputMode :: TestMode)" ---

-Could not find 'crate::TestStruct::incrementCount'

+Could not find 'crate::TestStruct::incrementCount'

Did you mean:

diff --git a/ferritin/src/snapshots/ferritin__tests__fuzzy_matching_trait_methods_test_mode.snap b/ferritin/src/snapshots/ferritin__tests__fuzzy_matching_trait_methods_test_mode.snap index 0cc2bf2..8c9882f 100644 --- a/ferritin/src/snapshots/ferritin__tests__fuzzy_matching_trait_methods_test_mode.snap +++ b/ferritin/src/snapshots/ferritin__tests__fuzzy_matching_trait_methods_test_mode.snap @@ -3,7 +3,7 @@ source: ferritin/src/tests.rs expression: "render_for_tests(Commands::get(\"crate::TestStruct::cute\"), OutputMode ::\nTestMode)" ---

-Could not find 'crate::TestStruct::cute'

+Could not find 'crate::TestStruct::cute'

Did you mean:

diff --git a/ferritin/src/snapshots/ferritin__tests__fuzzy_matching_typo_test_mode.snap b/ferritin/src/snapshots/ferritin__tests__fuzzy_matching_typo_test_mode.snap index 3a17f10..896ce22 100644 --- a/ferritin/src/snapshots/ferritin__tests__fuzzy_matching_typo_test_mode.snap +++ b/ferritin/src/snapshots/ferritin__tests__fuzzy_matching_typo_test_mode.snap @@ -3,7 +3,7 @@ source: ferritin/src/tests.rs expression: "render_for_tests(Commands::get(\"crate::TestStruct::test_metod\"), OutputMode ::\nTestMode)" ---

-Could not find 'crate::TestStruct::test_metod'

+Could not find 'crate::TestStruct::test_metod'

Did you mean:

diff --git a/ferritin/src/snapshots/ferritin__tests__nonexistent_item_test_mode.snap b/ferritin/src/snapshots/ferritin__tests__nonexistent_item_test_mode.snap index 2d88a09..fd8bb2c 100644 --- a/ferritin/src/snapshots/ferritin__tests__nonexistent_item_test_mode.snap +++ b/ferritin/src/snapshots/ferritin__tests__nonexistent_item_test_mode.snap @@ -3,7 +3,7 @@ source: ferritin/src/tests.rs expression: "render_for_tests(Commands::get(\"crate::DoesNotExist\"), OutputMode :: TestMode)" ---

-Could not find 'crate::DoesNotExist'

+Could not find 'crate::DoesNotExist'

Did you mean:

diff --git a/ferritin/src/tests.rs b/ferritin/src/tests.rs index 5d2c979..c6bfa6b 100644 --- a/ferritin/src/tests.rs +++ b/ferritin/src/tests.rs @@ -10,6 +10,7 @@ use ferritin_common::{ sources::{LocalSource, StdSource}, }; use ratatui::backend::TestBackend; +use regex::Regex; use std::path::PathBuf; /// Get the path to our test crate (fast to build, minimal dependencies) @@ -37,7 +38,7 @@ fn convert_osc8_to_markdown(text: &str) -> String { fn render_for_tests(command: Commands, output_mode: OutputMode) -> String { let request = create_test_state(); - let (document, _, _) = command.execute(&request); + let document = command.execute(&request); let mut output = String::new(); let render_context = RenderContext::new().with_output_mode(output_mode); render(&document, &render_context, &mut output).unwrap(); @@ -62,8 +63,7 @@ fn render_for_tests(command: Commands, output_mode: OutputMode) -> String { // Normalize Rust version info to avoid daily breakage with nightly updates // Matches patterns like: 1.95.0-nightly (f889772d6 2026-02-05) - let re = - regex::Regex::new(r"\d+\.\d+\.\d+-[a-z]+\s+\([a-f0-9]+\s+\d{4}-\d{2}-\d{2}\)").unwrap(); + let re = Regex::new(r"\d+\.\d+\.\d+-[a-z]+\s+\([a-f0-9]+\s+\d{4}-\d{2}-\d{2}\)").unwrap(); re.replace_all(&output, "RUST_VERSION").to_string() } @@ -71,7 +71,7 @@ fn render_interactive_for_tests(command: Commands) -> TestBackend { use crate::renderer::render_to_test_backend; let request = create_test_state(); - let (document, _, _) = command.execute(&request); + let document = command.execute(&request); let render_context = RenderContext::new(); render_to_test_backend(document, render_context) diff --git a/ferritin/src/web/handlers.rs b/ferritin/src/web/handlers.rs new file mode 100644 index 0000000..ce5db39 --- /dev/null +++ b/ferritin/src/web/handlers.rs @@ -0,0 +1,190 @@ +use super::json_document::JsonDocument; +use crate::commands::Commands; +use crate::document::Document; +use crate::request::Request; +//use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode}; +use querystrong::QueryStrong; +use std::sync::Arc; +use trillium::{Conn, Status}; +use trillium_router::RouterConnExt; + +/// List all available crates +pub async fn list_crates_handler(conn: Conn) -> Conn { + let request = conn.state::>().unwrap().clone(); + let document = Commands::List.execute(&request); + + if document.is_error() { + conn.with_status(Status::NotFound) + .with_body("Failed to list crates") + } else { + let json_doc = request.render_to_json(document); + json_response(conn, &json_doc) + } +} + +/// Get item documentation or search within a crate +pub async fn item_handler(conn: Conn) -> Conn { + let request = conn.state::>().unwrap().clone(); + let item_path = conn.wildcard().unwrap_or("").replace("/", "::"); + + let document = Commands::get(&item_path).execute(&request); + render_document(conn, document) +} + +fn render_document(conn: Conn, document: Document<'_>) -> Conn { + let request = conn.state::>().unwrap().clone(); + + // Extract actual resolved version from the returned item + // let canonical_url = document.item().map(|item| { + // let crate_name = item.crate_docs().name(); + // let version = item.crate_docs().version(); + // let path = item.summary().map(|s| s.path.join("/")); + // match (version, path) { + // (None, None) => crate_name.to_string(), + // (Some(version), None) => format!( + // "{crate_name}@{}", + // utf8_percent_encode(&version.to_string(), NON_ALPHANUMERIC) + // ), + // (Some(version), Some(path)) => format!( + // "{crate_name}@{}::{path}", + // utf8_percent_encode(&version.to_string(), NON_ALPHANUMERIC) + // ), + // (None, Some(path)) => format!("{crate_name}/{path}"), + // } + // }); + + let json_doc = request.render_to_json(document); + json_response(conn, &json_doc) +} + +/// Search within a specific crate +pub(crate) async fn search_handler(conn: Conn) -> Conn { + let request = conn.state::>().unwrap().clone(); + let crate_name = conn.param("crate").unwrap().to_string(); + let querystring = QueryStrong::parse(conn.querystring()); + let Some(query) = querystring.get_str("q") else { + return conn.with_status(Status::NotFound); + }; + + let document = Commands::search(query) + .in_crate(&crate_name) + .execute(&request); + + render_document(conn, document) +} + +/// Helper: JSON response with proper content-type +fn json_response(conn: Conn, data: &JsonDocument<'_>) -> Conn { + match sonic_rs::to_string(data) { + Ok(json) => conn + .with_response_header("content-type", "application/json") + .ok(json) + .halt(), + Err(e) => { + log::error!("JSON serialization failed: {}", e); + error_response(conn, Status::InternalServerError, "Serialization failed") + } + } +} + +/// Helper: Error response as JSON +fn error_response(conn: Conn, status: Status, message: &str) -> Conn { + let error_json = format!(r#"{{"error":"{}"}}"#, message); + conn.with_status(status) + .with_response_header("content-type", "application/json") + .with_body(error_json) + .halt() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{format_context::FormatContext, web::server::api_router}; + use ferritin_common::{Navigator, sources::StdSource}; + use trillium::{Handler, State}; + use trillium_testing::prelude::*; + + fn test_handler() -> impl Handler { + let request = Arc::new(build_test_request()); + (State::new(request), api_router()) + } + + fn build_test_request() -> Request { + let std_source = StdSource::from_rustup(); + let navigator = Navigator::default().with_std_source(std_source); + let format_context = FormatContext::new(); + Request::new(navigator, format_context) + } + + #[test] + fn test_list_crates() { + let mut conn = get("/api/crates").on(&test_handler()); + + assert_eq!(conn.status(), Some(Status::Ok)); + assert_eq!( + conn.response_headers() + .get("content-type") + .map(|h| h.as_ref()), + Some(b"application/json".as_ref()) + ); + + let body = conn.take_response_body_string().unwrap(); + let doc: JsonDocument = sonic_rs::from_str(&body).unwrap(); + + // Should have nodes + assert!(!doc.nodes().is_empty()); + } + + #[test] + fn test_get_nonexistent_crate() { + let mut conn = get("/api/crates/nonexistent@1.0.0").on(&test_handler()); + + assert_eq!(conn.status(), Some(Status::NotFound)); + assert_eq!( + conn.response_headers() + .get("content-type") + .map(|h| h.as_ref()), + Some(b"application/json".as_ref()) + ); + + let body = conn.take_response_body_string().unwrap(); + assert!(body.contains("error")); + } + + #[test] + fn test_get_std_crate() { + let mut conn = get("/api/crates/std").on(&test_handler()); + + assert_eq!(conn.status(), Some(Status::Ok)); + + let body = conn.take_response_body_string().unwrap(); + let doc: JsonDocument = sonic_rs::from_str(&body).unwrap(); + + assert!(!doc.nodes().is_empty()); + } + + #[test] + fn test_get_std_item() { + let mut conn = get("/api/crates/std::vec::Vec").on(&test_handler()); + + assert_eq!(conn.status(), Some(Status::Ok)); + + let body = conn.take_response_body_string().unwrap(); + let doc: JsonDocument = sonic_rs::from_str(&body).unwrap(); + + assert!(!doc.nodes().is_empty()); + } + + #[test] + fn test_search() { + let mut conn = get("/api/crates/std?q=vec").on(&test_handler()); + + assert_eq!(conn.status(), Some(Status::Ok)); + + let body = conn.take_response_body_string().unwrap(); + let doc: JsonDocument = sonic_rs::from_str(&body).unwrap(); + + // Should have search results + assert!(!doc.nodes().is_empty()); + } +} diff --git a/ferritin/src/web/json_document.rs b/ferritin/src/web/json_document.rs new file mode 100644 index 0000000..79bdbf9 --- /dev/null +++ b/ferritin/src/web/json_document.rs @@ -0,0 +1,243 @@ +use crate::document::{HeadingLevel, SpanStyle}; +use fieldwork::Fieldwork; +use serde::Serialize; +use std::borrow::Cow; + +/// A JSON-serializable document with hypermedia links +#[derive(Serialize, Debug, Clone, PartialEq, Fieldwork, Default)] +#[cfg_attr(test, derive(serde::Deserialize))] +#[serde(rename_all = "camelCase")] +#[fieldwork(get, set, with)] +pub struct JsonDocument<'a> { + /// Canonical URL for this document (e.g., "/tokio/1.49.0/io/AsyncWrite") + #[serde(skip_serializing_if = "Option::is_none")] + canonical_url: Option, + + nodes: Vec>, +} + +#[derive(Serialize, Debug, Clone, PartialEq)] +#[cfg_attr(test, derive(serde::Deserialize))] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum JsonNode<'a> { + Paragraph { + spans: Vec>, + }, + Heading { + level: HeadingLevel, + spans: Vec>, + }, + Section { + #[serde(skip_serializing_if = "Option::is_none")] + title: Option>>, + nodes: Vec>, + }, + List { + items: Vec>, + }, + CodeBlock { + #[serde(skip_serializing_if = "Option::is_none")] + lang: Option>, + code: Cow<'a, str>, + }, + GeneratedCode { + spans: Vec>, + }, + HorizontalRule, + BlockQuote { + nodes: Vec>, + }, + Table { + #[serde(skip_serializing_if = "Option::is_none")] + header: Option>>, + rows: Vec>>, + }, +} + +#[derive(Serialize, Debug, Clone, PartialEq)] +#[cfg_attr(test, derive(serde::Deserialize))] +#[serde(rename_all = "camelCase")] +pub struct JsonSpan<'a> { + pub text: Cow<'a, str>, + pub style: SpanStyle, + + /// Local URL for navigation (e.g., "/tokio/1.49.0/io/AsyncWrite") + /// Resolved from TuiAction during transformation + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option>, +} + +#[derive(Serialize, Debug, Clone, PartialEq)] +#[cfg_attr(test, derive(serde::Deserialize))] +pub struct JsonListItem<'a> { + pub content: Vec>, +} + +#[derive(Serialize, Debug, Clone, PartialEq)] +#[cfg_attr(test, derive(serde::Deserialize))] +pub struct JsonTableCell<'a> { + pub spans: Vec>, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_json_span_serialization() { + let span = JsonSpan { + text: Cow::Borrowed("hello"), + style: SpanStyle::Plain, + url: None, + }; + + let json = sonic_rs::to_string(&span).unwrap(); + assert!(json.contains("hello")); + assert!(json.contains(r#""style":"Plain"#)); + } + + #[test] + fn test_json_span_with_url() { + let span = JsonSpan { + text: Cow::Borrowed("Vec"), + style: SpanStyle::TypeName, + url: Some(Cow::Borrowed("/std/1.0.0/vec/Vec")), + }; + + let json = sonic_rs::to_string(&span).unwrap(); + assert!(json.contains("Vec")); + assert!(json.contains(r#""style":"TypeName"#)); + assert!(json.contains(r#""url":"/std/1.0.0/vec/Vec"#)); + } + + #[test] + fn test_paragraph_node() { + let node = JsonNode::Paragraph { + spans: vec![ + JsonSpan { + text: Cow::Borrowed("Hello "), + style: SpanStyle::Plain, + url: None, + }, + JsonSpan { + text: Cow::Borrowed("world"), + style: SpanStyle::Strong, + url: None, + }, + ], + }; + + let json = sonic_rs::to_string(&node).unwrap(); + assert!(json.contains(r#""type":"paragraph"#)); + assert!(json.contains("Hello ")); + assert!(json.contains("world")); + } + + #[test] + fn test_heading_node() { + let node = JsonNode::Heading { + level: HeadingLevel::Title, + spans: vec![JsonSpan { + text: Cow::Borrowed("API Documentation"), + style: SpanStyle::Plain, + url: None, + }], + }; + + let json = sonic_rs::to_string(&node).unwrap(); + assert!(json.contains(r#""type":"heading"#)); + assert!(json.contains(r#""level":"Title"#)); + assert!(json.contains("API Documentation")); + } + + #[test] + fn test_code_block_node() { + let node = JsonNode::CodeBlock { + lang: Some(Cow::Borrowed("rust")), + code: Cow::Borrowed("fn main() {}"), + }; + + let json = sonic_rs::to_string(&node).unwrap(); + assert!(json.contains(r#""type":"codeBlock"#)); + assert!(json.contains(r#""lang":"rust"#)); + assert!(json.contains("fn main() {}")); + } + + #[test] + fn test_document_with_canonical_url() { + let doc = JsonDocument { + canonical_url: Some("/tokio/1.49.0/io/AsyncWrite".into()), + nodes: vec![JsonNode::Paragraph { + spans: vec![JsonSpan { + text: Cow::Borrowed("Test"), + style: SpanStyle::Plain, + url: None, + }], + }], + }; + + let json = sonic_rs::to_string(&doc).unwrap(); + assert!(json.contains(r#""canonicalUrl":"/tokio/1.49.0/io/AsyncWrite"#)); + assert!(json.contains(r#""type":"paragraph"#)); + } + + #[test] + fn test_document_without_canonical_url() { + let doc = JsonDocument { + canonical_url: None, + nodes: vec![JsonNode::HorizontalRule], + }; + + let json = sonic_rs::to_string(&doc).unwrap(); + // canonicalUrl should be omitted when None + assert!(!json.contains("canonicalUrl")); + assert!(json.contains(r#""type":"horizontalRule"#)); + } + + #[test] + fn test_section_with_title() { + let node = JsonNode::Section { + title: Some(vec![JsonSpan { + text: Cow::Borrowed("Methods"), + style: SpanStyle::Plain, + url: None, + }]), + nodes: vec![JsonNode::HorizontalRule], + }; + + let json = sonic_rs::to_string(&node).unwrap(); + assert!(json.contains(r#""type":"section"#)); + assert!(json.contains("Methods")); + } + + #[test] + fn test_list_items() { + let node = JsonNode::List { + items: vec![ + JsonListItem { + content: vec![JsonNode::Paragraph { + spans: vec![JsonSpan { + text: Cow::Borrowed("Item 1"), + style: SpanStyle::Plain, + url: None, + }], + }], + }, + JsonListItem { + content: vec![JsonNode::Paragraph { + spans: vec![JsonSpan { + text: Cow::Borrowed("Item 2"), + style: SpanStyle::Plain, + url: None, + }], + }], + }, + ], + }; + + let json = sonic_rs::to_string(&node).unwrap(); + assert!(json.contains(r#""type":"list"#)); + assert!(json.contains("Item 1")); + assert!(json.contains("Item 2")); + } +} diff --git a/ferritin/src/web/mod.rs b/ferritin/src/web/mod.rs new file mode 100644 index 0000000..af25652 --- /dev/null +++ b/ferritin/src/web/mod.rs @@ -0,0 +1,6 @@ +mod handlers; +mod json_document; +mod server; +mod transform; + +pub use server::run_server; diff --git a/ferritin/src/web/server.rs b/ferritin/src/web/server.rs new file mode 100644 index 0000000..453cf6b --- /dev/null +++ b/ferritin/src/web/server.rs @@ -0,0 +1,64 @@ +use super::handlers::*; +use crate::{format_context::FormatContext, request::Request}; +use ferritin_common::{ + Navigator, + sources::{DocsRsSource, LocalSource, StdSource}, +}; +use std::path::Path; +use std::sync::Arc; +use trillium::{Conn, Init, State}; +use trillium_router::{Router, RouterConnExt}; + +/// Build the API router with all routes +pub fn api_router() -> Router { + Router::new() + .get("/api/crates/*", move |conn: Conn| async { + if let Some(wildcard) = conn.wildcard() + && !wildcard.is_empty() + { + item_handler(conn).await + } else { + list_crates_handler(conn).await + } + }) + .get("/api/search/:crate", search_handler) +} + +/// Build a Request instance for the server +pub fn build_request(manifest_path: &Path) -> Request { + let local_source = LocalSource::load(manifest_path).ok(); + let std_source = StdSource::from_rustup(); + let docsrs_source = DocsRsSource::from_default_cache(); + + let navigator = Navigator::default() + .with_std_source(std_source) + .with_local_source(local_source) + .with_docsrs_source(docsrs_source); + + let format_context = FormatContext::new(); + Request::new(navigator, format_context) +} + +/// Run the JSON API server +/// Uses HOST and PORT env vars (defaults to 127.0.0.1:8080) +pub fn run_server(manifest_path: &Path, open: bool) { + env_logger::init(); + let request = Arc::new(build_request(manifest_path)); + + let handler = ( + Init::new(move |info| async move { + if open && let Some(tcp) = info.tcp_socket_addr() { + let _ = webbrowser::open(&format!("http://{tcp}")); + } + }), + trillium_logger::logger(), + State::new(request), + api_router(), + #[cfg(feature = "web")] + trillium_frontend::frontend!("../web") + .with_client(trillium_smol::ClientConfig::default()) + .with_index_file("index.html"), + ); + + trillium_smol::run(handler); +} diff --git a/ferritin/src/web/transform.rs b/ferritin/src/web/transform.rs new file mode 100644 index 0000000..c4cef98 --- /dev/null +++ b/ferritin/src/web/transform.rs @@ -0,0 +1,364 @@ +use super::json_document::{JsonDocument, JsonListItem, JsonNode, JsonSpan, JsonTableCell}; +use crate::document::{Document, DocumentNode, Span, TuiAction}; +use crate::request::Request; +use ferritin_common::DocRef; +use rustdoc_types::Item; +use std::borrow::Cow; + +impl Request { + /// Transform a Document into JSON-serializable format + /// Resolves all TuiActions to local URLs + pub fn render_to_json<'a>(&'a self, document: Document<'a>) -> JsonDocument<'a> { + JsonDocument::default().with_nodes( + document + .into_nodes() + .into_iter() + .map(|node| self.transform_node(node)) + .collect(), + ) + } + + fn transform_node<'a>(&'a self, node: DocumentNode<'a>) -> JsonNode<'a> { + match node { + DocumentNode::Paragraph { spans } => JsonNode::Paragraph { + spans: spans.into_iter().map(|s| self.transform_span(s)).collect(), + }, + + DocumentNode::Heading { level, spans } => JsonNode::Heading { + level, + spans: spans.into_iter().map(|s| self.transform_span(s)).collect(), + }, + + DocumentNode::Section { title, nodes } => JsonNode::Section { + title: title + .map(|spans| spans.into_iter().map(|s| self.transform_span(s)).collect()), + nodes: nodes.into_iter().map(|n| self.transform_node(n)).collect(), + }, + + DocumentNode::List { items } => JsonNode::List { + items: items + .into_iter() + .map(|item| JsonListItem { + content: item + .content + .into_iter() + .map(|n| self.transform_node(n)) + .collect(), + }) + .collect(), + }, + + DocumentNode::CodeBlock { lang, code } => JsonNode::CodeBlock { lang, code }, + + DocumentNode::GeneratedCode { spans } => JsonNode::GeneratedCode { + spans: spans.into_iter().map(|s| self.transform_span(s)).collect(), + }, + + DocumentNode::HorizontalRule => JsonNode::HorizontalRule, + + DocumentNode::BlockQuote { nodes } => JsonNode::BlockQuote { + nodes: nodes.into_iter().map(|n| self.transform_node(n)).collect(), + }, + + DocumentNode::Table { header, rows } => JsonNode::Table { + header: header.map(|cells| { + cells + .into_iter() + .map(|cell| JsonTableCell { + spans: cell + .spans + .into_iter() + .map(|s| self.transform_span(s)) + .collect(), + }) + .collect() + }), + rows: rows + .into_iter() + .map(|row| { + row.into_iter() + .map(|cell| JsonTableCell { + spans: cell + .spans + .into_iter() + .map(|s| self.transform_span(s)) + .collect(), + }) + .collect() + }) + .collect(), + }, + + // Apply truncation server-side to reduce transport cost + DocumentNode::TruncatedBlock { nodes, level } => { + use crate::document::TruncationLevel; + + let truncated_nodes = match level { + TruncationLevel::SingleLine => { + // Show first node only (heading or paragraph) + if let Some(first) = nodes.first() { + match first { + DocumentNode::Heading { spans, .. } => { + // Just the heading text as a paragraph, no decoration + vec![DocumentNode::Paragraph { + spans: spans.clone(), + }] + } + _ => vec![first.clone()], + } + } else { + vec![] + } + } + TruncationLevel::Brief => { + // Show first paragraph/node only, skip code blocks and lists + nodes + .iter() + .take(1) + .filter(|node| { + !matches!( + node, + DocumentNode::CodeBlock { .. } + | DocumentNode::GeneratedCode { .. } + | DocumentNode::List { .. } + ) + }) + .cloned() + .collect() + } + TruncationLevel::Full => nodes, + }; + + JsonNode::Section { + title: None, + nodes: truncated_nodes + .into_iter() + .map(|n| self.transform_node(n)) + .collect(), + } + } + + // Flatten Conditional - web is always interactive + DocumentNode::Conditional { nodes, show_when } => { + use crate::document::ShowWhen; + + let should_show = match show_when { + ShowWhen::Always => true, + ShowWhen::Interactive => true, + ShowWhen::NonInteractive => false, + }; + + if should_show { + JsonNode::Section { + title: None, + nodes: nodes.into_iter().map(|n| self.transform_node(n)).collect(), + } + } else { + // Return empty section for non-interactive content + JsonNode::Section { + title: None, + nodes: vec![], + } + } + } + } + } + + fn transform_span<'a>(&'a self, span: Span<'a>) -> JsonSpan<'a> { + let url = span.action.as_ref().and_then(|action| match action { + TuiAction::Navigate { doc_ref, .. } => Some(Cow::Owned(self.item_to_url(*doc_ref))), + TuiAction::NavigateToPath { path, .. } => Some(Cow::Owned(self.path_to_url(path))), + TuiAction::OpenUrl(url) => Some(url.clone()), + TuiAction::ExpandBlock(_) | TuiAction::SelectTheme(_) => None, + }); + + JsonSpan { + text: span.text, + style: span.style, + url, + } + } + + /// Convert a DocRef to a local URL like "/tokio@1.49.0::io::AsyncWrite" + fn item_to_url<'a>(&'a self, item: DocRef<'a, Item>) -> String { + let crate_docs = item.crate_docs(); + let crate_name = crate_docs.name(); + let version = crate_docs.version(); + + let crate_part = match version { + Some(v) => format!("{}@{}", crate_name, v), + None => crate_name.to_string(), + }; + + if let Some(summary) = item.summary() { + // Skip first element (crate name) since it's already in crate_part + let path_parts: Vec<&str> = summary.path.iter().skip(1).map(|s| s.as_str()).collect(); + if path_parts.is_empty() { + format!("/{}", crate_part) + } else { + format!("/{}::{}", crate_part, path_parts.join("::")) + } + } else { + format!("/{}", crate_part) + } + } + + /// Convert a path string to a local URL + /// Handles paths like "tokio::io::AsyncWrite" or "tokio@1.49::io::AsyncWrite" + fn path_to_url(&self, path: &str) -> String { + format!("/{}", path) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::document::{DocumentNode, HeadingLevel, ListItem, Span, SpanStyle, TruncationLevel}; + use crate::format_context::FormatContext; + use ferritin_common::{Navigator, sources::StdSource}; + + fn test_request() -> Request { + let std_source = StdSource::from_rustup(); + let navigator = Navigator::default().with_std_source(std_source); + let format_context = FormatContext::new(); + Request::new(navigator, format_context) + } + + #[test] + fn test_path_to_url_simple() { + let request = test_request(); + assert_eq!(request.path_to_url("std::vec::Vec"), "/std::vec::Vec"); + assert_eq!( + request.path_to_url("tokio::io::AsyncWrite"), + "/tokio::io::AsyncWrite" + ); + } + + #[test] + fn test_path_to_url_with_version() { + let request = test_request(); + assert_eq!( + request.path_to_url("tokio@1.49::io::AsyncWrite"), + "/tokio@1.49::io::AsyncWrite" + ); + assert_eq!( + request.path_to_url("serde@1.0::Serialize"), + "/serde@1.0::Serialize" + ); + } + + #[test] + fn test_path_to_url_crate_only() { + let request = test_request(); + assert_eq!(request.path_to_url("std"), "/std"); + assert_eq!(request.path_to_url("tokio@1.49"), "/tokio@1.49"); + } + + #[test] + fn test_transform_paragraph() { + let request = test_request(); + let doc = Document::from(vec![Span::plain("Hello "), Span::strong("world")]); + + let json_doc = request.render_to_json(doc); + assert_eq!(json_doc.nodes().len(), 1); + + match &json_doc.nodes()[0] { + JsonNode::Paragraph { spans } => { + assert_eq!(spans.len(), 2); + assert_eq!(spans[0].text, "Hello "); + assert_eq!(spans[0].style, SpanStyle::Plain); + assert_eq!(spans[1].text, "world"); + assert_eq!(spans[1].style, SpanStyle::Strong); + } + _ => panic!("Expected paragraph"), + } + } + + #[test] + fn test_transform_heading() { + let request = test_request(); + let doc = Document::from(DocumentNode::heading( + HeadingLevel::Title, + vec![Span::plain("API Documentation")], + )); + + let json_doc = request.render_to_json(doc); + + match &json_doc.nodes()[0] { + JsonNode::Heading { level, spans } => { + assert_eq!(*level, HeadingLevel::Title); + assert_eq!(spans[0].text, "API Documentation"); + } + _ => panic!("Expected heading"), + } + } + + #[test] + fn test_transform_code_block() { + let request = test_request(); + let doc = Document::from(DocumentNode::code_block(Some("rust"), "fn main() {}")); + + let json_doc = request.render_to_json(doc); + + match &json_doc.nodes()[0] { + JsonNode::CodeBlock { lang, code } => { + assert_eq!(lang.as_deref(), Some("rust")); + assert_eq!(code, "fn main() {}"); + } + _ => panic!("Expected code block"), + } + } + + #[test] + fn test_transform_list() { + let request = test_request(); + let doc = Document::from(DocumentNode::list(vec![ + ListItem::new(vec![DocumentNode::paragraph(vec![Span::plain("Item 1")])]), + ListItem::new(vec![DocumentNode::paragraph(vec![Span::plain("Item 2")])]), + ])); + let json_doc = request.render_to_json(doc); + + match &json_doc.nodes()[0] { + JsonNode::List { items } => { + assert_eq!(items.len(), 2); + } + _ => panic!("Expected list"), + } + } + + #[test] + fn test_transform_truncated_block_flattens() { + let request = test_request(); + let doc = Document::from(DocumentNode::truncated_block( + vec![DocumentNode::paragraph(vec![Span::plain("Hidden content")])], + TruncationLevel::Brief, + )); + + let json_doc = request.render_to_json(doc); + + // Should be flattened to a Section + match &json_doc.nodes()[0] { + JsonNode::Section { title, nodes } => { + assert!(title.is_none()); + assert_eq!(nodes.len(), 1); + } + _ => panic!("Expected section (flattened truncated block)"), + } + } + + #[test] + fn test_transform_span_with_path_action() { + let request = test_request(); + let doc = Document::from(Span::type_name("Vec").with_path("std::vec::Vec")); + + let json_doc = request.render_to_json(doc); + + match &json_doc.nodes()[0] { + JsonNode::Paragraph { spans } => { + assert_eq!(spans[0].text, "Vec"); + assert_eq!(spans[0].url.as_deref(), Some("/std::vec::Vec")); + } + _ => panic!("Expected paragraph"), + } + } +} diff --git a/release-plz.toml b/release-plz.toml index b4731fd..d908221 100644 --- a/release-plz.toml +++ b/release-plz.toml @@ -1,2 +1,7 @@ [workspace] features_always_increment_minor = true + +[[package]] +name = "ferritin" +publish_features = ["web"] +publish_allow_dirty = true diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..81e93d1 --- /dev/null +++ b/web/README.md @@ -0,0 +1,32 @@ +# Ferritin Web Frontend + +React-based web interface for browsing Rust documentation. + +## Development + +```bash +# Install dependencies +npm install + +# Start dev server (proxies /api to localhost:8080) +npm run dev + +# Build for production +npm run build +``` + +## Architecture + +- **API Client** (`src/api/client.ts`) - Fetches JSON documents from the backend API +- **TypeScript Types** (`src/types/api.ts`) - Matches the JSON document format from the server +- **Components**: + - `DocumentRenderer` - Top-level document rendering + - `NodeRenderer` - Renders individual document nodes (paragraphs, headings, lists, etc.) + - `SpanRenderer` - Renders styled text spans with optional links +- **Routing** - React Router handles navigation between crates/items + +## Running + +1. Start the backend server: `cargo run --features serve-json -- serve` +2. Start the frontend dev server: `npm run dev` +3. Open http://localhost:5173 in your browser diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..4e2cb21 --- /dev/null +++ b/web/index.html @@ -0,0 +1,12 @@ + + + + + + Ferritin + + +
+ + + diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..317607f --- /dev/null +++ b/web/package.json @@ -0,0 +1,23 @@ +{ + "name": "ferritin-web", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "rhoto-router": "^0.3.0" + }, + "devDependencies": { + "@types/react": "^18.3.5", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "typescript": "^5.5.3", + "vite": "^5.4.3" + } +} diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml new file mode 100644 index 0000000..44dc459 --- /dev/null +++ b/web/pnpm-lock.yaml @@ -0,0 +1,1274 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + react: + specifier: ^18.3.1 + version: 18.3.1 + react-dom: + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) + rhoto-router: + specifier: ^0.3.0 + version: 0.3.0(react@18.3.1) + devDependencies: + '@types/react': + specifier: ^18.3.5 + version: 18.3.28 + '@types/react-dom': + specifier: ^18.3.0 + version: 18.3.7(@types/react@18.3.28) + '@vitejs/plugin-react': + specifier: ^4.3.1 + version: 4.7.0(vite@5.4.21) + typescript: + specifier: ^5.5.3 + version: 5.9.3 + vite: + specifier: ^5.4.3 + version: 5.4.21 + +packages: + + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.0': + resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.0': + resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.28.6': + resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.28.6': + resolution: {integrity: sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.0': + resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.27.1': + resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.27.1': + resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@rolldown/pluginutils@1.0.0-beta.27': + resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + + '@rollup/rollup-android-arm-eabi@4.57.1': + resolution: {integrity: sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.57.1': + resolution: {integrity: sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.57.1': + resolution: {integrity: sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.57.1': + resolution: {integrity: sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.57.1': + resolution: {integrity: sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.57.1': + resolution: {integrity: sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.57.1': + resolution: {integrity: sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.57.1': + resolution: {integrity: sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.57.1': + resolution: {integrity: sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.57.1': + resolution: {integrity: sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.57.1': + resolution: {integrity: sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.57.1': + resolution: {integrity: sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.57.1': + resolution: {integrity: sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.57.1': + resolution: {integrity: sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.57.1': + resolution: {integrity: sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.57.1': + resolution: {integrity: sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.57.1': + resolution: {integrity: sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.57.1': + resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.57.1': + resolution: {integrity: sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.57.1': + resolution: {integrity: sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.57.1': + resolution: {integrity: sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.57.1': + resolution: {integrity: sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.57.1': + resolution: {integrity: sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.57.1': + resolution: {integrity: sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.57.1': + resolution: {integrity: sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==} + cpu: [x64] + os: [win32] + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/prop-types@15.7.15': + resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + + '@types/qs@6.14.0': + resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==} + + '@types/react-dom@18.3.7': + resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} + peerDependencies: + '@types/react': ^18.0.0 + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@18.3.28': + resolution: {integrity: sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==} + + '@types/react@19.2.14': + resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + + '@vitejs/plugin-react@4.7.0': + resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + + baseline-browser-mapping@2.9.19: + resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} + hasBin: true + + browserslist@4.28.1: + resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + caniuse-lite@1.0.30001769: + resolution: {integrity: sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + electron-to-chromium@1.5.286: + resolution: {integrity: sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-releases@2.0.27: + resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + path-to-regexp@8.3.0: + resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + qs@6.15.0: + resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} + engines: {node: '>=0.6'} + + react-dom@18.3.1: + resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} + peerDependencies: + react: ^18.3.1 + + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + engines: {node: '>=0.10.0'} + + react@18.3.1: + resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} + engines: {node: '>=0.10.0'} + + rhoto-router@0.3.0: + resolution: {integrity: sha512-M2cLDB0zcm6MjM3nMtqodNiILGAI6ZyL1ejSTCpNK0FF1w+4KenNJQwU/t8AJqxpZG+iFImzssQfXwXMPM4sjA==} + peerDependencies: + react: ^16.8 || ^17 || ^18 + + rollup@4.57.1: + resolution: {integrity: sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + 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 + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + +snapshots: + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.0': {} + + '@babel/core@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helpers': 7.28.6 + '@babel/parser': 7.29.0 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.1': + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.28.6': + dependencies: + '@babel/compat-data': 7.29.0 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.1 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.28.6': {} + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.28.6': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + + '@babel/parser@7.29.0': + dependencies: + '@babel/types': 7.29.0 + + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.0 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@rolldown/pluginutils@1.0.0-beta.27': {} + + '@rollup/rollup-android-arm-eabi@4.57.1': + optional: true + + '@rollup/rollup-android-arm64@4.57.1': + optional: true + + '@rollup/rollup-darwin-arm64@4.57.1': + optional: true + + '@rollup/rollup-darwin-x64@4.57.1': + optional: true + + '@rollup/rollup-freebsd-arm64@4.57.1': + optional: true + + '@rollup/rollup-freebsd-x64@4.57.1': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.57.1': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.57.1': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.57.1': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.57.1': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.57.1': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.57.1': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-x64-musl@4.57.1': + optional: true + + '@rollup/rollup-openbsd-x64@4.57.1': + optional: true + + '@rollup/rollup-openharmony-arm64@4.57.1': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.57.1': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.57.1': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.57.1': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.57.1': + optional: true + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.0 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.0 + + '@types/estree@1.0.8': {} + + '@types/prop-types@15.7.15': {} + + '@types/qs@6.14.0': {} + + '@types/react-dom@18.3.7(@types/react@18.3.28)': + dependencies: + '@types/react': 18.3.28 + + '@types/react-dom@19.2.3(@types/react@19.2.14)': + dependencies: + '@types/react': 19.2.14 + + '@types/react@18.3.28': + dependencies: + '@types/prop-types': 15.7.15 + csstype: 3.2.3 + + '@types/react@19.2.14': + dependencies: + csstype: 3.2.3 + + '@vitejs/plugin-react@4.7.0(vite@5.4.21)': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) + '@rolldown/pluginutils': 1.0.0-beta.27 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 5.4.21 + transitivePeerDependencies: + - supports-color + + baseline-browser-mapping@2.9.19: {} + + browserslist@4.28.1: + dependencies: + baseline-browser-mapping: 2.9.19 + caniuse-lite: 1.0.30001769 + electron-to-chromium: 1.5.286 + node-releases: 2.0.27 + update-browserslist-db: 1.2.3(browserslist@4.28.1) + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + caniuse-lite@1.0.30001769: {} + + convert-source-map@2.0.0: {} + + csstype@3.2.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + electron-to-chromium@1.5.286: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + esbuild@0.21.5: + 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 + + escalade@3.2.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + gensync@1.0.0-beta.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + js-tokens@4.0.0: {} + + jsesc@3.1.0: {} + + json5@2.2.3: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + math-intrinsics@1.1.0: {} + + ms@2.1.3: {} + + nanoid@3.3.11: {} + + node-releases@2.0.27: {} + + object-inspect@1.13.4: {} + + path-to-regexp@8.3.0: {} + + picocolors@1.1.1: {} + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + qs@6.15.0: + dependencies: + side-channel: 1.1.0 + + react-dom@18.3.1(react@18.3.1): + dependencies: + loose-envify: 1.4.0 + react: 18.3.1 + scheduler: 0.23.2 + + react-refresh@0.17.0: {} + + react@18.3.1: + dependencies: + loose-envify: 1.4.0 + + rhoto-router@0.3.0(react@18.3.1): + dependencies: + '@types/qs': 6.14.0 + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + path-to-regexp: 8.3.0 + qs: 6.15.0 + react: 18.3.1 + + rollup@4.57.1: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.57.1 + '@rollup/rollup-android-arm64': 4.57.1 + '@rollup/rollup-darwin-arm64': 4.57.1 + '@rollup/rollup-darwin-x64': 4.57.1 + '@rollup/rollup-freebsd-arm64': 4.57.1 + '@rollup/rollup-freebsd-x64': 4.57.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.57.1 + '@rollup/rollup-linux-arm-musleabihf': 4.57.1 + '@rollup/rollup-linux-arm64-gnu': 4.57.1 + '@rollup/rollup-linux-arm64-musl': 4.57.1 + '@rollup/rollup-linux-loong64-gnu': 4.57.1 + '@rollup/rollup-linux-loong64-musl': 4.57.1 + '@rollup/rollup-linux-ppc64-gnu': 4.57.1 + '@rollup/rollup-linux-ppc64-musl': 4.57.1 + '@rollup/rollup-linux-riscv64-gnu': 4.57.1 + '@rollup/rollup-linux-riscv64-musl': 4.57.1 + '@rollup/rollup-linux-s390x-gnu': 4.57.1 + '@rollup/rollup-linux-x64-gnu': 4.57.1 + '@rollup/rollup-linux-x64-musl': 4.57.1 + '@rollup/rollup-openbsd-x64': 4.57.1 + '@rollup/rollup-openharmony-arm64': 4.57.1 + '@rollup/rollup-win32-arm64-msvc': 4.57.1 + '@rollup/rollup-win32-ia32-msvc': 4.57.1 + '@rollup/rollup-win32-x64-gnu': 4.57.1 + '@rollup/rollup-win32-x64-msvc': 4.57.1 + fsevents: 2.3.3 + + scheduler@0.23.2: + dependencies: + loose-envify: 1.4.0 + + semver@6.3.1: {} + + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + source-map-js@1.2.1: {} + + typescript@5.9.3: {} + + update-browserslist-db@1.2.3(browserslist@4.28.1): + dependencies: + browserslist: 4.28.1 + escalade: 3.2.0 + picocolors: 1.1.1 + + vite@5.4.21: + dependencies: + esbuild: 0.21.5 + postcss: 8.5.6 + rollup: 4.57.1 + optionalDependencies: + fsevents: 2.3.3 + + yallist@3.1.1: {} diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..c6c0005 --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,61 @@ +import { Router, Route } from 'rhoto-router'; +import { useEffect, useState } from 'react'; +import { DocumentRenderer } from './components/DocumentRenderer'; +import { fetchItem, listCrates } from './api/client'; +import type { JsonDocument } from './types/api'; + +function ItemPage({ itemPath }: { itemPath: string }) { + const [document, setDocument] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + window.scrollTo(0, 0); + fetchItem(itemPath) + .then(doc => setDocument(doc)) + .catch(err => setError(err.message)); + }, [itemPath]); + + if (error) { + return
Error: {error}
; + } + + if (!document) { + return
Loading...
; + } + + return ; +} + +function ListPage() { + const [document, setDocument] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + listCrates() + .then(doc => setDocument(doc)) + .catch(err => setError(err.message)); + }, []); + + if (error) { + return
Error: {error}
; + } + + if (!document) { + return
Loading...
; + } + + return ; +} + +export function App() { + return ( + + + + + + {(params) => } + + + ); +} diff --git a/web/src/api/client.ts b/web/src/api/client.ts new file mode 100644 index 0000000..2b2c31d --- /dev/null +++ b/web/src/api/client.ts @@ -0,0 +1,37 @@ +import type { JsonDocument } from "../types/api"; + +export async function fetchItem(itemPath: string): Promise { + // Accept both / and :: as path separators; normalize to :: + const normalized = itemPath.replace(/\//g, "::"); + const response = await fetch(`/api/crates/${normalized}`); + + if (!response.ok) { + throw new Error(`Failed to fetch: ${response.statusText}`); + } + + return response.json(); +} + +export async function search( + crateSpec: string, + query: string +): Promise { + const url = `/api/search/${crateSpec}?q=${encodeURIComponent(query)}`; + const response = await fetch(url); + + if (!response.ok) { + throw new Error(`Search failed: ${response.statusText}`); + } + + return response.json(); +} + +export async function listCrates(): Promise { + const response = await fetch("/api/crates"); + + if (!response.ok) { + throw new Error(`Failed to list crates: ${response.statusText}`); + } + + return response.json(); +} diff --git a/web/src/components/DocumentRenderer.tsx b/web/src/components/DocumentRenderer.tsx new file mode 100644 index 0000000..fcd2262 --- /dev/null +++ b/web/src/components/DocumentRenderer.tsx @@ -0,0 +1,12 @@ +import type { JsonDocument } from '../types/api'; +import { NodeRenderer } from './NodeRenderer'; + +export function DocumentRenderer({ document }: { document: JsonDocument }) { + return ( +
+ {document.nodes.map((node, i) => ( + + ))} +
+ ); +} diff --git a/web/src/components/NodeRenderer.tsx b/web/src/components/NodeRenderer.tsx new file mode 100644 index 0000000..39d798d --- /dev/null +++ b/web/src/components/NodeRenderer.tsx @@ -0,0 +1,106 @@ +import type { JsonNode } from '../types/api'; +import { SpanRenderer } from './SpanRenderer'; + +export function NodeRenderer({ node }: { node: JsonNode }) { + switch (node.type) { + case 'paragraph': + return ( +

+ +

+ ); + + case 'heading': + const HeadingTag = node.level === 'Title' ? 'h1' : 'h2'; + return ( + + + + ); + + case 'section': + return ( +
+ {node.title && ( +

+ +

+ )} + {node.nodes.map((n, i) => ( + + ))} +
+ ); + + case 'list': + return ( +
    + {node.items.map((item, i) => ( +
  • + {item.content.map((n, j) => ( + + ))} +
  • + ))} +
+ ); + + case 'codeBlock': + return ( +
+          
+            {node.code}
+          
+        
+ ); + + case 'generatedCode': + return ( +
+          
+            
+          
+        
+ ); + + case 'horizontalRule': + return
; + + case 'blockQuote': + return ( +
+ {node.nodes.map((n, i) => ( + + ))} +
+ ); + + case 'table': + return ( + + {node.header && ( + + + {node.header.map((cell, i) => ( + + ))} + + + )} + + {node.rows.map((row, i) => ( + + {row.map((cell, j) => ( + + ))} + + ))} + +
+ +
+ +
+ ); + } +} diff --git a/web/src/components/SpanRenderer.tsx b/web/src/components/SpanRenderer.tsx new file mode 100644 index 0000000..fd9d80d --- /dev/null +++ b/web/src/components/SpanRenderer.tsx @@ -0,0 +1,42 @@ +import { Link } from 'rhoto-router'; +import type { JsonSpan } from '../types/api'; + +export function SpanRenderer({ spans }: { spans: JsonSpan[] }) { + return ( + <> + {spans.map((span, i) => ( + + ))} + + ); +} + +function SpanElement({ span }: { span: JsonSpan }) { + const className = `span-${span.style.toLowerCase()}`; + const content = renderTextWithBreaks(span.text); + + if (span.url) { + if (span.url.startsWith('http://') || span.url.startsWith('https://')) { + return ( + + {content} + + ); + } + + return ( + + {content} + + ); + } + + return {content}; +} + +function renderTextWithBreaks(text: string) { + const lines = text.split('\n'); + return lines.flatMap((line, i) => + i < lines.length - 1 ? [line,
] : [line] + ); +} diff --git a/web/src/index.css b/web/src/index.css new file mode 100644 index 0000000..ad334d6 --- /dev/null +++ b/web/src/index.css @@ -0,0 +1,99 @@ +:root { + font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + line-height: 1.5; + font-weight: 400; + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; +} + +body { + margin: 0; + padding: 1rem; + min-height: 100vh; +} + +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; +} + +.ferritin-document { + max-width: 900px; +} + +.loading, +.error { + padding: 2rem; + text-align: center; +} + +.error { + color: #ff6b6b; +} + +pre { + background: #1a1a1a; + padding: 1rem; + border-radius: 4px; + overflow-x: auto; +} + +code { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; +} + +table { + border-collapse: collapse; + width: 100%; + margin: 1rem 0; +} + +th, +td { + border: 1px solid #444; + padding: 0.5rem; + text-align: left; +} + +th { + background: #1a1a1a; +} + +blockquote { + border-left: 3px solid #666; + margin-left: 0; + padding-left: 1rem; + color: #999; +} + +/* Basic span styles - will iterate on these */ +.span-keyword { color: #ff79c6; } +.span-typename { color: #8be9fd; } +.span-functionname { color: #50fa7b; } +.span-fieldname { color: #f1fa8c; } +.span-lifetime { color: #ff79c6; } +.span-generic { color: #8be9fd; } +.span-punctuation { color: #f8f8f2; } +.span-operator { color: #ff79c6; } +.span-comment { color: #6272a4; } +.span-inlinerustcode, +.span-inlinecode { + background: #1a1a1a; + padding: 0.2rem 0.4rem; + border-radius: 3px; + font-family: ui-monospace, monospace; +} +.span-strong { font-weight: bold; } +.span-emphasis { font-style: italic; } +.span-strikethrough { text-decoration: line-through; } + +a { + color: #8be9fd; + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..a633310 --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { App } from './App' +import './index.css' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/web/src/types/api.ts b/web/src/types/api.ts new file mode 100644 index 0000000..c3af496 --- /dev/null +++ b/web/src/types/api.ts @@ -0,0 +1,48 @@ +export interface JsonDocument { + canonicalUrl?: string; + nodes: JsonNode[]; +} + +export type JsonNode = + | { type: 'paragraph'; spans: JsonSpan[] } + | { type: 'heading'; level: HeadingLevel; spans: JsonSpan[] } + | { type: 'section'; title?: JsonSpan[]; nodes: JsonNode[] } + | { type: 'list'; items: JsonListItem[] } + | { type: 'codeBlock'; lang?: string; code: string } + | { type: 'generatedCode'; spans: JsonSpan[] } + | { type: 'horizontalRule' } + | { type: 'blockQuote'; nodes: JsonNode[] } + | { type: 'table'; header?: JsonTableCell[]; rows: JsonTableCell[][] }; + +export interface JsonSpan { + text: string; + style: SpanStyle; + url?: string; +} + +export interface JsonListItem { + content: JsonNode[]; +} + +export interface JsonTableCell { + spans: JsonSpan[]; +} + +export type HeadingLevel = 'Title' | 'Section'; + +export type SpanStyle = + | 'Keyword' + | 'TypeName' + | 'FunctionName' + | 'FieldName' + | 'Lifetime' + | 'Generic' + | 'Plain' + | 'Punctuation' + | 'Operator' + | 'Comment' + | 'InlineRustCode' + | 'InlineCode' + | 'Strong' + | 'Emphasis' + | 'Strikethrough'; diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/web/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..f50b75c --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/web/tsconfig.node.json b/web/tsconfig.node.json new file mode 100644 index 0000000..97ede7e --- /dev/null +++ b/web/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.ts"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..081c8d9 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], +});