diff --git a/.gitignore b/.gitignore index 2e5c5d0..f2298d9 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,9 @@ examples/ .env .vscode +# Per-project Claude Code state (planning files, agent transcripts, etc.) +.claude/ + # Session planning (never public) PLAN.md @@ -20,16 +23,16 @@ test-results/ # New tool dist files must be force-added: git add -f tool/dist/tool.html dist/ -# Release artifacts under website/releases/ ARE committed — per-version HTML -# tool files (_v.html) accumulate as immutable real files; partial -# version pins (_v.html, _v.html) and channels -# (_.html) are checked-in symlinks. The build script -# (shared/build-lib.sh promote_release) maintains the symlink chain on each -# release. Caddy serves these as plain static files; no manifest, no proxy. -# -# zddc-server binaries are NOT committed — they're per-platform multi-MB -# binaries that ship as Codeberg release assets, attached to clean -# zddc-server-vX.Y.Z tags by zddc/release.sh. +# Release artifacts under website/releases/ ARE committed — including +# zddc-server binaries. Per-version HTML tool files (_v.html) +# and per-version zddc-server binaries (zddc-server_v_) +# are immutable real files; partial-version pins (_v.html, +# _v.html, zddc-server_v_, zddc-server_v_) +# and channel mirrors (_.html, zddc-server__) +# are checked-in symlinks. The lockstep build (shared/build-lib.sh +# promote_release + promote_zddc_server) maintains both chains. Everything +# serves from zddc.varasys.io/releases/; no Codeberg release-asset +# publication anymore. # IDE and project files .opencode/ diff --git a/AGENTS.md b/AGENTS.md index 3a4eb22..57242c9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,22 +3,29 @@ ## Commands ```bash -# Build all tools (writes to dist/ only; also regenerates website/releases/index.html) +# Dev build: 5 HTML tools + cross-compile zddc-server binaries + regen the +# matrix at website/releases/index.html. dist/ artifacts only — no +# website/releases/ side-effect for the build outputs themselves. sh build.sh -# Build single tool -sh tool/build.sh # archive | transmittal | classifier | mdedit | landing +# Build a single HTML tool (archive | transmittal | classifier | mdedit | landing) +sh tool/build.sh -# Cut a stable release (auto-increments patch version, writes website/releases/_v.html, refreshes 5 symlinks, tags -v) -sh tool/build.sh --release -sh tool/build.sh --release 1.2.0 # explicit version +# ── Lockstep release: bumps ALL six tools (5 HTML + zddc-server) at once ── +# +# Coordinated version is max(latest tag across all six) + 1, so they +# always converge. Channel cuts (alpha/beta) follow the same lockstep — +# every tool's channel mirror is overwritten together. Workflow: +# alpha = active dev iteration → beta = ready for general testing → stable = ship +sh build.sh --release # stable, auto-coordinated next version +sh build.sh --release 1.2.0 # stable, explicit version +sh build.sh --release alpha # alpha cut for everything +sh build.sh --release beta # beta cut for everything (cascades alpha → beta) -# Cut an alpha/beta channel build (overwrites website/releases/_.html in place; on a beta cut, alpha cascades to a symlink → beta. No git tag.) -sh tool/build.sh --release alpha -sh tool/build.sh --release beta - -# Release all tools at once -sh build.sh --release [version|alpha|beta] +# Single-tool release (rare; prefer the lockstep top-level cut above so +# versions don't drift between tools). Same flags as the top-level form. +sh tool/build.sh --release [|alpha|beta] +./freshen-channel # rebuild one tool's alpha/beta from its current stable tag # Test all tools npm test @@ -33,6 +40,12 @@ npx playwright test tool # archive | transmittal | classifier | mdedit No lint, typecheck, or format commands exist — the project is plain sh + vanilla JS. +The build ends with a **channel-link verifier** that asserts every +`_{stable,beta,alpha}.html` (and zddc-server's per-platform binary +mirrors + stub pages) resolves. Build fails if any link is dangling. +Bootstrap-friendly: zddc-server checks are skipped until the first +`--release` cut materializes the binaries under `website/releases/`. + ## 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. @@ -73,7 +86,7 @@ helm/ **Critical:** `dist/` files are gitignored. They're the canonical built artifact for testing and the source for `--release` writes into `website/releases/`, but they aren't checked in. Never edit them directly. -`website/releases/` IS committed — per-version files as real bytes, partial-version pins (`_v`, `_v`) and channel mirrors (`_stable`, `_beta`, `_alpha`) as symlinks. The build script (`shared/build-lib.sh promote_release`) maintains the symlink chain on each release. zddc-server binaries are NOT in this repo — they're attached as Codeberg release assets to clean `zddc-server-vX.Y.Z` tags by `zddc/release.sh`. +`website/releases/` IS committed — per-version HTML and per-version zddc-server binaries as real bytes, partial-version pins (`_v`, `_v`) and channel mirrors (`_stable`, `_beta`, `_alpha`) as symlinks. `shared/build-lib.sh` provides `promote_release` (HTML tools) and `promote_zddc_server` (binaries + matching stub pages); the top-level `build.sh --release` calls them in lockstep. zddc-server binaries live in this repo too — there's no Codeberg release-asset publication anymore; everything serves from `zddc.varasys.io/releases/`. ## Shared CSS (`shared/base.css`) @@ -157,46 +170,51 @@ Format: `trackingNumber_revision (status) - title.extension` - Feature-branch workflow; squash-merge feature branches to `main` - Conventional commits: `feat(archive): ...`, `fix(transmittal): ...` -- Release tags: `archive-v1.0.0` (per-tool semver) +- Release tags: `-v` 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`) - Commit dist files: `git add -f tool/dist/tool.html` +- Commit zddc-server binaries (per-version + symlinks): they live under `website/releases/` like every other release artifact -### Releasing — channels and layout +### Releasing — lockstep, channels, layout -Three channels. Versioning is **per-tool semver**: stable owns clean `vX.Y.Z`; alpha and beta are mutable channel mirrors that get overwritten in place (no counter tags — channel URLs are stable URLs by design). The next-stable target X.Y.Z used in alpha/beta on-page labels is patch-bumped from the latest clean `-vX.Y.Z` tag. +**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). -**Storage model.** All HTML tool artifacts live in this repo under `website/releases/`. Per-version files (`_v.html`) are real, immutable, committed bytes; partial-version pins (`_v.html`, `_v.html`) and channel mirrors (`_.html`) are checked-in symlinks pointing at the appropriate concrete file. No `manifest.json`, no Codeberg indirection, no Caddy proxy magic. Caddy serves these as plain static files. +**Storage model.** All release artifacts live under `website/releases/` and are served from `zddc.varasys.io/releases/`. No Codeberg release assets, no third-party mirrors. -zddc-server binaries are a separate concern — they ship as Codeberg release assets attached to clean `zddc-server-vX.Y.Z` tags by `zddc/release.sh`. zddc-server has no alpha/beta channel for binaries; the helm charts under `helm/` build from source. +| Artifact | Type | Layout | +|---|---|---| +| `_v.html` | real, immutable | per-version HTML for each of archive, transmittal, classifier, mdedit, landing | +| `_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} | +| `zddc-server_v_`, `zddc-server_v_`, `zddc-server__` | symlinks (or real bytes during active channel dev) | partial-pin and channel mirrors per platform — same cascade as the HTML tools | +| `zddc-server_.html` | generated stub page | per-version / per-channel; lists the four platform downloads. This is what the matrix-cell link points at — one stub fans out to four binaries | +| `index.html` | regenerated by `build.sh` | matrix table, one column per tool, one row per release | -`shared/build-lib.sh promote_release` is the single point of truth for HTML-tool releases: +**Single point of truth.** `sh build.sh --release` is the canonical lockstep cut. It 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 `website/releases/` with the matching symlink chain, then `write_zddc_server_stubs_all` regenerates every stub page, then `build_releases_index` rewrites the matrix, then `verify_channel_links` asserts nothing dangles. -- **Stable** (`sh tool/build.sh --release [version]`, or just `--release` to auto-bump patch): Writes `website/releases/_v.html` (immutable real bytes), then refreshes 5 symlinks — `_v.html`, `_v.html`, `_stable.html`, `_beta.html`, `_alpha.html` — all → the new versioned file. Tags `-v`. Cascade rule: stable cut means beta and alpha both reset to stable (no active dev on either downstream channel). Skips silently if source has not changed since the latest stable tag. -- **Beta** (`sh tool/build.sh --release beta`): Overwrites `_beta.html` with the dist HTML bytes (replacing the symlink with a real file if one was there). Cascade: `_alpha.html` → `_beta.html` (symlink). No tag. -- **Alpha** (`sh tool/build.sh --release alpha`): Overwrites `_alpha.html` with the dist HTML bytes. No tag, no other side-effects. -- **Plain dev builds** (no `--release`): produce `tool/dist/.html` only. No `website/releases/` side-effect, no commit. To publish, re-run with `--release alpha`. +- **Stable** (`sh build.sh --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: `-v`. 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** (`sh build.sh --release beta`): Overwrites `_beta.html` with dist bytes for each HTML tool, and `zddc-server_beta_` with each platform's binary. Cascade: `_alpha.html` → `_beta.html` and `zddc-server_alpha_` → `zddc-server_beta_` (symlinks). No tag. +- **Alpha** (`sh build.sh --release alpha`): Overwrites only the alpha mirrors, all six tools. No tag, no other side-effects. +- **Plain dev builds** (no `--release`): produce `tool/dist/.html` for HTML tools and `zddc/dist/zddc-server-` binaries; do NOT touch `website/releases/`. The matrix index and stub pages still get regenerated from whatever `website/releases/` contains, so the build is idempotent for repeated dev runs. -On-page `{{BUILD_LABEL}}` format: +On-page `{{BUILD_LABEL}}` format (HTML tools only — zddc-server's version comes from the binary itself): -- Plain dev: `vX.Y.Z-alpha · · [-dirty]` (red), where X.Y.Z is the next-stable target. +- Plain dev: `vX.Y.Z-alpha · · [-dirty]` (red), where X.Y.Z is the per-tool next-stable target. - `--release alpha`: `vX.Y.Z-alpha · · ` (red). - `--release beta`: `vX.Y.Z-beta · · ` (red). - `--release [version]`: `v` (black). -After cutting a stable release, `git push origin ` to publish the tag. `git push origin main` publishes the new versioned file + updated symlinks. - -No `$CODEBERG_TOKEN` is needed for HTML-tool releases — they never touch Codeberg. (`zddc/release.sh` for zddc-server stable cuts still requires it for binary uploads.) - -`website/index.html` (the root URL of zddc.varasys.io) is **hand-edited static content**, not built by `landing/build.sh`. Its "Install on your server" section points operators at two paths: local download (per-tool links to `/releases/`) and `zddc-server` install (the binary has the current-stable build of every tool baked in via `//go:embed`). The landing tool's release file is `website/releases/landing_v.html`; on a `zddc-server` deployment, the embedded copy is served at the root URL by default, with operators able to override via `.zddc apps:` entries or by dropping a real `index.html`. +After cutting a stable release, `git push origin main && git push origin --tags` to publish the new version files + symlinks + every per-tool tag in lockstep. ### Channel discipline (MUST rules) -The build system does not enforce these. Treating channels carelessly defeats the point of having three. Be disciplined. +The build enforces lockstep mechanically (one command bumps all six). The rules below are still on you. -1. **Stable doesn't regress.** No known-broken features that worked in the previous stable. If you ship `v0.0.5` with a bug, the path forward is `v0.0.6` with a fix — never edit a previously-published `_v0.0.5.html` in place. Stable per-version files are immutable. -2. **Coordinated minor/major bumps.** When any tool needs a minor or major bump, all five tools cut at the same time even if patches differ. Per-tool patches stay independent. This is a release-time process rule, not enforced by tooling. -3. **No backports.** Don't try to patch an older stable version. Always cut a new stable at a higher version. Users pinned to the old version stay pinned by choice; they can move forward when they want. -4. **Alpha and beta are mutable.** Document this anywhere you invite users to test them. Pinning a deployment to a channel mirror means it gets rebuilt without notice. For something stable, pin to a per-version URL (the `_v0.0.5.html` file). -5. **Cascade is automatic.** A stable cut resets beta and alpha symlinks → stable. A beta cut resets alpha → beta. So "no active beta" silently shows current stable and "no active alpha" silently shows current beta. Operators don't need to run a freshen step after a stable release; the cascade handles it. +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. +3. **No backports.** Always cut a new stable at a higher version. Users pinned to an old version stay pinned by choice. +4. **Alpha and beta are mutable.** Document this anywhere you invite users to test them. Pinning a deployment to a channel mirror means it gets rebuilt without notice. For reproducibility, pin to a per-version URL — `_v0.0.5.html` or `zddc-server_v0.0.5.html`. +5. **Cascade is automatic.** Stable cut → beta + alpha mirrors reset to stable (per-tool HTML AND per-platform zddc-server). Beta cut → alpha → beta. "No active beta" silently shows current stable. No freshen step required after a stable release. 6. **Hotfix path.** For critical bugs: fix on `main`, cut a new stable. Tag the commit message `fix:` or include "hotfix" so intent is visible in `git log`. 7. **Beta soak before promoting (recommended).** Give a beta a few days of exposure before cutting the same code as stable. Not enforced; use judgment for trivial changes. @@ -272,7 +290,7 @@ Go HTTP server sub-project living at `zddc/`. Replaces `caddy file-server --brow ### Build -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 in `tnd-zddc-chart` compile from source at deploy time, fetching the right tag from Codeberg). +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+) @@ -282,7 +300,7 @@ zddc-server ships as a cross-compiled binary, not a container image. There's no (cd zddc && go run ./cmd/zddc-server) ``` -The repo's top-level `sh build.sh` cross-compiles the four release binaries (linux/amd64, darwin/amd64, darwin/arm64, windows/amd64) into `zddc/dist/` when Go is on PATH. It's silently skipped otherwise — the HTML tools build regardless. +The repo's top-level `sh build.sh` 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 `--release` it also promotes those binaries to `website/releases/` with the matching symlink chain and stub pages — same lockstep flow as the HTML tools. ### Run (development) @@ -291,11 +309,13 @@ ZDDC_ROOT=/path/to/your/archive ZDDC_TLS_CERT=none ZDDC_ADDR=:8080 \ go run ./cmd/zddc-server ``` -For a release binary (downloaded from Codeberg or built via `sh build.sh`): +For a release binary downloaded from `zddc.varasys.io/releases/`: ```sh +curl -O https://zddc.varasys.io/releases/zddc-server_stable_linux-amd64 +chmod +x zddc-server_stable_linux-amd64 ZDDC_ROOT=/path/to/your/archive ZDDC_TLS_CERT=none ZDDC_ADDR=:8080 \ - ./zddc/dist/zddc-server-linux-amd64 + ./zddc-server_stable_linux-amd64 ``` ### Key environment variables @@ -311,22 +331,20 @@ ZDDC_ROOT=/path/to/your/archive ZDDC_TLS_CERT=none ZDDC_ADDR=:8080 \ ### Release tagging -`zddc/release.sh` is the canonical path. It tags the commit, cross-compiles binaries (native Go), and uploads them as Codeberg release assets. **Stable cuts only** — zddc-server has no alpha/beta channel for binary distribution. Active dev/soak happens via the `helm/zddc-server-dev/` chart, which builds from source on every pod restart against any commit you point it at. +zddc-server has no separate release script anymore. The top-level `sh build.sh --release [version|alpha|beta]` is the canonical path: it cross-compiles the binaries inside the containerized Go toolchain, copies them into `website/releases/` with the lockstep symlink chain (one set of symlinks per platform), regenerates the per-version + per-channel stub pages, refreshes the matrix, and tags `zddc-server-v` alongside the five HTML-tool tags. ```sh -sh zddc/release.sh # patch-bump from latest clean stable tag -sh zddc/release.sh 0.1.0 # explicit version +sh build.sh --release # lockstep stable, coordinated next version +sh build.sh --release 1.2.0 # lockstep stable, explicit version +sh build.sh --release alpha # lockstep alpha cut for everything +sh build.sh --release beta # lockstep beta cut for everything ``` -The script tags the commit but does NOT push — finish with `git push origin main` and `git push origin `. +The script tags every tool but does NOT push — finish with `git push origin main && git push origin --tags`. -**Versioning** — clean semver. Stable cuts get `-vX.Y.Z` tags; no `-alpha.N` / `-beta.N` counters. The historical `zddc-server-v0.0.8-alpha.1` and `-alpha.2` tags from the previous scheme stay as artifacts but no new alpha/beta tags get added. +**Versioning** — clean semver. Stable cuts emit one `-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. -**Binary publishing** — release.sh uploads the four cross-compiled binaries (`zddc-server-{linux,darwin,windows}-{amd64,arm64}`) as release assets attached to the new git tag on Codeberg. Operators download from `https://codeberg.org/VARASYS/ZDDC/releases/download/zddc-server-vX.Y.Z/zddc-server-` directly. - -Prerequisites: -- Go 1.24+ on PATH (or run from a Go container). -- `$CODEBERG_TOKEN` exported, scoped to write the VARASYS/ZDDC repo. +**Binary distribution** — `website/releases/zddc-server__` are real static files served from `zddc.varasys.io/releases/`. No Codeberg release assets, no `$CODEBERG_TOKEN`, no third-party mirror. The matrix-cell link points at `zddc-server_.html`, a generated stub page that surfaces the four platform downloads in one click. There is no CI for this — solo workflow benefits from one canonical local path that fails loudly and visibly on the developer's terminal. @@ -335,7 +353,7 @@ local path that fails loudly and visibly on the developer's terminal. - No external test framework yet — Go unit tests run with `go test ./...` inside `zddc/` (requires Go 1.24+) - Portfolio files (`*.portfolio`) in the served tree appear as virtual group directories -- Every folder exposes a `.archive` virtual directory backed by the same global index — the depth in the URL only matters so HTML produced for offline use can reach `.archive/` via `../.archive/` relative links and have the browser resolve them before the request hits the server. 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 of the named revision. Modifier files (`_+C1.html` etc.) remain reachable via the resolver but are not surfaced in the listing — they're return traffic, not primary documents. ACL is the only filter: 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 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. - ACL is enforced via cascading `.zddc` YAML files; authentication is delegated to the upstream proxy via the `X-Auth-Request-Email` header (configurable with `ZDDC_EMAIL_HEADER`) - `.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". - **Reserved entry prefixes** under `ZDDC_ROOT`: `.`-prefixed entries are excluded from listings AND 404 on direct fetch (only `.archive` and `.admin` are exempt) — for invisible side-state like dev-shell home dirs. `_`-prefixed entries are excluded from listings only — for operator scaffolding like the `_template/` directory created by the self-contained install snippet, still reachable by direct URL. Drop side-state under `_` if it should be linkable; under `.` if it should be unreachable. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6901047..f0b3a1e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -33,24 +33,31 @@ tool/ tool.html # Generated output — never edit this manually ``` -Website files (what `zddc.varasys.io` serves) — committed in this repo as static assets, including the per-version HTML tool files: +Website files (what `zddc.varasys.io` serves) — committed in this repo as static assets, including the per-version HTML tool files AND the per-version zddc-server binaries: ``` website/ - index.html # hand-edited intro page + install snippets (root URL) + index.html # hand-edited intro page + install snippets (root URL) releases/ - index.html # versions index, regenerated by build.sh from filesystem scan - _v.html # real per-version files (committed, immutable) - _v.html → ... # symlink: latest patch within X.Y.* - _v.html → ... # symlink: latest within X.*.* - _stable.html → ... # symlink: current stable - _beta.html → ... # symlink to stable (or real bytes when active beta dev) - _alpha.html → ... # symlink to beta/stable (or real bytes when active alpha dev) + index.html # matrix table, regenerated by build.sh from filesystem scan + _v.html # real per-version HTML (committed, immutable) + _v.html → ... # symlink: latest patch within X.Y.* + _v.html → ... # symlink: latest within X.*.* + _stable.html → ... # symlink: current stable HTML + _beta.html → ... # symlink to stable (or real bytes when active beta dev) + _alpha.html → ... # symlink to beta/stable (or real bytes when active alpha dev) + zddc-server_v_ # real per-version cross-compiled binary + zddc-server_v_ → ... # symlink chain (mirrors the HTML cascade per platform) + zddc-server_v_ → ... + zddc-server__ → ... # channel mirror per platform + zddc-server_.html # generated stub: matrix-cell link → fans out 4 platform downloads ``` -Every URL under `/releases/` resolves directly via the symlink chain — no `manifest.json`, no Caddy regex-rewrite, no JavaScript indirection, no Codeberg proxy. Caddy serves these as plain static files. The Docker-tag pattern: `:1.2.3` is pinned, `:1.2` floats, `:1` floats further, `:stable` floats furthest, and `:beta` / `:alpha` are mutable channel mirrors that overwrite in place. +`` ∈ {archive, transmittal, classifier, mdedit, landing}. `` ∈ {linux-amd64, darwin-amd64, darwin-arm64, windows-amd64.exe}. -zddc-server binaries are a separate concern — they ship as Codeberg release assets attached to clean `zddc-server-vX.Y.Z` tags by `zddc/release.sh`. The `helm/zddc-server-{prod,dev}/` charts build from source via init container instead of fetching binaries. +Every URL under `/releases/` resolves directly via the symlink chain — no `manifest.json`, no Caddy regex-rewrite, no JavaScript indirection, no third-party mirror. Caddy serves these as plain static files. The Docker-tag pattern: `:1.2.3` is pinned, `:1.2` floats, `:1` floats further, `:stable` floats furthest, and `:beta` / `:alpha` are mutable channel mirrors that overwrite in place. + +**zddc-server binaries live in this repo too** — committed under `website/releases/`, served from `zddc.varasys.io/releases/`. No Codeberg release assets, no separate distribution channel. The `helm/zddc-server-{prod,dev}/` charts build from source via init container, but operators who want a prebuilt binary just `curl -O https://zddc.varasys.io/releases/zddc-server_stable_linux-amd64`. The single matrix-cell link per release points at `zddc-server_.html`, a small generated stub that surfaces all four platform downloads. There is no `website/dev/`. To preview a build locally, open `dist/tool.html` directly via the dev server. To publish on `zddc.varasys.io`, cut a release. @@ -83,7 +90,7 @@ When updating documentation, prefer linking over duplicating. If you find yourse ### How It Works -Each tool's `build.sh`: +Each HTML tool's `build.sh`: 1. Reads CSS files in declaration order, concatenates them 2. Reads JS files in declaration order, concatenates them @@ -91,19 +98,26 @@ Each tool's `build.sh`: 4. Writes the result to `dist/tool.html` 5. If `--release ` was passed, calls `promote_release` to write into `website/releases/` (per-version file + symlink updates for stable; channel mirror overwrite for alpha/beta). -The top-level `build.sh` at the repository root calls all five tool build scripts in sequence and writes `website/releases/index.html` from a filesystem scan of `website/releases/` so the versions index always matches the on-disk state. +The top-level `build.sh` at the repository root is the canonical lockstep entry point. It: + +1. 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. +2. Cross-compiles zddc-server for the four target platforms inside a containerized Go toolchain (podman/docker). +3. On `--release`, calls `promote_zddc_server` to copy the freshly cross-compiled binaries into `website/releases/` with the matching symlink chain (one set per platform) and tag `zddc-server-v` alongside the five HTML-tool tags. +4. Always calls `write_zddc_server_stubs_all` to refresh the per-version + per-channel stub HTML pages from whatever artifacts are in `website/releases/`. +5. Regenerates `website/releases/index.html` as a matrix table (rows = versions, columns = tools). +6. Calls `verify_channel_links` — fails the build if any channel link is dangling. ### Channels -Three release channels. `promote_release` in `shared/build-lib.sh` is the single point of truth for what each cut produces; the cascade rule keeps downstream channel symlinks current automatically. +Three release channels, applied in lockstep across all six tools (5 HTML + zddc-server). The cascade rule keeps downstream channel symlinks current automatically. -- **Stable** — versioned, immutable. `--release [version]` writes `website/releases/_v.html` (real bytes), refreshes 5 symlinks (`_v.html`, `_v.html`, `_stable.html`, `_beta.html`, `_alpha.html`) all → the new versioned file, and tags `-v` in git. Skips automatically when there is no source change since the last stable tag. -- **Beta** — `--release beta` overwrites `_beta.html` with the dist HTML bytes (replacing the symlink with a real file if one was there). Cascades `_alpha.html` → `_beta.html` (symlink). No tag — channel URLs are stable URLs by design; counter tags would defeat that. On-page label: `vX.Y.Z-beta · · ` where X.Y.Z is the next-stable target. -- **Alpha** — `--release alpha` overwrites `_alpha.html` with the dist HTML bytes. No tag, no other side-effects. On-page label: `vX.Y.Z-alpha · · `. +- **Stable** — versioned, immutable. `sh build.sh --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 `-v` for every tool. Skips per-tool HTML rewrites when source hasn't changed since that tool's last stable tag (binaries always rebuild). +- **Beta** — `sh build.sh --release beta` overwrites `_beta.html` for each HTML tool and `zddc-server_beta_` 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** — `sh build.sh --release alpha` overwrites only the alpha mirrors, all six tools. No tag, no other side-effects. -A plain `sh tool/build.sh` (no `--release`) is a dev build: it produces `dist/.html` only, with the on-page label `vX.Y.Z-alpha · · [-dirty]`. No write to `website/releases/`, no tag, no commit. +A plain `sh build.sh` (no `--release`) is a dev build: it produces `dist/.html` and `zddc/dist/zddc-server-` binaries; doesn't touch `website/releases/`. The matrix index and stub pages still get regenerated from whatever's in `website/releases/`, so dev builds remain idempotent and don't break the channel-link verifier. -The cascade rule (stable cut → beta + alpha both reset to stable; beta cut → alpha resets to beta) means downstream channels are never stale. "No active beta" silently shows current stable; "no active alpha" silently shows current beta or stable. Operators don't need to run a freshen step after each stable release. +The cascade rule (stable cut → beta + alpha mirrors reset to stable; beta cut → alpha resets to beta) means downstream channels are never stale across either HTML or binaries. "No active beta" silently shows current stable; "no active alpha" silently shows current beta or stable. Operators don't need to run a freshen step after each stable release. The on-page `{{BUILD_LABEL}}` is rendered red+bold for dev/alpha/beta builds (`is_red=1`) and black for stable releases. The label format is: @@ -151,7 +165,7 @@ Independent of how the tool got installed. `archive` auto-detects from the URL a Every `build.sh` must: - Begin with `#!/bin/sh` and `set -eu` (POSIX sh, not bash) -- Source `shared/build-lib.sh` first (provides `ensure_exists`, `concat_files`, `build_timestamp`, `compute_build_label`, `promote_release`) +- Source `shared/build-lib.sh` first (provides `ensure_exists`, `concat_files`, `build_timestamp`, `compute_build_label`, `promote_release`, plus the lockstep helpers `_coordinated_next_stable`, `promote_zddc_server`, `write_zddc_server_stubs_all`, `verify_channel_links`) - Fail immediately on missing source files (`ensure_exists` pattern) - Clean up temp files on exit (use `trap cleanup EXIT`) - Accept `--release [|alpha|beta]` — explicit version or channel name; otherwise produce a dev build diff --git a/CLAUDE.md b/CLAUDE.md index 053e8b4..9418e46 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,27 +16,32 @@ 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`). -- `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. Stable releases ship as cross-compiled binaries on Codeberg release assets; 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, `build-lib.sh` (POSIX sh helpers sourced by every tool's `build.sh`), and `publish-codeberg-release.sh` (used by `zddc/release.sh` to upload zddc-server binaries — HTML tools no longer use it). -- `website/` — committed static site: `index.html` (root URL, hand-edited intro), `releases/_v.html` (immutable per-version archives), `releases/_v.html` and `_v.html` (symlinks), `releases/_{stable,beta,alpha}.html` (channel mirrors), `releases/index.html` (regenerated by `build.sh`). **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 `/_app/`. Drop a real `.html` file at any path to override. +- `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 committed to `website/releases/` and served from `zddc.varasys.io/releases/` (no Codeberg release assets); 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.sh` for lockstep release helpers). +- `website/` — committed static site: `index.html` (root URL, hand-edited intro), `releases/_v.html` (immutable per-version archives), `releases/_v.html` and `_v.html` (symlinks), `releases/_{stable,beta,alpha}.html` (channel mirrors), `releases/zddc-server_v_` (per-version cross-compiled binaries), `releases/zddc-server__` (binary symlinks following the same cascade), `releases/zddc-server_.html` (per-version / per-channel stub pages that fan out the four platform downloads in one matrix-cell link), `releases/index.html` (matrix table regenerated by `build.sh`). **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 `/_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) ## Most-used commands ```bash -sh build.sh # build all five HTML tools (dist/ only) + regen website/releases/index.html -sh tool/build.sh # build one (archive|transmittal|classifier|mdedit|landing) -sh tool/build.sh --release [version] # cut stable; write website/releases/_v.html, refresh 5 symlinks, tag -vX.Y.Z -sh tool/build.sh --release alpha|beta # cut channel; overwrite website/releases/_.html in place. No tag (channel URLs are stable URLs by design) -./freshen-channel # rebuild alpha/beta from current stable tag (run after every stable release if you want to advance the channel mirror) +sh build.sh # dev build: 5 HTML tools + cross-compile zddc-server binaries + regen releases/index.html +sh tool/build.sh # build one HTML tool (archive|transmittal|classifier|mdedit|landing) + +# Lockstep releases — every cut bumps ALL tools (5 HTML + zddc-server) to the same version +sh build.sh --release # stable, coordinated next-version (max(latest tag) + 1) +sh build.sh --release X.Y.Z # stable, explicit version +sh build.sh --release alpha # alpha channel cut for everything +sh build.sh --release beta # beta channel cut for everything + +sh tool/build.sh --release [version|alpha|beta] # single-tool release (rare; prefer the lockstep top-level cut) +./freshen-channel # rebuild a single tool's alpha/beta from its current stable tag npm test # all Playwright specs (build first!) npx playwright test # one spec ./dev-server start # ./dev-server stop # cache-busting HTTP on :8000 -# zddc/ Go server (separate sub-project, not part of sh build.sh) +# zddc/ Go server (sub-project) (cd zddc && go test ./...) # unit tests (Go 1.24+) -sh zddc/release.sh [] # cut stable zddc-server release; tags + cross-compiles binaries + uploads to Codeberg. Stable-only (no alpha/beta channel for binaries). ``` No lint/typecheck/format commands exist for the HTML tools — vanilla JS + POSIX sh by design. @@ -44,10 +49,12 @@ No lint/typecheck/format commands exist for the HTML tools — vanilla JS + POSI ## Things that bite if you forget - **`dist/` is gitignored.** `tool/dist/.html` is the canonical built artifact for testing and as the source for `--release` writes. Never hand-edit a `dist/` file. -- **`website/releases/` is committed.** Per-version files (`_v.html`) are real immutable files; partial-version pins (`_v.html`, `_v.html`) and channels (`_.html`) are checked-in symlinks. Stable cuts write the new versioned file + refresh the 5 symlinks (cascade rule: stable cut → beta + alpha both reset to the new stable). Beta/alpha cuts overwrite their channel mirror in place; on a beta cut, alpha cascades to point at 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 ` · ` for traceability. Stable cuts still get clean `-vX.Y.Z` tags. -- **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 (patch+1 from latest clean `-vX.Y.Z` tag). Plain dev adds a full timestamp + `-dirty` marker; `--release alpha|beta` is date-only. -- **Plain `sh tool/build.sh` is a dev build.** Writes `dist/.html` only; no `website/releases/` side-effect. To publish, re-run with `--release alpha`. +- **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 — `sh build.sh --release` is the canonical path. Workflow: alpha = active dev, beta = ready for general testing, stable = ready to ship. +- **`website/releases/` is committed.** HTML tools: per-version `_v.html` (real immutable files) + partial-version pins + channel mirrors (symlinks). zddc-server: `zddc-server_v_` per-version binaries (real bytes), `zddc-server_v_` / `_v_` / `__` symlinks, plus `zddc-server_.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 ` · ` for traceability. Stable cuts get clean `-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. +- **Channel-link verifier.** Every `sh build.sh` ends with a check that every `_{stable,beta,alpha}.html` (and zddc-server's per-platform binary mirrors + stub pages) resolves. Bootstrap-friendly: skips zddc-server checks until the first `--release` cut materializes the binaries. +- **Plain `sh tool/build.sh` is a dev build.** Writes `dist/.html` only; no `website/releases/` side-effect. To publish, re-run with `sh build.sh --release alpha` to cut all six tools' alpha mirrors together. - **Always build before running tests** — Playwright opens `dist/tool.html` via `file://`. - **``** embedding. `shared/build-lib.sh` provides `escape_js_close_tags`; every tool's `build.sh` runs JS through it before inlining. - **All ZDDC parsing/formatting/hashing goes through `window.zddc`** (from `shared/zddc.js` + `shared/hash.js` + `shared/zddc-filter.js`). API: `parseFilename`, `parseFolder`, `parseRevision`, `formatFilename`, `formatFolder`, `compareRevisions`, `isValidStatus`, `splitExtension`, `joinExtension`, `crypto.{sha256Hex, sha256String, sha256File, bytesToHex}`, `filter.{parse, matches}`. File objects across tools use `trackingNumber` (string) and `extension` (string, **no leading dot** — use `zddc.joinExtension(name, ext)` to build a filename). Add edge cases to `tests/zddc.spec.js`, not per-tool tests. diff --git a/archive/js/app.js b/archive/js/app.js index 236dc0b..ca1b01a 100644 --- a/archive/js/app.js +++ b/archive/js/app.js @@ -504,27 +504,21 @@ }); } - // Returns true if any path segment of the transmittal folder matches a selected - // party name AND the segment immediately after it (if it's a folder-type name) - // is in enabledFolderTypes. Segment-equality matching means a party "BM" selected - // matches every "<...>/BM/<...>" path regardless of the prefix. + // Returns true if the transmittal folder's path satisfies all three cascade + // layers: project visibility, party selection (any path segment matches a + // selected party name), and folder-type enablement (no segment is a + // folder-type marker that's currently disabled, regardless of where in the + // path it sits). Segment-equality matching means a party "BM" selected + // matches every "<...>/BM/<...>" path regardless of the prefix; and the + // folder-type check covers BOTH the canonical "/Issued/" layout + // AND nested layouts like "//Issued/" — a deeper folder- + // type marker still triggers the cascade. function transmittalIsUnderVisibleParty(folder) { if (!pathIsInVisibleProject(folder.path)) return false; - const parts = folder.path.split('/'); - for (let i = 0; i < parts.length; i++) { - if (!window.app.selectedGroupingFolders.has(parts[i])) continue; - // i-th segment is a selected party. The segment after is either a - // folder-type marker (Issued/Received/MDL/Incoming) or the transmittal - // folder itself. - const next = parts[i + 1]; - if (!next) return true; - const folderType = next.toLowerCase(); - if (window.app.FOLDER_TYPE_NAMES.includes(folderType)) { - return window.app.enabledFolderTypes.has(folderType); - } - return true; - } - return false; + if (isUnderHiddenFolderType(folder.path)) return false; + return folder.path.split('/').some(seg => + window.app.selectedGroupingFolders.has(seg) + ); } // Render transmittal folders (rebuilds DOM) diff --git a/build.sh b/build.sh index 810e1f3..1961b61 100755 --- a/build.sh +++ b/build.sh @@ -1,11 +1,69 @@ #!/bin/sh set -eu -# Top-level build script — builds all ZDDC HTML tools, the zddc-server -# binaries, and the website/releases/index.html versions index. +# Top-level build script — builds all five HTML tools, cross-compiles +# zddc-server binaries, and (when invoked with --release) cuts a lockstep +# release that bumps every tool to the same version. +# +# Usage: +# sh build.sh # dev build +# sh build.sh --release # stable cut, coordinated version +# sh build.sh --release X.Y.Z # stable cut, explicit version +# sh build.sh --release alpha # alpha channel cut +# sh build.sh --release beta # beta channel cut +# +# Lockstep convention: every release 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. +# Channel cuts (alpha/beta) follow the same lockstep — every tool's +# channel mirror is overwritten in step. SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) +# Source build-lib.sh once at the top level so the helpers it provides +# (promote_zddc_server, write_zddc_server_stubs_all, verify_channel_links, +# _coordinated_next_stable) are in scope. Each tool's build.sh sources it +# again — that's a no-op on already-defined functions. +root_dir="$SCRIPT_DIR" +. "$SCRIPT_DIR/shared/build-lib.sh" + +# --- Parse release args ---------------------------------------------------- +RELEASE_FLAG="${1:-}" +RELEASE_ARG="${2:-}" +RELEASE_CHANNEL="" +RELEASE_VERSION="" + +if [ "$RELEASE_FLAG" = "--release" ]; then + case "$RELEASE_ARG" in + alpha | beta) + RELEASE_CHANNEL="$RELEASE_ARG" + ;; + '') + RELEASE_CHANNEL="stable" + RELEASE_VERSION=$(_coordinated_next_stable) + echo "=== Lockstep stable release — coordinated version: v$RELEASE_VERSION ===" + ;; + *) + _validate_semver "$RELEASE_ARG" + RELEASE_CHANNEL="stable" + RELEASE_VERSION="$RELEASE_ARG" + echo "=== Lockstep stable release — explicit version: v$RELEASE_VERSION ===" + ;; + esac +fi + +# Build the per-tool argument list. For stable lockstep cuts pass the +# explicit version so every tool agrees; for alpha/beta pass the channel +# name; for plain dev builds pass nothing. +TOOL_RELEASE_ARGS="" +if [ -n "$RELEASE_CHANNEL" ]; then + if [ "$RELEASE_CHANNEL" = "stable" ]; then + TOOL_RELEASE_ARGS="--release $RELEASE_VERSION" + else + TOOL_RELEASE_ARGS="--release $RELEASE_CHANNEL" + fi +fi + echo "=== Building ZDDC tools ===" # Each tool's compute_build_label writes a sidecar `.label` here so @@ -15,11 +73,12 @@ rm -rf "$BUILD_LABELS_DIR" mkdir -p "$BUILD_LABELS_DIR" export BUILD_LABELS_DIR -sh "$SCRIPT_DIR/transmittal/build.sh" "${1:-}" "${2:-}" -sh "$SCRIPT_DIR/archive/build.sh" "${1:-}" "${2:-}" -sh "$SCRIPT_DIR/classifier/build.sh" "${1:-}" "${2:-}" -sh "$SCRIPT_DIR/mdedit/build.sh" "${1:-}" "${2:-}" -sh "$SCRIPT_DIR/landing/build.sh" "${1:-}" "${2:-}" +# shellcheck disable=SC2086 # intentional word-splitting on TOOL_RELEASE_ARGS +sh "$SCRIPT_DIR/transmittal/build.sh" $TOOL_RELEASE_ARGS +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 echo "" echo "=== Assembling zddc/dist/web/ ===" @@ -97,16 +156,21 @@ GO_BUILD_IMAGE="${ZDDC_GO_BUILD_IMAGE:-docker.io/golang:1.24-alpine}" GO_MOD_VOL="${ZDDC_GO_MOD_VOL:-zddc-go-mod}" GO_BUILD_VOL="${ZDDC_GO_BUILD_VOL:-zddc-go-cache}" -# Compute the binary's own version: `git describe` if available (clean tag, -# or tag-N-gSHA[-dirty] for in-flight commits), else falls back to "dev". -# Surfaces via `zddc-server --version` and in the startup log line. -ZDDC_BINARY_VERSION=$(git -C "$SCRIPT_DIR" describe --tags --dirty --match 'zddc-server-v*' 2>/dev/null || true) -if [ -z "$ZDDC_BINARY_VERSION" ]; then - _sha=$(git -C "$SCRIPT_DIR" rev-parse --short=7 HEAD 2>/dev/null || echo unknown) - if ! git -C "$SCRIPT_DIR" diff --quiet HEAD 2>/dev/null; then - _sha="${_sha}-dirty" +# Compute the binary's own version. On a stable cut, hard-code the +# coordinated version so the binary embeds the same string the rest of the +# release cycle has agreed on. Otherwise fall back to git describe (clean +# tag, or tag-N-gSHA[-dirty] for in-flight commits). +if [ -n "$RELEASE_VERSION" ]; then + ZDDC_BINARY_VERSION="$RELEASE_VERSION" +else + ZDDC_BINARY_VERSION=$(git -C "$SCRIPT_DIR" describe --tags --dirty --match 'zddc-server-v*' 2>/dev/null || true) + if [ -z "$ZDDC_BINARY_VERSION" ]; then + _sha=$(git -C "$SCRIPT_DIR" rev-parse --short=7 HEAD 2>/dev/null || echo unknown) + if ! git -C "$SCRIPT_DIR" diff --quiet HEAD 2>/dev/null; then + _sha="${_sha}-dirty" + fi + ZDDC_BINARY_VERSION="dev-${_sha}" fi - ZDDC_BINARY_VERSION="dev-${_sha}" fi echo " binary version: $ZDDC_BINARY_VERSION" @@ -137,59 +201,100 @@ echo " binary version: $ZDDC_BINARY_VERSION" WEBSITE_DIR="$SCRIPT_DIR/website" RELEASES_DIR="$WEBSITE_DIR/releases" +mkdir -p "$RELEASES_DIR" -mkdir -p "$WEBSITE_DIR" +# --- Promote zddc-server release artifacts --------------------------------- +# On a release cut, copy the freshly cross-compiled binaries to +# website/releases/ under their canonical names + symlinks (lockstep with +# the HTML tools' release flow). On a plain build, just refresh stub pages +# from whatever artifacts already live in releases/. +if [ -n "$RELEASE_CHANNEL" ]; then + echo "" + echo "=== Promoting zddc-server $RELEASE_CHANNEL release ===" + promote_zddc_server "$RELEASE_CHANNEL" "$RELEASE_VERSION" "$RELEASES_DIR" "$SCRIPT_DIR/zddc/dist" +else + write_zddc_server_stubs_all "$RELEASES_DIR" +fi -# Regenerate website/releases/index.html from a filesystem scan of -# website/releases/. Lists per-version files (real .html files, immutable -# archives) plus channel mirrors and partial-version pins (symlinks). -# -# All URLs in the page resolve directly under /releases/ -# — no Codeberg API call, no manifest, no proxy magic. Page is static -# and current as of the last `sh build.sh` run. -# -# zddc-server's section links to its Codeberg release pages directly -# (different distribution model — per-platform binaries). +# Latest stable version, by following archive_stable.html → versioned target. +# Returns "" if no stable cut exists yet (bootstrap state). All HTML tools +# move in lockstep so any one of them is a valid probe; archive is canonical. +_latest_stable_version() { + _link="$RELEASES_DIR/archive_stable.html" + [ -L "$_link" ] || return 0 + _target=$(readlink "$_link") + # archive_v0.0.8.html → 0.0.8 + _v="${_target#archive_v}" + _v="${_v%.html}" + case "$_v" in + [0-9]*.[0-9]*.[0-9]*) echo "$_v" ;; + esac +} + +# Channel "active" iff the channel mirror is real bytes rather than a +# symlink → stable. Used to surface alpha/beta in the dropdown only when +# they meaningfully differ from stable. Probes archive (HTML lockstep +# representative); zddc-server's probe is its per-platform binary. +_channel_is_active() { + _ch="$1" # alpha | beta + _f="$RELEASES_DIR/archive_${_ch}.html" + [ -L "$_f" ] && return 1 # symlink → tracks stable, not "active" + [ -f "$_f" ] && return 0 + return 1 +} + +# Regenerate website/releases/index.html as the action-first install +# guide (not a matrix). The page guides users to either self-host the +# server or download individual tools, with one version dropdown that +# rewires every download link via JS. The default static state always +# uses latest-stable URLs so the page works fully without JS. build_releases_index() { _out="$RELEASES_DIR/index.html" mkdir -p "$RELEASES_DIR" + _latest=$(_latest_stable_version) + if [ -z "$_latest" ]; then + _latest="0.0.0" + fi + + # All distinct stable versions across every tool, descending. Same + # awk that the prior matrix used — proven across the tool naming. + _all_versions=$( + find "$RELEASES_DIR" -maxdepth 1 -type f \( \ + -name 'archive_v*.html' -o -name 'transmittal_v*.html' \ + -o -name 'classifier_v*.html' -o -name 'mdedit_v*.html' \ + -o -name 'landing_v*.html' \ + -o -name 'zddc-server_v*_linux-amd64' \ + \) 2>/dev/null \ + | awk -F/ '{ + n = split($NF, parts, "_v"); + if (n < 2) next; + v = parts[2]; + sub(/\.html$/, "", v); + sub(/_linux-amd64$/, "", v); + if (v ~ /^[0-9]+\.[0-9]+\.[0-9]+$/) print v; + }' \ + | sort -Vu \ + | sort -Vr + ) + + _alpha_active="0"; _channel_is_active alpha && _alpha_active="1" + _beta_active="0"; _channel_is_active beta && _beta_active="1" + { - cat <<'HEAD' + cat < - Releases — ZDDC - + Download ZDDC + -
-

