zebflow 0.11.6

Reactive web automation platform — pipelines, templates, and real-time collaboration in a single binary.
Documentation
# Static Entry Generation

## What this builds

One persisted static page for one entry.

The pipeline does not answer the current HTTP request. Instead it:
- collects one collection/entry payload
- renders a TSX page through RWE
- writes the resulting HTML into Zebflow FS

That makes it the right primitive for any regeneration flow where one content mutation can fan out into many related pages later.

---

## Template

Use a normal TSX page template. A project's source root is the repository
root (unless `zebflow.yaml` sets `spec.layout.source`), so the template lives
at:

- `pages/static-entry-page.tsx`

The template receives a single input payload like:

```json
{
  "collection": {
    "name": "Field Notes",
    "slug": "field-notes"
  },
  "entry": {
    "title": "City Garden",
    "slug": "city-garden",
    "summary": "A compact example of one generated static content page.",
    "body": "A small archive can still feel deliberate.\n\nThis example shows how one payload becomes one persisted HTML artifact."
  },
  "related_labels": [
    { "slug": "example", "label": "Example" },
    { "slug": "static", "label": "Static" }
  ],
  "generated_at": "2026-04-11T05:02:00Z"
}
```

When a `javascript.script.run` node builds this shape (as in the pipelines below), it
is the script's own answer, so anything downstream reads it one level deeper —
`input.script.collection`, `input.script.entry`, `input.script.related_labels`,
`input.script.generated_at` — since the script node now keeps the rest of the
payload and adds its own answer under `script` instead of replacing it.

**What the page publishes.** The page renders from the payload and embeds it
for hydration, so the generated file carries every key the payload holds —
shape it first. The one exception is the request: a trigger's `headers`,
`cookies` and `auth` (`input.webhook.headers`, `input.webhook.auth`, …) are
never part of a generated page. A value the page should show — a caller's
public profile, say — is copied into a key of its own by an upstream node.

---

## Pipeline

Minimal version as a callable function pipeline:

```zf
| trigger.function --description "Generate the static page for one entry" --parameter "entry_slug:string!" "Slug of the entry to generate"
| javascript.script.run -- "
const collection = {
  name: 'Field Notes',
  slug: 'field-notes'
};
const entry = {
  title: 'City Garden',
  slug: 'city-garden',
  summary: 'A compact example of one generated static content page.',
  body: `A small archive can still feel deliberate.

This example shows how one payload becomes one persisted HTML artifact.`
};
return {
  collection,
  entry,
  related_labels: [
    { slug: 'example', label: 'Example' },
    { slug: 'static', label: 'Static' }
  ],
  generated_at: new Date().toISOString()
};
"
| web.site.generate \
    --template pages/static-entry-page.tsx \
    --path "collections/{{ input.script.collection.slug }}/{{ input.script.entry.slug }}/index.html" \
    --route "/collections/{{ input.script.collection.slug }}/{{ input.script.entry.slug }}" \
    --on-conflict overwrite
```

Generated file:

- `site/collections/field-notes/city-garden/index.html`

Served URL, once the owner serves `site/` as a site on `https://www.example.com`:

- `https://www.example.com/collections/field-notes/city-garden/`

---

## Database-backed version

This is a more realistic content-backed version:

```zf
| trigger.function --description "Generate the static page for one content entry" --parameter "entry_id:string!" "Entry UUID"
| postgres.query.run --credential content-db --param "1={{ input.function.entry_id }}" -- "
SELECT
  e.entry_id::text AS entry_id,
  e.slug AS entry_slug,
  e.title AS entry_title,
  e.summary AS entry_summary,
  e.body AS entry_body,
  c.collection_id::text AS collection_id,
  c.slug AS collection_slug,
  c.name AS collection_name
FROM content.entry e
JOIN content.collection c ON c.collection_id = e.collection_id
WHERE e.entry_id = $1::uuid
"
| javascript.script.run -- "
const row = input.query.rows?.[0];
if (!row) throw new Error('entry not found');
return {
  collection: {
    id: row.collection_id,
    slug: row.collection_slug,
    name: row.collection_name
  },
  entry: {
    id: row.entry_id,
    slug: row.entry_slug,
    title: row.entry_title,
    summary: row.entry_summary,
    body: row.entry_body
  },
  related_labels: [],
  generated_at: new Date().toISOString()
};
"
| web.site.generate \
    --template pages/static-entry-page.tsx \
    --path "collections/{{ input.script.collection.slug }}/{{ input.script.entry.slug }}/index.html"
```

