From 9ca36f25d866a6e55857edf91ef13e5f0ffb91e4 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Tue, 5 May 2026 20:32:01 -0500 Subject: [PATCH] feat(tables): new sortable/filterable grid tool for directories of YAML files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tables is the eighth HTML tool: a read-only tabular view over a directory of YAML files declared via `tables:` in `.zddc`. Anchor use case is the Master Deliverables List, where each row is one `.yaml` under `Archive//MDL/`. Rows click through to the existing form renderer for editing. Schema (zddc/internal/zddc/file.go) - New `Tables map[string]string` on ZddcFile. Map key becomes the URL stem (`tables[MDL]` → `/MDL.table.html`); the value is a path relative to the .zddc pointing at a `*.table.yaml` spec describing columns + the rows directory. No upward cascade in v1 — each directory hosting a table declares it directly. Server handler (zddc/internal/handler/tablehandler.go) - `RecognizeTableRequest` matches GET `//.table.html` against the cascade's `tables:` declarations. Dispatch routes table requests before the form-system intercept. - `ServeTable` ACL-gates with `policy.ActionRead` and serves the embedded `tables.html` template; client walks the directory itself via the listing JSON or FS Access API. - tables.html embedded via //go:embed — same pattern as form.html. Frontend (tables/) - Vanilla JS: app/context/util/filters/sort/render/main modules. - Reads spec + row YAML files via window.zddc.source (HTTP polyfill or local FS handle); js-yaml 4.1.0 vendored in shared/vendor for client-side parsing. - Sample fixtures under tables/sample/ for local testing. Build + CI - Lockstep build registers tables alongside the other 7 tools (HTML output, embed mirror, versions.txt, release-output, tags). - Playwright project added; `npx playwright test --project=tables` is part of `npm test`. Drive-by: rename mdedit Playwright selectors `#select-directory` → `#addDirectoryBtn` to fix three pre-existing failing tests. Drive-by: ignore locally-built `zddc/zddc-server` binary so it doesn't get accidentally staged. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 6 + AGENTS.md | 67 +- README.md | 2 + build | 19 +- playwright.config.js | 4 + shared/build-lib.sh | 4 +- shared/vendor/js-yaml.min.js | 2 + tables/README.md | 94 + tables/build.sh | 78 + tables/css/table.css | 124 + tables/js/app.js | 15 + tables/js/context.js | 180 ++ tables/js/filters.js | 64 + tables/js/main.js | 104 + tables/js/render.js | 118 + tables/js/sort.js | 108 + tables/js/util.js | 151 ++ tables/sample/.zddc | 18 + tables/sample/MDL.form.yaml | 39 + tables/sample/MDL.table.yaml | 41 + tables/sample/MDL/D-001.yaml | 6 + tables/sample/MDL/D-002.yaml | 6 + tables/sample/MDL/D-003.yaml | 6 + tables/sample/MDL/D-004.yaml | 6 + tables/sample/MDL/D-005.yaml | 5 + tables/template.html | 108 + tests/mdedit.spec.js | 14 +- tests/tables.spec.js | 207 ++ zddc/cmd/zddc-server/main.go | 12 + zddc/internal/handler/tablehandler.go | 138 ++ zddc/internal/handler/tablehandler_test.go | 229 ++ zddc/internal/handler/tables.html | 2382 ++++++++++++++++++++ zddc/internal/zddc/file.go | 8 + zddc/internal/zddc/file_test.go | 76 + 34 files changed, 4415 insertions(+), 26 deletions(-) create mode 100644 shared/vendor/js-yaml.min.js create mode 100644 tables/README.md create mode 100755 tables/build.sh create mode 100644 tables/css/table.css create mode 100644 tables/js/app.js create mode 100644 tables/js/context.js create mode 100644 tables/js/filters.js create mode 100644 tables/js/main.js create mode 100644 tables/js/render.js create mode 100644 tables/js/sort.js create mode 100644 tables/js/util.js create mode 100644 tables/sample/.zddc create mode 100644 tables/sample/MDL.form.yaml create mode 100644 tables/sample/MDL.table.yaml create mode 100644 tables/sample/MDL/D-001.yaml create mode 100644 tables/sample/MDL/D-002.yaml create mode 100644 tables/sample/MDL/D-003.yaml create mode 100644 tables/sample/MDL/D-004.yaml create mode 100644 tables/sample/MDL/D-005.yaml create mode 100644 tables/template.html create mode 100644 tests/tables.spec.js create mode 100644 zddc/internal/handler/tablehandler.go create mode 100644 zddc/internal/handler/tablehandler_test.go create mode 100644 zddc/internal/handler/tables.html create mode 100644 zddc/internal/zddc/file_test.go diff --git a/.gitignore b/.gitignore index eec5842..ed34e3f 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,12 @@ test-results/ # and reproducible from any -vX.Y.Z tag. dist/ +# Locally-compiled zddc-server binary. `(cd zddc && go build ./cmd/zddc-server)` +# drops the binary at zddc/zddc-server; the canonical released artifacts live +# under dist/release-output/zddc-server_* with platform suffixes and signing. +zddc/zddc-server +zddc/zddc-server.exe + # IDE and project files .opencode/ opencode.json diff --git a/AGENTS.md b/AGENTS.md index 3196c47..fe8e960 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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|form +sh tool/build.sh # archive|transmittal|classifier|mdedit|landing|form|tables|browse # 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 [|alpha|beta] npm test # Test single tool -npx playwright test tool # archive | transmittal | classifier | mdedit | form-safety +npx playwright test tool # archive | transmittal | classifier | mdedit | form-safety | tables # 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 -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. +Eight independent single-file HTML tools (`archive`, `transmittal`, `classifier`, `mdedit`, `landing`, `form`, `tables`, `browse`). 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. `form` is the schema-driven renderer used by zddc-server's form-data system; `tables` is its read/aggregate counterpart, rendering a directory of YAML files declared in `.zddc tables:` as a sortable table whose rows click through to the form editor (see "Form-data system" and "Tables system" below). ``` tool/ @@ -202,20 +202,20 @@ Format: `trackingNumber_revision (status) - title.extension` - Feature-branch workflow; squash-merge feature branches to `main` - Conventional commits: `feat(archive): ...`, `fix(transmittal): ...` -- Release tags: `-v` 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`) +- Release tags: `-v` per tool, all nine 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`, `tables-v0.0.8`, `browse-v0.0.8`, `zddc-server-v0.0.8`) - `dist/` is gitignored. Build artifacts (per-tool `dist/.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 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). +**Lockstep convention.** Every release cut bumps all nine artifacts (8 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 nine 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 `-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 | |---|---|---| -| `_v.html` | real, immutable | per-version HTML for each of archive, transmittal, classifier, mdedit, landing, form | +| `_v.html` | real, immutable | per-version HTML for each of archive, transmittal, classifier, mdedit, landing, form, tables, browse | | `_v.html`, `_v.html` | symlinks | partial-version pins | | `_.html` | symlink (or real bytes during active channel dev) | mutable channel mirror per tool, channel ∈ {stable, beta, alpha} | | `zddc-server_v_` | real binary | per-version cross-compiled binary, platform ∈ {linux-amd64, darwin-amd64, darwin-arm64, windows-amd64.exe} | @@ -367,15 +367,58 @@ Go HTTP server sub-project living at `zddc/`. Replaces `caddy file-server --brow zddc-server ships as a cross-compiled binary, not a container image. There's no Containerfile or compose file in this repo (the chart Dockerfiles compile from source at deploy time at the right tag). ```sh -# Compile a local binary for the host platform (requires Go 1.24+) -(cd zddc && go build -o zddc-server ./cmd/zddc-server) +# Compile a local binary for the host platform via the build image. +# Same flag pattern as Test below — see that subsection for why. +podman run --rm --network=host -v "$PWD":/src:Z -v /tmp/gocache:/root/go/pkg/mod:Z \ + -w /src/zddc -e GOPROXY=https://proxy.golang.org -e GOSUMDB=off -e GOPRIVATE= \ + localhost/zddc-go:1.24 go build -o zddc-server ./cmd/zddc-server -# Or run directly without producing a binary -(cd zddc && go run ./cmd/zddc-server) +# `go run` is normally a one-liner but in-container `go run` of a network +# server is awkward — for dev iteration, build with the line above and +# launch the binary on the host (`./zddc/zddc-server`). ``` The repo's top-level `./build` cross-compiles the four release binaries (linux/amd64, darwin/amd64, darwin/arm64, windows/amd64) into `zddc/dist/` via a containerized Go toolchain (podman or docker). On `./build alpha|beta|release` it also promotes those binaries to `dist/release-output/` with the matching symlink chain and stub pages — same lockstep flow as the HTML tools. `./deploy` rsyncs the bundle to `/srv/zddc/releases/`. +### Test + +Go is **not** installed on the dev shell host directly — run `go test` (and `go build`) through a golang-alpine image. `./build`'s containerized cross-compile already pulls `golang:1.24-alpine` as a build stage; tag it for reuse: + +```sh +# One-time: locate the golang-alpine image (~810 MB, untagged after a build run) +# and give it a stable name. The size and `golang/.../go test` lines distinguish +# it from the small ~18 MB zddc-server runtime image. +podman images --filter dangling=true --format '{{.Size}}\t{{.ID}}' | sort -h | tail +podman tag localhost/zddc-go:1.24 + +# If you have no 810 MB image (fresh machine), pull directly: +podman pull docker.io/library/golang:1.24-alpine +podman tag docker.io/library/golang:1.24-alpine localhost/zddc-go:1.24 +``` + + Canonical invocation: + +```sh +podman run --rm --network=host \ + -v "$PWD":/src:Z \ + -v /tmp/gocache:/root/go/pkg/mod:Z \ + -w /src/zddc \ + -e GOPROXY=https://proxy.golang.org \ + -e GOSUMDB=off \ + -e GOPRIVATE= \ + localhost/zddc-go:1.24 \ + go test ./... +``` + +Why each flag: +- `--network=host` — the alpine image's TLS chain can't shake hands with `gopkg.in` directly (sandbox hits "Connection reset by peer"); host networking + the proxy below works around it. +- `GOPROXY=https://proxy.golang.org` — fetch via the public proxy. Without this, the build-image's baked `GOPRIVATE=*` forces direct VCS, which fails on `gopkg.in/natefinch/lumberjack.v2`. +- `GOSUMDB=off` — sum.golang.org isn't reachable from the sandbox either; we already trust the proxy. +- `GOPRIVATE=` (empty) — explicit override of the image's `GOPRIVATE=*`, which is a leftover from how `./build` does in-container compilation and would otherwise re-trigger direct fetch. +- `/tmp/gocache` mount — persistent module cache across runs. + +Run-it-once-per-session pattern: alias it. Do **not** `apt install golang` on the host — the image is the source of truth for the version pin, so dev and CI compile against the same Go. + ### Run (development) ```sh @@ -432,9 +475,9 @@ local path that fails loudly and visibly on the developer's terminal. ### Notes -- No external test framework yet — Go unit tests run with `go test ./...` inside `zddc/` (requires Go 1.24+) +- No external test framework yet — Go unit tests run with `go test ./...` inside `zddc/`. The Go toolchain is not on the host; use the `localhost/zddc-go:1.24` image as documented in the **Test** subsection above. - Portfolio files (`*.portfolio`) in the served tree appear as virtual group directories -- Every folder under a project exposes a `.archive` virtual directory backed by that **project's** index bucket — the project is the first slash-separated segment of the contextPath. Depth within a project doesn't change scope: `/ProjectA/sub/sub/.archive/X.html` resolves the same as `/ProjectA/.archive/X.html`, just with a different URL prefix on the listing entries. The flat listing emits two redirect entries per tracking number: `.html` (highest base rev) and `_.html` (each specific base rev). Both redirect to the first chronologically received copy within that project. Modifier files (`_+C1.html` etc.) remain reachable via the resolver but are not surfaced in the listing — they're return traffic, not primary documents. `/.archive/` at the very root has no project segment and returns 404 — stable references must include the project directory. Within one project, two different files claiming the same `(tracking, rev)` are an authoring mistake; chronological winner still wins, but a `WARN` is emitted with both paths. ACL is enforced twice: the listing endpoint is gated by the contextPath's `.zddc` chain, and each entry is then filtered against the ACL of its resolved file's directory — per-target denials return 404 rather than 403 to avoid leaking that the tracking number exists in another subtree. +- Every folder under a project exposes a `.archive` virtual directory backed by that **project's** index bucket — the project is the first slash-separated segment of the contextPath. Depth within a project doesn't change scope: `/ProjectA/sub/sub/.archive/X.html` resolves the same as `/ProjectA/.archive/X.html`, just with a different URL prefix on the listing entries. The flat listing emits two entries per tracking number: `.html` (highest base rev) and `_.html` (each specific base rev). Both **serve in place** — the handler streams the first chronologically received copy's bytes back at the `.archive/` URL without redirecting. The per-transmittal URL is intentionally hidden so external links of the form `.archive/.html#section` keep tracking the latest revision (a redirect would expose the snapshot URL and people would forward THAT instead). Cache-Control is `no-cache` so each load revalidates against the on-disk file's Last-Modified/ETag; when a new revision lands the resolver picks it and the browser refetches. Modifier files (`_+C1.html` etc.) remain reachable via the resolver but are not surfaced in the listing — they're return traffic, not primary documents. `/.archive/` at the very root has no project segment and returns 404 — stable references must include the project directory. Within one project, two different files claiming the same `(tracking, rev)` are an authoring mistake; chronological winner still wins, but a `WARN` is emitted with both paths. ACL is enforced twice: the listing endpoint is gated by the contextPath's `.zddc` chain, and each entry is then filtered against the ACL of its resolved file's directory — per-target denials return 404 rather than 403 to avoid leaking that the tracking number exists in another subtree. - ACL is enforced via cascading `.zddc` YAML files — first-explicit-match-wins evaluated bottom-up (deepest level first), with deny checked before allow within a single `.zddc`; default-deny when any `.zddc` exists in the chain. Authentication is delegated to the upstream proxy via the `X-Auth-Request-Email` header (configurable with `ZDDC_EMAIL_HEADER`). Operator-facing detail, anti-patterns, worked layouts, the verify-it-works recipe, and the federal-readiness gap analysis are in `zddc/README.md` § "Access control: the `.zddc` cascade." The architectural framing (cooperating layers, commercial-vs-federal trust model, why archive auto-serves at every directory) is in `ARCHITECTURE.md` § "Server security model." - `.zddc` schema also supports a top-level `admins:` glob list, peer to `acl.allow`/`acl.deny`. Honored **only** at the root `.zddc` (subdir `admins` entries are ignored to prevent privilege escalation via subtree write access). Drives the built-in debug dashboard at `/.admin/` (sub-routes: `/whoami`, `/config`, `/logs`); non-admin requests get 404 so the page is invisible. See `zddc/README.md` § "Admin Debug Page". - `GET /.auth/admin` is a **forward_auth target** for upstream proxies — returns 200 if the request's `X-Auth-Request-Email` is in the root `.zddc` `admins:` list, 403 otherwise. No body, no UI. Used by the dev-shell pod's Caddy to gate `/devshell/*` (code-server) on root-admin status without code-server learning about auth. zddc-server's own routes use the regular `.zddc` cascade ACL — they do NOT go through this endpoint. diff --git a/README.md b/README.md index 350bec9..a73d46a 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ The name "Zero Day Document Control" comes from the convention itself — adopt | **Transmittal Creator** | Self-contained HTML transmittal records with SHA-256 checksums and optional digital signatures. | | **Document Classifier** | Spreadsheet-like bulk-renamer that copy/pastes with Excel and writes back to disk. | | **Markdown Editor** | Browser-based markdown editor with YAML front matter, TOC, and direct local file access. | +| **Form Renderer** | Schema-driven `*.form.yaml` editor — every form spec auto-mounts an editable form at `.form.html`. | +| **Tables** | Read-only grid view of a directory of YAML files with sort + filter; click row → edit in the form renderer. Declared per-directory in `.zddc`. | Each tool is published in three channels (stable, beta, alpha) as static files served from . **Local use:** download a `.html` file from `releases/` and open it in a browser. **Server use:** run `zddc-server` — the current-stable build of every tool is baked into the binary at compile time, so a fresh deployment Just Works with zero config. Tools auto-appear at folder-name-driven paths (archive everywhere; classifier in `Incoming`/`Working`/`Staging`; mdedit in `Working`; transmittal in `Staging`). Override per-directory by writing an `apps:` entry in any `.zddc` file (channel/version/URL/path). URL overrides are fetched once and cached in `/_app/`; drop a real `.html` file at any path to override entirely. diff --git a/build b/build index 071a51c..b81e86a 100755 --- a/build +++ b/build @@ -157,6 +157,7 @@ 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 +sh "$SCRIPT_DIR/tables/build.sh" $TOOL_RELEASE_ARGS sh "$SCRIPT_DIR/browse/build.sh" $TOOL_RELEASE_ARGS echo "" @@ -177,8 +178,9 @@ cp "$SCRIPT_DIR/transmittal/dist/transmittal.html" "$SCRIPT_DIR/zddc/dist/web/ 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" cp "$SCRIPT_DIR/form/dist/form.html" "$SCRIPT_DIR/zddc/dist/web/form.html" +cp "$SCRIPT_DIR/tables/dist/tables.html" "$SCRIPT_DIR/zddc/dist/web/tables.html" cp "$SCRIPT_DIR/browse/dist/browse.html" "$SCRIPT_DIR/zddc/dist/web/browse.html" -echo "Wrote zddc/dist/web/{index,archive,transmittal,classifier,mdedit,form,browse}.html" +echo "Wrote zddc/dist/web/{index,archive,transmittal,classifier,mdedit,form,tables,browse}.html" # 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. ONLY happens @@ -203,6 +205,12 @@ if [ "$RELEASE_CHANNEL" = "beta" ] || [ "$RELEASE_CHANNEL" = "stable" ]; then cp "$SCRIPT_DIR/form/dist/form.html" "$SCRIPT_DIR/zddc/internal/handler/form.html" echo "Populated zddc/internal/handler/form.html for //go:embed" + # Same pattern for the tables renderer — embedded directly into the + # handler package (read-only directory-of-YAML view; not subject to + # per-folder version overrides). + cp "$SCRIPT_DIR/tables/dist/tables.html" "$SCRIPT_DIR/zddc/internal/handler/tables.html" + echo "Populated zddc/internal/handler/tables.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 @@ -210,7 +218,7 @@ if [ "$RELEASE_CHANNEL" = "beta" ] || [ "$RELEASE_CHANNEL" = "stable" ]; then VERSIONS_FILE="$EMBED_DIR/versions.txt" { echo "# Generated by build.sh — do not edit. One = per line." - for _tool in archive transmittal classifier mdedit landing form browse; do + for _tool in archive transmittal classifier mdedit landing form tables browse; do _label_file="$BUILD_LABELS_DIR/${_tool}.label" if [ -f "$_label_file" ]; then _label=$(cat "$_label_file") @@ -959,7 +967,8 @@ if [ "$RELEASE_CHANNEL" = "stable" ]; then # that the binary needs at //go:embed time + the embedded form # template. git -C "$SCRIPT_DIR" add "$EMBED_DIR/" \ - "$SCRIPT_DIR/zddc/internal/handler/form.html" + "$SCRIPT_DIR/zddc/internal/handler/form.html" \ + "$SCRIPT_DIR/zddc/internal/handler/tables.html" if ! git -C "$SCRIPT_DIR" diff --cached --quiet; then git -C "$SCRIPT_DIR" commit -m "release: v${RELEASE_VERSION} lockstep" @@ -971,7 +980,7 @@ if [ "$RELEASE_CHANNEL" = "stable" ]; then # Tag the seven artifacts at HEAD. Pre-flight already validated that # any pre-existing tag is in HEAD's history, so this is safe. _head=$(git -C "$SCRIPT_DIR" rev-parse HEAD) - for _t in archive transmittal classifier mdedit landing form browse zddc-server; do + for _t in archive transmittal classifier mdedit landing form tables browse zddc-server; do _tag="${_t}-v${RELEASE_VERSION}" if git -C "$SCRIPT_DIR" rev-parse -q --verify "refs/tags/$_tag" >/dev/null; then _existing=$(git -C "$SCRIPT_DIR" rev-list -n 1 "$_tag") @@ -1005,7 +1014,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 form browse zddc-server; do + for _t in archive transmittal classifier mdedit landing form tables browse zddc-server; do echo " ${_t}-v${RELEASE_VERSION}" done echo " git push origin main && git push origin --tags" diff --git a/playwright.config.js b/playwright.config.js index 6df9e5a..e8b6eb6 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -55,6 +55,10 @@ export default defineConfig({ name: 'form-safety', testMatch: 'form-safety.spec.js', }, + { + name: 'tables', + testMatch: 'tables.spec.js', + }, { name: 'zddc-filter', testMatch: 'zddc-filter.spec.js', diff --git a/shared/build-lib.sh b/shared/build-lib.sh index 5bb74e5..2808d51 100755 --- a/shared/build-lib.sh +++ b/shared/build-lib.sh @@ -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 form browse zddc-server" +ZDDC_RELEASE_TOOLS="archive transmittal classifier mdedit landing form tables browse zddc-server" # Compute the next-stable target for a single tool — patch-bump of its own # latest -vX.Y.Z tag. Used by compute_build_label so a tool's @@ -663,7 +663,7 @@ verify_channel_links() { _missing=0 _verified=0 - for _t in archive transmittal classifier mdedit landing form browse; do + for _t in archive transmittal classifier mdedit landing form tables browse; do for _ch in stable beta alpha; do _f="$_rdir/${_t}_${_ch}.html" if [ -e "$_f" ]; then diff --git a/shared/vendor/js-yaml.min.js b/shared/vendor/js-yaml.min.js new file mode 100644 index 0000000..bdd8eef --- /dev/null +++ b/shared/vendor/js-yaml.min.js @@ -0,0 +1,2 @@ +/*! js-yaml 4.1.0 https://github.com/nodeca/js-yaml @license MIT */ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).jsyaml={})}(this,(function(e){"use strict";function t(e){return null==e}var n={isNothing:t,isObject:function(e){return"object"==typeof e&&null!==e},toArray:function(e){return Array.isArray(e)?e:t(e)?[]:[e]},repeat:function(e,t){var n,i="";for(n=0;nl&&(t=i-l+(o=" ... ").length),n-i>l&&(n=i+l-(a=" ...").length),{str:o+e.slice(t,n).replace(/\t/g,"→")+a,pos:i-t+o.length}}function l(e,t){return n.repeat(" ",t-e.length)+e}var c=function(e,t){if(t=Object.create(t||null),!e.buffer)return null;t.maxLength||(t.maxLength=79),"number"!=typeof t.indent&&(t.indent=1),"number"!=typeof t.linesBefore&&(t.linesBefore=3),"number"!=typeof t.linesAfter&&(t.linesAfter=2);for(var i,r=/\r?\n|\r|\0/g,o=[0],c=[],s=-1;i=r.exec(e.buffer);)c.push(i.index),o.push(i.index+i[0].length),e.position<=i.index&&s<0&&(s=o.length-2);s<0&&(s=o.length-1);var u,p,f="",d=Math.min(e.line+t.linesAfter,c.length).toString().length,h=t.maxLength-(t.indent+d+3);for(u=1;u<=t.linesBefore&&!(s-u<0);u++)p=a(e.buffer,o[s-u],c[s-u],e.position-(o[s]-o[s-u]),h),f=n.repeat(" ",t.indent)+l((e.line-u+1).toString(),d)+" | "+p.str+"\n"+f;for(p=a(e.buffer,o[s],c[s],e.position,h),f+=n.repeat(" ",t.indent)+l((e.line+1).toString(),d)+" | "+p.str+"\n",f+=n.repeat("-",t.indent+d+3+p.pos)+"^\n",u=1;u<=t.linesAfter&&!(s+u>=c.length);u++)p=a(e.buffer,o[s+u],c[s+u],e.position-(o[s]-o[s+u]),h),f+=n.repeat(" ",t.indent)+l((e.line+u+1).toString(),d)+" | "+p.str+"\n";return f.replace(/\n$/,"")},s=["kind","multi","resolve","construct","instanceOf","predicate","represent","representName","defaultStyle","styleAliases"],u=["scalar","sequence","mapping"];var p=function(e,t){if(t=t||{},Object.keys(t).forEach((function(t){if(-1===s.indexOf(t))throw new o('Unknown option "'+t+'" is met in definition of "'+e+'" YAML type.')})),this.options=t,this.tag=e,this.kind=t.kind||null,this.resolve=t.resolve||function(){return!0},this.construct=t.construct||function(e){return e},this.instanceOf=t.instanceOf||null,this.predicate=t.predicate||null,this.represent=t.represent||null,this.representName=t.representName||null,this.defaultStyle=t.defaultStyle||null,this.multi=t.multi||!1,this.styleAliases=function(e){var t={};return null!==e&&Object.keys(e).forEach((function(n){e[n].forEach((function(e){t[String(e)]=n}))})),t}(t.styleAliases||null),-1===u.indexOf(this.kind))throw new o('Unknown kind "'+this.kind+'" is specified for "'+e+'" YAML type.')};function f(e,t){var n=[];return e[t].forEach((function(e){var t=n.length;n.forEach((function(n,i){n.tag===e.tag&&n.kind===e.kind&&n.multi===e.multi&&(t=i)})),n[t]=e})),n}function d(e){return this.extend(e)}d.prototype.extend=function(e){var t=[],n=[];if(e instanceof p)n.push(e);else if(Array.isArray(e))n=n.concat(e);else{if(!e||!Array.isArray(e.implicit)&&!Array.isArray(e.explicit))throw new o("Schema.extend argument should be a Type, [ Type ], or a schema definition ({ implicit: [...], explicit: [...] })");e.implicit&&(t=t.concat(e.implicit)),e.explicit&&(n=n.concat(e.explicit))}t.forEach((function(e){if(!(e instanceof p))throw new o("Specified list of YAML types (or a single Type object) contains a non-Type object.");if(e.loadKind&&"scalar"!==e.loadKind)throw new o("There is a non-scalar type in the implicit list of a schema. Implicit resolving of such types is not supported.");if(e.multi)throw new o("There is a multi type in the implicit list of a schema. Multi tags can only be listed as explicit.")})),n.forEach((function(e){if(!(e instanceof p))throw new o("Specified list of YAML types (or a single Type object) contains a non-Type object.")}));var i=Object.create(d.prototype);return i.implicit=(this.implicit||[]).concat(t),i.explicit=(this.explicit||[]).concat(n),i.compiledImplicit=f(i,"implicit"),i.compiledExplicit=f(i,"explicit"),i.compiledTypeMap=function(){var e,t,n={scalar:{},sequence:{},mapping:{},fallback:{},multi:{scalar:[],sequence:[],mapping:[],fallback:[]}};function i(e){e.multi?(n.multi[e.kind].push(e),n.multi.fallback.push(e)):n[e.kind][e.tag]=n.fallback[e.tag]=e}for(e=0,t=arguments.length;e=0?"0b"+e.toString(2):"-0b"+e.toString(2).slice(1)},octal:function(e){return e>=0?"0o"+e.toString(8):"-0o"+e.toString(8).slice(1)},decimal:function(e){return e.toString(10)},hexadecimal:function(e){return e>=0?"0x"+e.toString(16).toUpperCase():"-0x"+e.toString(16).toUpperCase().slice(1)}},defaultStyle:"decimal",styleAliases:{binary:[2,"bin"],octal:[8,"oct"],decimal:[10,"dec"],hexadecimal:[16,"hex"]}}),x=new RegExp("^(?:[-+]?(?:[0-9][0-9_]*)(?:\\.[0-9_]*)?(?:[eE][-+]?[0-9]+)?|\\.[0-9_]+(?:[eE][-+]?[0-9]+)?|[-+]?\\.(?:inf|Inf|INF)|\\.(?:nan|NaN|NAN))$");var I=/^[-+]?[0-9]+e/;var S=new p("tag:yaml.org,2002:float",{kind:"scalar",resolve:function(e){return null!==e&&!(!x.test(e)||"_"===e[e.length-1])},construct:function(e){var t,n;return n="-"===(t=e.replace(/_/g,"").toLowerCase())[0]?-1:1,"+-".indexOf(t[0])>=0&&(t=t.slice(1)),".inf"===t?1===n?Number.POSITIVE_INFINITY:Number.NEGATIVE_INFINITY:".nan"===t?NaN:n*parseFloat(t,10)},predicate:function(e){return"[object Number]"===Object.prototype.toString.call(e)&&(e%1!=0||n.isNegativeZero(e))},represent:function(e,t){var i;if(isNaN(e))switch(t){case"lowercase":return".nan";case"uppercase":return".NAN";case"camelcase":return".NaN"}else if(Number.POSITIVE_INFINITY===e)switch(t){case"lowercase":return".inf";case"uppercase":return".INF";case"camelcase":return".Inf"}else if(Number.NEGATIVE_INFINITY===e)switch(t){case"lowercase":return"-.inf";case"uppercase":return"-.INF";case"camelcase":return"-.Inf"}else if(n.isNegativeZero(e))return"-0.0";return i=e.toString(10),I.test(i)?i.replace("e",".e"):i},defaultStyle:"lowercase"}),O=b.extend({implicit:[A,v,C,S]}),j=O,T=new RegExp("^([0-9][0-9][0-9][0-9])-([0-9][0-9])-([0-9][0-9])$"),N=new RegExp("^([0-9][0-9][0-9][0-9])-([0-9][0-9]?)-([0-9][0-9]?)(?:[Tt]|[ \\t]+)([0-9][0-9]?):([0-9][0-9]):([0-9][0-9])(?:\\.([0-9]*))?(?:[ \\t]*(Z|([-+])([0-9][0-9]?)(?::([0-9][0-9]))?))?$");var F=new p("tag:yaml.org,2002:timestamp",{kind:"scalar",resolve:function(e){return null!==e&&(null!==T.exec(e)||null!==N.exec(e))},construct:function(e){var t,n,i,r,o,a,l,c,s=0,u=null;if(null===(t=T.exec(e))&&(t=N.exec(e)),null===t)throw new Error("Date resolve error");if(n=+t[1],i=+t[2]-1,r=+t[3],!t[4])return new Date(Date.UTC(n,i,r));if(o=+t[4],a=+t[5],l=+t[6],t[7]){for(s=t[7].slice(0,3);s.length<3;)s+="0";s=+s}return t[9]&&(u=6e4*(60*+t[10]+ +(t[11]||0)),"-"===t[9]&&(u=-u)),c=new Date(Date.UTC(n,i,r,o,a,l,s)),u&&c.setTime(c.getTime()-u),c},instanceOf:Date,represent:function(e){return e.toISOString()}});var E=new p("tag:yaml.org,2002:merge",{kind:"scalar",resolve:function(e){return"<<"===e||null===e}}),M="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=\n\r";var L=new p("tag:yaml.org,2002:binary",{kind:"scalar",resolve:function(e){if(null===e)return!1;var t,n,i=0,r=e.length,o=M;for(n=0;n64)){if(t<0)return!1;i+=6}return i%8==0},construct:function(e){var t,n,i=e.replace(/[\r\n=]/g,""),r=i.length,o=M,a=0,l=[];for(t=0;t>16&255),l.push(a>>8&255),l.push(255&a)),a=a<<6|o.indexOf(i.charAt(t));return 0===(n=r%4*6)?(l.push(a>>16&255),l.push(a>>8&255),l.push(255&a)):18===n?(l.push(a>>10&255),l.push(a>>2&255)):12===n&&l.push(a>>4&255),new Uint8Array(l)},predicate:function(e){return"[object Uint8Array]"===Object.prototype.toString.call(e)},represent:function(e){var t,n,i="",r=0,o=e.length,a=M;for(t=0;t>18&63],i+=a[r>>12&63],i+=a[r>>6&63],i+=a[63&r]),r=(r<<8)+e[t];return 0===(n=o%3)?(i+=a[r>>18&63],i+=a[r>>12&63],i+=a[r>>6&63],i+=a[63&r]):2===n?(i+=a[r>>10&63],i+=a[r>>4&63],i+=a[r<<2&63],i+=a[64]):1===n&&(i+=a[r>>2&63],i+=a[r<<4&63],i+=a[64],i+=a[64]),i}}),_=Object.prototype.hasOwnProperty,D=Object.prototype.toString;var U=new p("tag:yaml.org,2002:omap",{kind:"sequence",resolve:function(e){if(null===e)return!0;var t,n,i,r,o,a=[],l=e;for(t=0,n=l.length;t>10),56320+(e-65536&1023))}for(var ie=new Array(256),re=new Array(256),oe=0;oe<256;oe++)ie[oe]=te(oe)?1:0,re[oe]=te(oe);function ae(e,t){this.input=e,this.filename=t.filename||null,this.schema=t.schema||K,this.onWarning=t.onWarning||null,this.legacy=t.legacy||!1,this.json=t.json||!1,this.listener=t.listener||null,this.implicitTypes=this.schema.compiledImplicit,this.typeMap=this.schema.compiledTypeMap,this.length=e.length,this.position=0,this.line=0,this.lineStart=0,this.lineIndent=0,this.firstTabInLine=-1,this.documents=[]}function le(e,t){var n={name:e.filename,buffer:e.input.slice(0,-1),position:e.position,line:e.line,column:e.position-e.lineStart};return n.snippet=c(n),new o(t,n)}function ce(e,t){throw le(e,t)}function se(e,t){e.onWarning&&e.onWarning.call(null,le(e,t))}var ue={YAML:function(e,t,n){var i,r,o;null!==e.version&&ce(e,"duplication of %YAML directive"),1!==n.length&&ce(e,"YAML directive accepts exactly one argument"),null===(i=/^([0-9]+)\.([0-9]+)$/.exec(n[0]))&&ce(e,"ill-formed argument of the YAML directive"),r=parseInt(i[1],10),o=parseInt(i[2],10),1!==r&&ce(e,"unacceptable YAML version of the document"),e.version=n[0],e.checkLineBreaks=o<2,1!==o&&2!==o&&se(e,"unsupported YAML version of the document")},TAG:function(e,t,n){var i,r;2!==n.length&&ce(e,"TAG directive accepts exactly two arguments"),i=n[0],r=n[1],G.test(i)||ce(e,"ill-formed tag handle (first argument) of the TAG directive"),P.call(e.tagMap,i)&&ce(e,'there is a previously declared suffix for "'+i+'" tag handle'),V.test(r)||ce(e,"ill-formed tag prefix (second argument) of the TAG directive");try{r=decodeURIComponent(r)}catch(t){ce(e,"tag prefix is malformed: "+r)}e.tagMap[i]=r}};function pe(e,t,n,i){var r,o,a,l;if(t1&&(e.result+=n.repeat("\n",t-1))}function be(e,t){var n,i,r=e.tag,o=e.anchor,a=[],l=!1;if(-1!==e.firstTabInLine)return!1;for(null!==e.anchor&&(e.anchorMap[e.anchor]=a),i=e.input.charCodeAt(e.position);0!==i&&(-1!==e.firstTabInLine&&(e.position=e.firstTabInLine,ce(e,"tab characters must not be used in indentation")),45===i)&&z(e.input.charCodeAt(e.position+1));)if(l=!0,e.position++,ge(e,!0,-1)&&e.lineIndent<=t)a.push(null),i=e.input.charCodeAt(e.position);else if(n=e.line,we(e,t,3,!1,!0),a.push(e.result),ge(e,!0,-1),i=e.input.charCodeAt(e.position),(e.line===n||e.lineIndent>t)&&0!==i)ce(e,"bad indentation of a sequence entry");else if(e.lineIndentt?g=1:e.lineIndent===t?g=0:e.lineIndentt?g=1:e.lineIndent===t?g=0:e.lineIndentt)&&(y&&(a=e.line,l=e.lineStart,c=e.position),we(e,t,4,!0,r)&&(y?g=e.result:m=e.result),y||(de(e,f,d,h,g,m,a,l,c),h=g=m=null),ge(e,!0,-1),s=e.input.charCodeAt(e.position)),(e.line===o||e.lineIndent>t)&&0!==s)ce(e,"bad indentation of a mapping entry");else if(e.lineIndent=0))break;0===o?ce(e,"bad explicit indentation width of a block scalar; it cannot be less than one"):u?ce(e,"repeat of an indentation width identifier"):(p=t+o-1,u=!0)}if(Q(a)){do{a=e.input.charCodeAt(++e.position)}while(Q(a));if(35===a)do{a=e.input.charCodeAt(++e.position)}while(!J(a)&&0!==a)}for(;0!==a;){for(he(e),e.lineIndent=0,a=e.input.charCodeAt(e.position);(!u||e.lineIndentp&&(p=e.lineIndent),J(a))f++;else{if(e.lineIndent0){for(r=a,o=0;r>0;r--)(a=ee(l=e.input.charCodeAt(++e.position)))>=0?o=(o<<4)+a:ce(e,"expected hexadecimal character");e.result+=ne(o),e.position++}else ce(e,"unknown escape sequence");n=i=e.position}else J(l)?(pe(e,n,i,!0),ye(e,ge(e,!1,t)),n=i=e.position):e.position===e.lineStart&&me(e)?ce(e,"unexpected end of the document within a double quoted scalar"):(e.position++,i=e.position)}ce(e,"unexpected end of the stream within a double quoted scalar")}(e,d)?y=!0:!function(e){var t,n,i;if(42!==(i=e.input.charCodeAt(e.position)))return!1;for(i=e.input.charCodeAt(++e.position),t=e.position;0!==i&&!z(i)&&!X(i);)i=e.input.charCodeAt(++e.position);return e.position===t&&ce(e,"name of an alias node must contain at least one character"),n=e.input.slice(t,e.position),P.call(e.anchorMap,n)||ce(e,'unidentified alias "'+n+'"'),e.result=e.anchorMap[n],ge(e,!0,-1),!0}(e)?function(e,t,n){var i,r,o,a,l,c,s,u,p=e.kind,f=e.result;if(z(u=e.input.charCodeAt(e.position))||X(u)||35===u||38===u||42===u||33===u||124===u||62===u||39===u||34===u||37===u||64===u||96===u)return!1;if((63===u||45===u)&&(z(i=e.input.charCodeAt(e.position+1))||n&&X(i)))return!1;for(e.kind="scalar",e.result="",r=o=e.position,a=!1;0!==u;){if(58===u){if(z(i=e.input.charCodeAt(e.position+1))||n&&X(i))break}else if(35===u){if(z(e.input.charCodeAt(e.position-1)))break}else{if(e.position===e.lineStart&&me(e)||n&&X(u))break;if(J(u)){if(l=e.line,c=e.lineStart,s=e.lineIndent,ge(e,!1,-1),e.lineIndent>=t){a=!0,u=e.input.charCodeAt(e.position);continue}e.position=o,e.line=l,e.lineStart=c,e.lineIndent=s;break}}a&&(pe(e,r,o,!1),ye(e,e.line-l),r=o=e.position,a=!1),Q(u)||(o=e.position+1),u=e.input.charCodeAt(++e.position)}return pe(e,r,o,!1),!!e.result||(e.kind=p,e.result=f,!1)}(e,d,1===i)&&(y=!0,null===e.tag&&(e.tag="?")):(y=!0,null===e.tag&&null===e.anchor||ce(e,"alias node should not have any properties")),null!==e.anchor&&(e.anchorMap[e.anchor]=e.result)):0===g&&(y=c&&be(e,h))),null===e.tag)null!==e.anchor&&(e.anchorMap[e.anchor]=e.result);else if("?"===e.tag){for(null!==e.result&&"scalar"!==e.kind&&ce(e,'unacceptable node kind for ! tag; it should be "scalar", not "'+e.kind+'"'),s=0,u=e.implicitTypes.length;s"),null!==e.result&&f.kind!==e.kind&&ce(e,"unacceptable node kind for !<"+e.tag+'> tag; it should be "'+f.kind+'", not "'+e.kind+'"'),f.resolve(e.result,e.tag)?(e.result=f.construct(e.result,e.tag),null!==e.anchor&&(e.anchorMap[e.anchor]=e.result)):ce(e,"cannot resolve a node with !<"+e.tag+"> explicit tag")}return null!==e.listener&&e.listener("close",e),null!==e.tag||null!==e.anchor||y}function ke(e){var t,n,i,r,o=e.position,a=!1;for(e.version=null,e.checkLineBreaks=e.legacy,e.tagMap=Object.create(null),e.anchorMap=Object.create(null);0!==(r=e.input.charCodeAt(e.position))&&(ge(e,!0,-1),r=e.input.charCodeAt(e.position),!(e.lineIndent>0||37!==r));){for(a=!0,r=e.input.charCodeAt(++e.position),t=e.position;0!==r&&!z(r);)r=e.input.charCodeAt(++e.position);for(i=[],(n=e.input.slice(t,e.position)).length<1&&ce(e,"directive name must not be less than one character in length");0!==r;){for(;Q(r);)r=e.input.charCodeAt(++e.position);if(35===r){do{r=e.input.charCodeAt(++e.position)}while(0!==r&&!J(r));break}if(J(r))break;for(t=e.position;0!==r&&!z(r);)r=e.input.charCodeAt(++e.position);i.push(e.input.slice(t,e.position))}0!==r&&he(e),P.call(ue,n)?ue[n](e,n,i):se(e,'unknown document directive "'+n+'"')}ge(e,!0,-1),0===e.lineIndent&&45===e.input.charCodeAt(e.position)&&45===e.input.charCodeAt(e.position+1)&&45===e.input.charCodeAt(e.position+2)?(e.position+=3,ge(e,!0,-1)):a&&ce(e,"directives end mark is expected"),we(e,e.lineIndent-1,4,!1,!0),ge(e,!0,-1),e.checkLineBreaks&&H.test(e.input.slice(o,e.position))&&se(e,"non-ASCII line breaks are interpreted as content"),e.documents.push(e.result),e.position===e.lineStart&&me(e)?46===e.input.charCodeAt(e.position)&&(e.position+=3,ge(e,!0,-1)):e.position=55296&&i<=56319&&t+1=56320&&n<=57343?1024*(i-55296)+n-56320+65536:i}function Re(e){return/^\n* /.test(e)}function Be(e,t,n,i,r,o,a,l){var c,s,u=0,p=null,f=!1,d=!1,h=-1!==i,g=-1,m=De(s=Ye(e,0))&&s!==Oe&&!_e(s)&&45!==s&&63!==s&&58!==s&&44!==s&&91!==s&&93!==s&&123!==s&&125!==s&&35!==s&&38!==s&&42!==s&&33!==s&&124!==s&&61!==s&&62!==s&&39!==s&&34!==s&&37!==s&&64!==s&&96!==s&&function(e){return!_e(e)&&58!==e}(Ye(e,e.length-1));if(t||a)for(c=0;c=65536?c+=2:c++){if(!De(u=Ye(e,c)))return 5;m=m&&qe(u,p,l),p=u}else{for(c=0;c=65536?c+=2:c++){if(10===(u=Ye(e,c)))f=!0,h&&(d=d||c-g-1>i&&" "!==e[g+1],g=c);else if(!De(u))return 5;m=m&&qe(u,p,l),p=u}d=d||h&&c-g-1>i&&" "!==e[g+1]}return f||d?n>9&&Re(e)?5:a?2===o?5:2:d?4:3:!m||a||r(e)?2===o?5:2:1}function Ke(e,t,n,i,r){e.dump=function(){if(0===t.length)return 2===e.quotingType?'""':"''";if(!e.noCompatMode&&(-1!==Te.indexOf(t)||Ne.test(t)))return 2===e.quotingType?'"'+t+'"':"'"+t+"'";var a=e.indent*Math.max(1,n),l=-1===e.lineWidth?-1:Math.max(Math.min(e.lineWidth,40),e.lineWidth-a),c=i||e.flowLevel>-1&&n>=e.flowLevel;switch(Be(t,c,e.indent,l,(function(t){return function(e,t){var n,i;for(n=0,i=e.implicitTypes.length;n"+Pe(t,e.indent)+We(Me(function(e,t){var n,i,r=/(\n+)([^\n]*)/g,o=(l=e.indexOf("\n"),l=-1!==l?l:e.length,r.lastIndex=l,He(e.slice(0,l),t)),a="\n"===e[0]||" "===e[0];var l;for(;i=r.exec(e);){var c=i[1],s=i[2];n=" "===s[0],o+=c+(a||n||""===s?"":"\n")+He(s,t),a=n}return o}(t,l),a));case 5:return'"'+function(e){for(var t,n="",i=0,r=0;r=65536?r+=2:r++)i=Ye(e,r),!(t=je[i])&&De(i)?(n+=e[r],i>=65536&&(n+=e[r+1])):n+=t||Fe(i);return n}(t)+'"';default:throw new o("impossible error: invalid scalar style")}}()}function Pe(e,t){var n=Re(e)?String(t):"",i="\n"===e[e.length-1];return n+(i&&("\n"===e[e.length-2]||"\n"===e)?"+":i?"":"-")+"\n"}function We(e){return"\n"===e[e.length-1]?e.slice(0,-1):e}function He(e,t){if(""===e||" "===e[0])return e;for(var n,i,r=/ [^ ]/g,o=0,a=0,l=0,c="";n=r.exec(e);)(l=n.index)-o>t&&(i=a>o?a:l,c+="\n"+e.slice(o,i),o=i+1),a=l;return c+="\n",e.length-o>t&&a>o?c+=e.slice(o,a)+"\n"+e.slice(a+1):c+=e.slice(o),c.slice(1)}function $e(e,t,n,i){var r,o,a,l="",c=e.tag;for(r=0,o=n.length;r tag resolver accepts not "'+s+'" style');i=c.represent[s](t,s)}e.dump=i}return!0}return!1}function Ve(e,t,n,i,r,a,l){e.tag=null,e.dump=n,Ge(e,n,!1)||Ge(e,n,!0);var c,s=Ie.call(e.dump),u=i;i&&(i=e.flowLevel<0||e.flowLevel>t);var p,f,d="[object Object]"===s||"[object Array]"===s;if(d&&(f=-1!==(p=e.duplicates.indexOf(n))),(null!==e.tag&&"?"!==e.tag||f||2!==e.indent&&t>0)&&(r=!1),f&&e.usedDuplicates[p])e.dump="*ref_"+p;else{if(d&&f&&!e.usedDuplicates[p]&&(e.usedDuplicates[p]=!0),"[object Object]"===s)i&&0!==Object.keys(e.dump).length?(!function(e,t,n,i){var r,a,l,c,s,u,p="",f=e.tag,d=Object.keys(n);if(!0===e.sortKeys)d.sort();else if("function"==typeof e.sortKeys)d.sort(e.sortKeys);else if(e.sortKeys)throw new o("sortKeys must be a boolean or a function");for(r=0,a=d.length;r1024)&&(e.dump&&10===e.dump.charCodeAt(0)?u+="?":u+="? "),u+=e.dump,s&&(u+=Le(e,t)),Ve(e,t+1,c,!0,s)&&(e.dump&&10===e.dump.charCodeAt(0)?u+=":":u+=": ",p+=u+=e.dump));e.tag=f,e.dump=p||"{}"}(e,t,e.dump,r),f&&(e.dump="&ref_"+p+e.dump)):(!function(e,t,n){var i,r,o,a,l,c="",s=e.tag,u=Object.keys(n);for(i=0,r=u.length;i1024&&(l+="? "),l+=e.dump+(e.condenseFlow?'"':"")+":"+(e.condenseFlow?"":" "),Ve(e,t,a,!1,!1)&&(c+=l+=e.dump));e.tag=s,e.dump="{"+c+"}"}(e,t,e.dump),f&&(e.dump="&ref_"+p+" "+e.dump));else if("[object Array]"===s)i&&0!==e.dump.length?(e.noArrayIndent&&!l&&t>0?$e(e,t-1,e.dump,r):$e(e,t,e.dump,r),f&&(e.dump="&ref_"+p+e.dump)):(!function(e,t,n){var i,r,o,a="",l=e.tag;for(i=0,r=n.length;i",e.dump=c+" "+e.dump)}return!0}function Ze(e,t){var n,i,r=[],o=[];for(Je(e,r,o),n=0,i=o.length;n/MDL/`, where each `.yaml` file is one expected deliverable. Multiple parties keep their own MDLs side by side; the table aggregates within a single party's directory. + +## How it works + +- **Storage** is file-per-row YAML — one `*.yaml` file in a directory per table row. Concurrent edits don't collide on a shared blob, every row has independent git history, and per-row ACL inherits from the cascading `.zddc` chain. +- **Discovery** is `.zddc`-declarative. Drop a `tables:` entry in the directory's `.zddc` to register the table; no file-presence auto-mount, no phantom tables from rogue YAML drops. +- **Rendering** is server-side: `zddc-server` reads every `*.yaml` under the rows directory, normalizes them into a JSON list, and inlines the list into the page on render. The browser does sorting, filtering, and click-row navigation locally — no further server round-trips for those. +- **Editing** is delegated to the existing form tool. Each row's click target is the form's re-edit URL (`//.yaml.html`), which `zddc-server` already serves via the form handler. The table itself never writes. + +## Setup (for an MDL at `Archive/Acme/MDL/`) + +``` +Archive/Acme/ +├── .zddc # declares: tables: { MDL: ./MDL.table.yaml } +├── MDL.table.yaml # column spec + rows path + row schema reference +├── MDL.form.yaml # JSON Schema for one row (used by both the table and the form editor) +└── MDL/ + ├── D-001.yaml # one row + ├── D-002.yaml # one row + └── ... +``` + +Visit `Archive/Acme/MDL.table.html` and the table renders. Visit `Archive/Acme/MDL.form.html` to add a new row (the form handler creates a YAML in `MDL/`). + +### `.zddc` declaration + +```yaml +tables: + MDL: ./MDL.table.yaml +``` + +The map key (`MDL`) becomes the URL stem and must match both the rows directory name and the form spec name. v1 enforces this with a load-time spec-validation error. + +### Table spec (`MDL.table.yaml`) + +```yaml +title: Master Deliverables List +description: Optional description shown above the table. +rowSchema: ./MDL.form.yaml # path to the row's JSON Schema (form-spec format) +rows: ./MDL # directory of *.yaml row files (non-recursive in v1) +columns: + - field: id # top-level key OR JSON Pointer (e.g. /nested/path) + title: ID + width: 7em + sort: asc # default sort key (overridden by defaults.sort below) + - field: title + title: Deliverable + - field: dueDate + title: Due + format: date # date | datetime | number | bool + - field: status + title: Status + enum: [pending, submitted, accepted, rejected] # constrains values + enables enum filter +defaults: + sort: + - { field: dueDate, dir: asc } + filter: + status: [pending, submitted] # initial filter state; clear with the toolbar button +``` + +Columns are explicit — the renderer does not auto-derive from the row schema. Pick the subset you want to display. + +## ACL behavior + +- The page-level read check uses the cascade at the spec directory; a caller without `r` gets a 403. +- Per-row "edit" affordance is recomputed against the row's own parent dir. If the user has `w` there, the row is clickable; otherwise it's plain text. Hard enforcement remains on the form-handler side (the form's POST will refuse a write the cascade denies). +- `Issued`/`Received` archive folders are server-enforced WORM. The decider strips `w/d/a` from non-admin grants under those subtrees, so an MDL placed inside `Issued/` shows every row as read-only with no special-casing in the table tool. + +## v1 limits + +- Read-only grid; click-row opens the form editor. Inline cell editing is a v2 candidate (would PUT each edit through the new file API in `zddc/internal/handler/fileapi.go`). +- One directory of `*.yaml` per table; cross-directory aggregation (`Archive/*/MDL/*.yaml` as one combined view) is not yet supported. +- No virtualization — large tables (>1000 rows) will be slow. +- No multi-row bulk operations, no add-row UI inside the table (use the form editor at `.form.html`). +- `.zddc tables:` declarations are direct-lookup only; no upward cascade. Each directory hosting a table needs its own declaration. + +## Build & develop + +```bash +sh tables/build.sh # build (writes tables/dist/tables.html) +sh tables/build.sh --release alpha # cut alpha +sh ./build # full lockstep build (all tools + zddc-server) + +(cd zddc && go test ./internal/handler/... ./internal/zddc/...) +npx playwright test --project=tables +``` + +Authoritative architecture and build docs are in [`../AGENTS.md`](../AGENTS.md) and [`../ARCHITECTURE.md`](../ARCHITECTURE.md). diff --git a/tables/build.sh b/tables/build.sh new file mode 100755 index 0000000..6abd533 --- /dev/null +++ b/tables/build.sh @@ -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/tables.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/table.css" \ + > "$css_temp" + +concat_files \ + "../shared/vendor/js-yaml.min.js" \ + "../shared/zddc.js" \ + "../shared/zddc-source.js" \ + "../shared/theme.js" \ + "../shared/help.js" \ + "js/app.js" \ + "js/context.js" \ + "js/util.js" \ + "js/filters.js" \ + "js/sort.js" \ + "js/render.js" \ + "js/main.js" \ + > "$js_raw" + +escape_js_close_tags "$js_raw" "$js_temp" + +compute_build_label "tables" "${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\}\}/, "" build_label "") + } else { + gsub(/\{\{BUILD_LABEL\}\}/, build_label) + } + print + next + } + /\{\{FAVICON\}\}/ { + gsub(/\{\{FAVICON\}\}/, favicon_uri) + print + next + } + / + + + + diff --git a/tests/mdedit.spec.js b/tests/mdedit.spec.js index 2fb0f27..80361d4 100644 --- a/tests/mdedit.spec.js +++ b/tests/mdedit.spec.js @@ -19,15 +19,15 @@ test.describe('Markdown Editor', () => { await expect(page.locator(`.file-item[data-path="__scratchpad__"]`)).toBeVisible(); await expect(page.locator('#content-container')).toBeVisible(); - // Select Directory button is present and enabled - const selectDirBtn = page.locator('#select-directory'); - await expect(selectDirBtn).toBeVisible(); - await expect(selectDirBtn).not.toBeDisabled(); + // Add Local Directory button is present and enabled + const addDirBtn = page.locator('#addDirectoryBtn'); + await expect(addDirBtn).toBeVisible(); + await expect(addDirBtn).not.toBeDisabled(); }); test('renders a file tree from a mock directory', async ({ page }) => { await page.goto(`file://${HTML_PATH}`, { waitUntil: 'load' }); - await page.waitForSelector('#select-directory', { timeout: 15000 }); + await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 }); // Set up mock directory before triggering the picker await page.evaluate(() => { @@ -37,7 +37,7 @@ test.describe('Markdown Editor', () => { ]); }); - await page.locator('#select-directory').click(); + await page.locator('#addDirectoryBtn').click(); // File tree should populate with the two files await page.waitForFunction( @@ -54,7 +54,7 @@ test.describe('Markdown Editor', () => { page.on('console', msg => msg.type() === 'log' && logs.push(msg.text())); await page.goto(`file://${HTML_PATH}`, { waitUntil: 'load' }); - await page.waitForSelector('#select-directory', { timeout: 15000 }); + await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 }); const probe = await page.evaluate(() => ({ debugDefined: typeof DEBUG !== 'undefined', diff --git a/tests/tables.spec.js b/tests/tables.spec.js new file mode 100644 index 0000000..1dccb63 --- /dev/null +++ b/tests/tables.spec.js @@ -0,0 +1,207 @@ +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('tables/dist/tables.html'); +const HTML_RAW = fs.readFileSync(HTML_PATH, 'utf8'); + +const MDL_COLUMNS = [ + { field: 'id', title: 'ID', width: '6em' }, + { field: 'title', title: 'Deliverable' }, + { field: 'party', title: 'Party', enum: ['Acme', 'Beta', 'Gamma'] }, + { field: 'dueDate', title: 'Due', format: 'date' }, + { field: 'status', title: 'Status', enum: ['pending', 'submitted', 'accepted'] }, +]; + +function makeRow(id, title, party, dueDate, status, editable = true) { + return { + url: `/Working/MDL/${id}.yaml.html`, + data: { id, title, party, dueDate, status }, + editable, + }; +} + +const ROWS = [ + makeRow('D-001', 'Site survey report', 'Acme', '2026-05-12', 'pending'), + makeRow('D-002', 'Foundation drawings A', 'Beta', '2026-05-20', 'submitted'), + makeRow('D-003', 'Procurement schedule', 'Acme', '2026-05-08', 'accepted'), + makeRow('D-004', 'Safety plan', 'Gamma', '2026-05-15', 'pending'), + makeRow('D-005', 'Geotechnical report', 'Beta', '2026-05-30', 'submitted'), +]; + +// Inject a complete table context into the page. Same pattern as +// form-safety.spec.js: write a patched copy of tables.html to a temp +// file and navigate via file://. +async function loadTableWithContext(page, context) { + const ctxJson = JSON.stringify(context).replace(/<\//g, '<\\/'); + const replacement = ``; + const patched = HTML_RAW.replace( + /`) { + t.Error("inline context placeholder not preserved verbatim — client expects {} so it knows to walk") + } +} + +func TestServeTable_ACLForbidden(t *testing.T) { + zddcs := map[string]string{ + "Working": `acl: + permissions: + "root@example.com": rwcd +tables: + MDL: ./MDL.table.yaml +`, + } + _, do := tableTestSetup(t, map[string]string{"D.yaml": "id: D\n"}, zddcs) + rec := do(http.MethodGet, "/Working/MDL.table.html", "stranger@example.com") + if rec.Code != http.StatusForbidden { + t.Errorf("status = %d want 403; body = %s", rec.Code, rec.Body.String()) + } +} diff --git a/zddc/internal/handler/tables.html b/zddc/internal/handler/tables.html new file mode 100644 index 0000000..59acfd9 --- /dev/null +++ b/zddc/internal/handler/tables.html @@ -0,0 +1,2382 @@ + + + + + + ZDDC Table + + + + +
+
+ +
+ ZDDC Table + v0.0.1-alpha · 2026-05-06 00:27:31 · 3115e38-dirty +
+
+
+ + +
+
+ +
+ + +
+
+ + +
+
+
+ + + +
+
+ +
+ + + + + + + + + + diff --git a/zddc/internal/zddc/file.go b/zddc/internal/zddc/file.go index d15eb59..3e8e8ec 100644 --- a/zddc/internal/zddc/file.go +++ b/zddc/internal/zddc/file.go @@ -84,6 +84,14 @@ type ZddcFile struct { Apps map[string]string `yaml:"apps,omitempty" json:"apps,omitempty"` AppsPubKey string `yaml:"apps_pubkey,omitempty" json:"apps_pubkey,omitempty"` + // Tables declares directory-of-YAML table views available at this + // directory. The map key becomes the URL stem: tables[MDL] is served + // at /MDL.table.html. The value is a path (relative to this + // .zddc) to a *.table.yaml spec describing columns and the rows + // directory. There is no upward cascade for tables in v1 — each + // directory that hosts a table declares it directly. + Tables map[string]string `yaml:"tables,omitempty" json:"tables,omitempty"` + // Roles are named principal groups available at this level and below. // See Role for member syntax. Roles map[string]Role `yaml:"roles,omitempty" json:"roles,omitempty"` diff --git a/zddc/internal/zddc/file_test.go b/zddc/internal/zddc/file_test.go new file mode 100644 index 0000000..a15e620 --- /dev/null +++ b/zddc/internal/zddc/file_test.go @@ -0,0 +1,76 @@ +package zddc + +import ( + "os" + "path/filepath" + "testing" +) + +// TestParseFile_TablesRoundTrip exercises the Tables field added to +// support the table tool. A .zddc with a tables: map should round-trip +// through ParseFile cleanly without disturbing existing fields. +func TestParseFile_TablesRoundTrip(t *testing.T) { + root := t.TempDir() + body := `acl: + permissions: + "*@example.com": rwcd +title: Demo +apps: + archive: stable +tables: + MDL: ./MDL.table.yaml + Subcontracts: ./contracts/subs.table.yaml +roles: + reviewers: + members: ["bob@example.com"] +` + path := filepath.Join(root, ".zddc") + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatalf("write .zddc: %v", err) + } + zf, err := ParseFile(path) + if err != nil { + t.Fatalf("ParseFile: %v", err) + } + if got := zf.Tables["MDL"]; got != "./MDL.table.yaml" { + t.Errorf("Tables[MDL] = %q want %q", got, "./MDL.table.yaml") + } + if got := zf.Tables["Subcontracts"]; got != "./contracts/subs.table.yaml" { + t.Errorf("Tables[Subcontracts] = %q want %q", got, "./contracts/subs.table.yaml") + } + // Sibling fields should still parse. + if zf.Title != "Demo" { + t.Errorf("Title = %q want %q", zf.Title, "Demo") + } + if got := zf.Apps["archive"]; got != "stable" { + t.Errorf("Apps[archive] = %q want %q", got, "stable") + } + if r, ok := zf.Roles["reviewers"]; !ok || len(r.Members) != 1 { + t.Errorf("Roles[reviewers] = %+v want one member", r) + } + if got := zf.ACL.Permissions["*@example.com"]; got != "rwcd" { + t.Errorf("ACL.Permissions[*@example.com] = %q want rwcd", got) + } +} + +// TestParseFile_TablesEmptyOmitted confirms that a .zddc without a +// tables: key parses with a nil Tables map (omitempty round-trip). +func TestParseFile_TablesEmptyOmitted(t *testing.T) { + root := t.TempDir() + body := `title: NoTables +acl: + permissions: + "*@example.com": r +` + path := filepath.Join(root, ".zddc") + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatalf("write .zddc: %v", err) + } + zf, err := ParseFile(path) + if err != nil { + t.Fatalf("ParseFile: %v", err) + } + if zf.Tables != nil { + t.Errorf("Tables = %+v want nil for absent tables: key", zf.Tables) + } +}