zebflow 0.11.14

Reactive web automation platform — pipelines, templates, and real-time collaboration in a single binary.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
//! `web.site.generate` — a static site in the project store.
//!
//! One kind, two modes, chosen by the role given:
//!
//! - `--path` renders one TSX page (`--template`) into the site (`page.rs`);
//! - `--from` builds a whole docs site from a Markdown folder under `docs/`
//!   (`docs.rs`), its page template scaffolded when missing.
//!
//! Both write under `--folder` with one manifest and one `_assets/`
//! (`../static_site.rs`), and answer one key, `site`.

pub mod docs;
pub mod docs_blocks;
pub mod docs_links;
pub mod docs_math;
mod docs_scaffold;
pub mod docs_search;
mod docs_text;
#[cfg(test)]
mod layout_tests;
pub mod page;

use serde::{Deserialize, Serialize};
use serde_json::json;

use crate::pipeline::PipelineError;
use crate::pipeline::model::{
    DslFlag, DslFlagKind, LayoutItem, NodeCapability, NodeDefinition, NodeExample, NodeFieldDataSource, NodeFieldDef,
    NodeFieldType,
};
use crate::pipeline::nodes::shared::project_store::{OnConflict, on_conflict_flag, store_fields, store_flag};

pub const NODE_KIND: &str = "web.site.generate";
pub const INPUT_PIN_IN: &str = "in";
pub const OUTPUT_PIN_OUT: &str = "out";

/// The site's folder in the store when `--folder` is not given.
pub const DEFAULT_PAGE_FOLDER: &str = "site";
pub const DEFAULT_DOCS_FOLDER: &str = "docs";
/// The docs page template, under the source root, when `--template` is not given.
pub const DEFAULT_DOCS_TEMPLATE: &str = "docs.template.tsx";
/// The folder metadata file a docs folder may hold.
pub const DOCS_META_FILE: &str = "_meta.yaml";
/// What a docs-built page records as its generator in the site manifest: the
/// kind that built it, as the template mode records. A kind is written as
/// itself; the retired `n.web.docs.generate` label is gone.
pub const DOCS_MANIFEST_GENERATOR: &str = NODE_KIND;

pub const CODE_CONFIG: &str = "FW_NODE_WEB_SITE_GENERATE_CONFIG";
pub const CODE_MODE: &str = "FW_NODE_WEB_SITE_GENERATE_MODE";
pub const CODE_CONFLICT: &str = "FW_NODE_WEB_SITE_GENERATE_CONFLICT";
/// A docs build refused because the Markdown links somewhere it does not write.
pub const CODE_LINKS: &str = "FW_NODE_WEB_SITE_GENERATE_LINKS";
/// A `--version` that is not a version label, or one that collides with a page.
pub const CODE_VERSION: &str = "FW_NODE_WEB_SITE_GENERATE_VERSION";

/// Typed configuration for `web.site.generate`.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Config {
    /// The page template under the source root, ending `.tsx`. Required for
    /// a page; for a docs site, the template scaffolded when missing
    /// (default [`DEFAULT_DOCS_TEMPLATE`]).
    #[serde(default)]
    pub template: Option<String>,
    /// Template markup a higher layer already loaded (page mode); never
    /// written by an author.
    #[serde(default)]
    pub markup: Option<String>,
    /// Page mode: the page's path inside the site folder, e.g.
    /// `posts/{{ input.slug }}.html`.
    #[serde(default)]
    pub path: Option<String>,
    /// Docs mode: the Markdown folder under `docs/`.
    #[serde(default)]
    pub from: Option<String>,
    /// The site's folder in the store (default `site` for a page, `docs` for
    /// a docs site); every page of one site shares it.
    #[serde(default)]
    pub folder: Option<String>,
    /// The store to write to; saved explicitly at registration.
    #[serde(default)]
    pub store: Option<String>,
    /// Page mode: `ctx.route` while rendering (default: the page's address).
    #[serde(default)]
    pub route: Option<String>,
    /// Docs mode: the site's name (default: the docs folder's `_meta.yaml`
    /// title, else its name).
    #[serde(default)]
    pub name: Option<String>,
    /// `overwrite` (default), `skip` or `error` when a page exists with other content.
    #[serde(default)]
    pub on_conflict: Option<String>,
    /// Docs mode: this build is one version of the site (`0.11`), written
    /// under `<folder>/<version>/` and listed in `<folder>/versions.json`.
    #[serde(default)]
    pub version: Option<String>,
    /// Docs mode, with `--version`: the latest version, written again at
    /// `<folder>/` — the unversioned address, the only one indexed.
    #[serde(default)]
    pub latest: bool,
}