Releases

-

All published versions and channel builds of every ZDDC tool. Stable releases are immutable; alpha and beta channels are rebuilt without notice.

+

Download ZDDC

+

Pick how you want to use it. Pick the version you want. Every link below points at a real, immutable file you can save into your archive — your tools, your version, forever.

+
+ + + Changes every download link below. +
-
-

Each link above is a real static file (or a checked-in symlink resolving to one). Channel chips track the current build of that channel and may change at any time; per-version files are immutable. To install or pin in your own deployment, see the home page.

+ +
+ Or pick a channel: + + + +
+ + +
+

Path A — Self-host the server

+

One small Go binary. All five tools are baked in via //go:embed; the server picks the right one for each folder of your archive. Adds ACL via .zddc files, the virtual .archive document index, and SSO header passthrough. Stop the server and the directory is still a perfectly valid ZDDC archive — the server is convenience, not lock-in.

+PICKER_END + + # Render the download UI only when zddc-server has been published + # at least once. Until then, show an honest "not yet released" + # placeholder rather than dangling download buttons. + _zs_published="0" + if [ -e "$RELEASES_DIR/zddc-server_stable_linux-amd64" ]; then + _zs_published="1" + fi + + if [ "$_zs_published" = "1" ]; then + printf ' \n' + printf ' \n' + printf ' Download for Linux (x86_64)\n' + printf ' \n' + printf ' zddc-server_v%s_linux-amd64\n' "$_latest" + + printf '
\n' + printf ' Other platforms:\n' + for _entry in "linux-amd64|Linux (x86_64)" \ + "darwin-amd64|macOS (Intel)" \ + "darwin-arm64|macOS (Apple Silicon)" \ + "windows-amd64|Windows (x86_64)"; do + _plat="${_entry%%|*}" + _label="${_entry#*|}" + _suffix="" + case "$_plat" in *windows*) _suffix=".exe" ;; esac + printf ' %s\n' \ + "$_plat" "$_latest" "$_plat" "$_suffix" "$_label" + done + printf '
\n' + + cat <<'PATH_A_END' +

