lumen_runtime/pages.rs
1//! File-based pages - multi-`.lmn` discovery, registration, and the
2//! runtime navigation resolver.
3//!
4//! ## The model (Next.js / SvelteKit file-based routing, real-HTML `<a>`)
5//!
6//! Every `.lmn` file in the app's `src/` directory is a page, keyed by its
7//! filename stem, except `layout.lmn`, which is the shared layout rather than
8//! a page. The home page is `index.lmn` (falling back to the `[app] entry`
9//! stem, then `main.lmn` for single-file compat). All pages load up front,
10//! and the fragments declared in ANY of the app's files are merged into one
11//! table every page parses against, so a shared `layout.lmn` template (with
12//! a `<slot>`) is usable from every page.
13//!
14//! Rendering reuses the existing `<if>` reconciler with ZERO new machinery:
15//! the assembled tree is the entry `<root>` holding one synthetic
16//! `<if signal="route.path" eq="<page-key>">` gate per page, wrapping that
17//! page's content. Navigation is a reserved-signal write (`route.path`); the
18//! reconciler mounts the matching gate and unmounts the rest. This keeps
19//! navigation reactive-only (no per-frame tick) and candela-neutral (the
20//! navigation command rides the shared bus in [`lumen_core::nav`], not a
21//! Rhai builtin).
22//!
23//! ## Page resolution
24//!
25//! The framework does not pattern-match `:id` segments. A requested path is
26//! resolved by longest existing `.lmn` prefix ([`lumen_core::nav::resolve_path`]):
27//! `/settings` -> `settings.lmn`; `/user/7` (no `user/7.lmn`) -> `user.lmn`
28//! with `/7` exposed on the reserved `route.segment` signal for the page's
29//! own code to parse.
30//!
31//! ## Host seams
32//!
33//! - **Web transpile:** [`RouteHistory`] is the desktop back/forward stack.
34//! The web target binds the same navigation surface to the browser's own
35//! `history.pushState` / `popstate` API and `<a href>` to a real DOM anchor
36//! + URL, in `lumen-web-dom`'s `navigation` module. The reserved-signal
37//! surface is unchanged; only the history backend swaps.
38//! - **AOT packaging:** `lumenc build` bakes ONE `LayoutIR`, and the assembled
39//! multi-page IR (this module's output) already is one `LayoutIR`, so the
40//! AOT path runs [`assemble`] at build time and serialises the combined IR.
41//! The page keys travel on the artifact as `CompiledPages`, and a
42//! [`PageRegistry`] is rebuilt from them at load.
43
44use bevy_ecs::prelude::*;
45use lumen_core::nav;
46use std::path::{Path, PathBuf};
47
48use crate::config::LumenToml;
49#[cfg(feature = "runtime-parse")]
50use lumen_ir::fragment::FragmentTable;
51use lumen_ir::layout_ir::{Attributes, Element, IfModeSpec, LayoutIR};
52
53// The resolver, the history and the anchor are host-neutral and live in
54// `lumen-scene` beside the reconciler they drive. Discovery is what keeps
55// this module here: it reads a directory.
56pub use lumen_scene::routing::{
57 Anchor, Location, PageRegistry, RouteHistory, apply_navigation, install_routing,
58 navigate_on_anchor_click,
59};
60
61/// One discovered page file.
62#[derive(Clone, Debug)]
63pub struct PageFile {
64 /// Page key - the filename stem (`settings` for `settings.lmn`).
65 pub key: String,
66 /// Absolute (or app-relative) path to the `.lmn` file.
67 pub path: PathBuf,
68}
69
70/// The static plan produced by [`discover`], read by the loader and the
71/// runtime. Mirrors what `lumen.toml [pages]` describes when explicit.
72#[derive(Clone, Debug, Resource)]
73pub struct PagePlan {
74 /// `true` when more than one page participates (or `[pages] enabled`
75 /// forces it). `false` keeps the legacy single-file load path untouched.
76 pub multipage: bool,
77 /// Home page key.
78 pub entry_key: String,
79 /// The `.lmn` file handed to the loader as the entry (`html_path`).
80 pub entry_file: PathBuf,
81 /// All navigable pages, entry first. Excludes the shared `layout.lmn`.
82 pub pages: Vec<PageFile>,
83 /// Every `.lmn` file that contributes fragments to the app-wide table:
84 /// all pages plus the shared `layout.lmn`. Read by
85 /// [`collect_fragments`]; wider than [`Self::pages`] so `layout.lmn`
86 /// (which is not itself a page) still shares its fragments app-wide.
87 pub fragment_files: Vec<PathBuf>,
88 /// Directory the page files live in: the app's `src/`.
89 pub dir: PathBuf,
90}
91
92impl PagePlan {
93 /// Page keys, longest-first (so the longest existing prefix wins in
94 /// resolution).
95 pub fn keys(&self) -> Vec<String> {
96 let mut keys: Vec<String> = self.pages.iter().map(|p| p.key.clone()).collect();
97 keys.sort_by(|a, b| b.len().cmp(&a.len()).then_with(|| a.cmp(b)));
98 keys
99 }
100}
101
102// -- discovery ---------------------------------------------------------------
103
104/// Discover the pages in `src_dir` (the app's `src/`, from
105/// [`crate::app_layout::AppLayout`]), honouring `[pages]` / `[app]` config.
106///
107/// Single-file apps (a lone `main.lmn` or `index.lmn`, and no `[pages]
108/// enabled = true`) come back with `multipage = false` and the existing
109/// single-file load path runs unchanged - the compat guarantee.
110pub fn discover(src_dir: &Path, cfg: &LumenToml) -> PagePlan {
111 // 1. Scan the directory once for every `.lmn` file. This set feeds the
112 // app-wide fragment table (so a shared `layout.lmn` is usable from
113 // every page) and, absent an explicit `[pages] include`, the navigable
114 // page set.
115 let mut all_lmn: Vec<PageFile> = Vec::new();
116 if let Ok(rd) = std::fs::read_dir(src_dir) {
117 for entry in rd.flatten() {
118 let path = entry.path();
119 if path.extension().and_then(|e| e.to_str()) == Some("lmn") {
120 all_lmn.push(PageFile {
121 key: stem(&path.file_name().unwrap().to_string_lossy()),
122 path,
123 });
124 }
125 }
126 }
127 all_lmn.sort_by(|a, b| a.key.cmp(&b.key));
128
129 // 2. Navigable pages. `layout.lmn` is the shared layout, not a page: it
130 // contributes its fragments to the table but never gets its own
131 // `<if>` gate or a navigable key. An explicit `[pages] include` lists
132 // the navigable set verbatim; otherwise every non-layout `.lmn` is a
133 // page.
134 // An include entry naming a file that is not there yet is skipped so
135 // the block can be written ahead of the pages it declares - but only
136 // while a named page survives. When every entry is missing the app has
137 // no pages at all, so the unfiltered list stands and the load fails
138 // naming a file the author wrote rather than the `main.lmn` fallback.
139 let mut files: Vec<PageFile> = if let Some(list) = &cfg.pages.include {
140 let (present, missing): (Vec<PageFile>, Vec<PageFile>) = list
141 .iter()
142 .map(|name| PageFile {
143 key: stem(name),
144 path: src_dir.join(name),
145 })
146 .partition(|f| f.path.is_file());
147 if present.is_empty() {
148 missing
149 } else {
150 for f in &missing {
151 eprintln!(
152 "lumenc: [pages] include: no file at {}; skipping it (that page is not navigable)",
153 f.path.display()
154 );
155 }
156 present
157 }
158 } else {
159 all_lmn
160 .iter()
161 .filter(|f| f.key != LAYOUT_STEM)
162 .cloned()
163 .collect()
164 };
165
166 // 3. Pick the entry key.
167 let entry_stem = cfg.app.entry.as_deref().map(stem);
168 let entry_key = cfg
169 .pages
170 .entry
171 .clone()
172 .filter(|k| files.iter().any(|f| &f.key == k))
173 .or_else(|| {
174 files
175 .iter()
176 .find(|f| f.key == "index")
177 .map(|f| f.key.clone())
178 })
179 .or_else(|| {
180 entry_stem
181 .as_ref()
182 .filter(|s| files.iter().any(|f| &f.key == *s))
183 .cloned()
184 })
185 .or_else(|| {
186 files
187 .iter()
188 .find(|f| f.key == "main")
189 .map(|f| f.key.clone())
190 })
191 .or_else(|| files.first().map(|f| f.key.clone()))
192 .unwrap_or_else(|| "main".to_string());
193
194 // 4. Order pages entry-first.
195 files.sort_by_key(|f| f.key != entry_key);
196
197 let entry_file = files
198 .iter()
199 .find(|f| f.key == entry_key)
200 .map(|f| f.path.clone())
201 // Nothing discovered (empty dir / in-memory source): keep the legacy
202 // default so the caller still resolves `main.lmn`.
203 .unwrap_or_else(|| src_dir.join(cfg.app.entry.as_deref().unwrap_or("main.lmn")));
204
205 // Every `.lmn` in the dir contributes fragments (pages plus `layout.lmn`).
206 let fragment_files: Vec<PathBuf> = all_lmn.iter().map(|f| f.path.clone()).collect();
207
208 // The multi-page pipeline runs whenever more than one page participates.
209 // `files` already reflects an explicit `[pages] include`, so an include
210 // naming more than one page that exists (even ones living in a subfolder
211 // the directory scan never sees) flips the default on its own; a
212 // single-entry include does not, and neither does an entry whose file is
213 // not written yet - that page is not navigable, so it does not count.
214 // A directory scan finding more than one `.lmn` file also flips it: a
215 // lone `index.lmn` beside a shared `layout.lmn` counts, so the page still
216 // picks up the shared fragments; a single-file app does not and takes the
217 // untouched legacy path. An explicit `[pages] enabled` always wins over
218 // either count.
219 let multipage = cfg
220 .pages
221 .enabled
222 .unwrap_or(files.len() > 1 || all_lmn.len() > 1);
223
224 PagePlan {
225 multipage,
226 entry_key,
227 entry_file,
228 pages: files,
229 fragment_files,
230 dir: src_dir.to_path_buf(),
231 }
232}
233
234/// Reserved filename stem for the shared layout. `layout.lmn` contributes its
235/// fragments to every page but is not itself a navigable page.
236const LAYOUT_STEM: &str = "layout";
237
238fn stem(name: &str) -> String {
239 Path::new(name)
240 .file_stem()
241 .map(|s| s.to_string_lossy().into_owned())
242 .unwrap_or_else(|| name.to_string())
243}
244
245// -- IR assembly (graft pages under `<if>` gates) ----------------------------
246
247/// Give a page's gate one of its styles as markup would, so a restyle
248/// rebuilds it; see [`lumen_ir::css::apply_markup_declaration`].
249#[cfg(feature = "runtime-parse")]
250fn built_style(attrs: &mut Attributes, property: &str, value: &str) {
251 if let Err(e) = lumen_ir::css::apply_markup_declaration(property, value, attrs) {
252 unreachable!("a page gate's own style does not apply: {property}: {value}: {e}");
253 }
254}
255
256/// Graft every page in `plan` into `ir` (parsed from the entry file) as
257/// sibling `<if signal="route.path" eq="<key>">` gates, parsing each page
258/// against the app-wide `fragments` table and merging every page's scripts.
259/// Returns the extra page-file paths to add to the hot-reload watch set (all
260/// non-entry pages).
261///
262/// Must run BEFORE asset-path resolution / the CSS cascade so the assembled
263/// tree flows through the existing pipeline uniformly.
264#[cfg(feature = "runtime-parse")]
265pub fn assemble(
266 ir: &mut LayoutIR,
267 plan: &PagePlan,
268 fragments: &FragmentTable,
269 parser: &dyn crate::source_parser::SourceParser,
270 entry_markup: &str,
271) -> Result<Vec<PathBuf>, String> {
272 let mut gates: Vec<Element> = Vec::new();
273 let mut script_source = String::new();
274 let mut script_map = lumen_ir::source_map::SourceMap::default();
275 let mut external_scripts: Vec<String> = Vec::new();
276 let mut watch: Vec<PathBuf> = Vec::new();
277
278 for page in &plan.pages {
279 // The entry page parses from the text the loader already carries -
280 // compiler plugins may have rewritten it, and a disk re-read here
281 // would silently discard their markup transform for multi-page apps.
282 let raw = if page.path == plan.entry_file {
283 entry_markup.to_string()
284 } else {
285 std::fs::read_to_string(&page.path)
286 .map_err(|e| format!("read page {}: {e}", page.path.display()))?
287 };
288 let pir = parser
289 .parse_html_with_loader(&raw, &page.path, fragments)
290 .map_err(|e| format!("parse page {}: {e}", page.path.display()))?;
291
292 // A synthetic `<if>` gate keyed on the reserved active-page signal.
293 //
294 // The gate is the box the page mounts inside, so a page's own
295 // `<root>` attributes ride on it: `<root bg="..." padding="...">`
296 // in `settings.lmn` paints and insets the settings page. The entry
297 // is the exception: its `<root>` is itself the app's root element,
298 // spawned with those attributes already, so repeating them on the
299 // gate would paint and inset the home page twice. `skin` and
300 // `frameless` are document metadata rather than layout attributes
301 // (they live on the `LayoutIR`, not in `Attributes`), so a page
302 // cannot smuggle a window setting through here.
303 let is_entry = page.key == plan.entry_key;
304 let mut attrs = if is_entry {
305 Attributes::default()
306 } else {
307 pir.root.attrs.clone()
308 };
309 attrs.if_signal = Some(nav::PATH_SIGNAL.to_string());
310 attrs.if_eq = Some(page.key.clone());
311 attrs.if_mode = IfModeSpec::Render;
312 // A page lays out as its `<root>` would: in a column, which an
313 // `<if>` does not take from its tag the way a `<root>` does.
314 if !is_entry && attrs.flex.is_none() {
315 built_style(&mut attrs, "flex-direction", "column");
316 }
317 let gate = Element {
318 tag: "if".to_string(),
319 attrs,
320 // The placeholder catalog travels with the attributes it
321 // describes, or a `bg="{theme}"` on a page root resolves
322 // against nothing.
323 interpolations: if is_entry {
324 Vec::new()
325 } else {
326 pir.root.interpolations.clone()
327 },
328 children: pir.root.children.clone(),
329 ..Element::default()
330 };
331 gates.push(gate);
332
333 if !pir.script_source.trim().is_empty() {
334 script_map.append_mapped(&mut script_source, &pir.script_source, &pir.script_map);
335 }
336 for ext in pir.external_scripts {
337 if !external_scripts.contains(&ext) {
338 external_scripts.push(ext);
339 }
340 }
341 if page.key != plan.entry_key {
342 watch.push(page.path.clone());
343 }
344 }
345
346 // Watch every fragment-only file too (the shared `layout.lmn`), so editing
347 // the layout hot-reloads even though it is not a navigable page.
348 for tf in &plan.fragment_files {
349 if tf != &plan.entry_file && !plan.pages.iter().any(|p| &p.path == tf) {
350 watch.push(tf.clone());
351 }
352 }
353
354 // Replace the entry's own children/scripts with the assembled set. Root
355 // attrs (skin, class, window flags) stay as the entry parsed them.
356 ir.root.children = gates;
357 ir.script_source = script_source;
358 ir.script_map = script_map;
359 ir.external_scripts = external_scripts;
360
361 Ok(watch)
362}
363
364/// Merge the fragments every one of the app's `.lmn` files declares into the
365/// one table each page parses against. This is what makes a `<template>` in
366/// `layout.lmn` reachable from every page.
367///
368/// Two files declaring the same name with different bodies is an error: the
369/// table is app-wide, so either answer would silently change what half the
370/// use sites render.
371#[cfg(feature = "runtime-parse")]
372pub fn collect_fragments(
373 plan: &PagePlan,
374 parser: &dyn crate::source_parser::SourceParser,
375 entry_markup: Option<&str>,
376) -> Result<FragmentTable, String> {
377 let mut table = FragmentTable::new();
378 for path in &plan.fragment_files {
379 // Same substitution as `assemble`: the entry's declarations come
380 // from the (possibly plugin-transformed) text the loader holds, so
381 // a `<template>` a plugin emits into the entry is instantiable.
382 let src = match entry_markup {
383 Some(entry) if path == &plan.entry_file => entry.to_string(),
384 _ => std::fs::read_to_string(path)
385 .map_err(|e| format!("read {}: {e}", path.display()))?,
386 };
387 let declared = parser
388 .collect_fragments(&src, path)
389 .map_err(|e| format!("parse {}: {e}", path.display()))?;
390 table
391 .merge(declared)
392 .map_err(|e| format!("{}: {e}", path.display()))?;
393 }
394 Ok(table)
395}
396
397// -- runtime wiring ----------------------------------------------------------
398
399/// Install the page registry, in-memory history, the reserved-signal seeds,
400/// and the navigation systems onto a built app. Called once at boot when
401/// `plan.multipage` is set.
402///
403/// The [`PagePlan`] goes in as a resource alongside the routing itself,
404/// because a from-source run reloads pages from the files it names. A compiled
405/// app has no files to reload and installs through [`install_routing`].
406pub fn install(app: &mut lumen_core::app::App, plan: &PagePlan) {
407 install_routing(
408 app,
409 plan.entry_key.clone(),
410 plan.keys(),
411 Location::page(&plan.entry_key),
412 );
413 app.world.insert_resource(plan.clone());
414}
415
416#[cfg(test)]
417mod tests {
418 use super::*;
419 use crate::config::LumenToml;
420
421 /// Unique scratch dir per call, holding just `index.lmn`: the shared
422 /// fixture for the `discover` default-flip tests below.
423 fn temp_app_dir(tag: &str) -> PathBuf {
424 let dir = std::env::temp_dir().join(format!(
425 "lumen_pages_{tag}_{}_{}",
426 std::process::id(),
427 std::time::SystemTime::now()
428 .duration_since(std::time::UNIX_EPOCH)
429 .unwrap()
430 .as_nanos()
431 ));
432 std::fs::create_dir_all(&dir).unwrap();
433 std::fs::write(dir.join("index.lmn"), "<root></root>").unwrap();
434 dir
435 }
436
437 /// Write a page under `dir`, creating the subfolder an `include` entry
438 /// names along the way.
439 fn write_page(dir: &Path, name: &str) {
440 let path = dir.join(name);
441 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
442 std::fs::write(path, "<root></root>").unwrap();
443 }
444
445 #[test]
446 fn include_naming_several_subfolder_pages_enables_multipage() {
447 // The app dir itself holds only `index.lmn` (the directory scan sees
448 // one file), but `include` names two more pages in a subfolder the
449 // scan never looks into. That still states multi-page intent, so the
450 // default must flip on.
451 let dir = temp_app_dir("include_many");
452 write_page(&dir, "pages/settings.lmn");
453 write_page(&dir, "pages/about.lmn");
454 let mut cfg = LumenToml::default();
455 cfg.pages.include = Some(vec![
456 "index.lmn".into(),
457 "pages/settings.lmn".into(),
458 "pages/about.lmn".into(),
459 ]);
460
461 let plan = discover(&dir, &cfg);
462 assert!(plan.multipage, "a multi-page include must enable multipage");
463
464 let _ = std::fs::remove_dir_all(&dir);
465 }
466
467 #[test]
468 fn single_entry_include_stays_single_page() {
469 // An `include` naming exactly one page (even one living in a
470 // subfolder) states no more intent than the plain single-file case,
471 // so the default must stay off.
472 let dir = temp_app_dir("include_one");
473 write_page(&dir, "pages/settings.lmn");
474 let mut cfg = LumenToml::default();
475 cfg.pages.include = Some(vec!["pages/settings.lmn".into()]);
476
477 let plan = discover(&dir, &cfg);
478 assert!(
479 !plan.multipage,
480 "a single-entry include must not enable multipage"
481 );
482
483 let _ = std::fs::remove_dir_all(&dir);
484 }
485
486 #[test]
487 fn explicit_enabled_false_wins_over_a_multipage_include() {
488 // `[pages] enabled = false` is an explicit override and must win even
489 // when `include` alone would flip the default on.
490 let dir = temp_app_dir("include_disabled");
491 write_page(&dir, "pages/settings.lmn");
492 let mut cfg = LumenToml::default();
493 cfg.pages.enabled = Some(false);
494 cfg.pages.include = Some(vec!["index.lmn".into(), "pages/settings.lmn".into()]);
495
496 let plan = discover(&dir, &cfg);
497 assert!(
498 !plan.multipage,
499 "explicit enabled = false must override the include-derived default"
500 );
501
502 let _ = std::fs::remove_dir_all(&dir);
503 }
504
505 #[test]
506 fn include_entry_without_a_file_is_skipped() {
507 // A `[pages]` block written ahead of the pages it names: only
508 // `index.lmn` exists so far. The unwritten page is not navigable,
509 // so it leaves the page set and does not count toward the
510 // multi-page default.
511 let dir = temp_app_dir("include_missing");
512 let mut cfg = LumenToml::default();
513 cfg.pages.include = Some(vec!["index.lmn".into(), "pages/settings.lmn".into()]);
514
515 let plan = discover(&dir, &cfg);
516 let keys: Vec<&str> = plan.pages.iter().map(|p| p.key.as_str()).collect();
517 assert_eq!(keys, ["index"], "the unwritten page must be skipped");
518 assert!(
519 !plan.multipage,
520 "a one-page app until the other page is written"
521 );
522
523 let _ = std::fs::remove_dir_all(&dir);
524 }
525
526 #[test]
527 fn enabled_true_keeps_multipage_while_a_page_is_unwritten() {
528 // `enabled = true` is how the author says "keep multi-page while I
529 // write the rest", so the skip must not turn it back off.
530 let dir = temp_app_dir("include_missing_enabled");
531 let mut cfg = LumenToml::default();
532 cfg.pages.enabled = Some(true);
533 cfg.pages.include = Some(vec!["index.lmn".into(), "pages/settings.lmn".into()]);
534
535 let plan = discover(&dir, &cfg);
536 let keys: Vec<&str> = plan.pages.iter().map(|p| p.key.as_str()).collect();
537 assert_eq!(keys, ["index"], "the unwritten page must be skipped");
538 assert!(plan.multipage, "explicit enabled = true must still win");
539
540 let _ = std::fs::remove_dir_all(&dir);
541 }
542
543 #[test]
544 fn an_include_of_only_missing_files_is_left_alone() {
545 // Nothing the include names exists, so there is no page set to
546 // salvage. The list stands unfiltered and the loader still fails
547 // naming a file the author wrote.
548 let dir = temp_app_dir("include_all_missing");
549 std::fs::remove_file(dir.join("index.lmn")).unwrap();
550 let mut cfg = LumenToml::default();
551 cfg.pages.include = Some(vec!["index.lmn".into(), "pages/settings.lmn".into()]);
552
553 let plan = discover(&dir, &cfg);
554 let keys: Vec<&str> = plan.pages.iter().map(|p| p.key.as_str()).collect();
555 assert_eq!(keys, ["index", "settings"], "nothing is skipped");
556 assert_eq!(plan.entry_file, dir.join("index.lmn"));
557
558 let _ = std::fs::remove_dir_all(&dir);
559 }
560
561 #[test]
562 fn keys_are_longest_first() {
563 let plan = PagePlan {
564 multipage: true,
565 entry_key: "index".into(),
566 entry_file: PathBuf::new(),
567 pages: vec![
568 PageFile {
569 key: "index".into(),
570 path: PathBuf::new(),
571 },
572 PageFile {
573 key: "user".into(),
574 path: PathBuf::new(),
575 },
576 PageFile {
577 key: "settings".into(),
578 path: PathBuf::new(),
579 },
580 ],
581 fragment_files: Vec::new(),
582 dir: PathBuf::new(),
583 };
584 let keys = plan.keys();
585 assert_eq!(keys[0], "settings"); // longest first
586 }
587}