Skip to content

Commit 4ead7ee

Browse files
feat: add shared state to WebUI Press
Add top-level state/stateFile loading for global render state and merge it into every generated page, including the 404 page. Move shared and custom page state loading into a dedicated module with tests for merge precedence, reserved key protection, and state file validation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 0d7d5da commit 4ead7ee

8 files changed

Lines changed: 561 additions & 150 deletions

File tree

crates/webui-press/README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,8 @@ Shadow DOM components can react to the layout via `:host-context([data-layout="f
251251
"html": "Released under the MIT License."
252252
},
253253

254+
"stateFile": "./state/site.json",
255+
254256
"customPages": {
255257
"/playground/": {
256258
"layout": "full",
@@ -269,6 +271,34 @@ Shadow DOM components can react to the layout via `:host-context([data-layout="f
269271
- **`sidebarGroups`** maps URL prefixes to sidebar definitions. The longest matching prefix wins. A page at `/guide/concepts/` uses the `/guide/` sidebar.
270272
- **`prev`/`next`** links at the bottom of a page are derived from the active sidebar's flat link order.
271273

274+
### Shared state
275+
276+
Use top-level `state` or `stateFile` when every page needs the same project data:
277+
278+
```json
279+
{
280+
"stateFile": "./state/site.json"
281+
}
282+
```
283+
284+
The JSON must be an object and is merged into every page's render state, so
285+
components and Markdown-authored custom elements can bind to it directly:
286+
287+
```html
288+
<span>{{release.version}}</span>
289+
```
290+
291+
Shared state cannot override reserved docs keys such as `site`, `navigation`,
292+
`sidebar`, `page`, `hero`, `footer`, `prev`, `next`, `pageData`,
293+
`headTags`, `label`, or `icon`. Global state is applied first. Custom page
294+
state is applied afterward for that page, so non-reserved custom page keys can
295+
override global keys.
296+
297+
The merged render state is embedded into each generated page's `#webui-data`
298+
hydration block. Do not put secrets in `state` or `stateFile`, and keep shared
299+
state small. Large global JSON files are duplicated into every output page; use
300+
custom page state or static JSON assets for large page-specific datasets.
301+
272302
### `head` injection
273303

274304
Every entry in `head[]` is rendered into `<head>` with attributes sorted alphabetically (deterministic output for reproducible builds). Use it for favicons, analytics tags, preloads, OpenGraph overrides, anything `<head>`-shaped.

crates/webui-press/src/build.rs

Lines changed: 108 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,10 @@ use crate::bundler::{
2222
page_bundle_signature, resolve_config_component_source, resolve_node_modules, BundleOptions,
2323
BundleResult, BundleThread, PageBundleEntry, RootBundleEntry, ScriptSource,
2424
};
25-
use crate::content::process_content;
25+
use crate::content::process_content_with_states;
2626
use crate::error::{Error, Result};
2727
use crate::markdown::Highlighter;
28+
use crate::state::{load_render_states, merge_page_state};
2829
use crate::types::{BuildStats, DocsConfig};
2930

3031
/// Persistent state held by the dev server across rebuilds. The dev
@@ -98,6 +99,53 @@ fn inject_theme_tokens(
9899
Ok(())
99100
}
100101

102+
struct NotFoundStateInput<'a> {
103+
config: &'a DocsConfig,
104+
base_path: &'a str,
105+
nav: Value,
106+
footer: Value,
107+
content: &'a str,
108+
head_injection: &'a str,
109+
global_state: Option<&'a Value>,
110+
}
111+
112+
fn build_not_found_state(input: NotFoundStateInput<'_>) -> Value {
113+
let state = json_obj([
114+
(
115+
"site",
116+
json_obj([
117+
("title", Value::String(input.config.site.title.clone())),
118+
("base", Value::String(input.base_path.to_string())),
119+
]),
120+
),
121+
("navigation", input.nav),
122+
("sidebar", json_obj([("sections", Value::Array(vec![]))])),
123+
(
124+
"page",
125+
json_obj([
126+
("title", Value::String("Page Not Found".to_string())),
127+
(
128+
"description",
129+
Value::String("The page you're looking for doesn't exist.".to_string()),
130+
),
131+
("content", Value::String(input.content.to_string())),
132+
("isHome", Value::Bool(false)),
133+
("layout", Value::String("doc".to_string())),
134+
]),
135+
),
136+
("hero", Value::Null),
137+
("footer", input.footer),
138+
("prev", Value::Null),
139+
("next", Value::Null),
140+
("pageData", Value::Null),
141+
("headTags", Value::String(input.head_injection.to_string())),
142+
("label", Value::String("Copy".to_string())),
143+
("icon", Value::String("🌙".to_string())),
144+
]);
145+
146+
merge_page_state(state, input.global_state, None)
147+
}
148+
101149
// ── Output helpers ──────────────────────────────────────────────
102150
//
103151
// Mirrors the styling vocabulary in `crates/webui-cli/src/utils/output.rs`
@@ -267,9 +315,9 @@ pub fn build_docs_with_cache(
267315
// Step 1: Process content. Highlighter is cached across rebuilds —
268316
// syntect's default syntax/theme load is ~30-50ms which we don't want
269317
// to pay every keystroke.
318+
let render_states = load_render_states(config, config_dir)?;
270319
let highlighter = cache.highlighter.take().unwrap_or_default();
271-
272-
let pages = process_content(config, config_dir, &highlighter, &head_injection)?;
320+
let pages = process_content_with_states(config, &highlighter, &head_injection, &render_states)?;
273321

274322
// Restore the highlighter for the next rebuild.
275323
cache.highlighter = Some(highlighter);
@@ -645,37 +693,15 @@ pub fn build_docs_with_cache(
645693
.map(|f| json_obj([("html", Value::String(f.html.clone()))]))
646694
.unwrap_or(Value::Null);
647695

648-
let mut not_found_state = json_obj([
649-
(
650-
"site",
651-
json_obj([
652-
("title", Value::String(config.site.title.clone())),
653-
("base", Value::String(base_path.to_string())),
654-
]),
655-
),
656-
("navigation", nav_val),
657-
("sidebar", json_obj([("sections", Value::Array(vec![]))])),
658-
(
659-
"page",
660-
json_obj([
661-
("title", Value::String("Page Not Found".to_string())),
662-
(
663-
"description",
664-
Value::String("The page you're looking for doesn't exist.".to_string()),
665-
),
666-
("content", Value::String(not_found_content.clone())),
667-
("isHome", Value::Bool(false)),
668-
("layout", Value::String("doc".to_string())),
669-
]),
670-
),
671-
("hero", Value::Null),
672-
("footer", footer_val),
673-
("prev", Value::Null),
674-
("next", Value::Null),
675-
]);
676-
not_found_state["headTags"] = Value::String(head_injection);
677-
not_found_state["label"] = Value::String("Copy".to_string());
678-
not_found_state["icon"] = Value::String("🌙".to_string());
696+
let mut not_found_state = build_not_found_state(NotFoundStateInput {
697+
config,
698+
base_path,
699+
nav: nav_val,
700+
footer: footer_val,
701+
content: &not_found_content,
702+
head_injection: &head_injection,
703+
global_state: render_states.global(),
704+
});
679705

680706
let not_found_html = template_html.replace("{{{page.content}}}", &not_found_content);
681707
let nf_tmp = std::env::temp_dir().join(format!(
@@ -1366,7 +1392,54 @@ fn fxhash_bytes(bytes: &[u8]) -> u64 {
13661392
mod tests {
13671393
use super::*;
13681394

1369-
type TestResult = std::result::Result<(), Box<dyn std::error::Error>>;
1395+
type TestResult<T = ()> = std::result::Result<T, Box<dyn std::error::Error>>;
1396+
1397+
fn test_obj<const N: usize>(entries: [(&str, Value); N]) -> Value {
1398+
let mut map = Map::with_capacity(N);
1399+
for (key, value) in entries {
1400+
map.insert(key.to_string(), value);
1401+
}
1402+
Value::Object(map)
1403+
}
1404+
1405+
fn string_value(value: &str) -> Value {
1406+
Value::String(value.to_string())
1407+
}
1408+
1409+
fn docs_config() -> TestResult<DocsConfig> {
1410+
let config = serde_json::from_str(
1411+
r#"{
1412+
"site": { "title": "Docs" },
1413+
"basePath": "/",
1414+
"contentDir": ".",
1415+
"nav": [],
1416+
"sidebar": []
1417+
}"#,
1418+
)?;
1419+
Ok(config)
1420+
}
1421+
1422+
#[test]
1423+
fn not_found_state_includes_global_state() -> TestResult {
1424+
let config = docs_config()?;
1425+
let global = test_obj([("release", test_obj([("version", string_value("v1.2.3"))]))]);
1426+
1427+
let state = build_not_found_state(NotFoundStateInput {
1428+
config: &config,
1429+
base_path: "/",
1430+
nav: Value::Array(vec![]),
1431+
footer: Value::Null,
1432+
content: "<h1>404</h1>",
1433+
head_injection: "<meta name=\"docs\">",
1434+
global_state: Some(&global),
1435+
});
1436+
1437+
assert_eq!(state["release"]["version"], "v1.2.3");
1438+
assert_eq!(state["page"]["title"], "Page Not Found");
1439+
assert_eq!(state["pageData"], Value::Null);
1440+
assert_eq!(state["headTags"], "<meta name=\"docs\">");
1441+
Ok(())
1442+
}
13701443

13711444
// --- truncate_utf8 ---------------------------------------------------
13721445

crates/webui-press/src/bundler.rs

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1787,13 +1787,12 @@ mod tests {
17871787
}
17881788
fs::create_dir_all(root.join(".webui-press"))?;
17891789
let allowed_roots = allowed_script_roots(&root.join(".webui-press"), &root)?;
1790+
let absolute_import = root.join("secret.js").to_string_lossy().replace('\\', "/");
1791+
let code = format!("console.log('before'); import \"{absolute_import}\";");
17901792

1791-
let Err(err) = validate_inline_script_imports(
1792-
"console.log('before'); import \"/tmp/secret.js\";",
1793-
&root.join(".webui-press"),
1794-
&allowed_roots,
1795-
None,
1796-
) else {
1793+
let Err(err) =
1794+
validate_inline_script_imports(&code, &root.join(".webui-press"), &allowed_roots, None)
1795+
else {
17971796
panic!("absolute import should be rejected");
17981797
};
17991798

0 commit comments

Comments
 (0)