Skip to main content

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}