/// Which site a configuration builds.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
    Page,
    Docs,
}

impl Mode {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Page => "page",
            Self::Docs => "docs",
        }
    }
}

fn given(value: &Option<String>) -> Option<&str> {
    value.as_deref().map(str::trim).filter(|s| !s.is_empty())
}

impl Config {
    /// The mode the roles name: `--path` a page, `--from` a docs site; both
    /// or neither is refused. Also refuses a flag the mode does not read.
    pub fn mode(&self) -> Result<Mode, PipelineError> {
        let mode = match (given(&self.path), given(&self.from)) {
            (Some(_), None) => Mode::Page,
            (None, Some(_)) => Mode::Docs,
            (Some(_), Some(_)) => {
                return Err(PipelineError::new(
                    CODE_MODE,
                    "web.site.generate takes --path (one page) or --from (a docs site), not both",
                ));
            }
            (None, None) => {
                return Err(PipelineError::new(
                    CODE_MODE,
                    "web.site.generate needs --path PAGE (one page) or --from DOCS_FOLDER (a docs site)",
                ));
            }
        };
        match mode {
            Mode::Page if given(&self.template).is_none() => Err(PipelineError::new(
                CODE_MODE,
                "a page (--path) needs --template PAGE.tsx",
            )),
            Mode::Page if given(&self.name).is_some() => Err(PipelineError::new(
                CODE_MODE,
                "--name names a docs site (--from); a page takes its title from its template",
            )),
            Mode::Docs if given(&self.route).is_some() => Err(PipelineError::new(
                CODE_MODE,
                "--route sets one page's route (--path); a docs site routes every page from its file",
            )),
            Mode::Page if given(&self.version).is_some() || self.latest => Err(PipelineError::new(
                CODE_MODE,
                "--version and --latest version a docs site (--from); a page has no versions",
            )),
            Mode::Docs if self.latest && given(&self.version).is_none() => Err(PipelineError::new(
                CODE_VERSION,
                "--latest marks a version as the latest: give it --version, e.g. --version 0.11 --latest",
            )),
            Mode::Docs => {
                self.version_label()?;
                Ok(mode)
            }
            _ => Ok(mode),
        }
    }

    /// The `--version` label, checked: a letter or digit, then letters,
    /// digits, `.`, `-` or `_`, at most 32 — one path segment of the site.
    pub fn version_label(&self) -> Result<Option<&str>, PipelineError> {
        let Some(label) = given(&self.version) else { return Ok(None) };
        let ok = label.len() <= 32
            && label.chars().next().is_some_and(|c| c.is_ascii_alphanumeric())
            && label.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'));
        if !ok {
            return Err(PipelineError::new(
                CODE_VERSION,
                format!("--version '{label}' is not a version label: a letter or digit, then letters, digits, '.', '-' or '_' (e.g. 0.11, 2.x, next)"),
            ));
        }
        Ok(Some(label))
    }

    /// `--on-conflict`, overwrite by default (`node-conventions.md` §8).
    pub fn on_conflict(&self) -> Result<OnConflict, PipelineError> {
        OnConflict::parse(self.on_conflict.as_deref(), OnConflict::Overwrite, CODE_CONFLICT)
    }

    /// Everything that can be refused before rendering.
    pub fn check(&self) -> Result<Mode, PipelineError> {
        self.on_conflict()?;
        self.mode()
    }

    /// The site's folder in the store, normalised.
    pub fn folder_rel(&self, mode: Mode) -> Result<String, PipelineError> {
        let default = match mode {
            Mode::Page => DEFAULT_PAGE_FOLDER,
            Mode::Docs => DEFAULT_DOCS_FOLDER,
        };
        super::static_site::normalize_site_root_rel_path(given(&self.folder).unwrap_or(default))
    }
}