+ After download: chmod +x the file, set ZDDC_ROOT=/path/to/archive, run. + Need a different platform? Build from source at the matching tag. +

+
+PATH_A_END + else + # Bootstrap state: no zddc-server stable cut yet. + cat <<'PATH_A_BOOTSTRAP' +

+ Not yet published. The first lockstep release publishes binaries here. Until then, build from source: git clone and (cd zddc && go build ./cmd/zddc-server). Once sh build.sh --release runs, this card auto-populates with download buttons for every platform. +

+
+PATH_A_BOOTSTRAP + fi + + cat <<'PATH_B_OPEN' + + +
+

Path B — Standalone tools

+

Every tool is a single self-contained HTML file. Open it locally and point it at a folder on your disk — no install, no server, no account. Same on-disk layout the server uses. Use one tool, use all five, mix and match — there is no orchestration to set up.

+
+PATH_B_OPEN + + # Tool cards — reuse home page's .tool-card vocabulary + for _entry in "archive|Archive Browser|Browse and download from a ZDDC archive." \ + "transmittal|Transmittal Creator|Build, sign, and verify transmittal packages." \ + "classifier|Classifier|Rename loose files to ZDDC convention." \ + "mdedit|Markdown Editor|Edit project markdown files in place." \ + "landing|Landing|Project picker for multi-project servers."; do + _t="${_entry%%|*}" + _rest="${_entry#*|}" + _name="${_rest%%|*}" + _desc="${_rest#*|}" + printf ' \n' "$_t" "$_t" "$_latest" + printf ' %s\n' "$_name" + printf ' %s\n' "$_desc" + printf ' Download →\n' + printf ' \n' + done + + cat <<'PATH_B_END' +
+
+ + +
+

Your version, forever

+

Your server may run v0.0.8 next month and v0.1.0 the month after. Your project doesn't have to follow. If you depend on a specific behavior in archive v0.0.5, save that version into your archive — the next server upgrade can't take it away from you. Two ways to do it:

+
+
+

Drop a copy into your archive

+

Save the tool's HTML at the path the server would serve it from. The server's resolution order picks up real files first — before any cascade or embedded fallback.

+PATH_B_END + + printf '
curl -o MyProject/archive.html \\\n  https://zddc.varasys.io/releases/archive_v%s.html
\n' "$_latest" + + cat <<'PIN_MID' +

Now MyProject/archive.html is yours. The server serves your bytes; nothing about a future --release can change them.

+
+
+

Pin via .zddc

+

Less invasive — no copies in your archive, just a small config entry telling the server which version to fetch and cache. Closer-to-leaf wins, so subprojects can pin further.

+PIN_MID + + printf '
# MyProject/.zddc\napps:\n  archive: v%s
\n' "$_latest" + + cat <<'PIN_END' +

Server fetches once on first hit, caches under _app/, falls through to the embedded copy if the fetch fails.

+
+
+

Your archive's tools are yours. The server is convenience; deletion of the server doesn't break your archive — every per-version download above is a real, immutable static file. Save what you trust.

+
+ + +
+

Channels

+

Three channels, applied in lockstep across all tools. Pre-release channels exist to soak changes; stable is what production runs.

+
+
+

alpha

+

Active dev iteration. Rebuilds without notice. Look here for the very latest.

+
+
+

beta

+

Ready for general testing. Has soaked through alpha. Still mutable — pin to a versioned URL for reproducibility.

+
+
+

stable

+

Ready to ship. Every per-version file is immutable; _stable follows the latest cut. Channel cuts cascade: stable cut resets beta and alpha to track stable.

