Compare commits

...

6 commits

Author SHA1 Message Date
320c5d09ab chore(embedded): cut v0.0.17-beta
All checks were successful
Notify chart dev on beta cut / notify-chart-dev (push) Successful in 6s
2026-05-13 10:34:56 -05:00
e7f6334daa chore: retire mdedit tool — markdown editor lives in browse now
mdedit/ is gone. Its functionality moved into browse's preview plugin
(browse/js/preview-markdown.js) — YAML front matter editing, outline,
and on-demand DOCX/HTML/PDF download all happen there. Browse is the
default_tool for working/ + reviewing/ as of the previous commit, so
existing URLs of the form /<project>/working land on browse without
operator action.

Removed:

  • mdedit/ source tree (Toast UI app, CSS, JS, template, build.sh)
  • zddc/internal/apps/embedded/mdedit.html (//go:embed blob)
  • tests/mdedit.spec.js + the "mdedit" project in playwright.config.js
  • mdedit entries in zddc/internal/apps/embed.go (//go:embed, var,
    switch case in EmbeddedBytes)
  • "mdedit" in zddc/internal/zddc/validate.go AppNames + the matching
    error-message app list
  • "mdedit.html" branch in zddc/internal/apps/handler.go MatchAppHTML
  • mdedit case in tests (handler_test.go, validate_test.go,
    zddchandler_test.go) — test fixtures now use browse/classifier
  • mdedit from build (per-tool build.sh loop, tool-list literals,
    composer cards) and shared/build-lib.sh ZDDC_RELEASE_TOOLS
  • mdedit from freshen-channel's tool list and usage banner
  • mdedit-specific paragraphs in AGENTS.md and ARCHITECTURE.md;
    Markdown Editor section in ARCHITECTURE.md rewritten to point at
    browse/js/preview-markdown.js
  • mdedit from CLAUDE.md, README.md, zddc/README.md tool lists

Historical mdedit_v*.html / mdedit_v*.html.sig files in
/srv/zddc/releases/ on the deploy host are immutable history — they
stay where they are. The next ./build release cut will simply not
produce new mdedit_v* artifacts.
2026-05-13 10:34:31 -05:00
7fbe7867fd feat(zddc): defaults — browse hosts the markdown editor for working/+reviewing/
Flip default_tool from `mdedit` to `browse` (which now ships a Toast UI
markdown editor plugin in its preview pane) at:

  • paths."*".paths.working
  • paths."*".paths.working.paths."*"   (per-user homes)
  • paths."*".paths.reviewing

available_tools at those levels drops `mdedit` and adds `browse` next
to `classifier`. Operator overrides per .zddc cascade still work; only
the embedded baseline changes.

Test fixtures updated:

  • lookups_test.go     — DefaultToolAt assertions for working/+reviewing/
  • availability_test.go — AppAvailableAt + DefaultAppAt for working/+
                           reviewing/+per-user home
  • main_test.go        — dispatch route asserts "ZDDC Browse" (was "ZDDC
                          Markdown"); Apps cascade fixture swaps mdedit
                          for browse so the live route fetches the right
                          embedded HTML
2026-05-13 10:34:06 -05:00
b5aab81d31 feat(zddc): MD→{docx,html,pdf} server-side conversion via stock pandoc + chromium containers
New endpoint GET /<path>/foo.md?convert=docx|html|pdf renders a markdown
source on demand. Surfaced as the Download buttons in browse's markdown
editor (separate commit).

Execution model — two upstream container images, lazy-pulled:

  • docker.io/pandoc/latex:latest  — MD→DOCX, MD→HTML (entrypoint pandoc)
  • docker.io/zenika/alpine-chrome — HTML→PDF (entrypoint chromium-browser)

No custom image build. The runner passes --pull=missing on every podman/
docker invocation so the operator only needs the runtime installed —
first request pulls the image, subsequent requests use the local cache.
Overrides: --convert-pandoc-image / --convert-chromium-image (and the
matching ZDDC_CONVERT_* env vars). Engine: --convert-engine (podman
preferred, docker fallback). Resource caps: --convert-mem-mib (512),
--convert-cpus (2), --convert-pids (100), --convert-timeout (30s).

PDF flow is two-stage: pandoc renders the markdown through the embedded
viewer-template.html to standalone HTML, then chromium prints that HTML
via --print-to-pdf. Preserves the print-media CSS already authored in
viewer-template.html rather than going through pandoc's LaTeX template.

Each conversion runs in a throw-away container with --rm --network=none
--read-only --tmpfs=/tmp --cap-drop=ALL --security-opt=no-new-privileges
--env=HOME=/tmp plus a bind-mounted scratch dir for I/O. Pandoc reads
markdown from stdin / writes to stdout; the viewer template lives at
/tpl (ro). Chromium reads HTML from a read-write bind mount at /pdf
and writes the PDF to the same mount; the host reads it back. No shell
wrappers, no shell quoting — argv flows straight into each image's
entrypoint.

On-disk cache at <dir>/.converted/<base>.<ext> with mtime synced to the
source. Fast path is a stat-and-serve with no exec; slow path
singleflights concurrent requests for the same target. PUT/DELETE/MOVE
on the source .md purges the .converted/ sidecars.

Per-project template variables (client/project/contractor/project_number)
come from a new .zddc `convert:` cascade block, walked leaf→root with
per-key latest-wins. Filename-derived variables (title, tracking_number,
revision, status, is_draft) come from a new zddc.ParseFilename helper.

If neither podman nor docker is on PATH, the endpoint serves 503 with
a clear Retry-After. The rest of the server keeps working.

This is the first os/exec site in the codebase. The hardening in
internal/convert/runner.go — context.CancelFunc → process kill,
cmd.WaitDelay, platform-specific SysProcAttr (Setpgid + Pdeathsig on
Linux), minimal env, stdout cap via limitWriter, stderr ring buffer —
sets the pattern for any future shell-outs.

Public surface:
  convert.ToDocx(ctx, source, meta) / .ToHTML / .ToPDF
  convert.Probe(ctx, engineOverride) → install Runner if engine present
  convert.SetImages(pandoc, chromium)
  convert.ConfigureLimits(memMiB, cpus, pids, timeout)
  convert.Available()

Container handler at internal/handler/converthandler.go; dispatcher
branch in cmd/zddc-server/main.go inserts the convert lookup after the
existing ACL gate, reusing the source file's read policy verbatim.
2026-05-13 10:33:56 -05:00
b34edcecac feat(browse): markdown editor — editable YAML front matter + DOCX/HTML/PDF download buttons
Two improvements to browse's preview-markdown plugin so it can replace
the standalone mdedit tool:

1. **YAML front-matter editing.** The FM pane above the outline used to
   render a read-only <dl> of parsed keys — sparse and unusable when
   the file had no envelope yet. It's now a dedicated <textarea> that's
   always present. On load, parseFrontMatter() splits the `---\n…\n---`
   envelope off the body: the body feeds Toast UI Editor, the envelope
   feeds the textarea. On save, assembleContent() recombines them.
   Dirty tracking covers both halves via a SHA-256 of the assembled
   bytes. The shell mirrors mdedit's old layout (FM textarea top,
   outline below) but the FM pane is now always functional, eliminating
   the "empty pane over the TOC" problem.

2. **Download as DOCX / HTML / PDF.** When the file handle is HTTP-
   backed (server mode) and the file is a .md, three buttons appear in
   the info header next to Save. Clicking one fetches the server's
   ?convert=<fmt> endpoint and triggers a browser download with a
   clean filename (foo.md → foo.docx). Auto-saves the buffer first if
   dirty so the converted bytes reflect what's on screen.

Helper at window.zddc.source.downloadConverted (shared/zddc-source.js)
so other tools — archive, transmittal — can reuse the same flow later.
Friendly error messages map HTTP 503 / 422 / 504 to actionable toasts.
2026-05-13 10:32:38 -05:00
f5cf79dc1c docs: sweep stale "hardcoded canonical folders" model across the top-level docs
The .zddc cascade-config migration retired the hardcoded folder-name
predicates (special.go's IsAutoOwnPath/IsWormPath/…) in favour of a
baked-in defaults.zddc.yaml with a recursive paths: tree, but several
top-level docs still described the old model:

- README.md / CLAUDE.md: "tools auto-served at folder-name-driven
  paths (classifier in Incoming/Working/Staging, …)" → now: which tool
  a URL serves is the cascade's default_tool/dir_tool/available_tools;
  added the .zip-as-directory + GET /dir/?zip=1 + show-defaults notes;
  CLAUDE.md's shared/ inventory refreshed (zip-source.js, fonts, …).
- ARCHITECTURE.md: the "Cooperating layers" table's "Special folders"
  row (referenced special.go, the retired "WORM split") → rewritten as
  "Canonical-folder behaviour" driven by the auto_own/worm/virtual/
  drop_target .zddc keys; the "ACL cascade" row now mentions the
  defaults.zddc.yaml bottom layer + paths: walker.
- zddc/README.md: role resolution was described as "shadows" → it's a
  union with reset:true; WORM was "path-based, not cascade-based" → it's
  the worm: cascade key; the "Special folders" section rewritten as
  "Canonical-folder behaviour via .zddc keys" (a key table + the .zip
  /?zip=1 notes), pointing at show-defaults as the authoritative ref.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:32:59 -05:00
78 changed files with 3885 additions and 13975 deletions

View file

@ -27,7 +27,7 @@
./deploy --releases # only dist/release-output/ → /srv/zddc/releases/
# Single-tool dev build for testing (does NOT touch dist/release-output/):
sh tool/build.sh # archive|transmittal|classifier|mdedit|landing|form|tables|browse
sh tool/build.sh # archive|transmittal|classifier|landing|form|tables|browse
# Single-tool release (rare; prefer ./build alpha|beta|release so versions
# don't drift between tools). Same flag form as before.
@ -38,7 +38,7 @@ sh tool/build.sh --release [<version>|alpha|beta]
npm test
# Test single tool
npx playwright test tool # archive | transmittal | classifier | mdedit | form-safety | tables
npx playwright test tool # archive | transmittal | classifier | browse | form-safety | tables
# Dev server (cache-busting HTTP, on port 8000)
./dev-server start
@ -60,7 +60,7 @@ because the bundle is complete, dangling-link errors mean a real bug.
## Architecture
Eight independent single-file HTML tools (`archive`, `transmittal`, `classifier`, `mdedit`, `landing`, `form`, `tables`, `browse`). Each compiles to one self-contained `.html` in `dist/` with all CSS and JS inlined — most name their output `dist/tool.html`; `landing` writes `dist/index.html` (served at `/` by `zddc-server`). Tools share a small set of canonical helpers in `shared/` (filename parsing, ZDDC filter UI, theme, help) — see "Shared modules" below. `form` is the schema-driven renderer used by zddc-server's form-data system; `tables` is its read/aggregate counterpart, rendering a directory of YAML files as a sortable table whose rows click through to the form editor — discovered presence-based via `<name>.table.yaml` next to a sibling `<name>/` rows-dir (see "Form-data system" and "Tables system" below).
Seven independent single-file HTML tools (`archive`, `transmittal`, `classifier`, `landing`, `form`, `tables`, `browse`). Each compiles to one self-contained `.html` in `dist/` with all CSS and JS inlined — most name their output `dist/tool.html`; `landing` writes `dist/index.html` (served at `/` by `zddc-server`). Tools share a small set of canonical helpers in `shared/` (filename parsing, ZDDC filter UI, theme, help) — see "Shared modules" below. `form` is the schema-driven renderer used by zddc-server's form-data system; `tables` is its read/aggregate counterpart, rendering a directory of YAML files as a sortable table whose rows click through to the form editor — discovered presence-based via `<name>.table.yaml` next to a sibling `<name>/` rows-dir (see "Form-data system" and "Tables system" below). `browse` is the file-tree navigator and also hosts the in-place markdown editor (`browse/js/preview-markdown.js`); the dedicated `mdedit/` tool has been retired.
```
tool/
@ -202,7 +202,7 @@ Format: `trackingNumber_revision (status) - title.extension`
- Feature-branch workflow; squash-merge feature branches to `main`
- Conventional commits: `feat(archive): ...`, `fix(transmittal): ...`
- Release tags: `<tool>-v<X.Y.Z>` per tool, all nine sharing the same X.Y.Z on a coordinated cut (e.g. `archive-v0.0.8`, `transmittal-v0.0.8`, `classifier-v0.0.8`, `mdedit-v0.0.8`, `landing-v0.0.8`, `form-v0.0.8`, `tables-v0.0.8`, `browse-v0.0.8`, `zddc-server-v0.0.8`)
- Release tags: `<tool>-v<X.Y.Z>` per tool, all eight sharing the same X.Y.Z on a coordinated cut (e.g. `archive-v0.0.8`, `transmittal-v0.0.8`, `classifier-v0.0.8`, `landing-v0.0.8`, `form-v0.0.8`, `tables-v0.0.8`, `browse-v0.0.8`, `zddc-server-v0.0.8`)
- `dist/` is gitignored. Build artifacts (per-tool `dist/<tool>.html` and `dist/release-output/`) are NOT committed to this repo. Reproduce them from a tag with `./build release X.Y.Z`
- Hand-edited website content lives in a separate Codeberg repo (`codeberg.org/VARASYS/ZDDC-website`, cloned at `~/src/zddc-website/`). Source-code commits go to `main` here; content commits go to that repo
- Release artifacts live on the deploy host (`/srv/zddc/`), not in any git history. Use `./deploy` to publish
@ -215,7 +215,7 @@ Format: `trackingNumber_revision (status) - title.extension`
| Artifact | Type | Layout |
|---|---|---|
| `<tool>_v<X.Y.Z>.html` | real, immutable | per-version HTML for each of archive, transmittal, classifier, mdedit, landing, form, tables, browse |
| `<tool>_v<X.Y.Z>.html` | real, immutable | per-version HTML for each of archive, transmittal, classifier, landing, form, tables, browse |
| `<tool>_v<X.Y>.html`, `<tool>_v<X>.html` | symlinks | partial-version pins |
| `<tool>_<channel>.html` | symlink (or real bytes during active channel dev) | mutable channel mirror per tool, channel ∈ {stable, beta, alpha} |
| `zddc-server_v<X.Y.Z>_<platform>` | real binary | per-version cross-compiled binary, platform ∈ {linux-amd64, darwin-amd64, darwin-arm64, windows-amd64.exe} |
@ -284,7 +284,7 @@ The build pipeline used is the one **at the tag**, not on `main`. That is intent
No install script. Two paths:
- **Local** — download a tool `.html` from `https://zddc.varasys.io/releases/` and open it. Done.
- **Server** (`zddc-server`) — every tool is `//go:embed`'d into the binary at compile time (the current-stable build). Which tool a directory URL serves is driven by the `.zddc` cascade, not hardcoded: the baked-in baseline (`zddc/internal/zddc/defaults.zddc.yaml`, dumpable via `zddc-server show-defaults`) declares, via a recursive `paths:` tree, a `default_tool` (the no-slash form: `archive` everywhere, `transmittal` under `staging/`, `mdedit` under `working/`, `classifier` under `incoming/`, `tables` at `archive/<party>/mdl`, `landing` at the deployment root) and `available_tools` (which tools may be auto-served / offered) per folder. The trailing-slash form serves `dir_tool` (defaults to `browse`). See `internal/apps/availability.go` (`DefaultAppAt`, `AppAvailableAt`) and `internal/zddc/lookups.go` (`DefaultToolAt`, `DirToolAt`, `AvailableToolsAt`); the dispatcher chokepoint is `serveSpecializedNoSlash` in `cmd/zddc-server/main.go`. Where the cascade declares no tool, requesting `<app>.html` returns 404 like any other missing file. **The full canonical-folder convention (auto-own, WORM, virtual folders, standard roles) is documented in ARCHITECTURE.md § "Canonical folders, URL routing & the `.zddc` cascade".**
- **Server** (`zddc-server`) — every tool is `//go:embed`'d into the binary at compile time (the current-stable build). Which tool a directory URL serves is driven by the `.zddc` cascade, not hardcoded: the baked-in baseline (`zddc/internal/zddc/defaults.zddc.yaml`, dumpable via `zddc-server show-defaults`) declares, via a recursive `paths:` tree, a `default_tool` (the no-slash form: `archive` everywhere, `transmittal` under `staging/`, `browse` under `working/`+`reviewing/` (hosts the markdown editor), `classifier` under `incoming/`, `tables` at `archive/<party>/mdl`, `landing` at the deployment root) and `available_tools` (which tools may be auto-served / offered) per folder. The trailing-slash form serves `dir_tool` (defaults to `browse`). See `internal/apps/availability.go` (`DefaultAppAt`, `AppAvailableAt`) and `internal/zddc/lookups.go` (`DefaultToolAt`, `DirToolAt`, `AvailableToolsAt`); the dispatcher chokepoint is `serveSpecializedNoSlash` in `cmd/zddc-server/main.go`. Where the cascade declares no tool, requesting `<app>.html` returns 404 like any other missing file. **The full canonical-folder convention (auto-own, WORM, virtual folders, standard roles) is documented in ARCHITECTURE.md § "Canonical folders, URL routing & the `.zddc` cascade".**
To override at any level, either:
1. Drop a real `<app>.html` file at the path → static handler serves it (highest priority).
@ -314,11 +314,27 @@ Use `git worktree` to run multiple agents on separate branches simultaneously wi
- No runtime CDN loads. Every vendor library (jszip, docx-preview, xlsx, UTIF, Toast UI) is bundled at build time via `concat_files`. The dist HTML is fully self-contained — "ship the record player with the record."
- Published payload stored in `<script id="transmittal-data" type="application/json">`
## mdedit-specific
## Markdown editor (inside browse)
- `css/tailwind-utils.css` is a pre-generated static subset (~80 classes). Add new Tailwind classes here; do not re-run Tailwind.
- Toast UI Editor v3.2.2 is bundled in `vendor/`; `template.html` loads it from CDN for dev convenience
- `</` escaping is essential: `sed 's#</#<\\/#g'` runs on both app JS and vendor JS at build time
The markdown editor lives at `browse/js/preview-markdown.js` and is mounted as the preview plugin for `.md`/`.markdown` files by `browse/js/preview.js`. The standalone `mdedit/` tool has been retired — `browse` is the editor.
- Toast UI Editor v3.2.2 is vendored at `shared/vendor/toastui-editor-all.min.js` and concatenated into `browse/dist/browse.html` at build time. No runtime CDN.
- YAML front matter (`---\n…\n---`) is split off on load and edited in a dedicated `<textarea>` in the sidebar; on save it's recombined onto the body. Always present (no "empty pane") so authoring new FM is a single click.
- In server mode (HTTP-backed file handles), three Download buttons appear in the file header — DOCX/HTML/PDF — fetching `?convert=<fmt>` and triggering a browser download. The buttons auto-save the dirty buffer first so the converted bytes reflect what's on screen.
## Server-side document conversion (`zddc/internal/convert`)
zddc-server can convert `.md` → DOCX/HTML/PDF on demand at `GET /<path>/foo.md?convert=docx|html|pdf`. Implementation:
- **Two upstream images, pulled on first use.** No custom image build. Operator just needs `podman` or `docker` installed; the runner passes `--pull=missing` so the first request pulls each image and subsequent requests use the local cache.
- `docker.io/pandoc/latex:latest` — pandoc's official image, entrypoint `pandoc`. Used for MD → DOCX and MD → HTML. Override via `--convert-pandoc-image=` / `ZDDC_CONVERT_PANDOC_IMAGE` (e.g. switch to `docker.io/pandoc/core:latest` for a ~90% size reduction).
- `docker.io/zenika/alpine-chrome:latest` — Zenika's Alpine + Chromium image, entrypoint `chromium-browser`. Used for HTML → PDF (the PDF flow is two-stage: pandoc image emits HTML using viewer-template.html, chromium image prints it). Override via `--convert-chromium-image=` / `ZDDC_CONVERT_CHROMIUM_IMAGE`.
- Engine is podman preferred, docker fallback (`--convert-engine=` / `ZDDC_CONVERT_ENGINE` to override). No host pandoc or chromium needed.
- Each conversion runs in a throw-away container with `--rm --pull=missing --network=none --read-only --tmpfs=/tmp:size=128m,exec --memory --cpus --pids-limit --cap-drop=ALL --security-opt=no-new-privileges --env=HOME=/tmp`. Resource caps via `--convert-mem-mib` (default 512), `--convert-cpus` (default "2"), `--convert-pids` (default 100), `--convert-timeout` (default 30s). `--user` is intentionally not set so each image uses its default (root for pandoc/latex, uid 1000 for alpine-chrome) — the other flags already provide strong isolation and overriding the user would break alpine-chrome's user-data-dir layout.
- I/O via bind mount + stdin/stdout. Pandoc reads markdown from stdin, writes to stdout. The viewer template is bind-mounted read-only at `/tpl`. Chromium reads HTML from a read-write bind mount at `/pdf` and writes the PDF to the same mount; the host reads it back.
- Output cached at `<dir>/.converted/<base>.<ext>` (hidden by the `.` prefix). mtime synced to source so the fast path is a stat-and-serve with no exec. PUT/DELETE/MOVE on the source `.md` purges the sidecars.
- Per-project template variables (client/project/contractor/project_number) come from `.zddc` `convert:` cascade keys. Title/tracking_number/revision/status are derived from the filename via `zddc.ParseFilename`.
- If neither podman nor docker is present, the endpoint serves 503 with a Retry-After. The rest of the server keeps working.
## Form-data system (`form/` + zddc-server form handler)

View file

@ -62,7 +62,7 @@ Website files (what `zddc.varasys.io` serves) live on a **separate Codeberg repo
releases/ ← rsync'd from ~/src/zddc/dist/release-output/
```
`<tool>` ∈ {archive, transmittal, classifier, mdedit, landing, form, tables, browse}. `<platform>` ∈ {linux-amd64, darwin-amd64, darwin-arm64, windows-amd64.exe}.
`<tool>` ∈ {archive, transmittal, classifier, landing, form, tables, browse}. `<platform>` ∈ {linux-amd64, darwin-amd64, darwin-arm64, windows-amd64.exe}.
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.
@ -154,7 +154,7 @@ Two orthogonal axes: how the bytes get there (this section), and what runtime mo
|---------------|-------------------------------------------------------------------------|
| `archive` | every directory (multi-project, project, archive, vendor) |
| `classifier` | any `Incoming`, `Working`, or `Staging` directory and its subtree |
| `mdedit` | any `Working` directory and its subtree |
| `browse` | every directory (hosts the markdown editor as a preview plugin) |
| `transmittal` | any `Staging` directory and its subtree |
| `landing` | only at the deployment root |
@ -170,7 +170,7 @@ The `X-ZDDC-Source` response header always reports what was served: `fetch:URL`,
### Runtime mode detection
Independent of how the tool got installed. `archive` auto-detects from the URL and folder shape (`?projects=` set → multi-project; scan root has an `archive/` child → project-root; otherwise → in-archive). The other tools don't care — `transmittal`, `classifier`, `mdedit` work the same regardless of where they live.
Independent of how the tool got installed. `archive` auto-detects from the URL and folder shape (`?projects=` set → multi-project; scan root has an `archive/` child → project-root; otherwise → in-archive). The other tools don't care — `transmittal`, `classifier`, `browse` work the same regardless of where they live.
### Build Script Requirements
@ -198,7 +198,7 @@ sed 's#</#<\\/#g' "$input_js" > "$safe_js"
Then use `</script>` (not `<\/script>`) to close the `<script>` block, since the content no longer contains any `</` sequences that the parser could misread.
This is already enforced for mdedit's vendor bundling. It is the contributor's responsibility to ensure new tools follow this pattern.
This is already enforced for browse's Toast UI bundling. It is the contributor's responsibility to ensure new tools follow this pattern.
### Vendor Dependencies
@ -208,24 +208,25 @@ Some tools bundle third-party libraries. These live in `tool/vendor/` and are co
| Tool | Library | File | Notes |
|------|---------|------|-------|
| mdedit | Toast UI Editor v3.2.2 | `vendor/toastui-editor-all.min.js` | Markdown editor with live preview |
| mdedit | Toast UI Editor CSS | `vendor/toastui-editor.min.css` | Editor stylesheet |
| browse | Toast UI Editor v3.2.2 | `shared/vendor/toastui-editor-all.min.js` | Markdown editor (loaded by `browse/js/preview-markdown.js`) |
| browse | Toast UI Editor CSS | `shared/vendor/toastui-editor.min.css` | Editor stylesheet |
| shared | jszip | `shared/vendor/jszip.min.js` | ZIP read for previews + classifier hash-export |
| shared | docx-preview | `shared/vendor/docx-preview.min.js` | DOCX preview |
| shared | xlsx (SheetJS) | `shared/vendor/xlsx.full.min.js` | XLSX/XLS preview |
| shared | UTIF | `shared/vendor/utif.min.js` | TIFF preview |
**No runtime CDN loads.** Every external dependency is vendored into
`shared/vendor/` (or, for mdedit's editor, `mdedit/vendor/`) and
concatenated into each tool's bundle at build time. Tools that need a
given library include the vendor path in their `build.sh`'s
`concat_files` JS list. The "ship the record player with the record"
philosophy: a downloaded `.html` file works offline against any file
the user can open, with no network dependency at runtime.
`shared/vendor/` and concatenated into each tool's bundle at build
time. Tools that need a given library include the vendor path in
their `build.sh`'s `concat_files` JS list. The "ship the record
player with the record" philosophy: a downloaded `.html` file works
offline against any file the user can open, with no network
dependency at runtime.
Trade-off accepted: bundle sizes are larger. archive, classifier,
transmittal land around 1.5 MB after gzip; mdedit lands around 2 MB
because it carries Toast UI + jszip + docx-preview + xlsx + UTIF.
transmittal land around 1.5 MB after gzip; browse lands around 2 MB
because it carries Toast UI + jszip + docx-preview + xlsx + UTIF
for the in-place markdown editor and the preview pane.
Justified by the offline-first guarantee: any tool downloaded from
`/releases/` works without network, against air-gapped archives,
forever. See ARCHITECTURE.md § "Why Single-File HTML Applications"
@ -244,7 +245,7 @@ dependency inlined. No CDN URLs survive into the dist.
| Development | CDN (live, from `template.html`) | Open `template.html` directly in Chromium |
| Production | Bundled / Static CSS | Run `bash tool/build.sh`, open `dist/tool.html` |
For mdedit specifically: `template.html` loads Toast UI from CDN and uses Tailwind Play CDN. The build replaces Toast UI with the bundled vendor file and replaces the Tailwind CDN script with the static `css/tailwind-utils.css` subset.
For browse specifically: `template.html` loads Toast UI from CDN for dev convenience. The build replaces it with the bundled vendor file (`shared/vendor/toastui-editor-all.min.js`).
---
@ -290,7 +291,7 @@ main.js ← Initialization (depends on all modules)
### State Management
Three patterns coexist. **For new tools, prefer the first one** — direct mutation on `window.app` with explicit re-render. It's debuggable, it's the most common pattern in this codebase (archive, mdedit, browse, form, tables), and it doesn't hide control flow.
Three patterns coexist. **For new tools, prefer the first one** — direct mutation on `window.app` with explicit re-render. It's debuggable, it's the most common pattern in this codebase (archive, browse, form, tables), and it doesn't hide control flow.
**1. Direct mutation on `window.app` + explicit re-render** *(recommended for new tools)*
@ -301,7 +302,7 @@ window.app.files.push(newFile);
window.app.modules.table.render();
```
State is read directly. Mutations trigger explicit `render()` calls — no auto-tracking, no surprise updates. Used by archive, mdedit, browse, form, tables, landing.
State is read directly. Mutations trigger explicit `render()` calls — no auto-tracking, no surprise updates. Used by archive, browse, form, tables, landing.
**2. Pub-sub store on top of #1** (classifier)
@ -392,24 +393,17 @@ Files at the root level are ignored. The grouping folder list and transmittal fo
---
### Markdown Editor (mdedit)
### Markdown Editor (browse preview plugin)
**Pattern:** Global functions (`window.updateToc`), editor instances managed per file-path in a `Map`, File System Access API for direct file read/write.
**Lives at:** `browse/js/preview-markdown.js`, registered on `window.app.modules.markdown` and invoked by `browse/js/preview.js` for `.md`/`.markdown` files. The standalone `mdedit/` tool was retired in favour of this plugin.
**Dependencies:** Toast UI Editor v3.2.2 (bundled), Tailwind utility subset (static CSS).
**Pattern:** Editor instances per-file (constructed by `render(node, container, ctx)`, disposed by `dispose()`). CSS Grid layout for the shell — sidebar (FM textarea on top, outline below) on the left, content (info header + Toast UI editor) on the right.
**Toast UI availability check:**
**Front matter:** Parsed off the file on load by `parseFrontMatter()` (a small `---\n…\n---` parser); the FM body goes into a sidebar `<textarea>`, the markdown body into the Toast UI editor. On save, `assembleContent()` recombines them with the envelope on top. The textarea is always present so authoring brand-new FM is a single click; dirty tracking covers both halves via a SHA-256 hash of the assembled bytes.
```javascript
if (typeof toastui === 'undefined') {
// Graceful degradation — show error message
}
const editor = new toastui.Editor({ el: container, ... });
```
**Dependencies:** Toast UI Editor v3.2.2 (vendored at `shared/vendor/toastui-editor-all.min.js`, concatenated into `browse/dist/browse.html` at build time). No runtime CDN, no Tailwind.
**Key DOM IDs:** `#app`, `#select-directory`, `#welcome-screen`, `#file-tree`, `#content-container`.
**File tree:** Populated after `showDirectoryPicker()` resolves. File items are rendered as DOM children of `#file-tree`. Clicking a file opens it in the editor panel.
**Server-mode features:** When the file handle is an `HttpFileHandle` (so `node.url` is set and `state.source === 'server'`), three Download buttons appear in the file header — DOCX/HTML/PDF — fetching `?convert=<fmt>` via `window.zddc.source.downloadConverted()`. Clicks auto-save first if the buffer is dirty so converted bytes reflect what's on screen. See `zddc/internal/convert` for the server-side engine.
---
@ -504,8 +498,8 @@ none of them is load-bearing alone.
|---|---|---|
| Authentication | Establish caller identity (email) | Two paths: `Authorization: Bearer <token>` validated against `<ZDDC_ROOT>/.zddc.d/tokens/<sha256-hex>` (CLI / scripted callers); or `X-Auth-Request-Email` injected by an upstream auth proxy (browser users). Token system is built-in and self-issuing — no external IDP required |
| Policy decider | Yield an allow/deny verdict for (identity, path, chain) | Pluggable via `ZDDC_OPA_URL`: in-process Go evaluator (default) or external OPA-compatible HTTP/socket endpoint. `zddc/internal/policy/` |
| ACL cascade | The default decider's rule set | Per-directory `.zddc` YAML with verb-set permissions (`r`/`w`/`c`/`d`/`a`) and roles, walked deepest-first first-match-wins under `--cascade-mode=delegated` or with absolute ancestor denies under `--cascade-mode=strict` (`zddc/internal/zddc/acl.go`, `cascade.go`). External OPA can replace this rule set with arbitrary Rego while keeping the same `.zddc` files as input data |
| Special folders | Codify the bilateral exchange-record archetype | `Incoming`/`Working`/`Staging` get auto-ownership on mkdir (creator gets `rwcda` via an auto-written `.zddc`); `Issued`/`Received` enforce a server-side WORM split (ancestor grants masked to `r`; only an explicit `.zddc` at-or-below the WORM folder can grant `c` for a write-once drop-box). Admins exempt. `zddc/internal/zddc/special.go` |
| ACL cascade | The default decider's rule set | Per-directory `.zddc` YAML with verb-set permissions (`r`/`w`/`c`/`d`/`a`) and roles, plus a baked-in `defaults.zddc.yaml` bottom layer (`zddc-server show-defaults`) that uses a recursive `paths:` tree to describe subfolder rules even before those folders exist. Walked deepest-first first-match-wins under `--cascade-mode=delegated`, or with absolute ancestor denies under `--cascade-mode=strict` (`zddc/internal/zddc/cascade.go`, `walker.go`, `acl.go`). External OPA can replace this rule set with arbitrary Rego while keeping the same `.zddc` files as input data |
| Canonical-folder behaviour | Codify the bilateral exchange-record archetype | All driven by `.zddc` keys (baked into `defaults.zddc.yaml`): `auto_own:` / `auto_own_fenced:` — mkdir here writes a creator-owned `.zddc` (`<email>: rwcda`; fenced adds `acl.inherit:false`); `worm: [principal…]` — write-once-read-many (`w`/`d`/`a` stripped for everyone non-admin, `c` survives only for the listed principals; admins exempt); `virtual:` — never materialise on disk; `drop_target:` — browse shows a drag-drop upload overlay. The defaults put `auto_own` on `working`/`staging`/`archive-party`/`incoming` and `worm: [document_controller]` on `archive/<party>/{received,issued}`, so the canonical convention is unchanged — but an operator can reshape it (rename `received`/`issued`, mark any path WORM, …) without a code change. `zddc/internal/zddc/lookups.go`, `worm.go`, `roles.go`; `defaults.zddc.yaml` |
| Tool-rooted view | Make the caller's accessible subtree feel like their entire world (UX containment) | Archive auto-served at every directory; the URL it's served at *is* its root. No breadcrumb leads above |
| URL canonicalization | Resolve URL paths to on-disk casing before any layer below sees them | `zddc/internal/fs/resolve.go ResolveCanonical` — case-insensitive lookup with lowercase-wins tiebreak when sibling case variants exist on disk. File and folder names preserve case on disk; the canonicalization is purely URL→FS-name mapping. Virtual prefixes (`.archive`, `.profile`, `.tokens`) flow through verbatim |
| Reserved hidden prefixes | Hide operator side-state (caches, dev-shell home dirs) from listings and direct fetch | `.`-prefixed → 404 + listing-filtered; `_`-prefixed → listing-filtered only |
@ -709,7 +703,7 @@ The schema keys that drive built-in behavior:
| `roles` | `{ name → { members:[], reset:bool } }` | members union across cascade; `reset:true` stops the walk |
| `paths` | recursive map of child-path → `.zddc` overlay; the engine of the whole convention | replaces (the walker threads ancestor `paths:` to the right level) |
**Slash / no-slash URL routing.** Every directory URL has two forms: `<dir>/` serves `dir_tool` (defaults to `browse`, the file-tree navigator) and `<dir>` serves `default_tool` (the specialized app — `archive` under `archive/`, `transmittal` under `staging/`, `mdedit` under `working/`, `tables` at `archive/<party>/mdl`). A folder with no `default_tool` 302s the no-slash form to the slash form, so you land on `dir_tool`. JSON listing requests ignore both keys — the raw listing is always served, so the browse SPA can enumerate entries regardless. The dispatcher's `serveSpecializedNoSlash` (in `cmd/zddc-server/main.go`) is the single chokepoint for the no-slash side; `handler.ServeDirectory` (via `zddc.DirToolAt`) handles the slash side.
**Slash / no-slash URL routing.** Every directory URL has two forms: `<dir>/` serves `dir_tool` (defaults to `browse`, the file-tree navigator) and `<dir>` serves `default_tool` (the specialized app — `archive` under `archive/`, `transmittal` under `staging/`, `browse` under `working/`+`reviewing/` (hosts the markdown editor plugin), `tables` at `archive/<party>/mdl`). A folder with no `default_tool` 302s the no-slash form to the slash form, so you land on `dir_tool`. JSON listing requests ignore both keys — the raw listing is always served, so the browse SPA can enumerate entries regardless. The dispatcher's `serveSpecializedNoSlash` (in `cmd/zddc-server/main.go`) is the single chokepoint for the no-slash side; `handler.ServeDirectory` (via `zddc.DirToolAt`) handles the slash side.
**Zip-backed directories.** A `.zip` file is also a navigable directory: `GET …/Foo.zip/` returns a JSON listing of the zip's members (or the browse SPA for an HTML request) and `GET …/Foo.zip/sub/doc.pdf` extracts and streams that one member — so a client navigating a zipped transmittal folder never downloads the whole archive. `GET …/Foo.zip` (no trailing slash) is unchanged: the raw `.zip` download. Read-only: `PUT`/`DELETE`/`POST` to a path inside a `.zip` is rejected (405). ACL is the chain of the directory *containing* the zip — a zip carries no `.zddc` of its own, the same model as the `.archive` virtual surface. Implemented by `internal/zipfs` + `handler.ServeZip`, routed via `splitZipPath` in the dispatcher (before the file-API branch). Offline tools (archive's scanner, browse's tree) get the same capability client-side via `shared/zip-source.js` — a `ZipDirectoryHandle`/`ZipFileHandle` pair over JSZip that mimics the File-System-Access surface. The archive tool treats a `.zip` whose name minus `.zip` parses as a transmittal-folder name as that transmittal folder; the browse tool expands *any* `.zip`.
@ -734,7 +728,7 @@ zddc-server exposes write methods on the same URL space as GET. Each method maps
Writes use `WriteAtomic` (temp file → fsync → rename) for partial-write safety. Move uses `os.Rename` for same-FS atomicity. Body size capped by `--max-write-bytes` (default 256 MiB). Reserved hidden segments (`.`-prefixed, `_app`, `_template`) are 404'd uniformly with the read path. Every write logs a structured `file_write` event (op, path, email, status, bytes) into the same audit stream as access logs.
Browser clients reach the API through `shared/zddc-source.js` — an FS Access API polyfill (`HttpDirectoryHandle`, `HttpFileHandle`) that lets tools written against `showDirectoryPicker()` work unchanged when served by zddc-server. classifier, mdedit, transmittal, and browse auto-detect HTTP mode at startup, build a polyfill handle for `location.pathname`'s directory, and skip the file picker entirely. A 403 on the initial listing surfaces a "no permission to list this directory" message instead of the welcome screen.
Browser clients reach the API through `shared/zddc-source.js` — an FS Access API polyfill (`HttpDirectoryHandle`, `HttpFileHandle`) that lets tools written against `showDirectoryPicker()` work unchanged when served by zddc-server. classifier, transmittal, and browse auto-detect HTTP mode at startup, build a polyfill handle for `location.pathname`'s directory, and skip the file picker entirely. A 403 on the initial listing surfaces a "no permission to list this directory" message instead of the welcome screen.
#### `zddc-source.js` known gaps
@ -770,19 +764,15 @@ out-of-the-box behavior with no per-deployment configuration.
## CSS Architecture
All tools use vanilla CSS. No frameworks at build time (mdedit's Tailwind utilities are pre-generated static CSS).
All tools use vanilla CSS. No frameworks at build time.
**Common conventions:**
- CSS variables for theme colors and spacing in `base.css`
- Component-scoped class names (no global utilities except where Tailwind provides them)
- Component-scoped class names
- `.hidden` class uses `display: none !important` for JavaScript show/hide
- Print styles in a separate `print.css`
**mdedit Tailwind subset:**
`css/tailwind-utils.css` contains only the ~80 Tailwind v3 utility classes actually used in `template.html`. If a new utility class is needed in the template, add it here. Classes follow Tailwind v3 naming and values exactly.
---
## Testing
@ -805,7 +795,7 @@ Each tool has a spec file in `tests/`:
tests/
archive.spec.js ← 2 tests: load + directory scan
classifier.spec.js ← 2 tests: load + store injection
mdedit.spec.js ← 2 tests: load + file tree render
browse.spec.js ← load + file tree render + markdown editor mount
transmittal.spec.js ← 2 tests: paste round-trip + filesystem round-trip
fixtures/
mock-fs-api.js ← Reusable File System Access API mock

View file

@ -21,10 +21,10 @@ If something in this CLAUDE.md conflicts with those, those win — and please up
This is a **monorepo of independent tools**, not one application:
- `archive/`, `transmittal/`, `classifier/`, `mdedit/`, `landing/`, `form/`, `tables/`, `browse/`eight self-contained HTML tools, each compiled to a single inlined HTML file in its own `dist/`. Most output `dist/tool.html`; **`landing/` outputs `dist/index.html`** (it's the project picker served at the root of `zddc-server`). `form/` is the schema-driven renderer for the form-data system (any `<name>.form.yaml` file in the tree becomes an editable form at `<path>/<name>.form.html`); `tables/` is its read/aggregate counterpart, rendering a directory of YAML rows as a sortable table; `browse/` is the file-tree navigator. See AGENTS.md "Form-data system" / "Tables system" and ARCHITECTURE.md "Form Renderer".
- `archive/`, `transmittal/`, `classifier/`, `landing/`, `form/`, `tables/`, `browse/`seven self-contained HTML tools, each compiled to a single inlined HTML file in its own `dist/`. Most output `dist/tool.html`; **`landing/` outputs `dist/index.html`** (it's the project picker served at the root of `zddc-server`). `form/` is the schema-driven renderer for the form-data system (any `<name>.form.yaml` file in the tree becomes an editable form at `<path>/<name>.form.html`); `tables/` is its read/aggregate counterpart, rendering a directory of YAML rows as a sortable table; `browse/` is the file-tree navigator and **also hosts the in-place markdown editor** (`browse/js/preview-markdown.js` — Toast UI Editor + YAML front-matter pane + on-demand server-side MD→DOCX/HTML/PDF download buttons). A dedicated `mdedit/` tool used to live alongside these but has been retired. See AGENTS.md "Form-data system" / "Tables system" and ARCHITECTURE.md "Form Renderer".
- `zddc/` — Go HTTP server (separate sub-project; Go 1.24+). Two deployment shapes from the same binary: (1) **master** — owns a file tree under `ZDDC_ROOT`, applies `.zddc` ACL cascades, serves files / app HTML / archive listings. Two auth paths on master: `Authorization: Bearer <token>` validated against self-issued tokens at `<ZDDC_ROOT>/.zddc.d/tokens/<sha256-hex>` for CLI/scripted callers, or `X-Auth-Request-Email` injected by an upstream proxy for browser sessions. Self-service token UI at `/.tokens` + JSON API at `/.api/tokens`. (2) **client** — when `--upstream <url>` is set, the binary becomes a downstream proxy/cache/mirror (`zddc/internal/cache/`); master-side machinery is bypassed and `--root` becomes the cache directory. Three sub-modes via `--mode proxy|cache|mirror` (mirror is phase 3). Cache layout is a normal ZDDC root, so the cache dir can be served as a plain master if you unset `--upstream`. Marker file `.zddc-upstream` records provenance. `--no-auth` skips ACL enforcement entirely on this instance (distinct from `--insecure` which only relaxes the no-root-`.zddc` startup check); `--skip-tls-verify` is a separate flag for self-signed upstream certs. Cross-compiled binaries are produced by `./build` and live in `dist/release-output/` (gitignored); `./deploy` rsyncs them to `/srv/zddc/releases/` on the deploy host (Caddy serves them at `https://zddc.varasys.io/releases/`). The `helm/` charts in this repo build from source at deploy time.
- `shared/``base.css` plus shared JS modules (`zddc.js`, `hash.js`, `zddc-filter.js`, `theme.js`, `help.js`) included by every tool's build, and `build-lib.sh` (POSIX sh helpers sourced by every tool's `build.sh` AND by the top-level `build` for lockstep release helpers).
- **Two-repo + deploy-host model.** Source code lives here (`codeberg.org/VARASYS/ZDDC`). Hand-edited website content lives in a separate repo (`codeberg.org/VARASYS/ZDDC-website`, typically cloned at `~/src/zddc-website/` — just `index.html`, `reference.html`, `css/`, `js/`, `img/`; no releases, no LFS). The live site at `zddc.varasys.io` is served from `/srv/zddc/` on the deploy host: Caddy bind-mounts that path, and it's populated by `./deploy` from this repo's `dist/release-output/` plus `~/src/zddc-website/`. **Releases are NOT in any git history** — they're reproducible from this repo's `<tool>-vX.Y.Z` tags by checking out the tag and running `./build release X.Y.Z`. Per-version files (`<tool>_v<X.Y.Z>.html`) are immutable; partial-version pins (`<tool>_v<X.Y>.html`, `<tool>_v<X>.html`) and channel mirrors (`<tool>_{stable,beta,alpha}.html`) are symlinks; zddc-server has analogous `zddc-server_v<X.Y.Z>_<platform>` per-version binaries plus channel/partial-version symlinks plus `zddc-server_<X>.html` stub pages that fan out the four-platform download in one cell. **Install model:** local use is a download from `/releases/`. Server use is `zddc-server`, which has the current-stable build of all eight HTML tools baked in via `//go:embed` (compile-time default). Tools auto-served at folder-name-driven paths: `archive` everywhere, `classifier` in `Incoming`/`Working`/`Staging` subtrees, `mdedit` in `Working` subtrees, `transmittal` in `Staging` subtrees, `landing` only at root. Override via `.zddc apps:` cascade entry (channel/version/URL/path) — fetched once, cached at `<ZDDC_ROOT>/_app/`. Drop a real `.html` file at any path to override.
- `shared/`CSS (`base.css`, `fonts.css` + base64-inlined woff2 under `fonts/`, `nav.css`, `logo.css`, `toast.css`) plus shared JS modules (`zddc.js`, `hash.js`, `zddc-filter.js`, `zddc-source.js`, `zip-source.js`, `theme.js`, `toast.js`, `nav.js`, `logo.js`, `help.js`, `preview-lib.js`) and vendored libs (`vendor/`: jszip, xlsx, utif, docx-preview, toastui-editor) — each tool's `build.sh` concatenates the subset it needs. Also `build-lib.sh` (POSIX sh helpers sourced by every tool's `build.sh` AND by the top-level `build` for lockstep release helpers). See AGENTS.md "Shared modules" for the full inventory.
- **Two-repo + deploy-host model.** Source code lives here (`codeberg.org/VARASYS/ZDDC`). Hand-edited website content lives in a separate repo (`codeberg.org/VARASYS/ZDDC-website`, typically cloned at `~/src/zddc-website/` — just `index.html`, `reference.html`, `css/`, `js/`, `img/`; no releases, no LFS). The live site at `zddc.varasys.io` is served from `/srv/zddc/` on the deploy host: Caddy bind-mounts that path, and it's populated by `./deploy` from this repo's `dist/release-output/` plus `~/src/zddc-website/`. **Releases are NOT in any git history** — they're reproducible from this repo's `<tool>-vX.Y.Z` tags by checking out the tag and running `./build release X.Y.Z`. Per-version files (`<tool>_v<X.Y.Z>.html`) are immutable; partial-version pins (`<tool>_v<X.Y>.html`, `<tool>_v<X>.html`) and channel mirrors (`<tool>_{stable,beta,alpha}.html`) are symlinks; zddc-server has analogous `zddc-server_v<X.Y.Z>_<platform>` per-version binaries plus channel/partial-version symlinks plus `zddc-server_<X>.html` stub pages that fan out the four-platform download in one cell. **Install model:** local use is a download from `/releases/`. Server use is `zddc-server`, which has the current-stable build of all seven HTML tools baked in via `//go:embed` (compile-time default). **Which tool a directory URL serves is driven by the `.zddc` cascade, not hardcoded folder names** — a baked-in `defaults.zddc.yaml` (dump it: `zddc-server show-defaults`) declares, via a recursive `paths:` tree, per-folder `default_tool` (served at `<dir>``archive` under `archive/`, `transmittal` under `staging/`, `browse` under `working/`+`reviewing/` (hosts the markdown editor), `classifier` under `incoming/`, `tables` at `archive/<party>/mdl`, `landing` at root), `dir_tool` (served at `<dir>/`; defaults to `browse`), `available_tools`, plus the canonical-folder behaviour keys (`auto_own`, `worm:`, `virtual`, `drop_target`); operators override at any level. A `.zip` file is also a navigable directory (`GET …/Foo.zip/` → member listing; `…/Foo.zip/m.pdf` → that member); `GET /dir/?zip=1` streams an ACL-filtered zip of a subtree. Override the *tool source* via a `.zddc apps:` entry (channel/version/URL/path) — fetched once, cached at `<ZDDC_ROOT>/_app/`; or drop a real `.html` at any path. See AGENTS.md "URL handling"/"Install model" and ARCHITECTURE.md "Canonical folders, URL routing & the `.zddc` cascade".
- `helm/` — example Helm charts for zddc-server. Three flavors: `zddc-server-prod/` (production master), `zddc-server-dev/` (development master with OverlayFS isolation), `zddc-server-cache/` (downstream client running in proxy/cache/mirror mode against an upstream master, with bearer token from a Kubernetes Secret). All compile from source via init container. Operators copy `values.yaml.example` and customize. No secrets in repo — the cache chart references a separately-created Secret for the bearer token.
- `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)

View file

@ -15,13 +15,12 @@ The name "Zero Day Document Control" comes from the convention itself — adopt
| **Archive Browser** | Browse, search, and filter a project archive folder. Group by transmittal, export selections as ZIP. |
| **Transmittal Creator** | Self-contained HTML transmittal records with SHA-256 checksums and optional digital signatures. |
| **Document Classifier** | Spreadsheet-like bulk-renamer that copy/pastes with Excel and writes back to disk. |
| **Markdown Editor** | Browser-based markdown editor with YAML front matter, TOC, and direct local file access. |
| **Form Renderer** | Schema-driven `*.form.yaml` editor — every form spec auto-mounts an editable form at `<name>.form.html`. |
| **Tables** | Sortable, filterable, in-place-editable grid view over a directory of YAML rows; click a row → edit in the form renderer. Auto-mounts on any directory containing a `table.yaml`. |
| **Browse** | File-tree navigator with previews; the everywhere-available companion to the Archive Browser when you want plain folder navigation rather than tracking-number aggregation. |
| **Browse** | File-tree navigator with previews and an in-place markdown editor (YAML front matter, outline, server-side DOCX/HTML/PDF download); the everywhere-available companion to the Archive Browser when you want plain folder navigation rather than tracking-number aggregation. |
| **Landing** | The project picker served at the deployment root of a `zddc-server`. |
Each tool is published in three channels (stable, beta, alpha) as static files served from <https://zddc.varasys.io/releases/>. **Local use:** download a `.html` file from `releases/` and open it in a browser. **Server use:** run `zddc-server` — the current-stable build of every tool is baked into the binary at compile time, so a fresh deployment Just Works with zero config. Tools auto-appear at folder-name-driven paths (archive everywhere; classifier in `Incoming`/`Working`/`Staging`; mdedit in `Working`; transmittal in `Staging`). Override per-directory by writing an `apps:` entry in any `.zddc` file (channel/version/URL/path). URL overrides are fetched once and cached in `<ZDDC_ROOT>/_app/`; drop a real `.html` file at any path to override entirely.
Each tool is published in three channels (stable, beta, alpha) as static files served from <https://zddc.varasys.io/releases/>. **Local use:** download a `.html` file from `releases/` and open it in a browser. **Server use:** run `zddc-server` — the current-stable build of every tool is baked into the binary at compile time, so a fresh deployment Just Works with zero config. Which tool a directory URL serves is driven by the `.zddc` cascade: a baked-in `defaults.zddc.yaml` (dump it with `zddc-server show-defaults`) declares, per folder, `default_tool` (the no-slash form — archive under `archive/`, transmittal under `staging/`, browse under `working/`+`reviewing/` (browse hosts the in-place markdown editor), classifier under `incoming/`, tables at `archive/<party>/mdl`, landing at root) and `dir_tool` (the trailing-slash form; defaults to `browse`); operators override at any level. A `.zip` file is also a navigable directory (`GET …/Foo.zip/`), and `GET /dir/?zip=1` streams an ACL-filtered zip of a subtree. Override the *tool source* per-directory by writing an `apps:` entry in any `.zddc` file (channel/version/URL/path) — fetched once and cached in `<ZDDC_ROOT>/_app/` — or drop a real `.html` file at any path.
## File-naming convention

View file

@ -538,6 +538,16 @@ html, body {
background: var(--bg);
border: 1px solid var(--border);
}
.md-shell__download {
/* Slightly tighter than the Save button so a row of three doesn't
crowd the title. The base .btn styles still drive padding/color. */
font-variant-numeric: tabular-nums;
letter-spacing: 0.02em;
}
.md-shell__download[disabled] {
opacity: 0.55;
cursor: progress;
}
/* Editor host: a single grid cell with overflow:hidden so Toast UI's
internal scrollers handle the content. */
@ -623,34 +633,41 @@ html, body {
transition: background-color 0.3s ease;
}
/* ── Front matter list ──────────────────────────────────────────────────── */
.md-fm__empty {
/* ── Front matter editor ────────────────────────────────────────────────── */
.md-fm__body {
/* Body cell owns the textarea; sized by the sidebar's grid row. */
padding: 0;
display: block;
overflow: hidden;
}
.md-fm__textarea {
width: 100%;
height: 100%;
box-sizing: border-box;
margin: 0;
padding: 0.4rem 0.6rem;
border: 0;
background: transparent;
color: var(--text);
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Consolas, monospace);
font-size: 0.8rem;
line-height: 1.45;
resize: none;
outline: none;
white-space: pre;
overflow: auto;
tab-size: 2;
}
.md-fm__textarea::placeholder {
color: var(--text-muted);
font-style: italic;
font-size: 0.82rem;
margin: 0;
padding: 0.5rem 0.75rem;
}
.md-fm__list {
margin: 0;
padding: 0.3rem 0.75rem;
display: grid;
grid-template-columns: minmax(4.5rem, max-content) 1fr;
gap: 0.2rem 0.6rem;
font-size: 0.8rem;
.md-fm__textarea:focus {
background: var(--surface-2, rgba(0, 0, 0, 0.025));
}
.md-fm__list dt {
font-weight: 600;
.md-fm__textarea[readonly] {
color: var(--text-muted);
text-transform: lowercase;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.md-fm__list dd {
margin: 0;
color: var(--text);
overflow-wrap: anywhere;
cursor: not-allowed;
}
/* ── Sort control ────────────────────────────────────────────────────────── */

View file

@ -2,22 +2,31 @@
//
// Layout (CSS Grid):
// ┌─────────────────────────────────────────────────────────────────┐
// │ toolbar: Save | ● modified | status | source │
// ├────────────────────────────────────────┬────────────────────────┤
// │ │ Outline │
// │ │ • Heading 1 │
// │ Toast UI Editor │ • Subheading │
// │ (md / wysiwyg / preview) │ • Heading 2 │
// │ ├────────────────────────┤
// │ │ Front matter │
// │ │ title: Foo │
// │ │ revision: A │
// └────────────────────────────────────────┴────────────────────────┘
// │ info: name | dirty | status | source | DOCX HTML PDF | Save │
// ├────────────────────────┬────────────────────────────────────────┤
// │ YAML front matter │ │
// │ ┌──────────────────┐ │ │
// │ │ title: Foo │ │ Toast UI Editor │
// │ │ revision: A │ │ (md / wysiwyg / preview) │
// │ └──────────────────┘ │ │
// ├────────────────────────┤ │
// │ Outline │ │
// │ • Heading 1 │ │
// │ • Subheading │ │
// │ • Heading 2 │ │
// └────────────────────────┴────────────────────────────────────────┘
// Grid keeps every cell's size definite, which is what Toast UI needs
// to compute its inner scroll regions correctly. The previous nested-
// flexbox layout produced indeterminate heights and a fragile TOC
// pane width — grid fixes both.
//
// Front matter is edited in a dedicated <textarea> in the sidebar
// (always present — typing into the placeholder grows the envelope on
// save). On load the `---\n…\n---\n` envelope is stripped from the
// bytes fed to Toast UI; on save the textarea content is re-stitched
// on top of the editor body. Keeps YAML out of the rich editor where
// users can't reliably edit it.
//
// Save (Ctrl+S) writes back via PUT (server mode) or
// FileSystemWritableFileStream (FS-API). Zip-virtual files are
// read-only — Save stays disabled. Toast UI is vendored
@ -93,25 +102,37 @@
return { data: data, body: body };
}
function renderFrontMatter(fmEl, content) {
if (!fmEl) return;
var parsed = parseFrontMatter(content);
var keys = Object.keys(parsed.data);
if (keys.length === 0) {
fmEl.innerHTML = '<p class="md-fm__empty">No front matter.</p>';
return;
}
var html = '<dl class="md-fm__list">';
// Inverse of parseFrontMatter — turn a {key: value | array} object back
// into newline-separated YAML lines suitable for the textarea. Arrays
// are quoted to match what the parser will round-trip through. Returns
// "" when there are no keys (so the textarea shows its placeholder).
function stringifyFrontMatter(data) {
if (!data) return '';
var keys = Object.keys(data);
if (keys.length === 0) return '';
var out = [];
for (var i = 0; i < keys.length; i++) {
var k = keys[i];
var v = parsed.data[k];
var displayV = Array.isArray(v)
? v.map(escapeHtml).join(', ')
: escapeHtml(String(v));
html += '<dt>' + escapeHtml(k) + '</dt><dd>' + displayV + '</dd>';
var v = data[k];
if (Array.isArray(v)) {
out.push(k + ': [' + v.map(function (x) {
return '"' + String(x).replace(/"/g, '\\"') + '"';
}).join(', ') + ']');
} else {
out.push(k + ': ' + String(v));
}
}
html += '</dl>';
fmEl.innerHTML = html;
return out.join('\n');
}
// Stitch the textarea's YAML lines and the editor's body back together
// into the on-disk envelope. Empty textarea → return body unchanged
// (no envelope written). Trailing whitespace in the textarea is
// tolerated.
function assembleContent(fmText, body) {
var fm = (fmText || '').replace(/\s+$/, '');
if (!fm) return body || '';
return '---\n' + fm + '\n---\n' + (body || '');
}
// ── TOC (table of contents) ────────────────────────────────────────────
@ -337,6 +358,13 @@
fmHeader.textContent = 'YAML front matter';
var fmBody = document.createElement('div');
fmBody.className = 'md-side__body md-fm__body';
var fmTextarea = document.createElement('textarea');
fmTextarea.className = 'md-fm__textarea';
fmTextarea.spellcheck = false;
fmTextarea.autocapitalize = 'off';
fmTextarea.autocomplete = 'off';
fmTextarea.placeholder = 'title: Document Title\ndate: 2026-05-13\ntags: [example]';
fmBody.appendChild(fmTextarea);
fmSection.appendChild(fmHeader);
fmSection.appendChild(fmBody);
sidebar.appendChild(fmSection);
@ -408,10 +436,35 @@
sourceEl.textContent = 'server';
}
// Download-as-{docx,html,pdf} buttons. Server-mode + .md only:
// the server endpoint runs pandoc/chromium in a container and
// returns the converted bytes. Click handlers wire up below
// (after save() is defined) because they auto-save first when
// the buffer is dirty.
var serverModeMd = window.app && window.app.state &&
window.app.state.source === 'server' &&
node.url && /\.md$/i.test(node.name);
var convertBtns = [];
if (serverModeMd && window.zddc && window.zddc.source &&
typeof window.zddc.source.downloadConverted === 'function') {
['docx', 'html', 'pdf'].forEach(function (fmt) {
var btn = document.createElement('button');
btn.className = 'btn btn-sm btn-secondary md-shell__download';
btn.type = 'button';
btn.textContent = fmt.toUpperCase();
btn.title = 'Download as ' + fmt.toUpperCase();
btn.dataset.fmt = fmt;
convertBtns.push(btn);
});
}
infohdr.appendChild(titleEl);
infohdr.appendChild(dirtyEl);
infohdr.appendChild(statusEl);
infohdr.appendChild(sourceEl);
for (var ci = 0; ci < convertBtns.length; ci++) {
infohdr.appendChild(convertBtns[ci]);
}
infohdr.appendChild(saveBtn);
content.appendChild(infohdr);
@ -420,15 +473,21 @@
editorHost.className = 'md-shell__editor';
content.appendChild(editorHost);
// Construct the editor. height: 100% works because editorHost
// is a grid cell with a definite size.
var initialHash = await hashContent(text);
// Split the loaded bytes into FM (textarea) + body (editor). The
// hash that gates dirty-state is taken over the reassembled
// bytes so that round-tripping a clean file shows "not dirty"
// even if we tweak whitespace in the YAML lines.
var initialParsed = parseFrontMatter(text);
fmTextarea.value = stringifyFrontMatter(initialParsed.data);
var bodyText = initialParsed.body;
var initialHash = await hashContent(assembleContent(fmTextarea.value, bodyText));
var editor = new window.toastui.Editor({
el: editorHost,
height: '100%',
initialEditType: 'markdown',
previewStyle: 'vertical',
initialValue: text,
initialValue: bodyText,
usageStatistics: false,
toolbarItems: [
['heading', 'bold', 'italic', 'strike'],
@ -446,17 +505,17 @@
node: node,
hash: initialHash,
tocEl: tocBody,
fmEl: fmBody
fmEl: fmTextarea
};
var writable = canSave(node);
if (!writable) {
saveBtn.disabled = true;
saveBtn.title = 'Save not available — read-only source.';
fmTextarea.readOnly = true;
}
renderToc(tocBody, text, editor);
renderFrontMatter(fmBody, text);
renderToc(tocBody, bodyText, editor);
// ── Sidebar/content resizer ─────────────────────────────────────────
// Sidebar is on the LEFT now. Dragging right grows the
@ -552,18 +611,24 @@
}
var onChange = debounce(async function () {
var current = editor.getMarkdown();
var h = await hashContent(current);
var body = editor.getMarkdown();
var h = await hashContent(assembleContent(fmTextarea.value, body));
markDirty(h !== currentInstance.hash);
renderToc(tocBody, current, editor);
renderFrontMatter(fmBody, current);
renderToc(tocBody, body, editor);
}, 250);
editor.on('change', onChange);
var onFmChange = debounce(async function () {
var body = editor.getMarkdown();
var h = await hashContent(assembleContent(fmTextarea.value, body));
markDirty(h !== currentInstance.hash);
}, 250);
fmTextarea.addEventListener('input', onFmChange);
// ── Save ───────────────────────────────────────────────────────────
async function save() {
if (!currentInstance.dirty || !writable) return;
var content = editor.getMarkdown();
var content = assembleContent(fmTextarea.value, editor.getMarkdown());
try {
statusEl.textContent = 'Saving…';
await saveContent(node, content);
@ -587,6 +652,42 @@
save();
}
});
// Download-as-* click handlers. Auto-save when the buffer is
// dirty so the converted file reflects what's on screen. If
// the save fails the existing toast/status surfaces it; we
// bail without firing the conversion.
convertBtns.forEach(function (btn) {
btn.addEventListener('click', async function () {
var fmt = btn.dataset.fmt;
if (currentInstance.dirty) {
if (!writable) {
if (window.zddc && window.zddc.toast) {
window.zddc.toast(
'This source is read-only — save a copy elsewhere first.',
'error');
}
return;
}
btn.disabled = true;
try { await save(); } finally { btn.disabled = false; }
if (currentInstance.dirty) return; // save failed
}
btn.disabled = true;
try {
statusEl.textContent = 'Converting to ' + fmt.toUpperCase() + '…';
await window.zddc.source.downloadConverted(node.url, node.name, fmt);
statusEl.textContent = 'Downloaded ' + fmt.toUpperCase();
} catch (e) {
statusEl.textContent = (e && e.message) || String(e);
if (window.zddc && window.zddc.toast) {
window.zddc.toast((e && e.message) || String(e), 'error');
}
} finally {
btn.disabled = false;
}
});
});
}
window.app.modules.markdown = {

30
build
View file

@ -154,7 +154,6 @@ export BUILD_LABELS_DIR
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
sh "$SCRIPT_DIR/form/build.sh" $TOOL_RELEASE_ARGS
sh "$SCRIPT_DIR/tables/build.sh" $TOOL_RELEASE_ARGS
@ -162,27 +161,27 @@ sh "$SCRIPT_DIR/browse/build.sh" $TOOL_RELEASE_ARGS
echo ""
echo "=== Assembling zddc/dist/web/ ==="
# Six tool HTMLs ship inside the server bundle. landing and archive call
# Tool HTMLs ship inside the server bundle. landing and archive call
# server APIs (GET / for the project list, directory listings for archive) and
# are useless without zddc-server. transmittal, classifier, and mdedit are
# pure client-side tools but are still bundled — the server uses these copies
# are useless without zddc-server. transmittal and classifier are pure
# client-side tools but are still bundled — the server uses these copies
# as the embedded fallback (//go:embed in internal/apps/embedded/) when both
# the cache is empty AND the upstream is unreachable. form is the schema-
# driven form renderer used by the form-data system; it's embedded into the
# handler package directly (not the apps cascade) since it isn't subject to
# per-folder version overrides.
# per-folder version overrides. browse hosts the in-place markdown editor
# (no separate mdedit tool — retired in favor of browse's preview plugin).
mkdir -p "$SCRIPT_DIR/zddc/dist/web"
cp "$SCRIPT_DIR/landing/dist/index.html" "$SCRIPT_DIR/zddc/dist/web/index.html"
cp "$SCRIPT_DIR/archive/dist/archive.html" "$SCRIPT_DIR/zddc/dist/web/archive.html"
cp "$SCRIPT_DIR/transmittal/dist/transmittal.html" "$SCRIPT_DIR/zddc/dist/web/transmittal.html"
cp "$SCRIPT_DIR/classifier/dist/classifier.html" "$SCRIPT_DIR/zddc/dist/web/classifier.html"
cp "$SCRIPT_DIR/mdedit/dist/mdedit.html" "$SCRIPT_DIR/zddc/dist/web/mdedit.html"
cp "$SCRIPT_DIR/form/dist/form.html" "$SCRIPT_DIR/zddc/dist/web/form.html"
cp "$SCRIPT_DIR/tables/dist/tables.html" "$SCRIPT_DIR/zddc/dist/web/tables.html"
cp "$SCRIPT_DIR/browse/dist/browse.html" "$SCRIPT_DIR/zddc/dist/web/browse.html"
echo "Wrote zddc/dist/web/{index,archive,transmittal,classifier,mdedit,form,tables,browse}.html"
echo "Wrote zddc/dist/web/{index,archive,transmittal,classifier,form,tables,browse}.html"
# Mirror the five cascade-served HTMLs into the apps embed source dir so the
# Mirror the cascade-served HTMLs into the apps embed source dir so the
# next `go build` of zddc-server picks them up via //go:embed. ONLY happens
# on a beta or stable cut — that's the project invariant: alpha labels are
# never baked into the binary, beta labels go to the dev image (which builds
@ -196,7 +195,6 @@ if [ "$RELEASE_CHANNEL" = "beta" ] || [ "$RELEASE_CHANNEL" = "stable" ]; then
cp "$SCRIPT_DIR/archive/dist/archive.html" "$EMBED_DIR/archive.html"
cp "$SCRIPT_DIR/transmittal/dist/transmittal.html" "$EMBED_DIR/transmittal.html"
cp "$SCRIPT_DIR/classifier/dist/classifier.html" "$EMBED_DIR/classifier.html"
cp "$SCRIPT_DIR/mdedit/dist/mdedit.html" "$EMBED_DIR/mdedit.html"
cp "$SCRIPT_DIR/browse/dist/browse.html" "$EMBED_DIR/browse.html"
echo "Populated $EMBED_DIR/ for //go:embed"
fi
@ -221,7 +219,7 @@ if [ "$RELEASE_CHANNEL" = "beta" ] || [ "$RELEASE_CHANNEL" = "stable" ]; then
VERSIONS_FILE="$EMBED_DIR/versions.txt"
{
echo "# Generated by build.sh — do not edit. One <app>=<build label> per line."
for _tool in archive transmittal classifier mdedit landing form tables browse; do
for _tool in archive transmittal classifier landing form tables browse; do
_label_file="$BUILD_LABELS_DIR/${_tool}.label"
if [ -f "$_label_file" ]; then
_label=$(cat "$_label_file")
@ -436,7 +434,7 @@ build_releases_index() {
_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 'classifier_v*.html' -o -name 'browse_v*.html' \
-o -name 'landing_v*.html' \
-o -name 'zddc-server_v*_linux-amd64' \
\) 2>/dev/null \
@ -618,7 +616,7 @@ PATH_B_OPEN
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." \
"browse|File Browser|Browse the project tree; includes the markdown editor." \
"landing|Landing|Project picker for multi-project servers."; do
_t="${_entry%%|*}"
_rest="${_entry#*|}"
@ -685,8 +683,8 @@ PIN_MID
<select class="composer-select" data-app="classifier" style="min-width: 140px;"></select>
</label>
<label class="composer-row" style="display: flex; align-items: center; justify-content: space-between; gap: var(--spacing-sm);">
<span style="flex: 1;"><code class="inline">mdedit</code> <span style="color: var(--color-text-muted); font-size: 0.85rem;">— Markdown Editor</span></span>
<select class="composer-select" data-app="mdedit" style="min-width: 140px;"></select>
<span style="flex: 1;"><code class="inline">browse</code> <span style="color: var(--color-text-muted); font-size: 0.85rem;">— File Browser (with markdown editor)</span></span>
<select class="composer-select" data-app="browse" style="min-width: 140px;"></select>
</label>
<label class="composer-row" style="display: flex; align-items: center; justify-content: space-between; gap: var(--spacing-sm);">
<span style="flex: 1;"><code class="inline">landing</code> <span style="color: var(--color-text-muted); font-size: 0.85rem;">— Landing</span></span>
@ -1012,7 +1010,7 @@ if [ "$RELEASE_CHANNEL" = "stable" ]; then
# Tag the nine artifacts at HEAD. Pre-flight already validated that
# any pre-existing tag is in HEAD's history, so this is safe.
_head=$(git -C "$SCRIPT_DIR" rev-parse HEAD)
for _t in archive transmittal classifier mdedit landing form tables browse zddc-server; do
for _t in archive transmittal classifier landing form tables browse zddc-server; do
_tag="${_t}-v${RELEASE_VERSION}"
if git -C "$SCRIPT_DIR" rev-parse -q --verify "refs/tags/$_tag" >/dev/null; then
_existing=$(git -C "$SCRIPT_DIR" rev-list -n 1 "$_tag")
@ -1046,7 +1044,7 @@ else
echo "Version: v$RELEASE_VERSION"
echo ""
echo "Tags created locally on main (push when ready):"
for _t in archive transmittal classifier mdedit landing form tables browse zddc-server; do
for _t in archive transmittal classifier landing form tables browse zddc-server; do
echo " ${_t}-v${RELEASE_VERSION}"
done
echo " git push origin main && git push origin --tags"

View file

@ -6,7 +6,7 @@
#
# Usage:
# ./freshen-channel <tool> <channel>
# tool archive | transmittal | classifier | mdedit | landing
# tool archive | transmittal | classifier | browse | landing | form | tables
# channel alpha | beta
#
# Why this exists:
@ -41,10 +41,10 @@ TOOL="${1:-}"
CHANNEL="${2:-}"
case "$TOOL" in
archive | transmittal | classifier | mdedit | landing) ;;
archive | transmittal | classifier | browse | landing | form | tables) ;;
*)
echo "usage: $0 <tool> <channel>" >&2
echo " tool: archive | transmittal | classifier | mdedit | landing" >&2
echo " tool: archive | transmittal | classifier | browse | landing | form | tables" >&2
exit 1
;;
esac

View file

@ -650,9 +650,9 @@
// Render the project-workspace view: title, four stage links, MDL
// section. Stage hrefs use the no-trailing-slash form so the server
// routes them to each canonical default tool (mdedit for working/,
// transmittal for staging/, etc.). Browse-all and the archive deep
// link use the slash form to land on the directory listing.
// routes them to each canonical default tool (browse for working/+
// reviewing/, transmittal for staging/, etc.). Browse-all and the
// archive deep link use the slash form to land on the directory listing.
async function renderProjectMode() {
var project = projectFromPath();
if (!project) return;

View file

@ -1,131 +0,0 @@
# ZDDC Markdown Editor
[← Back to ZDDC](../README.md)
A lightweight, browser-based markdown editor with YAML front matter support.
**[🔗 Open Markdown Editor](dist/mdedit.html)** - Click to use online, or right-click → "Save Link As" to keep your own copy.
## Reliability
This tool follows the "record player with the record" philosophy - the application and your data travel together. The single HTML file contains everything needed to edit markdown files locally in your browser.
## Quick Start
1. Open the editor in your browser
2. Click **Add Local Directory** to choose a folder with markdown files
3. Navigate the file tree on the left
4. Click any `.md` file to edit it
5. Click **Save File** or **Save All** to save changes
## Features
### 📂 File Navigation
- Browse directories using the File System Access API
- Collapsible folder tree with file type icons
- Files sorted alphabetically with directories grouped
### ✏️ Markdown Editing
- Toast UI Editor with live preview
- Split view (markdown + preview)
- Full toolbar for formatting
### 📋 YAML Front Matter
- Separate front matter section at top of editor
- Auto-parsed and preserved on save
- Collapsible for more editing space
### 📑 Table of Contents
- Auto-generated from headings
- Adjustable depth (H1 only through H6)
- Click to jump to heading in preview
### 💾 File Operations
- Save individual files or Save All
- Reload from disk (discards unsaved changes)
- External change detection with reload prompt
- Unsaved change warnings before leaving
### 🖼️ File Previews
- Image preview for common formats
- HTML preview in sandboxed iframe
- Plain text editing for non-markdown files
## Build
The editor is built from modular source files using a bash script:
```bash
cd mdedit
./build.sh
```
This concatenates CSS and JS files into `dist/mdedit.html`.
## Project Structure
```
mdedit/
├── css/
│ ├── base.css # Core styles and layout
│ ├── editor.css # Toast UI Editor overrides
│ ├── toc.css # Table of Contents styles
│ └── markdown.css # Markdown rendering styles
├── js/
│ ├── app.js # Global state
│ ├── utils.js # Utility functions
│ ├── front-matter.js # YAML parsing
│ ├── file-system.js # File operations
│ ├── file-tree.js # Tree rendering
│ ├── editor.js # Toast UI setup
│ ├── toc.js # TOC generation
│ ├── resizer.js # Pane resizing
│ ├── events.js # Event listeners
│ └── main.js # Initialization
├── vendor/
│ ├── toastui-editor-all.min.js # Toast UI Editor JS (bundled)
│ └── toastui-editor.min.css # Toast UI Editor CSS (bundled)
├── template.html # HTML structure (uses CDN for local dev convenience)
├── build.sh # Build script (inlines vendor files, strips CDN refs)
└── dist/
└── mdedit.html # Built self-contained file
```
## Technical Details
- **No server required** - runs entirely in browser
- **File System Access API** - direct local file access
- **Toast UI Editor v3.2.2** - bundled from `vendor/` into the built output (no CDN required)
- **Tailwind CSS** - replaced at build time by `css/tailwind-utils.css`, a hand-written static subset containing only the ~80 utility classes actually used in `template.html` (no runtime overhead, no console warnings)
- **Fully self-contained** - `dist/mdedit.html` (~850 KB) works offline with no external dependencies
> **Development note**: `template.html` loads Toast UI and Tailwind from CDN for a faster local development
> experience (open `template.html` directly in a browser). The `build.sh` script replaces the Tailwind CDN
> `<script>` tag with nothing (utilities come from `css/tailwind-utils.css` instead) and replaces the Toast UI
> CDN tags with the locally bundled `vendor/` files when producing `dist/mdedit.html`.
### Modules
CSS and JS modules live under `css/` and `js/`. The canonical load order is in `build.sh`. See the root `ARCHITECTURE.md` for the build/module pattern and `AGENTS.md` for shared helpers.
mdedit-specific notes:
- `css/tailwind-utils.css` is a hand-curated static subset of Tailwind v3 — there is no Tailwind build step. Add a class here when adding it to `template.html`.
- Toast UI Editor v3.2.2 ships pre-bundled in `vendor/`. `template.html` loads it from CDN for dev convenience; `build.sh` swaps the CDN tag for the bundled file.
- File operations (create, rename, delete) live in `js/file-ops.js`.
### Build Process
The build script (`build.sh`):
1. Concatenates all local CSS and JS files in dependency order
2. **Replaces** the CDN `<script>`/`<link>` tags for Tailwind and Toast UI with the locally bundled files from `vendor/`
3. Injects everything into `template.html` to produce `dist/mdedit.html`
The final HTML file (~850 KB) is fully self-contained and works offline.
### Architecture Notes
- All local CSS/JS files are inlined into the output HTML
- Vendor dependencies (Toast UI, Tailwind) are bundled from `vendor/` — no runtime CDN access
- `template.html` loads dependencies from CDN for convenient local development, but `build.sh` replaces these
- No npm dependencies required at runtime
- File System Access API requires Chromium-based browsers

View file

@ -1,146 +0,0 @@
#!/bin/sh
set -eu
root_dir=$(cd "$(dirname "$0")" && pwd)
. "$root_dir/../shared/build-lib.sh"
src_html="$root_dir/template.html"
output_dir="$root_dir/dist"
output_html="$output_dir/mdedit.html"
# Vendor files (bundled dependencies — no CDN required at runtime)
# Note: Tailwind is NOT a vendor file — it's replaced by css/tailwind-utils.css,
# a hand-written subset of only the utility classes used in template.html.
toastui_js="$root_dir/../shared/vendor/toastui-editor-all.min.js"
toastui_css="$root_dir/../shared/vendor/toastui-editor.min.css"
mkdir -p "$output_dir"
ensure_exists "$src_html"
ensure_exists "$toastui_js"
ensure_exists "$toastui_css"
css_temp=$(mktemp)
js_raw=$(mktemp)
js_temp=$(mktemp)
toastui_js_safe=$(mktemp)
cleanup() { rm -f "$css_temp" "$js_raw" "$js_temp" "$toastui_js_safe"; }
trap cleanup EXIT
# CSS files to concatenate in order
concat_files \
"css/tailwind-utils.css" \
"../shared/fonts.css" \
"../shared/base.css" \
"../shared/toast.css" \
"../shared/nav.css" \
"../shared/logo.css" \
"css/base.css" \
"css/editor.css" \
"css/toc.css" \
"css/markdown.css" \
> "$css_temp"
# JavaScript files to concatenate in order
concat_files \
"../shared/vendor/jszip.min.js" \
"../shared/vendor/docx-preview.min.js" \
"../shared/vendor/xlsx.full.min.js" \
"../shared/vendor/utif.min.js" \
"../shared/zddc.js" \
"../shared/zddc-source.js" \
"../shared/theme.js" \
"../shared/toast.js" \
"../shared/nav.js" \
"../shared/logo.js" \
"../shared/preview-lib.js" \
"js/app.js" \
"js/utils.js" \
"js/front-matter.js" \
"js/file-ops.js" \
"js/file-system.js" \
"js/file-tree.js" \
"js/editor.js" \
"js/toc.js" \
"js/resizer.js" \
"js/events.js" \
"js/main.js" \
"../shared/help.js" \
> "$js_raw"
# Escape '</' in app JS and the Toast UI vendor JS so neither can prematurely
# close the inline <script> blocks they get embedded in.
escape_js_close_tags "$js_raw" "$js_temp"
escape_js_close_tags "$toastui_js" "$toastui_js_safe"
compute_build_label "mdedit" "${1:-}" "${2:-}"
# Process template:
# - Strip the Tailwind CDN <script> tag (css/tailwind-utils.css replaces it)
# - Replace CDN <link> for Toast UI CSS with inline bundled CSS
# - Replace CDN <script src="...toastui..."> with inline bundled Toast UI JS
# - Inject custom CSS/JS at {{CSS_PLACEHOLDER}} and {{JS_PLACEHOLDER}}
# - Substitute {{BUILD_LABEL}}
awk \
-v css_file="$css_temp" \
-v js_file="$js_temp" \
-v toastui_js="$toastui_js_safe" \
-v toastui_css="$toastui_css" \
-v build_label="$build_label" \
-v is_red="$is_red" \
-v favicon_uri="$favicon_data_uri" \
'
/\{\{CSS_PLACEHOLDER\}\}/ {
while ((getline line < css_file) > 0) print line
close(css_file)
next
}
/\{\{JS_PLACEHOLDER\}\}/ {
while ((getline line < js_file) > 0) print line
close(js_file)
next
}
/\{\{BUILD_LABEL\}\}/ {
if (is_red == "1") {
gsub(/\{\{BUILD_LABEL\}\}/, "<span style=\"color:red;font-weight:bold\">" build_label "</span>")
} else {
gsub(/\{\{BUILD_LABEL\}\}/, build_label)
}
print
next
}
/\{\{FAVICON\}\}/ {
gsub(/\{\{FAVICON\}\}/, favicon_uri)
print
next
}
/<script src="https:\/\/cdn\.tailwindcss\.com"/ {
# Stripped: Tailwind utility classes are in css/tailwind-utils.css instead
next
}
/<link rel="stylesheet" href="https:\/\/uicdn\.toast\.com\/editor\/[^"]*\/toastui-editor\.min\.css"/ {
# Inline the bundled Toast UI CSS
print "<style>"
while ((getline line < toastui_css) > 0) print line
close(toastui_css)
print "</style>"
next
}
/<script src="https:\/\/uicdn\.toast\.com\/editor\/[^"]*\/toastui-editor/ {
# Inline the bundled Toast UI JS (already passed through escape_js_close_tags
# so its content cannot contain a literal </script> sequence). We close with
# the real </script> because only that exact string terminates a script
# block per the HTML5 spec.
print "<script>"
while ((getline line < toastui_js) > 0) print line
close(toastui_js)
print "</script>"
next
}
{ print }
' "$src_html" > "$output_html"
echo "Wrote $output_html ($(wc -c < "$output_html") bytes)"
if [ "$is_release" = "1" ]; then
promote_release "mdedit"
fi

View file

@ -1,405 +0,0 @@
/* mdedit component styles — reset and tokens from shared/base.css */
/* Pane resizer */
.pane-resizer:hover {
background-color: var(--primary) !important;
}
/* File tree */
.file-tree {
font-size: 0.9rem;
}
.directory-item,
.file-item {
transition: background-color 0.15s ease;
}
.dir-icon {
display: inline-flex;
align-items: center;
justify-content: center;
transform: rotate(90deg);
transition: transform 0.2s ease;
vertical-align: middle;
}
.dir-icon svg {
width: 12px;
height: 12px;
stroke: currentColor;
stroke-width: 2;
}
.directory-item.collapsed .dir-icon {
transform: rotate(0deg);
}
/* Two-line filename styles */
.filename-main {
font-size: 0.9rem;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.filename-secondary {
font-size: 0.75rem;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Active file highlighting */
.active-file {
background-color: var(--primary) !important;
color: var(--text-light) !important;
font-weight: 500;
}
.active-file * {
color: var(--text-light) !important;
}
/* ── File Tree Action Buttons ──────────────────────────────────────────────── */
.tree-actions {
display: flex;
gap: 0.25rem;
align-items: center;
margin-left: auto;
opacity: 0;
transition: opacity 0.2s ease;
}
.directory-item:hover .tree-actions,
.file-item:hover .tree-actions,
.active-file .tree-actions {
opacity: 1;
}
/* Always-visible action buttons (e.g. scratchpad download) */
.tree-actions--always { opacity: 1; }
.tree-btn:disabled,
.tree-btn.is-disabled {
opacity: 0.35;
cursor: not-allowed;
}
.tree-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
padding: 0;
border: none;
background-color: transparent;
color: var(--text-muted);
cursor: pointer;
border-radius: 0.25rem;
transition: background-color 0.15s ease, color 0.15s ease;
}
.tree-btn:hover {
background-color: var(--bg-secondary);
color: var(--text);
}
.tree-btn--danger:hover {
background-color: #fee2e2;
color: #dc2626;
}
[data-theme="dark"] .tree-btn--danger:hover {
background-color: rgba(127, 29, 29, 0.5);
color: #fca5a5;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .tree-btn--danger:hover {
background-color: rgba(127, 29, 29, 0.5);
color: #fca5a5;
}
}
.tree-btn svg {
width: 1rem;
height: 1rem;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
/* Directory toggle indicator */
.directory-item {
position: relative;
}
.directory-item.collapsed .directory-contents {
display: none;
}
/* File view container */
.file-view-container {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
}
/* File header */
.file-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 1rem;
background-color: var(--bg-secondary);
border-bottom: 1px solid var(--border);
}
.file-title {
font-size: 1.125rem;
font-weight: 500;
color: var(--text);
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* File content area */
.file-content-area {
display: flex;
flex: 1;
overflow: hidden;
}
/* Content container */
#content-container {
flex-direction: column;
height: 100%;
}
/* Image preview */
.image-preview-container {
display: flex;
align-items: center;
justify-content: center;
background-color: var(--bg-secondary);
}
.image-preview {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
/* HTML preview iframe */
.html-preview-container {
width: 100%;
height: 100%;
}
.html-preview-iframe {
width: 100%;
height: 100%;
border: 0;
}
/* Dirty indicator */
.dirty-indicator {
margin-left: 0.25rem;
color: var(--warning);
font-weight: bold;
}
.is-dirty {
font-style: italic;
}
/* ── Tailwind class overrides: use CSS tokens instead of hardcoded colours ── */
/* bg-white / bg-gray-100 are used on the pane backgrounds in template.html. */
/* Override them here so they follow the design-token system (light + dark). */
.bg-white { background-color: var(--bg) !important; }
.bg-gray-100 { background-color: var(--bg-secondary) !important; }
/* ── Section headers (YAML front matter, TOC, etc.) ───────────────────────── */
/* Shared style for all collapsible/section headers inside the side pane
keeps font, padding, weight identical to the file-tree pane header. */
.pane-section-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background-color: var(--bg-secondary);
color: var(--text);
border-bottom: 1px solid var(--border);
font-size: 0.9rem;
font-weight: 500;
user-select: none;
}
.pane-section-header .toggle-icon {
font-size: 0.75rem;
color: var(--text-muted);
width: 0.75rem;
text-align: center;
}
/* ── Front matter section ──────────────────────────────────────────────────── */
.front-matter-nav {
display: flex;
flex-direction: column;
overflow: hidden;
flex-shrink: 0;
background-color: var(--bg);
}
.front-matter-header:hover {
background-color: var(--bg-hover);
}
.front-matter-content {
flex: 1;
overflow: auto;
min-height: 0;
}
/* When collapsed, hide content; height shrinks to header */
.front-matter-nav.collapsed {
height: auto !important;
flex-shrink: 0;
}
.front-matter-nav.collapsed .front-matter-content {
display: none;
}
/* Front matter textarea fills the content area */
.front-matter-textarea {
color: var(--text);
background-color: var(--bg);
border: none;
resize: none;
font-family: var(--font-mono);
font-size: 0.8rem;
white-space: pre;
overflow: auto;
width: 100%;
height: 100%;
padding: 0.5rem 1rem;
box-sizing: border-box;
display: block;
}
.front-matter-textarea:focus {
outline: none;
}
/* ── Horizontal pane resizer (height split) ─────────────────────────────── */
.pane-resizer.horizontal {
height: 4px;
width: 100%;
cursor: row-resize;
background-color: var(--border);
flex-shrink: 0;
transition: background-color 0.15s ease;
}
.pane-resizer.horizontal:hover,
.pane-resizer.horizontal.active {
background-color: var(--primary);
}
/* ── Hidden utility (for disabled buttons) ─────────────────────────────────── */
.hide { display: none; }
/* ── File tree row layout ───────────────────────────────────────────────────── */
.tree-row {
display: flex;
align-items: center;
min-width: 0;
}
.tree-row__label {
flex: 1;
min-width: 0;
overflow: hidden;
display: flex;
align-items: center;
gap: 0.25rem;
}
/* The text wrapper inside a tree-row label. For ZDDC-conforming files and
folders, this wraps two stacked <div>s (filename-main + filename-secondary)
so the row reads top-to-bottom as title + metadata same shape the archive
tool uses for its transmittal-folder list. For non-ZDDC entries it just
contains a single line. flex column makes the two-line case work; min-width:0
lets each line truncate independently. */
.tree-row__name {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
line-height: 1.25;
}
/* ── New-file modal ─────────────────────────────────────────────────────────── */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-overlay.hidden { display: none; }
.modal-box {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 0.5rem;
padding: 1.5rem;
min-width: 20rem;
display: flex;
flex-direction: column;
gap: 1rem;
box-shadow: 0 4px 24px rgba(0,0,0,0.18);
}
.modal-title {
font-size: 1rem;
font-weight: 600;
color: var(--text);
margin: 0;
}
.modal-input {
width: 100%;
padding: 0.4rem 0.6rem;
border: 1px solid var(--border);
border-radius: 0.25rem;
font-size: 0.9rem;
color: var(--text);
background: var(--bg);
box-sizing: border-box;
}
.modal-input:focus {
outline: 2px solid var(--primary);
outline-offset: 1px;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
/* File-nav pane: initial width + minimum size. Runtime resizer (resizer.js)
overrides via inline style.width when the user drags; the min-width here
is a defensive backstop. */
#file-nav {
width: 450px;
min-width: 200px;
}

View file

@ -1,119 +0,0 @@
/* Toast UI Editor styles */
#markdown-editor {
display: block !important;
height: 100% !important;
min-height: 500px !important;
width: 100% !important;
position: relative !important;
z-index: 10;
}
.editor-instance {
height: 100% !important;
min-height: 500px !important;
}
.toastui-editor-defaultUI {
height: 100% !important;
}
.toastui-editor-defaultUI-toolbar,
.toastui-editor-main,
.toastui-editor-main .ProseMirror,
.toastui-editor-main .toastui-editor-md-preview {
height: 100% !important;
}
/* Toast UI Editor dark-theme overrides
Toast UI ships with light-mode chrome and edit surfaces by default. In
mdedit's dark mode the editor's text (#222) falls onto the transparent
md-container, which inherits var(--bg) dark = #1e1e1e effectively
black-on-black. Override the load-bearing surfaces with mdedit's tokens
so the editor harmonises with the rest of the chrome.
The selectors target both manual override (data-theme="dark") and the
OS-pref auto fallback (prefers-color-scheme + no data-theme="light"). */
/* Manual dark override */
[data-theme="dark"] .toastui-editor-defaultUI,
[data-theme="dark"] .toastui-editor-md-container,
[data-theme="dark"] .toastui-editor-md-preview,
[data-theme="dark"] .toastui-editor-ww-container,
[data-theme="dark"] .toastui-editor-mode-switch,
[data-theme="dark"] .toastui-editor-main,
[data-theme="dark"] .ProseMirror {
background-color: var(--bg);
color: var(--text);
}
[data-theme="dark"] .toastui-editor-defaultUI-toolbar {
background-color: var(--bg-secondary);
border-bottom-color: var(--border);
}
[data-theme="dark"] .toastui-editor-md-splitter {
background-color: var(--border);
}
[data-theme="dark"] .toastui-editor-toolbar-icons {
/* Toast UI's icons are sprite-baked dark; invert flips them to light. */
filter: invert(0.85) hue-rotate(180deg);
}
[data-theme="dark"] .toastui-editor-toolbar-divider {
background-color: var(--border);
}
[data-theme="dark"] .toastui-editor-mode-switch {
border-top-color: var(--border);
}
[data-theme="dark"] .toastui-editor-mode-switch .tab-item {
color: var(--text-muted);
}
[data-theme="dark"] .toastui-editor-mode-switch .tab-item.active {
color: var(--text);
background-color: var(--bg);
}
[data-theme="dark"] .toastui-editor-popup,
[data-theme="dark"] .toastui-editor-context-menu {
background-color: var(--bg-secondary);
color: var(--text);
border-color: var(--border);
}
/* OS-pref auto fallback (matches every selector above) */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .toastui-editor-defaultUI,
:root:not([data-theme="light"]) .toastui-editor-md-container,
:root:not([data-theme="light"]) .toastui-editor-md-preview,
:root:not([data-theme="light"]) .toastui-editor-ww-container,
:root:not([data-theme="light"]) .toastui-editor-mode-switch,
:root:not([data-theme="light"]) .toastui-editor-main,
:root:not([data-theme="light"]) .ProseMirror {
background-color: var(--bg);
color: var(--text);
}
:root:not([data-theme="light"]) .toastui-editor-defaultUI-toolbar {
background-color: var(--bg-secondary);
border-bottom-color: var(--border);
}
:root:not([data-theme="light"]) .toastui-editor-md-splitter {
background-color: var(--border);
}
:root:not([data-theme="light"]) .toastui-editor-toolbar-icons {
filter: invert(0.85) hue-rotate(180deg);
}
:root:not([data-theme="light"]) .toastui-editor-toolbar-divider {
background-color: var(--border);
}
:root:not([data-theme="light"]) .toastui-editor-mode-switch {
border-top-color: var(--border);
}
:root:not([data-theme="light"]) .toastui-editor-mode-switch .tab-item {
color: var(--text-muted);
}
:root:not([data-theme="light"]) .toastui-editor-mode-switch .tab-item.active {
color: var(--text);
background-color: var(--bg);
}
:root:not([data-theme="light"]) .toastui-editor-popup,
:root:not([data-theme="light"]) .toastui-editor-context-menu {
background-color: var(--bg-secondary);
color: var(--text);
border-color: var(--border);
}
}

View file

@ -1,223 +0,0 @@
/* Markdown content rendering styles */
.markdown-content {
line-height: 1.6;
}
.markdown-content h1,
.toastui-editor-contents h1 {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
font-size: 2em;
font-weight: 600;
line-height: 1.25;
margin-top: 24px;
margin-bottom: 16px;
padding-bottom: 0.3em;
border-bottom: 1px solid var(--border);
color: var(--text);
word-wrap: break-word;
}
.markdown-content h2,
.toastui-editor-contents h2 {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
font-size: 1.5em;
font-weight: 600;
line-height: 1.25;
margin-top: 24px;
margin-bottom: 16px;
padding-bottom: 0.3em;
border-bottom: 1px solid var(--border);
color: var(--text);
word-wrap: break-word;
}
.markdown-content h3,
.toastui-editor-contents h3 {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
font-size: 1.25em;
font-weight: 600;
line-height: 1.25;
margin-top: 16px;
margin-bottom: 16px;
color: var(--text);
word-wrap: break-word;
}
.markdown-content h4,
.toastui-editor-contents h4 {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
font-size: 1em;
font-weight: 600;
line-height: 1.25;
margin-top: 16px;
margin-bottom: 16px;
color: var(--text);
word-wrap: break-word;
}
.markdown-content h5,
.toastui-editor-contents h5 {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
font-size: 0.875em;
font-weight: 600;
line-height: 1.25;
margin-top: 16px;
margin-bottom: 16px;
color: var(--text);
word-wrap: break-word;
}
.markdown-content h6,
.toastui-editor-contents h6 {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
font-size: 0.85em;
font-weight: 600;
line-height: 1.25;
margin-top: 16px;
margin-bottom: 16px;
color: var(--text-muted);
word-wrap: break-word;
}
/* Reset margin-top for first-child headings */
.markdown-content h1:first-child,
.markdown-content h2:first-child,
.markdown-content h3:first-child,
.markdown-content h4:first-child,
.markdown-content h5:first-child,
.markdown-content h6:first-child,
.toastui-editor-contents h1:first-child,
.toastui-editor-contents h2:first-child,
.toastui-editor-contents h3:first-child,
.toastui-editor-contents h4:first-child,
.toastui-editor-contents h5:first-child,
.toastui-editor-contents h6:first-child {
margin-top: 0;
}
/* Reduce spacing between consecutive headings */
.markdown-content h1 + h2,
.toastui-editor-contents h1 + h2 {
margin-top: 1rem;
}
.markdown-content h2 + h3,
.toastui-editor-contents h2 + h3 {
margin-top: 1.5rem;
}
.markdown-content h3 + h4,
.toastui-editor-contents h3 + h4 {
margin-top: 1.25rem;
}
.markdown-content h4 + h5,
.toastui-editor-contents h4 + h5 {
margin-top: 1rem;
}
.markdown-content h5 + h6,
.toastui-editor-contents h5 + h6 {
margin-top: 0.75rem;
}
.markdown-content p,
.toastui-editor-contents p {
margin-bottom: 1rem;
}
.markdown-content ul,
.markdown-content ol,
.toastui-editor-contents ul,
.toastui-editor-contents ol {
margin-bottom: 1rem;
padding-left: 2rem;
}
.markdown-content ul,
.toastui-editor-contents ul {
list-style-type: disc;
}
.markdown-content ol,
.toastui-editor-contents ol {
list-style-type: decimal;
}
.markdown-content li,
.toastui-editor-contents li {
margin-bottom: 0.25rem;
}
.markdown-content code,
.toastui-editor-contents code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 0.9em;
padding: 0.2em 0.4em;
background-color: var(--bg-secondary);
border-radius: 0.25rem;
}
.markdown-content pre,
.toastui-editor-contents pre {
margin-bottom: 1rem;
padding: 1rem;
background-color: var(--bg-secondary);
border-radius: 0.375rem;
overflow-x: auto;
}
.markdown-content pre code,
.toastui-editor-contents pre code {
background-color: transparent;
padding: 0;
border-radius: 0;
}
.markdown-content blockquote,
.toastui-editor-contents blockquote {
border-left: 4px solid var(--border);
padding-left: 1rem;
margin-left: 0;
margin-right: 0;
margin-bottom: 1rem;
color: var(--text-muted);
}
.markdown-content a,
.toastui-editor-contents a {
color: var(--primary);
text-decoration: none;
}
.markdown-content a:hover,
.toastui-editor-contents a:hover {
text-decoration: underline;
}
.markdown-content table,
.toastui-editor-contents table {
border-collapse: collapse;
width: 100%;
margin-bottom: 1rem;
}
.markdown-content th,
.markdown-content td,
.toastui-editor-contents th,
.toastui-editor-contents td {
border: 1px solid var(--border);
padding: 0.5rem;
text-align: left;
}
.markdown-content th,
.toastui-editor-contents th {
background-color: var(--bg-secondary);
font-weight: 600;
}
.markdown-content tr:nth-child(even),
.toastui-editor-contents tr:nth-child(even) {
background-color: var(--bg-hover);
}

View file

@ -1,184 +0,0 @@
/*
* Tailwind utility subset for mdedit
*
* This file replaces the Tailwind Play CDN. It contains only the utility
* classes actually used in template.html, hand-written to match Tailwind v3
* output exactly. If new Tailwind classes are needed in template.html, add
* them here and remove the class from this comment.
*
* Generated from: grep -o 'class="[^"]*"' template.html | tr ' ' '\n' | sort -u
* Tailwind version parity: v3.x (default spacing scale, gray palette, etc.)
*/
/* ── Reset ── */
*, ::before, ::after { box-sizing: border-box; }
/* ── Display ── */
.flex { display: flex; }
.inline-flex { display: inline-flex; }
/* .hidden lives in shared/base.css (uses !important) */
/* ── Flex direction ── */
.flex-col { flex-direction: column; }
.flex-row { flex-direction: row; }
/* ── Flex grow ── */
.flex-1 { flex: 1 1 0%; }
/* ── Alignment ── */
.items-center { align-items: center; }
.justify-between { justify-content: space-between; }
.justify-center { justify-content: center; }
/* ── Gap ── */
.gap-1 { gap: 0.25rem; }
.gap-2 { gap: 0.5rem; }
.gap-4 { gap: 1rem; }
.gap-6 { gap: 1.5rem; }
/* ── Overflow ── */
.overflow-hidden { overflow: hidden; }
.overflow-auto { overflow: auto; }
/* ── Sizing ── */
.h-screen { height: 100vh; }
.h-full { height: 100%; }
.h-12 { height: 3rem; }
.h-6 { height: 1.5rem; }
.h-3\.5 { height: 0.875rem; }
.h-24 { height: 6rem; }
/* ── Resize ── */
.resize-none { resize: none; }
/* ── Border ── */
.border-0 { border-width: 0; }
/* ── Outline ── */
.focus\:outline-none:focus { outline: none; }
.w-full { width: 100%; }
.w-1 { width: 0.25rem; }
.w-3\.5 { width: 0.875rem; }
/* ── Positioning ── */
.relative { position: relative; }
.z-10 { z-index: 10; }
/* ── Spacing ── */
.p-4 { padding: 1rem; }
.p-6 { padding: 1.5rem; }
.px-2 { padding-left: 0.5rem; padding-right: 0.5rem; }
.pl-2 { padding-left: 0.5rem; }
.px-3 { padding-left: 0.75rem; padding-right: 0.75rem; }
.px-4 { padding-left: 1rem; padding-right: 1rem; }
.py-1 { padding-top: 0.25rem; padding-bottom: 0.25rem; }
.py-2 { padding-top: 0.5rem; padding-bottom: 0.5rem; }
.mt-2 { margin-top: 0.5rem; }
.mb-2 { margin-bottom: 0.5rem; }
.mb-4 { margin-bottom: 1rem; }
/* ── Typography ── */
.text-xl { font-size: 1.25rem; line-height: 1.75rem; }
.text-sm { font-size: 0.875rem; line-height: 1.25rem; }
.text-xs { font-size: 0.75rem; line-height: 1rem; }
.font-semibold { font-weight: 600; }
.font-medium { font-weight: 500; }
.text-center { text-align: center; }
.leading-none { line-height: 1; }
.select-none { user-select: none; }
/* ── Colors — text ── */
.text-white { color: #ffffff; }
.text-gray-800 { color: #1f2937; }
.text-gray-700 { color: #374151; }
.text-gray-500 { color: #6b7280; }
.text-amber-600 { color: #d97706; }
/* ── Colors — background ── */
.bg-white { background-color: #ffffff; }
.bg-gray-100 { background-color: #f3f4f6; }
.bg-gray-200 { background-color: #e5e7eb; }
.bg-transparent { background-color: transparent; }
.bg-blue-500 { background-color: #3b82f6; }
/* ── Borders ── */
.border { border-width: 1px; border-style: solid; }
.border-b { border-bottom-width: 1px; border-bottom-style: solid; }
.border-t { border-top-width: 1px; border-top-style: solid; }
.border-gray-200 { border-color: #e5e7eb; }
.border-gray-300 { border-color: #d1d5db; }
.rounded { border-radius: 0.25rem; }
/* ── Opacity ── */
.opacity-70 { opacity: 0.7; }
.opacity-80 { opacity: 0.8; }
/* ── SVG ── */
.fill-current { fill: currentColor; }
/* ── Cursor ── */
.cursor-pointer { cursor: pointer; }
.cursor-col-resize { cursor: col-resize; }
/* ── Transitions ── */
.transition-all { transition-property: all; transition-timing-function: cubic-bezier(0.4,0,0.2,1); transition-duration: 150ms; }
.transition-colors { transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke; transition-timing-function: cubic-bezier(0.4,0,0.2,1); transition-duration: 150ms; }
.transition-opacity { transition-property: opacity; transition-timing-function: cubic-bezier(0.4,0,0.2,1); transition-duration: 150ms; }
/* ── Pseudo-class: hover ── */
.hover\:bg-blue-500:hover { background-color: #3b82f6; }
.hover\:bg-blue-600:hover { background-color: #2563eb; }
.hover\:bg-gray-200:hover { background-color: #e5e7eb; }
.hover\:opacity-80:hover { opacity: 0.8; }
/* ── Pseudo-class: disabled ── */
.disabled\:bg-gray-400:disabled { background-color: #9ca3af; }
.disabled\:cursor-not-allowed:disabled { cursor: not-allowed; }
/* ── Dark mode (prefers-color-scheme or manual [data-theme="dark"]) ── */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .dark\:bg-gray-700 { background-color: #374151; }
:root:not([data-theme="light"]) .dark\:bg-gray-800 { background-color: #1f2937; }
:root:not([data-theme="light"]) .dark\:bg-gray-900 { background-color: #111827; }
:root:not([data-theme="light"]) .dark\:border-gray-600 { border-color: #4b5563; }
:root:not([data-theme="light"]) .dark\:border-gray-700 { border-color: #374151; }
:root:not([data-theme="light"]) .dark\:text-gray-200 { color: #e5e7eb; }
:root:not([data-theme="light"]) .dark\:text-gray-400 { color: #9ca3af; }
:root:not([data-theme="light"]) .dark\:hover\:bg-gray-700:hover { background-color: #374151; }
:root:not([data-theme="light"]) .dark\:hover\:bg-gray-800:hover { background-color: #1f2937; }
}
/* Manual dark override */
[data-theme="dark"] .dark\:bg-gray-700 { background-color: #374151; }
[data-theme="dark"] .dark\:bg-gray-800 { background-color: #1f2937; }
[data-theme="dark"] .dark\:bg-gray-900 { background-color: #111827; }
[data-theme="dark"] .dark\:border-gray-600 { border-color: #4b5563; }
[data-theme="dark"] .dark\:border-gray-700 { border-color: #374151; }
[data-theme="dark"] .dark\:text-gray-200 { color: #e5e7eb; }
[data-theme="dark"] .dark\:text-gray-400 { color: #9ca3af; }
[data-theme="dark"] .dark\:hover\:bg-gray-700:hover { background-color: #374151; }
[data-theme="dark"] .dark\:hover\:bg-gray-800:hover { background-color: #1f2937; }
/* Manual light override — ensure bg-white/bg-gray-100 are NOT overridden by above */
[data-theme="light"] .dark\:bg-gray-700,
[data-theme="light"] .dark\:bg-gray-800,
[data-theme="light"] .dark\:bg-gray-900 { background-color: revert; }
/* ── Directional spacing (used in JS-generated elements) ── */
.ml-1 { margin-left: 0.25rem; }
.ml-4 { margin-left: 1rem; }
.mr-1 { margin-right: 0.25rem; }
.pl-0 { padding-left: 0; }
.pl-4 { padding-left: 1rem; }
/* ── Additional missing utilities ── */
.whitespace-nowrap { white-space: nowrap; }
.text-ellipsis { text-overflow: ellipsis; }
.font-bold { font-weight: 700; }
.border-r { border-right-width: 1px; border-right-style: solid; }
.mt-1 { margin-top: 0.25rem; }
.text-amber-500 { color: #f59e0b; }
.text-blue-600 { color: #2563eb; }
.hover\:bg-gray-100:hover { background-color: #f3f4f6; }
.hover\:text-blue-800:hover { color: #1e40af; }
.hover\:underline:hover { text-decoration: underline; }

View file

@ -1,280 +0,0 @@
/* Table of Contents styles */
.toc-pane {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.toc-section {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.toc-container,
.toc-content {
flex: 1;
overflow-y: auto;
padding: 1rem;
}
/* Header layout — font/padding/weight come from .pane-section-header. */
.toc-header {
justify-content: space-between;
}
.toc-depth-selector {
font-size: 0.85rem;
padding: 2px 6px;
border-radius: 3px;
border: 1px solid var(--border);
background: var(--bg);
color: var(--text);
}
.toc-list {
list-style: none;
padding-left: 0;
margin: 0;
font-size: 0.8rem;
}
.toc-item {
padding: 0;
margin: 0;
line-height: 1.2;
}
/* TOC heading level styles */
.toc-level-1 > a {
font-size: 0.9rem;
font-weight: 600;
color: var(--text);
}
.toc-level-2 > a {
font-size: 0.85rem;
font-weight: 600;
color: var(--text);
}
.toc-level-3 > a {
font-size: 0.8rem;
font-weight: 600;
color: var(--text-muted);
}
.toc-level-4 > a {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-muted);
}
.toc-level-5 > a,
.toc-level-6 > a {
font-size: 0.7rem;
font-weight: 600;
color: var(--text-muted);
}
/* Nested list spacing */
.toc-list ul {
list-style: none;
padding-left: 6px;
margin: 0;
}
.toc-list li {
margin-bottom: 1px;
line-height: 1.2;
}
.toc-list li a {
display: block;
padding: 2px 6px;
color: var(--text-muted);
text-decoration: none;
border-radius: 3px;
transition: background-color 0.15s ease;
}
.toc-list li a:hover {
background-color: var(--bg-hover);
color: var(--text);
}
/* Active TOC item highlighting */
.toc-list li.toc-active {
background-color: var(--primary);
border-radius: 3px;
}
/* Use high-specificity selectors to override per-level color rules */
.toc-list li.toc-active > a,
.toc-list li.toc-active > a:hover,
.toc-list li.toc-level-1.toc-active > a,
.toc-list li.toc-level-2.toc-active > a,
.toc-list li.toc-level-3.toc-active > a,
.toc-list li.toc-level-4.toc-active > a,
.toc-list li.toc-level-5.toc-active > a,
.toc-list li.toc-level-6.toc-active > a {
color: var(--text-light);
border-bottom-color: transparent;
background-color: transparent;
}
.toc-list li.toc-level-1 {
font-weight: 700;
font-size: 1rem;
padding-left: 0px;
}
.toc-list li.toc-level-1 a {
color: var(--text);
border-bottom: 1px solid var(--primary);
padding-bottom: 2px;
}
/* Tree-style connecting lines for TOC hierarchy */
.toc-list li.toc-level-2 {
font-weight: 650;
font-size: 0.9rem;
padding-left: 16px;
position: relative;
}
.toc-list li.toc-level-2::before {
content: '';
position: absolute;
left: 6px;
top: 0;
bottom: 50%;
border-left: 1px solid var(--border);
border-bottom: 1px solid var(--border);
width: 8px;
}
.toc-list li.toc-level-2 a {
color: var(--text);
}
.toc-list li.toc-level-3 {
font-weight: 600;
font-size: 0.8rem;
padding-left: 32px;
position: relative;
}
.toc-list li.toc-level-3::before {
content: '';
position: absolute;
left: 22px;
top: 0;
bottom: 50%;
border-left: 1px solid var(--border);
border-bottom: 1px solid var(--border);
width: 8px;
}
.toc-list li.toc-level-3 a {
color: var(--text-muted);
}
.toc-list li.toc-level-4 {
font-weight: 600;
font-size: 0.75rem;
padding-left: 48px;
position: relative;
}
.toc-list li.toc-level-4::before {
content: '';
position: absolute;
left: 38px;
top: 0;
bottom: 50%;
border-left: 1px solid var(--border);
border-bottom: 1px solid var(--border);
width: 8px;
}
.toc-list li.toc-level-4 a {
color: var(--text-muted);
}
.toc-list li.toc-level-5 {
font-weight: 600;
font-size: 0.7rem;
padding-left: 64px;
font-style: italic;
position: relative;
}
.toc-list li.toc-level-5::before {
content: '';
position: absolute;
left: 54px;
top: 0;
bottom: 50%;
border-left: 1px solid var(--border);
border-bottom: 1px solid var(--border);
width: 8px;
}
.toc-list li.toc-level-5 a {
color: var(--text-muted);
}
.toc-list li.toc-level-6 {
font-weight: 600;
font-size: 0.65rem;
padding-left: 80px;
font-style: italic;
text-transform: uppercase;
letter-spacing: 0.05em;
position: relative;
}
.toc-list li.toc-level-6::before {
content: '';
position: absolute;
left: 70px;
top: 0;
bottom: 50%;
border-left: 1px solid var(--border);
border-bottom: 1px solid var(--border);
width: 8px;
}
.toc-list li.toc-level-6 a {
color: var(--text-muted);
}
/* Vertical connecting lines */
.toc-list li:not(.toc-level-1)::after {
content: '';
position: absolute;
left: 6px;
top: 50%;
bottom: -2px;
border-left: 1px solid var(--border);
z-index: -1;
}
.toc-list li.toc-level-3::after {
left: 22px;
}
.toc-list li.toc-level-4::after {
left: 38px;
}
.toc-list li.toc-level-5::after {
left: 54px;
}
.toc-list li.toc-level-6::after {
left: 70px;
}

View file

@ -1,43 +0,0 @@
/**
* Global application state and constants
*/
// Set to true to enable verbose console logging for development.
const DEBUG = false;
// Check if File System Access API is available
const hasFileSystemAccess = 'showDirectoryPicker' in window;
// Directory and file handles
let directoryHandle = null;
let fileTree = {};
let currentFileHandle = null;
// True when the page is served over HTTP(S) and the file tree is sourced
// from the server's JSON directory listing instead of the local FS API.
let serverSourceMode = false;
// Map to store editor instances for each file
// Key: file path, Value: { editor, container, tocContainer, etc. }
const editorInstances = new Map();
// Current TOC max depth (1-6)
let tocMaxDepth = 3;
// Scratchpad ID constant
const SCRATCHPAD_ID = '__scratchpad__';
// Default scratchpad markdown — shown the first time mdedit loads.
// Acts as both a welcome message and a starter pad for quick notes.
const SCRATCHPAD_WELCOME = [
'# Welcome to ZDDC Markdown',
'',
'All editing happens locally on your computer — nothing is uploaded.',
'',
'Use this **Scratchpad** for quick notes. Download it any time with the ⬇',
'button on the Scratchpad row in the file list.',
'',
'Click **Add Local Directory** above to open a folder of Markdown files,',
'or just start typing here.',
'',
].join('\n');

View file

@ -1,419 +0,0 @@
/**
* Toast UI Editor initialization and management
*/
/**
* Initialize or update the Toast UI Editor for a file
* @param {string} content - Content to display
* @param {boolean} isMarkdown - Whether content is markdown
* @param {string} filePath - Path of the file
* @param {string} fileName - Name of the file
* @param {FileSystemFileHandle} fileHandle - File handle for saving
* @param {number} lastModified - Timestamp of last modification
*/
function initializeEditor(content, isMarkdown = true, filePath = '', fileName = '', fileHandle = null, lastModified = null) {
// Parse front matter
let frontMatterData = {};
let markdownBody = content;
if (isMarkdown && content) {
try {
const parsed = parseFrontMatter(content);
frontMatterData = parsed.data;
markdownBody = parsed.content;
} catch (error) {
console.error('Failed to parse front matter:', error);
}
}
const contentContainer = document.getElementById('content-container');
if (!contentContainer) {
alert('Error: content-container element not found!');
return;
}
// Hide all file view containers
document.querySelectorAll('.file-view-container').forEach(container => {
container.style.display = 'none';
});
// Check if file already has an instance
if (editorInstances.has(filePath)) {
const existingInstance = editorInstances.get(filePath);
if (existingInstance.fileViewContainer) {
existingInstance.fileViewContainer.style.display = 'flex';
}
return existingInstance.editor;
}
// Create file view container
const fileViewContainer = document.createElement('div');
fileViewContainer.className = 'file-view-container flex flex-col h-full';
// Create file header
const fileHeader = document.createElement('div');
fileHeader.className = 'file-header flex justify-between items-center px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 font-medium border-b border-gray-200 dark:border-gray-700';
const fileTitle = document.createElement('span');
fileTitle.textContent = fileName || 'No file selected';
fileHeader.appendChild(fileTitle);
// Button container for alignment
const buttonContainer = document.createElement('div');
buttonContainer.className = 'flex gap-2';
// Determine if this is a scratchpad (no file handle)
const isScratchpad = !fileHandle;
const isReadOnlyHandle = !!(fileHandle && fileHandle._readOnly);
// Save button (or Save As for scratchpads / read-only server files)
const saveButton = document.createElement('button');
saveButton.className = 'btn btn-primary btn-sm';
saveButton.textContent = (isScratchpad || isReadOnlyHandle) ? 'Save As...' : 'Save File';
saveButton.disabled = !isScratchpad; // Scratchpads can always save; read-only enables on edit
buttonContainer.appendChild(saveButton);
// Reload button (only for files, not scratchpads) — icon to match file-tree refresh
let reloadButton = null;
if (!isScratchpad) {
reloadButton = document.createElement('button');
reloadButton.className = 'btn btn-secondary btn-sm';
reloadButton.textContent = '↻';
reloadButton.title = 'Reload from disk (discards unsaved changes)';
reloadButton.setAttribute('aria-label', 'Reload from disk');
buttonContainer.appendChild(reloadButton);
}
fileHeader.appendChild(buttonContainer);
fileViewContainer.appendChild(fileHeader);
// Content area
const contentArea = document.createElement('div');
contentArea.className = 'flex flex-col flex-1 overflow-hidden';
// Editor area with TOC
const editorArea = document.createElement('div');
editorArea.className = 'flex flex-row flex-1 overflow-hidden';
// TOC pane (markdown only)
let tocContainer = null;
let frontMatterTextarea = null;
if (isMarkdown) {
const tocPane = document.createElement('div');
tocPane.className = 'toc-pane bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-700';
tocPane.style.width = '325px';
tocPane.style.minWidth = '150px';
// Front matter section (collapsible, height-resizable)
const frontMatterNav = document.createElement('div');
frontMatterNav.className = 'front-matter-nav';
frontMatterNav.style.height = '180px';
const frontMatterHeader = document.createElement('div');
frontMatterHeader.className = 'front-matter-header pane-section-header cursor-pointer';
const toggleIcon = document.createElement('span');
toggleIcon.textContent = '▼';
toggleIcon.className = 'toggle-icon';
frontMatterHeader.appendChild(toggleIcon);
const headerText = document.createElement('span');
headerText.textContent = 'YAML Front Matter';
frontMatterHeader.appendChild(headerText);
frontMatterNav.appendChild(frontMatterHeader);
const frontMatterContent = document.createElement('div');
frontMatterContent.className = 'front-matter-content';
frontMatterTextarea = document.createElement('textarea');
frontMatterTextarea.className = 'front-matter-textarea';
frontMatterTextarea.placeholder = 'title: Document Title\ndate: 2024-01-01\ntags: [example]';
if (frontMatterData && Object.keys(frontMatterData).length > 0) {
try {
let yamlText = '';
for (const [key, value] of Object.entries(frontMatterData)) {
if (Array.isArray(value)) {
yamlText += `${key}: [${value.map(v => `"${v}"`).join(', ')}]\n`;
} else {
yamlText += `${key}: ${value}\n`;
}
}
frontMatterTextarea.value = yamlText.trim();
} catch (error) {
console.warn('Failed to stringify front matter:', error);
frontMatterTextarea.value = '';
}
}
frontMatterContent.appendChild(frontMatterTextarea);
frontMatterNav.appendChild(frontMatterContent);
tocPane.appendChild(frontMatterNav);
// Horizontal resizer between front-matter and TOC
const fmTocResizer = document.createElement('div');
fmTocResizer.className = 'pane-resizer horizontal';
tocPane.appendChild(fmTocResizer);
// TOC section
const tocSection = document.createElement('div');
tocSection.className = 'toc-section';
const tocHeader = document.createElement('div');
tocHeader.className = 'toc-header pane-section-header';
const tocTitle = document.createElement('span');
tocTitle.textContent = 'Table of Contents';
tocHeader.appendChild(tocTitle);
const tocDepthSelector = document.createElement('select');
tocDepthSelector.className = 'toc-depth-selector';
tocDepthSelector.innerHTML = `
<option value="6">All Levels</option>
<option value="1">H1 Only</option>
<option value="2">H1-H2</option>
<option value="3" selected>H1-H3</option>
<option value="4">H1-H4</option>
<option value="5">H1-H5</option>
`;
tocHeader.appendChild(tocDepthSelector);
tocSection.appendChild(tocHeader);
tocContainer = document.createElement('div');
tocContainer.className = 'toc-container toc-content';
tocSection.appendChild(tocContainer);
tocPane.appendChild(tocSection);
// Toggle: collapsed only shows the header. Hide content + horizontal resizer.
let fmIsCollapsed = false;
frontMatterHeader.addEventListener('click', () => {
fmIsCollapsed = !fmIsCollapsed;
frontMatterNav.classList.toggle('collapsed', fmIsCollapsed);
toggleIcon.textContent = fmIsCollapsed ? '▶' : '▼';
fmTocResizer.style.display = fmIsCollapsed ? 'none' : '';
if (fmIsCollapsed) {
frontMatterNav.style.height = '';
} else {
frontMatterNav.style.height = '180px';
}
});
editorArea.appendChild(tocPane);
// Vertical resizer between toc-pane and editor (placed inside editorArea)
const tocResizer = document.createElement('div');
tocResizer.className = 'pane-resizer bg-gray-200 dark:bg-gray-700 transition-colors relative z-10 w-1 cursor-col-resize hover:bg-blue-500';
tocResizer.setAttribute('data-resizer-for', 'toc-pane');
editorArea.appendChild(tocResizer);
makeResizable(tocResizer, tocPane);
// Make the front-matter / TOC split height-adjustable
makeHeightResizable(fmTocResizer, frontMatterNav, tocPane);
tocDepthSelector.addEventListener('change', function () {
const depth = parseInt(this.value);
if (editorInstance) {
const currentContent = editorInstance.getMarkdown();
updateToc(currentContent, tocContainer, editorInstance, depth);
}
});
}
// Editor container
const editorContainer = document.createElement('div');
editorContainer.className = 'editor-instance flex-1 overflow-hidden';
editorArea.appendChild(editorContainer);
contentArea.appendChild(editorArea);
fileViewContainer.appendChild(contentArea);
contentContainer.appendChild(fileViewContainer);
// Check Toast UI availability
if (typeof toastui === 'undefined') {
alert('Error: Toast UI library not loaded!');
editorContainer.innerHTML = '<div style="padding: 20px; background: #ffeeee; color: red;">Error: Toast UI library not loaded!</div>';
return;
}
let editorInstance;
try {
// Initialize Toast UI Editor
const editor = new toastui.Editor({
el: editorContainer,
height: '100%',
initialEditType: 'markdown',
previewStyle: 'vertical',
initialValue: markdownBody,
toolbarItems: [
['heading', 'bold', 'italic', 'strike'],
['hr', 'quote'],
['ul', 'ol', 'task', 'indent', 'outdent'],
['table', 'image', 'link'],
['code', 'codeblock']
]
});
editorInstance = editor;
if (!isMarkdown) {
editorInstance.changeMode('wysiwyg');
}
// Generate initial TOC
if (isMarkdown && tocContainer) {
try {
updateToc(markdownBody, tocContainer, editorInstance, tocMaxDepth);
} catch (error) {
console.error('Error generating TOC:', error);
}
const debouncedUpdateToc = debounce(() => {
const currentContent = editorInstance.getMarkdown();
updateToc(currentContent, tocContainer, editorInstance, tocMaxDepth);
}, 300);
editorInstance.on('change', () => {
debouncedUpdateToc();
const instanceData = editorInstances.get(filePath);
if (instanceData && !instanceData.isDirty) {
instanceData.isDirty = true;
updateFileDirtyStatus(filePath, true);
updateUnsavedCount();
}
saveButton.disabled = false;
if (filePath === SCRATCHPAD_ID) updateScratchpadDownloadState();
});
// Scroll listener for TOC highlighting
const mdPreview = editorInstance.getEditorElements().mdPreview;
if (mdPreview) {
let activeTimeout = null;
let lastHeader = null;
const updateActiveHeader = () => {
// Re-query live headings (TOC may have been regenerated)
const liveHeaders = mdPreview.querySelectorAll('h1, h2, h3, h4, h5, h6');
const previewRect = mdPreview.getBoundingClientRect();
// Use a threshold slightly below the top so a header touching
// the top edge counts as "active"
const threshold = previewRect.top + 4;
let activeHeader = null;
for (const header of liveHeaders) {
if (header.getBoundingClientRect().top <= threshold) {
activeHeader = header.textContent.trim();
} else {
break;
}
}
if (activeHeader !== lastHeader) {
lastHeader = activeHeader;
setActiveTocItem(tocContainer, activeHeader);
}
};
const onScroll = () => {
cancelAnimationFrame(activeTimeout);
activeTimeout = requestAnimationFrame(updateActiveHeader);
};
mdPreview.addEventListener('scroll', onScroll);
}
} else {
editorInstance.on('change', () => {
const instanceData = editorInstances.get(filePath);
if (instanceData && !instanceData.isDirty) {
instanceData.isDirty = true;
updateFileDirtyStatus(filePath, true);
updateUnsavedCount();
}
saveButton.disabled = false;
});
}
// Front matter change listener
if (frontMatterTextarea) {
frontMatterTextarea.addEventListener('input', () => {
const instanceData = editorInstances.get(filePath);
if (instanceData && !instanceData.isDirty) {
instanceData.isDirty = true;
updateFileDirtyStatus(filePath, true);
updateUnsavedCount();
}
saveButton.disabled = false;
});
}
// Button event listeners
saveButton.addEventListener('click', async () => {
if (isScratchpad) {
// For scratchpads, use Save As
const content = editorInstance.getMarkdown();
const savedHandle = await saveFileAs(content, 'untitled.md');
if (savedHandle && hasFileSystemAccess) {
// Check if saved to current directory - add to file tree
if (directoryHandle) {
try {
// Try to get the file from the directory to verify it's there
const checkHandle = await directoryHandle.getFileHandle(savedHandle.name);
// File is in current directory, add to tree
fileTree.entries[savedHandle.name] = {
name: savedHandle.name,
type: 'file',
handle: checkHandle
};
renderFileTree();
} catch (e) {
// File not in current directory, that's fine
}
}
// Clear scratchpad content after successful save
editorInstance.setMarkdown('');
saveButton.disabled = true;
const instanceData = editorInstances.get(filePath);
if (instanceData) {
instanceData.isDirty = false;
}
}
} else {
saveFile(filePath);
}
});
if (reloadButton) {
reloadButton.addEventListener('click', async () => {
await reloadFileFromDisk(filePath);
});
}
// Store instance data
const instanceData = {
editor: editor,
fileViewContainer: fileViewContainer,
tocContainer: tocContainer,
saveButton: saveButton,
reloadButton: reloadButton,
frontMatterTextarea: frontMatterTextarea,
frontMatterData: frontMatterData,
fileHandle: fileHandle,
lastModified: lastModified,
isDirty: false
};
editorInstances.set(filePath, instanceData);
return editorInstance;
} catch (error) {
console.error('Error initializing editor:', error);
alert(`Error initializing Toast UI Editor: ${error}`);
return null;
}
}

View file

@ -1,81 +0,0 @@
/**
* Event listeners setup
*/
/**
* Set up all event listeners for the application
*/
function setupEventListeners() {
// Add Local Directory button (was id="select-directory" / "refresh-directory")
const selectDirectoryBtn = document.getElementById('addDirectoryBtn');
if (selectDirectoryBtn) {
selectDirectoryBtn.addEventListener('click', openDirectory);
}
// Refresh button (now in header, was in file-nav pane)
const refreshDirectoryBtn = document.getElementById('refreshHeaderBtn');
if (refreshDirectoryBtn) {
refreshDirectoryBtn.addEventListener('click', refreshDirectory);
}
// New file (root) button
const newFileRootBtn = document.getElementById('new-file-root');
if (newFileRootBtn) {
newFileRootBtn.addEventListener('click', () => {
if (directoryHandle) {
createNewFile('');
}
});
}
// Save All button
const saveAllBtn = document.getElementById('save-all');
if (saveAllBtn) {
saveAllBtn.addEventListener('click', saveAllFiles);
}
// Warn when leaving with unsaved changes
window.addEventListener('beforeunload', function (e) {
let hasUnsavedChanges = false;
editorInstances.forEach((instanceData) => {
if (instanceData.isDirty) {
hasUnsavedChanges = true;
}
});
if (hasUnsavedChanges) {
e.preventDefault();
return 'You have unsaved changes. If you leave now, your changes will be lost.';
}
});
}
/**
* Set up TOC depth selector
*/
function setupTocDepthSelector() {
const depthSelector = document.getElementById('toc-depth-selector');
if (!depthSelector) return;
depthSelector.value = tocMaxDepth.toString();
depthSelector.addEventListener('change', function () {
tocMaxDepth = parseInt(this.value, 10);
if (currentFileHandle && currentFileHandle.name.match(/\.(md|markdown)$/i)) {
const filePath = currentFileHandle.name;
const instance = editorInstances.get(filePath);
if (instance && instance.editor && instance.tocContainer) {
const content = instance.editor.getMarkdown();
try {
updateToc(content, instance.tocContainer, instance.editor, tocMaxDepth);
} catch (error) {
console.error('Error updating TOC depth:', error);
}
}
}
});
}

View file

@ -1,400 +0,0 @@
/**
* File management operations (create, rename, delete)
* Plain functions, no module wrapper
*/
/**
* Resolve a node in fileTree by filePath
* @param {string} filePath - Path like 'subdir/file.md' or ''
* @returns {Object|null} The node object or null if not found
*/
function resolveNode(filePath) {
if (!filePath) return fileTree;
const parts = filePath.split('/');
let node = fileTree;
for (const part of parts) {
if (!node.entries || !node.entries[part]) return null;
node = node.entries[part];
}
return node;
}
/**
* Resolve the parent directory handle for a given file path
* @param {string} filePath - Full path like 'subdir/file.md'
* @returns {FileSystemDirectoryHandle|null} Parent directory handle or null
*/
function resolveParentDirHandle(filePath) {
const parts = filePath.split('/');
if (parts.length === 1) return directoryHandle;
let node = fileTree;
for (let i = 0; i < parts.length - 1; i++) {
node = node.entries[parts[i]];
if (!node) return null;
}
return node.handle;
}
/**
* Create a new file
* @param {string} parentDirPath - '' for root, or 'subdir', 'a/b/c'
*/
async function createNewFile(parentDirPath) {
// Resolve parent directory handle first (no user activation needed for reads)
let parentHandle;
if (parentDirPath === '') {
parentHandle = directoryHandle;
} else {
const node = resolveNode(parentDirPath);
if (!node || !node.handle) {
alert('Could not locate parent directory.');
return;
}
parentHandle = node.handle;
}
// Show in-page modal and wait for user to confirm or cancel.
// Returns the filename string, or null if cancelled.
const name = await new Promise((resolve) => {
const modal = document.getElementById('new-file-modal');
const input = document.getElementById('new-file-input');
const confirmBtn = document.getElementById('new-file-confirm');
const cancelBtn = document.getElementById('new-file-cancel');
input.value = 'untitled.md';
modal.classList.remove('hidden');
input.focus();
input.select();
function cleanup() {
modal.classList.add('hidden');
confirmBtn.removeEventListener('click', onConfirm);
cancelBtn.removeEventListener('click', onCancel);
input.removeEventListener('keydown', onKey);
}
function onConfirm() {
const val = input.value.trim();
cleanup();
resolve(val || null);
}
function onCancel() {
cleanup();
resolve(null);
}
function onKey(e) {
if (e.key === 'Enter') onConfirm();
if (e.key === 'Escape') onCancel();
}
confirmBtn.addEventListener('click', onConfirm);
cancelBtn.addEventListener('click', onCancel);
input.addEventListener('keydown', onKey);
});
if (!name) {
if (DEBUG) console.log('New file creation cancelled');
return;
}
// Validate name
if (name.includes('/') || name.includes('\\')) {
alert('Invalid filename: cannot contain / or \\.');
return;
}
// Check if file already exists
try {
await parentHandle.getFileHandle(name);
const overwrite = window.confirm('A file named "' + name + '" already exists. Overwrite it?');
if (!overwrite) return;
} catch (e) {
if (e.name !== 'NotFoundError') throw e;
}
// Create the file — this must happen after the modal's button click
// which is the user activation token.
try {
const newHandle = await parentHandle.getFileHandle(name, { create: true });
const writable = await newHandle.createWritable();
await writable.write('');
await writable.close();
if (DEBUG) console.log(`Created new file: ${parentDirPath ? parentDirPath + '/' : ''}${name}`);
await refreshDirectory();
const newFilePath = parentDirPath ? parentDirPath + '/' + name : name;
const element = document.querySelector('.file-item[data-path="' + CSS.escape(newFilePath) + '"]');
if (element) {
handleFileClick(newHandle, newFilePath, element);
}
} catch (error) {
console.error('Error creating new file:', error);
alert('Error creating file: ' + error.message);
}
}
/**
* Rename a file or directory
* @param {string} filePath - Full path like 'subdir/file.md'
* @param {boolean} isDirectory - true if renaming a directory (not supported on Chrome)
*/
async function renameEntry(filePath, isDirectory) {
const currentName = filePath.split('/').pop();
const newName = window.prompt('Rename to:', currentName);
if (newName === null || newName === currentName) {
if (DEBUG) console.log('Rename cancelled or unchanged');
return;
}
// Validate name
if (newName.includes('/') || newName.includes('\\') || newName.trim() === '') {
alert('Invalid filename: cannot contain / or \\ and must not be empty.');
return;
}
// Resolve parent directory handle
const parentHandle = resolveParentDirHandle(filePath);
if (!parentHandle) {
alert('Could not locate parent directory.');
return;
}
// For files: rename via File System Access API
if (!isDirectory) {
try {
// Check if new name already exists (file or directory)
try {
const existing = await parentHandle.getFileHandle(newName);
// A file with that name exists
const overwrite = window.confirm('A file named "' + newName + '" already exists. Overwrite?');
if (!overwrite) return;
} catch (fileErr) {
if (fileErr.name === 'TypeMismatchError') {
// A directory with that name exists
window.alert('A folder named "' + newName + '" already exists. Choose a different name.');
return;
}
if (fileErr.name !== 'NotFoundError') throw fileErr;
// NotFoundError = safe to create
}
const oldHandle = resolveNode(filePath);
if (!oldHandle || !oldHandle.handle) {
alert('Could not find file to rename.');
return;
}
const file = await oldHandle.handle.getFile();
const content = await file.text();
const newHandle = await parentHandle.getFileHandle(newName, { create: true });
const writable = await newHandle.createWritable();
await writable.write(content);
await writable.close();
const newFile = await newHandle.getFile();
await parentHandle.removeEntry(currentName);
// Update editor instances
if (editorInstances.has(filePath)) {
const instance = editorInstances.get(filePath);
const newFilePath = filePath.substring(0, filePath.length - currentName.length) + newName;
// Remove old instance
const data = editorInstances.get(filePath);
if (data.fileViewContainer) {
data.fileViewContainer.classList.add('hidden');
}
editorInstances.delete(filePath);
// Re-add with new path
editorInstances.set(newFilePath, { ...data, fileHandle: newHandle, lastModified: newFile.lastModified });
// Update active state
if (instance.fileViewContainer) {
instance.fileViewContainer.classList.remove('hidden');
instance.fileViewContainer.dataset.path = newFilePath;
}
// Update fileTree entries
const parts = filePath.split('/');
const fileName = parts.pop();
const dirPath = parts.join('/');
let targetEntries = fileTree.entries;
if (dirPath) {
const dirParts = dirPath.split('/');
let current = fileTree;
for (const part of dirParts) {
current = current.entries[part];
}
targetEntries = current.entries;
}
if (targetEntries && targetEntries[currentName]) {
delete targetEntries[currentName];
targetEntries[newName] = {
name: newName,
type: 'file',
handle: newHandle
};
}
renderFileTree();
restoreActiveFile(newFilePath);
} else {
renderFileTree();
}
} catch (error) {
console.error('Error renaming file:', error);
alert('Error renaming file: ' + error.message);
}
} else {
// For directories: not supported by browser API
alert('Directory rename is not supported by the browser File System API. Please rename the folder in your OS file manager and refresh.');
}
}
/**
* Delete a file or directory
* @param {string} filePath - Full path like 'subdir/file.md' or 'subdir'
* @param {boolean} isDirectory - true if deleting a directory
*/
async function deleteEntry(filePath, isDirectory) {
const name = filePath.split('/').pop();
const message = isDirectory
? 'Delete folder "' + name + '" and all its contents?'
: 'Delete "' + name + '"?';
const ok = window.confirm(message);
if (!ok) {
if (DEBUG) console.log('Delete cancelled by user');
return;
}
// Resolve parent directory handle
const parentHandle = resolveParentDirHandle(filePath);
if (!parentHandle) {
alert('Could not locate parent directory.');
return;
}
let deleted = false;
try {
await parentHandle.removeEntry(name, { recursive: isDirectory });
deleted = true;
} catch (error) {
if (error.name === 'NotFoundError') {
// Already gone — treat as success for cleanup purposes
deleted = true;
} else {
console.error('Error deleting entry:', error);
alert('Error deleting entry: ' + error.message);
}
}
if (deleted) {
// Close editor if open
if (!isDirectory && editorInstances.has(filePath)) {
closeEditorInstance(filePath);
} else if (isDirectory) {
// Close any editors under this directory
const dirsToClose = [];
editorInstances.forEach(function(instance, key) {
if (key === filePath || key.startsWith(filePath + '/')) {
dirsToClose.push(key);
}
});
dirsToClose.forEach(function(key) {
closeEditorInstance(key);
});
}
// Remove from fileTree entries
const parts = filePath.split('/');
const entryName = parts.pop();
const dirPath = parts.join('/');
let targetEntries = fileTree.entries;
if (dirPath) {
const dirParts = dirPath.split('/');
let current = fileTree;
for (const part of dirParts) {
current = current.entries[part];
}
targetEntries = current.entries;
}
if (targetEntries && targetEntries[entryName]) {
delete targetEntries[entryName];
}
renderFileTree();
updateStatusCountsFromTree();
}
}
/**
* Close an editor instance and show welcome screen if no files open
* @param {string} filePath - Path of file to close
*/
function closeEditorInstance(filePath) {
const instance = editorInstances.get(filePath);
if (!instance) return;
if (instance.fileViewContainer) {
instance.fileViewContainer.classList.add('hidden');
}
editorInstances.delete(filePath);
// Check if any visible file-view-container children remain
const contentContainer = document.getElementById('content-container');
if (contentContainer) {
const visibleChildren = Array.from(contentContainer.querySelectorAll('.file-view-container'))
.filter(function(el) { return !el.classList.contains('hidden'); });
if (visibleChildren.length === 0) {
document.getElementById('welcome-screen').classList.remove('hidden');
contentContainer.classList.add('hidden');
}
}
}
/**
* Restore active file state after rename
* @param {string} newFilePath - New path of the file
*/
function restoreActiveFile(newFilePath) {
const element = document.querySelector('.file-item[data-path="' + CSS.escape(newFilePath) + '"]');
if (element) {
element.classList.add('active-file');
element.style.backgroundColor = '';
element.style.color = '';
}
}
/**
* Update status counts from fileTree
*/
function updateStatusCountsFromTree() {
let folderCount = 0;
let fileCount = 0;
function countEntries(entries) {
if (!entries) return;
for (const [name, item] of Object.entries(entries)) {
if (item.type === 'directory') {
folderCount++;
countEntries(item.entries);
} else if (item.type === 'file') {
fileCount++;
}
}
}
countEntries(fileTree.entries);
updateStatusCounts(folderCount, fileCount);
}

View file

@ -1,808 +0,0 @@
/**
* File system operations using File System Access API
*/
/**
* Open the scratchpad editor
*/
function openScratchpad() {
// Check if scratchpad already exists
if (editorInstances.has(SCRATCHPAD_ID)) {
// Just show it
const instance = editorInstances.get(SCRATCHPAD_ID);
document.getElementById('welcome-screen').classList.add('hidden');
document.getElementById('content-container').classList.remove('hidden');
// Hide all other editors, show scratchpad
editorInstances.forEach((data, path) => {
if (data.fileViewContainer) {
data.fileViewContainer.style.display = path === SCRATCHPAD_ID ? 'flex' : 'none';
}
});
return;
}
// Hide welcome screen, show content container
document.getElementById('welcome-screen').classList.add('hidden');
document.getElementById('content-container').classList.remove('hidden');
// Initialize editor with the welcome text seeded as the starting content.
initializeEditor(SCRATCHPAD_WELCOME, true, SCRATCHPAD_ID, 'Scratchpad', null, null);
// Mark as scratchpad
const instance = editorInstances.get(SCRATCHPAD_ID);
if (instance) {
instance.isScratchpad = true;
}
// Reflect non-empty starting content on the scratchpad row's download button.
updateScratchpadDownloadState();
if (DEBUG) console.log('Opened scratchpad');
}
/**
* Enable/disable the scratchpad-row download button based on whether the
* scratchpad currently holds any content. Idempotent safe to call from
* editor change listeners.
*/
function updateScratchpadDownloadState() {
const btn = document.getElementById('scratchpad-download-btn');
if (!btn) return;
const instance = editorInstances.get(SCRATCHPAD_ID);
let hasContent = false;
if (instance && instance.editor) {
try {
hasContent = (instance.editor.getMarkdown() || '').trim().length > 0;
} catch (_) { /* editor may not be ready yet */ }
}
btn.disabled = !hasContent;
btn.classList.toggle('is-disabled', !hasContent);
}
/**
* Trigger a browser download of the current scratchpad markdown.
* No-op if the scratchpad has no content.
*/
function downloadScratchpad() {
const instance = editorInstances.get(SCRATCHPAD_ID);
if (!instance || !instance.editor) return;
let content = '';
try { content = instance.editor.getMarkdown() || ''; } catch (_) { return; }
// Pull front matter from the textarea if any
if (instance.frontMatterTextarea) {
const fmText = instance.frontMatterTextarea.value.trim();
if (fmText) content = `---\n${fmText}\n---\n${content}`;
}
if (!content.trim()) return;
// Suggest a filename derived from the first H1 if present
let suggested = 'scratchpad.md';
const h1 = content.match(/^#\s+(.+)$/m);
if (h1) {
const slug = h1[1].trim().toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.substring(0, 60);
if (slug) suggested = `${slug}.md`;
}
saveFileAs(content, suggested);
}
/**
* Save file using Save As dialog (for scratchpads or new saves)
* @param {string} content - Content to save
* @param {string} suggestedName - Suggested filename
* @returns {Promise<FileSystemFileHandle|null>} File handle if saved, null otherwise
*/
async function saveFileAs(content, suggestedName = 'untitled.md') {
if (hasFileSystemAccess) {
try {
const fileHandle = await window.showSaveFilePicker({
suggestedName: suggestedName,
types: [{
description: 'Markdown files',
accept: { 'text/markdown': ['.md', '.markdown'] }
}]
});
const writable = await fileHandle.createWritable();
await writable.write(content);
await writable.close();
if (DEBUG) console.log(`File saved as: ${fileHandle.name}`);
return fileHandle;
} catch (error) {
if (error.name === 'AbortError') {
if (DEBUG) console.log('Save cancelled by user');
return null;
}
throw error;
}
} else {
// Fallback: download as blob
const blob = new Blob([content], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = suggestedName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
if (DEBUG) console.log(`File downloaded as: ${suggestedName}`);
return null;
}
}
/**
* Open directory picker and handle selection
*/
async function openDirectory() {
try {
if (!('showDirectoryPicker' in window)) {
throw new Error('The File System API is not supported in this browser.');
}
directoryHandle = await window.showDirectoryPicker();
if (DEBUG) console.log('Directory selected:', directoryHandle.name);
// Local picker wins over any active server-source mode.
serverSourceMode = false;
updateDirectoryStatus(directoryHandle.name);
await readDirectory(directoryHandle);
} catch (error) {
if (error.name === 'AbortError') {
if (DEBUG) console.log('User cancelled the directory selection');
} else {
console.error('Error selecting directory:', error);
alert(`Error: ${error.message}`);
}
}
}
/**
* Update UI to show selected directory
* @param {string} directoryName - Name of the selected directory
*/
function updateDirectoryStatus(directoryName) {
// Standardized header pattern (across all ZDDC tools): the button
// keeps the label "Add Local Directory"; de-emphasize it once a
// directory is loaded (the user can still click to pick another)
// by applying the shared btn--subtle variant. The directory name
// is shown in the file-nav pane, not on the button.
const selectDirectoryBtn = document.getElementById('addDirectoryBtn');
if (selectDirectoryBtn) {
selectDirectoryBtn.classList.remove('btn-primary');
selectDirectoryBtn.classList.add('btn--subtle');
selectDirectoryBtn.title = `Loaded: ${directoryName} — click to switch`;
}
const refreshBtn = document.getElementById('refreshHeaderBtn');
if (refreshBtn) {
refreshBtn.classList.remove('hidden');
}
// Show new file button when directory is selected
const newFileRootBtn = document.getElementById('new-file-root');
if (newFileRootBtn) {
newFileRootBtn.classList.remove('hidden');
}
}
/**
* Read directory contents and build tree structure
* @param {FileSystemDirectoryHandle} dirHandle - Directory handle
* @param {Object} parentNode - Parent node in tree (for recursion)
* @returns {Object} Statistics about the directory
*/
async function readDirectory(dirHandle, parentNode = null) {
if (parentNode === null) {
fileTree = {
name: dirHandle.name,
type: 'directory',
handle: dirHandle,
entries: {}
};
const fileTreeElement = document.getElementById('file-tree');
if (fileTreeElement) {
fileTreeElement.innerHTML = '';
}
parentNode = fileTree;
}
try {
let stats = { folderCount: 0, fileCount: 0 };
for await (const entry of dirHandle.values()) {
if (entry.kind === 'file' && !entry.name.startsWith('_')) {
parentNode.entries[entry.name] = {
name: entry.name,
type: 'file',
handle: entry
};
stats.fileCount++;
} else if (entry.kind === 'directory' && !entry.name.startsWith('_')) {
const dirNode = {
name: entry.name,
type: 'directory',
handle: entry,
entries: {}
};
parentNode.entries[entry.name] = dirNode;
const subStats = await readDirectory(entry, dirNode);
stats.folderCount += subStats.folderCount + 1;
stats.fileCount += subStats.fileCount;
}
}
if (parentNode === fileTree) {
renderFileTree();
updateStatusCounts(stats.folderCount, stats.fileCount);
}
return stats;
} catch (error) {
console.error('Error reading directory:', error);
return { folderCount: 0, fileCount: 0 };
}
}
/**
* Save a file by its path
* @param {string} filePath - Path of the file to save
* @returns {Promise<boolean>} Whether save was successful
*/
async function saveFile(filePath) {
if (!filePath && currentFileHandle) {
filePath = currentFileHandle.name;
} else if (!filePath) {
alert('No file is currently open');
return false;
}
try {
const editorInstance = editorInstances.get(filePath);
if (!editorInstance) {
throw new Error('No editor instance found for this file');
}
if (!editorInstance.isDirty) {
if (DEBUG) console.log(`File ${filePath} is not dirty, skipping save`);
return true;
}
const fileHandle = editorInstance.fileHandle;
if (!fileHandle) {
throw new Error('No file handle available for this file');
}
// Check for external modifications
const file = await fileHandle.getFile();
const currentLastModified = file.lastModified;
const storedLastModified = editorInstance.lastModified;
if (storedLastModified && currentLastModified !== storedLastModified) {
const confirmSave = confirm(
'Warning: This file has been modified outside of the application since you opened it. ' +
'Saving will overwrite those changes. Do you want to continue?'
);
if (!confirmSave) {
if (DEBUG) console.log('Save aborted by user due to external file modifications');
return false;
}
}
// Get markdown content from editor
const markdownContent = editorInstance.editor.getMarkdown();
// Get front matter from textarea
let frontMatterData = {};
if (editorInstance.frontMatterTextarea) {
const frontMatterText = editorInstance.frontMatterTextarea.value.trim();
if (frontMatterText) {
try {
const yamlContent = `---\n${frontMatterText}\n---\n`;
const parsed = parseFrontMatter(yamlContent);
frontMatterData = parsed.data;
} catch (error) {
console.error('Error parsing front matter:', error);
throw new Error(`Invalid YAML front matter: ${error.message}`);
}
}
}
// Apply before save hooks
frontMatterData = await applyBeforeSaveHooks(frontMatterData, markdownContent, fileHandle);
// Combine front matter with markdown
const finalContent = frontMatterData && Object.keys(frontMatterData).length > 0
? stringifyFrontMatter(markdownContent, frontMatterData)
: markdownContent;
// Server-mode files are read-only: fall back to a Save-As download.
if (typeof fileHandle.createWritable !== 'function') {
const downloadName = (fileHandle.name || filePath.split('/').pop() || 'untitled.md');
await saveFileAs(finalContent, downloadName);
editorInstance.isDirty = false;
updateFileDirtyStatus(filePath, false);
updateUnsavedCount();
if (editorInstance.saveButton) editorInstance.saveButton.disabled = true;
return true;
}
// Write to file
const writable = await fileHandle.createWritable();
await writable.write(finalContent);
await writable.close();
// Update state
const updatedFile = await fileHandle.getFile();
editorInstance.lastModified = updatedFile.lastModified;
editorInstance.isDirty = false;
updateFileDirtyStatus(filePath, false);
updateUnsavedCount();
if (editorInstance.saveButton) {
editorInstance.saveButton.disabled = true;
}
if (DEBUG) console.log(`File ${filePath} saved successfully!`);
await applyAfterSaveHooks(frontMatterData, markdownContent, fileHandle);
return true;
} catch (error) {
console.error(`Error saving file ${filePath}:`, error);
alert(`Error saving file: ${error.message}`);
return false;
}
}
/**
* Save all files with unsaved changes
* @returns {Promise<{saved: number, failed: number}>}
*/
async function saveAllFiles() {
let saved = 0;
let failed = 0;
const dirtyFiles = [];
editorInstances.forEach((instance, filePath) => {
if (instance.isDirty) {
dirtyFiles.push(filePath);
}
});
if (dirtyFiles.length === 0) {
if (DEBUG) console.log('No files with unsaved changes');
return { saved, failed };
}
for (const filePath of dirtyFiles) {
try {
const success = await saveFile(filePath);
if (success) {
saved++;
} else {
failed++;
}
} catch (error) {
console.error(`Error saving file ${filePath}:`, error);
failed++;
}
}
if (failed === 0) {
if (DEBUG) console.log(`All ${saved} files saved successfully`);
} else {
if (DEBUG) console.log(`Saved ${saved} files, ${failed} files failed to save`);
}
return { saved, failed };
}
/**
* Reload file from disk
* @param {string} filePath - Path of file to reload
* @returns {Promise<boolean>} Whether reload was successful
*/
async function reloadFileFromDisk(filePath) {
try {
const editorInstance = editorInstances.get(filePath);
if (!editorInstance) {
throw new Error('No editor instance found for this file');
}
if (editorInstance.isDirty) {
const confirmReload = confirm(
'This file has unsaved changes. Reloading will discard all changes. ' +
'Do you want to continue?'
);
if (!confirmReload) {
if (DEBUG) console.log('Reload cancelled by user');
return false;
}
}
const fileHandle = editorInstance.fileHandle;
if (!fileHandle) {
throw new Error('No file handle available for this file');
}
const file = await fileHandle.getFile();
const fileContent = await file.text();
editorInstance.lastModified = file.lastModified;
if (filePath.endsWith('.md') || filePath.endsWith('.markdown')) {
const parsed = parseFrontMatter(fileContent);
if (editorInstance.frontMatterTextarea) {
const frontMatterYaml = stringifyFrontMatterToTextarea(parsed.data);
editorInstance.frontMatterTextarea.value = frontMatterYaml;
}
let currentScrollTop = 0;
try {
currentScrollTop = editorInstance.editor.getScrollTop();
} catch (error) {
if (DEBUG) console.debug('Could not get scroll position:', error);
}
editorInstance.editor.setMarkdown(parsed.content);
setTimeout(() => {
try {
editorInstance.editor.setScrollTop(currentScrollTop);
} catch (error) {
if (DEBUG) console.debug('Could not restore scroll position:', error);
}
}, 100);
if (editorInstance.tocContainer) {
try {
updateToc(parsed.content, editorInstance.tocContainer, editorInstance.editor, tocMaxDepth);
} catch (error) {
console.error('Error updating TOC during reload:', error);
}
}
} else {
editorInstance.editor.setMarkdown(fileContent);
}
editorInstance.isDirty = false;
updateFileDirtyStatus(filePath, false);
updateUnsavedCount();
if (editorInstance.saveButton) {
editorInstance.saveButton.disabled = true;
}
if (DEBUG) console.log(`File ${filePath} reloaded successfully from disk!`);
return true;
} catch (error) {
console.error(`Error reloading file ${filePath}:`, error);
alert(`Error reloading file: ${error.message}`);
return false;
}
}
/**
* Before save hook - apply modifications before saving
*/
async function applyBeforeSaveHooks(frontMatter, markdownContent, fileHandle) {
frontMatter.lastModified = new Date().toISOString();
if (!frontMatter.title) {
const firstHeading = markdownContent.match(/^#\s+(.+)$/m);
if (firstHeading) {
frontMatter.title = firstHeading[1];
}
}
const customTags = (markdownContent.match(/<(deliverable|meeting|report|trkno)>/g) || []).length;
if (customTags > 0) {
frontMatter.customTagCount = customTags;
}
return frontMatter;
}
/**
* After save hook - perform actions after saving
*/
async function applyAfterSaveHooks(frontMatter, markdownContent, fileHandle) {
const tags = ['deliverable', 'meeting', 'report', 'trkno'];
const preservedTags = tags.filter(tag => markdownContent.includes(`<${tag}>`));
if (preservedTags.length > 0) {
if (DEBUG) console.log(`Preserved custom tags: ${preservedTags.join(', ')}`);
}
}
/**
* Refresh directory from disk without losing unsaved work
*/
async function refreshDirectory() {
if (serverSourceMode) {
await loadServerDirectory();
return;
}
if (!directoryHandle) {
if (DEBUG) console.log('No directory selected, cannot refresh');
return;
}
// Get active file path from DOM before refresh
const activeFileEl = document.querySelector('.file-item.active-file');
const activeFilePath = activeFileEl ? activeFileEl.dataset.path : null;
// Get dirty files from editorInstances
const dirtyFiles = new Set();
editorInstances.forEach((instance, filePath) => {
if (instance.isDirty) {
dirtyFiles.add(filePath);
}
});
// Re-read directory (calls renderFileTree at the end)
await readDirectory(directoryHandle);
// Restore active file state
if (activeFilePath) {
const activeElement = document.querySelector(`.file-item[data-path="${activeFilePath}"]`);
if (activeElement) {
activeElement.classList.add('active-file');
}
}
// Restore dirty indicators
dirtyFiles.forEach(filePath => {
updateFileDirtyStatus(filePath, true);
});
}
/**
* Surface a clear "no permission to list this directory" message in
* the file tree pane when the server returns 403 on the initial
* listing. Distinct from "host doesn't serve JSON" so the user
* understands why the tree is empty.
*/
function showServerForbiddenMessage() {
const treeEl = document.getElementById('file-tree');
if (!treeEl) return;
treeEl.innerHTML =
'<div class="server-forbidden-message" style="padding: 1rem; color: var(--text-muted, #555); font-size: 0.875rem;">' +
'<strong>No permission to list this directory.</strong>' +
'<p style="margin: 0.5rem 0 0;">Your account does not have read access here. ' +
'Contact the document controller if you believe this is wrong.</p>' +
'</div>';
}
/**
* Build a CRUD-capable file handle backed by a URL uses the shared
* HTTP polyfill from window.zddc.source. The polyfill's getFile() does
* a GET, and createWritable() PUTs bytes back (file API on zddc-server).
*
* Adds `_serverUrl` for legacy code paths that still expect that field.
* Marks `_readOnly: false` so editor.js leaves save buttons enabled.
*/
function createServerFileHandle(name, url) {
const handle = new window.zddc.source.HttpFileHandle(url, name);
handle._serverUrl = url;
handle._readOnly = false;
return handle;
}
/**
* Build a CRUD-capable directory handle backed by a server URL uses
* the shared HTTP polyfill. Supports values()/entries(), getFileHandle,
* getDirectoryHandle({create}), and removeEntry() against the server
* file API. _serverUrl/_readOnly are kept for legacy probes.
*/
function createServerDirectoryHandle(name, url) {
const handle = new window.zddc.source.HttpDirectoryHandle(url, name);
handle._serverUrl = url;
handle._readOnly = false;
return handle;
}
/**
* Recursively fetch the JSON directory listing for `dirUrl` and populate
* `parentNode.entries` with synthetic handles. Returns folder/file counts.
* Uses the same Caddy/zddc-server JSON shape archive's source.js consumes.
*/
async function readServerDirectory(dirUrl, parentNode, depth) {
if (depth > 10) return { folderCount: 0, fileCount: 0 };
let items;
try {
const resp = await fetch(dirUrl, { headers: { 'Accept': 'application/json' }, cache: 'no-cache' });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
items = await resp.json();
if (!Array.isArray(items)) throw new Error('Expected JSON array');
} catch (err) {
if (DEBUG) console.warn(`Server listing failed for ${dirUrl}:`, err);
return { folderCount: 0, fileCount: 0 };
}
const stats = { folderCount: 0, fileCount: 0 };
const subdirPromises = [];
for (const item of items) {
const rawName = item.name.endsWith('/') ? item.name.slice(0, -1) : item.name;
if (rawName.startsWith('.') || rawName.startsWith('_')) continue;
const base = dirUrl.endsWith('/') ? dirUrl : dirUrl + '/';
const childUrl = base + encodeURIComponent(rawName) + (item.is_dir ? '/' : '');
if (item.is_dir) {
const dirNode = {
name: rawName,
type: 'directory',
handle: createServerDirectoryHandle(rawName, childUrl),
entries: {},
};
parentNode.entries[rawName] = dirNode;
stats.folderCount++;
subdirPromises.push(
readServerDirectory(childUrl, dirNode, depth + 1).then((s) => {
stats.folderCount += s.folderCount;
stats.fileCount += s.fileCount;
})
);
} else {
parentNode.entries[rawName] = {
name: rawName,
type: 'file',
handle: createServerFileHandle(rawName, childUrl),
};
stats.fileCount++;
}
}
await Promise.all(subdirPromises);
return stats;
}
/**
* Detect HTTP context, fetch the directory the page lives under, and render
* the resulting subtree in the file pane. Idempotent safe to re-call.
*/
async function loadServerDirectory() {
if (!(location.protocol === 'http:' || location.protocol === 'https:')) return;
// Compute the directory URL the file tree should be rooted at.
//
// <project>/working/ → root = <project>/working/
// <project>/working/x/y/ → root = <project>/working/x/y/
// <project>/working → root = <project>/working/ (no-slash
// canonical-folder URL — the dispatcher
// routes mdedit here directly without
// a redirect, so we infer "directory"
// from the absence of a `.` in the
// last segment rather than stripping
// back to the parent.)
// <project>/x/y/mdedit.html → root = <project>/x/y/ (the leaf
// segment IS a file; strip to parent.)
//
// The rule: if the last path segment contains a "." it's a file,
// strip it; otherwise treat the whole path as the directory.
let href = window.location.href.split('?')[0].split('#')[0];
let baseUrl;
if (href.endsWith('/')) {
baseUrl = href;
} else {
const lastSlash = href.lastIndexOf('/');
const lastSeg = lastSlash >= 0 ? href.substring(lastSlash + 1) : href;
if (lastSeg.indexOf('.') !== -1) {
// Looks like a file (has an extension) — strip to parent.
baseUrl = lastSlash >= 0 ? href.substring(0, lastSlash + 1) : href + '/';
} else {
// Looks like a directory — append the trailing slash so all
// subsequent listing URLs are computed correctly.
baseUrl = href + '/';
}
}
// Only enter server-source mode if the host actually serves JSON directory
// listings (zddc-server / Caddy). On a plain static host the probe fails
// and we must leave "Add Local Directory" visible so the user can still
// load local files.
//
// 403 means the host is a zddc-server but the user lacks `r` on this
// directory (a "no list" permission posture). Show a clear message so
// the user understands why the tree is empty.
try {
const resp = await fetch(baseUrl, { headers: { 'Accept': 'application/json' }, cache: 'no-cache' });
if (resp.status === 403) {
showServerForbiddenMessage();
return;
}
if (!resp.ok) return;
const items = await resp.json();
if (!Array.isArray(items)) return;
} catch (_) {
return;
}
serverSourceMode = true;
const rootName = (() => {
const path = baseUrl.replace(/\/$/, '');
const seg = path.substring(path.lastIndexOf('/') + 1);
return seg || baseUrl;
})();
fileTree = {
name: rootName,
type: 'directory',
handle: createServerDirectoryHandle(rootName, baseUrl),
entries: {},
};
// Surface refresh. The server now exposes a CRUD file API, so write
// controls (new file, save, delete) stay enabled — the polyfill
// routes their writes through PUT/DELETE/POST. "Add Local Directory"
// is de-emphasized so the user can still load a local folder if they
// want, but server-mode is now the default working mode.
const refreshBtn = document.getElementById('refreshHeaderBtn');
if (refreshBtn) refreshBtn.classList.remove('hidden');
const addDirBtn = document.getElementById('addDirectoryBtn');
if (addDirBtn) {
addDirBtn.classList.remove('btn-primary');
addDirBtn.classList.add('btn--subtle');
}
const stats = await readServerDirectory(baseUrl, fileTree, 0);
renderFileTree();
updateStatusCounts(stats.folderCount, stats.fileCount);
}
/**
* Start monitoring files for external changes
*/
function startFileChangeMonitoring() {
setInterval(async () => {
for (const [filePath, editorInstance] of editorInstances) {
try {
const fileHandle = editorInstance.fileHandle;
if (!fileHandle) continue;
if (fileHandle._readOnly) continue;
const file = await fileHandle.getFile();
const currentLastModified = file.lastModified;
const storedLastModified = editorInstance.lastModified;
if (storedLastModified && currentLastModified !== storedLastModified) {
if (DEBUG) console.log(`File ${filePath} changed externally`);
const action = confirm(
`File "${filePath}" has been modified by another application.\n\n` +
'Click OK to reload from disk (discards unsaved changes)\n' +
'Click Cancel to keep current version'
);
if (action) {
await reloadFileFromDisk(filePath);
} else {
editorInstance.lastModified = currentLastModified;
}
}
} catch (error) {
if (DEBUG) console.debug(`Error checking file ${filePath}:`, error.message);
}
}
}, 3000);
}

View file

@ -1,868 +0,0 @@
/**
* File tree rendering and navigation
*/
// Cache for lazily loaded CDN libraries
const loadedLibraries = new Map();
/**
* Lazily load a script from CDN. Returns a promise that resolves when loaded.
* Caches the promise so subsequent calls return immediately.
*/
function loadLibrary(url) {
if (loadedLibraries.has(url)) return loadedLibraries.get(url);
const promise = new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = url;
script.onload = resolve;
script.onerror = () => reject(new Error(`Failed to load library: ${url}`));
document.head.appendChild(script);
});
loadedLibraries.set(url, promise);
return promise;
}
/**
* Render the file tree in the UI
*/
/**
* Create action buttons for file/directory items
* @param {string} filePath - Full path of the file/dir
* @param {string} type - 'file' or 'directory'
*/
function createActionButtons(filePath, type) {
const actionsDiv = document.createElement('div');
actionsDiv.className = 'tree-actions';
// Server mode now supports full CRUD via the file API — drop the
// legacy short-circuit that hid the rename/delete/new-file actions.
if (type === 'directory') {
// Directory: + (new file) + ✕ (delete)
const newFileBtn = document.createElement('button');
newFileBtn.className = 'tree-btn';
newFileBtn.setAttribute('title', 'New file');
newFileBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12h14"/></svg>';
newFileBtn.onclick = (e) => {
e.stopPropagation();
createNewFile(filePath);
};
const deleteBtn = document.createElement('button');
deleteBtn.className = 'tree-btn tree-btn--danger';
deleteBtn.setAttribute('title', 'Delete');
deleteBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>';
deleteBtn.onclick = (e) => {
e.stopPropagation();
deleteEntry(filePath, true);
};
actionsDiv.appendChild(newFileBtn);
actionsDiv.appendChild(deleteBtn);
} else {
// File: ✎ (rename) + ✕ (delete)
const renameBtn = document.createElement('button');
renameBtn.className = 'tree-btn';
renameBtn.setAttribute('title', 'Rename');
renameBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg>';
renameBtn.onclick = (e) => {
e.stopPropagation();
renameEntry(filePath, false);
};
const deleteBtn = document.createElement('button');
deleteBtn.className = 'tree-btn tree-btn--danger';
deleteBtn.setAttribute('title', 'Delete');
deleteBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>';
deleteBtn.onclick = (e) => {
e.stopPropagation();
deleteEntry(filePath, false);
};
actionsDiv.appendChild(renameBtn);
actionsDiv.appendChild(deleteBtn);
}
return actionsDiv;
}
function renderFileTree() {
const fileTreeElement = document.getElementById('file-tree');
if (!fileTreeElement) return;
fileTreeElement.innerHTML = '';
// Always show scratchpad at top
const scratchpadElement = document.createElement('div');
scratchpadElement.className = 'file-item tree-row px-2 py-1 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-gray-800 border-b border-gray-200 dark:border-gray-700 mb-2';
scratchpadElement.dataset.type = 'file';
scratchpadElement.dataset.path = SCRATCHPAD_ID;
scratchpadElement.dataset.name = 'Scratchpad';
const scratchLabel = document.createElement('span');
scratchLabel.className = 'tree-row__label';
scratchLabel.innerHTML = '<span class="tree-row__name"><div class="filename-main">📝 Scratchpad</div><div class="filename-secondary">Quick notes — no directory needed</div></span>';
scratchpadElement.appendChild(scratchLabel);
const scratchActions = document.createElement('div');
scratchActions.className = 'tree-actions tree-actions--always';
const scratchDownloadBtn = document.createElement('button');
scratchDownloadBtn.id = 'scratchpad-download-btn';
scratchDownloadBtn.className = 'tree-btn';
scratchDownloadBtn.title = 'Download scratchpad as a Markdown file';
scratchDownloadBtn.setAttribute('aria-label', 'Download scratchpad');
scratchDownloadBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12"/><path d="M7 10l5 5 5-5"/><path d="M5 21h14"/></svg>';
scratchDownloadBtn.disabled = true;
scratchDownloadBtn.classList.add('is-disabled');
scratchDownloadBtn.onclick = (e) => {
e.stopPropagation();
if (scratchDownloadBtn.disabled) return;
downloadScratchpad();
};
scratchActions.appendChild(scratchDownloadBtn);
scratchpadElement.appendChild(scratchActions);
scratchpadElement.addEventListener('click', (event) => {
event.stopPropagation();
openScratchpad();
document.querySelectorAll('.file-item').forEach(el => el.classList.remove('active-file'));
scratchpadElement.classList.add('active-file');
updateScratchpadDownloadState();
});
fileTreeElement.appendChild(scratchpadElement);
// Sync button state with current scratchpad content (re-renders preserve it)
updateScratchpadDownloadState();
function createFileTreeHTML(directory, parentElement, path = '') {
if (!directory || !directory.entries) return;
// Sort entries: files first, then directories, alphabetically
const sortedEntries = Object.entries(directory.entries).sort((a, b) => {
const [nameA, itemA] = a;
const [nameB, itemB] = b;
if (itemA.type !== itemB.type) {
return itemA.type === 'file' ? -1 : 1;
}
return nameA.localeCompare(nameB);
});
for (const [name, item] of sortedEntries) {
if (item.type === 'directory') {
const dirElement = document.createElement('div');
dirElement.className = 'directory-item tree-row px-2 py-1 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-gray-800 collapsed';
dirElement.dataset.type = 'directory';
const currentPath = path ? `${path}/${name}` : name;
dirElement.dataset.path = currentPath;
const dirIcon = document.createElement('span');
dirIcon.className = 'dir-icon mr-1';
dirIcon.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"></polyline></svg>';
const dirName = document.createElement('span');
dirName.className = 'tree-row__name';
const parsedFolder = zddc.parseFolder(name);
if (parsedFolder && parsedFolder.valid) {
const meta = `${parsedFolder.trackingNumber} (${parsedFolder.status}) — ${parsedFolder.date}`;
dirName.innerHTML = `<div class="filename-main">📁 ${escapeHtml(parsedFolder.title)}</div><div class="filename-secondary">${escapeHtml(meta)}</div>`;
} else {
// Non-ZDDC folder: still wrap in filename-main so
// typography matches the two-line entries (same font
// size + weight; just no secondary line).
dirName.innerHTML = `<div class="filename-main">📁 ${escapeHtml(name)}</div>`;
}
const dirLabel = document.createElement('span');
dirLabel.className = 'tree-row__label';
dirLabel.appendChild(dirIcon);
dirLabel.appendChild(dirName);
const dirActions = createActionButtons(currentPath, 'directory');
dirElement.appendChild(dirLabel);
dirElement.appendChild(dirActions);
parentElement.appendChild(dirElement);
const contentsElement = document.createElement('div');
contentsElement.className = 'directory-contents ml-4';
contentsElement.style.display = 'none';
parentElement.appendChild(contentsElement);
dirElement.addEventListener('click', (event) => {
event.stopPropagation();
dirElement.classList.toggle('collapsed');
const contents = dirElement.nextElementSibling;
if (contents && contents.classList.contains('directory-contents')) {
contents.style.display = dirElement.classList.contains('collapsed') ? 'none' : 'block';
}
});
createFileTreeHTML(item, contentsElement, currentPath);
} else if (item.type === 'file') {
const fileElement = document.createElement('div');
fileElement.className = 'file-item tree-row px-2 py-1 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-gray-800';
fileElement.dataset.type = 'file';
const filePath = path ? `${path}/${name}` : name;
fileElement.dataset.path = filePath;
fileElement.dataset.name = name;
const fileIcon = getFileTypeIcon(name);
// Build the inner two-line text inside a tree-row__name
// wrapper (column-flex). ZDDC-conforming filenames split
// into title + meta; "Title - filename.ext" pattern uses
// the dash as the same split. Plain names get a single
// line via filename-main only — same wrapper, just no
// secondary div, so the layout stays consistent.
let fileNameInner;
const parsed = zddc.parseFilename(name);
if (parsed && parsed.valid) {
const titleDisplay = escapeHtml(parsed.title);
const metaDisplay = escapeHtml(`${parsed.trackingNumber}_${parsed.revision} (${parsed.status})`);
fileNameInner = `<div class="filename-main">${fileIcon} ${titleDisplay}</div><div class="filename-secondary">${metaDisplay}</div>`;
} else if (name.includes(' - ')) {
const dashIdx = name.lastIndexOf(' - ');
const secondary = escapeHtml(name.substring(0, dashIdx));
const primary = escapeHtml(name.substring(dashIdx + 3).replace(/\.[^.]+$/, ''));
fileNameInner = `<div class="filename-main">${fileIcon} ${primary}</div><div class="filename-secondary">${secondary}</div>`;
} else {
fileNameInner = `<div class="filename-main">${fileIcon} ${escapeHtml(name)}</div>`;
}
const fileLabel = document.createElement('span');
fileLabel.className = 'tree-row__label';
fileLabel.innerHTML = `<span class="tree-row__name">${fileNameInner}</span>`;
const fileActions = createActionButtons(filePath, 'file');
fileElement.innerHTML = '';
fileElement.appendChild(fileLabel);
fileElement.appendChild(fileActions);
fileElement.addEventListener('click', (event) => {
event.stopPropagation();
handleFileClick(item.handle, filePath, fileElement);
});
parentElement.appendChild(fileElement);
}
}
}
createFileTreeHTML(fileTree, fileTreeElement);
}
/**
* Handle click on a file in the file tree
* @param {FileSystemFileHandle} fileHandle - The file handle
* @param {string} filePath - Path of the file
* @param {HTMLElement} fileElement - The clicked element
*/
async function handleFileClick(fileHandle, filePath, fileElement) {
try {
currentFileHandle = fileHandle;
// Remove active class from all file items
const allFileItems = document.querySelectorAll('.file-item');
allFileItems.forEach(item => {
item.classList.remove('active-file');
item.style.backgroundColor = '';
item.style.color = '';
});
// Add active class to clicked file
fileElement.classList.add('active-file');
fileElement.style.backgroundColor = '#3b82f6';
fileElement.style.color = 'white';
await displayFileContent(fileHandle, filePath);
} catch (error) {
console.error('Error handling file click:', error);
alert(`Error opening file: ${error.message}`);
}
}
/**
* Display file content in main area
* @param {FileSystemFileHandle} fileHandle - File handle
* @param {string} filePath - Path of the file
*/
async function displayFileContent(fileHandle, filePath) {
try {
currentFileHandle = fileHandle;
const file = await fileHandle.getFile();
const fileName = file.name;
const lastModified = file.lastModified;
document.getElementById('welcome-screen').classList.add('hidden');
document.getElementById('content-container').classList.remove('hidden');
const lower = fileName.toLowerCase();
const lastDot = lower.lastIndexOf('.');
const ext = lastDot >= 0 ? lower.substring(lastDot + 1) : '';
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg'];
const isImage = imageExtensions.some(e => lower.endsWith(e));
const isTiff = window.zddc && window.zddc.preview && window.zddc.preview.isTiff(ext);
const isZip = lower.endsWith('.zip');
const isHtml = lower.endsWith('.html') || lower.endsWith('.htm');
const isDocx = lower.endsWith('.docx');
const isXlsx = lower.endsWith('.xlsx') || lower.endsWith('.xls');
const isPdf = lower.endsWith('.pdf');
if (isImage) {
displayImagePreview(file, filePath, fileName, fileHandle, lastModified);
} else if (isTiff) {
displayTiffPreview(file, filePath, fileName, fileHandle, lastModified);
} else if (isZip) {
displayZipPreview(file, filePath, fileName, fileHandle, lastModified);
} else if (isHtml) {
displayHtmlPreview(file, filePath, fileName, fileHandle, lastModified);
} else if (isDocx) {
displayDocxPreview(file, filePath, fileName, fileHandle, lastModified);
} else if (isXlsx) {
displayXlsxPreview(file, filePath, fileName, fileHandle, lastModified);
} else if (isPdf) {
displayPdfPreview(file, filePath, fileName, fileHandle, lastModified);
} else {
const content = await file.text();
if (fileName.toLowerCase().endsWith('.md')) {
initializeEditor(content, true, filePath, fileName, fileHandle, lastModified);
} else {
initializeEditor(content, false, filePath, fileName, fileHandle, lastModified);
}
}
} catch (error) {
console.error('Error displaying file content:', error);
alert(`Error opening file: ${error.message}`);
}
}
/**
* Display image preview
*/
async function displayImagePreview(file, filePath, fileName, fileHandle, lastModified) {
const contentContainer = document.getElementById('content-container');
if (!contentContainer) {
alert('Error: content-container element not found!');
return;
}
document.querySelectorAll('.file-view-container').forEach(container => {
container.style.display = 'none';
});
if (editorInstances.has(filePath)) {
const existingInstance = editorInstances.get(filePath);
if (existingInstance.fileViewContainer) {
existingInstance.fileViewContainer.style.display = 'flex';
}
return;
}
const fileViewContainer = document.createElement('div');
fileViewContainer.className = 'file-view-container flex flex-col h-full';
const fileHeader = document.createElement('div');
fileHeader.className = 'file-header flex justify-between items-center px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 font-medium border-b border-gray-200 dark:border-gray-700';
const fileTitle = document.createElement('span');
fileTitle.textContent = fileName || 'No file selected';
fileHeader.appendChild(fileTitle);
fileViewContainer.appendChild(fileHeader);
const imageContainer = document.createElement('div');
imageContainer.className = 'image-preview-container flex-1 overflow-auto p-4';
const imageElement = document.createElement('img');
imageElement.className = 'image-preview';
imageElement.alt = fileName;
const objectUrl = URL.createObjectURL(file);
imageElement.src = objectUrl;
imageContainer.appendChild(imageElement);
fileViewContainer.appendChild(imageContainer);
contentContainer.appendChild(fileViewContainer);
const instanceData = {
fileViewContainer: fileViewContainer,
fileHandle: fileHandle,
lastModified: lastModified,
isDirty: false,
objectUrl: objectUrl
};
editorInstances.set(filePath, instanceData);
}
/**
* Display TIFF preview using shared zddc.preview.renderTiff (UTIF.js + canvas).
*/
async function displayTiffPreview(file, filePath, fileName, fileHandle, lastModified) {
const contentContainer = document.getElementById('content-container');
if (!contentContainer) return;
document.querySelectorAll('.file-view-container').forEach(c => { c.style.display = 'none'; });
if (editorInstances.has(filePath)) {
const existing = editorInstances.get(filePath);
if (existing.fileViewContainer) existing.fileViewContainer.style.display = 'flex';
return;
}
const fileViewContainer = document.createElement('div');
fileViewContainer.className = 'file-view-container flex flex-col h-full';
const fileHeader = document.createElement('div');
fileHeader.className = 'file-header flex justify-between items-center px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 font-medium border-b border-gray-200 dark:border-gray-700';
const fileTitle = document.createElement('span');
fileTitle.textContent = fileName || 'No file selected';
fileHeader.appendChild(fileTitle);
fileViewContainer.appendChild(fileHeader);
const tiffContainer = document.createElement('div');
tiffContainer.className = 'flex-1 min-h-0';
tiffContainer.style.display = 'flex';
tiffContainer.style.flexDirection = 'column';
fileViewContainer.appendChild(tiffContainer);
contentContainer.appendChild(fileViewContainer);
try {
const arrayBuffer = await file.arrayBuffer();
await window.zddc.preview.renderTiff(document, tiffContainer, arrayBuffer, { fileName: fileName });
} catch (err) {
console.error('Error rendering TIFF:', err);
tiffContainer.textContent = 'Error rendering TIFF: ' + (err.message || err);
}
editorInstances.set(filePath, { fileViewContainer, fileHandle, lastModified, isDirty: false });
}
/**
* Display ZIP listing using shared zddc.preview.renderZipListing.
*/
async function displayZipPreview(file, filePath, fileName, fileHandle, lastModified) {
const contentContainer = document.getElementById('content-container');
if (!contentContainer) return;
document.querySelectorAll('.file-view-container').forEach(c => { c.style.display = 'none'; });
if (editorInstances.has(filePath)) {
const existing = editorInstances.get(filePath);
if (existing.fileViewContainer) existing.fileViewContainer.style.display = 'flex';
return;
}
const fileViewContainer = document.createElement('div');
fileViewContainer.className = 'file-view-container flex flex-col h-full';
const fileHeader = document.createElement('div');
fileHeader.className = 'file-header flex justify-between items-center px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 font-medium border-b border-gray-200 dark:border-gray-700';
const fileTitle = document.createElement('span');
fileTitle.textContent = fileName || 'No file selected';
fileHeader.appendChild(fileTitle);
fileViewContainer.appendChild(fileHeader);
const zipContainer = document.createElement('div');
zipContainer.className = 'flex-1 min-h-0';
zipContainer.style.display = 'flex';
zipContainer.style.flexDirection = 'column';
fileViewContainer.appendChild(zipContainer);
contentContainer.appendChild(fileViewContainer);
try {
const arrayBuffer = await file.arrayBuffer();
await window.zddc.preview.renderZipListing(document, zipContainer, arrayBuffer, { fileName: fileName });
} catch (err) {
console.error('Error rendering ZIP listing:', err);
zipContainer.textContent = 'Error reading ZIP: ' + (err.message || err);
}
editorInstances.set(filePath, { fileViewContainer, fileHandle, lastModified, isDirty: false });
}
/**
* Display HTML preview in sandboxed iframe
*/
async function displayHtmlPreview(file, filePath, fileName, fileHandle, lastModified) {
const contentContainer = document.getElementById('content-container');
if (!contentContainer) {
alert('Error: content-container element not found!');
return;
}
document.querySelectorAll('.file-view-container').forEach(container => {
container.style.display = 'none';
});
if (editorInstances.has(filePath)) {
const existingInstance = editorInstances.get(filePath);
if (existingInstance.fileViewContainer) {
existingInstance.fileViewContainer.style.display = 'flex';
}
return;
}
const htmlContent = await file.text();
const fileViewContainer = document.createElement('div');
fileViewContainer.className = 'file-view-container flex flex-col h-full';
const fileHeader = document.createElement('div');
fileHeader.className = 'file-header flex justify-between items-center px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 font-medium border-b border-gray-200 dark:border-gray-700';
const fileTitle = document.createElement('span');
fileTitle.textContent = fileName || 'No file selected';
fileHeader.appendChild(fileTitle);
fileViewContainer.appendChild(fileHeader);
const htmlContainer = document.createElement('div');
htmlContainer.className = 'html-preview-container flex-1 overflow-hidden';
const iframe = document.createElement('iframe');
iframe.className = 'html-preview-iframe w-full h-full border-0';
iframe.setAttribute('sandbox', 'allow-same-origin allow-scripts allow-forms allow-popups allow-modals');
iframe.setAttribute('loading', 'lazy');
iframe.srcdoc = htmlContent;
htmlContainer.appendChild(iframe);
fileViewContainer.appendChild(htmlContainer);
contentContainer.appendChild(fileViewContainer);
const instanceData = {
fileViewContainer: fileViewContainer,
fileHandle: fileHandle,
lastModified: lastModified,
isDirty: false,
iframe: iframe
};
editorInstances.set(filePath, instanceData);
iframe.addEventListener('load', () => {
try {
const iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
if (iframeDoc) {
iframeDoc.addEventListener('click', function (e) {
const link = e.target.closest('a');
if (link && link.getAttribute('href')) {
const href = link.getAttribute('href');
if (href.startsWith('#')) {
e.preventDefault();
const targetId = href.substring(1);
const targetElement = iframeDoc.getElementById(targetId);
if (targetElement) {
targetElement.scrollIntoView({ behavior: 'smooth' });
}
}
}
});
}
} catch (error) {
if (DEBUG) console.log('Cannot access iframe content for navigation handling:', error);
}
});
}
/**
* Display DOCX preview in main content area
*/
async function displayDocxPreview(file, filePath, fileName, fileHandle, lastModified) {
const contentContainer = document.getElementById('content-container');
if (!contentContainer) {
alert('Error: content-container element not found!');
return;
}
document.querySelectorAll('.file-view-container').forEach(container => {
container.style.display = 'none';
});
if (editorInstances.has(filePath)) {
const existingInstance = editorInstances.get(filePath);
if (existingInstance.fileViewContainer) {
existingInstance.fileViewContainer.style.display = 'flex';
}
return;
}
const fileViewContainer = document.createElement('div');
fileViewContainer.className = 'file-view-container flex flex-col h-full';
const fileHeader = document.createElement('div');
fileHeader.className = 'file-header flex justify-between items-center px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 font-medium border-b border-gray-200 dark:border-gray-700';
const fileTitle = document.createElement('span');
fileTitle.textContent = fileName || 'No file selected';
fileHeader.appendChild(fileTitle);
fileViewContainer.appendChild(fileHeader);
const docxContainer = document.createElement('div');
docxContainer.className = 'flex-1 overflow-auto p-4';
docxContainer.innerHTML = '<div style="text-align:center;padding:2rem;color:#666;">Loading preview...</div>';
fileViewContainer.appendChild(docxContainer);
contentContainer.appendChild(fileViewContainer);
const instanceData = {
fileViewContainer: fileViewContainer,
fileHandle: fileHandle,
lastModified: lastModified,
isDirty: false
};
editorInstances.set(filePath, instanceData);
try {
// jszip + docx-preview bundled into the dist HTML; window.JSZip
// and window.docx are available synchronously.
const arrayBuffer = await file.arrayBuffer();
docxContainer.innerHTML = '';
await window.docx.renderAsync(arrayBuffer, docxContainer);
} catch (err) {
console.error('Error rendering DOCX:', err);
docxContainer.innerHTML = `<div style="text-align:center;padding:2rem;color:#c00;">Error rendering DOCX: ${err.message}</div>`;
}
}
/**
* Display XLSX/XLS preview in main content area
*/
async function displayXlsxPreview(file, filePath, fileName, fileHandle, lastModified) {
const contentContainer = document.getElementById('content-container');
if (!contentContainer) {
alert('Error: content-container element not found!');
return;
}
document.querySelectorAll('.file-view-container').forEach(container => {
container.style.display = 'none';
});
if (editorInstances.has(filePath)) {
const existingInstance = editorInstances.get(filePath);
if (existingInstance.fileViewContainer) {
existingInstance.fileViewContainer.style.display = 'flex';
}
return;
}
const fileViewContainer = document.createElement('div');
fileViewContainer.className = 'file-view-container flex flex-col h-full';
const fileHeader = document.createElement('div');
fileHeader.className = 'file-header flex justify-between items-center px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 font-medium border-b border-gray-200 dark:border-gray-700';
const fileTitle = document.createElement('span');
fileTitle.textContent = fileName || 'No file selected';
fileHeader.appendChild(fileTitle);
fileViewContainer.appendChild(fileHeader);
const xlsxContainer = document.createElement('div');
xlsxContainer.className = 'flex-1 overflow-auto';
xlsxContainer.innerHTML = '<div style="text-align:center;padding:2rem;color:#666;">Loading preview...</div>';
fileViewContainer.appendChild(xlsxContainer);
contentContainer.appendChild(fileViewContainer);
const instanceData = {
fileViewContainer: fileViewContainer,
fileHandle: fileHandle,
lastModified: lastModified,
isDirty: false
};
editorInstances.set(filePath, instanceData);
try {
// XLSX bundled into the dist HTML; window.XLSX is available
// synchronously, no runtime load needed.
const arrayBuffer = await file.arrayBuffer();
const workbook = XLSX.read(arrayBuffer, { type: 'array' });
xlsxContainer.innerHTML = '';
if (workbook.SheetNames.length > 1) {
const tabs = document.createElement('div');
tabs.style.cssText = 'display:flex;gap:0;border-bottom:1px solid #ddd;background:#f5f5f5;';
const tableArea = document.createElement('div');
tableArea.className = 'flex-1 overflow-auto';
workbook.SheetNames.forEach((name, i) => {
const tab = document.createElement('button');
tab.textContent = name;
tab.style.cssText = 'padding:0.4rem 1rem;cursor:pointer;border:1px solid transparent;border-bottom:none;font-size:0.85rem;background:transparent;';
if (i === 0) tab.style.cssText += 'background:white;border-color:#ddd;border-bottom-color:white;margin-bottom:-1px;font-weight:500;';
tab.onclick = () => {
tabs.querySelectorAll('button').forEach(t => { t.style.background = 'transparent'; t.style.borderColor = 'transparent'; t.style.fontWeight = 'normal'; });
tab.style.cssText = 'padding:0.4rem 1rem;cursor:pointer;border:1px solid #ddd;border-bottom-color:white;font-size:0.85rem;background:white;margin-bottom:-1px;font-weight:500;';
renderXlsxSheet(workbook, name, tableArea);
};
tabs.appendChild(tab);
});
xlsxContainer.appendChild(tabs);
xlsxContainer.appendChild(tableArea);
renderXlsxSheet(workbook, workbook.SheetNames[0], tableArea);
} else {
renderXlsxSheet(workbook, workbook.SheetNames[0], xlsxContainer);
}
} catch (err) {
console.error('Error rendering XLSX:', err);
xlsxContainer.innerHTML = `<div style="text-align:center;padding:2rem;color:#c00;">Error rendering spreadsheet: ${err.message}</div>`;
}
}
/**
* Render a single XLSX sheet as an HTML table
*/
function renderXlsxSheet(workbook, sheetName, container) {
const sheet = workbook.Sheets[sheetName];
const html = XLSX.utils.sheet_to_html(sheet, { editable: false });
container.innerHTML = html;
const table = container.querySelector('table');
if (table) {
table.style.cssText = 'border-collapse:collapse;width:100%;font-size:0.85rem;';
table.querySelectorAll('th,td').forEach(cell => {
cell.style.cssText = 'border:1px solid #ddd;padding:0.35rem 0.5rem;text-align:left;white-space:nowrap;';
});
table.querySelectorAll('th').forEach(th => {
th.style.background = '#f0f0f0';
th.style.fontWeight = '600';
});
}
}
/**
* Display PDF preview using browser's built-in PDF viewer
*/
async function displayPdfPreview(file, filePath, fileName, fileHandle, lastModified) {
const contentContainer = document.getElementById('content-container');
if (!contentContainer) return;
document.querySelectorAll('.file-view-container').forEach(container => {
container.style.display = 'none';
});
if (editorInstances.has(filePath)) {
const existingInstance = editorInstances.get(filePath);
if (existingInstance.fileViewContainer) {
existingInstance.fileViewContainer.style.display = 'flex';
}
return;
}
const fileViewContainer = document.createElement('div');
fileViewContainer.className = 'file-view-container flex flex-col h-full';
const fileHeader = document.createElement('div');
fileHeader.className = 'file-header flex justify-between items-center px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 font-medium border-b border-gray-200 dark:border-gray-700';
const fileTitle = document.createElement('span');
fileTitle.textContent = fileName;
fileHeader.appendChild(fileTitle);
fileViewContainer.appendChild(fileHeader);
const pdfContainer = document.createElement('div');
pdfContainer.className = 'flex-1 overflow-hidden';
const objectUrl = URL.createObjectURL(file);
const iframe = document.createElement('iframe');
iframe.className = 'w-full h-full border-0';
iframe.src = objectUrl;
iframe.setAttribute('title', fileName);
pdfContainer.appendChild(iframe);
fileViewContainer.appendChild(pdfContainer);
contentContainer.appendChild(fileViewContainer);
editorInstances.set(filePath, {
fileViewContainer,
fileHandle,
lastModified,
isDirty: false,
objectUrl
});
}
/**
* Update status bar counts
*/
function updateStatusCounts(folderCount, fileCount) {
const folderCountElement = document.getElementById('folder-count');
const fileCountElement = document.getElementById('file-count');
if (folderCountElement) {
folderCountElement.textContent = `${folderCount} folder${folderCount !== 1 ? 's' : ''}`;
}
if (fileCountElement) {
fileCountElement.textContent = `${fileCount} file${fileCount !== 1 ? 's' : ''}`;
}
updateUnsavedCount();
}
/**
* Update unsaved count in status bar
*/
function updateUnsavedCount() {
const unsavedCountElement = document.getElementById('unsaved-count');
if (!unsavedCountElement) return;
let dirtyCount = 0;
editorInstances.forEach(instance => {
if (instance.isDirty) {
dirtyCount++;
}
});
unsavedCountElement.textContent = `${dirtyCount} unsaved`;
if (dirtyCount > 0) {
unsavedCountElement.classList.add('text-amber-500', 'font-medium');
} else {
unsavedCountElement.classList.remove('text-amber-500', 'font-medium');
}
}
/**
* Update file dirty status indicator in tree
*/
function updateFileDirtyStatus(filePath, isDirty) {
const fileElement = document.querySelector(`.file-item[data-path="${filePath}"]`);
if (!fileElement) return;
if (isDirty) {
if (!fileElement.querySelector('.dirty-indicator')) {
const indicator = document.createElement('span');
indicator.className = 'dirty-indicator ml-1 text-amber-500 font-bold';
indicator.textContent = '●';
fileElement.appendChild(indicator);
}
fileElement.classList.add('is-dirty');
} else {
const indicator = fileElement.querySelector('.dirty-indicator');
if (indicator) {
fileElement.removeChild(indicator);
}
fileElement.classList.remove('is-dirty');
}
}

View file

@ -1,106 +0,0 @@
/**
* YAML front matter parsing and stringification
*/
/**
* Parse YAML front matter from markdown content
* @param {string} content - Full markdown content with potential front matter
* @returns {{data: Object, content: string}} Parsed front matter data and remaining content
*/
function parseFrontMatter(content) {
if (!content || !content.startsWith('---\n')) {
return {
data: {},
content: content || ''
};
}
const endMatch = content.indexOf('\n---\n', 4);
if (endMatch === -1) {
return {
data: {},
content: content
};
}
const frontMatterText = content.substring(4, endMatch);
const markdownBody = content.substring(endMatch + 5);
// Parse YAML front matter (basic key: value parsing)
const frontMatterData = {};
const lines = frontMatterText.split('\n');
for (const line of lines) {
const trimmedLine = line.trim();
if (!trimmedLine || trimmedLine.startsWith('#')) continue;
const colonIndex = trimmedLine.indexOf(':');
if (colonIndex > 0) {
const key = trimmedLine.substring(0, colonIndex).trim();
let value = trimmedLine.substring(colonIndex + 1).trim();
// Remove quotes
value = value.replace(/^["']|["']$/g, '');
// Handle arrays (basic support for [item1, item2])
if (value.startsWith('[') && value.endsWith(']')) {
value = value.slice(1, -1).split(',').map(item => item.trim().replace(/^["']|["']$/g, ''));
}
frontMatterData[key] = value;
}
}
return {
data: frontMatterData,
content: markdownBody
};
}
/**
* Stringify front matter data and combine with markdown content
* @param {string} content - Markdown content
* @param {Object} data - Front matter data object
* @returns {string} Combined YAML front matter and markdown
*/
function stringifyFrontMatter(content, data) {
if (!data || Object.keys(data).length === 0) {
return content;
}
let yamlString = '---\n';
for (const [key, value] of Object.entries(data)) {
if (Array.isArray(value)) {
yamlString += `${key}: [${value.map(v => `"${v}"`).join(', ')}]\n`;
} else {
yamlString += `${key}: "${value}"\n`;
}
}
yamlString += '---\n';
return yamlString + content;
}
/**
* Convert front matter data to YAML string for textarea display (without delimiters)
* @param {Object} data - Front matter data
* @returns {string} YAML string for textarea
*/
function stringifyFrontMatterToTextarea(data) {
if (!data || Object.keys(data).length === 0) {
return '';
}
let yamlString = '';
for (const [key, value] of Object.entries(data)) {
if (Array.isArray(value)) {
yamlString += `${key}: [${value.map(v => `"${v}"`).join(', ')}]\n`;
} else {
yamlString += `${key}: "${value}"\n`;
}
}
return yamlString.trim();
}

View file

@ -1,50 +0,0 @@
/**
* Application initialization
*/
// Initialize when DOM is loaded
document.addEventListener('DOMContentLoaded', function () {
// Check File System API availability and update UI
initializeApiAvailability();
setupEventListeners();
initializeFileNavResizer();
setupTocDepthSelector();
startFileChangeMonitoring();
// Show scratchpad in file tree on startup
renderFileTree();
// Always start with scratchpad selected and loaded
openScratchpad();
const scratchpadEl = document.querySelector(`.file-item[data-path="${SCRATCHPAD_ID}"]`);
if (scratchpadEl) scratchpadEl.classList.add('active-file');
// In server (HTTP) mode, fetch and render the current directory subtree.
if (location.protocol === 'http:' || location.protocol === 'https:') {
loadServerDirectory().catch((err) => {
if (DEBUG) console.warn('Server directory load failed:', err);
});
}
});
/**
* Initialize UI based on File System API availability
*/
function initializeApiAvailability() {
const selectDirectoryBtn = document.getElementById('addDirectoryBtn');
const welcomeHint = document.getElementById('welcome-hint');
const welcomeFirefox = document.getElementById('welcome-firefox');
if (!hasFileSystemAccess) {
// Disable file system buttons in Firefox and other unsupported browsers
if (selectDirectoryBtn) {
selectDirectoryBtn.disabled = true;
selectDirectoryBtn.title = 'File System API not supported in this browser';
}
// Show Firefox warning, hide normal hint
if (welcomeHint) welcomeHint.classList.add('hidden');
if (welcomeFirefox) welcomeFirefox.classList.remove('hidden');
}
}

View file

@ -1,137 +0,0 @@
/**
* Pane resizing functionality
*/
/**
* Make an element resizable by dragging its resizer
* @param {HTMLElement} resizer - The resizer element
* @param {HTMLElement} pane - The pane to resize
*/
function makeResizable(resizer, pane) {
const initialWidth = pane.offsetWidth;
let x = 0;
let paneWidth = initialWidth;
const mouseDownHandler = function (e) {
x = e.clientX;
paneWidth = pane.offsetWidth;
document.addEventListener('mousemove', mouseMoveHandler);
document.addEventListener('mouseup', mouseUpHandler);
resizer.classList.add('active');
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
};
const mouseMoveHandler = function (e) {
const dx = e.clientX - x;
const newWidth = Math.max(150, paneWidth + dx);
pane.style.width = `${newWidth}px`;
};
const mouseUpHandler = function () {
document.removeEventListener('mousemove', mouseMoveHandler);
document.removeEventListener('mouseup', mouseUpHandler);
resizer.classList.remove('active');
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
resizer.addEventListener('mousedown', mouseDownHandler);
}
/**
* Make a horizontal split height-adjustable: the resizer drags the height
* of `topPane` while it remains a sibling of the bottom section inside `container`.
*
* @param {HTMLElement} resizer - The horizontal resizer between the panes
* @param {HTMLElement} topPane - The pane whose height is set
* @param {HTMLElement} container - The flex column containing both panes
*/
function makeHeightResizable(resizer, topPane, container) {
let y = 0;
let topHeight = 0;
let containerHeight = 0;
const mouseDownHandler = (e) => {
y = e.clientY;
topHeight = topPane.offsetHeight;
containerHeight = container.offsetHeight;
document.addEventListener('mousemove', mouseMoveHandler);
document.addEventListener('mouseup', mouseUpHandler);
resizer.classList.add('active');
document.body.style.cursor = 'row-resize';
document.body.style.userSelect = 'none';
};
const mouseMoveHandler = (e) => {
const dy = e.clientY - y;
// Reserve at least 80px for the bottom pane (TOC); cap top at containerHeight - 80.
const minTop = 60;
const maxTop = Math.max(minTop, containerHeight - 100);
const newHeight = Math.max(minTop, Math.min(maxTop, topHeight + dy));
topPane.style.height = `${newHeight}px`;
};
const mouseUpHandler = () => {
document.removeEventListener('mousemove', mouseMoveHandler);
document.removeEventListener('mouseup', mouseUpHandler);
resizer.classList.remove('active');
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
resizer.addEventListener('mousedown', mouseDownHandler);
}
/**
* Initialize the file navigation pane resizer
*/
function initializeFileNavResizer() {
const fileNavResizer = document.querySelector('.pane-resizer[data-resizer-for="file-nav"]');
if (fileNavResizer && !fileNavResizer.hasAttribute('data-resizer-initialized')) {
fileNavResizer.setAttribute('data-resizer-initialized', 'true');
let x = 0;
let navWidth = 0;
const mouseDownHandler = function (e) {
x = e.clientX;
const navPane = document.getElementById('file-nav');
navWidth = navPane.getBoundingClientRect().width;
document.addEventListener('mousemove', mouseMoveHandler);
document.addEventListener('mouseup', mouseUpHandler);
fileNavResizer.classList.add('bg-blue-500');
};
const mouseMoveHandler = function (e) {
const dx = e.clientX - x;
const navPane = document.getElementById('file-nav');
const newWidth = navWidth + dx;
if (newWidth >= 200) {
navPane.style.width = `${newWidth}px`;
}
};
const mouseUpHandler = function () {
document.removeEventListener('mousemove', mouseMoveHandler);
document.removeEventListener('mouseup', mouseUpHandler);
fileNavResizer.classList.remove('bg-blue-500');
};
fileNavResizer.addEventListener('mousedown', mouseDownHandler);
}
}

View file

@ -1,254 +0,0 @@
/**
* Table of Contents generation and scroll functionality
*/
/**
* Scroll to header service - uses line numbers for reliable targeting
*/
const ScrollToHeaderService = {
/**
* Scroll to a specific header in the editor by line number
* @param {Object} editorInstance - Toast UI Editor instance
* @param {string} headerText - Text content of the header (for highlighting)
* @param {number} lineIndex - 0-based line index of the header in markdown
*/
scrollToHeader(editorInstance, headerText, lineIndex) {
if (!editorInstance) {
console.warn('Editor instance not available for scrolling');
return;
}
try {
const editorElements = editorInstance.getEditorElements();
const isWysiwygMode = editorInstance.isWysiwygMode();
if (isWysiwygMode) {
// In WYSIWYG mode, find header by text (no line numbers available)
const wysiwygEditor = editorElements.wwEditor;
if (wysiwygEditor) {
const headers = wysiwygEditor.querySelectorAll('h1, h2, h3, h4, h5, h6');
for (const header of headers) {
if (header.textContent.trim() === headerText.trim()) {
// Scroll the editor container directly with 10px offset
const headerPosition = header.getBoundingClientRect().top - wysiwygEditor.getBoundingClientRect().top;
const offset = 10; // Account for fixed headers or padding
wysiwygEditor.scrollTop = headerPosition - offset;
this._highlightHeader(header);
break;
}
}
}
} else {
// In markdown mode, use line number to position cursor, then scroll preview
const lineNumber = lineIndex + 1; // Convert to 1-based
// Move cursor to the heading line in the editor
try {
editorInstance.setSelection([lineNumber, 1], [lineNumber, 1]);
} catch (e) {
if (DEBUG) console.debug('Could not set selection:', e);
}
// Scroll preview to matching header
const previewElement = editorElements.mdPreview;
if (previewElement) {
const headers = previewElement.querySelectorAll('h1, h2, h3, h4, h5, h6');
for (const header of headers) {
if (header.textContent.trim() === headerText.trim()) {
// Scroll the preview container directly with 10px offset
const headerPosition = header.getBoundingClientRect().top - previewElement.getBoundingClientRect().top;
const offset = 10; // Account for fixed headers or padding
previewElement.scrollTop = headerPosition - offset;
this._highlightHeader(header);
break;
}
}
}
}
} catch (error) {
console.error('Error scrolling to header:', error);
}
},
/**
* Highlight header briefly for visual feedback
* @param {HTMLElement} headerElement - Header to highlight
*/
_highlightHeader(headerElement) {
if (!headerElement) return;
headerElement.style.transition = 'background-color 0.3s ease';
headerElement.style.backgroundColor = '#fef3c7';
setTimeout(() => {
headerElement.style.backgroundColor = '';
setTimeout(() => {
headerElement.style.transition = '';
}, 300);
}, 1500);
}
};
/**
* Generate and update the TOC from markdown content
* @param {string} content - Markdown content
* @param {HTMLElement} tocContainer - Container for the TOC
* @param {Object} editorInstance - Toast UI Editor instance
* @param {number} maxDepth - Maximum heading level (1-6)
*/
function updateToc(content, tocContainer, editorInstance, maxDepth = 6) {
if (content === undefined || content === null || !tocContainer) {
console.warn('Missing required params for updateToc');
return;
}
tocContainer.innerHTML = '';
const tocList = document.createElement('ul');
tocList.className = 'toc-list pl-0 text-sm';
if (!content.trim()) {
const emptyMessage = document.createElement('p');
emptyMessage.className = 'text-gray-500 p-4';
emptyMessage.textContent = 'This file is empty.';
tocContainer.appendChild(emptyMessage);
return;
}
const headings = [];
const lines = content.split('\n');
lines.forEach((line, index) => {
const match = line.match(/^(#{1,6})\s+(.+)$/);
if (match) {
const level = match[1].length;
let text = match[2].trim();
// Clean markdown formatting
text = text
.replace(/\\(.)/g, '$1')
.replace(/\*\*(.*?)\*\*/g, '$1')
.replace(/\*(.*?)\*/g, '$1')
.replace(/`(.*?)`/g, '$1')
.replace(/\[(.*?)\]\(.*?\)/g, '$1')
.replace(/~~(.*?)~~/g, '$1')
.trim();
const id = text.toLowerCase().replace(/\s+/g, '-').replace(/[^\w-]/g, '');
headings.push({
level,
text,
id,
lineIndex: index
});
}
});
let currentList = tocList;
let currentLevel = 0;
let listsStack = [tocList];
const filteredHeadings = headings.filter(heading => heading.level <= maxDepth);
if (filteredHeadings.length === 0) {
const noHeadings = document.createElement('p');
noHeadings.className = 'text-gray-500 p-4';
noHeadings.textContent = maxDepth === 6 ? 'No headings found in this document.' :
'No headings at or below level H' + maxDepth + ' found.';
tocContainer.appendChild(noHeadings);
return;
}
filteredHeadings.forEach(heading => {
const li = document.createElement('li');
li.className = `toc-item toc-level-${heading.level} py-1`;
const a = document.createElement('a');
a.innerHTML = heading.text;
a.href = '#';
a.className = 'text-blue-600 hover:text-blue-800 hover:underline cursor-pointer';
a.dataset.headerText = heading.text;
a.dataset.lineIndex = heading.lineIndex;
a.addEventListener('click', function(e) {
e.preventDefault();
if (editorInstance && ScrollToHeaderService) {
try {
ScrollToHeaderService.scrollToHeader(
editorInstance,
heading.text,
parseInt(heading.lineIndex)
);
} catch (error) {
console.error('Error in ScrollToHeaderService.scrollToHeader:', error);
}
}
});
li.appendChild(a);
if (heading.level > currentLevel) {
const nestedUl = document.createElement('ul');
nestedUl.className = 'pl-4 mt-1';
listsStack[listsStack.length - 1].appendChild(nestedUl);
listsStack.push(nestedUl);
currentList = nestedUl;
currentLevel = heading.level;
} else if (heading.level < currentLevel) {
while (heading.level < currentLevel && listsStack.length > 1) {
listsStack.pop();
currentLevel--;
}
currentList = listsStack[listsStack.length - 1];
}
currentList.appendChild(li);
});
tocContainer.appendChild(tocList);
clearActiveTocItem(tocContainer);
}
/**
* Clear active TOC item from all items within the container
* @param {HTMLElement} tocContainer - Container element holding the TOC
*/
function clearActiveTocItem(tocContainer) {
if (!tocContainer) return;
const activeItems = tocContainer.querySelectorAll('.toc-active');
activeItems.forEach(item => {
item.classList.remove('toc-active');
});
}
/**
* Set active TOC item by finding the link matching the header text
* @param {HTMLElement} tocContainer - Container element holding the TOC
* @param {string} headerText - Text of the header to match and activate
*/
function setActiveTocItem(tocContainer, headerText) {
if (!tocContainer || !headerText) return;
// First clear any existing active items
clearActiveTocItem(tocContainer);
// Find the link matching the header text
const links = tocContainer.querySelectorAll('a[data-header-text]');
for (const link of links) {
if (link.dataset.headerText === headerText) {
// Add toc-active class to the parent li element
const li = link.parentElement;
if (li) {
li.classList.add('toc-active');
}
break;
}
}
}
// Reachable at top-level scope to other concatenated mdedit JS files via the
// build's flat-IIFE-less module pattern; no window.* exports needed.

View file

@ -1,113 +0,0 @@
/**
* Utility functions
*/
/**
* HTML-escape a string for safe insertion into innerHTML.
*/
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text == null ? '' : String(text);
return div.innerHTML;
}
/**
* Debounce function calls
* @param {Function} func - Function to debounce
* @param {number} wait - Wait time in milliseconds
* @returns {Function} Debounced function
*/
function debounce(func, wait) {
let timeout;
return function () {
const context = this;
const args = arguments;
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(context, args), wait);
};
}
/**
* Get file type icon based on file extension
* @param {string} fileName - Name of the file
* @returns {string} Emoji icon for the file type
*/
function getFileTypeIcon(fileName) {
const extension = zddc.splitExtension(fileName).extension;
const iconMap = {
// Documents
'md': '📝',
'markdown': '📝',
'txt': '📄',
'rtf': '📄',
'doc': '📘',
'docx': '📘',
'odt': '📘',
// Web files
'html': '🌐',
'htm': '🌐',
'css': '🎨',
'js': '⚡',
'json': '📋',
'xml': '📊',
'yaml': '⚙️',
'yml': '⚙️',
// PDFs and presentations
'pdf': '📕',
'ppt': '📊',
'pptx': '📊',
'odp': '📊',
// Spreadsheets
'xls': '📗',
'xlsx': '📗',
'csv': '📊',
'ods': '📗',
// Images
'png': '🖼️',
'jpg': '🖼️',
'jpeg': '🖼️',
'gif': '🖼️',
'svg': '🖼️',
'webp': '🖼️',
'bmp': '🖼️',
// Archives
'zip': '📦',
'rar': '📦',
'tar': '📦',
'gz': '📦',
'7z': '📦',
// Code files
'py': '🐍',
'java': '☕',
'cpp': '⚙️',
'c': '⚙️',
'h': '⚙️',
'php': '🔧',
'rb': '💎',
'go': '🔵',
'rs': '🦀',
'swift': '🧡',
'kt': '💜',
// Configuration
'ini': '⚙️',
'conf': '⚙️',
'cfg': '⚙️',
'env': '⚙️',
// Other
'log': '📃',
'sql': '🗄️',
'db': '🗄️',
'sqlite': '🗄️',
};
return iconMap[extension] || '📄';
}

View file

@ -1,170 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ZDDC Markdown</title>
<link rel="icon" type="image/svg+xml" href="{{FAVICON}}">
<script src="https://cdn.tailwindcss.com"></script>
<!-- Toast UI Editor v3.2.2 -->
<link rel="stylesheet" href="https://uicdn.toast.com/editor/3.2.2/toastui-editor.min.css">
<script src="https://uicdn.toast.com/editor/3.2.2/toastui-editor-all.min.js"></script>
<style>
{{CSS_PLACEHOLDER}}
</style>
</head>
<body>
<div id="app" class="flex flex-col h-screen w-full overflow-hidden">
<header class="app-header">
<div class="header-left">
<svg class="app-header__logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" aria-hidden="true">
<rect width="64" height="64" rx="12" fill="#1e3a5f"/>
<g fill="#fff">
<rect x="14" y="18" width="36" height="7"/>
<polygon points="43,25 50,25 21,43 14,43"/>
<rect x="14" y="43" width="36" height="7"/>
</g>
</svg>
<div class="header-title-group">
<span class="app-header__title">ZDDC Markdown</span>
<span class="build-timestamp">{{BUILD_LABEL}}</span>
</div>
<button id="addDirectoryBtn" class="btn btn-primary" title="Add a local directory">Add Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh directory" aria-label="Refresh"></button>
</div>
<div class="header-right">
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)"></button>
<button id="help-btn" class="btn btn-secondary" title="Help">?</button>
</div>
</header>
<main class="flex-1 overflow-hidden relative">
<div class="resizable-pane horizontal flex flex-row relative w-full h-full overflow-hidden" id="root-pane" data-pane-type="root">
<div class="pane nav-pane relative flex flex-col bg-white dark:bg-gray-900 overflow-hidden" id="file-nav" data-pane-type="file-nav">
<div class="pane-header flex flex-col px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 font-medium border-b border-gray-200 dark:border-gray-700 select-none">
<div class="flex justify-between items-center w-full">
<span>Files</span>
<div class="flex items-center gap-1">
<button id="new-file-root" class="btn btn-secondary btn-sm hidden" title="New file in root directory">+</button>
</div>
</div>
</div>
<div class="pane-content flex-1 overflow-auto p-4">
<div id="file-tree" class="file-tree py-2">
</div>
</div>
</div>
<div class="pane-resizer bg-gray-200 dark:bg-gray-700 transition-colors relative z-10 w-1 cursor-col-resize hover:bg-blue-500" data-resizer-for="file-nav"></div>
<div class="pane content-pane flex-1 relative flex flex-col bg-white dark:bg-gray-900 overflow-hidden" id="main-content">
<div id="welcome-screen" class="welcome-screen hidden flex-col items-center justify-center h-full text-gray-500 dark:text-gray-400 text-center p-6">
<div id="welcome-banner" style="background:var(--bg-secondary);padding:0.75rem 1rem;border-left:3px solid var(--warning);text-align:left;font-size:0.9rem;color:var(--text-muted);margin-bottom:1rem;border-radius:var(--radius);max-width:36rem">
<strong>The Browse app now opens markdown files in this same editor.</strong>
Browse provides a unified file tree + per-file-type preview where
<code>.md</code> files render in this Toast UI editor. The
standalone Markdown Editor remains available for offline single-file
editing and air-gapped environments.
</div>
<p id="welcome-hint" class="text-sm">Click <strong>Scratchpad</strong> in the file list to start editing,<br>or <strong>Add Local Directory</strong> to work with files.</p>
<p id="welcome-firefox" class="text-sm text-amber-600 hidden mt-2">Your browser doesn't support the File System API.<br>Use <strong>Scratchpad</strong> to edit markdown and download as a file.</p>
</div>
<div id="content-container" class="content-container flex flex-col h-full hidden">
</div>
</div>
</div>
</main>
<footer class="status-bar flex justify-between items-center px-4 h-6 text-xs bg-gray-100 dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700">
<div class="status-left flex items-center gap-6 py-1">
<button id="save-all" class="btn inline-flex items-center gap-2 px-3 py-1 text-sm bg-transparent border border-gray-300 dark:border-gray-600 rounded text-gray-800 dark:text-gray-200 cursor-pointer transition-all hover:bg-gray-200 dark:hover:bg-gray-700 h-6 leading-none" title="Save All">
<svg class="btn-icon w-3.5 h-3.5 fill-current opacity-80" viewBox="0 0 16 16" fill="currentColor">
<path d="M11.5 1H4.5a1.5 1.5 0 0 0-1.5 1.5v11a1.5 1.5 0 0 0 1.5 1.5h7a1.5 1.5 0 0 0 1.5-1.5v-11a1.5 1.5 0 0 0-1.5-1.5zm-7 1h7a.5.5 0 0 1 .5.5V9H4V2.5a.5.5 0 0 1 .5-.5zM4 10h8v3.5a.5.5 0 0 1-.5.5h-7a.5.5 0 0 1-.5-.5V10z"></path>
<path d="M6.5 0a.5.5 0 0 1 .5.5V2h2V.5a.5.5 0 0 1 1 0V2h1.5a.5.5 0 0 1 0 1H10v2.5a.5.5 0 0 1-1 0V3H7v2.5a.5.5 0 0 1-1 0V3H4.5a.5.5 0 0 1 0-1H6V.5a.5.5 0 0 1 .5-.5z"></path>
</svg>
Save All
</button>
<span id="folder-count" class="status-message text-sm text-gray-800 dark:text-gray-200 opacity-80">0 folders</span>
<span id="file-count" class="status-message text-sm text-gray-800 dark:text-gray-200 opacity-80">0 files</span>
<span id="unsaved-count" class="status-message text-sm text-gray-800 dark:text-gray-200 opacity-80">0 unsaved</span>
</div>
<div class="status-right flex items-center gap-4">
<a href="https://codeberg.org/VARASYS/ZDDC" target="_blank" rel="noopener noreferrer" class="source-link" title="View source code">
<svg class="source-icon fill-current transition-opacity hover:opacity-80" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
<path d="M5.5 11.5L1 8l4.5-3.5L4.4 3 0 8l4.4 5 1.1-1.5zm5 0L15 8l-4.5-3.5L11.6 3 16 8l-4.4 5-1.1-1.5z"></path>
</svg>
</a>
</div>
</footer>
<!-- Help Panel -->
<aside id="help-panel" class="help-panel" hidden aria-labelledby="help-panel-title">
<div class="help-panel__header">
<h2 id="help-panel-title" class="help-panel__title">Help — ZDDC Markdown</h2>
<button type="button" class="help-panel__close" id="help-panel-close" aria-label="Close">&times;</button>
</div>
<div class="help-panel__body">
<h3>What is ZDDC Markdown?</h3>
<p>ZDDC Markdown is a browser-based Markdown editor that reads and writes files directly on your local file system. Everything runs locally — no data is sent to any server.</p>
<h3>Getting Started</h3>
<ol>
<li>Click <strong>Add Local Directory</strong> to open a folder. The file tree on the left will populate with all files in that folder.</li>
<li>Click any Markdown file (<code>.md</code>) in the tree to open it in the editor.</li>
<li>Use the <strong>Scratchpad</strong> entry (always visible at the top of the tree) for temporary notes without saving to disk.</li>
</ol>
<h3>Editor Modes</h3>
<dl>
<dt>WYSIWYG</dt>
<dd>A rich-text view where formatting is rendered live. Good for composing content.</dd>
<dt>Markdown</dt>
<dd>A plain-text view showing raw Markdown syntax. Good for precise control.</dd>
</dl>
<p>Switch between modes using the toolbar buttons at the top-right of the editor.</p>
<h3>Saving Files</h3>
<dl>
<dt>Auto-save indicator</dt>
<dd>A bullet (•) next to the filename in the tree indicates unsaved changes.</dd>
<dt>Save (Ctrl+S)</dt>
<dd>Saves the currently active file.</dd>
<dt>Save All</dt>
<dd>Saves all files that have unsaved changes in one operation.</dd>
</dl>
<h3>Table of Contents</h3>
<p>When a Markdown file is open, a table of contents is generated from its headings and shown on the right side. Use the depth selector to control how many heading levels appear.</p>
<h3>Browser Compatibility</h3>
<p>File system access requires a Chromium-based browser (Chrome, Edge, Brave). In Firefox and other browsers, the <strong>Scratchpad</strong> is available for editing, and files can be saved via download.</p>
<h3>Keyboard Shortcuts</h3>
<dl>
<dt><kbd>Ctrl+S</kbd></dt>
<dd>Save the current file.</dd>
<dt><kbd>Escape</kbd></dt>
<dd>Close this help panel.</dd>
</dl>
</div>
</aside>
<!-- New-file modal -->
<div id="new-file-modal" class="modal-overlay hidden" role="dialog" aria-modal="true" aria-labelledby="new-file-modal-title">
<div class="modal-box">
<h3 id="new-file-modal-title" class="modal-title">New file name</h3>
<input id="new-file-input" type="text" class="modal-input" value="untitled.md" autocomplete="off" spellcheck="false">
<div class="modal-actions">
<button id="new-file-cancel" class="btn btn-secondary">Cancel</button>
<button id="new-file-confirm" class="btn btn-primary">Create</button>
</div>
</div>
</div>
</div>
<script>
{{JS_PLACEHOLDER}}
</script>
</body>
</html>

View file

@ -2,6 +2,44 @@
A collection of tools for converting Markdown documents to HTML with a professional viewer interface, optimized for technical documentation and engineering documents.
## Server-side conversion (`zddc-server`)
zddc-server can offer the same conversions on demand: a `.md` file in any
served directory becomes downloadable as `.docx`, `.html`, and `.pdf` via the
`?convert=` query parameter, surfaced as Download buttons in the browse app's
markdown editor.
The server shells out to two upstream container images, pulling each on
first use via `--pull=missing`. No custom image build is required —
operators just install `podman` (preferred) or `docker`, and the first
conversion request pulls the image:
- `docker.io/pandoc/latex:latest` — MD → DOCX and MD → HTML
(override: `--convert-pandoc-image=` or `ZDDC_CONVERT_PANDOC_IMAGE`;
switch to `docker.io/pandoc/core:latest` for a ~90% size reduction
if you don't need pandoc's native LaTeX-PDF path)
- `docker.io/zenika/alpine-chrome:latest` — HTML → PDF
(override: `--convert-chromium-image=` or `ZDDC_CONVERT_CHROMIUM_IMAGE`)
The PDF flow is two-stage: pandoc renders the markdown through
`viewer-template.html` to standalone HTML, then headless Chromium
prints that HTML to PDF. This preserves the existing print-media CSS
authored for the viewer template rather than going through pandoc's
LaTeX template.
If neither podman nor docker is on PATH the endpoint serves 503 with
a clear "no container runtime" message. Engine choice is overridable
via `--convert-engine=` or `ZDDC_CONVERT_ENGINE`.
Resource limits are per-container and configurable: `--convert-mem-mib`
(default 512), `--convert-cpus` (default "2"), `--convert-pids`
(default 100), `--convert-timeout` (default 30s).
Each conversion runs in a throw-away container with
`--rm --network=none --read-only --tmpfs=/tmp --cap-drop=ALL
--security-opt=no-new-privileges` plus a bind-mounted scratch dir
for I/O (read-only for the template; read-write for the PDF output).
## Features
### Document Conversion (`convert`)

View file

@ -47,10 +47,6 @@ export default defineConfig({
name: 'classifier',
testMatch: 'classifier.spec.js',
},
{
name: 'mdedit',
testMatch: 'mdedit.spec.js',
},
{
name: 'browse',
testMatch: 'browse.spec.js',

View file

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

View file

@ -7,7 +7,7 @@
*
* Renderers operate on any document (parent window or popup window), so the
* same code works for tools whose preview opens in a popup (classifier,
* archive, transmittal) and tools that render inline (mdedit).
* archive, transmittal) and tools that render inline (browse).
*
* Public API on window.zddc.preview:
* loadLibrary(url) Promise<void>

View file

@ -1,5 +1,5 @@
// shared/zddc-source.js — source abstraction for tools that handle
// directory trees (classifier, mdedit, transmittal, browse, archive).
// directory trees (classifier, transmittal, browse, archive).
//
// Two backends:
//
@ -370,12 +370,44 @@
return !!(handle && handle.isHttp === true);
}
// downloadConverted fetches a server-side MD→{docx,html,pdf}
// conversion and triggers a browser download with a clean filename.
// srcUrl points at the .md source on the server. fmt is one of
// "docx" | "html" | "pdf". The server response status maps to a
// friendly error message for the caller to surface (toast / status).
async function downloadConverted(srcUrl, fileName, fmt) {
var resp = await fetch(srcUrl + '?convert=' + encodeURIComponent(fmt),
{ credentials: 'same-origin' });
if (!resp.ok) {
var msg;
if (resp.status === 503) msg = 'Conversion service unavailable on this server.';
else if (resp.status === 422) msg = 'Conversion failed — the source may be malformed.';
else if (resp.status === 504) msg = 'Conversion timed out.';
else msg = 'Conversion failed (HTTP ' + resp.status + ').';
// Append server-supplied body text if it adds detail.
try {
var detail = await resp.text();
if (detail && detail.length < 400) msg += ' ' + detail.trim();
} catch (_) { /* ignore */ }
throw new Error(msg);
}
var blob = await resp.blob();
var a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = fileName.replace(/\.md$/i, '') + '.' + fmt;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(function () { URL.revokeObjectURL(a.href); }, 1000);
}
window.zddc.source = {
HttpDirectoryHandle: HttpDirectoryHandle,
HttpFileHandle: HttpFileHandle,
detectServerRoot: detectServerRoot,
moveFile: moveFile,
isHttpHandle: isHttpHandle,
downloadConverted: downloadConverted,
// Lower-level helpers exposed for tools that want to call the
// server directly without going through the polyfill.
httpListing: httpListing,

View file

@ -16,7 +16,7 @@ import { test, expect } from '@playwright/test';
import * as path from 'path';
import * as fs from 'fs';
const tools = ['archive', 'transmittal', 'classifier', 'mdedit'];
const tools = ['archive', 'transmittal', 'classifier', 'browse'];
for (const tool of tools) {
const distPath = path.resolve(`${tool}/dist/${tool}.html`);
@ -31,7 +31,8 @@ for (const tool of tools) {
});
test(`dist file: .build-timestamp element is visible in browser`, async ({ page }) => {
const waitUntil = tool === 'mdedit' ? 'load' : 'domcontentloaded';
// browse may load Toast UI lazily; wait for full load.
const waitUntil = tool === 'browse' ? 'load' : 'domcontentloaded';
await page.goto(`file://${distPath}`, { waitUntil });
const el = page.locator('.build-timestamp');
await expect(el).toBeVisible({ timeout: 10000 });

View file

@ -1,77 +0,0 @@
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('mdedit/dist/mdedit.html');
test.describe('Markdown Editor', () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript(MOCK_FS_INIT_SCRIPT);
});
test('loads without errors', async ({ page }) => {
// Use 'load' rather than 'networkidle' — the bundled Toast UI/Tailwind
// scripts run inline so there is no external network activity to wait for.
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'load' });
await page.waitForSelector('#app', { timeout: 15000 });
// Scratchpad opens by default with welcome content seeded into the editor.
await expect(page.locator(`.file-item[data-path="__scratchpad__"]`)).toBeVisible();
await expect(page.locator('#content-container')).toBeVisible();
// Add Local Directory button is present and enabled
const addDirBtn = page.locator('#addDirectoryBtn');
await expect(addDirBtn).toBeVisible();
await expect(addDirBtn).not.toBeDisabled();
});
test('renders a file tree from a mock directory', async ({ page }) => {
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'load' });
await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 });
// Set up mock directory before triggering the picker
await page.evaluate(() => {
window.__setMockDirectory('notes', [
{ name: 'readme.md', content: '# Hello\n\nWelcome.', size: 30 },
{ name: 'notes.md', content: '# Notes\n\nSome notes.', size: 25 },
]);
});
await page.locator('#addDirectoryBtn').click();
// File tree should populate with the two files
await page.waitForFunction(
() => document.querySelector('#file-tree')?.children.length > 0,
{ timeout: 10000 }
);
const items = await page.locator('#file-tree *').count();
expect(items).toBeGreaterThanOrEqual(2);
});
test('DEBUG flag is defined and console.log calls are gated', async ({ page }) => {
const logs = [];
page.on('console', msg => msg.type() === 'log' && logs.push(msg.text()));
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'load' });
await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 });
const probe = await page.evaluate(() => ({
debugDefined: typeof DEBUG !== 'undefined',
debugValue: typeof DEBUG !== 'undefined' ? DEBUG : null,
}));
expect(probe.debugDefined).toBe(true);
expect(probe.debugValue).toBe(false);
// With DEBUG=false, no console.log should fire from app code on load.
// (Browser/Toast-UI may still log; we only check none of the gated lines fired.)
const ourLogs = logs.filter(l =>
l.startsWith('Opened scratchpad') ||
l.startsWith('Directory selected') ||
l.startsWith('File ') ||
l.startsWith('Created new file')
);
expect(ourLogs).toEqual([]);
});
});

View file

@ -57,8 +57,8 @@ test.describe('shared/nav.js stage strip', () => {
await expect(active).toHaveAttribute('aria-current', 'page');
});
test('renders for <project>/working/foo/mdedit.html with working active', async ({ page }) => {
await page.goto(`${baseUrl}/projA/working/casey/mdedit.html`, { waitUntil: 'load' });
test('renders for <project>/working/foo/browse.html with working active', async ({ page }) => {
await page.goto(`${baseUrl}/projA/working/casey/browse.html`, { waitUntil: 'load' });
const active = page.locator('.zddc-stage-strip .zddc-stage--active');
await expect(active).toHaveText('Working');
});

View file

@ -381,7 +381,7 @@ roles:
members: [dc@mycompany.com, alice@mycompany.com]
```
Members are email patterns using the same glob syntax as legacy `acl.allow`. Underscore-prefixed names are conventional (`_doc_controller`, `_company`) but not magic. A role redefined closer to the leaf shadows the ancestor's definition. Permission-map keys without `@` are treated as role references first; if no role of that name exists in the visible cascade, they fall back to legacy email-pattern matching (so `*@example.com` and bare `*` continue to work).
Members are email patterns using the same glob syntax as legacy `acl.allow`. Underscore-prefixed names are conventional (`_doc_controller`, `_company`) but not magic. **Role membership UNIONS across the cascade** — a `.zddc` that defines `vendor_acme` again with one extra member *adds* that member to the inherited role; use `reset: true` on the role at a level to break the union (ancestor definitions above the reset are then excluded). Permission-map keys without `@` are treated as role references first; if no role of that name exists in the visible cascade, they fall back to legacy email-pattern matching (so `*@example.com` and bare `*` continue to work). The baked-in `defaults.zddc.yaml` ships two empty standard roles — `document_controller` and `project_team` — referenced by the default ACLs; a deployment populates their members.
### Step 1: starter `.zddc`
@ -478,8 +478,11 @@ Behaviour:
- **Admins:** the root `admins:` list is unaffected. Root admins still
bypass all ACL evaluation, fence or no fence — that's the deliberate
escape hatch for misfiled documents.
- **WORM:** the `archive/<party>/issued|received/` mask is path-based,
not cascade-based. `inherit:` does not change WORM behaviour.
- **WORM:** a `worm:` zone (declared by a `worm: [principal…]` key on a
`.zddc` — the baked-in `defaults.zddc.yaml` puts it on
`archive/<party>/{received,issued}`) is independent of the `inherit:`
fence; `inherit: false` does not change WORM behaviour. See
"Canonical-folder behaviour via `.zddc` keys" below.
**Strict cascade mode IGNORES `inherit: false`.** NIST AC-6 requires
ancestor explicit-denies to be absolute, and the inherit directive
@ -499,21 +502,49 @@ Implementation: parser (`zddc/internal/zddc/file.go`),
`PolicyChain.VisibleStart` (`zddc/internal/zddc/cascade.go`), and the
fence-aware role walk (`zddc/internal/zddc/roles.go`).
#### Special folders
#### Canonical-folder behaviour via `.zddc` keys
Five folder names trigger built-in behaviors regardless of cascade mode (canonical list in `zddc/internal/zddc/special.go`):
**There are no hardcoded folder names.** The canonical project structure
(`archive/`, `working/`, `staging/`, `reviewing/`; `archive/<party>/{mdl,
incoming,received,issued}/`) and its built-in behaviours are described by a
baked-in baseline `.zddc``zddc/internal/zddc/defaults.zddc.yaml`, the
bottom layer of every cascade, dumpable with `zddc-server show-defaults` — that
uses a recursive `paths:` tree to declare subfolder rules even before those
folders exist on disk. Operators override at the on-disk root (or any deeper
level) by mirroring the structure and changing what they need; setting
file-scope `inherit: false` on an on-disk `.zddc` rejects the embedded layer
entirely (the structural convention included, not just the default ACLs).
- **`Incoming`, `Working`, `Staging`** — *auto-ownership*. When the file API processes `POST /<parent>/<new>/ X-ZDDC-Op: mkdir` and the parent is one of these three, the server writes a `.zddc` into the new folder containing `created_by: <email>` and `permissions: { <email>: rwcda }`. The grant uses the same direct email-pattern form an operator would write by hand; the creator can edit the `.zddc` later to add collaborators. `created_by` is an audit field — the cascade evaluator does not consult it.
- **`Issued`, `Received`** — *write-once / immutable archive*. When a request path crosses an `Issued` or `Received` segment, the server applies a **WORM split**: cascade grants inherited from ancestors above the WORM folder are masked to `r` only; grants at-or-below the WORM folder retain `r,c`. Anyone with `w`/`d`/`a` from inheritance loses those verbs once they enter the archive. To grant write-once (`cr`) to the doc controller, the operator places an explicit `.zddc` at the `Issued` or `Received` folder:
The keys that drive built-in behaviour:
```yaml
# /<vendor>/Issued/.zddc
acl:
permissions:
_doc_controller: cr
```
| Key | Effect |
|---|---|
| `default_tool` | tool served at `<dir>` (no trailing slash) — the "specialized app": `archive` under `archive/`, `transmittal` under `staging/`, `browse` under `working/`+`reviewing/` (`browse` hosts the markdown editor plugin), `classifier` under `incoming/`, `tables` at `archive/<party>/mdl`, `landing` at root. Cascades leaf→root. |
| `dir_tool` | tool served at `<dir>/` (trailing slash) — the directory view; floors at `browse`. Cascades leaf→root. (JSON listing requests ignore both keys — the raw listing is always served, so the browse SPA can enumerate entries regardless.) |
| `auto_own` / `auto_own_fenced` | mkdir here writes a creator-owned `.zddc` (`created_by: <email>` + `permissions: { <email>: rwcda }` — the same direct form an operator would write; the creator can edit it later to add collaborators; `created_by` is an audit field, not consulted by the evaluator). `auto_own_fenced` additionally sets `acl.inherit: false` (private to creator). Defaults: `auto_own` on `working`/`staging`/`archive/<party>`/`incoming`; fenced on the per-user `working/<email>/` homes. |
| `worm` | `worm: [principal…]` marks a **write-once-read-many** zone: `w`/`d`/`a` are stripped for everyone non-admin; `c` survives only for the listed principals (who get read + write-once-create); `r` for outsiders is whatever the normal cascade ACL granted; admins (root / subtree) bypass entirely — the escape hatch for misfiled documents. Defaults: `worm: [document_controller]` on `archive/<party>/{received,issued}` — so filing into the archive is write-once for the doc controller and immutable for everyone else (same effect as the old hardcoded "WORM split", but the operator can rename `received`/`issued`, mark any path WORM, or add more controllers, without a code change). |
| `available_tools` | tools the server may auto-serve at this path (cascade-unioned leaf→root). |
| `virtual` | the directory is never materialised on disk (e.g. `reviewing/`, `archive/<party>/mdl`). |
| `drop_target` | the browse tool shows a drag-drop upload overlay here (surfaced via the `X-ZDDC-Drop-Target` response header). |
| `roles` | `{ name → { members: [...], reset: bool } }` — see "Roles" above (union across the cascade; `reset: true` breaks it). |
| `admins` | subtree-admin principals (email globs or role names) — get unconditional `rwcda` over the subtree and bypass the cascade + WORM. |
| `paths` | recursive map `<child-name> → <.zddc overlay>` — the engine of the whole convention; the walker threads each ancestor's `paths:` contributions down to the right level. |
The mask preserves the `c` from this same-level grant, so the doc controller can file new documents — but they still cannot overwrite, delete, or change the ACL. **Only admins (root or subtree) can mutate filed documents.** The mask is server-enforced and not configurable in v1; operators who want a non-WORM directory must avoid the names `Issued` and `Received`.
A `.zip` file is also a navigable directory: `GET …/Foo.zip/` returns a JSON
listing of its members (or the browse SPA for an HTML request), and
`GET …/Foo.zip/sub/doc.pdf` extracts and streams that one member (Range / ETag
supported); `GET …/Foo.zip` (no trailing slash) is unchanged — the raw `.zip`
download; write methods to a path inside a `.zip` are rejected (405). And
`GET /dir/?zip=1` streams an `application/zip` of every readable file under
`/dir/`, recursively, ACL-filtered (`Content-Disposition: attachment;
filename="<dir>.zip"`).
The baked-in `defaults.zddc.yaml` is the authoritative, heavily-commented
reference for all of the above — `zddc-server show-defaults` prints it.
Implementation: `zddc/internal/zddc/walker.go` (`mergeOverlay`, the `paths:`
walk), `lookups.go` (`DefaultToolAt`/`DirToolAt`/`AutoOwnAt`/…), `worm.go`,
`roles.go`; the file API's mkdir hook (`zddc/internal/handler/fileapi.go`) and
`zddc/internal/zddc/ensure.go` seed auto-own `.zddc`s via `AutoOwnAt`.
### Glob patterns
@ -1500,10 +1531,11 @@ fsnotify watcher's debounce window (~2 s) — no service restart needed.
## Apps: virtual tool HTMLs
`zddc-server` virtually serves the five tool HTMLs (archive, transmittal,
classifier, mdedit, landing) at the appropriate paths. The current-stable
build of each tool is **baked into the binary at compile time** via
`//go:embed`; that's the default. No fetch happens out of the box.
`zddc-server` virtually serves the tool HTMLs (archive, transmittal,
classifier, landing, browse, form, tables) at the appropriate paths.
The current-stable build of each tool is **baked into the binary at
compile time** via `//go:embed`; that's the default. No fetch happens
out of the box.
### Where each tool is served
@ -1511,7 +1543,7 @@ build of each tool is **baked into the binary at compile time** via
|---------------|-------------------------------------------------------------------------|
| `archive` | every directory (multi-project, project, archive, vendor) |
| `classifier` | any `Incoming`, `Working`, or `Staging` directory and its subtree |
| `mdedit` | any `Working` directory and its subtree |
| `browse` | every directory (hosts the in-place markdown editor) |
| `transmittal` | any `Staging` directory and its subtree |
| `landing` | only at the deployment root (the project picker) |
@ -1550,7 +1582,7 @@ to the embedded copy and emits a one-time WARN log per source. The
apps:
classifier: alpha # track alpha for this project
archive: https://my-mirror.internal/zddc/archive_v0.0.4.html # custom mirror, pinned
mdedit: ./our-mdedit.html # local fork
browse: ./our-browse.html # local fork
```
### Env vars

View file

@ -19,6 +19,7 @@ import (
"codeberg.org/VARASYS/ZDDC/zddc/internal/auth"
"codeberg.org/VARASYS/ZDDC/zddc/internal/cache"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/convert"
appfs "codeberg.org/VARASYS/ZDDC/zddc/internal/fs"
"codeberg.org/VARASYS/ZDDC/zddc/internal/handler"
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
@ -86,6 +87,19 @@ func main() {
"addr", cfg.Addr,
"embedded_apps", embeddedVersionsForLog(embedded))
// Probe the container runtime for the MD→{docx,html,pdf} endpoint.
// Non-fatal: if the host has no podman/docker, conversion requests
// return 503 and everything else keeps working. The probe installs
// the package-level Runner when an engine is found; the configured
// image refs are pulled lazily on first conversion via
// `--pull=missing` so there's no manual setup beyond installing
// podman or docker.
convert.SetImages(cfg.ConvertPandocImage, cfg.ConvertChromiumImage)
probeCtx, probeCancel := context.WithTimeout(context.Background(), 5*time.Second)
convert.Probe(probeCtx, cfg.ConvertEngine)
probeCancel()
convert.ConfigureLimits(cfg.ConvertMemMiB, cfg.ConvertCPUs, cfg.ConvertPIDs, cfg.ConvertTimeout)
// Client mode short-circuit: when cfg.Upstream is set, this binary
// runs as a downstream proxy/cache/mirror rather than a master.
// The master-side machinery below (archive index, watcher, apps
@ -472,7 +486,7 @@ func setupAccessAuditLog(path string) *slog.Logger {
// through unchanged when the client doesn't advertise gzip), appends
// Vary: Accept-Encoding automatically, and passes through 304s untouched.
// Yields ~75% size reduction on the larger embedded HTML responses
// (mdedit: 920 KB → ~250 KB on the wire).
// (browse: ~2 MB → a few hundred KB on the wire).
//
// Extracted so tests can construct an equivalent wrapper without going
// through the full main() server boot.
@ -981,9 +995,9 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
// File doesn't exist at this path. If the URL matches one of
// the canonical app HTML names AND the request directory is
// one where that app is available (working/staging/incoming
// for classifier, working for mdedit, staging for
// transmittal, anywhere for archive, root only for landing),
// resolve via the apps subsystem.
// for classifier, staging for transmittal, anywhere for
// archive + browse, root only for landing), resolve via the
// apps subsystem.
if appsSrv != nil {
if app, requestDirRel := apps.MatchAppHTML(urlPath); app != "" {
requestDir := filepath.Join(cfg.Root, filepath.FromSlash(requestDirRel))
@ -1002,13 +1016,14 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
// a virtual view. The shape rule mirrors the other canonical
// folders (slash → browse, no-slash → default tool):
// - JSON request, any depth → aggregator listing (handler.ServeReviewing)
// - HTML, no slash → mdedit (default tool, via DefaultAppAt)
// - HTML, no slash → browse (default tool, via DefaultAppAt;
// browse hosts the markdown editor plugin)
// - HTML, with slash → browse.html (via ServeDirectory).
// browse fetches JSON which routes back
// through here to ServeReviewing.
// Depth-3 no-slash (reviewing/<tracking>) 302s to the slash form.
// Depth-2 no-slash (reviewing) falls through to the canonical-
// folder block below where DefaultAppAt routes to mdedit.
// folder block below where DefaultAppAt routes to browse.
if r.Method == http.MethodGet || r.Method == http.MethodHead {
if proj, tracking, sidePath, ok := handler.IsReviewingPath(urlPath); ok {
if !strings.HasSuffix(urlPath, "/") {
@ -1098,9 +1113,10 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
// directory view (handler.ServeDirectory → DirTool, which
// resolves to browse by default; JSON requests always get the
// raw listing regardless). No trailing slash → the directory's
// default_tool ("specialized app") — mdedit under working/,
// transmittal under staging/, archive under archive/, tables
// under archive/<party>/mdl/ — if one is declared; otherwise
// default_tool ("specialized app") — browse under working/+
// reviewing/ (hosts the markdown editor), transmittal under
// staging/, archive under archive/, tables under
// archive/<party>/mdl/ — if one is declared; otherwise
// (after the project-root landing case below) a 302 to the
// slash form.
if !strings.HasSuffix(urlPath, "/") && (r.Method == http.MethodGet || r.Method == http.MethodHead) && !isRoot {
@ -1138,6 +1154,17 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
// MD→{docx,html,pdf} on-demand conversion. The endpoint reuses the
// source file's read policy (already gated above), so no separate
// ACL verb. Only .md sources are convertible; everything else falls
// through to the regular file serve.
if fmt := r.URL.Query().Get("convert"); fmt != "" &&
strings.HasSuffix(strings.ToLower(absPath), ".md") {
handler.ServeConverted(cfg, w, r, absPath, fmt, chain)
return
}
handler.ServeFile(w, r, absPath)
}

View file

@ -138,14 +138,14 @@ func TestDispatchAppsResolution(t *testing.T) {
"archive": upstream.URL + "/archive_stable.html",
"transmittal": upstream.URL + "/transmittal_stable.html",
"classifier": upstream.URL + "/classifier_stable.html",
"mdedit": upstream.URL + "/mdedit_stable.html",
"landing": upstream.URL + "/landing_stable.html",
"browse": upstream.URL + "/browse_stable.html",
},
}
if err := zddc.WriteFile(root, zf); err != nil {
t.Fatalf("WriteFile: %v", err)
}
// Create folder convention dirs so classifier/mdedit/transmittal
// Create folder convention dirs so classifier/browse/transmittal
// availability rules pass for the test paths used below.
mustMkdir(t, filepath.Join(root, "Project-A", "Working"))
@ -380,10 +380,10 @@ func TestDispatchArchiveRedirect(t *testing.T) {
func TestDispatchSlashRouting(t *testing.T) {
// Convention: <dir>/ → browse (directory view, via DirTool which
// defaults to browse); <dir> → the directory's default_tool ("the
// specialized app": mdedit under working/, transmittal under
// staging/, archive under archive/, tables under archive/<party>/mdl).
// Without a default_tool, no-slash falls through to the trailing-
// slash redirect (302).
// specialized app": browse under working/+reviewing/, transmittal
// under staging/, archive under archive/, tables under
// archive/<party>/mdl). Without a default_tool, no-slash falls
// through to the trailing-slash redirect (302).
//
// The only trailing-slash redirect is for a directory that is the
// rows-dir of a table declared via a REAL on-disk parent .zddc
@ -429,7 +429,7 @@ func TestDispatchSlashRouting(t *testing.T) {
wantNoRedirect bool
wantLoc string // checked when wantStatus is a redirect
}{
{"working no-slash → mdedit", "/Project/working", http.StatusOK, true, ""},
{"working no-slash → browse", "/Project/working", http.StatusOK, true, ""},
{"working slash → browse", "/Project/working/", http.StatusOK, true, ""},
{"staging no-slash → transmittal", "/Project/staging", http.StatusOK, true, ""},
{"staging slash → browse", "/Project/staging/", http.StatusOK, true, ""},
@ -525,21 +525,21 @@ func TestDispatchEmptyCanonicalProjectFolders(t *testing.T) {
}
// No-trailing-slash form on a canonical folder → default app
// (mdedit for working/, transmittal for staging/, archive for
// archive/). Mirror of the existing "no-slash → default app"
// behavior at the IsDir branch, extended to cover the case where
// the folder doesn't exist on disk yet.
// (browse for working/+reviewing/, transmittal for staging/,
// archive for archive/). Mirror of the existing "no-slash →
// default app" behavior at the IsDir branch, extended to cover
// the case where the folder doesn't exist on disk yet.
noSlashDefaultApp := []struct {
stage string
expect string // substring that should appear in the response body
}{
{"working", "ZDDC Markdown"},
{"working", "ZDDC Browse"},
{"staging", "ZDDC Transmittal"},
{"archive", "ZDDC Archive"},
// reviewing/ also routes to mdedit; the polyfill follows the
// virtual aggregator's listing into canonical archive/+staging
// paths from there.
{"reviewing", "ZDDC Markdown"},
// reviewing/ also routes to browse (markdown editor lives
// inside it now); the polyfill follows the virtual aggregator's
// listing into canonical archive/+staging paths from there.
{"reviewing", "ZDDC Browse"},
}
for _, tc := range noSlashDefaultApp {
t.Run("no-slash/"+tc.stage+" → default app", func(t *testing.T) {

View file

@ -1,7 +1,8 @@
// Package apps serves the five ZDDC tool HTML files (archive, transmittal,
// classifier, mdedit, landing) on virtual paths in the file tree. Each tool
// is "available" only at directories whose name matches a folder convention
// (Incoming/Working/Staging) — see availability.go.
// Package apps serves the ZDDC tool HTML files (archive, transmittal,
// classifier, landing, browse, form, tables) on virtual paths in the
// file tree. Each tool is "available" only at directories whose name
// matches a folder convention (Incoming/Working/Staging) — see
// availability.go. The markdown editor lives as a plugin inside browse.
//
// Resolution priority for an enabled <dir>/<app>.html request:
//

View file

@ -43,8 +43,9 @@ func AppAvailableAt(root, requestDir, app string) bool {
// - <project>/archive/ → "archive"
// - <project>/archive/<party>/... → "archive"
// - <project>/staging/... → "transmittal"
// - <project>/working/... → "mdedit"
// - <project>/reviewing/... → "mdedit" (operates on the
// - <project>/working/... → "browse" (hosts the
// markdown editor plugin)
// - <project>/reviewing/... → "browse" (operates on the
// virtual aggregator listing)
// - any other directory → "" (no default)
//

View file

@ -35,11 +35,12 @@ func TestAppAvailableAt(t *testing.T) {
{root + "/Project-A/archive/ACME/mdl", "classifier", false},
{root + "/Project-A/some-other-folder", "classifier", false},
// mdedit: working/ only (review responses live in working/<rs-name>/)
{root + "/Project-A/working", "mdedit", true},
{root + "/Project-A/working/sub", "mdedit", true},
{root + "/Project-A/staging", "mdedit", false},
{root + "/Project-A/archive/ACME/incoming", "mdedit", false},
// browse: universal — every directory has browse available
// (it's in the embedded-defaults baseline available_tools).
{root + "/Project-A/working", "browse", true},
{root + "/Project-A/working/sub", "browse", true},
{root + "/Project-A/staging", "browse", true},
{root + "/Project-A/archive/ACME/incoming", "browse", true},
// transmittal: staging/ only
{root + "/Project-A/staging", "transmittal", true},
@ -48,8 +49,6 @@ func TestAppAvailableAt(t *testing.T) {
{root + "/Project-A/archive/ACME/issued", "transmittal", false},
// case-fold: any case of canonical names matches
{root + "/Project-A/Working", "mdedit", true},
{root + "/Project-A/WORKING", "mdedit", true},
{root + "/Project-A/Staging", "transmittal", true},
{root + "/Project-A/STAGING", "transmittal", true},
{root + "/Project-A/archive/ACME/Incoming", "classifier", true},
@ -81,9 +80,9 @@ func TestDefaultAppAt(t *testing.T) {
// no-slash falls through to the redirect.
{root + "/Project-A", ""},
// Canonical project-root folders.
{root + "/Project-A/working", "mdedit"},
{root + "/Project-A/working/alice@example.com", "mdedit"},
{root + "/Project-A/working/2026-06-15_x (DFT) - y", "mdedit"},
{root + "/Project-A/working", "browse"},
{root + "/Project-A/working/alice@example.com", "browse"},
{root + "/Project-A/working/2026-06-15_x (DFT) - y", "browse"},
{root + "/Project-A/staging", "transmittal"},
{root + "/Project-A/staging/2026-06-15_x (DFT) - y", "transmittal"},
// archive: at the archive root, party folders default to archive.
@ -98,15 +97,16 @@ func TestDefaultAppAt(t *testing.T) {
// mdl wins over the broader archive rule.
{root + "/Project-A/archive/Acme/mdl", "tables"},
{root + "/Project-A/archive/Acme/mdl/anything-deeper", "tables"},
// reviewing/ is virtual but mdedit is wired as the default
// tool; the polyfill follows the listing's canonical URLs
// into archive/ and staging/ for the actual files.
{root + "/Project-A/reviewing", "mdedit"},
{root + "/Project-A/reviewing/123-EM-SUB-0001", "mdedit"},
// reviewing/ is virtual; browse hosts the markdown editor that
// renders responses (the polyfill follows the listing's
// canonical URLs into archive/ and staging/ for the actual
// files).
{root + "/Project-A/reviewing", "browse"},
{root + "/Project-A/reviewing/123-EM-SUB-0001", "browse"},
// Random non-canonical folder names → no default.
{root + "/Project-A/scratch", ""},
// Case-fold on canonical names.
{root + "/Project-A/Working", "mdedit"},
{root + "/Project-A/Working", "browse"},
{root + "/Project-A/STAGING", "transmittal"},
{root + "/Project-A/Archive/Acme/MDL", "tables"},
}

View file

@ -7,9 +7,9 @@ import (
"sync"
)
// Embedded fallback: the five tool HTMLs from the time the binary was
// built. Used as a last-resort served-bytes when (cache miss) AND
// (upstream unreachable) AND (no operator override) — see handler.go.
// Embedded fallback: tool HTMLs from the time the binary was built.
// Used as a last-resort served-bytes when (cache miss) AND (upstream
// unreachable) AND (no operator override) — see handler.go.
//
// The files are populated by the top-level build.sh, which copies the
// freshly-built dist/<tool>.html into ./embedded/ before `go build` runs.
@ -26,9 +26,6 @@ var embeddedTransmittal []byte
//go:embed embedded/classifier.html
var embeddedClassifier []byte
//go:embed embedded/mdedit.html
var embeddedMdedit []byte
//go:embed embedded/index.html
var embeddedLanding []byte
@ -47,8 +44,6 @@ func EmbeddedBytes(app string) []byte {
b = embeddedTransmittal
case "classifier":
b = embeddedClassifier
case "mdedit":
b = embeddedMdedit
case "landing":
b = embeddedLanding
case "browse":

View file

@ -2470,7 +2470,7 @@ td[data-field="trackingNumber"] {
</svg>
<div class="header-title-group">
<span class="app-header__title">ZDDC Archive</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-12 · candle-mast-pearl</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 · plaza-fiddle-panel</span></span>
</div>
<button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data"></button>
@ -5214,7 +5214,7 @@ X.B(E,Y);return E}return J}())
*
* Renderers operate on any document (parent window or popup window), so the
* same code works for tools whose preview opens in a popup (classifier,
* archive, transmittal) and tools that render inline (mdedit).
* archive, transmittal) and tools that render inline (browse).
*
* Public API on window.zddc.preview:
* loadLibrary(url) → Promise<void>

View file

@ -1477,6 +1477,16 @@ html, body {
background: var(--bg);
border: 1px solid var(--border);
}
.md-shell__download {
/* Slightly tighter than the Save button so a row of three doesn't
crowd the title. The base .btn styles still drive padding/color. */
font-variant-numeric: tabular-nums;
letter-spacing: 0.02em;
}
.md-shell__download[disabled] {
opacity: 0.55;
cursor: progress;
}
/* Editor host: a single grid cell with overflow:hidden so Toast UI's
internal scrollers handle the content. */
@ -1562,34 +1572,41 @@ html, body {
transition: background-color 0.3s ease;
}
/* ── Front matter list ──────────────────────────────────────────────────── */
.md-fm__empty {
/* ── Front matter editor ────────────────────────────────────────────────── */
.md-fm__body {
/* Body cell owns the textarea; sized by the sidebar's grid row. */
padding: 0;
display: block;
overflow: hidden;
}
.md-fm__textarea {
width: 100%;
height: 100%;
box-sizing: border-box;
margin: 0;
padding: 0.4rem 0.6rem;
border: 0;
background: transparent;
color: var(--text);
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Consolas, monospace);
font-size: 0.8rem;
line-height: 1.45;
resize: none;
outline: none;
white-space: pre;
overflow: auto;
tab-size: 2;
}
.md-fm__textarea::placeholder {
color: var(--text-muted);
font-style: italic;
font-size: 0.82rem;
margin: 0;
padding: 0.5rem 0.75rem;
}
.md-fm__list {
margin: 0;
padding: 0.3rem 0.75rem;
display: grid;
grid-template-columns: minmax(4.5rem, max-content) 1fr;
gap: 0.2rem 0.6rem;
font-size: 0.8rem;
.md-fm__textarea:focus {
background: var(--surface-2, rgba(0, 0, 0, 0.025));
}
.md-fm__list dt {
font-weight: 600;
.md-fm__textarea[readonly] {
color: var(--text-muted);
text-transform: lowercase;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.md-fm__list dd {
margin: 0;
color: var(--text);
overflow-wrap: anywhere;
cursor: not-allowed;
}
/* ── Sort control ────────────────────────────────────────────────────────── */
@ -1640,7 +1657,7 @@ html, body {
</svg>
<div class="header-title-group">
<span class="app-header__title">ZDDC Browse</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-12 · candle-mast-pearl</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 · plaza-fiddle-panel</span></span>
</div>
<button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing"></button>
@ -4293,7 +4310,7 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
*
* Renderers operate on any document (parent window or popup window), so the
* same code works for tools whose preview opens in a popup (classifier,
* archive, transmittal) and tools that render inline (mdedit).
* archive, transmittal) and tools that render inline (browse).
*
* Public API on window.zddc.preview:
* loadLibrary(url) → Promise<void>
@ -5874,22 +5891,31 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
//
// Layout (CSS Grid):
// ┌─────────────────────────────────────────────────────────────────┐
// │ toolbar: Save | ● modified | status | source │
// ├────────────────────────────────────────┬────────────────────────┤
// │ │ Outline │
// │ │ • Heading 1 │
// │ Toast UI Editor │ • Subheading │
// │ (md / wysiwyg / preview) │ • Heading 2 │
// │ ├────────────────────────┤
// │ │ Front matter │
// │ │ title: Foo │
// │ │ revision: A │
// └────────────────────────────────────────┴────────────────────────┘
// │ info: name | dirty | status | source | DOCX HTML PDF | Save │
// ├────────────────────────┬────────────────────────────────────────┤
// │ YAML front matter │ │
// │ ┌──────────────────┐ │ │
// │ │ title: Foo │ │ Toast UI Editor │
// │ │ revision: A │ │ (md / wysiwyg / preview) │
// │ └──────────────────┘ │ │
// ├────────────────────────┤ │
// │ Outline │ │
// │ • Heading 1 │ │
// │ • Subheading │ │
// │ • Heading 2 │ │
// └────────────────────────┴────────────────────────────────────────┘
// Grid keeps every cell's size definite, which is what Toast UI needs
// to compute its inner scroll regions correctly. The previous nested-
// flexbox layout produced indeterminate heights and a fragile TOC
// pane width — grid fixes both.
//
// Front matter is edited in a dedicated <textarea> in the sidebar
// (always present — typing into the placeholder grows the envelope on
// save). On load the `---\n…\n---\n` envelope is stripped from the
// bytes fed to Toast UI; on save the textarea content is re-stitched
// on top of the editor body. Keeps YAML out of the rich editor where
// users can't reliably edit it.
//
// Save (Ctrl+S) writes back via PUT (server mode) or
// FileSystemWritableFileStream (FS-API). Zip-virtual files are
// read-only — Save stays disabled. Toast UI is vendored
@ -5965,25 +5991,37 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
return { data: data, body: body };
}
function renderFrontMatter(fmEl, content) {
if (!fmEl) return;
var parsed = parseFrontMatter(content);
var keys = Object.keys(parsed.data);
if (keys.length === 0) {
fmEl.innerHTML = '<p class="md-fm__empty">No front matter.</p>';
return;
}
var html = '<dl class="md-fm__list">';
// Inverse of parseFrontMatter — turn a {key: value | array} object back
// into newline-separated YAML lines suitable for the textarea. Arrays
// are quoted to match what the parser will round-trip through. Returns
// "" when there are no keys (so the textarea shows its placeholder).
function stringifyFrontMatter(data) {
if (!data) return '';
var keys = Object.keys(data);
if (keys.length === 0) return '';
var out = [];
for (var i = 0; i < keys.length; i++) {
var k = keys[i];
var v = parsed.data[k];
var displayV = Array.isArray(v)
? v.map(escapeHtml).join(', ')
: escapeHtml(String(v));
html += '<dt>' + escapeHtml(k) + '</dt><dd>' + displayV + '</dd>';
var v = data[k];
if (Array.isArray(v)) {
out.push(k + ': [' + v.map(function (x) {
return '"' + String(x).replace(/"/g, '\\"') + '"';
}).join(', ') + ']');
} else {
out.push(k + ': ' + String(v));
}
}
html += '</dl>';
fmEl.innerHTML = html;
return out.join('\n');
}
// Stitch the textarea's YAML lines and the editor's body back together
// into the on-disk envelope. Empty textarea → return body unchanged
// (no envelope written). Trailing whitespace in the textarea is
// tolerated.
function assembleContent(fmText, body) {
var fm = (fmText || '').replace(/\s+$/, '');
if (!fm) return body || '';
return '---\n' + fm + '\n---\n' + (body || '');
}
// ── TOC (table of contents) ────────────────────────────────────────────
@ -6209,6 +6247,13 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
fmHeader.textContent = 'YAML front matter';
var fmBody = document.createElement('div');
fmBody.className = 'md-side__body md-fm__body';
var fmTextarea = document.createElement('textarea');
fmTextarea.className = 'md-fm__textarea';
fmTextarea.spellcheck = false;
fmTextarea.autocapitalize = 'off';
fmTextarea.autocomplete = 'off';
fmTextarea.placeholder = 'title: Document Title\ndate: 2026-05-13\ntags: [example]';
fmBody.appendChild(fmTextarea);
fmSection.appendChild(fmHeader);
fmSection.appendChild(fmBody);
sidebar.appendChild(fmSection);
@ -6280,10 +6325,35 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
sourceEl.textContent = 'server';
}
// Download-as-{docx,html,pdf} buttons. Server-mode + .md only:
// the server endpoint runs pandoc/chromium in a container and
// returns the converted bytes. Click handlers wire up below
// (after save() is defined) because they auto-save first when
// the buffer is dirty.
var serverModeMd = window.app && window.app.state &&
window.app.state.source === 'server' &&
node.url && /\.md$/i.test(node.name);
var convertBtns = [];
if (serverModeMd && window.zddc && window.zddc.source &&
typeof window.zddc.source.downloadConverted === 'function') {
['docx', 'html', 'pdf'].forEach(function (fmt) {
var btn = document.createElement('button');
btn.className = 'btn btn-sm btn-secondary md-shell__download';
btn.type = 'button';
btn.textContent = fmt.toUpperCase();
btn.title = 'Download as ' + fmt.toUpperCase();
btn.dataset.fmt = fmt;
convertBtns.push(btn);
});
}
infohdr.appendChild(titleEl);
infohdr.appendChild(dirtyEl);
infohdr.appendChild(statusEl);
infohdr.appendChild(sourceEl);
for (var ci = 0; ci < convertBtns.length; ci++) {
infohdr.appendChild(convertBtns[ci]);
}
infohdr.appendChild(saveBtn);
content.appendChild(infohdr);
@ -6292,15 +6362,21 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
editorHost.className = 'md-shell__editor';
content.appendChild(editorHost);
// Construct the editor. height: 100% works because editorHost
// is a grid cell with a definite size.
var initialHash = await hashContent(text);
// Split the loaded bytes into FM (textarea) + body (editor). The
// hash that gates dirty-state is taken over the reassembled
// bytes so that round-tripping a clean file shows "not dirty"
// even if we tweak whitespace in the YAML lines.
var initialParsed = parseFrontMatter(text);
fmTextarea.value = stringifyFrontMatter(initialParsed.data);
var bodyText = initialParsed.body;
var initialHash = await hashContent(assembleContent(fmTextarea.value, bodyText));
var editor = new window.toastui.Editor({
el: editorHost,
height: '100%',
initialEditType: 'markdown',
previewStyle: 'vertical',
initialValue: text,
initialValue: bodyText,
usageStatistics: false,
toolbarItems: [
['heading', 'bold', 'italic', 'strike'],
@ -6318,17 +6394,17 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
node: node,
hash: initialHash,
tocEl: tocBody,
fmEl: fmBody
fmEl: fmTextarea
};
var writable = canSave(node);
if (!writable) {
saveBtn.disabled = true;
saveBtn.title = 'Save not available — read-only source.';
fmTextarea.readOnly = true;
}
renderToc(tocBody, text, editor);
renderFrontMatter(fmBody, text);
renderToc(tocBody, bodyText, editor);
// ── Sidebar/content resizer ─────────────────────────────────────────
// Sidebar is on the LEFT now. Dragging right grows the
@ -6424,18 +6500,24 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
}
var onChange = debounce(async function () {
var current = editor.getMarkdown();
var h = await hashContent(current);
var body = editor.getMarkdown();
var h = await hashContent(assembleContent(fmTextarea.value, body));
markDirty(h !== currentInstance.hash);
renderToc(tocBody, current, editor);
renderFrontMatter(fmBody, current);
renderToc(tocBody, body, editor);
}, 250);
editor.on('change', onChange);
var onFmChange = debounce(async function () {
var body = editor.getMarkdown();
var h = await hashContent(assembleContent(fmTextarea.value, body));
markDirty(h !== currentInstance.hash);
}, 250);
fmTextarea.addEventListener('input', onFmChange);
// ── Save ───────────────────────────────────────────────────────────
async function save() {
if (!currentInstance.dirty || !writable) return;
var content = editor.getMarkdown();
var content = assembleContent(fmTextarea.value, editor.getMarkdown());
try {
statusEl.textContent = 'Saving…';
await saveContent(node, content);
@ -6459,6 +6541,42 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
save();
}
});
// Download-as-* click handlers. Auto-save when the buffer is
// dirty so the converted file reflects what's on screen. If
// the save fails the existing toast/status surfaces it; we
// bail without firing the conversion.
convertBtns.forEach(function (btn) {
btn.addEventListener('click', async function () {
var fmt = btn.dataset.fmt;
if (currentInstance.dirty) {
if (!writable) {
if (window.zddc && window.zddc.toast) {
window.zddc.toast(
'This source is read-only — save a copy elsewhere first.',
'error');
}
return;
}
btn.disabled = true;
try { await save(); } finally { btn.disabled = false; }
if (currentInstance.dirty) return; // save failed
}
btn.disabled = true;
try {
statusEl.textContent = 'Converting to ' + fmt.toUpperCase() + '…';
await window.zddc.source.downloadConverted(node.url, node.name, fmt);
statusEl.textContent = 'Downloaded ' + fmt.toUpperCase();
} catch (e) {
statusEl.textContent = (e && e.message) || String(e);
if (window.zddc && window.zddc.toast) {
window.zddc.toast((e && e.message) || String(e), 'error');
}
} finally {
btn.disabled = false;
}
});
});
}
window.app.modules.markdown = {

View file

@ -1681,7 +1681,7 @@ body.help-open .app-header {
</svg>
<div class="header-title-group">
<span class="app-header__title">ZDDC Classifier</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-12 · candle-mast-pearl</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 · plaza-fiddle-panel</span></span>
</div>
<button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;"></button>
@ -3584,7 +3584,7 @@ X.B(E,Y);return E}return J}())
})(typeof window !== 'undefined' ? window : globalThis);
// shared/zddc-source.js — source abstraction for tools that handle
// directory trees (classifier, mdedit, transmittal, browse, archive).
// directory trees (classifier, transmittal, browse, archive).
//
// Two backends:
//
@ -3955,12 +3955,44 @@ X.B(E,Y);return E}return J}())
return !!(handle && handle.isHttp === true);
}
// downloadConverted fetches a server-side MD→{docx,html,pdf}
// conversion and triggers a browser download with a clean filename.
// srcUrl points at the .md source on the server. fmt is one of
// "docx" | "html" | "pdf". The server response status maps to a
// friendly error message for the caller to surface (toast / status).
async function downloadConverted(srcUrl, fileName, fmt) {
var resp = await fetch(srcUrl + '?convert=' + encodeURIComponent(fmt),
{ credentials: 'same-origin' });
if (!resp.ok) {
var msg;
if (resp.status === 503) msg = 'Conversion service unavailable on this server.';
else if (resp.status === 422) msg = 'Conversion failed — the source may be malformed.';
else if (resp.status === 504) msg = 'Conversion timed out.';
else msg = 'Conversion failed (HTTP ' + resp.status + ').';
// Append server-supplied body text if it adds detail.
try {
var detail = await resp.text();
if (detail && detail.length < 400) msg += ' ' + detail.trim();
} catch (_) { /* ignore */ }
throw new Error(msg);
}
var blob = await resp.blob();
var a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = fileName.replace(/\.md$/i, '') + '.' + fmt;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(function () { URL.revokeObjectURL(a.href); }, 1000);
}
window.zddc.source = {
HttpDirectoryHandle: HttpDirectoryHandle,
HttpFileHandle: HttpFileHandle,
detectServerRoot: detectServerRoot,
moveFile: moveFile,
isHttpHandle: isHttpHandle,
downloadConverted: downloadConverted,
// Lower-level helpers exposed for tools that want to call the
// server directly without going through the polyfill.
httpListing: httpListing,
@ -4428,7 +4460,7 @@ X.B(E,Y);return E}return J}())
*
* Renderers operate on any document (parent window or popup window), so the
* same code works for tools whose preview opens in a popup (classifier,
* archive, transmittal) and tools that render inline (mdedit).
* archive, transmittal) and tools that render inline (browse).
*
* Public API on window.zddc.preview:
* loadLibrary(url) → Promise<void>

View file

@ -1424,7 +1424,7 @@ body {
</svg>
<div class="header-title-group">
<span class="app-header__title">ZDDC</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-12 · candle-mast-pearl</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 · plaza-fiddle-panel</span></span>
</div>
</div>
<div class="header-right">
@ -3284,9 +3284,9 @@ body {
// Render the project-workspace view: title, four stage links, MDL
// section. Stage hrefs use the no-trailing-slash form so the server
// routes them to each canonical default tool (mdedit for working/,
// transmittal for staging/, etc.). Browse-all and the archive deep
// link use the slash form to land on the directory listing.
// routes them to each canonical default tool (browse for working/+
// reviewing/, transmittal for staging/, etc.). Browse-all and the
// archive deep link use the slash form to land on the directory listing.
async function renderProjectMode() {
var project = projectFromPath();
if (!project) return;

File diff suppressed because one or more lines are too long

View file

@ -2523,7 +2523,7 @@ dialog.modal--narrow {
</svg>
<div class="header-title-group">
<span class="app-header__title">ZDDC Transmittal</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-12 · candle-mast-pearl</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 · plaza-fiddle-panel</span></span>
</div>
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
<!-- Publish split-button (Transmittal-specific primary action;
@ -4640,7 +4640,7 @@ X.B(E,Y);return E}return J}())
})(typeof window !== 'undefined' ? window : globalThis);
// shared/zddc-source.js — source abstraction for tools that handle
// directory trees (classifier, mdedit, transmittal, browse, archive).
// directory trees (classifier, transmittal, browse, archive).
//
// Two backends:
//
@ -5011,12 +5011,44 @@ X.B(E,Y);return E}return J}())
return !!(handle && handle.isHttp === true);
}
// downloadConverted fetches a server-side MD→{docx,html,pdf}
// conversion and triggers a browser download with a clean filename.
// srcUrl points at the .md source on the server. fmt is one of
// "docx" | "html" | "pdf". The server response status maps to a
// friendly error message for the caller to surface (toast / status).
async function downloadConverted(srcUrl, fileName, fmt) {
var resp = await fetch(srcUrl + '?convert=' + encodeURIComponent(fmt),
{ credentials: 'same-origin' });
if (!resp.ok) {
var msg;
if (resp.status === 503) msg = 'Conversion service unavailable on this server.';
else if (resp.status === 422) msg = 'Conversion failed — the source may be malformed.';
else if (resp.status === 504) msg = 'Conversion timed out.';
else msg = 'Conversion failed (HTTP ' + resp.status + ').';
// Append server-supplied body text if it adds detail.
try {
var detail = await resp.text();
if (detail && detail.length < 400) msg += ' ' + detail.trim();
} catch (_) { /* ignore */ }
throw new Error(msg);
}
var blob = await resp.blob();
var a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = fileName.replace(/\.md$/i, '') + '.' + fmt;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(function () { URL.revokeObjectURL(a.href); }, 1000);
}
window.zddc.source = {
HttpDirectoryHandle: HttpDirectoryHandle,
HttpFileHandle: HttpFileHandle,
detectServerRoot: detectServerRoot,
moveFile: moveFile,
isHttpHandle: isHttpHandle,
downloadConverted: downloadConverted,
// Lower-level helpers exposed for tools that want to call the
// server directly without going through the polyfill.
httpListing: httpListing,
@ -5484,7 +5516,7 @@ X.B(E,Y);return E}return J}())
*
* Renderers operate on any document (parent window or popup window), so the
* same code works for tools whose preview opens in a popup (classifier,
* archive, transmittal) and tools that render inline (mdedit).
* archive, transmittal) and tools that render inline (browse).
*
* Public API on window.zddc.preview:
* loadLibrary(url) → Promise<void>

View file

@ -1,9 +1,8 @@
# Generated by build.sh — do not edit. One <app>=<build label> per line.
archive=v0.0.17-beta · 2026-05-12 · candle-mast-pearl
transmittal=v0.0.17-beta · 2026-05-12 · candle-mast-pearl
classifier=v0.0.17-beta · 2026-05-12 · candle-mast-pearl
mdedit=v0.0.17-beta · 2026-05-12 · candle-mast-pearl
landing=v0.0.17-beta · 2026-05-12 · candle-mast-pearl
form=v0.0.17-beta · 2026-05-12 · candle-mast-pearl
tables=v0.0.17-beta · 2026-05-12 · candle-mast-pearl
browse=v0.0.17-beta · 2026-05-12 · candle-mast-pearl
archive=v0.0.17-beta · 2026-05-13 · plaza-fiddle-panel
transmittal=v0.0.17-beta · 2026-05-13 · plaza-fiddle-panel
classifier=v0.0.17-beta · 2026-05-13 · plaza-fiddle-panel
landing=v0.0.17-beta · 2026-05-13 · plaza-fiddle-panel
form=v0.0.17-beta · 2026-05-13 · plaza-fiddle-panel
tables=v0.0.17-beta · 2026-05-13 · plaza-fiddle-panel
browse=v0.0.17-beta · 2026-05-13 · plaza-fiddle-panel

View file

@ -60,8 +60,6 @@ func MatchAppHTML(requestPath string) (app string, requestDirRel string) {
return "transmittal", dir
case "classifier.html":
return "classifier", dir
case "mdedit.html":
return "mdedit", dir
case "browse.html":
return "browse", dir
}

View file

@ -50,7 +50,7 @@ func TestMatchAppHTML(t *testing.T) {
{"/index.html", "landing", ""},
{"/archive.html", "archive", ""},
{"/Project-X/archive.html", "archive", "Project-X"},
{"/Project-X/Working/mdedit.html", "mdedit", "Project-X/Working"},
{"/Project-X/Working/browse.html", "browse", "Project-X/Working"},
{"/foo.html", "", ""},
}
for _, tc := range cases {

View file

@ -47,6 +47,20 @@ type Config struct {
MaxWriteBytes int64 // --max-write-bytes / ZDDC_MAX_WRITE_BYTES — upper bound on PUT body size. Default 256 MiB. Per-request limit; rejected with 413.
CascadeMode string // --cascade-mode / ZDDC_CASCADE_MODE — "delegated" (default; leaf grants override ancestor denies) or "strict" (ancestor explicit-denies are absolute, NIST AC-6).
ArchiveRescanInterval time.Duration // --archive-rescan-interval / ZDDC_ARCHIVE_RESCAN_INTERVAL — periodic full re-walk of the archive index. Covers SMB/CIFS where inotify misses cross-client writes. Default 60s; 0 to disable.
// MD→{docx,html,pdf} conversion endpoint (see internal/convert).
// The server shells out to upstream pandoc + chromium container
// images via podman or docker, pulling each on first use via
// `--pull=missing`. No custom image build is required — only that
// podman or docker is on PATH and the configured image refs are
// reachable. If no runtime is found the endpoint serves 503.
ConvertPandocImage string // --convert-pandoc-image / ZDDC_CONVERT_PANDOC_IMAGE — image for MD→DOCX/HTML. Default docker.io/pandoc/latex:latest.
ConvertChromiumImage string // --convert-chromium-image / ZDDC_CONVERT_CHROMIUM_IMAGE — image for HTML→PDF. Default docker.io/zenika/alpine-chrome:latest.
ConvertEngine string // --convert-engine / ZDDC_CONVERT_ENGINE — override engine binary (default: probe for podman, then docker).
ConvertMemMiB int // --convert-mem-mib / ZDDC_CONVERT_MEM_MIB — per-container memory cap in MiB. Default 512.
ConvertCPUs string // --convert-cpus / ZDDC_CONVERT_CPUS — per-container CPU limit. Default "2".
ConvertPIDs int // --convert-pids / ZDDC_CONVERT_PIDS — per-container PID limit. Default 100.
ConvertTimeout time.Duration // --convert-timeout / ZDDC_CONVERT_TIMEOUT — per-conversion wall clock. Default 30s.
}
// ErrHelpRequested is returned by Load when --help is passed; the caller
@ -127,6 +141,20 @@ func Load(args []string) (Config, error) {
"ACL cascade evaluation mode: \"delegated\" (default — subtree allow can override ancestor deny) or \"strict\" (ancestor explicit-deny is absolute; NIST AC-6).")
archiveRescanIntervalFlag := fs.Duration("archive-rescan-interval", parseDurationOrDefault(os.Getenv("ZDDC_ARCHIVE_RESCAN_INTERVAL"), 60*time.Second),
"Periodic full re-walk of the archive index. Required on SMB/CIFS-backed roots where inotify misses cross-client writes. Default 60s; set 0 to disable.")
convertPandocImageFlag := fs.String("convert-pandoc-image", getEnv("ZDDC_CONVERT_PANDOC_IMAGE", "docker.io/pandoc/latex:latest"),
"Pandoc container image for MD→DOCX and MD→HTML. Pulled on first use via --pull=missing.")
convertChromiumImageFlag := fs.String("convert-chromium-image", getEnv("ZDDC_CONVERT_CHROMIUM_IMAGE", "docker.io/zenika/alpine-chrome:latest"),
"Headless Chromium container image for HTML→PDF. Pulled on first use via --pull=missing.")
convertEngineFlag := fs.String("convert-engine", os.Getenv("ZDDC_CONVERT_ENGINE"),
"Container engine override (default: probe for podman, then docker).")
convertMemMiBFlag := fs.Int("convert-mem-mib", parseIntOrDefault(os.Getenv("ZDDC_CONVERT_MEM_MIB"), 512),
"Per-conversion container memory limit in MiB. Default 512.")
convertCPUsFlag := fs.String("convert-cpus", getEnv("ZDDC_CONVERT_CPUS", "2"),
"Per-conversion container CPU limit (passed to --cpus). Default 2.")
convertPIDsFlag := fs.Int("convert-pids", parseIntOrDefault(os.Getenv("ZDDC_CONVERT_PIDS"), 100),
"Per-conversion container PID limit. Default 100.")
convertTimeoutFlag := fs.Duration("convert-timeout", parseDurationOrDefault(os.Getenv("ZDDC_CONVERT_TIMEOUT"), 30*time.Second),
"Per-conversion wall-clock timeout. Default 30s.")
accessLogFlag := fs.String("access-log", os.Getenv("ZDDC_ACCESS_LOG"),
"Tee structured access logs to this file (JSON, size-rotated). "+
"Default: <ZDDC_ROOT>/.zddc.d/logs/access-<hostname>.log. "+
@ -199,6 +227,13 @@ func Load(args []string) (Config, error) {
MaxWriteBytes: *maxWriteBytesFlag,
CascadeMode: *cascadeModeFlag,
ArchiveRescanInterval: *archiveRescanIntervalFlag,
ConvertPandocImage: *convertPandocImageFlag,
ConvertChromiumImage: *convertChromiumImageFlag,
ConvertEngine: *convertEngineFlag,
ConvertMemMiB: *convertMemMiBFlag,
ConvertCPUs: *convertCPUsFlag,
ConvertPIDs: *convertPIDsFlag,
ConvertTimeout: *convertTimeoutFlag,
}
// Default Root to the current working directory.
@ -494,3 +529,14 @@ func parseInt64OrDefault(s string, def int64) int64 {
}
return def
}
func parseIntOrDefault(s string, def int) int {
if s == "" {
return def
}
var n int
if _, err := fmt.Sscan(s, &n); err == nil {
return n
}
return def
}

View file

@ -0,0 +1,253 @@
// Package convert turns a markdown source byte-buffer into DOCX, HTML,
// or PDF via two stock upstream container images: pandoc (default
// `docker.io/pandoc/latex:latest`) handles MD↔DOCX and MD→HTML, and
// a headless-chromium image (default `docker.io/zenika/alpine-chrome:latest`)
// handles HTML→PDF. No custom image build is required — the operator
// just needs `podman` or `docker` on PATH and the runner pulls each
// image on first use via `--pull=missing`.
//
// Public surface:
//
// ToDocx(ctx, source, meta) → []byte (DOCX bytes)
// ToHTML(ctx, source, meta) → []byte (standalone HTML)
// ToPDF (ctx, source, meta) → []byte (PDF, via HTML + chromium)
//
// Probe(ctx, override) → Capabilities (call once at startup)
// Available() → (Capabilities, bool)
// SetImages(pandoc, chromium) — install image refs from config
//
// All three converters are safe for concurrent use; each call gets a
// fresh container. The pandoc image's entrypoint is `pandoc`, so the
// argv we pass after the image flows straight into pandoc. The
// alpine-chrome image's entrypoint is `chromium-browser`, so the argv
// flows into chromium-browser. No `sh -c` wrappers, no shell quoting.
//
// Metadata maps to the placeholders consumed by viewer-template.html.
// title/tracking_number/revision/status/is_draft typically come from
// the source filename (zddc.ParseFilename); client/project/contractor/
// project_number from the .zddc cascade `convert:` block.
package convert
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"sync/atomic"
"time"
)
// Metadata is the variable bag passed to pandoc as `--variable k=v`
// pairs. Fields with zero values are omitted. The viewer-template.html
// uses `$if(field)$ … $endif$` blocks so absent fields render cleanly.
type Metadata struct {
Title string
TrackingNumber string
Revision string
Status string
Client string
Project string
Contractor string
ProjectNumber string
GenerationTime time.Time
IsDraft bool
NoTOC bool
}
// Default images. Operator overrides via --convert-pandoc-image /
// --convert-chromium-image (see cmd/zddc-server). pandoc/latex carries
// TeX Live for native PDF too, so it's a superset of pandoc/core;
// operators wanting a slimmer footprint can switch to pandoc/core.
const (
DefaultPandocImage = "docker.io/pandoc/latex:latest"
DefaultChromiumImage = "docker.io/zenika/alpine-chrome:latest"
)
var (
pandocImage atomic.Pointer[string]
chromiumImage atomic.Pointer[string]
)
// SetImages installs the image refs used for subsequent ToDocx/ToHTML/
// ToPDF calls. Empty values keep the previous setting (or the
// DefaultPandocImage / DefaultChromiumImage constants on first call).
// Called from cmd/zddc-server/main.go after flag parsing.
func SetImages(pandoc, chromium string) {
if pandoc != "" {
s := pandoc
pandocImage.Store(&s)
}
if chromium != "" {
s := chromium
chromiumImage.Store(&s)
}
}
func currentPandocImage() string {
if p := pandocImage.Load(); p != nil && *p != "" {
return *p
}
return DefaultPandocImage
}
func currentChromiumImage() string {
if p := chromiumImage.Load(); p != nil && *p != "" {
return *p
}
return DefaultChromiumImage
}
// ToDocx renders source markdown to DOCX bytes. One container run via
// the pandoc image. Caller passes the full file content (envelope +
// body); pandoc handles `markdown+yaml_metadata_block` natively.
func ToDocx(ctx context.Context, source []byte, m Metadata) ([]byte, error) {
r := currentRunner()
if r == nil {
return nil, ErrUnavailable
}
cmd := []string{
"--from=markdown+yaml_metadata_block",
"--to=docx",
"--output=-",
}
cmd = append(cmd, metadataArgs(m)...)
cmd = append(cmd, "-")
return r.Run(ctx, currentPandocImage(), source, nil, cmd)
}
// ToHTML renders source markdown to standalone HTML using
// viewer-template.html. Embeds CSS + images via --embed-resources.
// Template + custom.css are bind-mounted into the container at /tpl
// from a per-call scratch dir.
func ToHTML(ctx context.Context, source []byte, m Metadata) ([]byte, error) {
r := currentRunner()
if r == nil {
return nil, ErrUnavailable
}
scratch, err := writeAssetsToScratch()
if err != nil {
return nil, fmt.Errorf("scratch: %w", err)
}
defer os.RemoveAll(scratch)
cmd := []string{
"--from=markdown+yaml_metadata_block",
"--to=html5",
"--standalone",
"--embed-resources",
"--section-divs",
"--id-prefix=",
"--html-q-tags",
"--template=/tpl/viewer-template.html",
}
if !m.NoTOC {
cmd = append(cmd, "--toc", "--toc-depth=6")
}
cmd = append(cmd, metadataArgs(m)...)
cmd = append(cmd, "--output=-", "-")
mounts := []string{scratch + ":/tpl:ro"}
return r.Run(ctx, currentPandocImage(), source, mounts, cmd)
}
// ToPDF renders source markdown to PDF in two stages: pandoc produces
// HTML using viewer-template.html (stage 1, pandoc image), then headless
// Chromium prints that HTML to PDF (stage 2, chromium image). The
// two-stage choice preserves the print-media CSS already authored in
// viewer-template.html — pandoc's native --pdf-engine path uses LaTeX
// which would bypass it entirely.
//
// Chromium runs from the alpine-chrome image whose entrypoint is
// `chromium-browser`; our cmd is the flag list passed straight to that
// binary. The host scratch dir is bind-mounted read-write at /pdf so
// chromium can write out.pdf and we read it back afterward.
func ToPDF(ctx context.Context, source []byte, m Metadata) ([]byte, error) {
html, err := ToHTML(ctx, source, m)
if err != nil {
return nil, err
}
r := currentRunner()
if r == nil {
return nil, ErrUnavailable
}
scratch, err := os.MkdirTemp("", "zddc-pdf-")
if err != nil {
return nil, fmt.Errorf("scratch: %w", err)
}
defer os.RemoveAll(scratch)
htmlPath := filepath.Join(scratch, "in.html")
pdfPath := filepath.Join(scratch, "out.pdf")
if err := os.WriteFile(htmlPath, html, 0o644); err != nil {
return nil, fmt.Errorf("write html: %w", err)
}
if err := chmodTree(scratch, 0o755, 0o644); err != nil {
return nil, err
}
mounts := []string{scratch + ":/pdf:rw"}
// alpine-chrome's entrypoint is `chromium-browser`. --no-sandbox is
// required because the container drops CAP_SYS_ADMIN; the threat
// model is "malicious markdown drives chromium RCE", contained by
// --network=none + --cap-drop=ALL + --read-only + tmpfs.
cmd := []string{
"--headless",
"--disable-gpu",
"--no-sandbox",
"--user-data-dir=/tmp/chrome",
"--no-pdf-header-footer",
"--virtual-time-budget=10000",
"--print-to-pdf=/pdf/out.pdf",
"file:///pdf/in.html",
}
if _, err := r.Run(ctx, currentChromiumImage(), nil, mounts, cmd); err != nil {
return nil, err
}
out, err := os.ReadFile(pdfPath)
if err != nil {
return nil, fmt.Errorf("read pdf: %w", err)
}
if len(out) < 4 || string(out[:4]) != "%PDF" {
return nil, &ConvertError{
Tool: "chromium",
ExitCode: 0,
Stderr: "chromium did not produce a valid PDF",
Cause: fmt.Errorf("invalid PDF magic in output (got %d bytes)", len(out)),
}
}
return out, nil
}
// metadataArgs renders Metadata into pandoc -V flags. Order is stable
// so test fixtures don't churn. Empty values are omitted (the template
// uses $if(...)$ blocks).
func metadataArgs(m Metadata) []string {
var out []string
add := func(k, v string) {
v = strings.TrimSpace(v)
if v == "" {
return
}
out = append(out, "-V", k+"="+v)
}
add("title", m.Title)
add("tracking_number", m.TrackingNumber)
add("revision", m.Revision)
add("status", m.Status)
add("client", m.Client)
add("project", m.Project)
add("contractor", m.Contractor)
add("project_number", m.ProjectNumber)
if !m.GenerationTime.IsZero() {
add("generation_time", m.GenerationTime.Format("January 02, 2006 at 3:04:05 PM MST"))
}
if m.IsDraft {
add("is_draft", "true")
}
if m.NoTOC {
add("no-toc", "true")
}
return out
}

View file

@ -0,0 +1,286 @@
package convert
import (
"context"
"errors"
"strings"
"sync"
"testing"
"time"
)
// fakeRunner records the args it was invoked with and replays canned
// responses. Lets us assert the command lines + image refs without
// needing podman.
type fakeRunner struct {
mu sync.Mutex
calls [][]string
images []string
stdin [][]byte
mounts [][]string
resp []byte
err error
}
func (f *fakeRunner) Run(_ context.Context, image string, stdin []byte, mounts []string, cmd []string) ([]byte, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.calls = append(f.calls, append([]string(nil), cmd...))
f.images = append(f.images, image)
f.stdin = append(f.stdin, append([]byte(nil), stdin...))
f.mounts = append(f.mounts, append([]string(nil), mounts...))
return f.resp, f.err
}
func (f *fakeRunner) lastCall() (string, []string) {
f.mu.Lock()
defer f.mu.Unlock()
if len(f.calls) == 0 {
return "", nil
}
return f.images[len(f.images)-1], f.calls[len(f.calls)-1]
}
func TestToDocx_UsesPandocImage(t *testing.T) {
f := &fakeRunner{resp: []byte("FAKE-DOCX")}
InstallRunner(f)
t.Cleanup(func() { InstallRunner(nil) })
SetImages("docker.io/pandoc/latex:latest", "")
out, err := ToDocx(context.Background(), []byte("# Hello\n"), Metadata{
Title: "Hello",
Client: "Acme",
})
if err != nil {
t.Fatalf("ToDocx: %v", err)
}
if string(out) != "FAKE-DOCX" {
t.Errorf("unexpected output: %q", out)
}
image, call := f.lastCall()
if image != "docker.io/pandoc/latex:latest" {
t.Errorf("expected pandoc image, got %q", image)
}
if !contains(call, "--to=docx") {
t.Errorf("missing --to=docx: %v", call)
}
if !contains(call, "title=Hello") {
t.Errorf("missing title metadata: %v", call)
}
if !contains(call, "client=Acme") {
t.Errorf("missing client metadata: %v", call)
}
// Last arg must be "-" so pandoc reads from stdin.
if call[len(call)-1] != "-" {
t.Errorf("expected stdin marker as last arg, got %q", call[len(call)-1])
}
}
func TestToHTML_UsesTemplateAndMountsScratch(t *testing.T) {
f := &fakeRunner{resp: []byte("<html>fake</html>")}
InstallRunner(f)
t.Cleanup(func() { InstallRunner(nil) })
SetImages("docker.io/pandoc/latex:latest", "")
_, err := ToHTML(context.Background(), []byte("# Hi\n"), Metadata{Title: "Hi"})
if err != nil {
t.Fatalf("ToHTML: %v", err)
}
image, call := f.lastCall()
if image != "docker.io/pandoc/latex:latest" {
t.Errorf("expected pandoc image, got %q", image)
}
if !contains(call, "--template=/tpl/viewer-template.html") {
t.Errorf("template flag missing: %v", call)
}
if !contains(call, "--toc") {
t.Errorf("TOC flag missing (default NoTOC=false): %v", call)
}
if len(f.mounts) == 0 || len(f.mounts[0]) == 0 {
t.Fatalf("expected at least one bind mount for /tpl")
}
mount := f.mounts[0][0]
if !strings.Contains(mount, ":/tpl:") {
t.Errorf("mount missing /tpl: %q", mount)
}
}
func TestToHTML_NoTOCSuppressesTOC(t *testing.T) {
f := &fakeRunner{resp: []byte("<html/>")}
InstallRunner(f)
t.Cleanup(func() { InstallRunner(nil) })
_, _ = ToHTML(context.Background(), []byte("# Hi\n"), Metadata{NoTOC: true})
_, call := f.lastCall()
if contains(call, "--toc") {
t.Errorf("TOC should be suppressed when NoTOC=true: %v", call)
}
if !contains(call, "no-toc=true") {
t.Errorf("no-toc metadata variable missing: %v", call)
}
}
// recordingRunner records every call and returns canned responses
// in sequence. Lets ToPDF tests assert the two-stage pipeline
// (pandoc image then chromium image).
type recordingRunner struct {
mu sync.Mutex
calls []recordedCall
resp [][]byte
err []error
cursor int
}
type recordedCall struct {
image string
cmd []string
mounts []string
}
func (r *recordingRunner) Run(_ context.Context, image string, _ []byte, mounts []string, cmd []string) ([]byte, error) {
r.mu.Lock()
defer r.mu.Unlock()
r.calls = append(r.calls, recordedCall{
image: image,
cmd: append([]string(nil), cmd...),
mounts: append([]string(nil), mounts...),
})
if r.cursor >= len(r.resp) {
return nil, nil
}
out := r.resp[r.cursor]
var e error
if r.cursor < len(r.err) {
e = r.err[r.cursor]
}
r.cursor++
return out, e
}
func TestToPDF_TwoStagePipeline(t *testing.T) {
// Stage 1: pandoc emits HTML. Stage 2: chromium reads HTML from
// the bind mount and writes /pdf/out.pdf. The fake runner can't
// actually write the PDF, so we expect ToPDF to fail at the
// read-back step — but we can still assert the two-stage call
// shape and the right image per stage.
r := &recordingRunner{
resp: [][]byte{
[]byte("<html><body>fake</body></html>"), // stage 1 stdout
nil, // stage 2 stdout (chromium writes PDF to bind mount)
},
}
InstallRunner(r)
t.Cleanup(func() { InstallRunner(nil) })
SetImages("docker.io/pandoc/latex:latest", "docker.io/zenika/alpine-chrome:latest")
_, err := ToPDF(context.Background(), []byte("# Hi\n"), Metadata{})
// PDF read-back will fail (fake runner didn't write the file) —
// that's expected for this test which only inspects the call
// shape.
if err == nil {
t.Fatalf("expected error from PDF read-back; got nil")
}
if len(r.calls) != 2 {
t.Fatalf("expected 2 container calls (pandoc + chromium); got %d", len(r.calls))
}
if r.calls[0].image != "docker.io/pandoc/latex:latest" {
t.Errorf("stage 1 image: got %q want pandoc/latex", r.calls[0].image)
}
if r.calls[1].image != "docker.io/zenika/alpine-chrome:latest" {
t.Errorf("stage 2 image: got %q want alpine-chrome", r.calls[1].image)
}
// Stage 2 must include the --print-to-pdf flag pointing at /pdf.
if !contains(r.calls[1].cmd, "--print-to-pdf=/pdf/out.pdf") {
t.Errorf("chromium call missing --print-to-pdf flag: %v", r.calls[1].cmd)
}
if !contains(r.calls[1].cmd, "--no-sandbox") {
t.Errorf("chromium call missing --no-sandbox: %v", r.calls[1].cmd)
}
// Stage 2's bind mount must be writable (chromium writes the PDF).
if len(r.calls[1].mounts) == 0 || !strings.Contains(r.calls[1].mounts[0], ":rw") {
t.Errorf("chromium mount must be :rw, got %v", r.calls[1].mounts)
}
}
func TestErrUnavailable_WhenNoRunner(t *testing.T) {
InstallRunner(nil)
_, err := ToDocx(context.Background(), []byte("x"), Metadata{})
if !errors.Is(err, ErrUnavailable) {
t.Errorf("expected ErrUnavailable, got %v", err)
}
}
func TestMetadataArgs_OmitsEmptyAndOrdersStably(t *testing.T) {
args := metadataArgs(Metadata{
Title: "T",
Project: "P",
GenerationTime: time.Date(2026, 5, 13, 14, 30, 22, 0, time.UTC),
})
want := []string{
"-V", "title=T",
"-V", "project=P",
}
for i, w := range want {
if i >= len(args) || args[i] != w {
t.Fatalf("args[%d]: got %v want prefix %v", i, args, want)
}
}
joined := strings.Join(args, "|")
if !strings.Contains(joined, "generation_time=") || !strings.Contains(joined, "2026") {
t.Errorf("generation_time missing or malformed: %v", args)
}
if strings.Contains(joined, "client=") {
t.Errorf("empty client should not be passed: %v", args)
}
}
func TestImageTag(t *testing.T) {
cases := map[string]string{
"docker.io/pandoc/latex:latest": "pandoc/latex",
"docker.io/zenika/alpine-chrome:latest": "zenika/alpine-chrome",
"pandoc/core": "pandoc/core",
"quay.io/example/foo:v1": "example/foo",
"alpine": "alpine",
}
for in, want := range cases {
if got := imageTag(in); got != want {
t.Errorf("imageTag(%q) = %q, want %q", in, got, want)
}
}
}
func TestSingleflight_Collapses(t *testing.T) {
var g singleflightGroup
const N = 50
var wg sync.WaitGroup
var hits int32
var mu sync.Mutex
wg.Add(N)
for i := 0; i < N; i++ {
go func() {
defer wg.Done()
_, _ = g.Do("k", func() (any, error) {
mu.Lock()
hits++
mu.Unlock()
time.Sleep(20 * time.Millisecond)
return "v", nil
})
}()
}
wg.Wait()
if hits != 1 {
t.Errorf("singleflight collapse: got %d invocations, want 1", hits)
}
}
// contains reports whether haystack has needle as any of its elements.
func contains(haystack []string, needle string) bool {
for _, s := range haystack {
if s == needle {
return true
}
}
return false
}

View file

@ -0,0 +1,163 @@
/*
* Legal-style heading numbering for ZDDC documents
* Adds hierarchical numbering like 1, 1.1, 1.1.1, etc.
*/
/* Reset counters at document level */
.document-content {
counter-reset: h1-counter;
}
/* H1 counters */
h1 {
counter-reset: h2-counter h3-counter h4-counter h5-counter h6-counter;
counter-increment: h1-counter;
}
h1::before {
content: counter(h1-counter) ". ";
font-weight: bold;
color: var(--primary-color);
}
/* H2 counters */
h2 {
counter-reset: h3-counter h4-counter h5-counter h6-counter;
counter-increment: h2-counter;
}
h2::before {
content: counter(h1-counter) "." counter(h2-counter) " ";
font-weight: bold;
color: var(--primary-color);
}
/* H3 counters */
h3 {
counter-reset: h4-counter h5-counter h6-counter;
counter-increment: h3-counter;
}
h3::before {
content: counter(h1-counter) "." counter(h2-counter) "." counter(h3-counter) " ";
font-weight: bold;
color: var(--primary-color);
}
/* H4 counters */
h4 {
counter-reset: h5-counter h6-counter;
counter-increment: h4-counter;
}
h4::before {
content: counter(h1-counter) "." counter(h2-counter) "." counter(h3-counter) "." counter(h4-counter) " ";
font-weight: bold;
color: var(--primary-color);
}
/* H5 counters */
h5 {
counter-reset: h6-counter;
counter-increment: h5-counter;
}
h5::before {
content: counter(h1-counter) "." counter(h2-counter) "." counter(h3-counter) "." counter(h4-counter) "." counter(h5-counter) " ";
font-weight: bold;
color: var(--primary-color);
}
/* H6 counters */
h6 {
counter-increment: h6-counter;
}
h6::before {
content: counter(h1-counter) "." counter(h2-counter) "." counter(h3-counter) "." counter(h4-counter) "." counter(h5-counter) "." counter(h6-counter) " ";
font-weight: bold;
color: var(--primary-color);
}
/* TOC numbering to match document headings */
.toc {
counter-reset: toc-h1;
}
.toc ul {
list-style: none;
}
.toc > ul > li {
counter-increment: toc-h1;
counter-reset: toc-h2 toc-h3 toc-h4 toc-h5 toc-h6;
}
.toc > ul > li > a::before {
content: counter(toc-h1) ". ";
font-weight: bold;
color: var(--primary-color);
margin-right: 0.25em;
}
.toc > ul > li > ul > li {
counter-increment: toc-h2;
counter-reset: toc-h3 toc-h4 toc-h5 toc-h6;
}
.toc > ul > li > ul > li > a::before {
content: counter(toc-h1) "." counter(toc-h2) " ";
font-weight: bold;
color: var(--primary-color);
margin-right: 0.25em;
}
.toc > ul > li > ul > li > ul > li {
counter-increment: toc-h3;
counter-reset: toc-h4 toc-h5 toc-h6;
}
.toc > ul > li > ul > li > ul > li > a::before {
content: counter(toc-h1) "." counter(toc-h2) "." counter(toc-h3) " ";
font-weight: bold;
color: var(--primary-color);
margin-right: 0.25em;
}
/* Optional: Add some spacing after the numbers */
h1::before, h2::before, h3::before, h4::before, h5::before, h6::before {
margin-right: 0.5em;
}
/* Print-specific adjustments */
@media print {
h1::before, h2::before, h3::before, h4::before, h5::before, h6::before {
color: #000 !important; /* Ensure numbers print in black */
}
}
/* Optional: Style adjustments for better visual hierarchy */
h1 {
border-bottom: 2px solid var(--primary-color);
padding-bottom: 0.3em;
margin-top: 1em;
}
/* Reduce margin for first heading */
h1:first-of-type {
margin-top: 0.5em;
}
h2 {
border-bottom: 1px solid var(--border-color);
padding-bottom: 0.2em;
margin-top: 1.5em;
}
h3 {
margin-top: 1.2em;
}
h4, h5, h6 {
margin-top: 1em;
}

View file

@ -0,0 +1,19 @@
package convert
import _ "embed"
// Pandoc HTML template and its companion stylesheet, copied verbatim from
// /pandoc/viewer-template.html and /pandoc/custom.css. The runner writes
// these to a host scratch dir on each conversion and bind-mounts them
// read-only into the container so pandoc can `--template` against them.
//
// Refresh: when /pandoc/viewer-template.html changes, copy the new bytes
// here. There's no symlink because go:embed paths must resolve under the
// containing module — and we want the binary to ship the bytes verbatim,
// not depend on the source tree at runtime.
//go:embed viewer-template.html
var viewerTemplate []byte
//go:embed custom.css
var customCSS []byte

View file

@ -0,0 +1,152 @@
package convert
import (
"context"
"fmt"
"log/slog"
"os/exec"
"strings"
"sync"
"sync/atomic"
"time"
)
// Capabilities is the snapshot of "can we convert right now?". The
// only hard requirement is a container runtime on PATH — image presence
// is left to `--pull=missing` at conversion time, so a missing image
// surfaces as a normal ConvertError (not a probe failure).
type Capabilities struct {
Engine string // "podman" | "docker" | ""
EngineVer string // first line of "<engine> --version"
PandocImage string // resolved pandoc image ref
ChromiumImage string // resolved chromium image ref
ProbedAt time.Time
Err error
}
// Ready reports whether conversions can be attempted. The first
// conversion may still fail if the configured image isn't reachable
// from the host's registry (the runner will surface a clear error
// from podman/docker stderr).
func (c Capabilities) Ready() bool {
return c.Engine != "" && c.Err == nil
}
// Reason returns a short human-friendly explanation when Ready() is
// false. Used as the body of a 503.
func (c Capabilities) Reason() string {
if c.Engine == "" {
return "no container runtime (podman or docker) found on PATH"
}
if c.Err != nil {
return c.Err.Error()
}
return "unavailable"
}
var (
caps atomic.Pointer[Capabilities]
probeCool sync.Mutex
)
// Available returns the current Capabilities snapshot and whether
// conversions can proceed.
func Available() (Capabilities, bool) {
p := caps.Load()
if p == nil {
return Capabilities{}, false
}
return *p, p.Ready()
}
// Probe locates the container engine and installs a containerRunner
// as the package default. Call once at server startup. Returns the
// captured Capabilities for logging.
//
// Engine order: engineOverride (if non-empty) → podman → docker. First
// hit wins. Image presence is NOT probed: the runner uses
// `--pull=missing` so the first conversion request will pull whichever
// image it needs.
//
// Any failure here is non-fatal: the server still starts, conversion
// endpoints just return 503. This matches the user's locked-in
// requirement that no-container-runtime ⇒ "can't do conversions".
func Probe(ctx context.Context, engineOverride string) Capabilities {
probeCool.Lock()
defer probeCool.Unlock()
now := time.Now()
c := Capabilities{
PandocImage: currentPandocImage(),
ChromiumImage: currentChromiumImage(),
ProbedAt: now,
}
engine := resolveEngine(engineOverride)
if engine == "" {
c.Err = fmt.Errorf("no container runtime found (tried: %s)", strings.Join(enginesTried(engineOverride), ", "))
caps.Store(&c)
slog.Warn("convert: probe failed", "reason", c.Err.Error())
return c
}
c.Engine = engine
if v, err := probeVersion(ctx, engine); err == nil {
c.EngineVer = v
}
InstallRunner(newContainerRunner(engine))
caps.Store(&c)
slog.Info("convert: ready",
"engine", engine,
"engine_version", c.EngineVer,
"pandoc_image", c.PandocImage,
"chromium_image", c.ChromiumImage)
return c
}
// Reprobe re-runs Probe with the existing configuration. Used by the
// handler when a request hits a not-Ready state — gives the operator
// a way to recover (e.g. installed podman after the server started)
// without a server restart. Cooldown of 60 s between probes to keep
// error-path requests cheap.
func Reprobe(ctx context.Context, engineOverride string) Capabilities {
if p := caps.Load(); p != nil {
if time.Since(p.ProbedAt) < 60*time.Second {
return *p
}
}
return Probe(ctx, engineOverride)
}
func resolveEngine(override string) string {
if override != "" {
if p, err := exec.LookPath(override); err == nil {
return p
}
return ""
}
for _, name := range []string{"podman", "docker"} {
if p, err := exec.LookPath(name); err == nil {
return p
}
}
return ""
}
func enginesTried(override string) []string {
if override != "" {
return []string{override}
}
return []string{"podman", "docker"}
}
func probeVersion(ctx context.Context, engine string) (string, error) {
c := exec.CommandContext(ctx, engine, "--version")
out, err := c.CombinedOutput()
if err != nil {
return "", err
}
line := strings.SplitN(strings.TrimSpace(string(out)), "\n", 2)[0]
return line, nil
}

View file

@ -0,0 +1,386 @@
package convert
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"io/fs"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
)
// Runner executes a conversion sub-process and returns its stdout.
// The host-side implementation (containerRunner) wraps `podman run`
// or `docker run`; tests use a fake.
//
// image is the OCI image to invoke (e.g. "docker.io/pandoc/latex:latest"
// or "docker.io/zenika/alpine-chrome:latest"). stdin is piped to the
// container's stdin. cmd is the argv passed *to the image's entrypoint*
// — for pandoc/latex the entrypoint is `pandoc`, for alpine-chrome it
// is `chromium-browser`. mounts is a list of "<hostPath>:<containerPath>"
// specs handed to --volume (":ro" is added if no mode segment is
// present).
//
// All exec calls in this package go through Runner.Run. This is the
// first os/exec site in the codebase; the hardening here is the
// pattern for future shell-outs.
type Runner interface {
Run(ctx context.Context, image string, stdin []byte, mounts []string, cmd []string) ([]byte, error)
}
// ErrUnavailable means no container runtime is present on the host.
// Handlers translate to HTTP 503.
var ErrUnavailable = errors.New("conversion unavailable")
// ConvertError carries the failure surface from a non-zero exit.
// Stderr is captured (truncated to 4 KiB by the runner) so callers can
// surface pandoc/chromium's own complaint.
type ConvertError struct {
Tool string // image name fragment, used only for logging
ExitCode int
Stderr string
Cause error
}
func (e *ConvertError) Error() string {
if e == nil {
return "<nil>"
}
if e.Stderr != "" {
return fmt.Sprintf("%s exit %d: %s", e.Tool, e.ExitCode, strings.TrimSpace(e.Stderr))
}
return fmt.Sprintf("%s exit %d: %v", e.Tool, e.ExitCode, e.Cause)
}
func (e *ConvertError) Unwrap() error { return e.Cause }
// containerRunner runs each conversion inside a fresh container.
// The engine ("podman" preferred, "docker" fallback) is resolved once
// at startup by Probe. Resource limits are configurable via
// SetLimits (called from main.go after flag parsing). Images are passed
// per call so the same runner handles both pandoc and chromium
// invocations.
//
// The runner relies on `--pull=missing` so the operator never has to
// pre-pull images: the first request that needs an image pulls it,
// subsequent requests use the local cache. Both podman and docker
// honour this flag identically.
type containerRunner struct {
mu sync.RWMutex
engine string
memMiB int
cpus string
pids int
timeout time.Duration
}
var (
// shared default runner, populated by InstallRunner (called from
// the health probe at startup once the engine is known).
defaultRunnerMu sync.RWMutex
defaultRunner Runner
)
// InstallRunner sets the package-level Runner used by ToDocx/ToHTML/ToPDF.
// Tests inject a fake; production code lets the health probe install a
// containerRunner. Safe to call from multiple goroutines.
func InstallRunner(r Runner) {
defaultRunnerMu.Lock()
defaultRunner = r
defaultRunnerMu.Unlock()
}
// ConfigureLimits applies resource limits to the package-level Runner,
// if it's a containerRunner. No-op when no runner is installed yet
// (the probe failed) or when the installed runner doesn't accept
// limits (e.g. a test fake). Zero values keep the previous setting.
//
// Called from cmd/zddc-server/main.go after Probe so the limits from
// the operator's flags take effect before any conversion request lands.
func ConfigureLimits(memMiB int, cpus string, pids int, timeout time.Duration) {
defaultRunnerMu.RLock()
r := defaultRunner
defaultRunnerMu.RUnlock()
if cr, ok := r.(*containerRunner); ok {
cr.SetLimits(memMiB, cpus, pids, timeout)
}
}
func currentRunner() Runner {
defaultRunnerMu.RLock()
r := defaultRunner
defaultRunnerMu.RUnlock()
return r
}
// SetLimits updates the resource ceilings used for subsequent Run
// invocations. Zero values keep the previous setting (or the defaults
// set at construction). Safe to call from multiple goroutines.
func (cr *containerRunner) SetLimits(memMiB int, cpus string, pids int, timeout time.Duration) {
cr.mu.Lock()
defer cr.mu.Unlock()
if memMiB > 0 {
cr.memMiB = memMiB
}
if cpus != "" {
cr.cpus = cpus
}
if pids > 0 {
cr.pids = pids
}
if timeout > 0 {
cr.timeout = timeout
}
}
func newContainerRunner(engine string) *containerRunner {
return &containerRunner{
engine: engine,
memMiB: 512,
cpus: "2",
pids: 100,
timeout: 30 * time.Second,
}
}
// Run executes one container invocation. cmd is the argv passed to the
// image's entrypoint (pandoc for pandoc/latex, chromium-browser for
// alpine-chrome). mounts is a list of "<hostPath>:<containerPath>"
// strings; ":ro" is appended when no mode segment is present. stdin is
// piped to the container, stdout is returned as bytes (capped at
// 128 MiB).
//
// Hardening:
// - --pull=missing: image is fetched on first use, cached after.
// Operator only needs podman/docker installed; no manual pull.
// - --rm: container is removed on exit, even if killed.
// - --network=none: no network inside the container. Prevents data
// exfiltration through embedded URLs in source documents.
// - --read-only + tmpfs on /tmp and /run: image fs is immutable;
// pandoc/chromium scratch goes to tmpfs only.
// - --memory / --cpus / --pids-limit: kernel-enforced caps.
// - --cap-drop=ALL + --security-opt=no-new-privileges: standard
// container-escape hardening.
// - context-cancel kill + WaitDelay: a wedged podman gets force-
// killed; pipes drop after 2s so we don't leak goroutines.
// - cmd.Env minimal: only PATH + HOME are passed through to the
// engine binary; the container itself sees only what the image
// bakes in plus what --env adds (HOME=/tmp).
//
// Note: --user is intentionally NOT set so each image uses its
// default user (pandoc/latex runs as root, alpine-chrome runs as
// uid 1000). With --read-only + tmpfs + --cap-drop=ALL +
// --network=none + --no-new-privileges the additional defense from
// forcing nobody is small and would break alpine-chrome's own
// user-data-dir layout.
func (cr *containerRunner) Run(ctx context.Context, image string, stdin []byte, mounts []string, cmd []string) ([]byte, error) {
cr.mu.RLock()
engine := cr.engine
memMiB := cr.memMiB
cpus := cr.cpus
pids := cr.pids
timeout := cr.timeout
cr.mu.RUnlock()
if engine == "" {
return nil, ErrUnavailable
}
if image == "" {
return nil, fmt.Errorf("convert.Run: image is empty")
}
runCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
args := []string{
"run",
"--rm",
"--pull=missing",
"-i",
"--network=none",
"--read-only",
"--tmpfs=/tmp:size=128m,exec",
"--tmpfs=/run:size=4m",
fmt.Sprintf("--memory=%dm", memMiB),
fmt.Sprintf("--cpus=%s", cpus),
fmt.Sprintf("--pids-limit=%d", pids),
"--cap-drop=ALL",
"--security-opt=no-new-privileges",
"--env=HOME=/tmp",
"--workdir=/tmp",
}
for _, m := range mounts {
if !strings.Contains(m, ":ro") && !strings.Contains(m, ":rw") {
m += ":ro"
}
args = append(args, "--volume="+m)
}
args = append(args, image)
args = append(args, cmd...)
c := exec.CommandContext(runCtx, engine, args...)
c.Cancel = func() error {
if c.Process == nil {
return nil
}
return c.Process.Kill()
}
c.WaitDelay = 2 * time.Second
c.SysProcAttr = sysProcAttr()
c.Env = []string{
"PATH=" + os.Getenv("PATH"),
"HOME=" + os.TempDir(),
}
c.Stdin = bytes.NewReader(stdin)
var stdoutBuf bytes.Buffer
c.Stdout = &limitWriter{w: &stdoutBuf, max: 128 << 20}
stderr := newRingWriter(4 << 10)
c.Stderr = stderr
err := c.Run()
if err != nil {
exitCode := -1
if ee, ok := err.(*exec.ExitError); ok {
exitCode = ee.ExitCode()
}
toolName := imageTag(image)
if runCtx.Err() == context.DeadlineExceeded {
return nil, &ConvertError{
Tool: toolName,
ExitCode: exitCode,
Stderr: stderr.String(),
Cause: fmt.Errorf("timeout after %s: %w", timeout, runCtx.Err()),
}
}
return nil, &ConvertError{
Tool: toolName,
ExitCode: exitCode,
Stderr: stderr.String(),
Cause: err,
}
}
return stdoutBuf.Bytes(), nil
}
// imageTag extracts a short name for an image reference, used as the
// "Tool" label on ConvertError. "docker.io/pandoc/latex:latest" →
// "pandoc/latex".
func imageTag(image string) string {
s := image
// Strip registry prefix.
if i := strings.Index(s, "/"); i >= 0 {
if strings.Contains(s[:i], ".") || strings.Contains(s[:i], ":") {
s = s[i+1:]
}
}
// Strip tag suffix.
if i := strings.LastIndex(s, ":"); i >= 0 {
s = s[:i]
}
return s
}
// limitWriter caps the underlying buffer at max bytes. Writes past the
// cap return io.ErrShortWrite, which surfaces as a Run() error — the
// caller then maps to 422 (output too large) at the handler edge.
type limitWriter struct {
w io.Writer
max int64
n int64
}
func (l *limitWriter) Write(p []byte) (int, error) {
if l.n >= l.max {
return 0, fmt.Errorf("output exceeded %d bytes", l.max)
}
rem := l.max - l.n
if int64(len(p)) > rem {
n, _ := l.w.Write(p[:rem])
l.n += int64(n)
return n, fmt.Errorf("output exceeded %d bytes", l.max)
}
n, err := l.w.Write(p)
l.n += int64(n)
return n, err
}
// ringWriter keeps only the tail of what's written — useful for stderr
// capture where the most-recent bytes are the ones with the actual
// error message and earlier output is usually progress noise.
type ringWriter struct {
mu sync.Mutex
buf []byte
max int
}
func newRingWriter(max int) *ringWriter {
return &ringWriter{max: max}
}
func (r *ringWriter) Write(p []byte) (int, error) {
r.mu.Lock()
defer r.mu.Unlock()
if len(p) >= r.max {
r.buf = append(r.buf[:0], p[len(p)-r.max:]...)
return len(p), nil
}
r.buf = append(r.buf, p...)
if len(r.buf) > r.max {
r.buf = r.buf[len(r.buf)-r.max:]
}
return len(p), nil
}
func (r *ringWriter) String() string {
r.mu.Lock()
defer r.mu.Unlock()
return string(r.buf)
}
// writeAssetsToScratch materialises the embedded viewer-template.html
// and custom.css into a fresh scratch dir under TMPDIR and returns the
// host path. Caller is responsible for os.RemoveAll(dir) when done.
// Used by ToHTML which needs the template visible inside the container.
//
// Files are written world-readable so the container's default user
// (root for pandoc/latex, uid 1000 for alpine-chrome) can read them
// through the read-only bind mount regardless of the host's umask.
func writeAssetsToScratch() (string, error) {
dir, err := os.MkdirTemp("", "zddc-convert-")
if err != nil {
return "", fmt.Errorf("scratch dir: %w", err)
}
if err := os.WriteFile(filepath.Join(dir, "viewer-template.html"), viewerTemplate, 0o644); err != nil {
os.RemoveAll(dir)
return "", fmt.Errorf("write template: %w", err)
}
if err := os.WriteFile(filepath.Join(dir, "custom.css"), customCSS, 0o644); err != nil {
os.RemoveAll(dir)
return "", fmt.Errorf("write css: %w", err)
}
if err := chmodTree(dir, 0o755, 0o644); err != nil {
os.RemoveAll(dir)
return "", err
}
return dir, nil
}
func chmodTree(root string, dirMode, fileMode os.FileMode) error {
return filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return os.Chmod(p, dirMode)
}
return os.Chmod(p, fileMode)
})
}

View file

@ -0,0 +1,43 @@
package convert
import "sync"
// singleflightGroup deduplicates concurrent calls keyed by string. If N
// goroutines call Do(key, fn) before the first one returns, fn runs once
// and all callers receive the same (val, err).
//
// Copy of internal/apps/singleflight.go so this package has no internal
// cross-imports. If a third caller appears, lift to internal/sf/.
type singleflightGroup struct {
mu sync.Mutex
m map[string]*sfCall
}
type sfCall struct {
done chan struct{}
val any
err error
}
func (g *singleflightGroup) Do(key string, fn func() (any, error)) (any, error) {
g.mu.Lock()
if g.m == nil {
g.m = make(map[string]*sfCall)
}
if c, ok := g.m[key]; ok {
g.mu.Unlock()
<-c.done
return c.val, c.err
}
c := &sfCall{done: make(chan struct{})}
g.m[key] = c
g.mu.Unlock()
c.val, c.err = fn()
close(c.done)
g.mu.Lock()
delete(g.m, key)
g.mu.Unlock()
return c.val, c.err
}

View file

@ -0,0 +1,20 @@
//go:build linux
package convert
import "syscall"
// sysProcAttr returns the platform-specific SysProcAttr for the
// container-engine child.
//
// - Setpgid: put the child in its own process group so a kill
// targeted at -pid kills grandchildren too (podman/docker spawn
// helper processes for chromium).
// - Pdeathsig: SIGKILL the child if the zddc-server parent exits.
// This is a Linux-only feature (other platforms get only Setpgid).
func sysProcAttr() *syscall.SysProcAttr {
return &syscall.SysProcAttr{
Setpgid: true,
Pdeathsig: syscall.SIGKILL,
}
}

View file

@ -0,0 +1,17 @@
//go:build darwin || freebsd || netbsd || openbsd
package convert
import "syscall"
// sysProcAttr returns the platform-specific SysProcAttr for the
// container-engine child. BSD-family targets get Setpgid only (no
// Pdeathsig); a zddc-server crash mid-conversion may leave the
// detached engine process running on macOS/BSD. In practice
// production deployments are Linux containers where the full
// hardening applies.
func sysProcAttr() *syscall.SysProcAttr {
return &syscall.SysProcAttr{
Setpgid: true,
}
}

View file

@ -0,0 +1,14 @@
//go:build windows
package convert
import "syscall"
// sysProcAttr returns the platform-specific SysProcAttr for the
// container-engine child. Windows: no Setpgid / Pdeathsig analogue;
// process-group semantics differ. We rely on context cancel +
// cmd.Process.Kill() + WaitDelay for cleanup. In practice production
// deployments are Linux containers where the full hardening applies.
func sysProcAttr() *syscall.SysProcAttr {
return &syscall.SysProcAttr{}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,297 @@
package handler
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/convert"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
// On-demand MD→{docx,html,pdf} conversion endpoint.
//
// GET /<path>/foo.md?convert=docx|html|pdf
//
// The source file's read policy (already enforced by the dispatcher
// before this handler runs) gates the response. The converted bytes
// are cached at <dir>/.converted/<base>.<ext>, with mtime synced to the
// source — so a fast-path GET that finds a fresh cache hit serves the
// disk file via http.ServeContent without invoking pandoc at all.
//
// When the cache is stale (or absent) the handler:
// 1. Reads source bytes.
// 2. Walks the .zddc cascade to assemble the convert.Metadata.
// 3. Calls convert.ToDocx / ToHTML / ToPDF (containerised pandoc).
// 4. Atomically writes the result to .converted/ and syncs mtime.
// 5. Serves it.
//
// Concurrent requests for the same URL share a single conversion via
// the singleflightGroup keyed by the cached-file absolute path.
var convertSF singleflightGroup
// convertTimeout bounds the slow-path conversion + write + serve. The
// runner itself enforces a finer-grained timeout on the container.
const convertTimeout = 90 * time.Second
// ServeConverted is the entry point. format is the requested target
// extension; chain is the already-resolved ACL chain (re-used here
// only to extract the convert: cascade metadata).
func ServeConverted(cfg config.Config, w http.ResponseWriter, r *http.Request, srcAbs, format string, chain zddc.PolicyChain) {
format = strings.ToLower(strings.TrimSpace(format))
switch format {
case "docx", "html", "pdf":
default:
http.Error(w, "Bad Request — convert must be docx, html, or pdf", http.StatusBadRequest)
return
}
caps, ok := convert.Available()
if !ok {
// One re-probe attempt — gives the operator a way to recover
// after building the image without restarting the server.
caps = convert.Reprobe(r.Context(), os.Getenv("ZDDC_CONVERT_ENGINE"))
if !caps.Ready() {
w.Header().Set("Retry-After", "60")
http.Error(w, "Service Unavailable — "+caps.Reason(), http.StatusServiceUnavailable)
return
}
}
srcInfo, err := os.Stat(srcAbs)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
http.Error(w, "Not Found", http.StatusNotFound)
} else {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
return
}
if srcInfo.IsDir() {
http.Error(w, "Bad Request — convert applies to files", http.StatusBadRequest)
return
}
base := strings.TrimSuffix(filepath.Base(srcAbs), filepath.Ext(srcAbs))
dir := filepath.Dir(srcAbs)
cacheDir := filepath.Join(dir, ".converted")
cacheAbs := filepath.Join(cacheDir, base+"."+format)
// Fast path: cached file present and mtime-equal to source.
if cacheInfo, err := os.Stat(cacheAbs); err == nil && cacheInfo.Mode().IsRegular() {
if cacheInfo.ModTime().Equal(srcInfo.ModTime()) {
serveCached(w, r, cacheAbs, format, base)
return
}
}
// Slow path: convert, cache, serve. Singleflight collapses
// concurrent requests for the same target.
_, err = convertSF.Do(cacheAbs, func() (any, error) {
return nil, buildAndStore(r.Context(), srcAbs, srcInfo, cacheDir, cacheAbs, format, base, chain)
})
if err != nil {
mapConvertError(w, err, format)
return
}
serveCached(w, r, cacheAbs, format, base)
}
// buildAndStore reads the source, runs the conversion, atomically
// writes the result, and syncs the cached mtime to the source mtime.
// Returns the cached file's absolute path on success.
func buildAndStore(ctx context.Context, srcAbs string, srcInfo os.FileInfo, cacheDir, cacheAbs, format, base string, chain zddc.PolicyChain) error {
source, err := os.ReadFile(srcAbs)
if err != nil {
return fmt.Errorf("read source: %w", err)
}
meta := buildMetadata(srcAbs, chain)
ctx, cancel := context.WithTimeout(ctx, convertTimeout)
defer cancel()
var out []byte
switch format {
case "docx":
out, err = convert.ToDocx(ctx, source, meta)
case "html":
out, err = convert.ToHTML(ctx, source, meta)
case "pdf":
out, err = convert.ToPDF(ctx, source, meta)
default:
return fmt.Errorf("unsupported format %q", format)
}
if err != nil {
return err
}
if err := os.MkdirAll(cacheDir, 0o755); err != nil {
return fmt.Errorf("mkdir cache: %w", err)
}
if err := zddc.WriteAtomic(cacheAbs, out); err != nil {
return fmt.Errorf("write cache: %w", err)
}
// Sync mtime to source so the fast-path predicate works on the
// next request. Both atime and mtime get the source's mtime —
// http.ServeContent honors mtime for Last-Modified / ETag.
srcMT := srcInfo.ModTime()
if err := os.Chtimes(cacheAbs, srcMT, srcMT); err != nil {
slog.Warn("convert: chtimes failed (continuing)", "path", cacheAbs, "err", err)
}
return nil
}
// buildMetadata assembles the Metadata used by pandoc -V flags. The
// filename-derived fields (title, tracking_number, revision, status,
// is_draft) come from zddc.ParseFilename; the project-wide fields
// (client/project/contractor/project_number) come from the cascade.
//
// chain.Levels is walked from leaf (last index, most specific) toward
// root, then Embedded as the final fallback. The first non-empty value
// per field wins.
func buildMetadata(srcAbs string, chain zddc.PolicyChain) convert.Metadata {
meta := convert.Metadata{
GenerationTime: time.Now(),
}
name := filepath.Base(srcAbs)
parsed := zddc.ParseFilename(name)
if parsed.Valid {
meta.Title = parsed.Title
meta.TrackingNumber = parsed.TrackingNumber
meta.Revision = parsed.Revision
meta.Status = parsed.Status
meta.IsDraft = strings.Contains(parsed.Revision, "~")
} else {
// Strip extension as a last-resort title.
stem := strings.TrimSuffix(name, filepath.Ext(name))
meta.Title = stem
}
apply := func(zf zddc.ZddcFile) {
if zf.Convert == nil {
return
}
if meta.Client == "" {
meta.Client = zf.Convert.Client
}
if meta.Project == "" {
meta.Project = zf.Convert.Project
}
if meta.Contractor == "" {
meta.Contractor = zf.Convert.Contractor
}
if meta.ProjectNumber == "" {
meta.ProjectNumber = zf.Convert.ProjectNumber
}
}
// Leaf → root.
for i := len(chain.Levels) - 1; i >= 0; i-- {
apply(chain.Levels[i])
}
apply(chain.Embedded)
return meta
}
// serveCached writes the cached file with the correct headers. ETag is
// derived from the source's mtime so a refresh changes it cleanly.
func serveCached(w http.ResponseWriter, r *http.Request, cacheAbs, format, base string) {
f, err := os.Open(cacheAbs)
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
defer f.Close()
info, err := f.Stat()
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", contentTypeFor(format))
w.Header().Set("Content-Disposition", contentDispositionFor(format, base))
w.Header().Set("X-ZDDC-Source", "convert:"+format)
// http.ServeContent handles If-Modified-Since / Range / etc. and
// emits Last-Modified from info.ModTime(). The clients we ship
// don't issue conditional GETs for conversions today, but other
// callers might.
http.ServeContent(w, r, filepath.Base(cacheAbs), info.ModTime(), f)
}
func contentTypeFor(format string) string {
switch format {
case "docx":
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
case "html":
return "text/html; charset=utf-8"
case "pdf":
return "application/pdf"
}
return "application/octet-stream"
}
// contentDispositionFor returns the disposition header. HTML is served
// inline (so the browser renders the rich viewer template directly);
// DOCX and PDF are inline too but the browse client adds the anchor's
// `download` attribute, which forces save-as. Filename is the source
// stem + the target extension so the user gets `foo.docx`, not
// `foo.md.docx`.
func contentDispositionFor(format, base string) string {
return fmt.Sprintf(`inline; filename="%s.%s"`, base, format)
}
// purgeConverted removes the cached .converted/<base>.{docx,html,pdf}
// sidecars for an .md source. Called from the file API after a
// successful PUT/DELETE/MOVE so the next GET ?convert= regenerates.
// Best-effort: errors (including "directory doesn't exist") are
// swallowed. Non-.md sources are a no-op so this is safe to call
// unconditionally after any write.
func purgeConverted(srcAbs string) {
if !strings.HasSuffix(strings.ToLower(srcAbs), ".md") {
return
}
dir := filepath.Dir(srcAbs)
base := strings.TrimSuffix(filepath.Base(srcAbs), filepath.Ext(srcAbs))
for _, ext := range []string{".docx", ".html", ".pdf"} {
_ = os.Remove(filepath.Join(dir, ".converted", base+ext))
}
}
func mapConvertError(w http.ResponseWriter, err error, format string) {
if errors.Is(err, convert.ErrUnavailable) {
w.Header().Set("Retry-After", "60")
http.Error(w, "Service Unavailable — conversion runtime not available", http.StatusServiceUnavailable)
return
}
var ce *convert.ConvertError
if errors.As(err, &ce) {
// Timeout → 504. Non-zero exit with stderr → 422 with the
// stderr excerpt so the client can show a real message.
if ce.Cause != nil && errors.Is(ce.Cause, context.DeadlineExceeded) {
http.Error(w, "Gateway Timeout — conversion timed out", http.StatusGatewayTimeout)
return
}
msg := strings.TrimSpace(ce.Stderr)
if msg == "" {
msg = ce.Error()
}
if len(msg) > 1024 {
msg = msg[:1024]
}
http.Error(w, "Unprocessable Entity — "+msg, http.StatusUnprocessableEntity)
return
}
slog.Warn("convert: unexpected error", "format", format, "err", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}

View file

@ -379,6 +379,9 @@ func serveFilePut(cfg config.Config, w http.ResponseWriter, r *http.Request) {
// Invalidate ETag cache (static.go memoizes by mtime; rename produces
// a fresh mtime so a stale entry is harmless, but clearing is cheap).
etagCacheM.Delete(abs)
// Invalidate any cached MD→{docx,html,pdf} conversions sitting in
// the sibling .converted/ dir for this source.
purgeConverted(abs)
etag := fileETag(body)
w.Header().Set("ETag", `"`+etag+`"`)
@ -433,6 +436,7 @@ func serveFileDelete(cfg config.Config, w http.ResponseWriter, r *http.Request)
return
}
etagCacheM.Delete(abs)
purgeConverted(abs)
w.Header().Set("X-ZDDC-Source", "fileapi:delete")
w.WriteHeader(http.StatusNoContent)
@ -553,6 +557,8 @@ func serveFileMove(cfg config.Config, w http.ResponseWriter, r *http.Request) {
}
etagCacheM.Delete(srcAbs)
etagCacheM.Delete(dstAbs)
purgeConverted(srcAbs)
purgeConverted(dstAbs)
// Compute new ETag from the moved bytes for the response — clients
// that want to keep tracking should pin to this ETag.

View file

@ -233,8 +233,8 @@ func computePending(ctx context.Context, decider policy.Decider,
// ServeReviewing emits the aggregator JSON listing for any depth under
// <project>/reviewing/. The HTML branch is handled separately by the
// apps subsystem (mdedit served at the URL); only requests that accept
// JSON reach here.
// apps subsystem (browse served at the URL — its markdown editor plugin
// renders responses); only requests that accept JSON reach here.
//
// Depths:
//

View file

@ -0,0 +1,44 @@
package handler
import "sync"
// singleflightGroup deduplicates concurrent calls keyed by string. If N
// goroutines call Do(key, fn) before the first one returns, fn runs once
// and all callers receive the same (val, err).
//
// Copy of internal/apps/singleflight.go (same pattern, no extra
// dependency). The convert package has its own copy too; if a third
// caller appears, lift to internal/sf/.
type singleflightGroup struct {
mu sync.Mutex
m map[string]*sfCall
}
type sfCall struct {
done chan struct{}
val any
err error
}
func (g *singleflightGroup) Do(key string, fn func() (any, error)) (any, error) {
g.mu.Lock()
if g.m == nil {
g.m = make(map[string]*sfCall)
}
if c, ok := g.m[key]; ok {
g.mu.Unlock()
<-c.done
return c.val, c.err
}
c := &sfCall{done: make(chan struct{})}
g.m[key] = c
g.mu.Unlock()
c.val, c.err = fn()
close(c.done)
g.mu.Lock()
delete(g.m, key)
g.mu.Unlock()
return c.val, c.err
}

View file

@ -1300,7 +1300,7 @@ body.help-open .app-header {
</svg>
<div class="header-title-group">
<span class="app-header__title" id="table-title">ZDDC Table</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-12 · candle-mast-pearl</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 · plaza-fiddle-panel</span></span>
</div>
</div>
<div class="header-right">
@ -1845,7 +1845,7 @@ body.help-open .app-header {
}(typeof window !== 'undefined' ? window : this));
// shared/zddc-source.js — source abstraction for tools that handle
// directory trees (classifier, mdedit, transmittal, browse, archive).
// directory trees (classifier, transmittal, browse, archive).
//
// Two backends:
//
@ -2216,12 +2216,44 @@ body.help-open .app-header {
return !!(handle && handle.isHttp === true);
}
// downloadConverted fetches a server-side MD→{docx,html,pdf}
// conversion and triggers a browser download with a clean filename.
// srcUrl points at the .md source on the server. fmt is one of
// "docx" | "html" | "pdf". The server response status maps to a
// friendly error message for the caller to surface (toast / status).
async function downloadConverted(srcUrl, fileName, fmt) {
var resp = await fetch(srcUrl + '?convert=' + encodeURIComponent(fmt),
{ credentials: 'same-origin' });
if (!resp.ok) {
var msg;
if (resp.status === 503) msg = 'Conversion service unavailable on this server.';
else if (resp.status === 422) msg = 'Conversion failed — the source may be malformed.';
else if (resp.status === 504) msg = 'Conversion timed out.';
else msg = 'Conversion failed (HTTP ' + resp.status + ').';
// Append server-supplied body text if it adds detail.
try {
var detail = await resp.text();
if (detail && detail.length < 400) msg += ' ' + detail.trim();
} catch (_) { /* ignore */ }
throw new Error(msg);
}
var blob = await resp.blob();
var a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = fileName.replace(/\.md$/i, '') + '.' + fmt;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(function () { URL.revokeObjectURL(a.href); }, 1000);
}
window.zddc.source = {
HttpDirectoryHandle: HttpDirectoryHandle,
HttpFileHandle: HttpFileHandle,
detectServerRoot: detectServerRoot,
moveFile: moveFile,
isHttpHandle: isHttpHandle,
downloadConverted: downloadConverted,
// Lower-level helpers exposed for tools that want to call the
// server directly without going through the polyfill.
httpListing: httpListing,

View file

@ -276,7 +276,7 @@ func TestServeZddcEditorRendersAppsSection(t *testing.T) {
`data-apps-key="default"`,
`data-apps-key="archive"`,
`data-apps-key="classifier"`,
`data-apps-key="mdedit"`,
`data-apps-key="browse"`,
`data-apps-key="transmittal"`,
`data-apps-key="landing"`,
`value=":beta"`,

View file

@ -53,11 +53,12 @@ roles:
members: []
# Universal tool baseline. archive (record browser), browse (file
# tree), and landing (project picker) work everywhere. Each canonical
# folder below adds its own context-specific tools (mdedit in
# working/, transmittal in staging/, etc.). The cascade unions
# available_tools across all levels — leaf restrictions don't drop
# ancestor entries — so this baseline propagates to every descendant.
# tree, hosts the in-place markdown editor), and landing (project
# picker) work everywhere. Each canonical folder below adds its own
# context-specific tools (transmittal in staging/, etc.). The cascade
# unions available_tools across all levels — leaf restrictions don't
# drop ancestor entries — so this baseline propagates to every
# descendant.
available_tools: [archive, browse, landing]
# ── The slash / no-slash routing convention ────────────────────────────────
@ -71,9 +72,9 @@ available_tools: [archive, browse, landing]
# default; you rarely set it.
# <dir> (no slash) → `default_tool` — the "specialized
# app" for this folder (e.g. archive,
# transmittal, mdedit, tables). If a
# folder declares no default_tool, the
# no-slash form just 302s to the slash
# transmittal, tables). If a folder
# declares no default_tool, the no-
# slash form just 302s to the slash
# form, so you land on `dir_tool`.
#
# JSON listing requests are unaffected by either key — they always get
@ -81,7 +82,7 @@ available_tools: [archive, browse, landing]
# can enumerate entries no matter what dir_tool/default_tool are.
#
# Both keys cascade leaf→root: a parent's default_tool applies to
# descendants unless a deeper level overrides it (mdedit set on
# descendants unless a deeper level overrides it (browse set on
# working/ reaches working/alice/notes/ for free). The keys below set
# default_tool on the canonical folders; dir_tool is left unset
# everywhere, so the slash form is always `browse`.
@ -201,8 +202,8 @@ paths:
default_tool: archive
worm: [document_controller]
working:
default_tool: mdedit
available_tools: [mdedit, classifier]
default_tool: browse
available_tools: [browse, classifier]
# working/ auto-owns the first creator + the per-user homes
# below.
auto_own: true
@ -215,8 +216,8 @@ paths:
admins: [document_controller]
paths:
"*": # per-user home dir
default_tool: mdedit
available_tools: [mdedit, classifier]
default_tool: browse
available_tools: [browse, classifier]
auto_own: true
# Per-user home is private by default: the generated
# auto-own .zddc carries inherit:false so ancestor ACL
@ -233,8 +234,8 @@ paths:
# rationale as working/.
admins: [document_controller]
reviewing:
default_tool: mdedit
available_tools: [mdedit]
default_tool: browse
available_tools: [browse]
# reviewing/ is purely virtual — the aggregator handler
# synthesises listings from received/ ↔ staging/ ↔ issued/.
virtual: true

View file

@ -92,6 +92,22 @@ type Role struct {
Reset bool `yaml:"reset,omitempty" json:"reset,omitempty"`
}
// ConvertMetadata supplies per-project template variables for the
// server-side MD→{docx,html,pdf} conversion endpoint. The handler
// resolves the effective set by walking the .zddc cascade leaf→root
// with per-key latest-wins (an empty deeper value does NOT clear an
// ancestor value — operators write the explicit string they want).
//
// Variables are passed to pandoc as -V key=value flags and consumed by
// pandoc/viewer-template.html's $if(client)$ / $if(project)$ /
// $if(contractor)$ / $if(project_number)$ blocks.
type ConvertMetadata struct {
Client string `yaml:"client,omitempty" json:"client,omitempty"`
Project string `yaml:"project,omitempty" json:"project,omitempty"`
Contractor string `yaml:"contractor,omitempty" json:"contractor,omitempty"`
ProjectNumber string `yaml:"project_number,omitempty" json:"project_number,omitempty"`
}
// ZddcFile represents the parsed contents of a .zddc configuration file.
//
// Admins is honored only in the root .zddc file (<ZDDC_ROOT>/.zddc); subdir
@ -157,6 +173,17 @@ type ZddcFile struct {
// directory whose entries they want renamed.
Display map[string]string `yaml:"display,omitempty" json:"display,omitempty"`
// Convert supplies template variables for the server-side
// MD→{docx,html,pdf} conversion endpoint (see internal/convert).
// Cascades leaf→root with per-key latest-wins. Pointer-to-struct
// so unset is distinguishable from "explicitly empty" — relevant
// because the cascade merger needs to know whether a deeper .zddc
// is contributing a value or just inheriting.
//
// Filename-derived variables (title, tracking_number, revision,
// status) come from zddc.ParseFilename and are NOT in this struct.
Convert *ConvertMetadata `yaml:"convert,omitempty" json:"convert,omitempty"`
// Roles are named principal groups available at this level and below.
// See Role for member syntax.
Roles map[string]Role `yaml:"roles,omitempty" json:"roles,omitempty"`
@ -185,9 +212,9 @@ type ZddcFile struct {
Inherit *bool `yaml:"inherit,omitempty" json:"inherit,omitempty"`
// DefaultTool is the tool name served at this directory's
// no-slash URL form (e.g. /Project/working without trailing slash
// → mdedit). Empty means "no default" — the no-slash form 302s to
// the slash form, which serves DirTool (browse by default).
// no-slash URL form (e.g. /Project/staging without trailing slash
// → transmittal). Empty means "no default" — the no-slash form
// 302s to the slash form, which serves DirTool (browse by default).
// Cascades through Paths: an ancestor's Paths entry can set
// DefaultTool for a virtual descendant without anyone creating
// that dir. This is the "specialized app" half of the slash/no-
@ -271,8 +298,10 @@ type ZddcFile struct {
// Empty list at every level means "no tools available" (effectively
// blocks all auto-serving); the embedded defaults seed the
// universal baseline of archive/browse/landing at root. Operators
// can add tools at deeper levels (working/ adds mdedit + classifier,
// staging/ adds transmittal + classifier, etc.).
// can add tools at deeper levels (working/ adds classifier,
// staging/ adds transmittal + classifier, etc.). browse hosts the
// markdown editor as a plugin so no extra tool is needed under
// working/ or reviewing/.
//
// This does NOT gate explicit static files: an on-disk
// <dir>/transmittal.html is always served. It gates only the

View file

@ -46,3 +46,50 @@ func IsTrnOrSubTracking(tracking string) bool {
upper := strings.ToUpper(tracking)
return strings.Contains(upper, "-TRN-") || strings.Contains(upper, "-SUB-")
}
// documentFilenameRE matches the canonical ZDDC document-filename shape:
//
// <tracking>_<revision> (<status>) - <title>.<ext>
//
// where <tracking> has no spaces or underscores in the tracking part,
// <revision> is anything without a space, <status> is anything inside
// parentheses, and <title> is anything after the dash up to the
// last "." before the extension.
//
// Mirror of the JS parser in shared/zddc.js — kept here for the
// conversion handler which needs to feed title/tracking/revision/
// status to pandoc as template variables.
var documentFilenameRE = regexp.MustCompile(
`^([^_]+)_(\S+)\s*\(([^)]+)\)\s*-\s*(.+)\.([^.]+)$`,
)
// ParsedFilename is the result of ParseFilename: tracking number,
// revision, status, title (everything before the extension), and the
// lowercased extension. Valid is true iff the filename matched the
// canonical pattern.
type ParsedFilename struct {
TrackingNumber string
Revision string
Status string
Title string
Extension string
Valid bool
}
// ParseFilename splits a document filename into its ZDDC components.
// Returns Valid=false if the filename doesn't match the canonical shape;
// callers can fall back to stem-based metadata in that case.
func ParseFilename(name string) ParsedFilename {
m := documentFilenameRE.FindStringSubmatch(name)
if m == nil {
return ParsedFilename{}
}
return ParsedFilename{
TrackingNumber: m[1],
Revision: m[2],
Status: m[3],
Title: m[4],
Extension: strings.ToLower(m[5]),
Valid: true,
}
}

View file

@ -12,8 +12,8 @@ import (
// Lookup walks chain.Levels from leaf toward root, returning the
// first non-empty value. This implements the "parent applies to
// descendants unless overridden" cascade rule: a working/ folder's
// default_tool=mdedit propagates to working/alice/notes/ even when
// no .zddc declares mdedit at the deeper levels.
// default_tool=browse propagates to working/alice/notes/ even when
// no .zddc declares browse at the deeper levels.
//
// Used by the URL dispatcher to route no-slash directory URLs.
// Replaces apps.DefaultAppAt once consumers are migrated (Phase 3b).

View file

@ -21,10 +21,10 @@ func TestDefaultToolAt_FromEmbeddedConvention(t *testing.T) {
{filepath.Join(root, "Project-X", "archive", "Acme", "incoming"), "classifier"},
{filepath.Join(root, "Project-X", "archive", "Acme", "received"), "archive"},
{filepath.Join(root, "Project-X", "archive", "Acme", "issued"), "archive"},
{filepath.Join(root, "Project-X", "working"), "mdedit"},
{filepath.Join(root, "Project-X", "working", "alice@example.com"), "mdedit"},
{filepath.Join(root, "Project-X", "working"), "browse"},
{filepath.Join(root, "Project-X", "working", "alice@example.com"), "browse"},
{filepath.Join(root, "Project-X", "staging"), "transmittal"},
{filepath.Join(root, "Project-X", "reviewing"), "mdedit"},
{filepath.Join(root, "Project-X", "reviewing"), "browse"},
}
for _, tc := range cases {
got := DefaultToolAt(root, tc.path)
@ -177,7 +177,7 @@ func TestOperatorOverride_DefaultsAreSurfaceable(t *testing.T) {
t.Fatal(err)
}
// Operator declares that Special/working uses classifier
// instead of the embedded-default mdedit.
// instead of the embedded-default browse.
writeZddc(t, filepath.Join(root, "Special", "working"),
"default_tool: classifier\n")
@ -185,7 +185,7 @@ func TestOperatorOverride_DefaultsAreSurfaceable(t *testing.T) {
t.Errorf("operator override should set default_tool=classifier, got %q", got)
}
// Default still applies at other projects.
if got := DefaultToolAt(root, filepath.Join(root, "Project-Y", "working")); got != "mdedit" {
if got := DefaultToolAt(root, filepath.Join(root, "Project-Y", "working")); got != "browse" {
t.Errorf("default convention should hold at unchanged paths, got %q", got)
}
}
@ -193,14 +193,14 @@ func TestOperatorOverride_DefaultsAreSurfaceable(t *testing.T) {
// TestDefaultToolAt_PropagatesToDescendants — once an ancestor sets
// default_tool, descendants inherit it unless they override. So a
// path under working/ that isn't explicitly declared in paths: still
// gets mdedit as its default tool.
// gets browse as its default tool.
func TestDefaultToolAt_PropagatesToDescendants(t *testing.T) {
resetCache()
root := t.TempDir()
// Deep path under working/ — not explicitly mentioned in paths:.
deep := filepath.Join(root, "Project-X", "working", "alice@example.com", "notes", "sub", "deep")
if got := DefaultToolAt(root, deep); got != "mdedit" {
t.Errorf("DefaultToolAt(%q) = %q, want mdedit (cascade propagation)",
if got := DefaultToolAt(root, deep); got != "browse" {
t.Errorf("DefaultToolAt(%q) = %q, want browse (cascade propagation)",
deep[len(root):], got)
}
}

View file

@ -9,11 +9,14 @@ import (
// via the apps fetch+cache subsystem. Order is stable for reproducible
// admin-UI rendering.
//
// All eight HTML tools belong here — including browse, form, and tables.
// All seven HTML tools belong here — including browse, form, and tables.
// Omitting any of them means the apps cascade (.zddc apps:) silently
// short-circuits to embedded for that name, defeating live-dev
// path-source overrides.
var AppNames = []string{"archive", "transmittal", "classifier", "mdedit", "landing", "browse", "form", "tables"}
//
// Markdown editing used to be a dedicated tool ("mdedit"); it now
// lives as a plugin inside browse (browse/js/preview-markdown.js).
var AppNames = []string{"archive", "transmittal", "classifier", "landing", "browse", "form", "tables"}
// AppsDefaultKey is the special apps-map key that provides the baseline
// URL prefix and channel for any app not overridden per-name. Cascades
@ -237,7 +240,7 @@ func ValidateFile(zf ZddcFile) []FieldError {
if !IsValidAppsKey(app) {
errs = append(errs, FieldError{
Field: fmt.Sprintf("apps.%s", app),
Message: fmt.Sprintf("unknown app %q (known: default, archive, transmittal, classifier, mdedit, landing, browse, form, tables)", app),
Message: fmt.Sprintf("unknown app %q (known: default, archive, transmittal, classifier, landing, browse, form, tables)", app),
})
continue
}

View file

@ -139,7 +139,7 @@ func TestIsValidAppsKey(t *testing.T) {
{"archive", true},
{"transmittal", true},
{"classifier", true},
{"mdedit", true},
{"browse", true},
{"landing", true},
{"unknown", false},
{"", false},
@ -161,7 +161,7 @@ func TestValidateFile_Apps(t *testing.T) {
"classifier": "v0.0.4", // ok
"default": "https://zddc.varasys.io/releases:stable", // ok (default key + URL+channel)
"transmittal": ":beta", // ok (channel-only)
"mdedit": "https://my-mirror.example/releases", // ok (URL-prefix only)
"browse": "https://my-mirror.example/releases", // ok (URL-prefix only)
"unknown": "stable", // unknown app
"landing": "what is this", // bad spec
},

View file

@ -105,6 +105,29 @@ func mergeOverlay(base, top ZddcFile) ZddcFile {
out.Tables = mergeStringMap(out.Tables, top.Tables)
out.Display = mergeStringMap(out.Display, top.Display)
// Convert: per-key latest-wins. Pointer-to-struct so we can tell
// "absent" from "explicitly empty" — the latter is rare but valid
// (an operator who wants to suppress a deployment-default value).
// Empty top values do NOT clear the ancestor value; operators must
// set an explicit non-empty string to override.
if top.Convert != nil {
if out.Convert == nil {
out.Convert = &ConvertMetadata{}
}
if top.Convert.Client != "" {
out.Convert.Client = top.Convert.Client
}
if top.Convert.Project != "" {
out.Convert.Project = top.Convert.Project
}
if top.Convert.Contractor != "" {
out.Convert.Contractor = top.Convert.Contractor
}
if top.Convert.ProjectNumber != "" {
out.Convert.ProjectNumber = top.Convert.ProjectNumber
}
}
// Roles: per-name merge (top wins on name clash). This combines
// the on-disk .zddc at a level with any virtual contributions
// from ancestor paths: at the same level. Cross-LEVEL role