---

## A whole docs site — `--from`

The same node builds a documentation site from a Markdown folder under
`docs/`: one page per `.md`, a sidebar from the tree (each folder's
`_meta.yaml` gives its `title`, `order`, `collapsed` and `nav`), a search
index, and a sitemap once the folder is served.

```zf
| trigger.schedule --cron "0 3 * * *"
| web.site.generate --from handbook --folder handbook-site --name "Example Handbook"
```

One order — a folder's `nav` (the names listed come first, in that order),
then `order` (a folder's in its `_meta.yaml`, a page's in its front matter;
unordered items follow), then title — drives the sidebar, prev/next and the
search index; the root `index.md` is first, a folder's `index.md` before its
pages. A page whose Markdown was removed or renamed leaves the site on the
next build: its file, its manifest entry, its sitemap and index lines.

`--template` is the page template under the source root (default
`docs.template.tsx`); when the file does not exist it is written from a
scaffold, and edited from then on. `--path` and `--from` choose the mode —
give one, never both.

---

## Why this is the correct first step

This node should stay single-target first.

One run should generate one artifact. That gives:
- clear observability
- clear retries
- easier invalidation logic
- no giant opaque batch node

Later, when one label rename touches thousands of pages, the pipeline should:
1. compute the dirty entry/collection/tag set
2. loop or fan out over that set
3. call `web.site.generate` once per artifact

That is much easier to debug than hiding traversal and parallelism inside one giant node.

---

## Serving behavior

Everything generated is private, like any uploaded file: a folder name never
exposes it, and no node can. The owner exposes the output folder in Studio →
Files:

- **Serve as a site** (`public_execute`) on the addresses written out in its
  `serve` list — each a host the project has in Settings → Addressing. That
  address answers the folder at `/`: `/collections/field-notes/city-garden/`
  reads `site/collections/field-notes/city-garden/index.html`, scripts running.
- **Public read** (`public_read`) instead makes the files readable on the
  project's file host, but never run as a page — a downloaded HTML file, not a
  site.

So:
- generation pipeline: `trigger.function` is enough
- serving generated artifact: no webhook is required once its folder is
  served as a site

Only add a webhook or ingress rewrite if you want a prettier public route like:

- `/collections/field-notes/city-garden`

instead of the native `/files/...` address.

---

## Separate nginx publish surface

For production static publishing, prefer a separate static host or ingress instead of serving generated HTML from the same origin as Zebflow admin.

Example artifact root inside the project data volume:

- `users/superadmin/default/files/static/site-a`

Example nginx server:

```nginx
server {
  listen 80;
  server_name site-a.example;

  root /data/users/superadmin/default/files/static/site-a;
  index index.html;

  location / {
    try_files $uri $uri/ $uri/index.html =404;
  }

  location /_assets/ {
    expires 30d;
    add_header Cache-Control "public, max-age=2592000, immutable";
  }

  location ~* \.html$ {
    expires -1;
    add_header Cache-Control "no-cache";
  }
}
```

That mapping makes these files resolve directly:

- `a/index.html` -> `https://site-a.example/a/`
- `a/demo-author/index.html` -> `https://site-a.example/a/demo-author/`
- `a/demo-author/notes/first-note/index.html` -> `https://site-a.example/a/demo-author/notes/first-note/`

The generator is only writing artifacts. The nginx host is the publishing surface.

---

## Security boundary

Static generation is not the risky part. The real trust boundary is **where the generated HTML executes** and **who can influence the generated bytes**.

Use this rule:

- Hyperguard all project-user-generated or project-user-influenceable content from the platform origin.

That means:

- Platform-authored static pages can be trusted more.
- Project-user-generated content should be strongly guarded and isolated from platform origin.

In practice:

- trusted platform-maintained static pages may be acceptable on the main platform origin
- project-user-authored or project-user-influenceable HTML should not execute on the same origin as project studio, admin pages, API routes, or platform session cookies

If you need public HTML hosting for lower-trust content, prefer a separate publishing origin instead of serving executable HTML on the main Zebflow origin.