feat: form-data system v0 (sixth tool + zddc-server endpoints)
All checks were successful
Build + deploy releases / build-and-deploy (push) Successful in 8s

Schema-driven form renderer plus zddc-server endpoints that turn any
<name>.form.yaml into a working data-collection form at <path>/<name>.form.html.
Submissions land in <path>/<name>/<YYYY-MM-DD>-<email-sanitized>.yaml,
ACL-gated by the existing .zddc cascade. The form posts back to its own URL;
the server strips ".html" and routes by what's underneath, so create and
update use the same client-side code path.

Form spec dialect: JSON Schema 2020-12 + RJSF-style ui:* hints, written in
YAML. Chosen for LLM authorability — it's the canonical structured-output
target for OpenAI/Anthropic, and the ui:* convention is the most-trained UI
hint vocabulary. Supported subset for v0: type (string/number/integer/boolean/
array/object), enum, min/max, minLength/maxLength, required, additionalProperties:
false, properties, items, format (date, email). Round-trip mode is form-as-truth:
submission YAML is regenerated each save, comments are not preserved (the v1
file-as-truth mode for hand-edited files like .zddc itself is deferred).

New components:
  * form/ — sixth single-file HTML tool, vanilla JS renderer (~760 LoC)
  * zddc/internal/jsonschema/ — focused JSON Schema validator covering only
    the v0 keyword subset. Match-implementation-cost-to-surface-used: a full
    library brings 70%+ surface we don't use; revisit when v1 adds $ref +
    oneOf + if/then/else.
  * zddc/internal/handler/formhandler.go — RecognizeFormRequest / ServeForm,
    capability-URL re-edit, atomic submission writes via the new
    zddc.WriteAtomic helper extracted from writer.go.
  * dispatch() in zddc-server/main.go now intercepts *.form.html and
    *.yaml.html before the static-file path; spec existence is the trigger.

Build pipeline: form joins ZDDC_RELEASE_TOOLS in lockstep, gets its own
embedded copy in handler/form.html (separate from the apps cascade —
the form renderer is fixed, not subject to per-folder version overrides).

Tests: 5 new Playwright specs (form-safety) + 14 new Go tests across the
validator and handler. All 172 Playwright tests + 10 Go packages green.
End-to-end manual verification: GET empty → POST 201 + capability URL →
GET re-edit (pre-filled) → POST update → 200, raw YAML browsable, ACL
deny → 403.

Docs: form/ section added to AGENTS.md and ARCHITECTURE.md. AGENTS.md
also documents the implementation-vs-dependency policy. CLAUDE.md repo-shape
list extended.

Deferred (v1+): .zddc editor migration onto this system, file-as-truth
lossless YAML round-trip, ui:show-when conditional visibility, oneOf/anyOf,
apps-cascade preview hook, cascade-fetched form definitions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-02 20:12:16 -05:00
parent c099676024
commit a02a26d3c2
38 changed files with 4910 additions and 74 deletions

View file