fn flag(name: &str, key: &str, value: &str, description: &str) -> DslFlag {
    DslFlag {
        flag: name.to_string(),
        config_key: key.to_string(),
        description: description.to_string(),
        kind: DslFlagKind::Scalar,
        value: value.to_string(),
        ..Default::default()
    }
}

fn field(name: &str, label: &str, placeholder: &str, help: &str) -> NodeFieldDef {
    NodeFieldDef {
        name: name.to_string(),
        label: label.to_string(),
        field_type: NodeFieldType::Text,
        placeholder: Some(placeholder.to_string()).filter(|p| !p.is_empty()),
        help: Some(help.to_string()),
        ..Default::default()
    }
}

/// Kind-level contract for the pipeline editor, DSL, and MCP-facing node help.
pub fn definition() -> NodeDefinition {
    NodeDefinition {
        kind: NODE_KIND.to_string(),
        capabilities: vec![NodeCapability::Filesystem, NodeCapability::Process],
        title: "Web Site Generate".to_string(),
        description: "Write a static site into the project store. One page: `--template pages/post.tsx --path posts/{{ input.slug }}.html` \
            renders the TSX page with the payload as its `input` (`--route` sets `ctx.route`; default: the page's address). \
            A docs site: `--from handbook` turns the Markdown under `docs/handbook/` into pages, a sidebar, a search index and, \
            when the folder is served, a sitemap; `--template` is the page template, created from a scaffold when missing \
            (default `docs.template.tsx`); `--name` is the site's name; each folder's `_meta.yaml` sets its title, `order` and `nav` \
            (a page's front matter its `order`), and that one order drives the sidebar, prev/next and the search index; \
            a page whose Markdown is gone is removed from the site on the next build. \
            A link to another page is written as that page's address. The site answers at its folder's place under the served folder \
            (served `site`, docs written to `site/docs`: pages at `/docs/…`), at `/` until the folder is served. \
            `--version 0.11` makes the build one version: written under `<folder>/0.11/` with its own assets and search index, never indexed, \
            listed in `<folder>/versions.json`; with `--latest` it is also written at `<folder>/` — the unversioned address, the only one indexed, \
            with the sitemap. A version once built is never rebuilt by a newer one: the template's switcher reads versions.json when the page loads. \
            Both write under `--folder` (default `site` for a page, `docs` for a docs site), sharing one manifest and one `_assets/`; \
            a page whose content changed is overwritten unless `--on-conflict` says skip or error, decided before anything is written. \
            The page renders from the payload, which it also embeds for hydration — so it publishes what the payload holds — \
            except the request: the trigger's `headers`, `cookies` and `auth` are never part of it; copy a value into a key of its own upstream to show it. \
            Adds `site` and keeps the payload. The files stay private until the owner serves the folder in Studio → Files."
            .to_string(),
        input_schema: json!({ "type": "object" }),
        output_schema: json!({
            "type": "object",
            "properties": {
                "site": {
                    "type": "object",
                    "description": "Page mode: mode, status, path, route, store, file, origin, template, folder, manifest_path, asset_group, bytes. \
                        Docs mode: mode, status, name, template, from, folder, store, origin, manifest_path, asset_group, page_count, \
                        generated_files, skipped_files, sitemap_path, search_index_path, routes; with --version also version, latest, versions_path.",
                    "properties": {
                        "mode": { "type": "string", "enum": ["page", "docs"] },
                        "status": { "type": "string", "description": "page: written, unchanged or skipped; docs: ok" },
                        "path": { "type": "string" },
                        "route": { "type": "string" },
                        "routes": { "type": "array", "items": { "type": "string" } },
                        "store": { "type": ["string", "null"] },
                        "file": { "description": "A durable FileRef for the page" },
                        "origin": { "type": ["string", "null"], "description": "The site's serve origin from Studio → Files, if it has one" },
                        "template": { "type": "string" },
                        "from": { "type": "string" },
                        "name": { "type": "string" },
                        "folder": { "type": "string" },
                        "manifest_path": { "type": "string" },
                        "asset_group": { "type": "string" },
                        "bytes": { "type": "integer" },
                        "page_count": { "type": "integer" },
                        "generated_files": { "type": "integer" },
                        "skipped_files": { "type": "integer" },
                        "sitemap_path": { "type": ["string", "null"] },
                        "search_index_path": { "type": "string" },
                        "version": { "type": ["string", "null"], "description": "The --version label" },
                        "latest": { "type": "boolean" },
                        "versions_path": { "type": ["string", "null"], "description": "The versions list, <folder>/versions.json" }
                    }
                }
            }
        }),
        input_pins: vec![INPUT_PIN_IN.to_string()],
        output_pins: vec![OUTPUT_PIN_OUT.to_string()],
        script_available: false,
        script_bridge: None,
        config_schema: Default::default(),
        dsl_flags: vec![
            flag("--template", "template", "text", "The page template under the source root, ending .tsx, e.g. pages/post.tsx. Required for a page; for a docs site, created from a scaffold when missing (default: docs.template.tsx)."),
            flag("--path", "path", "text", "One page: its path inside the site folder, e.g. posts/{{ input.slug }}.html."),
            flag("--from", "from", "text", "A docs site: the Markdown folder under docs/, e.g. handbook."),
            flag("--route", "route", "text", "One page: ctx.route while rendering (default: the page's address on the site)."),
            flag("--name", "name", "text", "A docs site: its name (default: the folder's _meta.yaml title, else the folder name)."),
            DslFlag { value: "text".to_string(), ..store_flag() },
            flag("--folder", "folder", "text", "The site's folder in the store (default: site for a page, docs for a docs site). Every page of one site shares it, with one manifest and one _assets/."),
            flag("--version", "version", "text", "A docs site: this build is one version, e.g. 0.11 — written under <folder>/0.11/ and listed in <folder>/versions.json."),
            DslFlag { kind: DslFlagKind::Bool, ..flag("--latest", "latest", "", "A docs site, with --version: the latest version, written again at <folder>/ — the unversioned address, the only one indexed.") },
            DslFlag {
                choices: vec!["overwrite".to_string(), "skip".to_string(), "error".to_string()],
                ..on_conflict_flag(OnConflict::Overwrite)
            },
        ],
        fields: vec![
            NodeFieldDef {
                field_type: NodeFieldType::Datalist,
                data_source: Some(NodeFieldDataSource::TemplatesPages),
                ..field("template", "Template", "pages/post.tsx", "The page template, ending .tsx. Required for a page; a docs site's is created when missing.")
            },
            field("path", "Path", "posts/{{ input.slug }}.html", "One page: its path inside the site folder. Set Path or From."),
            field("from", "From", "handbook", "A docs site: the Markdown folder under docs/. Set Path or From."),
            field("route", "Route", "/posts/{{ input.slug }}", "One page: ctx.route while rendering (default: the page's address)."),
            field("name", "Name", "Handbook", "A docs site: its name."),
            field("folder", "Folder", "site", "The site's folder in the store (default: site for a page, docs for a docs site)."),
            field("version", "Version", "0.11", "A docs site: this build is one version, under <folder>/<version>/."),
            NodeFieldDef { field_type: NodeFieldType::Checkbox, default_value: Some(json!(false)), ..field("latest", "Latest", "", "A docs site, with Version: also written at the unversioned address.") },
        ]
        .into_iter()
        .chain(store_fields(OnConflict::Overwrite))
        .collect(),
        layout: ["template", "path", "from", "route", "name", "folder", "version", "latest", "store", "on_conflict"]
            .iter()
            .map(|name| LayoutItem::Field(name.to_string()))
            .collect(),
        ai_tool: Default::default(),
        examples: vec![
            NodeExample::dsl("Render one post to a static page", r#"web.site.generate --template pages/post.tsx --path "posts/{{ input.query.rows[0].slug }}.html" --route "/posts/{{ input.query.rows[0].slug }}""#)
                .output(json!({ "site": { "mode": "page", "status": "written", "path": "site/posts/hello.html", "route": "/posts/hello", "template": "pages/post.tsx", "folder": "site", "store": "local", "manifest_path": "site/.zebflow-static-site.json", "file": { "__zf_type": "file_ref", "backend": "zebfs", "store": "local", "ref": "site/posts/hello.html", "filename": "hello.html", "mime": "text/html", "kind": "binary", "size": 5120, "sha256": "sha256:…", "lifecycle": "durable", "origin": "web.site.generate", "trust": "generated" } } })),
            NodeExample::dsl("Build the docs site nightly", r#"web.site.generate --from handbook --folder handbook-site --name "Example Handbook""#)
                .output(json!({ "site": { "mode": "docs", "status": "ok", "name": "Example Handbook", "template": "docs.template.tsx", "from": "handbook", "folder": "handbook-site", "store": "local", "page_count": 12, "search_index_path": "handbook-site/search-index.json" } }))
                .note("Markdown under `docs/handbook/` becomes HTML under `handbook-site/`, private until the owner serves that folder as a site in Studio → Files."),
            NodeExample::dsl("Publish the 0.11 docs as the latest version", r#"web.site.generate --from handbook --folder site/docs --version 0.11 --latest"#)
                .output(json!({ "site": { "mode": "docs", "status": "ok", "name": "Handbook", "template": "docs.template.tsx", "from": "handbook", "folder": "site/docs", "store": "local", "page_count": 12, "version": "0.11", "latest": true, "versions_path": "site/docs/versions.json" } }))
                .note("With `site` served, the pages answer at `/docs/…` (latest) and `/docs/0.11/…` (pinned, not indexed); a later `--version 0.12 --latest` adds `/docs/0.12/` and leaves 0.11 as it was."),
        ],
        ..Default::default()
    }
}

#[cfg(test)]
mod tests {
    use super::{Config, Mode};

    fn config(path: Option<&str>, from: Option<&str>) -> Config {
        Config {
            template: Some("pages/a.tsx".to_string()),
            path: path.map(str::to_string),
            from: from.map(str::to_string),
            ..Default::default()
        }
    }

    #[test]
    fn the_role_given_chooses_the_mode() {
        assert_eq!(config(Some("index.html"), None).check().unwrap(), Mode::Page);
        assert_eq!(config(None, Some("handbook")).check().unwrap(), Mode::Docs);
        for (path, from) in [(Some("index.html"), Some("handbook")), (None, None), (Some("  "), None)] {
            let err = config(path, from).check().unwrap_err();
            assert_eq!(err.code, super::CODE_MODE, "{path:?} {from:?}");
        }
    }

    #[test]
    fn a_page_needs_its_template_and_each_mode_refuses_the_others_flag() {
        let mut page = config(Some("index.html"), None);
        page.template = None;
        assert_eq!(page.check().unwrap_err().code, super::CODE_MODE);
        let mut named_page = config(Some("index.html"), None);
        named_page.name = Some("Site".to_string());
        assert_eq!(named_page.check().unwrap_err().code, super::CODE_MODE);
        let mut routed_docs = config(None, Some("handbook"));
        routed_docs.route = Some("/x".to_string());
        assert_eq!(routed_docs.check().unwrap_err().code, super::CODE_MODE);
        let mut docs = config(None, Some("handbook"));
        docs.template = None;
        assert_eq!(docs.check().unwrap(), Mode::Docs, "a docs site has a default template");
    }

    #[test]
    fn on_conflict_is_closed_and_overwrites_by_default() {
        let mut page = config(Some("index.html"), None);
        assert_eq!(page.on_conflict().unwrap().as_str(), "overwrite");
        page.on_conflict = Some("replace".to_string());
        assert_eq!(page.check().unwrap_err().code, super::CODE_CONFLICT);
    }

    #[test]
    fn each_mode_has_its_default_folder() {
        let page = config(Some("index.html"), None);
        assert_eq!(page.folder_rel(Mode::Page).unwrap(), "site");
        assert_eq!(page.folder_rel(Mode::Docs).unwrap(), "docs");
        let named = Config { folder: Some("/static/site-a/".to_string()), ..page };
        assert_eq!(named.folder_rel(Mode::Page).unwrap(), "static/site-a");
    }
}