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()],
+});