@ -14,7 +14,7 @@
./build alpha # cut alpha (cascades nothing)
./build beta # cut beta (cascades alpha → beta)
./build release # cut stable, coordinated next version
# (cascades alpha + beta → new stable; tags all six)
# (cascades alpha + beta → new stable; tags all seven)
./build release 1.2.0 # cut stable at explicit version
./build help
@ -27,7 +27,7 @@
./deploy --releases # only dist/release-output/ → /srv/zddc/releases/
# Single-tool dev build for testing (does NOT touch dist/release-output/):
sh tool/build.sh # archive|transmittal|classifier|mdedit|landing
sh tool/build.sh # archive|transmittal|classifier|mdedit|landing|form
# Single-tool release (rare; prefer ./build alpha|beta|release so versions
# don't drift between tools). Same flag form as before.
@ -38,7 +38,7 @@ sh tool/build.sh --release [<version>|alpha|beta]
npm test
# Test single tool
npx playwright test tool # archive | transmittal | classifier | mdedit
npx playwright test tool # archive | transmittal | classifier | mdedit | form-safety
# Dev server (cache-busting HTTP, on port 8000)
./dev-server start
@ -60,7 +60,7 @@ because the bundle is complete, dangling-link errors mean a real bug.
## Architecture
Five independent single-file HTML tools (`archive`, `transmittal`, `classifier`, `mdedit`, `landing`). Each compiles to one self-contained `.html` in `dist/` with all CSS and JS inlined — the first four name their output `dist/tool.html`; `landing` writes `dist/index.html` (it's served at `/` by `zddc-server`). Tools share a small set of canonical helpers in `shared/` (filename parsing, ZDDC filter UI, theme, help) — see "Shared modules" below.
Six independent single-file HTML tools (`archive`, `transmittal`, `classifier`, `mdedit`, `landing`, `form`). Each compiles to one self-contained `.html` in `dist/` with all CSS and JS inlined — most name their output `dist/tool.html`; `landing` writes `dist/index.html` (served at `/` by `zddc-server`). Tools share a small set of canonical helpers in `shared/` (filename parsing, ZDDC filter UI, theme, help) — see "Shared modules" below. The sixth tool, `form`, is the schema-driven renderer used by zddc-server's form-data system; see "Form-data system" below.
```
tool/
@ -134,7 +134,7 @@ Included as the **first** positional arg to every tool's `concat_files` CSS call
- Build scripts use **POSIX sh** (`#!/bin/sh` with `set -eu`), not bash.
- `concat_files` accepts **positional args only** (not array names).
- `awk` processes `template.html`, replacing `{{PLACEHOLDER}}` markers and stripping CDN `<script>`/`<link>` tags (pattern: `https?://`)
- `{{BUILD_LABEL}}` is substituted in all five tools via `gsub` in awk (use `gsub`, not `print` — the placeholder is inline in an HTML line). Value is `Built: <timestamp> BETA` for dev builds, `v<version>` for stable releases, and `<channel> · <date> · <sha>` for alpha/beta channel builds; computed before the awk step. The shared `is_red` flag controls whether the label is wrapped in a red+bold `<span>` (true for dev/alpha/beta, false for stable).
- `{{BUILD_LABEL}}` is substituted in all six tools via `gsub` in awk (use `gsub`, not `print` — the placeholder is inline in an HTML line). Value is `Built: <timestamp> BETA` for dev builds, `v<version>` for stable releases, and `<channel> · <date> · <sha>` for alpha/beta channel builds; computed before the awk step. The shared `is_red` flag controls whether the label is wrapped in a red+bold `<span>` (true for dev/alpha/beta, false for stable).
- Cleans up temp files via `trap cleanup EXIT`
**`</` escaping is mandatory.** Any JS containing `</tag>` inside string or template literals will break inline `<script>` embedding. Run:
@ -192,20 +192,20 @@ Format: `trackingNumber_revision (status) - title.extension`
- Feature-branch workflow; squash-merge feature branches to `main`
- Conventional commits: `feat(archive): ...`, `fix(transmittal): ...`
- Release tags: `<tool>-v<X.Y.Z>` per tool, all six sharing the same X.Y.Z on a coordinated cut (e.g. `archive-v0.0.8`, `transmittal-v0.0.8`, `classifier-v0.0.8`, `mdedit-v0.0.8`, `landing-v0.0.8`, `zddc-server-v0.0.8`)
- Release tags: `<tool>-v<X.Y.Z>` per tool, all seven sharing the same X.Y.Z on a coordinated cut (e.g. `archive-v0.0.8`, `transmittal-v0.0.8`, `classifier-v0.0.8`, `mdedit-v0.0.8`, `landing-v0.0.8`, `form-v0.0.8`, `zddc-server-v0.0.8`)
- `dist/` is gitignored. Build artifacts (per-tool `dist/<tool>.html` and `dist/release-output/`) are NOT committed to this repo. Reproduce them from a tag with `./build release X.Y.Z`
- Hand-edited website content lives in a separate Codeberg repo (`codeberg.org/VARASYS/ZDDC-website`, cloned at `~/src/zddc-website/`). Source-code commits go to `main` here; content commits go to that repo
- Release artifacts live on the deploy host (`/srv/zddc/`), not in any git history. Use `./deploy` to publish
### Releasing — lockstep, channels, layout
**Lockstep convention.** Every release cut bumps all six artifacts (5 HTML tools + zddc-server) to the same version, even if a tool didn't change. Per-tool independent versions are gone. The coordinated next-stable target is `max(latest tag across all six tools) + 1` — `_coordinated_next_stable` in `shared/build-lib.sh`. Channel cuts (alpha/beta) follow the same lockstep — every tool's channel mirror is overwritten in step. Three channels, ordered: **alpha** (dev iteration) → **beta** (general testing) → **stable** (ship).
**Lockstep convention.** Every release cut bumps all seven artifacts (6 HTML tools + zddc-server) to the same version, even if a tool didn't change. Per-tool independent versions are gone. The coordinated next-stable target is `max(latest tag across all seven tools) + 1` — `_coordinated_next_stable` in `shared/build-lib.sh`. Channel cuts (alpha/beta) follow the same lockstep — every tool's channel mirror is overwritten in step. Three channels, ordered: **alpha** (dev iteration) → **beta** (general testing) → **stable** (ship).
**Storage model.** All release artifacts live on the deploy host at `/srv/zddc/releases/` (Caddy bind-mount, served as `https://zddc.varasys.io/releases/`). Locally they materialize in this repo's `dist/release-output/` (gitignored) when `./build alpha|beta|release` runs; `./deploy` rsyncs them out. **No git history holds release artifacts** — older versions are reproducible from any `<tool>-vX.Y.Z` tag (`git checkout zddc-server-v0.0.8 && ./build release 0.0.8`). No Codeberg release assets, no LFS, no third-party mirrors.
| Artifact | Type | Layout |
|---|---|---|
| `<tool>_v<X.Y.Z>.html` | real, immutable | per-version HTML for each of archive, transmittal, classifier, mdedit, landing |
| `<tool>_v<X.Y.Z>.html` | real, immutable | per-version HTML for each of archive, transmittal, classifier, mdedit, landing, form |
| `<tool>_v<X.Y>.html`, `<tool>_v<X>.html` | symlinks | partial-version pins |
| `<tool>_<channel>.html` | symlink (or real bytes during active channel dev) | mutable channel mirror per tool, channel ∈ {stable, beta, alpha} |
| `zddc-server_v<X.Y.Z>_<platform>` | real binary | per-version cross-compiled binary, platform ∈ {linux-amd64, darwin-amd64, darwin-arm64, windows-amd64.exe} |
@ -215,9 +215,9 @@ Format: `trackingNumber_revision (status) - title.extension`
**Single point of truth.** `./build release` is the canonical lockstep cut. It seeds `dist/release-output/` from `/srv/zddc/releases/` (so cascades and the verifier see a complete world), forwards each HTML tool's build with the agreed version, then `promote_zddc_server` (in `shared/build-lib.sh`) copies the freshly cross-compiled binaries into `dist/release-output/` with the matching symlink chain, then `write_zddc_server_stubs_all` regenerates every stub page, then `build_releases_index` rewrites the index, then `verify_channel_links` asserts nothing dangles. `./deploy --releases` then publishes the bundle.
- **Stable** (`./build release` or `--release X.Y.Z`): Writes per-version HTML for the five HTML tools + per-version binaries for zddc-server (real bytes, immutable). Refreshes 5 symlinks per HTML tool + 5 symlinks per zddc-server platform → the new version. Tags all six: `<tool>-v<X.Y.Z>`. Cascade: stable cut means beta and alpha both reset to stable for every tool. Skips silently if source for an HTML tool hasn't changed since the latest stable tag (the binary always builds).
- **Stable** (`./build release` or `--release X.Y.Z`): Writes per-version HTML for the six HTML tools + per-version binaries for zddc-server (real bytes, immutable). Refreshes 5 symlinks per HTML tool + 5 symlinks per zddc-server platform → the new version. Tags all seven: `<tool>-v<X.Y.Z>`. Cascade: stable cut means beta and alpha both reset to stable for every tool. Skips silently if source for an HTML tool hasn't changed since the latest stable tag (the binary always builds).
- **Beta** (`./build beta`): Overwrites `<tool>_beta.html` with dist bytes for each HTML tool, and `zddc-server_beta_<platform>` with each platform's binary. Cascade: `<tool>_alpha.html``<tool>_beta.html` and `zddc-server_alpha_<platform>``zddc-server_beta_<platform>` (symlinks). No tag.
- **Alpha** (`./build`): Overwrites only the alpha mirrors, all six tools. No tag, no other side-effects.
- **Alpha** (`./build`): Overwrites only the alpha mirrors, all seven tools. No tag, no other side-effects.
- **Plain dev builds** (`./build` with no arg): produce `tool/dist/<tool>.html` for HTML tools and `zddc/dist/zddc-server-<platform>` binaries; do NOT touch `dist/release-output/` or the live site. Use it to iterate without affecting deployable state.
On-page `{{BUILD_LABEL}}` format (HTML tools only — zddc-server's version comes from the binary itself):
@ -231,7 +231,7 @@ After cutting a stable release, `git push origin main && git push origin --tags`
### Channel discipline (MUST rules)
The build enforces lockstep mechanically (one command bumps all six). The rules below are still on you.
The build enforces lockstep mechanically (one command bumps all seven). The rules below are still on you.
1. **Stable doesn't regress.** No known-broken features that worked in the previous stable. If `v0.0.5` ships with a bug, the path forward is `v0.0.6` with a fix — never edit a previously-published per-version file in place. Stable per-version files are immutable.
2. **Lockstep is the contract.** Don't cut a single tool's release without bumping the rest. The HTML tool's standalone `--release` flag still exists as an escape hatch but emits a tag that immediately drifts out of sync with the others.
@ -307,6 +307,37 @@ Use `git worktree` to run multiple agents on separate branches simultaneously wi
- Toast UI Editor v3.2.2 is bundled in `vendor/`; `template.html` loads it from CDN for dev convenience
- `</` escaping is essential: `sed 's#</#<\\/#g'` runs on both app JS and vendor JS at build time
## Form-data system (`form/` + zddc-server form handler)
A schema-driven form renderer used to collect structured data into YAML files in the file tree. The form tool (`form/`) is the renderer; the server-side endpoints live in `zddc/internal/handler/formhandler.go`; the validator is `zddc/internal/jsonschema/`.
**Form spec**: `<name>.form.yaml` — top-level envelope is `{title, description, schema, ui, mode}`. `schema` is JSON Schema 2020-12 (subset; see "Validator subset" below). `ui` is RJSF-style (`ui:widget`, `ui:order`, `ui:autofocus`, `ui:placeholder`, `ui:help`, `ui:readonly`, `ui:options.{addable,removable}`). LLMs author this dialect well.
**URL conventions** (form posts back to its own URL; server strips `.html`):
- `GET /<path>/<name>.form.html` — render empty form
- `POST /<path>/<name>.form.html` — create new submission → 201 + Location capability URL
- `GET /<path>/<name>/<id>.yaml.html` — render form pre-filled from `<id>.yaml`
- `POST /<path>/<name>/<id>.yaml.html` — overwrite that submission → 200
**Storage**: spec at `<dir>/<name>.form.yaml`, submissions at `<dir>/<name>/<YYYY-MM-DD>-<email-sanitized>.yaml`. Submissions folder is created lazily; ACL applies via the existing `.zddc` cascade.
**Round-trip**: v0 is form-as-truth — submission YAML is regenerated from form state on every save; comments in submissions are not preserved. File-as-truth mode (lossless YAML round-trip via the eemeli/yaml Document API) is a v1 feature, needed for hand-edited files like `.zddc` itself.
**Validator subset** (`zddc/internal/jsonschema/`): `type` (string/number/integer/boolean/array/object), `enum`, `minimum`, `maximum`, `minLength`, `maxLength`, `required`, `additionalProperties: false`, `properties`, `items`, `format` (`date`, `email`). NOT supported in v0: `$ref`, `$defs`, `if/then/else`, `oneOf`/`anyOf`/`allOf`, conditional visibility. The form-spec meta-schema enforces that authors stay in the supported subset.
**Renderer subset** (`form/js/`): types listed above, enum (select / `ui:widget: radio`), `format: date|email`, textarea, nested objects, arrays of primitives, arrays of objects with add/remove rows. `ui:show-when` and reorder are v1.
**Adding a new form**: drop a `<name>.form.yaml` into any path users can write to (per `.zddc` ACL). No code change required. Visit `<that-path>/<name>.form.html`.
## Implementation-vs-dependency policy
Match implementation cost to actual surface used. Reimplement focused subsets when a dep's surface area is much larger than what we consume; adopt for genuinely large specs (YAML parsing, etc.) where reimplementing is foolish. Examples in this codebase:
- **`zddc/internal/jsonschema/`** — focused 2020-12 validator (~300 LoC) covering only the v0 form-spec subset. A full library (e.g. `santhosh-tekuri/jsonschema/v6`) brings 70%+ surface we don't use.
- **`gopkg.in/yaml.v3`** — adopted as a dep. Reimplementing YAML is foolish.
This is a guideline, not a rule. Revisit per-feature: when v1+ form-spec adds `$ref` + `oneOf` + `if/then/else`, the validator's "savings" evaporate and adopting becomes cheaper.
## zddc-server
Go HTTP server sub-project living at `zddc/`. Replaces `caddy file-server --browse` for ZDDC archives.
@ -354,7 +385,7 @@ ZDDC_ROOT=/path/to/your/archive ZDDC_TLS_CERT=none ZDDC_ADDR=:8080 \
### Release tagging
zddc-server has no separate release script. The top-level `./build alpha|beta|release [version]` is the canonical path: it cross-compiles the binaries inside the containerized Go toolchain, copies them into `dist/release-output/` with the lockstep symlink chain (one set of symlinks per platform), regenerates the per-version + per-channel stub pages, refreshes the index, and (on stable) tags `zddc-server-v<X.Y.Z>` alongside the five HTML-tool tags.
zddc-server has no separate release script. The top-level `./build alpha|beta|release [version]` is the canonical path: it cross-compiles the binaries inside the containerized Go toolchain, copies them into `dist/release-output/` with the lockstep symlink chain (one set of symlinks per platform), regenerates the per-version + per-channel stub pages, refreshes the index, and (on stable) tags `zddc-server-v<X.Y.Z>` alongside the six HTML-tool tags.
```sh
./build release # lockstep stable, coordinated next version
@ -366,7 +397,7 @@ zddc-server has no separate release script. The top-level `./build alpha|beta|re
The script tags every tool but does NOT push — finish with `git push origin main && git push origin --tags` (and run `./deploy` to put the artifacts on the live site).
**Versioning** — clean semver. Stable cuts emit one `<tool>-vX.Y.Z` tag per tool, all six sharing the same X.Y.Z. No `-alpha.N` / `-beta.N` counter tags — channel URLs are stable URLs by design. Historical per-tool independent tags (`archive-v0.0.2`, `zddc-server-v0.0.7`, etc.) stay as artifacts; the next coordinated cut jumps every tool to the same number.
**Versioning** — clean semver. Stable cuts emit one `<tool>-vX.Y.Z` tag per tool, all seven sharing the same X.Y.Z. No `-alpha.N` / `-beta.N` counter tags — channel URLs are stable URLs by design. Historical per-tool independent tags (`archive-v0.0.2`, `zddc-server-v0.0.7`, etc.) stay as artifacts; the next coordinated cut jumps every tool to the same number.
**Binary distribution** — `/srv/zddc/releases/zddc-server_<X>_<platform>` (on the deploy host) are real static files served from `zddc.varasys.io/releases/`. No Codeberg release assets, no `$CODEBERG_TOKEN`, no third-party mirror, no LFS. The matrix-cell link points at `zddc-server_<X>.html`, a generated stub page that surfaces the four platform downloads in one click.

View file

@ -89,7 +89,7 @@ Each topic has exactly one authoritative home; everything else links to it.
| Architecture & internal patterns | `ARCHITECTURE.md` (this file) | `AGENTS.md` |
| Per-tool internal design quirks | `<tool>/README.md` | (linked from website intro tool cards) |
`index.html` in the `ZDDC-website` repo (working dir `~/src/zddc-website/index.html`) is **hand-edited static content** (analogous to `reference.html`), not the landing-tool output. The install section points operators at two paths: **local** (download a `.html` file from `/releases/`) and **server** (run `zddc-server`; current-stable builds of all five tools are baked into the binary at compile time via `//go:embed`). The landing tool's released bytes live at `/srv/zddc/releases/landing_v<X.Y.Z>.html` (rsync'd from `dist/release-output/`); the embedded copy serves at the deployment root by default. The public website at `zddc.varasys.io/` is the same hand-edited `index.html` — its root URL is the introduction page, not the project picker (because there are no projects to pick from a static site).
`index.html` in the `ZDDC-website` repo (working dir `~/src/zddc-website/index.html`) is **hand-edited static content** (analogous to `reference.html`), not the landing-tool output. The install section points operators at two paths: **local** (download a `.html` file from `/releases/`) and **server** (run `zddc-server`; current-stable builds of all six tools are baked into the binary at compile time via `//go:embed`). The landing tool's released bytes live at `/srv/zddc/releases/landing_v<X.Y.Z>.html` (rsync'd from `dist/release-output/`); the embedded copy serves at the deployment root by default. The public website at `zddc.varasys.io/` is the same hand-edited `index.html` — its root URL is the introduction page, not the project picker (because there are no projects to pick from a static site).
When updating documentation, prefer linking over duplicating. If you find yourself rewriting the file-naming convention in a tool's README, link to `reference.html` instead.
@ -112,7 +112,7 @@ The top-level `./build` at the repository root is the canonical lockstep entry p
1. On a channel/release cut, **seeds `dist/release-output/` from `/srv/zddc/releases/`** (preserving symlinks) so the bundle is a complete intended-live snapshot, not a sparse one-channel diff. Cascades and the verifier downstream see the same world the live site has.
2. Forwards `--release [version|alpha|beta]` to every HTML tool's build, computing a coordinated next-stable target via `_coordinated_next_stable` (max of every tool's latest tag + 1) when no explicit version is given.
3. Cross-compiles zddc-server for the four target platforms inside a containerized Go toolchain (podman/docker).
4. On a channel/release cut, calls `promote_zddc_server` to copy the freshly cross-compiled binaries into `dist/release-output/` with the matching symlink chain (one set per platform) and tag `zddc-server-v<X.Y.Z>` alongside the five HTML-tool tags (stable cuts only).
4. On a channel/release cut, calls `promote_zddc_server` to copy the freshly cross-compiled binaries into `dist/release-output/` with the matching symlink chain (one set per platform) and tag `zddc-server-v<X.Y.Z>` alongside the six HTML-tool tags (stable cuts only).
5. Calls `write_zddc_server_stubs_all` to refresh the per-version + per-channel stub HTML pages from whatever artifacts are in `dist/release-output/`.
6. Regenerates `dist/release-output/index.html` as the action-first download page.
7. Calls `verify_channel_links` — fails the build if any channel link is dangling.
@ -121,11 +121,11 @@ Then `./deploy --releases` rsyncs `dist/release-output/` → `/srv/zddc/releases
### Channels
Three release channels, applied in lockstep across all six tools (5 HTML + zddc-server). The cascade rule keeps downstream channel symlinks current automatically.
Three release channels, applied in lockstep across all seven tools (6 HTML + zddc-server). The cascade rule keeps downstream channel symlinks current automatically.
- **Stable** — versioned, immutable. `./build release [version]` writes per-version HTML for the five HTML tools and per-version binaries for zddc-server (real bytes), refreshes the symlink chain (5 symlinks per HTML tool + 5 symlinks per zddc-server platform) all → the new version, and tags `<tool>-v<X.Y.Z>` for every tool. Skips per-tool HTML rewrites when source hasn't changed since that tool's last stable tag (binaries always rebuild).
- **Stable** — versioned, immutable. `./build release [version]` writes per-version HTML for the six HTML tools and per-version binaries for zddc-server (real bytes), refreshes the symlink chain (5 symlinks per HTML tool + 5 symlinks per zddc-server platform) all → the new version, and tags `<tool>-v<X.Y.Z>` for every tool. Skips per-tool HTML rewrites when source hasn't changed since that tool's last stable tag (binaries always rebuild).
- **Beta**`./build beta` overwrites `<tool>_beta.html` for each HTML tool and `zddc-server_beta_<platform>` for each platform with fresh bytes. Cascades alpha → beta for both HTML and binaries (one symlink per platform). No tag — channel URLs are stable URLs by design.
- **Alpha**`./build` overwrites only the alpha mirrors, all six tools. No tag, no other side-effects.
- **Alpha**`./build` overwrites only the alpha mirrors, all seven tools. No tag, no other side-effects.
A plain `./build` (no arg) is a dev build: it produces `dist/<tool>.html` and `zddc/dist/zddc-server-<platform>` binaries; doesn't touch `dist/release-output/` or the live site. The download index, stub pages, and verifier only run when a channel/release is being cut.
@ -412,6 +412,37 @@ app.state.subscribe((property, newValue) => {
---
### Form Renderer (`form/`)
**Pattern:** Schema-driven renderer for the form-data system. Reads a JSON Schema 2020-12 + RJSF-style `ui:*` hints from a server-injected `<script id="form-context">` block; recursively walks the schema and mounts a tree of widgets; on submit, walks the widget tree to serialize back to JSON and POSTs to the URL the form was loaded from.
**Why schema-driven** (vs. transmittal's hardcoded HTML): the form tool is generic — one renderer serves any form spec a user (or LLM) drops into the file tree. Adding a new form requires no code change; adding a new field type to an *existing* form requires only a YAML edit.
**Widget interface** — every widget exposes:
- `el` — DOM root
- `read()` — current value (recurses into children for object / array)
- `setError(msg)` / `clearErrors()` — show / clear field-level errors
- `child(name|idx)` — for container widgets, look up nested widget by JSON-Pointer segment (used by `errors.js` to attach server-side validation messages by path)
**Module layout:**
- `js/app.js``window.formApp = { context, rootWidget, modules }`
- `js/context.js` — read injected `#form-context` JSON
- `js/util.js``h()` DOM builder, JSON-Pointer encode/parse
- `js/widgets.js` — primitives (string/number/integer/boolean/enum, format date/email, textarea)
- `js/object.js` — fieldset rendering with `ui:order` resolution
- `js/array.js` — repeating-row UX (add/remove)
- `js/render.js` — type-triage dispatcher
- `js/serialize.js` — read tree → JSON
- `js/errors.js` — distribute errors by JSON Pointer path
- `js/post.js` — POST + handle 200/201/422/403/409 responses
- `js/main.js` — boot: load context, mount root widget, wire submit
**Server-side counterpart:** `zddc/internal/handler/formhandler.go` recognizes `*.form.html` and `*.yaml.html` URLs, parses the spec, validates submissions via `zddc/internal/jsonschema/`, writes via `zddc.WriteAtomic`. Existence of `<name>.form.yaml` is the trigger; without it, the URL falls through to static-file serving.
**Round-trip philosophy:** v0 is "form-as-truth" — submission YAML is regenerated from form state on every save. Hand-edits to submission files are not preserved across re-edit→re-submit. v1 will add an opt-in "file-as-truth" mode (eemeli/yaml Document API) for forms like `.zddc` itself where users hand-edit and comments must survive.
---
## CSS Architecture
All tools use vanilla CSS. No frameworks at build time (mdedit's Tailwind utilities are pre-generated static CSS).

View file

@ -15,10 +15,10 @@ If something in this CLAUDE.md conflicts with those, those win — and please up
This is a **monorepo of independent tools**, not one application:
- `archive/`, `transmittal/`, `classifier/`, `mdedit/`, `landing/` — five self-contained HTML tools, each compiled to a single inlined HTML file in its own `dist/`. Naming: the first four output `dist/tool.html`; **`landing/` outputs `dist/index.html`** (it's the project picker served at the root of `zddc-server`).
- `archive/`, `transmittal/`, `classifier/`, `mdedit/`, `landing/`, `form/` — six self-contained HTML tools, each compiled to a single inlined HTML file in its own `dist/`. Most output `dist/tool.html`; **`landing/` outputs `dist/index.html`** (it's the project picker served at the root of `zddc-server`). The sixth tool, `form/`, is the schema-driven renderer for the form-data system (any `<name>.form.yaml` file in the tree becomes an editable form at `<path>/<name>.form.html`); see AGENTS.md "Form-data system" and ARCHITECTURE.md "Form Renderer".
- `zddc/` — Go HTTP server (separate sub-project; Go 1.24+). Serves `ZDDC_ROOT/index.html` at `GET /` as the landing page; `Accept: application/json` on `/` returns the ACL-filtered project list. Cross-compiled binaries are produced by `./build` and live in `dist/release-output/` (gitignored); `./deploy` rsyncs them to `/srv/zddc/releases/` on the deploy host (Caddy serves them at `https://zddc.varasys.io/releases/`). The `helm/` charts in this repo build from source at deploy time.
- `shared/``base.css` plus shared JS modules (`zddc.js`, `hash.js`, `zddc-filter.js`, `theme.js`, `help.js`) included by every tool's build, and `build-lib.sh` (POSIX sh helpers sourced by every tool's `build.sh` AND by the top-level `build` for lockstep release helpers).
- **Two-repo + deploy-host model.** Source code lives here (`codeberg.org/VARASYS/ZDDC`). Hand-edited website content lives in a separate repo (`codeberg.org/VARASYS/ZDDC-website`, typically cloned at `~/src/zddc-website/` — just `index.html`, `reference.html`, `css/`, `js/`, `img/`; no releases, no LFS). The live site at `zddc.varasys.io` is served from `/srv/zddc/` on the deploy host: Caddy bind-mounts that path, and it's populated by `./deploy` from this repo's `dist/release-output/` plus `~/src/zddc-website/`. **Releases are NOT in any git history** — they're reproducible from this repo's `<tool>-vX.Y.Z` tags by checking out the tag and running `./build release X.Y.Z`. Per-version files (`<tool>_v<X.Y.Z>.html`) are immutable; partial-version pins (`<tool>_v<X.Y>.html`, `<tool>_v<X>.html`) and channel mirrors (`<tool>_{stable,beta,alpha}.html`) are symlinks; zddc-server has analogous `zddc-server_v<X.Y.Z>_<platform>` per-version binaries plus channel/partial-version symlinks plus `zddc-server_<X>.html` stub pages that fan out the four-platform download in one cell. **Install model:** local use is a download from `/releases/`. Server use is `zddc-server`, which has the current-stable build of all five tools baked in via `//go:embed` (compile-time default). Tools auto-served at folder-name-driven paths: `archive` everywhere, `classifier` in `Incoming`/`Working`/`Staging` subtrees, `mdedit` in `Working` subtrees, `transmittal` in `Staging` subtrees, `landing` only at root. Override via `.zddc apps:` cascade entry (channel/version/URL/path) — fetched once, cached at `<ZDDC_ROOT>/_app/`. Drop a real `.html` file at any path to override.
- **Two-repo + deploy-host model.** Source code lives here (`codeberg.org/VARASYS/ZDDC`). Hand-edited website content lives in a separate repo (`codeberg.org/VARASYS/ZDDC-website`, typically cloned at `~/src/zddc-website/` — just `index.html`, `reference.html`, `css/`, `js/`, `img/`; no releases, no LFS). The live site at `zddc.varasys.io` is served from `/srv/zddc/` on the deploy host: Caddy bind-mounts that path, and it's populated by `./deploy` from this repo's `dist/release-output/` plus `~/src/zddc-website/`. **Releases are NOT in any git history** — they're reproducible from this repo's `<tool>-vX.Y.Z` tags by checking out the tag and running `./build release X.Y.Z`. Per-version files (`<tool>_v<X.Y.Z>.html`) are immutable; partial-version pins (`<tool>_v<X.Y>.html`, `<tool>_v<X>.html`) and channel mirrors (`<tool>_{stable,beta,alpha}.html`) are symlinks; zddc-server has analogous `zddc-server_v<X.Y.Z>_<platform>` per-version binaries plus channel/partial-version symlinks plus `zddc-server_<X>.html` stub pages that fan out the four-platform download in one cell. **Install model:** local use is a download from `/releases/`. Server use is `zddc-server`, which has the current-stable build of all six tools baked in via `//go:embed` (compile-time default). Tools auto-served at folder-name-driven paths: `archive` everywhere, `classifier` in `Incoming`/`Working`/`Staging` subtrees, `mdedit` in `Working` subtrees, `transmittal` in `Staging` subtrees, `landing` only at root. Override via `.zddc apps:` cascade entry (channel/version/URL/path) — fetched once, cached at `<ZDDC_ROOT>/_app/`. Drop a real `.html` file at any path to override.
- `helm/` — example Helm charts for zddc-server (`zddc-server-prod/`, `zddc-server-dev/`). Both compile from source via init container. Operators copy `values.yaml.example` and customize. No secrets in repo.
- `tests/` — Playwright specs (Chromium only, requires File System Access API). `tests/schema.spec.js` validates `transmittal.schema.json` against canonical fixtures via `ajv` (only dev dep besides Playwright)
@ -36,7 +36,7 @@ This is a **monorepo of independent tools**, not one application:
./build alpha # cut alpha (cascades nothing)
./build beta # cut beta (cascades alpha → beta)
./build release # cut stable, coordinated next version
# (cascades alpha + beta → new stable; tags all six tools)
# (cascades alpha + beta → new stable; tags all seven tools)
./build release X.Y.Z # cut stable at explicit version
./build help # usage
@ -65,7 +65,7 @@ No lint/typecheck/format commands exist for the HTML tools — vanilla JS + POSI
- **`dist/` is gitignored.** `tool/dist/<tool>.html` is the canonical built artifact for testing and as the source for `--release` writes. `dist/release-output/` is the local-only release bundle written by `./build alpha|beta|release`. Never hand-edit a `dist/` file.
- **Build vs deploy are separate verbs.** `./build` and `./build alpha|beta|release` produce artifacts under `dist/release-output/`. Nothing escapes the source tree until the operator runs `./deploy`, which rsyncs into `/srv/zddc/` (Caddy's bind-mount). This decouples local iteration from live state.
- **Channel/release cuts seed from live state.** Before running per-tool promote, `./build alpha|beta|release` clears `dist/release-output/` and copies `/srv/zddc/releases/` into it (preserving symlinks). The cut then mutates the channels being cut on top. Result: `dist/release-output/` is always a complete intended-live snapshot, the verifier sees a complete world, and `./deploy --releases` (rsync `--delete-after`) replaces live state cleanly.
- **Lockstep releases.** Every release cut bumps all six artifacts (5 HTML tools + zddc-server) to the same version, even if a tool didn't change. The coordinated next-stable target is `max(latest tag across all tools) + 1`. Per-tool independent versions are no longer the norm — `./build release` is the canonical path. Workflow: alpha = active dev, beta = ready for general testing, stable = ready to ship.
- **Lockstep releases.** Every release cut bumps all seven artifacts (6 HTML tools + zddc-server) to the same version, even if a tool didn't change. The coordinated next-stable target is `max(latest tag across all tools) + 1`. Per-tool independent versions are no longer the norm — `./build release` is the canonical path. Workflow: alpha = active dev, beta = ready for general testing, stable = ready to ship.
- **Release artifact layout** (in `dist/release-output/`, mirrored to `/srv/zddc/releases/`). HTML tools: per-version `<tool>_v<X.Y.Z>.html` (real immutable files) + partial-version pins (`<tool>_v<X.Y>.html`, `_v<X>.html`) + channel mirrors (`<tool>_{stable,beta,alpha}.html`) — all symlinks except per-version. zddc-server: `zddc-server_v<X.Y.Z>_<platform>` per-version binaries (raw bytes, no LFS), `_v<X.Y>_<platform>` / `_v<X>_<platform>` / `_<channel>_<platform>` symlinks, plus `zddc-server_<X>.html` stub pages that surface the four platform downloads in one matrix-cell link. Same cascade rule for both: stable cut → beta + alpha both reset to stable; beta cut → alpha cascades to beta.
- **No tags for alpha/beta.** Channel URLs are stable URLs by design — appending counter tags would defeat the purpose. The on-page label encodes `<date> · <sha>` for traceability. Stable cuts get clean `<tool>-vX.Y.Z` tags for every tool (six tags per cut, all sharing the same X.Y.Z).
- **Pre-release semver in the on-page label.** Plain dev builds and `--release alpha|beta` cuts embed `vX.Y.Z-{alpha,beta}` in `{{BUILD_LABEL}}` where X.Y.Z is the next-stable target. Plain dev adds a full timestamp + `-dirty` marker; `--release alpha|beta` is date-only.

28
build
View file

@ -135,27 +135,32 @@ sh "$SCRIPT_DIR/archive/build.sh" $TOOL_RELEASE_ARGS
sh "$SCRIPT_DIR/classifier/build.sh" $TOOL_RELEASE_ARGS
sh "$SCRIPT_DIR/mdedit/build.sh" $TOOL_RELEASE_ARGS
sh "$SCRIPT_DIR/landing/build.sh" $TOOL_RELEASE_ARGS
sh "$SCRIPT_DIR/form/build.sh" $TOOL_RELEASE_ARGS
echo ""
echo "=== Assembling zddc/dist/web/ ==="
# All five tool HTMLs ship inside the server bundle. landing and archive call
# Six tool HTMLs ship inside the server bundle. landing and archive call
# server APIs (GET / for the project list, directory listings for archive) and
# are useless without zddc-server. transmittal, classifier, and mdedit are
# pure client-side tools but are still bundled — the server uses these copies
# as the embedded fallback (//go:embed in internal/apps/embedded/) when both
# the cache is empty AND the upstream is unreachable.
# the cache is empty AND the upstream is unreachable. form is the schema-
# driven form renderer used by the form-data system; it's embedded into the
# handler package directly (not the apps cascade) since it isn't subject to
# per-folder version overrides.
mkdir -p "$SCRIPT_DIR/zddc/dist/web"
cp "$SCRIPT_DIR/landing/dist/index.html" "$SCRIPT_DIR/zddc/dist/web/index.html"
cp "$SCRIPT_DIR/archive/dist/archive.html" "$SCRIPT_DIR/zddc/dist/web/archive.html"
cp "$SCRIPT_DIR/transmittal/dist/transmittal.html" "$SCRIPT_DIR/zddc/dist/web/transmittal.html"
cp "$SCRIPT_DIR/classifier/dist/classifier.html" "$SCRIPT_DIR/zddc/dist/web/classifier.html"
cp "$SCRIPT_DIR/mdedit/dist/mdedit.html" "$SCRIPT_DIR/zddc/dist/web/mdedit.html"
echo "Wrote zddc/dist/web/{index,archive,transmittal,classifier,mdedit}.html"
cp "$SCRIPT_DIR/form/dist/form.html" "$SCRIPT_DIR/zddc/dist/web/form.html"
echo "Wrote zddc/dist/web/{index,archive,transmittal,classifier,mdedit,form}.html"
# Mirror the same five HTMLs into the Go embed source dir so the next
# `go build` of zddc-server picks them up via //go:embed. Files are checked
# into git as empty placeholders; the build always overwrites them with the
# fresh dist/ output.
# Mirror the five cascade-served HTMLs into the apps embed source dir so the
# next `go build` of zddc-server picks them up via //go:embed. Files are
# checked into git as empty placeholders; the build always overwrites them
# with the fresh dist/ output.
EMBED_DIR="$SCRIPT_DIR/zddc/internal/apps/embedded"
mkdir -p "$EMBED_DIR"
cp "$SCRIPT_DIR/landing/dist/index.html" "$EMBED_DIR/index.html"
@ -165,6 +170,11 @@ cp "$SCRIPT_DIR/classifier/dist/classifier.html" "$EMBED_DIR/classifier.html
cp "$SCRIPT_DIR/mdedit/dist/mdedit.html" "$EMBED_DIR/mdedit.html"
echo "Populated $EMBED_DIR/ for //go:embed"
# The form renderer lives next to its handler (no cascade needed — it's a
# fixed renderer, not a per-folder-override tool).
cp "$SCRIPT_DIR/form/dist/form.html" "$SCRIPT_DIR/zddc/internal/handler/form.html"
echo "Populated zddc/internal/handler/form.html for //go:embed"
# Assemble the embedded versions manifest from the per-tool .label sidecars
# written by shared/build-lib.sh's compute_build_label. The Go side reads
# this via //go:embed in internal/apps/versions.go and surfaces it in
@ -172,7 +182,7 @@ echo "Populated $EMBED_DIR/ for //go:embed"
VERSIONS_FILE="$EMBED_DIR/versions.txt"
{
echo "# Generated by build.sh — do not edit. One <app>=<build label> per line."
for _tool in archive transmittal classifier mdedit landing; do
for _tool in archive transmittal classifier mdedit landing form; do
_label_file="$BUILD_LABELS_DIR/${_tool}.label"
if [ -f "$_label_file" ]; then
_label=$(cat "$_label_file")
@ -691,7 +701,7 @@ else
echo "Version: v$RELEASE_VERSION"
echo ""
echo "Tags created locally on main (push when ready):"
for _t in archive transmittal classifier mdedit landing zddc-server; do
for _t in archive transmittal classifier mdedit landing form zddc-server; do
echo " ${_t}-v${RELEASE_VERSION}"
done
echo " git push origin main && git push origin --tags"

78
form/build.sh Executable file
View file

@ -0,0 +1,78 @@
#!/bin/sh
set -eu
root_dir=$(cd "$(dirname "$0")" && pwd)
. "$root_dir/../shared/build-lib.sh"
src_html="$root_dir/template.html"
output_dir="$root_dir/dist"
output_html="$output_dir/form.html"
mkdir -p "$output_dir"
ensure_exists "$src_html"
css_temp=$(mktemp)
js_raw=$(mktemp)
js_temp=$(mktemp)
cleanup() { rm -f "$css_temp" "$js_raw" "$js_temp"; }
trap cleanup EXIT
concat_files \
"../shared/base.css" \
"css/form.css" \
> "$css_temp"
concat_files \
"../shared/theme.js" \
"js/app.js" \
"js/context.js" \
"js/util.js" \
"js/widgets.js" \
"js/object.js" \
"js/array.js" \
"js/render.js" \
"js/serialize.js" \
"js/errors.js" \
"js/post.js" \
"js/main.js" \
> "$js_raw"
escape_js_close_tags "$js_raw" "$js_temp"
compute_build_label "form" "${1:-}" "${2:-}"
awk -v css_file="$css_temp" -v js_file="$js_temp" -v build_label="$build_label" -v is_red="$is_red" -v favicon_uri="$favicon_data_uri" '
/\{\{CSS_PLACEHOLDER\}\}/ {
while ((getline line < css_file) > 0) print line
close(css_file)
next
}
/\{\{JS_PLACEHOLDER\}\}/ {
while ((getline line < js_file) > 0) print line
close(js_file)
next
}
/\{\{BUILD_LABEL\}\}/ {
if (is_red == "1") {
gsub(/\{\{BUILD_LABEL\}\}/, "<span style=\"color:red;font-weight:bold\">" build_label "</span>")
} else {
gsub(/\{\{BUILD_LABEL\}\}/, build_label)
}
print
next
}
/\{\{FAVICON\}\}/ {
gsub(/\{\{FAVICON\}\}/, favicon_uri)
print
next
}
/<script src="https?:\/\// { next }
/<link rel="stylesheet" href="https?:\/\// { next }
{ print }
' "$src_html" > "$output_html"
echo "Wrote $output_html"
if [ "$is_release" = "1" ]; then
promote_release "form"
fi

200
form/css/form.css Normal file
View file

@ -0,0 +1,200 @@
/* form/ ZDDC generic form renderer.
Pulls theme tokens from shared/base.css; only adds form-specific layout. */
.form-main {
max-width: 800px;
margin: 1.5rem auto;
padding: 0 1rem 4rem;
}
.form-status {
padding: 0.75rem 1rem;
margin-bottom: 1rem;
border-radius: 4px;
border: 1px solid var(--color-border);
}
.form-status.is-error {
background: var(--color-bg-alt);
border-color: #c43;
color: #c43;
}
.form-status.is-success {
background: var(--color-bg-alt);
border-color: #283;
color: #283;
}
.form-root {
display: flex;
flex-direction: column;
gap: 1rem;
}
.form-field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.form-field__label {
font-weight: 600;
font-size: 0.95rem;
}
.form-field__label .required-mark {
color: #c43;
margin-left: 0.15rem;
}
.form-field__description {
font-size: 0.85rem;
color: var(--color-text-muted, #666);
}
.form-field__error {
font-size: 0.85rem;
color: #c43;
margin-top: 0.15rem;
}
.form-field__help {
font-size: 0.8rem;
color: var(--color-text-muted, #666);
font-style: italic;
}
.form-field__input,
.form-field__textarea,
.form-field__select {
padding: 0.5rem 0.65rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-bg, #fff);
color: var(--color-text, #111);
font: inherit;
width: 100%;
box-sizing: border-box;
}
.form-field__textarea {
min-height: 5em;
resize: vertical;
}
.form-field__input:focus,
.form-field__textarea:focus,
.form-field__select:focus {
outline: 2px solid var(--color-primary, #1e3a5f);
outline-offset: -1px;
}
.form-field--invalid .form-field__input,
.form-field--invalid .form-field__textarea,
.form-field--invalid .form-field__select {
border-color: #c43;
}
.form-field__radio-group,
.form-field__checkbox-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.form-field__radio-group label,
.form-field__checkbox-group label {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 400;
cursor: pointer;
}
.form-fieldset {
border: 1px solid var(--color-border);
border-radius: 4px;
padding: 0.75rem 1rem 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.form-fieldset__legend {
font-weight: 600;
padding: 0 0.4rem;
}
.form-array {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-array__row {
display: flex;
gap: 0.5rem;
align-items: flex-start;
border: 1px solid var(--color-border);
border-radius: 4px;
padding: 0.5rem;
background: var(--color-bg-alt, #f6f6f8);
}
.form-array__row-body {
flex: 1 1 auto;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-array__row-actions {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.form-array__add {
align-self: flex-start;
}
.form-actions {
margin-top: 1.5rem;
display: flex;
gap: 0.5rem;
}
.btn {
padding: 0.5rem 1rem;
border-radius: 4px;
border: 1px solid var(--color-border);
background: var(--color-bg, #fff);
color: var(--color-text, #111);
cursor: pointer;
font: inherit;
}
.btn:hover {
background: var(--color-bg-alt, #f6f6f8);
}
.btn-primary {
background: var(--color-primary, #1e3a5f);
color: #fff;
border-color: var(--color-primary, #1e3a5f);
}
.btn-primary:hover {
filter: brightness(1.1);
}
.btn-small {
padding: 0.2rem 0.5rem;
font-size: 0.85rem;
}
.btn[disabled] {
opacity: 0.5;
cursor: not-allowed;
}

11
form/js/app.js Normal file
View file

@ -0,0 +1,11 @@
(function (global) {
'use strict';
if (global.formApp) {
return;
}
global.formApp = {
context: null,
rootWidget: null,
modules: {}
};
})(window);

127
form/js/array.js Normal file
View file

@ -0,0 +1,127 @@
(function (app) {
'use strict';
const u = app.modules.util;
function makeArray(schema, ui, path, value, options) {
const wrap = u.h('div', { className: 'form-field form-array' });
const label = (ui && ui['ui:title']) || schema.title || options.fieldName || '';
if (label) {
const lbl = u.h('label', { className: 'form-field__label' });
lbl.appendChild(document.createTextNode(label));
if (options.required) {
lbl.appendChild(u.h('span', { className: 'required-mark' }, '*'));
}
wrap.appendChild(lbl);
}
if (schema.description) {
wrap.appendChild(u.h('div', { className: 'form-field__description' }, schema.description));
}
const errEl = u.h('div', { className: 'form-field__error', hidden: true });
wrap.appendChild(errEl);
const rowsEl = u.h('div', { className: 'form-array__rows' });
wrap.appendChild(rowsEl);
const itemSchema = schema.items || { type: 'string' };
const itemUi = (ui && ui.items) || {};
const uiOpts = (ui && ui['ui:options']) || {};
const addable = uiOpts.addable !== false;
const removable = uiOpts.removable !== false;
const rows = [];
function repath() {
for (let i = 0; i < rows.length; i++) {
rows[i].widget.path = u.ptrPush(path, String(i));
}
}
function addRow(rowValue) {
const idx = rows.length;
const rowPath = u.ptrPush(path, String(idx));
const childWidget = app.modules.render.create(itemSchema, itemUi, rowPath, rowValue, {
fieldName: '',
required: false
});
const rowEl = u.h('div', { className: 'form-array__row' });
const body = u.h('div', { className: 'form-array__row-body' });
body.appendChild(childWidget.el);
rowEl.appendChild(body);
if (removable) {
const actions = u.h('div', { className: 'form-array__row-actions' });
const removeBtn = u.h('button', {
type: 'button',
className: 'btn btn-small',
title: 'Remove this row',
onClick: function () { removeRow(rowEl); }
}, '×');
actions.appendChild(removeBtn);
rowEl.appendChild(actions);
}
rows.push({ widget: childWidget, rowEl: rowEl });
rowsEl.appendChild(rowEl);
}
function removeRow(targetEl) {
for (let i = 0; i < rows.length; i++) {
if (rows[i].rowEl === targetEl) {
rows.splice(i, 1);
targetEl.remove();
repath();
return;
}
}
}
const initial = Array.isArray(value) ? value : [];
for (let i = 0; i < initial.length; i++) {
addRow(initial[i]);
}
if (addable) {
const addBtn = u.h('button', {
type: 'button',
className: 'btn btn-small form-array__add',
onClick: function () { addRow(undefined); }
}, '+ Add');
wrap.appendChild(addBtn);
}
return {
el: wrap,
path: path,
type: 'array',
read: function () {
const out = [];
for (let i = 0; i < rows.length; i++) {
const v = rows[i].widget.read();
if (v !== undefined) {
out.push(v);
}
}
return out;
},
setError: function (msg) {
errEl.textContent = msg;
errEl.hidden = false;
},
clearErrors: function () {
errEl.textContent = '';
errEl.hidden = true;
for (let i = 0; i < rows.length; i++) {
rows[i].widget.clearErrors();
}
},
child: function (idxStr) {
const i = parseInt(idxStr, 10);
return (rows[i] && rows[i].widget) || null;
}
};
}
app.modules.array = { makeArray: makeArray };
})(window.formApp);

18
form/js/context.js Normal file
View file

@ -0,0 +1,18 @@
(function (app) {
'use strict';
function load() {
const el = document.getElementById('form-context');
if (!el) {
return {};
}
try {
return JSON.parse(el.textContent || '{}');
} catch (err) {
console.error('[form] failed to parse #form-context', err);
return {};
}
}
app.modules.context = { load };
})(window.formApp);

41
form/js/errors.js Normal file
View file

@ -0,0 +1,41 @@
(function (app) {
'use strict';
const u = app.modules.util;
function findByPath(root, path) {
if (!path || path === '') {
return root;
}
const segs = u.ptrParse(path);
let cur = root;
for (let i = 0; i < segs.length; i++) {
if (!cur || typeof cur.child !== 'function') {
return null;
}
cur = cur.child(segs[i]);
}
return cur || null;
}
function apply(errors) {
if (!errors || !errors.length || !app.rootWidget) {
return;
}
for (let i = 0; i < errors.length; i++) {
const err = errors[i];
const widget = findByPath(app.rootWidget, err.path || '');
if (widget && typeof widget.setError === 'function') {
widget.setError(err.message || 'Invalid value');
}
}
}
function clear() {
if (app.rootWidget) {
app.rootWidget.clearErrors();
}
}
app.modules.errors = { apply: apply, clear: clear };
})(window.formApp);

41
form/js/main.js Normal file
View file

@ -0,0 +1,41 @@
(function (app) {
'use strict';
function boot() {
app.context = app.modules.context.load();
if (app.context.title) {
const t = document.getElementById('form-title');
if (t) {
t.textContent = app.context.title;
}
document.title = app.context.title + ' — ZDDC';
}
const root = document.getElementById('form-root');
if (root && app.context.schema) {
app.rootWidget = app.modules.render.mount(
root,
app.context.schema,
app.context.ui || {},
app.context.data
);
}
if (app.context.errors && app.context.errors.length) {
app.modules.errors.apply(app.context.errors);
app.modules.post.showStatus('Please correct the errors below.', 'error');
}
const submitBtn = document.getElementById('submit-btn');
if (submitBtn) {
submitBtn.addEventListener('click', app.modules.post.submit);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot, { once: true });
} else {
boot();
}
})(window.formApp);

109
form/js/object.js Normal file
View file

@ -0,0 +1,109 @@
(function (app) {
'use strict';
const u = app.modules.util;
function makeObject(schema, ui, path, value, options) {
const fs = u.h('fieldset', { className: 'form-fieldset' });
const label = (ui && ui['ui:title']) || schema.title || options.fieldName;
if (label) {
fs.appendChild(u.h('legend', { className: 'form-fieldset__legend' }, label));
}
if (schema.description) {
fs.appendChild(u.h('div', { className: 'form-field__description' }, schema.description));
}
const errEl = u.h('div', { className: 'form-field__error', hidden: true });
fs.appendChild(errEl);
const props = schema.properties || {};
const requiredSet = {};
(schema.required || []).forEach(function (n) { requiredSet[n] = true; });
// Resolve render order: ui:order first (with '*' as "everything else"),
// then fall back to declaration order.
const declared = Object.keys(props);
const uiOrder = (ui && ui['ui:order']) || null;
const ordered = [];
const seen = {};
if (uiOrder && Array.isArray(uiOrder)) {
for (let i = 0; i < uiOrder.length; i++) {
const name = uiOrder[i];
if (name === '*') {
for (let j = 0; j < declared.length; j++) {
const dn = declared[j];
if (!seen[dn] && uiOrder.indexOf(dn) < 0) {
ordered.push(dn);
seen[dn] = true;
}
}
} else if (props[name] && !seen[name]) {
ordered.push(name);
seen[name] = true;
}
}
// Append anything declared but not mentioned in ui:order (and no '*' was used).
for (let j = 0; j < declared.length; j++) {
if (!seen[declared[j]]) {
ordered.push(declared[j]);
seen[declared[j]] = true;
}
}
} else {
for (let j = 0; j < declared.length; j++) {
ordered.push(declared[j]);
}
}
const children = {};
const dataObj = (value && typeof value === 'object' && !Array.isArray(value)) ? value : {};
for (let i = 0; i < ordered.length; i++) {
const name = ordered[i];
const childSchema = props[name];
const childUi = (ui && ui[name]) || {};
const childPath = u.ptrPush(path, name);
const childValue = dataObj[name];
const childWidget = app.modules.render.create(childSchema, childUi, childPath, childValue, {
fieldName: u.humanize(name),
required: !!requiredSet[name]
});
children[name] = childWidget;
fs.appendChild(childWidget.el);
}
return {
el: fs,
path: path,
type: 'object',
read: function () {
const out = {};
const keys = Object.keys(children);
for (let i = 0; i < keys.length; i++) {
const k = keys[i];
const v = children[k].read();
if (v !== undefined) {
out[k] = v;
}
}
return out;
},
setError: function (msg) {
errEl.textContent = msg;
errEl.hidden = false;
},
clearErrors: function () {
errEl.textContent = '';
errEl.hidden = true;
const keys = Object.keys(children);
for (let i = 0; i < keys.length; i++) {
children[keys[i]].clearErrors();
}
},
child: function (name) {
return children[name] || null;
}
};
}
app.modules.object = { makeObject: makeObject };
})(window.formApp);

76
form/js/post.js Normal file
View file

@ -0,0 +1,76 @@
(function (app) {
'use strict';
function showStatus(msg, kind) {
const el = document.getElementById('form-status');
if (!el) {
return;
}
el.textContent = msg || '';
el.hidden = !msg;
el.classList.remove('is-error', 'is-success');
if (kind === 'error') {
el.classList.add('is-error');
} else if (kind === 'success') {
el.classList.add('is-success');
}
}
async function submit() {
if (!app.context || !app.context.submitUrl) {
showStatus('No submit URL configured.', 'error');
return;
}
const data = app.modules.serialize.read();
app.modules.errors.clear();
showStatus('', '');
const submitBtn = document.getElementById('submit-btn');
if (submitBtn) {
submitBtn.disabled = true;
}
try {
const res = await fetch(app.context.submitUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (res.status === 200) {
showStatus('Saved.', 'success');
} else if (res.status === 201) {
const loc = res.headers.get('Location');
showStatus('Submitted.', 'success');
if (loc) {
// Capability URL for the new submission. Append .html to land
// on the form-rendered view of the just-saved data.
setTimeout(function () {
window.location.href = loc + '.html';
}, 400);
}
} else if (res.status === 422) {
let body = {};
try { body = await res.json(); } catch (e) { /* ignore */ }
app.modules.errors.apply(body.errors || []);
showStatus('Please correct the errors below.', 'error');
} else if (res.status === 403) {
showStatus('You are not allowed to submit here.', 'error');
} else if (res.status === 409) {
showStatus('A submission with this filename already exists.', 'error');
} else {
let detail = '';
try { detail = await res.text(); } catch (e) { /* ignore */ }
showStatus('Submission failed (' + res.status + ')' + (detail ? ': ' + detail : ''), 'error');
}
} catch (err) {
showStatus('Network error: ' + (err && err.message ? err.message : err), 'error');
} finally {
if (submitBtn) {
submitBtn.disabled = false;
}
}
}
app.modules.post = { submit: submit, showStatus: showStatus };
})(window.formApp);

28
form/js/render.js Normal file
View file

@ -0,0 +1,28 @@
(function (app) {
'use strict';
function create(schema, ui, path, value, options) {
options = options || {};
if (!schema) {
return app.modules.widgets.makePrimitive({ type: 'string' }, ui, path, value, options);
}
const t = schema.type;
if (t === 'object') {
return app.modules.object.makeObject(schema, ui, path, value, options);
}
if (t === 'array') {
return app.modules.array.makeArray(schema, ui, path, value, options);
}
// Anything else (string, number, integer, boolean, enum) falls through
// to the primitive widget which dispatches on schema.type / schema.enum.
return app.modules.widgets.makePrimitive(schema, ui, path, value, options);
}
function mount(rootEl, schema, ui, data) {
const widget = create(schema, ui, '', data, { fieldName: '', required: false });
rootEl.appendChild(widget.el);
return widget;
}
app.modules.render = { create: create, mount: mount };
})(window.formApp);

12
form/js/serialize.js Normal file
View file

@ -0,0 +1,12 @@
(function (app) {
'use strict';
function read() {
if (!app.rootWidget) {
return null;
}
return app.rootWidget.read();
}
app.modules.serialize = { read: read };
})(window.formApp);

72
form/js/util.js Normal file
View file

@ -0,0 +1,72 @@
(function (app) {
'use strict';
const util = {};
util.h = function (tag, attrs) {
const el = document.createElement(tag);
if (attrs) {
for (const k of Object.keys(attrs)) {
const v = attrs[k];
if (v == null || v === false) {
continue;
}
if (k === 'className') {
el.className = v;
} else if (k.length > 2 && k.slice(0, 2) === 'on' && typeof v === 'function') {
el.addEventListener(k.slice(2).toLowerCase(), v);
} else if (v === true) {
el.setAttribute(k, '');
} else {
el.setAttribute(k, v);
}
}
}
for (let i = 2; i < arguments.length; i++) {
const c = arguments[i];
if (c == null || c === false) {
continue;
}
if (typeof c === 'string' || typeof c === 'number') {
el.appendChild(document.createTextNode(String(c)));
} else {
el.appendChild(c);
}
}
return el;
};
// JSON Pointer (RFC 6901): encode one segment.
util.ptrEnc = function (s) {
return String(s).replace(/~/g, '~0').replace(/\//g, '~1');
};
util.ptrPush = function (path, segment) {
return path + '/' + util.ptrEnc(segment);
};
util.ptrParse = function (path) {
if (!path) {
return [];
}
return path.split('/').slice(1).map(function (s) {
return s.replace(/~1/g, '/').replace(/~0/g, '~');
});
};
let idCounter = 0;
util.uid = function (prefix) {
idCounter += 1;
return (prefix || 'f') + '-' + idCounter;
};
// Turn camelCase / snake_case into a Title Case string for default labels.
util.humanize = function (name) {
return String(name)
.replace(/_/g, ' ')
.replace(/([a-z])([A-Z])/g, '$1 $2')
.replace(/^./, function (c) { return c.toUpperCase(); });
};
app.modules.util = util;
})(window.formApp);

226
form/js/widgets.js Normal file
View file

@ -0,0 +1,226 @@
(function (app) {
'use strict';
const u = app.modules.util;
// Build the standard label / description / input / help / error scaffold
// shared by all primitive widgets. Returns { wrap, errEl }.
function fieldContainer(opts) {
const wrap = u.h('div', { className: 'form-field' });
if (opts.label) {
const lbl = u.h('label', { className: 'form-field__label', for: opts.id });
lbl.appendChild(document.createTextNode(opts.label));
if (opts.required) {
lbl.appendChild(u.h('span', { className: 'required-mark' }, '*'));
}
wrap.appendChild(lbl);
}
if (opts.description) {
wrap.appendChild(u.h('div', { className: 'form-field__description' }, opts.description));
}
wrap.appendChild(opts.input);
if (opts.help) {
wrap.appendChild(u.h('div', { className: 'form-field__help' }, opts.help));
}
const errEl = u.h('div', { className: 'form-field__error', hidden: true });
wrap.appendChild(errEl);
return { wrap: wrap, errEl: errEl };
}
function coerceEnum(rawValue, options) {
for (let i = 0; i < options.length; i++) {
if (String(options[i]) === rawValue) {
return options[i];
}
}
return rawValue;
}
function makePrimitive(schema, ui, path, value, options) {
const id = u.uid('w');
const required = !!options.required;
const label = (ui && ui['ui:title']) || schema.title || options.fieldName || '';
const description = (ui && ui['ui:description']) || schema.description || '';
const help = (ui && ui['ui:help']) || '';
const placeholder = (ui && ui['ui:placeholder']) || '';
const widget = (ui && ui['ui:widget']) || '';
const readonly = !!(ui && ui['ui:readonly']);
const autofocus = !!(ui && ui['ui:autofocus']);
let input;
let read;
const t = schema.type;
if (t === 'boolean') {
// Render boolean as a single checkbox with an inline label, suppressing
// the standard label-above layout for cleaner UX.
const cb = u.h('input', { type: 'checkbox', id: id });
if (value === true) {
cb.checked = true;
}
if (readonly) {
cb.disabled = true;
}
const wrap = u.h('div', { className: 'form-field form-field--boolean' });
const inlineLabel = u.h('label', { for: id, className: 'form-field__checkbox-inline' });
inlineLabel.appendChild(cb);
inlineLabel.appendChild(document.createTextNode(' '));
inlineLabel.appendChild(document.createTextNode(label || ''));
if (required) {
inlineLabel.appendChild(u.h('span', { className: 'required-mark' }, '*'));
}
wrap.appendChild(inlineLabel);
if (description) {
wrap.appendChild(u.h('div', { className: 'form-field__description' }, description));
}
if (help) {
wrap.appendChild(u.h('div', { className: 'form-field__help' }, help));
}
const errEl = u.h('div', { className: 'form-field__error', hidden: true });
wrap.appendChild(errEl);
return widgetObject(wrap, errEl, path, function () {
return cb.checked;
});
}
if (Array.isArray(schema.enum)) {
const opts = schema.enum;
if (widget === 'radio') {
input = u.h('div', { className: 'form-field__radio-group' });
opts.forEach(function (opt, idx) {
const radioId = id + '-' + idx;
const radio = u.h('input', { type: 'radio', name: id, id: radioId, value: String(opt) });
if (value === opt) {
radio.checked = true;
}
if (readonly) {
radio.disabled = true;
}
const lbl = u.h('label', { for: radioId });
lbl.appendChild(radio);
lbl.appendChild(document.createTextNode(' ' + String(opt)));
input.appendChild(lbl);
});
read = function () {
const checked = input.querySelector('input[type="radio"]:checked');
return checked ? coerceEnum(checked.value, opts) : undefined;
};
} else {
input = u.h('select', { id: id, className: 'form-field__select' });
if (!required) {
input.appendChild(u.h('option', { value: '' }, '— select —'));
}
opts.forEach(function (opt) {
const o = u.h('option', { value: String(opt) }, String(opt));
if (value === opt) {
o.selected = true;
}
input.appendChild(o);
});
if (readonly) {
input.disabled = true;
}
read = function () {
if (input.value === '') {
return undefined;
}
return coerceEnum(input.value, opts);
};
}
} else if (t === 'number' || t === 'integer') {
input = u.h('input', {
type: 'number',
id: id,
className: 'form-field__input',
step: t === 'integer' ? '1' : 'any'
});
if (placeholder) {
input.placeholder = placeholder;
}
if (value != null) {
input.value = String(value);
}
if (readonly) {
input.readOnly = true;
}
if (autofocus) {
input.autofocus = true;
}
read = function () {
const v = input.value.trim();
if (v === '') {
return undefined;
}
const n = Number(v);
// If the user typed something non-numeric, return the raw string and
// let server validation produce a friendly error.
return Number.isFinite(n) ? n : v;
};
} else {
// Default: string-shaped input.
const fmt = schema.format;
if (widget === 'textarea') {
input = u.h('textarea', { id: id, className: 'form-field__textarea' });
} else {
let inputType = 'text';
if (fmt === 'date') {
inputType = 'date';
} else if (fmt === 'email') {
inputType = 'email';
}
input = u.h('input', { type: inputType, id: id, className: 'form-field__input' });
}
if (placeholder) {
input.placeholder = placeholder;
}
if (value != null) {
input.value = String(value);
}
if (readonly) {
input.readOnly = true;
}
if (autofocus) {
input.autofocus = true;
}
read = function () {
return input.value === '' ? undefined : input.value;
};
}
const built = fieldContainer({
id: id,
label: label,
description: description,
help: help,
required: required,
input: input
});
return widgetObject(built.wrap, built.errEl, path, read);
}
// Common widget shape used by both primitive and the wrapper above.
function widgetObject(wrapEl, errEl, path, read) {
return {
el: wrapEl,
path: path,
type: 'primitive',
read: read,
setError: function (msg) {
errEl.textContent = msg;
errEl.hidden = false;
wrapEl.classList.add('form-field--invalid');
},
clearErrors: function () {
errEl.textContent = '';
errEl.hidden = true;
wrapEl.classList.remove('form-field--invalid');
},
child: function () { return null; }
};
}
app.modules.widgets = { makePrimitive: makePrimitive };
})(window.formApp);

View file

@ -0,0 +1,77 @@
# Example form spec — daily safety check-in.
#
# Drop this file at any path under ZDDC_ROOT where users have write access via
# the .zddc cascade (e.g. /Working/Daily/safety.form.yaml). zddc-server then
# serves:
# GET <path>/safety.form.html empty form
# POST <path>/safety.form.html creates <path>/safety/<date>-<email>.yaml
# GET <path>/safety/<id>.yaml.html form pre-filled from <id>.yaml
# POST <path>/safety/<id>.yaml.html updates <id>.yaml
#
# Submissions are plain YAML files — readable in any editor, browsable via the
# existing archive tool. v0 round-trip is form-as-truth: comments in submissions
# are not preserved across edits. (File-as-truth mode for hand-edited files
# like .zddc itself is a v1 feature; see plan.)
title: Daily Safety Check-In
description: Brief end-of-shift safety report. Required daily on active sites.
schema:
type: object
required: [date, location, hazardsObserved]
additionalProperties: false
properties:
date:
type: string
format: date
title: Date
description: Date of the shift being reported.
location:
type: string
enum: [Site A, Site B, Site C, Office]
title: Location
hazardsObserved:
type: boolean
title: Hazards observed?
description: Check if anything noteworthy happened during this shift.
hazards:
type: array
title: Hazards
description: List each hazard observed. Leave empty if nothing to report.
items:
type: object
required: [kind, severity]
additionalProperties: false
properties:
kind:
type: string
title: Kind
description: Short label, e.g. "Loose handrail".
minLength: 1
maxLength: 80
severity:
type: integer
title: Severity
description: 1 = minor, 5 = stop-work.
minimum: 1
maximum: 5
notes:
type: string
title: Notes
description: Anything else worth recording.
additionalNotes:
type: string
title: Additional notes
ui:
date:
ui:autofocus: true
location:
ui:widget: radio
hazards:
ui:options:
addable: true
removable: true
additionalNotes:
ui:widget: textarea
ui:placeholder: Anything else worth flagging…

56
form/template.html Normal file
View file

@ -0,0 +1,56 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ZDDC Form</title>
<link rel="icon" type="image/svg+xml" href="{{FAVICON}}">
<style>
{{CSS_PLACEHOLDER}}
</style>
</head>
<body>
<header class="app-header">
<div class="header-left">
<svg class="app-header__logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" aria-hidden="true">
<rect width="64" height="64" rx="12" fill="#1e3a5f"/>
<g fill="#fff">
<rect x="14" y="18" width="36" height="7"/>
<polygon points="43,25 50,25 21,43 14,43"/>
<rect x="14" y="43" width="36" height="7"/>
</g>
</svg>
<span class="app-header__title" id="form-title">ZDDC Form</span>
<span class="build-timestamp">{{BUILD_LABEL}}</span>
</div>
<div class="header-right">
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)"></button>
</div>
</header>
<main class="form-main">
<div id="form-status" class="form-status" hidden></div>
<form id="form-root" class="form-root" novalidate></form>
<div class="form-actions">
<button type="button" id="submit-btn" class="btn btn-primary">Submit</button>
</div>
</main>
<!--
Server injects the form context here on render. Shape:
{
"title": "Optional page title override",
"schema": { JSON Schema 2020-12 subset },
"ui": { RJSF-style ui:* hints, recursively keyed },
"data": { existing submission data, or null for empty form },
"submitUrl": "/path/to/submit",
"errors": [{path, message}] // only populated on POST→422 re-render
}
-->
<script id="form-context" type="application/json">{}</script>
<script>
{{JS_PLACEHOLDER}}
</script>
</body>
</html>

View file

@ -1774,7 +1774,7 @@ body.help-open .app-header {
</svg>
<div class="header-title-group">
<span class="app-header__title">ZDDC Markdown</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.9-alpha · 2026-05-02 14:02:00 · 76e1e78-dirty</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.9-alpha · 2026-05-03 01:04:52 · c099676-dirty</span></span>
</div>
<button id="select-directory" class="btn btn-primary" title="Select a Directory">Select Directory</button>
</div>

View file

@ -51,6 +51,10 @@ export default defineConfig({
name: 'zddc',
testMatch: 'zddc.spec.js',
},
{
name: 'form-safety',
testMatch: 'form-safety.spec.js',
},
{
name: 'zddc-filter',
testMatch: 'zddc-filter.spec.js',
@ -65,3 +69,4 @@ export default defineConfig({
},
],
});

View file

@ -222,7 +222,7 @@ _emit_build_label_sidecar() {
# Tools that participate in the lockstep release. Source of truth — used
# by helpers that enumerate "all release artifacts" (matrix render,
# coordinated next-stable, channel-link verifier).
ZDDC_RELEASE_TOOLS="archive transmittal classifier mdedit landing zddc-server"
ZDDC_RELEASE_TOOLS="archive transmittal classifier mdedit landing form zddc-server"
# Compute the next-stable target for a single tool — patch-bump of its own
# latest <tool>-vX.Y.Z tag. Used by compute_build_label so a tool's
@ -653,7 +653,7 @@ verify_channel_links() {
_missing=0
_verified=0
for _t in archive transmittal classifier mdedit landing; do
for _t in archive transmittal classifier mdedit landing form; do
for _ch in stable beta alpha; do
_f="$_rdir/${_t}_${_ch}.html"
if [ -e "$_f" ]; then

250
tests/form-safety.spec.js Normal file
View file

@ -0,0 +1,250 @@
import { test, expect } from '@playwright/test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
const HTML_PATH = path.resolve('form/dist/form.html');
const HTML_RAW = fs.readFileSync(HTML_PATH, 'utf8');
const SAFETY_SCHEMA = {
type: 'object',
required: ['date', 'location'],
additionalProperties: false,
properties: {
date: { type: 'string', format: 'date' },
location: { type: 'string', enum: ['Site A', 'Site B', 'Site C'] },
hazards: {
type: 'array',
items: {
type: 'object',
required: ['kind', 'severity'],
properties: {
kind: { type: 'string' },
severity: { type: 'integer', minimum: 1, maximum: 5 },
notes: { type: 'string' },
},
},
},
additionalNotes: { type: 'string' },
},
};
const SAFETY_UI = {
location: { 'ui:widget': 'radio' },
hazards: { 'ui:options': { addable: true, removable: true } },
additionalNotes: { 'ui:widget': 'textarea' },
};
// Inject a complete form-context into the page before form bootstraps.
// Writes a patched copy of form.html to a temp file and navigates via
// file:// — page.setContent's about:blank origin doesn't expose
// localStorage, which trips up shared/theme.js. page.route can't intercept
// file://, so this is the cleanest path. The form is fully self-contained,
// so the temp file works without relative-resource resolution.
async function loadFormWithContext(page, context) {
const ctxJson = JSON.stringify(context).replace(/<\//g, '<\\/');
const replacement = `<script id="form-context" type="application/json">${ctxJson}</script>`;
const patched = HTML_RAW.replace(
/<script id="form-context" type="application\/json">[\s\S]*?<\/script>/,
replacement
);
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'form-spec-'));
const tmpPath = path.join(tmpDir, 'form.html');
fs.writeFileSync(tmpPath, patched);
await page.goto(`file://${tmpPath}`, { waitUntil: 'load' });
}
test.describe('form/ — safety check-in renderer', () => {
test('renders all field types from the schema', async ({ page }) => {
page.on('console', msg => {
if (msg.type() === 'error') console.log('[browser-error]', msg.text());
});
page.on('pageerror', e => console.log('[pageerror]', e.message));
await loadFormWithContext(page, {
title: 'Safety Check-In',
schema: SAFETY_SCHEMA,
ui: SAFETY_UI,
data: null,
submitUrl: '/test/safety.form.html',
});
// Wait for the renderer to populate the form (#form-root has display:flex
// but is reported as "hidden" by Playwright when it has zero children).
await page.waitForFunction(
() => document.getElementById('form-root') && document.getElementById('form-root').children.length > 0,
null,
{ timeout: 5000 },
);
await expect(page.locator('#form-root input[type="date"]')).toHaveCount(1);
const radios = page.locator('#form-root input[type="radio"]');
await expect(radios).toHaveCount(3); // Site A / B / C
await expect(page.locator('#form-root textarea')).toHaveCount(1);
await expect(page.locator('#form-root .form-array__add')).toHaveCount(1);
await expect(page.locator('#form-title')).toContainText('Safety Check-In');
});
test('add/remove hazard rows works', async ({ page }) => {
await loadFormWithContext(page, {
schema: SAFETY_SCHEMA,
ui: SAFETY_UI,
data: null,
submitUrl: '/test/safety.form.html',
});
await page.waitForSelector('#form-root');
await expect(page.locator('.form-array__row')).toHaveCount(0);
await page.locator('.form-array__add').click();
await expect(page.locator('.form-array__row')).toHaveCount(1);
await page.locator('.form-array__add').click();
await expect(page.locator('.form-array__row')).toHaveCount(2);
// Remove the first row.
await page.locator('.form-array__row').first().locator('button.btn-small').click();
await expect(page.locator('.form-array__row')).toHaveCount(1);
});
test('valid submission posts JSON matching schema shape', async ({ page }) => {
// Install an in-page fetch mock (page.route doesn't intercept file://).
await page.addInitScript(() => {
window.__captured = [];
window.__mockFetchResponse = {
status: 201,
headers: new Headers({ Location: '/test/safety/2026-05-01-casey.yaml', 'Content-Type': 'application/json' }),
bodyText: '{"location":"/test/safety/2026-05-01-casey.yaml"}',
};
const origFetch = window.fetch;
window.fetch = async function (input, init) {
const url = typeof input === 'string' ? input : input.url;
const method = (init && init.method) || 'GET';
if (method === 'POST') {
let body = init && init.body;
try { body = JSON.parse(body); } catch (e) { /* ignore */ }
window.__captured.push({ url, method, body });
const r = window.__mockFetchResponse;
return new Response(r.bodyText || '', { status: r.status, headers: r.headers });
}
return origFetch(input, init);
};
});
await loadFormWithContext(page, {
schema: SAFETY_SCHEMA,
ui: SAFETY_UI,
data: null,
submitUrl: '/test/safety.form.html',
});
await page.waitForSelector('#form-root');
// Fill fields.
await page.locator('#form-root input[type="date"]').fill('2026-05-01');
await page.locator('#form-root input[type="radio"][value="Site B"]').check();
await page.locator('.form-array__add').click();
await page.locator('.form-array__row input[type="text"]').first().fill('Loose handrail');
await page.locator('.form-array__row input[type="number"]').fill('3');
await page.locator('#form-root textarea').fill('Fixed during shift.');
// Submit and prevent navigation away (the form redirects on 201).
await page.evaluate(() => {
// Pin window.location.href to no-op so the test doesn't navigate.
const stub = () => {};
Object.defineProperty(window, 'location', {
value: new Proxy(window.location, {
set: () => true,
get: (target, prop) => {
if (prop === 'href') return target.href;
return target[prop];
},
}),
writable: true,
configurable: true,
});
void stub;
}).catch(() => { /* best-effort; not all browsers permit overriding location */ });
// Stub setTimeout so the post-201 navigation doesn't fire during the test.
await page.evaluate(() => {
const origSetTimeout = window.setTimeout;
window.setTimeout = function (fn, ms) {
if (ms === 400) return 0; // suppress redirect timer
return origSetTimeout(fn, ms);
};
});
await page.locator('#submit-btn').click();
await page.waitForFunction(() => window.__captured && window.__captured.length > 0, null, { timeout: 5000 });
const captured = await page.evaluate(() => window.__captured);
expect(captured.length).toBeGreaterThan(0);
const body = captured[0].body;
expect(body.date).toBe('2026-05-01');
expect(body.location).toBe('Site B');
expect(Array.isArray(body.hazards)).toBe(true);
expect(body.hazards.length).toBe(1);
expect(body.hazards[0].kind).toBe('Loose handrail');
expect(body.hazards[0].severity).toBe(3);
expect(body.additionalNotes).toBe('Fixed during shift.');
});
test('server validation errors display per-field', async ({ page }) => {
await page.addInitScript(() => {
window.fetch = async function (input, init) {
if (init && init.method === 'POST') {
return new Response(JSON.stringify({
errors: [
{ path: '/location', message: 'required' },
{ path: '/hazards/0/severity', message: 'must be at most 5' },
],
}), { status: 422, headers: { 'Content-Type': 'application/json' } });
}
throw new Error('unexpected fetch');
};
});
await loadFormWithContext(page, {
schema: SAFETY_SCHEMA,
ui: SAFETY_UI,
data: null,
submitUrl: '/test/safety.form.html',
});
await page.waitForSelector('#form-root');
await page.locator('#form-root input[type="date"]').fill('2026-05-01');
await page.locator('.form-array__add').click();
await page.locator('.form-array__row input[type="text"]').first().fill('x');
await page.locator('.form-array__row input[type="number"]').fill('99');
await page.locator('#submit-btn').click();
// Two error messages should be visible (location, severity).
await expect(page.locator('.form-field__error:not([hidden])')).toHaveCount(2);
await expect(page.locator('#form-status')).toContainText('Please correct');
});
test('pre-fills form when data is provided', async ({ page }) => {
await loadFormWithContext(page, {
schema: SAFETY_SCHEMA,
ui: SAFETY_UI,
data: {
date: '2026-04-15',
location: 'Site C',
hazards: [
{ kind: 'Slippery floor', severity: 2, notes: 'Wet from rain.' },
],
additionalNotes: 'Pre-existing draft.',
},
submitUrl: '/test/safety/2026-04-15-jamie.yaml.html',
});
await page.waitForSelector('#form-root');
await expect(page.locator('#form-root input[type="date"]')).toHaveValue('2026-04-15');
await expect(page.locator('#form-root input[type="radio"][value="Site C"]')).toBeChecked();
await expect(page.locator('.form-array__row')).toHaveCount(1);
await expect(page.locator('.form-array__row input[type="text"]').first()).toHaveValue('Slippery floor');
await expect(page.locator('.form-array__row input[type="number"]')).toHaveValue('2');
await expect(page.locator('#form-root textarea')).toHaveValue('Pre-existing draft.');
});
});

View file

@ -283,6 +283,16 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
}
}
// Form-system intercept: *.form.html and *.yaml.html under a sibling form
// folder are virtual URLs that the form handler renders inline, reading
// the underlying *.form.yaml spec (and, for re-edit, the *.yaml data) from
// disk. RecognizeFormRequest returns nil when the spec doesn't exist, so
// non-form .html URLs fall through to the static-file path below.
if formReq := handler.RecognizeFormRequest(cfg.Root, r.Method, urlPath); formReq != nil {
handler.ServeForm(cfg, formReq, w, r)
return
}
// Apps resolution for the root landing path: GET / or /index.html with
// no real index.html on disk → serve via apps.Serve("landing"). The
// other four apps are caught by the "stat fails → app HTML?" branch

View file

@ -2113,7 +2113,7 @@ td[data-field="trackingNumber"] {
</svg>
<div class="header-title-group">
<span class="app-header__title">ZDDC Archive</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.9-alpha · 2026-05-02 14:02:00 · 76e1e78-dirty</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.9-alpha · 2026-05-03 01:04:52 · c099676-dirty</span></span>
</div>
<button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data" style="font-size:1.1rem;"></button>

View file

@ -1376,7 +1376,7 @@ body.help-open .app-header {
</svg>
<div class="header-title-group">
<span class="app-header__title">ZDDC Classifier</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.9-alpha · 2026-05-02 14:02:00 · 76e1e78-dirty</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.9-alpha · 2026-05-03 01:04:52 · c099676-dirty</span></span>
</div>
<button id="selectDirectoryBtn" class="btn btn-primary">Select Directory</button>
<button id="refreshBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory">Refresh</button>

View file

@ -866,7 +866,7 @@ body {
</g>
</svg>
<span class="app-header__title">ZDDC Archive</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.9-alpha · 2026-05-02 14:02:00 · 76e1e78-dirty</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.9-alpha · 2026-05-03 01:04:52 · c099676-dirty</span></span>
</div>
<div class="header-right">
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)"></button>

View file

@ -1774,7 +1774,7 @@ body.help-open .app-header {
</svg>
<div class="header-title-group">
<span class="app-header__title">ZDDC Markdown</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.9-alpha · 2026-05-02 14:02:00 · 76e1e78-dirty</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.9-alpha · 2026-05-03 01:04:52 · c099676-dirty</span></span>
</div>
<button id="select-directory" class="btn btn-primary" title="Select a Directory">Select Directory</button>
</div>

View file

@ -2210,7 +2210,7 @@ dialog.modal--narrow {
</svg>
<div class="header-title-group">
<span class="app-header__title">ZDDC Transmittal</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.9-alpha · 2026-05-02 14:02:00 · 76e1e78-dirty</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.9-alpha · 2026-05-03 01:04:52 · c099676-dirty</span></span>
</div>
<div class="app-header__spacer"></div>
<div class="app-header__icons">

View file

@ -1,6 +1,7 @@
# Generated by build.sh — do not edit. One <app>=<build label> per line.
archive=v0.0.9-alpha · 2026-05-02 14:02:00 · 76e1e78-dirty
transmittal=v0.0.9-alpha · 2026-05-02 14:02:00 · 76e1e78-dirty
classifier=v0.0.9-alpha · 2026-05-02 14:02:00 · 76e1e78-dirty
mdedit=v0.0.9-alpha · 2026-05-02 14:02:00 · 76e1e78-dirty
landing=v0.0.9-alpha · 2026-05-02 14:02:00 · 76e1e78-dirty
archive=v0.0.9-alpha · 2026-05-03 01:04:52 · c099676-dirty
transmittal=v0.0.9-alpha · 2026-05-03 01:04:52 · c099676-dirty
classifier=v0.0.9-alpha · 2026-05-03 01:04:52 · c099676-dirty
mdedit=v0.0.9-alpha · 2026-05-03 01:04:52 · c099676-dirty
landing=v0.0.9-alpha · 2026-05-03 01:04:52 · c099676-dirty
form=v0.0.1-alpha · 2026-05-03 01:04:52 · c099676-dirty

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,578 @@
// Package handler — formhandler.go: the form-data system endpoints.
//
// URL conventions (the form always POSTs to the same URL it was GET'd from;
// the server strips ".html" and routes by what's underneath):
//
// GET /<path>/<name>.form.html → render empty form
// POST /<path>/<name>.form.html → create new submission → 201 + Location
// GET /<path>/<name>/<id>.yaml.html → render form pre-filled from <id>.yaml
// POST /<path>/<name>/<id>.yaml.html → validate + overwrite that submission → 200
//
// Direct GET of the raw .yaml (data) and .form.yaml (spec) continues through
// the existing static-file path; only the .html suffix is hijacked here.
//
// Storage layout: a form named "safety" lives at <dir>/safety.form.yaml;
// submissions go to <dir>/safety/<YYYY-MM-DD>-<email-sanitized>.yaml. The
// submissions folder is created lazily on first POST. ACL via the existing
// .zddc cascade — submit-rights = path-write-rights at the submissions
// directory.
package handler
import (
_ "embed"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/jsonschema"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
"gopkg.in/yaml.v3"
)
//go:embed form.html
var embeddedFormHTML []byte
// FormSpec is the YAML envelope of a <name>.form.yaml file.
//
// v0 fields: Title, Description, Schema, UI. Mode is reserved for the v1
// file-as-truth introduction (default form-as-truth = empty / "form-as-truth").
// Unknown YAML keys are ignored — this struct is the source of truth for the
// supported form-spec vocabulary.
type FormSpec struct {
Title string `yaml:"title"`
Description string `yaml:"description"`
Schema *jsonschema.Schema `yaml:"schema"`
UI map[string]interface{} `yaml:"ui"`
Mode string `yaml:"mode"`
}
// formContext is the JSON object the server injects into the form HTML.
// The renderer (form/js/context.js) reads this from #form-context.
type formContext struct {
Title string `json:"title,omitempty"`
Schema *jsonschema.Schema `json:"schema"`
UI map[string]interface{} `json:"ui,omitempty"`
Data interface{} `json:"data,omitempty"`
SubmitURL string `json:"submitUrl"`
Errors []jsonschema.Error `json:"errors,omitempty"`
}
// FormRequest describes a recognized form-system request.
type FormRequest struct {
// Kind is one of: "render-empty", "create", "render-edit", "update".
Kind string
// SpecPath is the absolute filesystem path to the <name>.form.yaml.
SpecPath string
// DataPath is the absolute filesystem path to the data .yaml; empty for
// render-empty / create.
DataPath string
// SubmitURL is the URL the form should POST back to (the server-injected
// "submit to my own URL" value).
SubmitURL string
}
// RecognizeFormRequest classifies r as a form-system request, or returns nil
// if it falls through to static file serving. Form-spec existence on disk is
// required: a *.form.html URL with no corresponding *.form.yaml is not a
// form request.
//
// Methods other than GET / POST return nil (HEAD / OPTIONS pass through to
// the catch-all so the standard handlers respond).
func RecognizeFormRequest(fsRoot, method, urlPath string) *FormRequest {
if method != http.MethodGet && method != http.MethodPost {
return nil
}
if !strings.HasSuffix(urlPath, ".html") {
return nil
}
underlying := strings.TrimSuffix(urlPath, ".html")
// Form-spec URL: <name>.form.html → spec at <name>.form.yaml.
// Data URL: <name>/<id>.yaml.html → underlying ends in .yaml → spec at
// <name>.form.yaml at the parent's parent.
if strings.HasSuffix(underlying, ".form") {
// <name>.form.html — empty form / create.
specRel := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(underlying, "/"))) + ".yaml"
specAbs := filepath.Join(fsRoot, specRel)
if !strings.HasPrefix(specAbs, fsRoot+string(filepath.Separator)) && specAbs != fsRoot {
return nil
}
if !fileExists(specAbs) {
return nil
}
kind := "render-empty"
if method == http.MethodPost {
kind = "create"
}
return &FormRequest{
Kind: kind,
SpecPath: specAbs,
SubmitURL: urlPath,
}
}
if strings.HasSuffix(underlying, ".yaml") {
// <name>/<id>.yaml.html — re-edit / update.
dataRel := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(underlying, "/")))
dataAbs := filepath.Join(fsRoot, dataRel)
if !strings.HasPrefix(dataAbs, fsRoot+string(filepath.Separator)) && dataAbs != fsRoot {
return nil
}
// Spec lives at the parent's parent: <dir>/<name>/<id>.yaml →
// <dir>/<name>.form.yaml.
parentDir := filepath.Dir(dataAbs)
formName := filepath.Base(parentDir)
grandparent := filepath.Dir(parentDir)
specPath := filepath.Join(grandparent, formName+".form.yaml")
if !fileExists(specPath) {
return nil
}
kind := "render-edit"
if method == http.MethodPost {
kind = "update"
}
return &FormRequest{
Kind: kind,
SpecPath: specPath,
DataPath: dataAbs,
SubmitURL: urlPath,
}
}
return nil
}
// ServeForm dispatches a recognized form request to render or write logic.
// The catch-all dispatch in zddc-server/main.go calls this whenever
// RecognizeFormRequest returns non-nil.
func ServeForm(cfg config.Config, req *FormRequest, w http.ResponseWriter, r *http.Request) {
switch req.Kind {
case "render-empty":
serveFormRender(cfg, req, w, r, nil)
case "render-edit":
serveFormRender(cfg, req, w, r, nil)
case "create":
serveFormCreate(cfg, req, w, r)
case "update":
serveFormUpdate(cfg, req, w, r)
default:
http.Error(w, "unknown form request kind", http.StatusInternalServerError)
}
}
// serveFormRender handles GET requests for both empty and pre-filled forms.
// validationErrs is non-nil only when re-rendering after a POST→422 (not used
// in v0 — POST returns JSON 422 and the client patches errors into the live
// form via JS).
func serveFormRender(cfg config.Config, req *FormRequest, w http.ResponseWriter, r *http.Request, validationErrs []jsonschema.Error) {
email := EmailFromContext(r)
// ACL: read-rights at the directory holding the spec (and, for edits, at
// the directory holding the data file). Cascade chain is the same for
// every entity in the same directory — a single check covers both.
gateDir := filepath.Dir(req.SpecPath)
if req.DataPath != "" {
gateDir = filepath.Dir(req.DataPath)
}
chain, err := zddc.EffectivePolicy(cfg.Root, gateDir)
if err != nil {
slog.Warn("form: policy error", "path", gateDir, "err", err)
}
if !zddc.AllowedWithChain(chain, email) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
if len(embeddedFormHTML) == 0 {
http.Error(w, "form renderer not built into this binary", http.StatusServiceUnavailable)
return
}
spec, err := loadFormSpec(req.SpecPath)
if err != nil {
slog.Warn("form: spec parse error", "path", req.SpecPath, "err", err)
http.Error(w, "form spec error: "+err.Error(), http.StatusInternalServerError)
return
}
var data interface{}
if req.DataPath != "" {
if !fileExists(req.DataPath) {
http.NotFound(w, r)
return
}
raw, err := os.ReadFile(req.DataPath)
if err != nil {
http.Error(w, "read submission: "+err.Error(), http.StatusInternalServerError)
return
}
if err := yaml.Unmarshal(raw, &data); err != nil {
http.Error(w, "parse submission: "+err.Error(), http.StatusInternalServerError)
return
}
data = normalizeYAMLForJSON(data)
}
ctx := formContext{
Title: spec.Title,
Schema: spec.Schema,
UI: spec.UI,
Data: data,
SubmitURL: req.SubmitURL,
Errors: validationErrs,
}
html, err := injectFormContext(embeddedFormHTML, ctx)
if err != nil {
http.Error(w, "render: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(html)
}
// serveFormCreate handles POST to <name>.form.html — creates a new submission.
func serveFormCreate(cfg config.Config, req *FormRequest, w http.ResponseWriter, r *http.Request) {
email := EmailFromContext(r)
if email == "" {
http.Error(w, "authentication required", http.StatusUnauthorized)
return
}
formName := strings.TrimSuffix(filepath.Base(req.SpecPath), ".form.yaml")
specDir := filepath.Dir(req.SpecPath)
submissionsDir := filepath.Join(specDir, formName)
// ACL: write-rights at submissions dir. The dir may not exist yet; the
// cascade chain falls back to the parent.
gateDir := submissionsDir
if !fileExists(submissionsDir) {
gateDir = specDir
}
chain, err := zddc.EffectivePolicy(cfg.Root, gateDir)
if err != nil {
slog.Warn("form: policy error", "path", gateDir, "err", err)
}
if !zddc.AllowedWithChain(chain, email) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
data, err := decodeRequestData(r)
if err != nil {
http.Error(w, "request body: "+err.Error(), http.StatusBadRequest)
return
}
spec, err := loadFormSpec(req.SpecPath)
if err != nil {
http.Error(w, "form spec error: "+err.Error(), http.StatusInternalServerError)
return
}
if errs := jsonschema.Validate(spec.Schema, data); len(errs) > 0 {
writeValidationErrors(w, errs)
return
}
if err := os.MkdirAll(submissionsDir, 0o755); err != nil {
http.Error(w, "ensure submissions dir: "+err.Error(), http.StatusInternalServerError)
return
}
dateStr := time.Now().UTC().Format("2006-01-02")
emailSan := sanitizeEmail(email)
base := dateStr + "-" + emailSan
target, fname, ok := pickAvailableFilename(submissionsDir, base, ".yaml")
if !ok {
http.Error(w, "could not pick a free filename (>100 collisions)", http.StatusConflict)
return
}
yamlBytes, err := yaml.Marshal(data)
if err != nil {
http.Error(w, "marshal yaml: "+err.Error(), http.StatusInternalServerError)
return
}
if err := zddc.WriteAtomic(target, yamlBytes); err != nil {
http.Error(w, "write: "+err.Error(), http.StatusInternalServerError)
return
}
// Capability URL: the path to the new submission file. The renderer
// appends ".html" to navigate back to the form-rendered view of the just-
// saved data.
relPath, err := filepath.Rel(cfg.Root, target)
if err != nil {
slog.Warn("form: rel path error", "root", cfg.Root, "target", target, "err", err)
http.Error(w, "post-write: "+err.Error(), http.StatusInternalServerError)
return
}
capURL := "/" + filepath.ToSlash(relPath)
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Location", capURL)
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(map[string]string{
"location": capURL,
"filename": fname,
})
}
// serveFormUpdate handles POST to <name>/<id>.yaml.html — overwrites an
// existing submission after re-validating against the form spec.
func serveFormUpdate(cfg config.Config, req *FormRequest, w http.ResponseWriter, r *http.Request) {
email := EmailFromContext(r)
if email == "" {
http.Error(w, "authentication required", http.StatusUnauthorized)
return
}
if !fileExists(req.DataPath) {
http.NotFound(w, r)
return
}
chain, err := zddc.EffectivePolicy(cfg.Root, filepath.Dir(req.DataPath))
if err != nil {
slog.Warn("form: policy error", "path", req.DataPath, "err", err)
}
if !zddc.AllowedWithChain(chain, email) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
data, err := decodeRequestData(r)
if err != nil {
http.Error(w, "request body: "+err.Error(), http.StatusBadRequest)
return
}
spec, err := loadFormSpec(req.SpecPath)
if err != nil {
http.Error(w, "form spec error: "+err.Error(), http.StatusInternalServerError)
return
}
if errs := jsonschema.Validate(spec.Schema, data); len(errs) > 0 {
writeValidationErrors(w, errs)
return
}
yamlBytes, err := yaml.Marshal(data)
if err != nil {
http.Error(w, "marshal yaml: "+err.Error(), http.StatusInternalServerError)
return
}
if err := zddc.WriteAtomic(req.DataPath, yamlBytes); err != nil {
http.Error(w, "write: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"ok":true}`))
}
// --- Helpers -----------------------------------------------------------------
func loadFormSpec(path string) (*FormSpec, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var spec FormSpec
if err := yaml.Unmarshal(data, &spec); err != nil {
return nil, fmt.Errorf("parse: %w", err)
}
if spec.Schema == nil {
return nil, errors.New("form spec has no schema")
}
return &spec, nil
}
// decodeRequestData reads the request body as JSON (preferred) or YAML,
// returning the decoded value as the same `any` shape jsonschema.Validate
// expects. Body size is capped at 1 MiB.
func decodeRequestData(r *http.Request) (interface{}, error) {
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
return nil, err
}
if len(body) == 0 {
return nil, errors.New("empty body")
}
ct := strings.ToLower(strings.TrimSpace(strings.Split(r.Header.Get("Content-Type"), ";")[0]))
if ct == "application/yaml" || ct == "application/x-yaml" || ct == "text/yaml" {
var v interface{}
if err := yaml.Unmarshal(body, &v); err != nil {
return nil, err
}
return normalizeYAMLForJSON(v), nil
}
var v interface{}
dec := json.NewDecoder(strings.NewReader(string(body)))
dec.UseNumber()
if err := dec.Decode(&v); err != nil {
return nil, err
}
return normalizeJSONNumbers(v), nil
}
// normalizeYAMLForJSON converts yaml.v3's `map[interface{}]interface{}` (which
// it produces for mappings under a generic `interface{}` target) into
// `map[string]interface{}` so the rest of the pipeline can assume JSON shape.
// Also recurses through slices.
func normalizeYAMLForJSON(v interface{}) interface{} {
switch x := v.(type) {
case map[interface{}]interface{}:
out := make(map[string]interface{}, len(x))
for k, val := range x {
out[fmt.Sprintf("%v", k)] = normalizeYAMLForJSON(val)
}
return out
case map[string]interface{}:
out := make(map[string]interface{}, len(x))
for k, val := range x {
out[k] = normalizeYAMLForJSON(val)
}
return out
case []interface{}:
out := make([]interface{}, len(x))
for i, item := range x {
out[i] = normalizeYAMLForJSON(item)
}
return out
}
return v
}
// normalizeJSONNumbers converts json.Number values into int64 (when integral)
// or float64. Without this, the validator would have to know about
// json.Number, which would couple the focused jsonschema package to the
// json decoder we happen to use here.
func normalizeJSONNumbers(v interface{}) interface{} {
switch x := v.(type) {
case json.Number:
if i, err := x.Int64(); err == nil {
return i
}
if f, err := x.Float64(); err == nil {
return f
}
return x.String()
case map[string]interface{}:
for k, val := range x {
x[k] = normalizeJSONNumbers(val)
}
return x
case []interface{}:
for i, item := range x {
x[i] = normalizeJSONNumbers(item)
}
return x
}
return v
}
func writeValidationErrors(w http.ResponseWriter, errs []jsonschema.Error) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnprocessableEntity)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"errors": errs,
})
}
// sanitizeEmail produces a safe filename component from an email address.
// "casey@proton.me" → "casey-at-proton-me". Conservative: also flattens any
// path-meaningful characters so a malicious email can't escape its directory,
// then collapses runs of '-' and trims leading/trailing '-' so the resulting
// filename is well-formed.
func sanitizeEmail(s string) string {
s = strings.ReplaceAll(s, "@", "-at-")
s = strings.ReplaceAll(s, ".", "-")
var b strings.Builder
for _, r := range s {
switch {
case r >= 'a' && r <= 'z',
r >= 'A' && r <= 'Z',
r >= '0' && r <= '9',
r == '-', r == '_':
b.WriteRune(r)
}
}
out := b.String()
for strings.Contains(out, "--") {
out = strings.ReplaceAll(out, "--", "-")
}
out = strings.Trim(out, "-")
if out == "" {
out = "anonymous"
}
return out
}
// pickAvailableFilename tries `<base><ext>`, then `<base>-2<ext>`, ...,
// `<base>-100<ext>`, returning the first that does not yet exist on disk.
func pickAvailableFilename(dir, base, ext string) (path, name string, ok bool) {
name = base + ext
path = filepath.Join(dir, name)
if !fileExists(path) {
return path, name, true
}
for i := 2; i < 100; i++ {
name = base + "-" + strconv.Itoa(i) + ext
path = filepath.Join(dir, name)
if !fileExists(path) {
return path, name, true
}
}
return "", "", false
}
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// injectFormContext rewrites the embedded form HTML's #form-context placeholder
// with a serialized form context. Defends against script-tag breakouts in the
// JSON values by escaping any "</" sequences as "<\\/" — the JS engine treats
// a backslash-escaped slash identically inside a string literal, so behavior
// is preserved while the HTML parser cannot mistake content for a closing
// </script>.
func injectFormContext(template []byte, ctx formContext) ([]byte, error) {
js, err := json.Marshal(ctx)
if err != nil {
return nil, err
}
js = []byte(strings.ReplaceAll(string(js), "</", "<\\/"))
needle := []byte(`<script id="form-context" type="application/json">{}</script>`)
if !bytesContains(template, needle) {
return nil, errors.New("#form-context placeholder not found in template")
}
replacement := append([]byte(`<script id="form-context" type="application/json">`), js...)
replacement = append(replacement, []byte(`</script>`)...)
out := bytesReplace(template, needle, replacement)
return out, nil
}
// Tiny bytes helpers — scoped here so we don't pull in "bytes" for two calls.
func bytesContains(haystack, needle []byte) bool {
return strings.Contains(string(haystack), string(needle))
}
func bytesReplace(haystack, needle, replacement []byte) []byte {
return []byte(strings.Replace(string(haystack), string(needle), string(replacement), 1))
}

View file

@ -0,0 +1,491 @@
package handler
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
const sampleFormSpec = `title: Daily Safety Check-In
schema:
type: object
required: [date, location]
additionalProperties: false
properties:
date:
type: string
format: date
location:
type: string
enum: [Site A, Site B]
severity:
type: integer
minimum: 1
maximum: 5
notes:
type: string
ui:
notes:
ui:widget: textarea
`
// formTestSetup writes a directory tree under a temp root including a
// safety.form.yaml at /Working/safety.form.yaml plus optional .zddc files.
// Returns (config, do) where do dispatches a request through ServeForm via
// the same recognize → serve path the production catch-all uses.
func formTestSetup(t *testing.T, zddcFiles map[string]string) (config.Config, func(method, target, email, body string) *httptest.ResponseRecorder) {
t.Helper()
root := t.TempDir()
// Always seed the form spec at /Working/safety.form.yaml.
working := filepath.Join(root, "Working")
if err := os.MkdirAll(working, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
specPath := filepath.Join(working, "safety.form.yaml")
if err := os.WriteFile(specPath, []byte(sampleFormSpec), 0o644); err != nil {
t.Fatalf("write spec: %v", err)
}
for rel, body := range zddcFiles {
dir := filepath.Join(root, rel)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir %s: %v", dir, err)
}
zddc.InvalidateCache(dir)
if body == "" {
continue
}
if err := os.WriteFile(filepath.Join(dir, ".zddc"), []byte(body), 0o644); err != nil {
t.Fatalf("write .zddc: %v", err)
}
}
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
do := func(method, target, email, body string) *httptest.ResponseRecorder {
var req *http.Request
if body != "" {
req = httptest.NewRequest(method, target, bytes.NewReader([]byte(body)))
req.Header.Set("Content-Type", "application/json")
} else {
req = httptest.NewRequest(method, target, nil)
}
ctx := context.WithValue(req.Context(), EmailKey, email)
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
formReq := RecognizeFormRequest(cfg.Root, method, target)
if formReq == nil {
rec.WriteHeader(http.StatusNotFound)
return rec
}
ServeForm(cfg, formReq, rec, req)
return rec
}
return cfg, do
}
func TestRecognizeFormRequest(t *testing.T) {
root := t.TempDir()
if err := os.MkdirAll(filepath.Join(root, "Working", "safety"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "Working", "safety.form.yaml"), []byte("schema:\n type: object\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "Working", "safety", "2026-05-01-casey.yaml"), []byte("date: 2026-05-01\n"), 0o644); err != nil {
t.Fatal(err)
}
cases := []struct {
method, url string
wantKind string // "" means expect nil
wantSpec string
wantData string
}{
{"GET", "/Working/safety.form.html", "render-empty", "Working/safety.form.yaml", ""},
{"POST", "/Working/safety.form.html", "create", "Working/safety.form.yaml", ""},
{"GET", "/Working/safety/2026-05-01-casey.yaml.html", "render-edit", "Working/safety.form.yaml", "Working/safety/2026-05-01-casey.yaml"},
{"POST", "/Working/safety/2026-05-01-casey.yaml.html", "update", "Working/safety.form.yaml", "Working/safety/2026-05-01-casey.yaml"},
// No spec → not a form request.
{"GET", "/Working/missing.form.html", "", "", ""},
// Bare .yaml (not .yaml.html) → not a form request, falls through to static.
{"GET", "/Working/safety/2026-05-01-casey.yaml", "", "", ""},
// Random .html → falls through.
{"GET", "/index.html", "", "", ""},
// Wrong method.
{"DELETE", "/Working/safety.form.html", "", "", ""},
// Path traversal attempt.
{"GET", "/../etc/passwd.form.html", "", "", ""},
}
for _, tc := range cases {
t.Run(tc.method+" "+tc.url, func(t *testing.T) {
got := RecognizeFormRequest(root, tc.method, tc.url)
if tc.wantKind == "" {
if got != nil {
t.Errorf("got %+v, want nil", got)
}
return
}
if got == nil {
t.Fatalf("got nil, want kind=%q", tc.wantKind)
}
if got.Kind != tc.wantKind {
t.Errorf("Kind = %q want %q", got.Kind, tc.wantKind)
}
wantSpec := filepath.Join(root, tc.wantSpec)
if got.SpecPath != wantSpec {
t.Errorf("SpecPath = %q want %q", got.SpecPath, wantSpec)
}
if tc.wantData != "" {
wantData := filepath.Join(root, tc.wantData)
if got.DataPath != wantData {
t.Errorf("DataPath = %q want %q", got.DataPath, wantData)
}
} else if got.DataPath != "" {
t.Errorf("DataPath = %q want empty", got.DataPath)
}
})
}
}
func TestRenderEmptyForm(t *testing.T) {
_, do := formTestSetup(t, map[string]string{
"": `acl:
allow: ["*@example.com"]
`,
})
rec := do(http.MethodGet, "/Working/safety.form.html", "casey@example.com", "")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
// The placeholder should be replaced with real context content.
if !strings.Contains(body, `<script id="form-context" type="application/json">`) {
t.Fatal("form-context script tag missing from rendered HTML")
}
if strings.Contains(body, `<script id="form-context" type="application/json">{}</script>`) {
t.Fatal("placeholder {} was not replaced")
}
// Title from the form spec should land in the rendered context.
if !strings.Contains(body, "Daily Safety Check-In") {
t.Errorf("expected title in body, got first 500 chars:\n%s", body[:min(500, len(body))])
}
}
func TestRenderEmptyForm_ACLDeny(t *testing.T) {
_, do := formTestSetup(t, map[string]string{
"": `acl:
allow: ["root@example.com"]
`,
})
rec := do(http.MethodGet, "/Working/safety.form.html", "stranger@example.com", "")
if rec.Code != http.StatusForbidden {
t.Errorf("status = %d want 403; body = %s", rec.Code, rec.Body.String())
}
}
func TestCreateSubmission_Valid(t *testing.T) {
cfg, do := formTestSetup(t, map[string]string{
"": `acl:
allow: ["*@example.com"]
`,
})
body := `{"date":"2026-05-01","location":"Site A","severity":3,"notes":"all clear"}`
rec := do(http.MethodPost, "/Working/safety.form.html", "casey@example.com", body)
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d want 201; body = %s", rec.Code, rec.Body.String())
}
loc := rec.Header().Get("Location")
if loc == "" {
t.Fatal("Location header missing")
}
// Filename uses the server's UTC date (not the user-entered date), so just
// check the path prefix and email-sanitized component.
if !strings.HasPrefix(loc, "/Working/safety/") || !strings.Contains(loc, "casey-at-example-com") {
t.Errorf("Location = %q; expected /Working/safety/...casey-at-example-com...", loc)
}
// File should exist on disk with the submitted values reflected.
abs := filepath.Join(cfg.Root, filepath.FromSlash(strings.TrimPrefix(loc, "/")))
yamlBytes, err := os.ReadFile(abs)
if err != nil {
t.Fatalf("read submission: %v", err)
}
yamlStr := string(yamlBytes)
if !strings.Contains(yamlStr, "2026-05-01") {
t.Errorf("submission YAML missing user-entered date: %s", yamlStr)
}
if !strings.Contains(yamlStr, "Site A") {
t.Errorf("submission YAML missing location: %s", yamlStr)
}
if !strings.Contains(yamlStr, "all clear") {
t.Errorf("submission YAML missing notes: %s", yamlStr)
}
}
func TestCreateSubmission_Invalid_Returns422(t *testing.T) {
_, do := formTestSetup(t, map[string]string{
"": `acl:
allow: ["*@example.com"]
`,
})
// Missing required `location`, severity out of range.
body := `{"date":"2026-05-01","severity":99}`
rec := do(http.MethodPost, "/Working/safety.form.html", "casey@example.com", body)
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("status = %d want 422; body = %s", rec.Code, rec.Body.String())
}
var resp struct {
Errors []struct {
Path string `json:"path"`
Message string `json:"message"`
} `json:"errors"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v; body = %s", err, rec.Body.String())
}
if len(resp.Errors) < 2 {
t.Errorf("expected at least 2 errors, got %d: %+v", len(resp.Errors), resp.Errors)
}
gotPaths := map[string]bool{}
for _, e := range resp.Errors {
gotPaths[e.Path] = true
}
if !gotPaths["/location"] {
t.Errorf("expected error at /location, got paths %v", gotPaths)
}
if !gotPaths["/severity"] {
t.Errorf("expected error at /severity, got paths %v", gotPaths)
}
}
func TestCreateSubmission_ACLDeny(t *testing.T) {
_, do := formTestSetup(t, map[string]string{
"": `acl:
allow: ["root@example.com"]
`,
})
body := `{"date":"2026-05-01","location":"Site A"}`
rec := do(http.MethodPost, "/Working/safety.form.html", "stranger@example.com", body)
if rec.Code != http.StatusForbidden {
t.Errorf("status = %d want 403; body = %s", rec.Code, rec.Body.String())
}
}
func TestCreateSubmission_NoAuth_Returns401(t *testing.T) {
_, do := formTestSetup(t, map[string]string{
"": `acl:
allow: ["*"]
`,
})
body := `{"date":"2026-05-01","location":"Site A"}`
rec := do(http.MethodPost, "/Working/safety.form.html", "", body)
if rec.Code != http.StatusUnauthorized {
t.Errorf("status = %d want 401; body = %s", rec.Code, rec.Body.String())
}
}
func TestCreateSubmission_FilenameCollision(t *testing.T) {
cfg, do := formTestSetup(t, map[string]string{
"": `acl:
allow: ["*@example.com"]
`,
})
body := `{"date":"2026-05-01","location":"Site A"}`
first := do(http.MethodPost, "/Working/safety.form.html", "casey@example.com", body)
if first.Code != http.StatusCreated {
t.Fatalf("first submit: status = %d; body = %s", first.Code, first.Body.String())
}
second := do(http.MethodPost, "/Working/safety.form.html", "casey@example.com", body)
if second.Code != http.StatusCreated {
t.Fatalf("second submit: status = %d; body = %s", second.Code, second.Body.String())
}
loc1 := first.Header().Get("Location")
loc2 := second.Header().Get("Location")
if loc1 == loc2 {
t.Errorf("collision suffix not applied: both submissions at %q", loc1)
}
if !strings.Contains(loc2, "-2.yaml") {
t.Errorf("second submission Location = %q; expected -2.yaml suffix", loc2)
}
// Both files exist on disk.
for _, l := range []string{loc1, loc2} {
abs := filepath.Join(cfg.Root, filepath.FromSlash(strings.TrimPrefix(l, "/")))
if _, err := os.Stat(abs); err != nil {
t.Errorf("expected submission at %s: %v", abs, err)
}
}
}
func TestRenderEdit_LoadsSubmission(t *testing.T) {
cfg, do := formTestSetup(t, map[string]string{
"": `acl:
allow: ["*@example.com"]
`,
})
// Pre-populate a submission file.
subDir := filepath.Join(cfg.Root, "Working", "safety")
if err := os.MkdirAll(subDir, 0o755); err != nil {
t.Fatal(err)
}
subPath := filepath.Join(subDir, "2026-05-01-jamie-at-example-com.yaml")
if err := os.WriteFile(subPath, []byte("date: 2026-05-01\nlocation: Site B\nseverity: 4\n"), 0o644); err != nil {
t.Fatal(err)
}
rec := do(http.MethodGet, "/Working/safety/2026-05-01-jamie-at-example-com.yaml.html", "jamie@example.com", "")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
// The form-context JSON should now contain the loaded data.
if !strings.Contains(body, `"location":"Site B"`) {
t.Errorf("expected loaded location in form context; first 500 chars:\n%s", body[:min(500, len(body))])
}
}
func TestUpdateSubmission_OverwritesFile(t *testing.T) {
cfg, do := formTestSetup(t, map[string]string{
"": `acl:
allow: ["*@example.com"]
`,
})
subDir := filepath.Join(cfg.Root, "Working", "safety")
if err := os.MkdirAll(subDir, 0o755); err != nil {
t.Fatal(err)
}
subPath := filepath.Join(subDir, "2026-05-01-jamie-at-example-com.yaml")
if err := os.WriteFile(subPath, []byte("date: 2026-05-01\nlocation: Site A\n"), 0o644); err != nil {
t.Fatal(err)
}
body := `{"date":"2026-05-01","location":"Site B","severity":2}`
rec := do(http.MethodPost, "/Working/safety/2026-05-01-jamie-at-example-com.yaml.html", "jamie@example.com", body)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
}
updated, err := os.ReadFile(subPath)
if err != nil {
t.Fatalf("read updated: %v", err)
}
if !strings.Contains(string(updated), "Site B") {
t.Errorf("update did not change location; got: %s", string(updated))
}
if !strings.Contains(string(updated), "severity: 2") {
t.Errorf("update did not include severity; got: %s", string(updated))
}
}
func TestUpdateSubmission_NotFound(t *testing.T) {
_, do := formTestSetup(t, map[string]string{
"": `acl:
allow: ["*@example.com"]
`,
})
body := `{"date":"2026-05-01","location":"Site A"}`
rec := do(http.MethodPost, "/Working/safety/missing.yaml.html", "jamie@example.com", body)
if rec.Code != http.StatusNotFound {
t.Errorf("status = %d want 404; body = %s", rec.Code, rec.Body.String())
}
}
func TestSanitizeEmail(t *testing.T) {
cases := map[string]string{
"casey@proton.me": "casey-at-proton-me",
"first.last@example.com": "first-last-at-example-com",
"casey+tag@example.io": "caseytag-at-example-io",
"": "anonymous",
"../etc/passwd@evil.com": "etcpasswd-at-evil-com",
}
for in, want := range cases {
got := sanitizeEmail(in)
if got != want {
t.Errorf("sanitizeEmail(%q) = %q want %q", in, got, want)
}
}
}
func TestPickAvailableFilename_Collision(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "a.yaml"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
path, name, ok := pickAvailableFilename(dir, "a", ".yaml")
if !ok {
t.Fatal("ok=false on first collision step")
}
if name != "a-2.yaml" {
t.Errorf("name = %q want a-2.yaml", name)
}
if filepath.Base(path) != "a-2.yaml" {
t.Errorf("path basename = %q want a-2.yaml", filepath.Base(path))
}
}
func TestInjectFormContext_PlaceholderReplaced(t *testing.T) {
template := []byte(`<html><script id="form-context" type="application/json">{}</script></html>`)
out, err := injectFormContext(template, formContext{
Title: "X",
SubmitURL: "/x",
})
if err != nil {
t.Fatalf("inject: %v", err)
}
s := string(out)
if strings.Contains(s, `"application/json">{}</script>`) {
t.Error("placeholder still present")
}
if !strings.Contains(s, `"title":"X"`) {
t.Errorf("missing title in injected JSON; got: %s", s)
}
}
func TestInjectFormContext_EscapesScriptCloseInValue(t *testing.T) {
// A schema description containing "</script>" must not break out of the
// inline JSON. encoding/json's default escapes `<` → `<`, so the
// rendered output should still contain exactly one </script> (the actual
// closing tag) regardless of what the user-controlled value held.
template := []byte(`<html><script id="form-context" type="application/json">{}</script></html>`)
ctx := formContext{
Title: `legit </script><script>alert(1)</script>`,
SubmitURL: "/x",
}
out, err := injectFormContext(template, ctx)
if err != nil {
t.Fatalf("inject: %v", err)
}
s := string(out)
if n := strings.Count(s, "</script>"); n != 1 {
t.Errorf("expected exactly 1 </script> closing tag, got %d:\n%s", n, s)
}
// The user-controlled value should be present in escaped form.
if !strings.Contains(s, `</script>`) {
t.Errorf("expected escaped \\u003c/script\\u003e in output:\n%s", s)
}
}
func min(a, b int) int {
if a < b {
return a
}
return b
}

View file

@ -0,0 +1,29 @@
package jsonschema
import (
"regexp"
"time"
)
var (
dateRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
emailRe = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`)
)
// formatValid checks whether s satisfies the named format. Returns true for
// formats we don't support — JSON Schema treats `format` as an annotation by
// default, so unknown formats are non-failing.
func formatValid(format, s string) bool {
switch format {
case "date":
if !dateRe.MatchString(s) {
return false
}
// Reject obviously-malformed dates like 2026-13-40.
_, err := time.Parse("2006-01-02", s)
return err == nil
case "email":
return emailRe.MatchString(s)
}
return true
}

View file

@ -0,0 +1,273 @@
package jsonschema
import (
"encoding/json"
"testing"
)
// fl returns a *float64 pointing at v — convenience for table tests.
func fl(v float64) *float64 { return &v }
func ip(v int) *int { return &v }
// jsonAny decodes a JSON literal into the same `any` shape that the form
// handler hands to Validate (json.Unmarshal of the request body).
func jsonAny(t *testing.T, s string) any {
t.Helper()
var v any
if err := json.Unmarshal([]byte(s), &v); err != nil {
t.Fatalf("decode %q: %v", s, err)
}
return v
}
func TestValidate_Types(t *testing.T) {
cases := []struct {
name string
schema *Schema
valueJS string
wantErr bool
}{
{"string-ok", &Schema{Type: "string"}, `"hi"`, false},
{"string-wrong-type", &Schema{Type: "string"}, `42`, true},
{"number-ok-int", &Schema{Type: "number"}, `42`, false},
{"number-ok-float", &Schema{Type: "number"}, `3.14`, false},
{"number-wrong-type", &Schema{Type: "number"}, `"hi"`, true},
{"integer-ok", &Schema{Type: "integer"}, `42`, false},
{"integer-rejects-float", &Schema{Type: "integer"}, `3.14`, true},
{"integer-accepts-floaty-int", &Schema{Type: "integer"}, `42.0`, false},
{"boolean-ok", &Schema{Type: "boolean"}, `true`, false},
{"boolean-wrong-type", &Schema{Type: "boolean"}, `"true"`, true},
{"null-ok", &Schema{Type: "null"}, `null`, false},
{"array-ok", &Schema{Type: "array"}, `[]`, false},
{"array-wrong-type", &Schema{Type: "array"}, `{}`, true},
{"object-ok", &Schema{Type: "object"}, `{}`, false},
{"object-wrong-type", &Schema{Type: "object"}, `[]`, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
errs := Validate(tc.schema, jsonAny(t, tc.valueJS))
if (len(errs) > 0) != tc.wantErr {
t.Fatalf("Validate errs = %v; wantErr=%v", errs, tc.wantErr)
}
})
}
}
func TestValidate_Enum(t *testing.T) {
s := &Schema{Type: "string", Enum: []any{"a", "b", "c"}}
if errs := Validate(s, "a"); len(errs) != 0 {
t.Errorf("a: unexpected errs %v", errs)
}
if errs := Validate(s, "z"); len(errs) == 0 {
t.Errorf("z: expected enum error")
}
// Numeric enum with int↔float coercion: schema authored with int, value
// arrives as float64 (JSON default).
num := &Schema{Type: "integer", Enum: []any{1, 2, 3}}
if errs := Validate(num, jsonAny(t, `2`)); len(errs) != 0 {
t.Errorf("int 2 against int enum: unexpected errs %v", errs)
}
if errs := Validate(num, jsonAny(t, `4`)); len(errs) == 0 {
t.Errorf("int 4 against int enum: expected error")
}
}
func TestValidate_StringConstraints(t *testing.T) {
s := &Schema{Type: "string", MinLength: ip(3), MaxLength: ip(8)}
cases := map[string]int{
`"hi"`: 1, // too short
`"hello"`: 0,
`"hellothere"`: 1, // too long
}
for v, want := range cases {
errs := Validate(s, jsonAny(t, v))
if len(errs) != want {
t.Errorf("%s: got %d errs (%v), want %d", v, len(errs), errs, want)
}
}
}
func TestValidate_NumberConstraints(t *testing.T) {
s := &Schema{Type: "integer", Minimum: fl(1), Maximum: fl(5)}
cases := []struct {
v string
want int
}{
{`0`, 1}, {`1`, 0}, {`3`, 0}, {`5`, 0}, {`6`, 1},
}
for _, tc := range cases {
errs := Validate(s, jsonAny(t, tc.v))
if len(errs) != tc.want {
t.Errorf("v=%s: got %d errs (%v), want %d", tc.v, len(errs), errs, tc.want)
}
}
}
func TestValidate_Format_Date(t *testing.T) {
s := &Schema{Type: "string", Format: "date"}
cases := map[string]bool{
`"2026-05-01"`: false,
`"2026-13-01"`: true, // bad month
`"2026-02-30"`: true, // bad day
`"05/01/2026"`: true, // wrong format
`"2026-5-1"`: true, // missing zero-pad
}
for v, wantErr := range cases {
errs := Validate(s, jsonAny(t, v))
if (len(errs) > 0) != wantErr {
t.Errorf("%s: got errs=%v wantErr=%v", v, errs, wantErr)
}
}
}
func TestValidate_Format_Email(t *testing.T) {
s := &Schema{Type: "string", Format: "email"}
cases := map[string]bool{
`"casey@example.com"`: false,
`"casey+tag@ex.io"`: false,
`"not-an-email"`: true,
`"missing@dot"`: true,
`"@nouser.com"`: true,
}
for v, wantErr := range cases {
errs := Validate(s, jsonAny(t, v))
if (len(errs) > 0) != wantErr {
t.Errorf("%s: got errs=%v wantErr=%v", v, errs, wantErr)
}
}
}
func TestValidate_Object_Required(t *testing.T) {
s := &Schema{
Type: "object",
Required: []string{"name", "age"},
Properties: map[string]*Schema{
"name": {Type: "string"},
"age": {Type: "integer"},
},
}
errs := Validate(s, jsonAny(t, `{"name":"Casey","age":42}`))
if len(errs) != 0 {
t.Errorf("complete object: unexpected errs %v", errs)
}
errs = Validate(s, jsonAny(t, `{"name":"Casey"}`))
if len(errs) != 1 || errs[0].Path != "/age" {
t.Errorf("missing age: got errs=%v want one error at /age", errs)
}
errs = Validate(s, jsonAny(t, `{}`))
if len(errs) != 2 {
t.Errorf("empty object: got %d errs (%v) want 2", len(errs), errs)
}
}
func TestValidate_Object_AdditionalPropertiesFalse(t *testing.T) {
s := &Schema{
Type: "object",
Properties: map[string]*Schema{"a": {Type: "string"}},
AdditionalProperties: false,
}
if errs := Validate(s, jsonAny(t, `{"a":"hi"}`)); len(errs) != 0 {
t.Errorf("declared-only: unexpected %v", errs)
}
errs := Validate(s, jsonAny(t, `{"a":"hi","b":1}`))
if len(errs) != 1 || errs[0].Path != "/b" {
t.Errorf("extra prop: got %v want one error at /b", errs)
}
}
func TestValidate_Object_NestedErrorPaths(t *testing.T) {
s := &Schema{
Type: "object",
Properties: map[string]*Schema{
"inner": {
Type: "object",
Properties: map[string]*Schema{
"n": {Type: "integer", Minimum: fl(0)},
},
},
},
}
errs := Validate(s, jsonAny(t, `{"inner":{"n":-5}}`))
if len(errs) != 1 || errs[0].Path != "/inner/n" {
t.Errorf("nested path: got %v want /inner/n", errs)
}
}
func TestValidate_Array_Items(t *testing.T) {
s := &Schema{
Type: "array",
Items: &Schema{Type: "integer", Minimum: fl(0)},
}
if errs := Validate(s, jsonAny(t, `[1,2,3]`)); len(errs) != 0 {
t.Errorf("valid array: unexpected %v", errs)
}
errs := Validate(s, jsonAny(t, `[1,-2,3,-4]`))
if len(errs) != 2 {
t.Errorf("two bad items: got %d errs %v want 2", len(errs), errs)
}
if errs[0].Path != "/1" || errs[1].Path != "/3" {
t.Errorf("array paths: got %v want /1 then /3", errs)
}
}
func TestValidate_Array_NestedObjects(t *testing.T) {
s := &Schema{
Type: "array",
Items: &Schema{
Type: "object",
Required: []string{"k"},
Properties: map[string]*Schema{
"k": {Type: "string"},
"v": {Type: "integer", Minimum: fl(1), Maximum: fl(5)},
},
},
}
errs := Validate(s, jsonAny(t, `[{"k":"a","v":3},{"v":99}]`))
// Expected: missing k at /1/k, v out of range at /1/v
if len(errs) != 2 {
t.Fatalf("got %d errs (%v) want 2", len(errs), errs)
}
gotPaths := map[string]bool{errs[0].Path: true, errs[1].Path: true}
if !gotPaths["/1/k"] || !gotPaths["/1/v"] {
t.Errorf("array-of-objects paths: got %v", errs)
}
}
func TestValidate_PtrEnc_SpecialChars(t *testing.T) {
s := &Schema{
Type: "object",
Required: []string{"a/b", "c~d"},
Properties: map[string]*Schema{
"a/b": {Type: "string"},
"c~d": {Type: "string"},
},
}
errs := Validate(s, jsonAny(t, `{}`))
if len(errs) != 2 {
t.Fatalf("got %d errs %v", len(errs), errs)
}
gotPaths := map[string]bool{errs[0].Path: true, errs[1].Path: true}
// Per RFC 6901: '/' → '~1', '~' → '~0'.
if !gotPaths["/a~1b"] {
t.Errorf("expected /a~1b in %v", errs)
}
if !gotPaths["/c~0d"] {
t.Errorf("expected /c~0d in %v", errs)
}
}
func TestValidate_NilSchemaIsNoOp(t *testing.T) {
if errs := Validate(nil, "anything"); errs != nil {
t.Errorf("nil schema returned errs: %v", errs)
}
}

View file

@ -0,0 +1,49 @@
// Package jsonschema is a focused JSON Schema 2020-12 validator covering only
// the subset of keywords used by the form-data system. See validate.go for the
// supported keyword list. Unsupported keywords are silently ignored — the
// form-spec meta-schema enforces that authors only use the supported subset.
//
// This package is intentionally smaller than a full JSON Schema implementation
// (e.g. github.com/santhosh-tekuri/jsonschema). Match-implementation-cost-to-
// surface-used: the form system never needs $ref, oneOf/anyOf, if/then/else,
// or remote schema fetch in v0; reimplementing that machinery would be more
// code than the entire validator we ship here.
//
// When the v1+ form-spec adds those features, revisit this trade-off.
package jsonschema
// Schema is the in-memory representation of a JSON Schema. Fields use both
// YAML and JSON tags so the same struct round-trips through either encoding —
// form specs are authored in YAML, but data submissions arrive as JSON.
//
// Pointer-typed fields (Minimum, Maximum, MinLength, MaxLength) distinguish
// "unset" from "set to zero", which matters because zero is a valid bound.
//
// AdditionalProperties is `any` to accept either a bool (the v0-supported
// shape — only `false` is enforced; `true` and unset are both "allow") or a
// nested schema (parsed but not enforced in v0).
type Schema struct {
Type string `yaml:"type" json:"type,omitempty"`
Properties map[string]*Schema `yaml:"properties" json:"properties,omitempty"`
Required []string `yaml:"required" json:"required,omitempty"`
Items *Schema `yaml:"items" json:"items,omitempty"`
Enum []any `yaml:"enum" json:"enum,omitempty"`
Minimum *float64 `yaml:"minimum" json:"minimum,omitempty"`
Maximum *float64 `yaml:"maximum" json:"maximum,omitempty"`
MinLength *int `yaml:"minLength" json:"minLength,omitempty"`
MaxLength *int `yaml:"maxLength" json:"maxLength,omitempty"`
Format string `yaml:"format" json:"format,omitempty"`
AdditionalProperties any `yaml:"additionalProperties" json:"additionalProperties,omitempty"`
Title string `yaml:"title" json:"title,omitempty"`
Description string `yaml:"description" json:"description,omitempty"`
Default any `yaml:"default" json:"default,omitempty"`
}
// Error reports a single validation failure. Path is a JSON Pointer (RFC 6901)
// pointing at the offending value within the validated document. Both fields
// flow through the form handler unchanged into the JSON response, so the
// browser-side renderer can locate and highlight the right widget.
type Error struct {
Path string `json:"path"`
Message string `json:"message"`
}

View file

@ -0,0 +1,277 @@
package jsonschema
import (
"reflect"
"strconv"
"strings"
"unicode/utf8"
)
// Validate reports all violations of s against v. v is the decoded data —
// typically the result of json.Unmarshal or yaml.Unmarshal into `any`.
//
// Returns nil when v conforms. Returns one or more Error entries otherwise.
// Errors carry JSON-Pointer paths so the form renderer can attach each
// message to the right widget.
//
// Supported keywords (v0 subset): type, properties, required, items, enum,
// minimum, maximum, minLength, maxLength, format (date, email),
// additionalProperties: false. Everything else is silently ignored — the
// form-spec meta-schema enforces that authors stay within this subset.
func Validate(s *Schema, v any) []Error {
if s == nil {
return nil
}
var errs []Error
validate(s, v, "", &errs)
return errs
}
func validate(s *Schema, v any, path string, errs *[]Error) {
if s == nil {
return
}
// Type check first — a wrong-typed value can't satisfy the rest, and
// dispatching the constraint checks below assumes the type matches.
if s.Type != "" && !typeMatches(s.Type, v) {
*errs = append(*errs, Error{
Path: path,
Message: typeMessage(s.Type, v),
})
return
}
// enum: value must be one of the listed alternatives. Numeric comparisons
// coerce int↔float to handle JSON's float64 vs YAML's int mismatch.
if len(s.Enum) > 0 && !enumContains(s.Enum, v) {
*errs = append(*errs, Error{
Path: path,
Message: "must be one of the allowed values",
})
return
}
switch s.Type {
case "string":
validateString(s, v.(string), path, errs)
case "number", "integer":
validateNumber(s, v, path, errs)
case "object":
validateObject(s, v, path, errs)
case "array":
validateArray(s, v, path, errs)
}
}
func validateString(s *Schema, str, path string, errs *[]Error) {
n := utf8.RuneCountInString(str)
if s.MinLength != nil && n < *s.MinLength {
*errs = append(*errs, Error{Path: path, Message: minLenMsg(*s.MinLength)})
}
if s.MaxLength != nil && n > *s.MaxLength {
*errs = append(*errs, Error{Path: path, Message: maxLenMsg(*s.MaxLength)})
}
if s.Format != "" && !formatValid(s.Format, str) {
*errs = append(*errs, Error{Path: path, Message: "must be a valid " + s.Format})
}
}
func validateNumber(s *Schema, v any, path string, errs *[]Error) {
f, _ := toFloat(v)
if s.Minimum != nil && f < *s.Minimum {
*errs = append(*errs, Error{Path: path, Message: "must be ≥ " + numStr(*s.Minimum)})
}
if s.Maximum != nil && f > *s.Maximum {
*errs = append(*errs, Error{Path: path, Message: "must be ≤ " + numStr(*s.Maximum)})
}
}
func validateObject(s *Schema, v any, path string, errs *[]Error) {
obj, ok := v.(map[string]any)
if !ok {
return
}
for _, name := range s.Required {
if _, present := obj[name]; !present {
*errs = append(*errs, Error{
Path: ptrPush(path, name),
Message: "required",
})
}
}
for name, propSchema := range s.Properties {
if val, present := obj[name]; present {
validate(propSchema, val, ptrPush(path, name), errs)
}
}
// additionalProperties: false → reject anything not declared in properties.
// Only the bool-false form is enforced in v0; schema form is parsed but ignored.
if ap, ok := s.AdditionalProperties.(bool); ok && !ap {
for name := range obj {
if _, declared := s.Properties[name]; !declared {
*errs = append(*errs, Error{
Path: ptrPush(path, name),
Message: "unexpected property",
})
}
}
}
}
func validateArray(s *Schema, v any, path string, errs *[]Error) {
arr, ok := v.([]any)
if !ok {
return
}
if s.Items != nil {
for i, item := range arr {
validate(s.Items, item, ptrPushIdx(path, i), errs)
}
}
}
// --- Type / coercion helpers -------------------------------------------------
func typeMatches(t string, v any) bool {
switch t {
case "null":
return v == nil
case "boolean":
_, ok := v.(bool)
return ok
case "string":
_, ok := v.(string)
return ok
case "number":
_, ok := toFloat(v)
return ok
case "integer":
if _, ok := toFloat(v); !ok {
return false
}
return isInteger(v)
case "array":
_, ok := v.([]any)
return ok
case "object":
_, ok := v.(map[string]any)
return ok
}
return true
}
func toFloat(v any) (float64, bool) {
switch n := v.(type) {
case int:
return float64(n), true
case int8:
return float64(n), true
case int16:
return float64(n), true
case int32:
return float64(n), true
case int64:
return float64(n), true
case uint:
return float64(n), true
case uint8:
return float64(n), true
case uint16:
return float64(n), true
case uint32:
return float64(n), true
case uint64:
return float64(n), true
case float32:
return float64(n), true
case float64:
return n, true
}
return 0, false
}
func isInteger(v any) bool {
switch n := v.(type) {
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64:
return true
case float32:
f := float64(n)
return f == float64(int64(f))
case float64:
return n == float64(int64(n))
}
return false
}
// enumContains returns true when v matches one of the enum values, with
// numeric coercion (int↔float) so that JSON's float64-default doesn't reject
// an enum authored in YAML as plain integers.
func enumContains(opts []any, v any) bool {
for _, opt := range opts {
if valuesEqual(opt, v) {
return true
}
}
return false
}
func valuesEqual(a, b any) bool {
af, aok := toFloat(a)
bf, bok := toFloat(b)
if aok && bok {
return af == bf
}
return reflect.DeepEqual(a, b)
}
// --- JSON Pointer (RFC 6901) -------------------------------------------------
func ptrPush(path, segment string) string {
return path + "/" + ptrEnc(segment)
}
func ptrPushIdx(path string, idx int) string {
return path + "/" + strconv.Itoa(idx)
}
func ptrEnc(s string) string {
s = strings.ReplaceAll(s, "~", "~0")
s = strings.ReplaceAll(s, "/", "~1")
return s
}
// --- Message formatting ------------------------------------------------------
func typeMessage(want string, got any) string {
return "expected " + want + ", got " + typeName(got)
}
func typeName(v any) string {
switch v.(type) {
case nil:
return "null"
case bool:
return "boolean"
case string:
return "string"
case []any:
return "array"
case map[string]any:
return "object"
}
if _, ok := toFloat(v); ok {
return "number"
}
return "unknown"
}
func minLenMsg(n int) string { return "must be at least " + strconv.Itoa(n) + " characters" }
func maxLenMsg(n int) string { return "must be at most " + strconv.Itoa(n) + " characters" }
func numStr(f float64) string {
return strconv.FormatFloat(f, 'g', -1, 64)
}

View file

@ -8,43 +8,26 @@ import (
"gopkg.in/yaml.v3"
)
// WriteFile atomically writes zf as YAML to <dirPath>/.zddc.
// WriteAtomic writes data to absPath atomically. The parent directory is
// created (mode 0o755) if it does not exist; the file is written with mode
// 0o644.
//
// The YAML round-trips through Marshal then Unmarshal as a sanity check —
// this catches struct-encoding bugs before they hit disk and ensures the
// file we produce is parseable by ParseFile (which is what every reader
// uses). On any failure the original file is untouched.
//
// Atomicity: the encoded bytes are written to a sibling temp file, fsync'd,
// and renamed onto the target. The cache for dirPath (and descendants) is
// invalidated after the rename so the next EffectivePolicy call reads
// fresh content.
func WriteFile(dirPath string, zf ZddcFile) error {
dirPath = filepath.Clean(dirPath)
if err := os.MkdirAll(dirPath, 0o755); err != nil {
// Implementation: bytes go to a sibling temp file, are fsync'd, then renamed
// onto absPath. On any failure the temp is removed and absPath is untouched.
// Knows nothing about caches — callers that need cache invalidation
// (.zddc, the apps cascade, etc.) handle it themselves.
func WriteAtomic(absPath string, data []byte) error {
dir := filepath.Dir(absPath)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("ensure dir: %w", err)
}
data, err := yaml.Marshal(&zf)
if err != nil {
return fmt.Errorf("marshal: %w", err)
}
// Sanity round-trip: re-parse what we just produced. If this fails the
// in-memory struct does not survive a write/read cycle and we should
// abort before touching disk.
var probe ZddcFile
if err := yaml.Unmarshal(data, &probe); err != nil {
return fmt.Errorf("round-trip parse: %w", err)
}
target := filepath.Join(dirPath, ".zddc")
tmp, err := os.CreateTemp(dirPath, ".zddc.*.tmp")
base := filepath.Base(absPath)
tmp, err := os.CreateTemp(dir, "."+base+".*.tmp")
if err != nil {
return fmt.Errorf("create temp: %w", err)
}
tmpPath := tmp.Name()
// Best-effort cleanup if anything below fails.
defer func() {
_ = os.Remove(tmpPath)
}()
@ -63,9 +46,42 @@ func WriteFile(dirPath string, zf ZddcFile) error {
if err := os.Chmod(tmpPath, 0o644); err != nil {
return fmt.Errorf("chmod temp: %w", err)
}
if err := os.Rename(tmpPath, target); err != nil {
if err := os.Rename(tmpPath, absPath); err != nil {
return fmt.Errorf("rename: %w", err)
}
return nil
}
// WriteFile atomically writes zf as YAML to <dirPath>/.zddc.
//
// The YAML round-trips through Marshal then Unmarshal as a sanity check —
// this catches struct-encoding bugs before they hit disk and ensures the
// file we produce is parseable by ParseFile (which is what every reader
// uses). On any failure the original file is untouched.
//
// After the write succeeds the policy and scan caches for dirPath (and
// descendants) are invalidated so the next EffectivePolicy / ScanZddcFiles
// call reads fresh content.
func WriteFile(dirPath string, zf ZddcFile) error {
dirPath = filepath.Clean(dirPath)
data, err := yaml.Marshal(&zf)
if err != nil {
return fmt.Errorf("marshal: %w", err)
}
// Sanity round-trip: re-parse what we just produced. If this fails the
// in-memory struct does not survive a write/read cycle and we should
// abort before touching disk.
var probe ZddcFile
if err := yaml.Unmarshal(data, &probe); err != nil {
return fmt.Errorf("round-trip parse: %w", err)
}
target := filepath.Join(dirPath, ".zddc")
if err := WriteAtomic(target, data); err != nil {
return err
}
InvalidateCache(dirPath)
InvalidateScanCache()