+
+
@@ -290,9 +519,122 @@ HEAD ZDDC is open source — codeberg.org/VARASYS/ZDDC + + -TAIL +PIN_END } > "$_out" echo "Wrote $_out" } @@ -301,11 +643,27 @@ echo "" echo "=== Building releases/index.html ===" build_releases_index +echo "" +echo "=== Verifying channel links ===" +verify_channel_links "$RELEASES_DIR" + echo "" echo "=== All tools built successfully ===" echo "" -echo "Server deployment package: zddc/dist/" -echo " Binaries: zddc-server-{linux,darwin,windows}-*" -echo " Web files: web/ (copy contents to ZDDC_ROOT)" -echo "" -echo "Operator install: see website/index.html 'Install on your server'." +if [ -n "$RELEASE_CHANNEL" ]; then + echo "Release: $RELEASE_CHANNEL" + if [ -n "$RELEASE_VERSION" ]; then + echo "Version: v$RELEASE_VERSION" + echo "" + echo "Tags created (push together):" + for _t in archive transmittal classifier mdedit landing zddc-server; do + echo " ${_t}-v${RELEASE_VERSION}" + done + echo "" + echo "Publish: git push origin main && git push origin --tags" + fi +else + echo "Server deployment package: zddc/dist/" + echo " Binaries: zddc-server-{linux,darwin,windows}-*" + echo " Web files: web/ (copy contents to ZDDC_ROOT)" +fi diff --git a/playwright.config.js b/playwright.config.js index 387cfa2..339bbd4 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -19,6 +19,10 @@ export default defineConfig({ name: 'archive', testMatch: 'archive.spec.js', }, + { + name: 'archive-cascade', + testMatch: 'archive-cascade.spec.js', + }, { name: 'landing', testMatch: 'landing.spec.js', diff --git a/shared/build-lib.sh b/shared/build-lib.sh index 526297a..bb05532 100755 --- a/shared/build-lib.sh +++ b/shared/build-lib.sh @@ -211,9 +211,16 @@ _emit_build_label_sidecar() { printf '%s\n' "$build_label" > "$BUILD_LABELS_DIR/$1.label" } -# Compute the next-stable target version for a tool — i.e., the patch-bump -# of the latest clean -vX.Y.Z tag. Used by compute_build_label to -# embed the target version in alpha/beta labels. +# 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" + +# 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 +# alpha/beta on-page label still reads against its own history (e.g. an +# alpha cut for a tool that's been quiet still labels itself targeting that +# tool's next stable, even when the lockstep convention is in force). _next_stable_for_tool() { _t="$1" _latest=$(git -C "$root_dir" tag --list "${_t}-v*" 2>/dev/null \ @@ -229,6 +236,31 @@ _next_stable_for_tool() { echo "${_major}.${_minor}.$((_patch + 1))" } +# Compute the coordinated next-stable target across every release artifact +# (5 HTML tools + zddc-server). Used by the top-level build.sh on +# `--release` (no explicit version) to enforce lockstep — every tool cuts +# at the same version even if it hasn't changed. Picks max(latest tag +# across all tools) + patch bump, so a tool at v0.0.2 jumps straight to +# wherever the leader is + 1 the first time the lockstep rule fires. +_coordinated_next_stable() { + _max="0.0.0" + for _t in $ZDDC_RELEASE_TOOLS; do + _latest=$(git -C "$root_dir" tag --list "${_t}-v*" 2>/dev/null \ + | grep -E "^${_t}-v[0-9]+\.[0-9]+\.[0-9]+\$" \ + | sed "s|^${_t}-v||" \ + | sort -V \ + | tail -1) + [ -n "$_latest" ] || continue + # sort -V picks the larger of two semvers + _max=$(printf '%s\n%s\n' "$_max" "$_latest" | sort -V | tail -1) + done + _major="${_max%%.*}" + _rest="${_max#*.}" + _minor="${_rest%%.*}" + _patch="${_rest#*.}" + echo "${_major}.${_minor}.$((_patch + 1))" +} + # Promote a built dist file to website/releases/. Reads from caller scope: # $channel ("stable" / "alpha" / "beta"), $build_version (stable only), # $output_html, $root_dir. @@ -351,3 +383,310 @@ _promote_channel() { echo "Released ${_t} ${_ch}" } + +# Platforms zddc-server is cross-compiled for. The first three are +# extension-less (Linux/macOS); Windows gets .exe. The build always emits +# all four; the matrix cell's stub page links each by its tag. +ZDDC_SERVER_PLATFORMS="linux-amd64 darwin-amd64 darwin-arm64 windows-amd64" + +# Display label for the stub-page download list. Keeps the binary-asset +# names canonical even if we later add e.g. linux-arm64. +_zddc_server_platform_label() { + case "$1" in + linux-amd64) echo "Linux (x86_64)" ;; + darwin-amd64) echo "macOS (Intel)" ;; + darwin-arm64) echo "macOS (Apple Silicon)" ;; + windows-amd64) echo "Windows (x86_64)" ;; + *) echo "$1" ;; + esac +} + +# Resolve a zddc-server binary's filename for one (version, platform). +# Returns the bare name (no path); ".exe" suffix on windows. +_zddc_server_binary_name() { + _ver_or_chan="$1" + _plat="$2" + _suffix="" + case "$_plat" in *windows*) _suffix=".exe" ;; esac + if echo "$_ver_or_chan" | grep -qE '^v[0-9]'; then + # Per-version asset, e.g. zddc-server_v0.0.8_linux-amd64 + printf 'zddc-server_%s_%s%s' "$_ver_or_chan" "$_plat" "$_suffix" + else + # Channel mirror, e.g. zddc-server_stable_linux-amd64 + printf 'zddc-server_%s_%s%s' "$_ver_or_chan" "$_plat" "$_suffix" + fi +} + +# Write the small HTML index page that becomes the matrix cell's link for +# a zddc-server release. Lists each platform binary with a download link. +# $1 — release directory (absolute) +# $2 — slug (e.g. v0.0.8, v0.0, stable, beta, alpha) +# $3 — display label (e.g. "v0.0.8", "stable channel") +write_zddc_server_stub() { + _rdir="$1" + _slug="$2" + _label="$3" + _out="$_rdir/zddc-server_${_slug}.html" + + { + cat < + + + + + zddc-server ${_label} — ZDDC + + + + +
+ +

zddc-server — ${_label}

+

Cross-compiled binaries. Download for your platform, mark executable, and run with ZDDC_ROOT=/path/to/archive ./zddc-server.

+ + + +HEAD + for _plat in $ZDDC_SERVER_PLATFORMS; do + _bin=$(_zddc_server_binary_name "$_slug" "$_plat") + _plabel=$(_zddc_server_platform_label "$_plat") + printf ' \n' "$_plabel" "$_bin" "$_bin" + done + cat <<'TAIL' + +
PlatformDownload
%s%s
+

Need a different platform? Build from source: (cd zddc && go build -o zddc-server ./cmd/zddc-server) from the repo at the matching tag.

+
+ + +TAIL + } > "$_out" +} + +# Refresh every zddc-server stub page based on what's currently in +# website/releases/. Driven by the existing per-version binary files + +# symlinks that the release flow already maintains; just emits the HTML +# wrappers for them. Safe to run on every build (idempotent), so plain +# `sh build.sh` keeps the stub pages in sync if a release file was added +# out of band. +# +# $1 — releases dir (absolute) +write_zddc_server_stubs_all() { + _rdir="$1" + + # Every per-version stable binary that exists. We index off + # linux-amd64 specifically since all four platforms ship in lockstep + # — if the linux build is missing the version is incomplete anyway. + for _bin in "$_rdir"/zddc-server_v*_linux-amd64; do + [ -e "$_bin" ] || continue + _name=$(basename "$_bin") + # zddc-server_vX.Y.Z_linux-amd64 → vX.Y.Z + _slug=$(echo "$_name" | sed -E 's/^zddc-server_(v[^_]+)_linux-amd64$/\1/') + # Skip partial-version pins (vX.Y, vX) — these are written + # separately below from symlink resolution. + case "$_slug" in + v*.*.*) write_zddc_server_stub "$_rdir" "$_slug" "$_slug" ;; + esac + done + + # Partial-version + channel stubs follow the symlink chain. If the + # symlink resolves to a real binary, write the stub; otherwise skip. + for _slug in stable beta alpha; do + _probe="$_rdir/zddc-server_${_slug}_linux-amd64" + if [ -e "$_probe" ]; then + write_zddc_server_stub "$_rdir" "$_slug" "${_slug} channel" + fi + done + + # vX.Y and vX partial pins — derive the slug list from the per-version + # binaries so we only emit pages we actually have artifacts for. + _all_versions=$(find "$_rdir" -maxdepth 1 -name 'zddc-server_v*_linux-amd64' \ + | sed -E 's|^.*/zddc-server_(v[0-9]+\.[0-9]+\.[0-9]+)_linux-amd64$|\1|' \ + | sort -Vu) + if [ -n "$_all_versions" ]; then + # vX.Y pins — pick the highest patch within each X.Y, then make + # sure the symlink and stub exist. + echo "$_all_versions" | sed -E 's|^v([0-9]+\.[0-9]+)\.[0-9]+$|\1|' | sort -Vu | while read -r _xy; do + _probe="$_rdir/zddc-server_v${_xy}_linux-amd64" + if [ -e "$_probe" ]; then + write_zddc_server_stub "$_rdir" "v${_xy}" "v${_xy}" + fi + done + # vX pins. + echo "$_all_versions" | sed -E 's|^v([0-9]+)\..*$|\1|' | sort -Vu | while read -r _x; do + _probe="$_rdir/zddc-server_v${_x}_linux-amd64" + if [ -e "$_probe" ]; then + write_zddc_server_stub "$_rdir" "v${_x}" "v${_x}" + fi + done + fi +} + +# Promote a freshly-cross-compiled set of zddc-server binaries to +# website/releases/. Called by the top-level build.sh on a release cut. +# +# $1 — channel ("stable" | "alpha" | "beta") +# $2 — version (X.Y.Z; required for stable; ignored for alpha/beta but +# passed through so labels can include the next-stable target) +# $3 — releases dir (absolute) +# $4 — dist dir holding cross-compiled binaries (absolute) +promote_zddc_server() { + _ch="$1" + _ver="$2" + _rdir="$3" + _dist="$4" + + # Verify all four binaries exist before doing anything destructive. + for _plat in $ZDDC_SERVER_PLATFORMS; do + _suffix="" + case "$_plat" in *windows*) _suffix=".exe" ;; esac + _src="$_dist/zddc-server-${_plat}${_suffix}" + if [ ! -f "$_src" ]; then + echo "promote_zddc_server: missing source binary $_src" >&2 + return 1 + fi + done + + case "$_ch" in + stable) + if [ -z "$_ver" ]; then + echo "promote_zddc_server: stable cut requires version" >&2 + return 1 + fi + _major="${_ver%%.*}" + _rest="${_ver#*.}" + _minor="${_rest%%.*}" + + # Per-version: copy each binary to its immutable name + refresh + # the partial-version + channel symlinks. Mirrors the HTML-tool + # cascade: stable cut → beta + alpha both reset to stable. + for _plat in $ZDDC_SERVER_PLATFORMS; do + _suffix="" + case "$_plat" in *windows*) _suffix=".exe" ;; esac + _src="$_dist/zddc-server-${_plat}${_suffix}" + _versioned="zddc-server_v${_ver}_${_plat}${_suffix}" + cp "$_src" "$_rdir/$_versioned" + echo "Wrote $_rdir/$_versioned" + for _sym in "zddc-server_v${_major}.${_minor}_${_plat}${_suffix}" \ + "zddc-server_v${_major}_${_plat}${_suffix}" \ + "zddc-server_stable_${_plat}${_suffix}" \ + "zddc-server_beta_${_plat}${_suffix}" \ + "zddc-server_alpha_${_plat}${_suffix}"; do + ln -sfn "$_versioned" "$_rdir/$_sym" + done + done + + # Tag the commit so the binary set is reproducible. + _tag="zddc-server-v${_ver}" + if git -C "$root_dir" rev-parse -q --verify "refs/tags/$_tag" >/dev/null; then + _existing=$(git -C "$root_dir" rev-list -n 1 "$_tag") + _head=$(git -C "$root_dir" rev-parse HEAD) + if [ "$_existing" != "$_head" ]; then + echo "promote_zddc_server: tag $_tag exists at $_existing but HEAD is $_head" >&2 + return 1 + fi + echo "(tag $_tag already at HEAD)" + else + git -C "$root_dir" tag "$_tag" + echo "tagged $_tag" + fi + echo "Released zddc-server v${_ver} (stable)" + ;; + alpha | beta) + # Mutable channel mirror per platform; cascade alpha → beta on + # a beta cut. + for _plat in $ZDDC_SERVER_PLATFORMS; do + _suffix="" + case "$_plat" in *windows*) _suffix=".exe" ;; esac + _src="$_dist/zddc-server-${_plat}${_suffix}" + _file="zddc-server_${_ch}_${_plat}${_suffix}" + rm -f "$_rdir/$_file" + cp "$_src" "$_rdir/$_file" + echo "Wrote $_rdir/$_file" + if [ "$_ch" = "beta" ]; then + ln -sfn "$_file" "$_rdir/zddc-server_alpha_${_plat}${_suffix}" + fi + done + echo "Released zddc-server ${_ch}" + ;; + *) + echo "promote_zddc_server: unknown channel '$_ch'" >&2 + return 1 + ;; + esac + + # Refresh every stub page (covers the new release plus any pre-existing). + write_zddc_server_stubs_all "$_rdir" +} + +# Verify every channel link for every release tool exists and resolves. +# Runs at the end of every build. Fails the build if anything is dangling. +# Channel verification covers both HTML tools (one .html per channel) and +# zddc-server (one stub HTML + four binaries per channel). +# +# Bootstrap-friendly: if zddc-server has no per-version artifacts at all +# (i.e. no release has been cut yet under the new lockstep model), the +# zddc-server entries are skipped with a heads-up rather than failing. The +# first stable cut materializes them. +verify_channel_links() { + _rdir="$1" + _missing=0 + _verified=0 + + for _t in archive transmittal classifier mdedit landing; do + for _ch in stable beta alpha; do + _f="$_rdir/${_t}_${_ch}.html" + if [ -e "$_f" ]; then + _verified=$((_verified + 1)) + else + echo " MISSING: ${_t}_${_ch}.html" >&2 + _missing=$((_missing + 1)) + fi + done + done + + # zddc-server's stable cut anchors the channel chain (cascade rule: + # stable cut → alpha + beta both reset to stable). Until stable + # exists, the verifier runs in bootstrap mode and skips — alpha/beta + # cuts in isolation are valid bootstrap state but have no cascade + # fallback target yet. + _zs_stable_exists=$(find "$_rdir" -maxdepth 1 -name 'zddc-server_stable_linux-amd64' -print -quit 2>/dev/null) + if [ -z "$_zs_stable_exists" ]; then + echo " (zddc-server stable not yet cut — run 'sh build.sh --release' to anchor the channel chain)" + else + for _ch in stable beta alpha; do + _f="$_rdir/zddc-server_${_ch}.html" + if [ -e "$_f" ]; then + _verified=$((_verified + 1)) + else + echo " MISSING: zddc-server_${_ch}.html" >&2 + _missing=$((_missing + 1)) + fi + for _plat in $ZDDC_SERVER_PLATFORMS; do + _suffix="" + case "$_plat" in *windows*) _suffix=".exe" ;; esac + _f="$_rdir/zddc-server_${_ch}_${_plat}${_suffix}" + if [ -e "$_f" ]; then + _verified=$((_verified + 1)) + else + echo " MISSING: zddc-server_${_ch}_${_plat}${_suffix}" >&2 + _missing=$((_missing + 1)) + fi + done + done + fi + + if [ "$_missing" -gt 0 ]; then + echo "channel-link verification: $_missing missing artifact(s)" >&2 + return 1 + fi + echo "channel-link verification: $_verified link(s) ok" +} diff --git a/shared/publish-codeberg-release.sh b/shared/publish-codeberg-release.sh deleted file mode 100755 index c88f1ad..0000000 --- a/shared/publish-codeberg-release.sh +++ /dev/null @@ -1,167 +0,0 @@ -#!/bin/sh -# publish-codeberg-release.sh — upload assets to a Codeberg release. -# -# Usage: -# publish_codeberg_release ... -# -# Where: -# e.g. VARASYS/ZDDC -# e.g. zddc-server-v0.0.8-alpha.3 or archive-v0.0.3 -# one or more files to attach to the release -# -# Prerequisites: -# - $CODEBERG_TOKEN exported in the environment, with scope sufficient -# to create/update releases on the target repo. (Codeberg/Gitea -# terminology: "Application token with `write:repository` access".) -# - curl, jq. -# -# Behavior: -# - If a release for $tag does not exist on Codeberg, create it. The -# prerelease flag is derived from the tag itself: if the version -# part (text after the last 'v') contains a '-', it is a pre-release -# (e.g. 'zddc-server-v0.0.8-alpha.2' → prerelease=true); -# 'zddc-server-v0.0.7' → prerelease=false. Codeberg orders releases -# by published-at and sets a "Latest" badge against the latest -# non-prerelease, so this matters. -# - For each asset: if a same-named asset already exists, delete it -# first (Codeberg/Gitea API doesn't support in-place replacement). -# Then upload the new bytes. -# -# Idempotent: re-running with the same args leaves the release with the -# same set of assets. -# -# This file is meant to be sourced and invoked via the function name, but -# it's also runnable directly as a script for quick testing — when run -# directly (i.e., $0 ends in publish-codeberg-release.sh), the function -# is called with the script's argv. -# -# NOTE: We do NOT `set -eu` at the top, because that would leak into any -# caller that sources this file. The direct-run dispatch at the bottom -# turns -eu on for that path only. - -CODEBERG_API="${CODEBERG_API:-https://codeberg.org/api/v1}" - -# True iff $1's "version part" (text after last 'v') contains '-'. -# Tags with a '-' in the version part are pre-releases per the -# pre-release-semver scheme (see AGENTS.md "Releasing"). -_is_prerelease() { - _ver="${1##*v}" - case "$_ver" in *-*) return 0 ;; *) return 1 ;; esac -} - -# Fetch a release by tag. Echoes the numeric release ID, or empty on 404. -# Suppresses 404-on-stderr; other errors propagate. -_get_release_id() { - _repo="$1" - _tag="$2" - _resp=$(curl -fsS -H "Authorization: token $CODEBERG_TOKEN" \ - "$CODEBERG_API/repos/$_repo/releases/tags/$_tag" 2>/dev/null) || _resp="" - [ -z "$_resp" ] && return 0 - printf '%s' "$_resp" | jq -r '.id // empty' -} - -# Create a release for the given tag. Echoes the new release ID. Bombs -# out on any error (the caller relies on stable behavior — releases -# don't get half-created). -_create_release() { - _repo="$1" - _tag="$2" - if _is_prerelease "$_tag"; then - _prerelease=true - else - _prerelease=false - fi - # Inline JSON; tag/name don't contain quotes per our naming rules. - _body=$(printf '{"tag_name":"%s","name":"%s","prerelease":%s,"draft":false}' \ - "$_tag" "$_tag" "$_prerelease") - curl -fsS \ - -X POST \ - -H "Authorization: token $CODEBERG_TOKEN" \ - -H "Content-Type: application/json" \ - -d "$_body" \ - "$CODEBERG_API/repos/$_repo/releases" \ - | jq -r '.id' -} - -# Echo the asset ID for an asset of the given filename in the given -# release, or empty if no such asset. -_find_asset_id() { - _repo="$1" - _release_id="$2" - _name="$3" - curl -fsS -H "Authorization: token $CODEBERG_TOKEN" \ - "$CODEBERG_API/repos/$_repo/releases/$_release_id" \ - | jq -r --arg n "$_name" '.assets[] | select(.name == $n) | .id' \ - | head -1 -} - -_delete_asset() { - _repo="$1" - _asset_id="$2" - curl -fsS -X DELETE \ - -H "Authorization: token $CODEBERG_TOKEN" \ - "$CODEBERG_API/repos/$_repo/releases/assets/$_asset_id" >/dev/null -} - -_upload_asset() { - _repo="$1" - _release_id="$2" - _asset_path="$3" - _name=$(basename "$_asset_path") - # Codeberg/Gitea expects the file under field name "attachment", and - # the desired display name as the ?name= query parameter (otherwise - # the original filename is used; we set both for clarity). - curl -fsS -X POST \ - -H "Authorization: token $CODEBERG_TOKEN" \ - -F "attachment=@${_asset_path}" \ - "$CODEBERG_API/repos/$_repo/releases/$_release_id/assets?name=$(printf '%s' "$_name" | jq -sRr @uri)" \ - >/dev/null -} - -publish_codeberg_release() { - if [ $# -lt 3 ]; then - echo "usage: publish_codeberg_release ..." >&2 - return 2 - fi - if [ -z "${CODEBERG_TOKEN:-}" ]; then - echo "publish_codeberg_release: CODEBERG_TOKEN not set" >&2 - return 2 - fi - _repo="$1" - _tag="$2" - shift 2 - - _release_id=$(_get_release_id "$_repo" "$_tag") - if [ -z "$_release_id" ]; then - echo " creating release for $_tag" - _release_id=$(_create_release "$_repo" "$_tag") - if [ -z "$_release_id" ] || [ "$_release_id" = "null" ]; then - echo "publish_codeberg_release: failed to create release for $_tag" >&2 - return 1 - fi - fi - echo " release id: $_release_id" - - for _asset_path do - if [ ! -f "$_asset_path" ]; then - echo "publish_codeberg_release: asset not readable: $_asset_path" >&2 - return 1 - fi - _name=$(basename "$_asset_path") - _existing=$(_find_asset_id "$_repo" "$_release_id" "$_name") - if [ -n "$_existing" ]; then - echo " replacing existing asset $_name (id $_existing)" - _delete_asset "$_repo" "$_existing" - fi - echo " uploading $_name" - _upload_asset "$_repo" "$_release_id" "$_asset_path" - done -} - -# When invoked directly (not sourced), call the function with argv. -case "${0##*/}" in - publish-codeberg-release.sh) - set -eu - publish_codeberg_release "$@" - ;; -esac diff --git a/tests/archive-cascade.spec.js b/tests/archive-cascade.spec.js new file mode 100644 index 0000000..234eb51 --- /dev/null +++ b/tests/archive-cascade.spec.js @@ -0,0 +1,514 @@ +// End-to-end cascade behaviour for the archive browser. +// +// Pins the contract for how the three filter layers compose: +// 1. Folder-type bar (enabledFolderTypes ⊂ {issued, received, mdl, incoming}) +// 2. Selected parties (selectedGroupingFolders, name-keyed) +// 3. Selected projects (visibleProjects, in multi-project mode) +// +// Specifically targets the symptom "I select various third-party folders and +// the Issued/Received/MDL/Incoming badges, and sometimes files that should be +// there aren't shown". Each test asserts both the file table and the +// transmittal folder list, since the user reported both can drop entries. + +import { test, expect } from '@playwright/test'; +import { MOCK_FS_INIT_SCRIPT } from './fixtures/mock-fs-api.js'; +import * as path from 'path'; + +const HTML_PATH = path.resolve('archive/dist/archive.html'); + +// All four folder types enabled — used at the start of each test so default +// (issued+received only) isn't quietly hiding things. +async function enableAllFolderTypes(page) { + await page.evaluate(() => { + window.app.enabledFolderTypes = new Set(['issued', 'received', 'mdl', 'incoming']); + }); +} + +async function selectAllParties(page) { + await page.evaluate(() => { + const cb = document.getElementById('selectAllGroupingCheckbox'); + if (cb && !cb.checked) cb.click(); + }); + await page.waitForTimeout(200); +} + +// Returns the names of files currently visible in the table (parsed from the +// row labels). Folder-type changes flow through applyFilters → filteredFiles +// → updateFileTable, so reading filteredFiles directly is the closest signal. +async function visibleFileNames(page) { + return page.evaluate(() => window.app.filteredFiles.map(f => f.name)); +} + +// Returns the visible-transmittal-folder paths (the ones that pass the +// grouping/folder-type cascade — what the left panel actually renders). +async function visibleTransmittalPaths(page) { + return page.evaluate(() => { + const items = document.querySelectorAll('#transmittalFoldersList .folder-item'); + return Array.from(items).map(el => el.getAttribute('data-path')); + }); +} + +test.describe('Archive cascade: folder-type × parties × outstanding', () => { + test.beforeEach(async ({ page }) => { + await page.addInitScript(MOCK_FS_INIT_SCRIPT); + }); + + test('all four folder-type badges hide their subtrees and only their subtrees', async ({ page }) => { + await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 }); + + // Start with all four enabled so the scan picks up MDL/Incoming. + await enableAllFolderTypes(page); + + await page.evaluate(() => { + window.__setMockDirectoryTree('Archive', { + 'BM': { + 'Issued': { + '2025-01-01_T-ISSUED (IFC) - I': { + '100-EL-SPC-0001_A (IFC) - SpecIssued.pdf': '%PDF', + }, + }, + 'Received': { + '2025-01-02_T-RECEIVED (IFC) - R': { + '100-EL-SPC-0002_A (IFC) - SpecReceived.pdf': '%PDF', + }, + }, + 'MDL': { + '2025-01-03_T-MDL (IFC) - M': { + '100-EL-SPC-0003_A (IFC) - SpecMDL.pdf': '%PDF', + }, + }, + 'Incoming': { + '2025-01-04_T-INCOMING (IFC) - In': { + '100-EL-SPC-0004_A (IFC) - SpecIncoming.pdf': '%PDF', + }, + }, + }, + }); + }); + + await page.locator('#addDirectoryBtn').click(); + await page.waitForTimeout(2000); + + // All four folder types should have surfaced files at scan time. + const allFour = await visibleFileNames(page); + for (const expected of ['SpecIssued', 'SpecReceived', 'SpecMDL', 'SpecIncoming']) { + expect(allFour.some(n => n.includes(expected)), `expected ${expected} present with all four enabled; got ${allFour.join(', ')}`).toBe(true); + } + + // Toggle each folder type off and verify its subtree's files disappear, + // while the other three stay. This is the load-bearing claim. + const types = [ + { type: 'issued', expectGone: 'SpecIssued' }, + { type: 'received', expectGone: 'SpecReceived' }, + { type: 'mdl', expectGone: 'SpecMDL' }, + { type: 'incoming', expectGone: 'SpecIncoming' }, + ]; + + for (const { type, expectGone } of types) { + // Disable the type via the canonical app entry point. + await page.evaluate((t) => window.app.modules.app.toggleFolderType(t), type); + await page.waitForTimeout(300); + + const visible = await visibleFileNames(page); + expect( + visible.some(n => n.includes(expectGone)), + `disabling ${type} should hide ${expectGone}; saw: ${visible.join(', ')}` + ).toBe(false); + + // The other three subtrees keep their files. + for (const other of types) { + if (other.type === type) continue; + expect( + visible.some(n => n.includes(other.expectGone)), + `disabling ${type} must not hide ${other.expectGone}; saw: ${visible.join(', ')}` + ).toBe(true); + } + + // Re-enable for the next iteration. toggleFolderType triggers a + // refreshDirectories() because the scan dropped that subtree — + // we need to wait for the rescan to finish before continuing. + await page.evaluate((t) => window.app.modules.app.toggleFolderType(t), type); + await page.waitForTimeout(2000); + } + }); + + test('toggling a folder type off then on does not duplicate or lose files', async ({ page }) => { + await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 }); + + await enableAllFolderTypes(page); + + await page.evaluate(() => { + window.__setMockDirectoryTree('Archive', { + 'BM': { + 'Issued': { + '2025-01-01_T-ISSUED (IFC) - I': { + '100-EL-SPC-0001_A (IFC) - I1.pdf': '%PDF', + '100-EL-SPC-0001_B (IFC) - I2.pdf': '%PDF', + }, + }, + 'Received': { + '2025-01-02_T-RECEIVED (IFC) - R': { + '100-EL-SPC-0002_A (IFC) - R1.pdf': '%PDF', + }, + }, + }, + }); + }); + + await page.locator('#addDirectoryBtn').click(); + await page.waitForTimeout(2000); + + const baseline = await page.evaluate(() => ({ + files: window.app.files.length, + txn: window.app.transmittalFolders.length, + grouping: window.app.groupingFolders.length, + })); + expect(baseline.files).toBe(3); + + // Toggle Issued off → on. + await page.evaluate(() => window.app.modules.app.toggleFolderType('issued')); + await page.waitForTimeout(300); + await page.evaluate(() => window.app.modules.app.toggleFolderType('issued')); + await page.waitForTimeout(2000); + + const after = await page.evaluate(() => ({ + files: window.app.files.length, + txn: window.app.transmittalFolders.length, + grouping: window.app.groupingFolders.length, + // Distinct-file-id counts to catch duplication via re-scan. + distinctIds: new Set(window.app.files.map(f => f.id)).size, + distinctPaths: new Set(window.app.files.map(f => f.path)).size, + })); + + expect(after.files, 'no file duplication after toggle off→on').toBe(baseline.files); + expect(after.distinctIds, 'each file should still have a unique id').toBe(after.files); + expect(after.distinctPaths, 'no duplicate file paths').toBe(after.files); + expect(after.grouping, 'grouping folders not duplicated').toBe(baseline.grouping); + expect(after.txn, 'transmittal folders not duplicated').toBe(baseline.txn); + }); + + test('outstanding files: visible under /, /Issued/, /MDL/ matches the folder-type cascade', async ({ page }) => { + await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 }); + + await enableAllFolderTypes(page); + + await page.evaluate(() => { + window.__setMockDirectoryTree('Archive', { + 'BM': { + // Loose file directly under party — not under any folder-type marker. + '100-EL-SPC-LOOSE_A (IFC) - LooseAtParty.pdf': '%PDF', + 'Issued': { + // Loose file under Issued (no transmittal folder wrapper) + '100-EL-SPC-LOOSE_A (IFC) - LooseAtIssued.pdf': '%PDF', + }, + 'MDL': { + '100-EL-SPC-LOOSE_A (IFC) - LooseAtMDL.pdf': '%PDF', + }, + }, + }); + }); + + await page.locator('#addDirectoryBtn').click(); + await page.waitForTimeout(2000); + + await selectAllParties(page); + + // With all folder types enabled, all three loose files should be visible + // under the Outstanding virtual transmittal. + const allEnabled = await visibleFileNames(page); + expect(allEnabled.some(n => n.includes('LooseAtParty'))).toBe(true); + expect(allEnabled.some(n => n.includes('LooseAtIssued'))).toBe(true); + expect(allEnabled.some(n => n.includes('LooseAtMDL'))).toBe(true); + + // Disable MDL — only LooseAtMDL should drop. + await page.evaluate(() => window.app.modules.app.toggleFolderType('mdl')); + await page.waitForTimeout(2000); + + const noMDL = await visibleFileNames(page); + expect(noMDL.some(n => n.includes('LooseAtParty'))).toBe(true); + expect(noMDL.some(n => n.includes('LooseAtIssued'))).toBe(true); + expect(noMDL.some(n => n.includes('LooseAtMDL'))).toBe(false); + + // Disable Issued too — LooseAtIssued drops, LooseAtParty stays + // (LooseAtParty's path has no folder-type segment at all). + await page.evaluate(() => window.app.modules.app.toggleFolderType('issued')); + await page.waitForTimeout(2000); + + const noIssuedMDL = await visibleFileNames(page); + expect(noIssuedMDL.some(n => n.includes('LooseAtParty'))).toBe(true); + expect(noIssuedMDL.some(n => n.includes('LooseAtIssued'))).toBe(false); + expect(noIssuedMDL.some(n => n.includes('LooseAtMDL'))).toBe(false); + }); + + test('same-name party across two projects + folder-type cascade hides both projects symmetrically', async ({ page }) => { + // BM in ProjectA AND ProjectB, each with Issued and Received subtrees. + // Disabling Received must hide BOTH projects' Received files; + // selecting BM (one row in the panel) must surface both projects' Issued. + await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 }); + + await enableAllFolderTypes(page); + + await page.evaluate(() => { + window.__setMockDirectoryTree('combined-root', { + 'ProjectA': { + 'Archive': { + 'BM': { + 'Issued': { + '2025-01-01_T-A-I (IFC) - X': { + '100-EL-SPC-0001_A (IFC) - A_Issued.pdf': '%PDF', + }, + }, + 'Received': { + '2025-01-02_T-A-R (IFC) - X': { + '100-EL-SPC-0002_A (IFC) - A_Received.pdf': '%PDF', + }, + }, + }, + }, + }, + 'ProjectB': { + 'Archive': { + 'BM': { + 'Issued': { + '2025-02-01_T-B-I (IFC) - X': { + '200-EL-SPC-0001_A (IFC) - B_Issued.pdf': '%PDF', + }, + }, + 'Received': { + '2025-02-02_T-B-R (IFC) - X': { + '200-EL-SPC-0002_A (IFC) - B_Received.pdf': '%PDF', + }, + }, + }, + }, + }, + }); + window.app.projectFilter = new Set(['ProjectA', 'ProjectB']); + window.app.visibleProjects = new Set(['ProjectA', 'ProjectB']); + window.app.isMultiProject = true; + }); + + await page.locator('#addDirectoryBtn').click(); + await page.waitForTimeout(2000); + + // BM should appear once in the parties panel. + const partyRows = await page.locator('#groupingFoldersList .folder-item-name').allTextContents(); + const bmCount = partyRows.filter(n => n.trim() === 'BM').length; + expect(bmCount, 'BM merged to one party row across projects').toBe(1); + + // All four files visible with all four folder types enabled. + const all = await visibleFileNames(page); + expect(all.some(n => n.includes('A_Issued'))).toBe(true); + expect(all.some(n => n.includes('B_Issued'))).toBe(true); + expect(all.some(n => n.includes('A_Received'))).toBe(true); + expect(all.some(n => n.includes('B_Received'))).toBe(true); + + // Disable Received — BOTH projects' Received files hide. + await page.evaluate(() => window.app.modules.app.toggleFolderType('received')); + await page.waitForTimeout(2000); + + const noReceived = await visibleFileNames(page); + expect(noReceived.some(n => n.includes('A_Issued')), 'ProjectA Issued stays').toBe(true); + expect(noReceived.some(n => n.includes('B_Issued')), 'ProjectB Issued stays').toBe(true); + expect(noReceived.some(n => n.includes('A_Received')), 'ProjectA Received hidden').toBe(false); + expect(noReceived.some(n => n.includes('B_Received')), 'ProjectB Received hidden').toBe(false); + + // Transmittal folder list also drops both projects' Received transmittals. + const txnPaths = await visibleTransmittalPaths(page); + expect(txnPaths.every(p => !p.includes('/Received/')), 'no Received transmittals: ' + txnPaths.join(', ')).toBe(true); + }); + + test('toggling a party off then on with selectAllTransmittals re-syncs the transmittal selection', async ({ page }) => { + // Default selectAllTransmittals=true. After deselecting BM, BM's + // transmittal folders are gone from the list. Reselecting BM should + // make them visible AND auto-selected (because select-all is still on). + await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 }); + + await enableAllFolderTypes(page); + + await page.evaluate(() => { + window.__setMockDirectoryTree('Archive', { + 'BM': { + 'Issued': { + '2025-01-01_T-BM (IFC) - X': { + '100-EL-SPC-0001_A (IFC) - BM1.pdf': '%PDF', + }, + }, + }, + 'OTHER': { + 'Issued': { + '2025-01-02_T-OTHER (IFC) - X': { + '200-EL-SPC-0002_A (IFC) - OTHER1.pdf': '%PDF', + }, + }, + }, + }); + }); + + await page.locator('#addDirectoryBtn').click(); + await page.waitForTimeout(2000); + + // Both parties auto-selected, both transmittals auto-selected. + const initial = await page.evaluate(() => ({ + selectAllTxn: window.app.selectAllTransmittals, + selectedGrouping: Array.from(window.app.selectedGroupingFolders).sort(), + selectedTxn: Array.from(window.app.selectedTransmittalFolders).sort(), + })); + expect(initial.selectAllTxn).toBe(true); + expect(initial.selectedGrouping).toEqual(['BM', 'OTHER']); + expect(initial.selectedTxn.some(p => p.includes('T-BM'))).toBe(true); + expect(initial.selectedTxn.some(p => p.includes('T-OTHER'))).toBe(true); + + // Deselect BM via the canonical event handler (mimics user click) + await page.evaluate(() => { + window.app.selectAllGroupingFolders = false; + window.app.selectedGroupingFolders.delete('BM'); + window.app.modules.app.renderGroupingFolders(); + window.app.modules.app.renderTransmittalFolders(); + window.app.modules.filtering.applyFilters(); + }); + await page.waitForTimeout(200); + + // BM's transmittal is no longer visible AND no longer in the selection set. + const afterDeselect = await page.evaluate(() => ({ + selectedTxn: Array.from(window.app.selectedTransmittalFolders).sort(), + visiblePaths: Array.from(document.querySelectorAll('#transmittalFoldersList .folder-item')) + .map(el => el.getAttribute('data-path')), + })); + expect(afterDeselect.selectedTxn.some(p => p.includes('T-BM'))).toBe(false); + expect(afterDeselect.visiblePaths.some(p => p && p.includes('T-BM'))).toBe(false); + + // Re-add BM. Because selectAllTransmittals stays true, BM's + // transmittal should be auto-selected on the re-render. + await page.evaluate(() => { + window.app.selectedGroupingFolders.add('BM'); + window.app.modules.app.renderGroupingFolders(); + window.app.modules.app.renderTransmittalFolders(); + window.app.modules.filtering.applyFilters(); + }); + await page.waitForTimeout(200); + + const afterReselect = await page.evaluate(() => ({ + selectAllTxn: window.app.selectAllTransmittals, + selectedTxn: Array.from(window.app.selectedTransmittalFolders).sort(), + visibleFiles: window.app.filteredFiles.map(f => f.name), + })); + expect(afterReselect.selectAllTxn).toBe(true); + expect( + afterReselect.selectedTxn.some(p => p.includes('T-BM')), + 'BM transmittal should be re-selected via selectAll cascade' + ).toBe(true); + expect(afterReselect.visibleFiles.some(n => n.includes('BM1'))).toBe(true); + }); +}); + +test.describe('Archive cascade: nested-party path-segment contract', () => { + test.beforeEach(async ({ page }) => { + await page.addInitScript(MOCK_FS_INIT_SCRIPT); + }); + + // Pin the contract for nested-party paths. With folder-type segment NOT + // immediately after the party, the current rule (transmittalIsUnderVisibleParty + // checks only the segment right after the first matched party) means the + // folder-type cascade can leak. Either the test should fail and we fix + // the cascade to walk all party matches; or the contract is "the folder + // type only applies one level under the party". Recording the call here + // forces us to face the question explicitly when a future change touches + // the helper. + test('nested party + deep folder-type marker: the deep folder-type filter applies to the file', async ({ page }) => { + await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 }); + + await enableAllFolderTypes(page); + + await page.evaluate(() => { + window.__setMockDirectoryTree('Archive', { + 'BM': { + 'sub': { + 'Issued': { + '2025-01-01_T-NESTED (IFC) - X': { + '100-EL-SPC-NESTED_A (IFC) - SpecDeepIssued.pdf': '%PDF', + }, + }, + }, + }, + }); + }); + + await page.locator('#addDirectoryBtn').click(); + await page.waitForTimeout(2000); + + await selectAllParties(page); + + // With Issued enabled, the file is visible. + const visibleAll = await visibleFileNames(page); + expect(visibleAll.some(n => n.includes('SpecDeepIssued'))).toBe(true); + + // Turn Issued OFF — the file lives under .../Issued/... so its scan-time + // listing should be skipped. After the toggle-driven rescan, the file + // must NOT be visible regardless of how transmittalIsUnderVisibleParty + // walks segments. + await page.evaluate(() => window.app.modules.app.toggleFolderType('issued')); + await page.waitForTimeout(2000); + + const visibleNoIssued = await visibleFileNames(page); + expect( + visibleNoIssued.some(n => n.includes('SpecDeepIssued')), + 'deep Issued subtree must hide when Issued is toggled off' + ).toBe(false); + }); +}); + +test.describe('Archive cascade: URL state round-trip', () => { + test.beforeEach(async ({ page }) => { + await page.addInitScript(MOCK_FS_INIT_SCRIPT); + }); + + test('non-default folder-type set round-trips through serialize → restore', async ({ page }) => { + await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 }); + + const qs = await page.evaluate(() => { + window.app.enabledFolderTypes = new Set(['issued', 'mdl']); // received off, incoming off, mdl on + return window.app.modules.urlState.serialize(); + }); + // serialize() returns "?types=..." (leading "?" included) or "" when no + // diffs from defaults exist. Strip the leading "?" before re-emitting. + const cleanQs = qs.startsWith('?') ? qs.slice(1) : qs; + expect(cleanQs).toContain('types='); + + const round = await page.evaluate((querystring) => { + // Reset to defaults so restore has work to do. + window.app.enabledFolderTypes = new Set(['issued', 'received']); + history.replaceState(null, '', '?' + querystring); + window.app.modules.urlState.restore(); + return Array.from(window.app.enabledFolderTypes).sort(); + }, cleanQs); + + expect(round).toEqual(['issued', 'mdl']); + }); + + // Selected parties are deliberately NOT in URL state today — the natural + // flow is: pick a directory locally, then narrow with party checkboxes. + // Pin this in a test so accidentally adding party serialization later + // doesn't break sharing semantics; remove the test (and the assertion) + // when/if sharing-with-parties becomes a feature. + test('selected parties are NOT serialized to URL state (current contract)', async ({ page }) => { + await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 }); + + const qs = await page.evaluate(() => { + window.app.selectAllGroupingFolders = false; + window.app.selectedGroupingFolders = new Set(['BM', 'OTHER']); + return window.app.modules.urlState.serialize(); + }); + expect(qs).not.toContain('parties='); + expect(qs).not.toContain('groups='); + expect(qs).not.toContain('selected='); + }); +}); diff --git a/website/css/style.css b/website/css/style.css index 1ddbcf4..9d4617f 100644 --- a/website/css/style.css +++ b/website/css/style.css @@ -1123,3 +1123,164 @@ html[data-theme="light"] { @media (max-width: 900px) { .ref-layout { gap: var(--spacing-xl); } } + +/* ── Releases page components ─────────────────────────── */ +/* Used by website/releases/index.html (rendered by build.sh). + Reuses existing tokens; adds nothing to the design system that + isn't load-bearing for the install + version-pinning narrative. */ + +.version-picker-bar { + display: flex; + align-items: center; + gap: var(--spacing-sm); + padding: var(--spacing-sm) var(--spacing-md); + background: var(--color-bg-subtle); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + margin: var(--spacing-md) 0 var(--spacing-lg); + font-size: 0.95rem; +} +.version-picker-bar label { color: var(--color-text-muted); margin-right: var(--spacing-xs); } +.version-picker-bar select { + font: inherit; + padding: 0.25rem 0.5rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-bg); + color: var(--color-text); + cursor: pointer; +} +.version-picker-bar select:hover { border-color: var(--color-accent); } +.version-picker-bar .picker-hint { color: var(--color-text-muted); margin-left: auto; font-size: 0.875rem; } + +.dl-primary { + display: inline-flex; + align-items: center; + gap: var(--spacing-sm); + padding: 0.85rem 1.5rem; + background: var(--color-accent); + color: #fff; + text-decoration: none; + font-weight: 600; + font-size: 1.05rem; + border-radius: var(--radius-md); + box-shadow: var(--shadow-md); + transition: background 0.15s, box-shadow 0.15s, transform 0.05s; +} +.dl-primary:hover { background: var(--color-accent-hover); box-shadow: var(--shadow-lg); } +.dl-primary:active { transform: translateY(1px); } +.dl-primary .dl-icon { font-size: 1.2em; line-height: 1; } +.dl-primary-meta { + display: block; + margin-top: var(--spacing-xs); + color: var(--color-text-muted); + font-size: 0.875rem; + font-family: var(--font-mono); +} + +.dl-secondary-row { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-sm) var(--spacing-md); + margin-top: var(--spacing-md); + font-size: 0.92rem; + color: var(--color-text-muted); +} +.dl-secondary-row > span { color: var(--color-text-muted); } +.dl-secondary-row a { + color: var(--color-text); + text-decoration: none; + padding: 0.2rem 0.5rem; + border-radius: var(--radius-sm); + border: 1px solid var(--color-border); + background: var(--color-bg); +} +.dl-secondary-row a:hover { background: var(--color-accent-soft); border-color: var(--color-accent); color: var(--color-accent); } + +.pin-card { + background: var(--color-bg-subtle); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: var(--spacing-md) var(--spacing-lg); +} +.pin-card h3 { + font-size: 1rem; + margin: 0 0 var(--spacing-sm); + color: var(--color-accent); +} +.pin-card p { margin: var(--spacing-sm) 0; color: var(--color-text); } +.pin-card pre { + background: var(--color-bg); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + padding: var(--spacing-sm) var(--spacing-md); + margin: var(--spacing-sm) 0; + font-family: var(--font-mono); + font-size: 0.85rem; + overflow-x: auto; + color: var(--color-text); +} +.pin-card code { font-family: var(--font-mono); font-size: 0.85em; padding: 0.05rem 0.3rem; background: var(--color-bg); border: 1px solid var(--color-border); border-radius: var(--radius-sm); } +.pin-note { + margin-top: var(--spacing-md); + padding: var(--spacing-sm) var(--spacing-md); + background: var(--color-accent-soft); + border-left: 3px solid var(--color-accent); + border-radius: var(--radius-sm); + color: var(--color-text); + font-size: 0.95rem; +} + +/* Channel quick-pick chips next to the version picker. Visible at-a- + glance entry points to alpha and beta channels even when those + currently cascade to stable — discoverability matters more than + minimalism here. Clicking drives the version picker. */ +.channel-chips { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--spacing-sm); + margin: 0 0 var(--spacing-lg); + font-size: 0.92rem; +} +.channel-chips-label { color: var(--color-text-muted); margin-right: var(--spacing-xs); } +.channel-chip { + font: inherit; + padding: 0.25rem 0.75rem; + background: var(--color-bg); + color: var(--color-text); + border: 1px solid var(--color-border); + border-radius: 999px; + cursor: pointer; + transition: background 0.12s, border-color 0.12s, color 0.12s; +} +.channel-chip:hover { background: var(--color-accent-soft); border-color: var(--color-accent); color: var(--color-accent); } +.channel-chip.is-current { + background: var(--color-accent); + color: #fff; + border-color: var(--color-accent); + font-weight: 600; +} +.channel-chip[data-channel="alpha"]:not(.is-current), +.channel-chip[data-channel="beta"]:not(.is-current) { + color: var(--color-text-muted); +} + +/* Channel explainer (bottom of the page) */ +.channel-explainer { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--spacing-md); margin-top: var(--spacing-md); } +.channel-explainer > div { + padding: var(--spacing-md); + background: var(--color-bg-subtle); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); +} +.channel-explainer h4 { margin: 0 0 var(--spacing-xs); font-size: 0.95rem; } +.channel-explainer h4.alpha { color: var(--color-text-muted); } +.channel-explainer h4.beta { color: var(--color-text); } +.channel-explainer h4.stable { color: var(--color-accent); } +.channel-explainer p { margin: 0; font-size: 0.875rem; color: var(--color-text-muted); } + +@media (max-width: 700px) { + .channel-explainer { grid-template-columns: 1fr; } + .dl-primary { width: 100%; justify-content: center; } +} diff --git a/website/releases/index.html b/website/releases/index.html index 5c9cc01..5213a81 100644 --- a/website/releases/index.html +++ b/website/releases/index.html @@ -3,35 +3,13 @@ - Releases — ZDDC - + Download ZDDC + -
-

