Skip to main content

lumen_modules/
addon.rs

1//! A module's web half: the `web/` directory of a `[dependencies]` entry,
2//! holding a `lumen-addon.toml`.
3//!
4//! Every dependency is a module, and a module has up to two halves. Its
5//! desktop half is the library the loader opens (or finds compiled in). Its
6//! web half is data, not a library: a JavaScript module a page loads beside
7//! the runtime, with optional stylesheets, a script that runs before the page
8//! paints, and files the module reads, all described by the descriptor at the
9//! root of `web/`:
10//!
11//! ```toml
12//! [addon]
13//! namespace = "echo"
14//! module = "echo.js"
15//! styles = ["echo.css"]
16//!
17//! [[function]]
18//! name = "shout"
19//! params = ["text: string"]
20//! returns = "string"
21//!
22//! [[function]]
23//! name = "later"
24//! params = ["text: string", "ms: int"]
25//! async = "on_echo"
26//!
27//! [[element]]
28//! tag = "echo-view"
29//! html = "div"
30//! ```
31//!
32//! A web build takes the web half of every dependency and refuses one that
33//! has none; every other build loads the library and never reads `web/`. The
34//! directory is found the same way for every source: under the module's root,
35//! which is the directory a `path` source names, the package a `version`
36//! source unpacked to, or the toolchain's copy of a `bundled` module.
37//!
38//! Reading one is a compile's job: the descriptor becomes the
39//! [`lumen_ir::addon::Addon`] the compiled app carries, and the files become
40//! the site's. No engine opens anything here.
41
42use std::path::{Component, Path, PathBuf};
43
44use lumen_ir::addon::{Addon, AddonElement, AddonFunction, AddonParam};
45use serde::Deserialize;
46
47/// The directory a module keeps its web half in, under its root.
48pub const WEB_DIR: &str = "web";
49
50/// The descriptor's file name, at the root of a module's web half.
51pub const ADDON_MANIFEST: &str = "lumen-addon.toml";
52
53/// Module exports an add-on cannot name a function after, because the page
54/// reads them as the module's own hooks.
55pub const RESERVED_EXPORTS: &[&str] = &["install", "elements", "default"];
56
57/// Script namespaces an add-on cannot take: the runtime's own, the embedder's,
58/// and the one candela keeps for its standard library.
59pub const RESERVED_NAMESPACES: &[&str] = &["lumen", "native", "fs"];
60
61/// An add-on read off disk: what the compiled app carries, and the files the
62/// site ships.
63#[derive(Debug, Clone, PartialEq)]
64pub struct AddonPackage {
65    /// The functions and elements, as the compiled app carries them.
66    pub addon: Addon,
67    /// The package root.
68    pub dir: PathBuf,
69    /// The JavaScript module, relative to [`Self::dir`].
70    pub module: String,
71    /// Stylesheets the pages link, relative to [`Self::dir`], in order.
72    pub styles: Vec<String>,
73    /// A classic script the pages run before they paint, relative to
74    /// [`Self::dir`].
75    pub head: Option<String>,
76    /// Other files the module reads at run time, relative to [`Self::dir`]. A
77    /// directory stands for everything under it.
78    pub files: Vec<String>,
79    /// The `config` table the app's dependency entry gave it, which a page
80    /// hands the module at install. Empty until the dependency is known:
81    /// [`read_web_half`] reads the module alone.
82    pub config: toml::Table,
83}
84
85/// The web half of the module rooted at `root`: its `web/` directory, when
86/// that holds a descriptor.
87pub fn web_half(root: &Path) -> Option<PathBuf> {
88    let dir = root.join(WEB_DIR);
89    dir.join(ADDON_MANIFEST).is_file().then_some(dir)
90}
91
92/// Read the web half of the module rooted at `root`, declared in
93/// `[dependencies]` as `name`.
94///
95/// # Errors
96///
97/// The descriptor is missing or does not parse, names a file the web half
98/// does not hold or one outside it, or declares a function, a namespace or an
99/// element this format refuses. Every message names the module.
100pub fn read_web_half(name: &str, root: &Path) -> Result<AddonPackage, String> {
101    let dir = root.join(WEB_DIR);
102    let path = dir.join(ADDON_MANIFEST);
103    let text = std::fs::read_to_string(&path)
104        .map_err(|e| format!("the web half of '{name}': read {}: {e}", path.display()))?;
105    parse_addon(name, &dir, &text)
106}
107
108/// The script surface and the elements a descriptor's `text` declares, for
109/// the module `name`, without looking at any file it names.
110///
111/// This is how a module whose desktop half has nothing to offer but the
112/// answer "runs only in a browser" takes its functions from its own web half,
113/// read at the module's compile time, so the two halves cannot drift.
114///
115/// # Errors
116///
117/// As [`read_web_half`], less the checks on files.
118pub fn describe(name: &str, text: &str) -> Result<Addon, String> {
119    parse_descriptor(name, text).map(|(_, addon)| addon)
120}
121
122/// The descriptor as it is written.
123#[derive(Deserialize)]
124#[serde(deny_unknown_fields)]
125struct Descriptor {
126    addon: Header,
127    #[serde(default)]
128    function: Vec<FunctionDecl>,
129    #[serde(default)]
130    element: Vec<ElementDecl>,
131}
132
133#[derive(Deserialize)]
134#[serde(deny_unknown_fields)]
135struct Header {
136    namespace: String,
137    module: String,
138    #[serde(default)]
139    styles: Vec<String>,
140    #[serde(default)]
141    head: Option<String>,
142    #[serde(default)]
143    files: Vec<String>,
144}
145
146#[derive(Deserialize)]
147#[serde(deny_unknown_fields)]
148struct FunctionDecl {
149    name: String,
150    #[serde(default)]
151    params: Vec<String>,
152    #[serde(default)]
153    returns: Option<String>,
154    #[serde(default, rename = "async")]
155    event: Option<String>,
156    #[serde(default)]
157    doc: String,
158}
159
160#[derive(Deserialize)]
161#[serde(deny_unknown_fields)]
162struct ElementDecl {
163    tag: String,
164    html: String,
165    #[serde(default)]
166    void: bool,
167}
168
169/// Parse a descriptor's text, checking the files it names against `dir`.
170fn parse_addon(name: &str, dir: &Path, text: &str) -> Result<AddonPackage, String> {
171    let fail = |message: String| format!("the web half of '{name}': {message}");
172    let (header, addon) = parse_descriptor(name, text)?;
173    let file = |path: &str, what: &str| -> Result<String, String> {
174        package_file(dir, path).map_err(|why| fail(format!("{what} `{path}` {why}")))
175    };
176    let module = file(&header.module, "module")?;
177    if !(module.ends_with(".js") || module.ends_with(".mjs")) {
178        return Err(fail(format!(
179            "module `{module}` must be a JavaScript module (.js or .mjs)"
180        )));
181    }
182    let styles = header
183        .styles
184        .iter()
185        .map(|s| file(s, "stylesheet"))
186        .collect::<Result<Vec<_>, _>>()?;
187    let head = header
188        .head
189        .as_deref()
190        .map(|h| file(h, "head script"))
191        .transpose()?;
192    let files = header
193        .files
194        .iter()
195        .map(|f| file(f, "file"))
196        .collect::<Result<Vec<_>, _>>()?;
197
198    Ok(AddonPackage {
199        addon,
200        dir: dir.to_path_buf(),
201        module,
202        styles,
203        head,
204        files,
205        config: toml::Table::new(),
206    })
207}
208
209/// Parse a descriptor's text into its header and the surface it declares.
210fn parse_descriptor(name: &str, text: &str) -> Result<(Header, Addon), String> {
211    let fail = |message: String| format!("the web half of '{name}': {message}");
212    let descriptor: Descriptor =
213        toml::from_str(text).map_err(|e| fail(format!("{ADDON_MANIFEST}: {e}")))?;
214    let header = descriptor.addon;
215
216    if !is_identifier(&header.namespace) {
217        return Err(fail(format!(
218            "namespace `{}` must be lowercase letters, digits and underscores, starting with a \
219             letter",
220            header.namespace
221        )));
222    }
223    if RESERVED_NAMESPACES.contains(&header.namespace.as_str()) {
224        return Err(fail(format!(
225            "namespace `{}` is taken by the runtime; pick another",
226            header.namespace
227        )));
228    }
229
230    let mut functions: Vec<AddonFunction> = Vec::with_capacity(descriptor.function.len());
231    for decl in descriptor.function {
232        let qualified = format!("{}::{}", header.namespace, decl.name);
233        if !is_identifier(&decl.name) {
234            return Err(fail(format!(
235                "function `{}` must be lowercase letters, digits and underscores, starting with \
236                 a letter",
237                decl.name
238            )));
239        }
240        if RESERVED_EXPORTS.contains(&decl.name.as_str()) {
241            return Err(fail(format!(
242                "function `{}` shares its name with a module hook; rename it",
243                decl.name
244            )));
245        }
246        if functions.iter().any(|f| f.name == decl.name) {
247            return Err(fail(format!("function `{}` is declared twice", decl.name)));
248        }
249        let mut params = Vec::with_capacity(decl.params.len());
250        for spelling in &decl.params {
251            let Some((param, ty)) = spelling.split_once(':') else {
252                return Err(fail(format!(
253                    "{qualified}: parameter `{spelling}` must be written `name: type`"
254                )));
255            };
256            let (param, ty) = (param.trim(), ty.trim());
257            if !is_identifier(param) || ty.is_empty() {
258                return Err(fail(format!(
259                    "{qualified}: parameter `{spelling}` must be written `name: type`"
260                )));
261            }
262            params.push(AddonParam {
263                name: param.to_owned(),
264                ty: ty.to_owned(),
265            });
266        }
267        if let Some(event) = &decl.event {
268            if decl.returns.is_some() {
269                return Err(fail(format!(
270                    "{qualified} is async, so its result arrives as `{event}` and it returns \
271                     nothing; drop `returns`"
272                )));
273            }
274            if !is_identifier(event) {
275                return Err(fail(format!(
276                    "{qualified}: event `{event}` must be lowercase letters, digits and \
277                     underscores, starting with a letter"
278                )));
279            }
280        }
281        functions.push(AddonFunction {
282            name: decl.name,
283            params,
284            returns: decl.returns.unwrap_or_else(|| "null".to_owned()),
285            event: decl.event,
286            doc: decl.doc,
287        });
288    }
289
290    let mut elements: Vec<AddonElement> = Vec::with_capacity(descriptor.element.len());
291    for decl in descriptor.element {
292        crate::validate_tag(name, &decl.tag)?;
293        if elements.iter().any(|e| e.tag == decl.tag) {
294            return Err(fail(format!("element `{}` is declared twice", decl.tag)));
295        }
296        if !is_element_name(&decl.html) {
297            return Err(fail(format!(
298                "element `{}`: `html = \"{}\"` must be an HTML element name",
299                decl.tag, decl.html
300            )));
301        }
302        elements.push(AddonElement {
303            tag: decl.tag,
304            html: decl.html,
305            void: decl.void,
306        });
307    }
308
309    let addon = Addon {
310        name: name.to_owned(),
311        namespace: header.namespace.clone(),
312        functions,
313        elements,
314    };
315    Ok((header, addon))
316}
317
318/// A path the package holds, relative to its root and inside it, written with
319/// `/` whatever the platform.
320fn package_file(dir: &Path, path: &str) -> Result<String, &'static str> {
321    let relative = Path::new(path);
322    if path.is_empty()
323        || relative.is_absolute()
324        || relative
325            .components()
326            .any(|c| !matches!(c, Component::Normal(_) | Component::CurDir))
327    {
328        return Err("must be a path inside the package");
329    }
330    if !dir.join(relative).exists() {
331        return Err("is not in the package");
332    }
333    Ok(relative
334        .components()
335        .filter_map(|c| match c {
336            Component::Normal(part) => Some(part.to_string_lossy().into_owned()),
337            _ => None,
338        })
339        .collect::<Vec<_>>()
340        .join("/"))
341}
342
343/// A name a script can write: lowercase ASCII letters, digits and
344/// underscores, starting with a letter.
345fn is_identifier(name: &str) -> bool {
346    let mut chars = name.chars();
347    chars.next().is_some_and(|c| c.is_ascii_lowercase())
348        && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
349}
350
351/// A name HTML writes an element with: lowercase ASCII letters, digits and
352/// dashes, starting with a letter.
353fn is_element_name(name: &str) -> bool {
354    let mut chars = name.chars();
355    chars.next().is_some_and(|c| c.is_ascii_lowercase())
356        && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362
363    /// A package directory holding `files`, for one test.
364    fn package(test: &str, files: &[&str]) -> PathBuf {
365        let dir =
366            std::env::temp_dir().join(format!("lumen-modules-addon-{}-{test}", std::process::id()));
367        let _ = std::fs::remove_dir_all(&dir);
368        for file in files {
369            let path = dir.join(file);
370            std::fs::create_dir_all(path.parent().expect("a parent")).expect("mkdir");
371            std::fs::write(&path, "").expect("write");
372        }
373        dir
374    }
375
376    const ECHO: &str = r#"
377[addon]
378namespace = "echo"
379module = "echo.js"
380styles = ["echo.css"]
381head = "early.js"
382files = ["assets"]
383
384[[function]]
385name = "shout"
386params = ["text: string"]
387returns = "string"
388doc = "Upper-case it."
389
390[[function]]
391name = "later"
392params = ["text: string", "ms : int"]
393async = "on_echo"
394
395[[element]]
396tag = "echo-view"
397html = "div"
398"#;
399
400    #[test]
401    fn a_descriptor_reads_into_the_addon_and_its_files() {
402        let dir = package(
403            "reads",
404            &["echo.js", "echo.css", "early.js", "assets/logo.svg"],
405        );
406        let web = dir.join(WEB_DIR);
407        std::fs::create_dir_all(&web).unwrap();
408        for file in ["echo.js", "echo.css", "early.js", "assets/logo.svg"] {
409            std::fs::create_dir_all(web.join(file).parent().unwrap()).unwrap();
410            std::fs::rename(dir.join(file), web.join(file)).unwrap();
411        }
412        std::fs::write(web.join(ADDON_MANIFEST), ECHO).unwrap();
413        assert_eq!(web_half(&dir), Some(web.clone()));
414        let package = read_web_half("echo", &dir).unwrap();
415        assert_eq!(package.dir, web);
416        assert_eq!(package.module, "echo.js");
417        assert_eq!(package.styles, ["echo.css"]);
418        assert_eq!(package.head.as_deref(), Some("early.js"));
419        assert_eq!(package.files, ["assets"]);
420        let addon = package.addon;
421        assert_eq!(addon.name, "echo");
422        assert_eq!(addon.namespace, "echo");
423        assert_eq!(addon.functions[0].returns, "string");
424        assert_eq!(addon.functions[0].event, None);
425        assert_eq!(addon.functions[1].returns, "null");
426        assert_eq!(addon.functions[1].event.as_deref(), Some("on_echo"));
427        assert_eq!(addon.functions[1].params[1].name, "ms");
428        assert_eq!(addon.functions[1].params[1].ty, "int");
429        assert_eq!(addon.elements[0].html, "div");
430        assert!(!addon.elements[0].void);
431    }
432
433    fn refusal(test: &str, text: &str) -> String {
434        let dir = package(
435            test,
436            &["echo.js", "echo.css", "early.js", "assets/logo.svg"],
437        );
438        parse_addon("echo", &dir, text).unwrap_err()
439    }
440
441    #[test]
442    fn a_file_outside_the_package_is_refused() {
443        let err = refusal(
444            "outside",
445            "[addon]\nnamespace = \"echo\"\nmodule = \"../echo.js\"\n",
446        );
447        assert!(err.contains("must be a path inside the package"), "{err}");
448        let err = refusal(
449            "missing",
450            "[addon]\nnamespace = \"echo\"\nmodule = \"gone.js\"\n",
451        );
452        assert!(err.contains("is not in the package"), "{err}");
453    }
454
455    #[test]
456    fn a_function_named_after_a_hook_is_refused() {
457        let err = refusal(
458            "hook",
459            "[addon]\nnamespace = \"echo\"\nmodule = \"echo.js\"\n[[function]]\nname = \"install\"\n",
460        );
461        assert!(err.contains("module hook"), "{err}");
462    }
463
464    #[test]
465    fn the_runtimes_namespaces_are_refused() {
466        for ns in RESERVED_NAMESPACES {
467            let err = refusal(
468                &format!("ns-{ns}"),
469                &format!("[addon]\nnamespace = \"{ns}\"\nmodule = \"echo.js\"\n"),
470            );
471            assert!(err.contains("taken by the runtime"), "{err}");
472        }
473    }
474
475    #[test]
476    fn an_async_function_returns_nothing() {
477        let err = refusal(
478            "async-returns",
479            "[addon]\nnamespace = \"echo\"\nmodule = \"echo.js\"\n[[function]]\nname = \
480             \"later\"\nreturns = \"int\"\nasync = \"on_echo\"\n",
481        );
482        assert!(err.contains("drop `returns`"), "{err}");
483    }
484
485    #[test]
486    fn a_parameter_is_written_name_colon_type() {
487        let err = refusal(
488            "param",
489            "[addon]\nnamespace = \"echo\"\nmodule = \"echo.js\"\n[[function]]\nname = \
490             \"shout\"\nparams = [\"string\"]\n",
491        );
492        assert!(err.contains("must be written `name: type`"), "{err}");
493    }
494
495    #[test]
496    fn a_built_in_tag_cannot_be_an_addon_element() {
497        let err = refusal(
498            "tag",
499            "[addon]\nnamespace = \"echo\"\nmodule = \"echo.js\"\n[[element]]\ntag = \
500             \"button\"\nhtml = \"div\"\n",
501        );
502        assert!(err.contains("built-in tag"), "{err}");
503    }
504
505    #[test]
506    fn a_module_without_a_web_directory_has_no_web_half() {
507        let dir = package("no-web", &["echo.js", "lumen-addon.toml"]);
508        // A descriptor at the module's root is not its web half.
509        assert_eq!(web_half(&dir), None);
510        assert_eq!(web_half(&dir.join("nowhere")), None);
511        let err = read_web_half("echo", &dir).unwrap_err();
512        assert!(err.contains("the web half of 'echo': read"), "{err}");
513    }
514
515    #[test]
516    fn a_descriptor_describes_its_surface_without_its_files() {
517        // No file on disk: the surface alone is read.
518        let addon = describe("echo", ECHO).unwrap();
519        assert_eq!(addon.name, "echo");
520        assert_eq!(addon.namespace, "echo");
521        let names: Vec<&str> = addon.functions.iter().map(|f| f.name.as_str()).collect();
522        assert_eq!(names, ["shout", "later"]);
523        assert_eq!(addon.elements[0].tag, "echo-view");
524        let err = describe(
525            "echo",
526            "[addon]\nnamespace = \"lumen\"\nmodule = \"x.js\"\n",
527        )
528        .unwrap_err();
529        assert!(err.contains("taken by the runtime"), "{err}");
530    }
531
532    #[test]
533    fn an_unknown_key_is_refused() {
534        let err = refusal(
535            "unknown",
536            "[addon]\nnamespace = \"echo\"\nmodule = \"echo.js\"\nversion = \"1\"\n",
537        );
538        assert!(err.contains("unknown field `version`"), "{err}");
539    }
540}