Releases

-

All published versions and channel builds of every ZDDC tool. Stable releases are immutable; alpha and beta channels are rebuilt without notice.

+

Download ZDDC

+

Pick how you want to use it. Pick the version you want. Every link below points at a real, immutable file you can save into your archive — your tools, your version, forever.

-
-

Archive

-
- stable - beta - alpha -
-
Pin to version: - v0.0.2 - v0.0.1 -
-
-
-

Transmittal

-
- stable - beta - alpha -
-
Pin to version: - v0.0.2 - v0.0.1 -
-
-
-

Classifier

-
- stable - beta - alpha -
-
Pin to version: - v0.0.2 - v0.0.1 -
-
-
-

Markdown Editor

-
- stable - beta - alpha -
-
Pin to version: - v0.0.2 - v0.0.1 -
-
-
-

Landing (project picker)

-
- stable - beta - alpha -
-
Pin to version: - v0.0.2 - v0.0.1 -
-
-
-

zddc-server (Go file server)

-

Binaries are published as Codeberg release assets. Pick a platform from the release page; or build from source via the helm charts under helm/.

-

Browse zddc-server releases on Codeberg →

+
+ + + Changes every download link below. +
+ + +
+ Or pick a channel: + + + +
+ + +
+

Path A — Self-host the server

+

One small Go binary. All five tools are baked in via //go:embed; the server picks the right one for each folder of your archive. Adds ACL via .zddc files, the virtual .archive document index, and SSO header passthrough. Stop the server and the directory is still a perfectly valid ZDDC archive — the server is convenience, not lock-in.

+

+ Not yet published. The first lockstep release publishes binaries here. Until then, build from source: git clone and (cd zddc && go build ./cmd/zddc-server). Once sh build.sh --release runs, this card auto-populates with download buttons for every platform. +

-
-

Each link above is a real static file (or a checked-in symlink resolving to one). Channel chips track the current build of that channel and may change at any time; per-version files are immutable. To install or pin in your own deployment, see the home page.

+ +
+

Path B — Standalone tools

+

Every tool is a single self-contained HTML file. Open it locally and point it at a folder on your disk — no install, no server, no account. Same on-disk layout the server uses. Use one tool, use all five, mix and match — there is no orchestration to set up.

+ +
+ + +
+

Your version, forever

+

Your server may run v0.0.8 next month and v0.1.0 the month after. Your project doesn't have to follow. If you depend on a specific behavior in archive v0.0.5, save that version into your archive — the next server upgrade can't take it away from you. Two ways to do it:

+
+
+

Drop a copy into your archive

+

Save the tool's HTML at the path the server would serve it from. The server's resolution order picks up real files first — before any cascade or embedded fallback.

+
curl -o MyProject/archive.html \
+  https://zddc.varasys.io/releases/archive_v0.0.2.html
+

Now MyProject/archive.html is yours. The server serves your bytes; nothing about a future --release can change them.

+
+
+

Pin via .zddc

+

Less invasive — no copies in your archive, just a small config entry telling the server which version to fetch and cache. Closer-to-leaf wins, so subprojects can pin further.

+
# MyProject/.zddc
+apps:
+  archive: v0.0.2
+

Server fetches once on first hit, caches under _app/, falls through to the embedded copy if the fetch fails.

+
+
+

Your archive's tools are yours. The server is convenience; deletion of the server doesn't break your archive — every per-version download above is a real, immutable static file. Save what you trust.

+
+ + +
+

Channels

+

Three channels, applied in lockstep across all tools. Pre-release channels exist to soak changes; stable is what production runs.

+
+
+

alpha

+

Active dev iteration. Rebuilds without notice. Look here for the very latest.

+
+
+

beta

+

Ready for general testing. Has soaked through alpha. Still mutable — pin to a versioned URL for reproducibility.

+
+
+

stable

+

Ready to ship. Every per-version file is immutable; _stable follows the latest cut. Channel cuts cascade: stable cut resets beta and alpha to track stable.

+
+
@@ -139,5 +158,118 @@ ZDDC is open source — codeberg.org/VARASYS/ZDDC + + diff --git a/zddc/README.md b/zddc/README.md index 5d749d5..b65c45a 100644 --- a/zddc/README.md +++ b/zddc/README.md @@ -330,27 +330,46 @@ Any URL path segment named `.archive` (configurable via `ZDDC_INDEX_PATH`) is in by the server and treated as a virtual document index. The index is built at startup by scanning all transmittal folders under `ZDDC_ROOT`. It -maps each `(trackingNumber, revision, modifier)` tuple to the file from the -**chronologically earliest** transmittal folder that contains it. +maps each `(project, trackingNumber, revision, modifier)` tuple to the file from the +**chronologically earliest** transmittal folder within that project that contains it. + +### Project scoping + +The `.archive` index is **scoped to the project** — i.e. the first slash-separated +segment of the request's `.archive` context path. The same tracking number issued +under two different projects does NOT collide; each project's `.archive/` surfaces +only that project's documents. + +A request to `/.archive/...` at the very root has no project segment to scope by +and returns **404 Not Found**. Stable references must always be project-rooted +(e.g. `/ProjectA/.archive/TRK-001.html`). + +Within one project, two different files claiming to be the same `(tracking, rev)` +are an authoring mistake. The chronological winner still wins, but a `WARN` +log is emitted with both paths so the conflict can be diagnosed and corrected. ### URL patterns | URL | Resolves to | |---|---| -| `GET /Project/.archive/TRK-001.html` | Latest base revision of TRK-001 | -| `GET /Project/.archive/TRK-001_A.html` | Base revision A of TRK-001 | -| `GET /Project/.archive/TRK-001_A+C1.html` | Modifier C1 of revision A of TRK-001 | -| `GET /Project/.archive/` | JSON listing of all resolvable trackingNumber.html entries | +| `GET /Project/.archive/TRK-001.html` | Latest base revision of TRK-001 within Project | +| `GET /Project/.archive/TRK-001_A.html` | Base revision A of TRK-001 within Project | +| `GET /Project/.archive/TRK-001_A+C1.html` | Modifier C1 of revision A of TRK-001 within Project | +| `GET /Project/.archive/` | JSON listing of Project's resolvable entries | +| `GET /Project/sub/sub/.archive/TRK-001.html` | Same as the top-level Project listing — depth within a project doesn't change scope | +| `GET /.archive/...` | **404** — root has no project segment | -All responses are `302 Found` redirects to the actual file URL. ACL is enforced on both -the `.archive` context directory and the resolved target file. +All successful responses are `302 Found` redirects to the actual file URL. ACL +is enforced on both the `.archive` context directory and the resolved target file. ### Why "earliest" transmittal? -Any file claiming to be `TRK-001_A (IFC)` should be identical across transmittals -(same content, same SHA-256). If the same tracking number and revision appears in multiple -transmittals, the first one received chronologically is treated as the authoritative copy. -A later arrival with a different hash is an error condition (to be detected separately). +Within one project, any file claiming to be `TRK-001_A (IFC)` should be identical +across transmittals (same content, same SHA-256). If the same tracking number and +revision appears in multiple transmittals, the first one received chronologically is +treated as the authoritative copy. A later arrival with a different file path is an +error condition; the server logs a `WARN` with both paths but does not change the +winner. ### Index refresh diff --git a/zddc/internal/apps/embedded/archive.html b/zddc/internal/apps/embedded/archive.html index 07f5f07..b2d61c5 100644 --- a/zddc/internal/apps/embedded/archive.html +++ b/zddc/internal/apps/embedded/archive.html @@ -2113,7 +2113,7 @@ td[data-field="trackingNumber"] {
ZDDC Archive - v0.0.3-alpha · 2026-05-01 20:41:59 · adb6904-dirty + v0.0.3-alpha · 2026-05-02 01:03:25 · 4ede420-dirty
@@ -7996,27 +7996,21 @@ window.app.modules.filtering = { }); } - // Returns true if any path segment of the transmittal folder matches a selected - // party name AND the segment immediately after it (if it's a folder-type name) - // is in enabledFolderTypes. Segment-equality matching means a party "BM" selected - // matches every "<...>/BM/<...>" path regardless of the prefix. + // Returns true if the transmittal folder's path satisfies all three cascade + // layers: project visibility, party selection (any path segment matches a + // selected party name), and folder-type enablement (no segment is a + // folder-type marker that's currently disabled, regardless of where in the + // path it sits). Segment-equality matching means a party "BM" selected + // matches every "<...>/BM/<...>" path regardless of the prefix; and the + // folder-type check covers BOTH the canonical "/Issued/" layout + // AND nested layouts like "//Issued/" — a deeper folder- + // type marker still triggers the cascade. function transmittalIsUnderVisibleParty(folder) { if (!pathIsInVisibleProject(folder.path)) return false; - const parts = folder.path.split('/'); - for (let i = 0; i < parts.length; i++) { - if (!window.app.selectedGroupingFolders.has(parts[i])) continue; - // i-th segment is a selected party. The segment after is either a - // folder-type marker (Issued/Received/MDL/Incoming) or the transmittal - // folder itself. - const next = parts[i + 1]; - if (!next) return true; - const folderType = next.toLowerCase(); - if (window.app.FOLDER_TYPE_NAMES.includes(folderType)) { - return window.app.enabledFolderTypes.has(folderType); - } - return true; - } - return false; + if (isUnderHiddenFolderType(folder.path)) return false; + return folder.path.split('/').some(seg => + window.app.selectedGroupingFolders.has(seg) + ); } // Render transmittal folders (rebuilds DOM) diff --git a/zddc/internal/apps/embedded/classifier.html b/zddc/internal/apps/embedded/classifier.html index 44b8588..edcbf57 100644 --- a/zddc/internal/apps/embedded/classifier.html +++ b/zddc/internal/apps/embedded/classifier.html @@ -1376,7 +1376,7 @@ body.help-open .app-header {
ZDDC Classifier - v0.0.3-alpha · 2026-05-01 20:41:59 · adb6904-dirty + v0.0.3-alpha · 2026-05-02 01:03:25 · 4ede420-dirty
diff --git a/zddc/internal/apps/embedded/index.html b/zddc/internal/apps/embedded/index.html index 5b61163..4b216e0 100644 --- a/zddc/internal/apps/embedded/index.html +++ b/zddc/internal/apps/embedded/index.html @@ -866,7 +866,7 @@ body { ZDDC Archive - v0.0.3-alpha · 2026-05-01 20:41:59 · adb6904-dirty + v0.0.3-alpha · 2026-05-02 01:03:25 · 4ede420-dirty
diff --git a/zddc/internal/apps/embedded/mdedit.html b/zddc/internal/apps/embedded/mdedit.html index 819e5d7..b7ccce4 100644 --- a/zddc/internal/apps/embedded/mdedit.html +++ b/zddc/internal/apps/embedded/mdedit.html @@ -1668,7 +1668,7 @@ body.help-open .app-header {
ZDDC Markdown - v0.0.3-alpha · 2026-05-01 20:41:59 · adb6904-dirty + v0.0.3-alpha · 2026-05-02 01:03:25 · 4ede420-dirty
diff --git a/zddc/internal/apps/embedded/transmittal.html b/zddc/internal/apps/embedded/transmittal.html index 3d6ab0b..cd3746d 100644 --- a/zddc/internal/apps/embedded/transmittal.html +++ b/zddc/internal/apps/embedded/transmittal.html @@ -2210,7 +2210,7 @@ dialog.modal--narrow {
ZDDC Transmittal - v0.0.3-alpha · 2026-05-01 20:41:59 · adb6904-dirty + v0.0.3-alpha · 2026-05-02 01:03:25 · 4ede420-dirty
diff --git a/zddc/internal/apps/embedded/versions.txt b/zddc/internal/apps/embedded/versions.txt index d46413f..73e6b84 100644 --- a/zddc/internal/apps/embedded/versions.txt +++ b/zddc/internal/apps/embedded/versions.txt @@ -1,6 +1,6 @@ # Generated by build.sh — do not edit. One = per line. -archive=v0.0.3-alpha · 2026-05-01 20:41:59 · adb6904-dirty -transmittal=v0.0.3-alpha · 2026-05-01 20:41:59 · adb6904-dirty -classifier=v0.0.3-alpha · 2026-05-01 20:41:59 · adb6904-dirty -mdedit=v0.0.3-alpha · 2026-05-01 20:41:59 · adb6904-dirty -landing=v0.0.3-alpha · 2026-05-01 20:41:59 · adb6904-dirty +archive=v0.0.3-alpha · 2026-05-02 01:03:25 · 4ede420-dirty +transmittal=v0.0.3-alpha · 2026-05-02 01:03:25 · 4ede420-dirty +classifier=v0.0.3-alpha · 2026-05-02 01:03:25 · 4ede420-dirty +mdedit=v0.0.3-alpha · 2026-05-02 01:03:25 · 4ede420-dirty +landing=v0.0.3-alpha · 2026-05-02 01:03:25 · 4ede420-dirty diff --git a/zddc/internal/archive/index.go b/zddc/internal/archive/index.go index c081e46..9ed0861 100644 --- a/zddc/internal/archive/index.go +++ b/zddc/internal/archive/index.go @@ -1,6 +1,7 @@ package archive import ( + "log/slog" "os" "path/filepath" "regexp" @@ -22,16 +23,28 @@ type TrackingEntry struct { ByRevision map[string]*RevisionEntry // base revision → entry } -// Index is the in-memory archive index. -type Index struct { - mu sync.RWMutex +// ProjectEntry buckets all tracking numbers under one top-level segment of +// fsRoot (the "project"). Each project is its own namespace — the same +// tracking number issued under two different projects does NOT collide; each +// project's .archive/ surfaces only its own. +type ProjectEntry struct { ByTracking map[string]*TrackingEntry } +// Index is the in-memory archive index, bucketed by project. The project key +// is the first slash-separated segment of an indexed file's server-relative +// path. .archive virtual requests under //.../.archive/ resolve +// against the named project's bucket; /.archive/ at the very root has no +// project and returns 404. +type Index struct { + mu sync.RWMutex + ByProject map[string]*ProjectEntry +} + // NewIndex returns an empty Index. func NewIndex() *Index { return &Index{ - ByTracking: make(map[string]*TrackingEntry), + ByProject: make(map[string]*ProjectEntry), } } @@ -153,17 +166,43 @@ func indexTransmittalFolder(idx *Index, fsRoot, folderAbs, folderServerPath, dat }) } -// recordFile adds a parsed file to the index using first-seen (oldest date) logic. +// projectOf returns the top-level slash-separated segment of a server-relative +// path. Files at the root (no slash) have no project and are not indexable. +func projectOf(serverPath string) string { + i := strings.IndexByte(serverPath, '/') + if i <= 0 { + return "" + } + return serverPath[:i] +} + +// recordFile adds a parsed file to the index using first-seen (oldest date) +// logic, bucketed under the project (top-level segment) the file lives in. func (idx *Index) recordFile(pf parsedFile) { + project := projectOf(pf.serverPath) + if project == "" { + // File sits directly at the served root with no project wrapper. + // Skipping it means /.archive/ at the root surfaces nothing — which + // is exactly the contract: stable references must include a project + // directory. Such files are still reachable as ordinary static URLs. + return + } + idx.mu.Lock() defer idx.mu.Unlock() - te, ok := idx.ByTracking[pf.trackingNumber] + pe, ok := idx.ByProject[project] + if !ok { + pe = &ProjectEntry{ByTracking: make(map[string]*TrackingEntry)} + idx.ByProject[project] = pe + } + + te, ok := pe.ByTracking[pf.trackingNumber] if !ok { te = &TrackingEntry{ ByRevision: make(map[string]*RevisionEntry), } - idx.ByTracking[pf.trackingNumber] = te + pe.ByTracking[pf.trackingNumber] = te } re, ok := te.ByRevision[pf.baseRev] @@ -175,10 +214,29 @@ func (idx *Index) recordFile(pf parsedFile) { } if pf.modifier == "" { - // Base revision file — record if this transmittal is older than current - if re.BasePath == "" || pf.date < re.Date { + switch { + case re.BasePath == "": re.BasePath = pf.serverPath re.Date = pf.date + case re.BasePath == pf.serverPath: + // same file, no-op (e.g. re-index from the watcher) + default: + // Two different files claim to be (project, tracking, rev) — + // that's a within-project authoring mistake. Log once with both + // paths so it's diagnosable; chronological winner still wins. + slog.Warn("archive: within-project revision collision", + "project", project, + "tracking", pf.trackingNumber, + "revision", pf.baseRev, + "existing", re.BasePath, + "existingDate", re.Date, + "new", pf.serverPath, + "newDate", pf.date, + ) + if pf.date < re.Date { + re.BasePath = pf.serverPath + re.Date = pf.date + } } } else { // Modifier file — record if no entry yet or this transmittal is older @@ -279,7 +337,8 @@ type Entry struct { TargetPath string } -// AllEntries returns a sorted snapshot of every redirect entry. Two kinds: +// AllEntries returns a sorted snapshot of every redirect entry for the named +// project. Two kinds per tracking number: // // - .html → first-chronological copy of the highest base rev // - _.html → first-chronological copy of that specific base rev @@ -291,12 +350,20 @@ type Entry struct { // Sort order is by URLName; the "." in .html sorts before the "_" // in _.html, so each tracking number's highest-rev shortcut // comes first, followed by its individual revisions in revision order. -func (idx *Index) AllEntries() []Entry { +// +// An empty project (or one with no indexed tracking numbers) returns nil, +// keeping the caller branch-free. +func (idx *Index) AllEntries(project string) []Entry { idx.mu.RLock() defer idx.mu.RUnlock() + pe, ok := idx.ByProject[project] + if !ok { + return nil + } + var result []Entry - for tn, te := range idx.ByTracking { + for tn, te := range pe.ByTracking { if te.HighestBaseRev != "" { if re, ok := te.ByRevision[te.HighestBaseRev]; ok && re.BasePath != "" { result = append(result, Entry{ diff --git a/zddc/internal/archive/index_test.go b/zddc/internal/archive/index_test.go index 4c6e3eb..04e6bed 100644 --- a/zddc/internal/archive/index_test.go +++ b/zddc/internal/archive/index_test.go @@ -1,9 +1,12 @@ package archive import ( + "bytes" + "log/slog" "os" "path/filepath" "sort" + "strings" "testing" ) @@ -48,7 +51,7 @@ func TestCompareRevisions_DraftOrdering(t *testing.T) { func TestIndexAndResolve_DraftOnly(t *testing.T) { root := t.TempDir() - mkTransmittal(t, root, "2025-01-01_T1 (IFR) - Title", + mkTransmittal(t, root, "ProjectA/2025-01-01_T1 (IFR) - Title", "123_~A (IFR) - Title.pdf", ) @@ -57,72 +60,132 @@ func TestIndexAndResolve_DraftOnly(t *testing.T) { t.Fatalf("BuildIndex: %v", err) } - te, ok := idx.ByTracking["123"] + pe, ok := idx.ByProject["ProjectA"] if !ok { - t.Fatalf("tracking 123 not indexed") + t.Fatalf("ProjectA bucket not indexed; ByProject = %v", idx.ByProject) + } + te, ok := pe.ByTracking["123"] + if !ok { + t.Fatalf("tracking 123 not indexed in ProjectA") } if te.HighestBaseRev != "~A" { t.Errorf("HighestBaseRev = %q, want ~A", te.HighestBaseRev) } - if _, ok := Resolve(idx, "123.html"); !ok { - t.Errorf("Resolve(123.html) failed") + if _, ok := Resolve(idx, "ProjectA", "123.html"); !ok { + t.Errorf("Resolve(ProjectA, 123.html) failed") } - if _, ok := Resolve(idx, "123_~A.html"); !ok { - t.Errorf("Resolve(123_~A.html) failed") + if _, ok := Resolve(idx, "ProjectA", "123_~A.html"); !ok { + t.Errorf("Resolve(ProjectA, 123_~A.html) failed") + } + + // Same tracking number queried under a different project must NOT resolve. + if _, ok := Resolve(idx, "ProjectB", "123.html"); ok { + t.Errorf("Resolve(ProjectB, 123.html) should fail — 123 belongs to ProjectA") + } + // Empty project — /.archive/ at the very root — never resolves. + if _, ok := Resolve(idx, "", "123.html"); ok { + t.Errorf("Resolve(\"\", 123.html) should fail — empty project must 404") } } func TestIndexAndResolve_DraftWithModifier(t *testing.T) { root := t.TempDir() - mkTransmittal(t, root, "2025-01-01_T1 (IFR) - Title", + mkTransmittal(t, root, "ProjectA/2025-01-01_T1 (IFR) - Title", "123_~A (IFR) - Title.pdf", ) - mkTransmittal(t, root, "2025-02-01_T2 (RTN) - Comments", + mkTransmittal(t, root, "ProjectA/2025-02-01_T2 (RTN) - Comments", "123_~A+C1 (RTN) - Comments.pdf", ) idx, _ := BuildIndex(root) - if _, ok := Resolve(idx, "123_~A+C1.html"); !ok { - t.Errorf("Resolve(123_~A+C1.html) failed") + if _, ok := Resolve(idx, "ProjectA", "123_~A+C1.html"); !ok { + t.Errorf("Resolve(ProjectA, 123_~A+C1.html) failed") } } // "First chronologically found version of the latest rev": when the same rev -// appears in two transmittals, the earlier date's copy wins. +// appears in two transmittals within ONE project, the earlier date's copy +// wins. (Cross-project duplicates are handled separately — see +// TestCrossProject_NoCollision.) func TestRecordFile_FirstChronologicalWins(t *testing.T) { root := t.TempDir() - mkTransmittal(t, root, "2025-03-01_Late (IFR) - Title", + mkTransmittal(t, root, "ProjectA/2025-03-01_Late (IFR) - Title", "123_A (IFR) - Title.pdf", ) - mkTransmittal(t, root, "2025-01-01_Early (IFR) - Title", + mkTransmittal(t, root, "ProjectA/2025-01-01_Early (IFR) - Title", "123_A (IFR) - Title.pdf", ) idx, _ := BuildIndex(root) - target, ok := Resolve(idx, "123_A.html") + target, ok := Resolve(idx, "ProjectA", "123_A.html") if !ok { - t.Fatalf("Resolve(123_A.html) failed") + t.Fatalf("Resolve(ProjectA, 123_A.html) failed") } if !contains(target, "2025-01-01_Early") { t.Errorf("got %q, want path under 2025-01-01_Early/", target) } } +// Same tracking number issued under two different projects must NOT collide: +// each project's bucket carries its own copy and resolves independently. +func TestCrossProject_NoCollision(t *testing.T) { + root := t.TempDir() + mkTransmittal(t, root, "ProjectA/2025-01-01_T1 (IFR) - Title", + "123_A (IFR) - Title.pdf", + ) + mkTransmittal(t, root, "ProjectB/2025-06-01_T9 (IFR) - Different Title", + "123_A (IFR) - Different Title.pdf", + ) + + idx, _ := BuildIndex(root) + + a, okA := Resolve(idx, "ProjectA", "123_A.html") + if !okA { + t.Fatalf("Resolve(ProjectA, 123_A.html) failed") + } + if !contains(a, "ProjectA/") { + t.Errorf("ProjectA target = %q, want path under ProjectA/", a) + } + + b, okB := Resolve(idx, "ProjectB", "123_A.html") + if !okB { + t.Fatalf("Resolve(ProjectB, 123_A.html) failed") + } + if !contains(b, "ProjectB/") { + t.Errorf("ProjectB target = %q, want path under ProjectB/", b) + } + + if a == b { + t.Errorf("ProjectA and ProjectB targets must differ; got %q == %q", a, b) + } + + // Each project's listing surfaces only its own tracking numbers. + aNames := entryNames(idx.AllEntries("ProjectA")) + bNames := entryNames(idx.AllEntries("ProjectB")) + for _, n := range aNames { + for _, m := range bNames { + if n == m && n == "123_A.html" { + // Same URLName is expected; targets just differ. + } + } + } +} + // AllEntries: every (tracking) gets .html (highest) AND a -// _.html for every base revision present. +// _.html for every base revision present, scoped to project. func TestAllEntries_PerRevisionSurfaced(t *testing.T) { root := t.TempDir() - mkTransmittal(t, root, "2025-01-01_T1 (IFR) - Title", + mkTransmittal(t, root, "ProjectA/2025-01-01_T1 (IFR) - Title", "123_~A (IFR) - Title.pdf", ) - mkTransmittal(t, root, "2025-03-01_T3 (IFC) - Title", + mkTransmittal(t, root, "ProjectA/2025-03-01_T3 (IFC) - Title", "123_A (IFC) - Title.pdf", "456_0 (IFR) - Other.pdf", ) idx, _ := BuildIndex(root) - entries := idx.AllEntries() + entries := idx.AllEntries("ProjectA") got := make(map[string]string, len(entries)) for _, e := range entries { @@ -156,6 +219,14 @@ func TestAllEntries_PerRevisionSurfaced(t *testing.T) { t.Errorf("AllEntries not sorted: %q before %q", entries[i-1].URLName, entries[i].URLName) } } + + // Empty project key returns nil — root .archive doesn't exist. + if got := idx.AllEntries(""); got != nil { + t.Errorf("AllEntries(\"\") = %v, want nil", got) + } + if got := idx.AllEntries("NoSuchProject"); got != nil { + t.Errorf("AllEntries(NoSuchProject) = %v, want nil", got) + } } // Modifier-only files (no base) don't get a .html or @@ -164,18 +235,119 @@ func TestAllEntries_PerRevisionSurfaced(t *testing.T) { // through the resolver but are not surfaced in the listing. func TestAllEntries_ModifierOnlyNoBaseSkipped(t *testing.T) { root := t.TempDir() - mkTransmittal(t, root, "2025-02-01_T2 (RTN) - Comments", + mkTransmittal(t, root, "ProjectA/2025-02-01_T2 (RTN) - Comments", "123_~A+C1 (RTN) - Comments.pdf", ) idx, _ := BuildIndex(root) - for _, e := range idx.AllEntries() { + for _, e := range idx.AllEntries("ProjectA") { if e.URLName == "123.html" || e.URLName == "123_~A.html" { t.Errorf("unexpected entry %q (no base file exists)", e.URLName) } } } +// Within-project collision: two different files claim to be the same +// (project, tracking, rev). Chronological winner still wins, but a WARN log +// is emitted with both paths so the authoring mistake is diagnosable. +// +// (Cross-project duplicates are NOT collisions — they live in separate +// buckets. See TestCrossProject_NoCollision.) +func TestRecordFile_WithinProjectCollisionLogged(t *testing.T) { + root := t.TempDir() + mkTransmittal(t, root, "ProjectA/2025-03-01_Late (IFR) - Title", + "123_A (IFR) - Title.pdf", + ) + // Different transmittal folder, same tracking+rev — e.g. operator + // re-issued under a different cover sheet by mistake. + mkTransmittal(t, root, "ProjectA/2025-01-01_Early (IFR) - Title", + "123_A (IFR) - Title.pdf", + ) + + // Capture slog output during BuildIndex. + var buf bytes.Buffer + prev := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))) + defer slog.SetDefault(prev) + + idx, err := BuildIndex(root) + if err != nil { + t.Fatalf("BuildIndex: %v", err) + } + + logged := buf.String() + if !strings.Contains(logged, "within-project revision collision") { + t.Errorf("expected collision WARN; got log:\n%s", logged) + } + if !strings.Contains(logged, "project=ProjectA") { + t.Errorf("expected project field in log; got:\n%s", logged) + } + if !strings.Contains(logged, "tracking=123") { + t.Errorf("expected tracking field in log; got:\n%s", logged) + } + + // Chronological winner still wins. + target, ok := Resolve(idx, "ProjectA", "123_A.html") + if !ok { + t.Fatalf("Resolve failed") + } + if !contains(target, "2025-01-01_Early") { + t.Errorf("got %q, want path under 2025-01-01_Early/ (chronological winner)", target) + } +} + +// Re-indexing the same transmittal folder (e.g. via the watcher) must NOT +// trip the collision detector — same path is a no-op, not a conflict. +func TestRecordFile_ReindexSamePathNoCollisionLog(t *testing.T) { + root := t.TempDir() + mkTransmittal(t, root, "ProjectA/2025-01-01_T1 (IFR) - Title", + "123_A (IFR) - Title.pdf", + ) + + var buf bytes.Buffer + prev := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))) + defer slog.SetDefault(prev) + + idx, err := BuildIndex(root) + if err != nil { + t.Fatalf("BuildIndex: %v", err) + } + // Simulate the watcher firing again on the same transmittal folder. + transmittalAbs := filepath.Join(root, "ProjectA", "2025-01-01_T1 (IFR) - Title") + if err := idx.UpdateFromDir(root, transmittalAbs); err != nil { + t.Fatalf("UpdateFromDir: %v", err) + } + + if strings.Contains(buf.String(), "within-project revision collision") { + t.Errorf("re-index should not log collision; got:\n%s", buf.String()) + } +} + +// projectOf is the canonical place to derive the project key. Validate the +// edge cases so the contract doesn't drift silently. +func TestProjectOf(t *testing.T) { + cases := []struct { + path string + want string + }{ + {"ProjectA/2025-01-01_T1/100_A.pdf", "ProjectA"}, + {"ProjectA/sub/deep/file.pdf", "ProjectA"}, + // Files at the root with no slash have no project. + {"top-level-loose-file.pdf", ""}, + {"", ""}, + // Defensive: leading slash should never reach this helper, but if it + // did, we'd return "" rather than picking up an empty leading segment. + {"/ProjectA/file", ""}, + } + for _, c := range cases { + got := projectOf(c.path) + if got != c.want { + t.Errorf("projectOf(%q) = %q, want %q", c.path, got, c.want) + } + } +} + func contains(s, sub string) bool { for i := 0; i+len(sub) <= len(s); i++ { if s[i:i+len(sub)] == sub { @@ -193,3 +365,12 @@ func sortedKeys(m map[string]string) []string { sort.Strings(out) return out } + +func entryNames(entries []Entry) []string { + out := make([]string, 0, len(entries)) + for _, e := range entries { + out = append(out, e.URLName) + } + sort.Strings(out) + return out +} diff --git a/zddc/internal/archive/resolver.go b/zddc/internal/archive/resolver.go index 5c2cc8d..1199109 100644 --- a/zddc/internal/archive/resolver.go +++ b/zddc/internal/archive/resolver.go @@ -5,15 +5,25 @@ import ( ) // Resolve parses the .archive request filename and returns the server-relative -// redirect target URL (no leading slash). +// redirect target URL (no leading slash) within the named project. +// +// Project is the top-level segment of the .archive contextPath +// (//.../.archive/). An empty project — i.e. a request +// against /.archive/ at the very root — returns ("", false): stable refs +// must be project-rooted to avoid cross-project tracking-number collisions. // // Supported URL filename patterns (after stripping .html suffix): // - trackingNumber → highest base revision of trackingNumber // - trackingNumber_rev → base revision file for rev // - trackingNumber_rev+C1 → modifier file (C1, B1, N1, Q1) // -// Returns ("", false) if the filename cannot be parsed or no match exists. -func Resolve(idx *Index, filename string) (string, bool) { +// Returns ("", false) if project is empty, the filename cannot be parsed, or +// no match exists in the project. +func Resolve(idx *Index, project, filename string) (string, bool) { + if project == "" { + return "", false + } + // Strip .html suffix stem := strings.TrimSuffix(filename, ".html") if stem == filename { @@ -24,12 +34,17 @@ func Resolve(idx *Index, filename string) (string, bool) { idx.mu.RLock() defer idx.mu.RUnlock() + pe, ok := idx.ByProject[project] + if !ok { + return "", false + } + // Try to split off revision part (last _ segment) lastUnderscore := strings.LastIndex(stem, "_") if lastUnderscore < 0 { // No underscore — treat entire stem as tracking number tracking := stem - te, ok := idx.ByTracking[tracking] + te, ok := pe.ByTracking[tracking] if !ok || te.HighestBaseRev == "" { return "", false } @@ -54,7 +69,7 @@ func Resolve(idx *Index, filename string) (string, bool) { modifier = revPart[plusIdx+1:] } - te, ok := idx.ByTracking[tracking] + te, ok := pe.ByTracking[tracking] if !ok { return "", false } diff --git a/zddc/internal/archive/watcher.go b/zddc/internal/archive/watcher.go index 2e63a1e..b455275 100644 --- a/zddc/internal/archive/watcher.go +++ b/zddc/internal/archive/watcher.go @@ -89,10 +89,15 @@ func (w *Watcher) handleEvent(event fsnotify.Event) { } } - // .zddc file changed — invalidate ACL policy cache + // .zddc file changed — invalidate ACL policy cache for the chain rooted + // here AND the global scan cache (which lists every directory containing + // a .zddc file). The scan cache is fsRoot-keyed and only changes when + // .zddc files are added/removed, but distinguishing modify from + // create/remove in fsnotify is fragile so we just always invalidate. if base == ".zddc" { dir := filepath.Dir(path) zddc.InvalidateCache(dir) + zddc.InvalidateScanCache() return } diff --git a/zddc/internal/handler/archivehandler.go b/zddc/internal/handler/archivehandler.go index 7c107b3..05c1b0a 100644 --- a/zddc/internal/handler/archivehandler.go +++ b/zddc/internal/handler/archivehandler.go @@ -18,20 +18,27 @@ import ( // .archive is exposed at every folder depth so HTML produced for offline use // can reference sibling tracking numbers via "../.archive/.html". // In a browser the relative link is resolved before the request reaches the -// server, so the server treats every .archive request the same regardless of -// the contextPath it arrived under: the same global index is consulted, and -// access is gated only by the cascading .zddc ACL. +// server, so the contextPath the request arrives under is significant: its +// FIRST segment is the project, and the .archive listing/resolver is scoped +// to that project's bucket. This avoids cross-project collisions when the +// same tracking number is issued under multiple projects. // // contextPath: the URL path leading up to (but not including) .archive -// - used to gate the listing endpoint (caller must have ACL access to the -// directory the .archive virtual entry sits in — otherwise just knowing -// the folder exists would leak) +// - first segment selects the project bucket +// - used to gate the listing endpoint via cascading .zddc ACL // - used as the URL prefix for the entries returned in the listing +// - empty (root /.archive/) returns 404 — refs must be project-rooted // // filename: the part after .archive/ (empty for directory listing) func ServeArchive(cfg config.Config, idx *archive.Index, w http.ResponseWriter, r *http.Request, contextPath, filename string) { email := EmailFromContext(r) + project := projectFromContextPath(contextPath) + if project == "" { + http.Error(w, "Not Found: .archive must be requested under a project directory (e.g. //.archive/)", http.StatusNotFound) + return + } + // ACL gate on the context directory: callers who can't reach the // directory hosting this .archive shouldn't be able to query it either. dirPath := strings.TrimPrefix(contextPath, "/") @@ -47,11 +54,11 @@ func ServeArchive(cfg config.Config, idx *archive.Index, w http.ResponseWriter, } if filename == "" { - serveArchiveListing(cfg, idx, w, r, contextPath, email) + serveArchiveListing(cfg, idx, w, r, contextPath, project, email) return } - target, ok := archive.Resolve(idx, filename) + target, ok := archive.Resolve(idx, project, filename) if !ok { http.Error(w, "Not Found", http.StatusNotFound) return @@ -73,8 +80,22 @@ func ServeArchive(cfg config.Config, idx *archive.Index, w http.ResponseWriter, http.Redirect(w, r, "/"+target, http.StatusFound) } -func serveArchiveListing(cfg config.Config, idx *archive.Index, w http.ResponseWriter, r *http.Request, contextPath, email string) { - allEntries := idx.AllEntries() +// projectFromContextPath returns the first non-empty segment of the +// contextPath, which is the project bucket key for archive lookups. Returns +// "" for "/" or "" (root .archive — has no project). +func projectFromContextPath(contextPath string) string { + cleaned := strings.Trim(contextPath, "/") + if cleaned == "" { + return "" + } + if i := strings.IndexByte(cleaned, '/'); i >= 0 { + return cleaned[:i] + } + return cleaned +} + +func serveArchiveListing(cfg config.Config, idx *archive.Index, w http.ResponseWriter, r *http.Request, contextPath, project, email string) { + allEntries := idx.AllEntries(project) archiveBase := contextPath if !strings.HasSuffix(archiveBase, "/") { archiveBase += "/" diff --git a/zddc/internal/handler/archivehandler_test.go b/zddc/internal/handler/archivehandler_test.go index 262aefb..ec5d8eb 100644 --- a/zddc/internal/handler/archivehandler_test.go +++ b/zddc/internal/handler/archivehandler_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "net/url" "os" "path/filepath" "strings" @@ -16,8 +17,9 @@ import ( "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" ) -// archiveTestRoot lays down a two-project tree so listings exercise scope and -// ACL cascading. ACLs are written per-test in the helper that calls this. +// archiveTestRoot lays down a two-project tree so listings exercise project +// scoping, ACL cascading, and the per-project bucket boundary. ACLs are +// written per-test in the helper that calls this. // // / // ProjectA/ @@ -73,11 +75,16 @@ func archiveCfg(root string) config.Config { func callArchive(t *testing.T, cfg config.Config, idx *archive.Index, email, contextPath, filename string) *httptest.ResponseRecorder { t.Helper() - urlPath := contextPath - if !strings.HasSuffix(urlPath, "/") { + // Build a syntactically valid URL by escaping each segment of the + // contextPath and filename. The handler receives the decoded + // contextPath/filename arguments directly (as the dispatcher would have + // decoded them); the URL itself just needs to parse for httptest. + urlPath := encodePath(contextPath) + "/" + cfg.IndexPath + if filename != "" { + urlPath += "/" + url.PathEscape(filename) + } else { urlPath += "/" } - urlPath += ".archive/" + filename req := httptest.NewRequest(http.MethodGet, urlPath, nil) req = req.WithContext(context.WithValue(req.Context(), EmailKey, email)) rec := httptest.NewRecorder() @@ -85,6 +92,21 @@ func callArchive(t *testing.T, cfg config.Config, idx *archive.Index, email, con return rec } +// encodePath URL-escapes each non-empty slash-separated segment of p so +// special characters like spaces and parens don't break NewRequest's URL +// parser. A leading slash is preserved; an empty input becomes "/". +func encodePath(p string) string { + trimmed := strings.Trim(p, "/") + if trimmed == "" { + return "" + } + parts := strings.Split(trimmed, "/") + for i, s := range parts { + parts[i] = url.PathEscape(s) + } + return "/" + strings.Join(parts, "/") +} + func decodeListing(t *testing.T, body []byte) []listing.FileInfo { t.Helper() var out []listing.FileInfo @@ -111,11 +133,37 @@ func contains(xs []string, x string) bool { return false } -// .archive at any depth serves the SAME global index (modulo ACL). Only the -// URL prefix on the entries differs, so relative ../.archive/ links resolve -// to a working server endpoint no matter which folder the source page sits -// in. -func TestServeArchive_ListingIsGlobalAtEveryDepth(t *testing.T) { +// /.archive/ at the very root has no project segment to scope by, so it's a +// hard 404 — even for an admin. Stable references must include the project +// directory; otherwise cross-project tracking-number collisions would silently +// pick a winner. +func TestServeArchive_RootHasNoProjectScope404(t *testing.T) { + root, idx := archiveTestRoot(t) + writeZddc(t, root, ".", `acl: + allow: ["*"] +`) + cfg := archiveCfg(root) + + for _, ctx := range []string{"/", ""} { + t.Run("ctx="+ctx, func(t *testing.T) { + rec := callArchive(t, cfg, idx, "alice@example.com", ctx, "") + if rec.Code != http.StatusNotFound { + t.Errorf("listing at root: status %d, want 404; body = %s", rec.Code, rec.Body.String()) + } + rec = callArchive(t, cfg, idx, "alice@example.com", ctx, "100.html") + if rec.Code != http.StatusNotFound { + t.Errorf("resolve at root: status %d, want 404", rec.Code) + } + }) + } +} + +// .archive listings are scoped to the contextPath's first segment (the +// project). Each project sees only its own tracking numbers; cross-project +// entries are invisible. Subdirectory contextPaths still resolve to the +// top-level project's bucket — a request from /ProjectA/sub/sub/.archive/ +// shows ProjectA's entries with that deeper URL prefix. +func TestServeArchive_ListingScopedToProject(t *testing.T) { root, idx := archiveTestRoot(t) writeZddc(t, root, ".", `acl: allow: ["*"] @@ -127,14 +175,32 @@ func TestServeArchive_ListingIsGlobalAtEveryDepth(t *testing.T) { name string contextPath string urlPrefix string + wantNames []string + denyNames []string }{ - {"root", "/", "/.archive/"}, - {"project depth", "/ProjectA", "/ProjectA/.archive/"}, - {"unrelated project depth", "/ProjectB", "/ProjectB/.archive/"}, + { + "ProjectA top level", + "/ProjectA", + "/ProjectA/.archive/", + []string{"100.html", "100_A.html", "100_~A.html"}, + []string{"200.html", "200_0.html"}, + }, + { + "ProjectA deeper subpath", + "/ProjectA/2025-01-01_T1 (IFR) - Title", + "/ProjectA/2025-01-01_T1 (IFR) - Title/.archive/", + []string{"100.html", "100_A.html", "100_~A.html"}, + []string{"200.html", "200_0.html"}, + }, + { + "ProjectB top level", + "/ProjectB", + "/ProjectB/.archive/", + []string{"200.html", "200_0.html"}, + []string{"100.html", "100_A.html", "100_~A.html"}, + }, } - wantNames := []string{"100.html", "100_A.html", "100_~A.html", "200.html", "200_0.html"} - for _, c := range cases { t.Run(c.name, func(t *testing.T) { rec := callArchive(t, cfg, idx, email, c.contextPath, "") @@ -143,11 +209,16 @@ func TestServeArchive_ListingIsGlobalAtEveryDepth(t *testing.T) { } got := decodeListing(t, rec.Body.Bytes()) gotNames := names(got) - for _, want := range wantNames { + for _, want := range c.wantNames { if !contains(gotNames, want) { t.Errorf("missing %q at %s; got %v", want, c.contextPath, gotNames) } } + for _, deny := range c.denyNames { + if contains(gotNames, deny) { + t.Errorf("unexpected cross-project entry %q at %s; got %v", deny, c.contextPath, gotNames) + } + } for _, e := range got { if !strings.HasPrefix(e.URL, c.urlPrefix) { t.Errorf("entry %q URL = %q, want %s prefix", e.Name, e.URL, c.urlPrefix) @@ -183,21 +254,25 @@ func TestServeArchive_ListingDeniedByContextPathACL(t *testing.T) { } // Listing entries are filtered per-target by ACL: a caller denied at a -// subtree sees no entries from it — even when querying /.archive/ at the -// root where they ARE allowed. Excluding a user from a subdir requires an -// explicit deny there (the cascade is "first explicit match wins, bottom- -// up", so a child allow list doesn't narrow a parent's allow:["*"]). +// subtree's transmittal directory sees no entries whose target lives there. +// Excluding a user from a subdir requires an explicit deny there (the +// cascade is "first explicit match wins, bottom-up", so a child allow list +// doesn't narrow a parent's allow:["*"]). func TestServeArchive_ListingFiltersEntriesByPerTargetACL(t *testing.T) { root, idx := archiveTestRoot(t) writeZddc(t, root, ".", `acl: allow: ["*"] `) - writeZddc(t, root, "ProjectB", `acl: + // Deny alice on the transmittal folder where 100_~A+C1 lives, so her + // listing of /ProjectA/.archive/ drops that entry — but other ProjectA + // entries stay visible. (A blanket /ProjectA deny would 403 the + // listing entirely; that's covered by the previous test.) + writeZddc(t, root, "ProjectA/2025-02-01_T2 (RTN) - Comments", `acl: deny: ["alice@example.com"] `) cfg := archiveCfg(root) - rec := callArchive(t, cfg, idx, "alice@example.com", "/", "") + rec := callArchive(t, cfg, idx, "alice@example.com", "/ProjectA", "") if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body = %s", rec.Code, rec.Body.String()) } @@ -208,13 +283,12 @@ func TestServeArchive_ListingFiltersEntriesByPerTargetACL(t *testing.T) { t.Errorf("alice missing accessible entry %q; got %v", want, gotNames) } } - for _, hidden := range []string{"200.html", "200_0.html"} { - if contains(gotNames, hidden) { - t.Errorf("alice should not see ACL-blocked entry %q; got %v", hidden, gotNames) - } - } - rec = callArchive(t, cfg, idx, "bob@example.com", "/", "") + // Bob has no per-target denials in either project. + rec = callArchive(t, cfg, idx, "bob@example.com", "/ProjectB", "") + if rec.Code != http.StatusOK { + t.Fatalf("bob ProjectB listing: status %d, want 200", rec.Code) + } gotNames = names(decodeListing(t, rec.Body.Bytes())) if !contains(gotNames, "200.html") { t.Errorf("bob should see ProjectB entry 200.html; got %v", gotNames) @@ -223,7 +297,8 @@ func TestServeArchive_ListingFiltersEntriesByPerTargetACL(t *testing.T) { // Direct redirect requests for a tracking number whose target the caller // can't read return 404 (not 403, not 302) — the file's existence must not -// leak across the ACL boundary. +// leak across the ACL boundary. Cross-project tracking-number requests also +// 404 because each project's bucket is separate. func TestServeArchive_ResolveACLDeniedReturns404(t *testing.T) { root, idx := archiveTestRoot(t) writeZddc(t, root, ".", `acl: @@ -234,33 +309,44 @@ func TestServeArchive_ResolveACLDeniedReturns404(t *testing.T) { `) cfg := archiveCfg(root) - rec := callArchive(t, cfg, idx, "alice@example.com", "/", "200.html") + // 200 doesn't even live in ProjectA, so the resolver itself returns 404 + // regardless of ACL — project scoping comes first. + rec := callArchive(t, cfg, idx, "alice@example.com", "/ProjectA", "200.html") if rec.Code != http.StatusNotFound { - t.Errorf("alice → 200.html: status %d, want 404 (target ACL-denied)", rec.Code) + t.Errorf("alice → /ProjectA/.archive/200.html: status %d, want 404 (cross-project)", rec.Code) } + // Alice in /ProjectA can resolve all of ProjectA's entries. for _, fn := range []string{"100.html", "100_A.html", "100_~A.html", "100_~A+C1.html"} { - rec := callArchive(t, cfg, idx, "alice@example.com", "/", fn) + rec := callArchive(t, cfg, idx, "alice@example.com", "/ProjectA", fn) if rec.Code != http.StatusFound { - t.Errorf("alice → %s: status %d, want 302; body = %s", fn, rec.Code, rec.Body.String()) + t.Errorf("alice → /ProjectA/.archive/%s: status %d, want 302; body = %s", fn, rec.Code, rec.Body.String()) } } - rec = callArchive(t, cfg, idx, "bob@example.com", "/", "200.html") + // Alice attempting ProjectB directly is denied at the contextPath ACL. + rec = callArchive(t, cfg, idx, "alice@example.com", "/ProjectB", "200.html") + if rec.Code != http.StatusForbidden { + t.Errorf("alice → /ProjectB/.archive/200.html: status %d, want 403 (denied at contextPath)", rec.Code) + } + + // Bob has no denies — he can pull 200.html from /ProjectB. + rec = callArchive(t, cfg, idx, "bob@example.com", "/ProjectB", "200.html") if rec.Code != http.StatusFound { - t.Errorf("bob → 200.html: status %d, want 302", rec.Code) + t.Errorf("bob → /ProjectB/.archive/200.html: status %d, want 302", rec.Code) } } // Cascade direction sanity check: a denial at the subtree wins over an // allow at the parent, AND a target-level allow can rescue a user the -// parent didn't mention. Both directions of cascade must be exercised so -// future refactors of the per-target ACL helper can't silently break one. +// parent didn't mention. Both directions must be exercised so future +// refactors of the per-target ACL helper can't silently break one. func TestServeArchive_CascadeDirectionsBothEnforced(t *testing.T) { root, idx := archiveTestRoot(t) // Root: deny default — only bob is on the list. ProjectA: explicitly - // allow alice. So alice is rescued at the leaf, mallory stays out - // everywhere, bob stays in everywhere. + // allow alice. So alice is rescued at ProjectA, mallory stays out + // everywhere, bob stays in everywhere. Per-target ACL on resolved files + // doesn't kick in here — both projects allow bob via the root rule. writeZddc(t, root, ".", `acl: allow: ["bob@example.com"] `) @@ -270,40 +356,35 @@ func TestServeArchive_CascadeDirectionsBothEnforced(t *testing.T) { cfg := archiveCfg(root) cases := []struct { - email string - filename string - wantStatus int - why string + email string + contextPath string + filename string + wantStatus int + why string }{ - {"bob@example.com", "100.html", http.StatusFound, "bob allowed at root → reaches ProjectA target"}, - {"bob@example.com", "200.html", http.StatusFound, "bob allowed at root → reaches ProjectB target"}, - {"alice@example.com", "100.html", http.StatusFound, "alice rescued by ProjectA allow"}, - {"alice@example.com", "200.html", http.StatusNotFound, "alice not in ProjectB chain → 404"}, - // mallory is denied EVERYWHERE — including the /ProjectA contextPath - // — so she never reaches per-target evaluation; the contextPath - // gate returns 403. (404 leak-prevention only kicks in once the - // contextPath itself is reachable.) - {"mallory@example.com", "100.html", http.StatusForbidden, "mallory blocked at contextPath"}, + {"bob@example.com", "/ProjectA", "100.html", http.StatusFound, "bob allowed at root → reaches ProjectA target"}, + {"bob@example.com", "/ProjectB", "200.html", http.StatusFound, "bob allowed at root → reaches ProjectB target"}, + {"alice@example.com", "/ProjectA", "100.html", http.StatusFound, "alice rescued by ProjectA allow"}, + {"alice@example.com", "/ProjectB", "200.html", http.StatusForbidden, "alice not in ProjectB chain → 403 at contextPath"}, + // mallory denied everywhere; the contextPath gate fires first. + {"mallory@example.com", "/ProjectA", "100.html", http.StatusForbidden, "mallory blocked at contextPath"}, } for _, c := range cases { - t.Run(c.email+"_"+c.filename, func(t *testing.T) { - // Use ProjectA as contextPath: alice is rescued there (so she - // passes the gate and we get to per-target ACL on the ProjectB - // resolve), and bob+mallory's behavior is governed by the root - // rules. - rec := callArchive(t, cfg, idx, c.email, "/ProjectA", c.filename) + t.Run(c.email+"_"+c.contextPath+"_"+c.filename, func(t *testing.T) { + rec := callArchive(t, cfg, idx, c.email, c.contextPath, c.filename) if rec.Code != c.wantStatus { - t.Errorf("%s → %s: status %d, want %d (%s)", c.email, c.filename, rec.Code, c.wantStatus, c.why) + t.Errorf("%s @ %s → %s: status %d, want %d (%s)", c.email, c.contextPath, c.filename, rec.Code, c.wantStatus, c.why) } }) } } -// Resolved redirect Location header must be the absolute path to the actual -// file under cfg.Root, regardless of which contextPath the caller used to -// reach .archive. So /ProjectA/.archive/100.html and /.archive/100.html -// both 302 to the same file. -func TestServeArchive_ResolveLocationIsAbsoluteAndStableAcrossDepth(t *testing.T) { +// Resolved redirect Location header is the absolute path to the actual file +// under cfg.Root. From any depth within the same project, the resolver +// returns the same target — `/ProjectA/.archive/100.html` and +// `/ProjectA/2025-01-01_T1 (IFR) - Title/.archive/100.html` 302 to the same +// file because both look up project ProjectA. +func TestServeArchive_ResolveLocationStableAcrossDepthWithinProject(t *testing.T) { root, idx := archiveTestRoot(t) writeZddc(t, root, ".", `acl: allow: ["*"] @@ -311,7 +392,11 @@ func TestServeArchive_ResolveLocationIsAbsoluteAndStableAcrossDepth(t *testing.T cfg := archiveCfg(root) wantLocPrefix := "/ProjectA/2025-01-01_T1 (IFR) - Title/100_A" - for _, ctx := range []string{"/", "/ProjectA", "/ProjectB"} { + for _, ctx := range []string{ + "/ProjectA", + "/ProjectA/2025-01-01_T1 (IFR) - Title", + "/ProjectA/2025-02-01_T2 (RTN) - Comments", + } { rec := callArchive(t, cfg, idx, "alice@example.com", ctx, "100.html") if rec.Code != http.StatusFound { t.Errorf("ctx=%s status=%d body=%s", ctx, rec.Code, rec.Body.String()) @@ -324,6 +409,72 @@ func TestServeArchive_ResolveLocationIsAbsoluteAndStableAcrossDepth(t *testing.T } } +// Cross-project: same tracking number issued under two projects. Each +// project's .archive/ resolves to its own copy, never the other's. +func TestServeArchive_CrossProjectSameTrackingNoLeak(t *testing.T) { + root := t.TempDir() + mk := func(rel string) { + path := filepath.Join(root, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(path, []byte("x"), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } + } + mk("ProjectA/2025-01-01_T1 (IFR) - Title/123_A (IFR) - Title.pdf") + mk("ProjectB/2025-06-01_T9 (IFR) - Other Title/123_A (IFR) - Other Title.pdf") + idx, err := archive.BuildIndex(root) + if err != nil { + t.Fatalf("BuildIndex: %v", err) + } + + writeZddc(t, root, ".", `acl: + allow: ["*"] +`) + cfg := archiveCfg(root) + const email = "alice@example.com" + + recA := callArchive(t, cfg, idx, email, "/ProjectA", "123.html") + if recA.Code != http.StatusFound { + t.Fatalf("ProjectA 123.html status=%d body=%s", recA.Code, recA.Body.String()) + } + locA := recA.Header().Get("Location") + if !strings.HasPrefix(locA, "/ProjectA/") { + t.Errorf("ProjectA Location=%q, want /ProjectA/ prefix", locA) + } + + recB := callArchive(t, cfg, idx, email, "/ProjectB", "123.html") + if recB.Code != http.StatusFound { + t.Fatalf("ProjectB 123.html status=%d body=%s", recB.Code, recB.Body.String()) + } + locB := recB.Header().Get("Location") + if !strings.HasPrefix(locB, "/ProjectB/") { + t.Errorf("ProjectB Location=%q, want /ProjectB/ prefix", locB) + } + + if locA == locB { + t.Errorf("cross-project leak: same Location for both projects: %q", locA) + } + + // Listing each project shows only its own. + for _, c := range []struct{ ctx, mustHave, mustNot string }{ + {"/ProjectA", "ProjectA", "ProjectB"}, + {"/ProjectB", "ProjectB", "ProjectA"}, + } { + rec := callArchive(t, cfg, idx, email, c.ctx, "") + if rec.Code != http.StatusOK { + t.Fatalf("listing %s: status %d", c.ctx, rec.Code) + } + got := decodeListing(t, rec.Body.Bytes()) + for _, e := range got { + if !strings.Contains(e.URL, "/"+c.mustHave+"/") { + t.Errorf("ctx=%s entry URL %q lacks /%s/ segment", c.ctx, e.URL, c.mustHave) + } + } + } +} + // Default-deny: as soon as ANY .zddc exists in the chain, an unmatched // caller is denied. Verify this applies to listing entries too — a target // in a directory with a restrictive .zddc is not surfaced to outsiders even @@ -336,8 +487,8 @@ func TestServeArchive_DefaultDenyOnceZddcExists(t *testing.T) { `) cfg := archiveCfg(root) - // alice sees everything she's allowed to. - rec := callArchive(t, cfg, idx, "alice@example.com", "/", "") + // alice sees everything she's allowed to in ProjectA. + rec := callArchive(t, cfg, idx, "alice@example.com", "/ProjectA", "") if rec.Code != http.StatusOK { t.Fatalf("alice listing: status %d, want 200", rec.Code) } @@ -345,15 +496,14 @@ func TestServeArchive_DefaultDenyOnceZddcExists(t *testing.T) { t.Errorf("alice listing was empty, want entries") } - // Charlie isn't on any list → default-deny at root → 403 even for the listing. - rec = callArchive(t, cfg, idx, "charlie@example.com", "/", "") + // Charlie isn't on any list → default-deny → 403 even for the listing. + rec = callArchive(t, cfg, idx, "charlie@example.com", "/ProjectA", "") if rec.Code != http.StatusForbidden { t.Errorf("charlie listing: status %d, want 403", rec.Code) } - // Direct resolve also denied (404 to avoid leak). - rec = callArchive(t, cfg, idx, "charlie@example.com", "/", "100.html") - // contextPath ACL fires first: at root, charlie is denied → 403. + // Direct resolve: contextPath ACL fires first → 403. + rec = callArchive(t, cfg, idx, "charlie@example.com", "/ProjectA", "100.html") if rec.Code != http.StatusForbidden { t.Errorf("charlie resolve: status %d, want 403 (denied at contextPath)", rec.Code) } @@ -368,8 +518,30 @@ func TestServeArchive_EmptyEmailDeniedEvenWithStarAllow(t *testing.T) { `) cfg := archiveCfg(root) - rec := callArchive(t, cfg, idx, "", "/", "") + rec := callArchive(t, cfg, idx, "", "/ProjectA", "") if rec.Code != http.StatusForbidden { t.Errorf("anonymous listing: status %d, want 403", rec.Code) } } + +// projectFromContextPath is the canonical place to derive the project key +// from the .archive contextPath. Pin the edge cases. +func TestProjectFromContextPath(t *testing.T) { + cases := []struct { + ctx string + want string + }{ + {"/ProjectA", "ProjectA"}, + {"/ProjectA/", "ProjectA"}, + {"/ProjectA/sub/sub", "ProjectA"}, + {"/", ""}, + {"", ""}, + {"ProjectA/sub", "ProjectA"}, + } + for _, c := range cases { + got := projectFromContextPath(c.ctx) + if got != c.want { + t.Errorf("projectFromContextPath(%q) = %q, want %q", c.ctx, got, c.want) + } + } +} diff --git a/zddc/internal/handler/profilehandler.go b/zddc/internal/handler/profilehandler.go index 9661039..2f64cdd 100644 --- a/zddc/internal/handler/profilehandler.go +++ b/zddc/internal/handler/profilehandler.go @@ -66,21 +66,25 @@ func ServeProfile(cfg config.Config, ring *LogRing, w http.ResponseWriter, r *ht } } -// AccessView is the data the profile page renders in its top section and -// /.profile/access serves as JSON. It is derived from cfg + the caller's -// email; everything reuses existing helpers in package zddc. +// AccessView is the data the profile page lazy-loads from /.profile/access +// after first paint. The HTML shell renders only Email/EmailHeader/ +// IsSuperAdmin (all cheap); Projects + AdminSubtrees + HasAnyAdminScope come +// in via JS. EditableParentChoices is what the create-project form's +// parent-selector renders — derived from AdminSubtrees on the client. type AccessView struct { - Email string `json:"email"` - EmailHeader string `json:"email_header"` - IsSuperAdmin bool `json:"is_super_admin"` - HasAnyAdminScope bool `json:"has_any_admin_scope"` - Projects []ProjectInfo `json:"projects"` - AdminSubtrees []treeEntry `json:"admin_subtrees"` + Email string `json:"email"` + EmailHeader string `json:"email_header"` + IsSuperAdmin bool `json:"is_super_admin"` + HasAnyAdminScope bool `json:"has_any_admin_scope"` + Projects []ProjectInfo `json:"projects"` + AdminSubtrees []treeEntry `json:"admin_subtrees"` + EditableParentChoices []treeEntry `json:"editable_parent_choices"` } -// enumerateAccess builds an AccessView for the given caller. Callable by -// both the HTML page (server-render) and the JSON endpoint without -// duplicating the access-walk logic. +// enumerateAccess builds an AccessView for the given caller. Used by the +// JSON endpoint at /.profile/access; the HTML page no longer calls this on +// the request hot path — it ships a shell first and the client fetches the +// view after first paint. func enumerateAccess(cfg config.Config, email string) AccessView { view := AccessView{ Email: email, @@ -90,6 +94,11 @@ func enumerateAccess(cfg config.Config, email string) AccessView { view.Projects, _ = EnumerateProjects(cfg, email) view.AdminSubtrees = enumerateAdminSubtrees(cfg, email) view.HasAnyAdminScope = view.IsSuperAdmin || len(view.AdminSubtrees) > 0 + for _, t := range view.AdminSubtrees { + if t.CanEdit { + view.EditableParentChoices = append(view.EditableParentChoices, t) + } + } return view } diff --git a/zddc/internal/handler/profilehandler_test.go b/zddc/internal/handler/profilehandler_test.go index 6da9848..7a68a6d 100644 --- a/zddc/internal/handler/profilehandler_test.go +++ b/zddc/internal/handler/profilehandler_test.go @@ -207,9 +207,48 @@ func TestServeProfileLogsLevelFilter(t *testing.T) { } } -// TestServeProfileHTMLLayered verifies server-side conditional rendering: -// non-admin HTML contains zero admin markup, admin HTML adds the admin -// block, super-admin HTML adds the diagnostics block. +// stripTemplates removes every block from the +// HTML body so substring assertions check only ACTIVE markup — i.e. live +// DOM content the user (and their browser) actually sees, as opposed to +// inert content that JS may clone in based on a later access fetch. +// +// Naive but sufficient for the controlled output of profileTemplate (the +// template tags are unnested and well-formed). If the page ever grows +// nested templates, swap this for an html.Tokenizer-based pass. +func stripTemplates(body string) string { + var b strings.Builder + for { + i := strings.Index(body, "") + if j < 0 { + // Unterminated "):] + } +} + +// TestServeProfileHTMLLayered pins the page-render contract after the +// lazy-load refactor: +// +// - The shell is the same byte-stream for every caller modulo the +// identity card and the super-admin diagnostics scaffold (gated by the +// cheap IsSuperAdmin check on the root .zddc). +// - Subtree-admin scaffolds (Editable .zddc files / Create new project) +// live ONLY inside