Machine-readable diagnostics
zigapagos release --format=json emits build diagnostics as NDJSON (newline-delimited JSON) on stderr, with a stable per-diagnostic code, rather than prose. zigapagos explain-code <CODE> is the companion command for reading the long-form meaning of a code.
This document is the consumer contract: the wire schema, what is and isn’t guaranteed, and what the stream covers.
Enabling it
zigapagos release --format=json -o public
Default is --format=text. An invalid value fatals with a usage error naming the accepted values:
zigapagos release --format=xml
# error: invalid --format value 'xml' (want text|json)
--format is accepted by release and validate (build diagnostics), plus doctor (its own finding stream on stdout — see below) and inspect-output (its own inventory stream on stdout). release and validate share one stream and schema — validate covers the release build’s pre-SSR subset, see zigapagos validate --help for exactly what that excludes. It is not a flag dev recognises: dev re-runs a rebuild command and reports whatever that command printed.
Wire schema
One JSON object per line on stderr, minified, UTF-8, LF-terminated:
{"code":"ZP_LINK_NOT_A_SECTION","severity":"error","file":"content/other.smd","line":11,"col":1,"message":"this page has no subpages (page is not a section)","help":"a leading '.' means \"subpage of this section\", not a relative path -- to link a sibling page, use $link.page(\"...\")"}
Fields, in this exact declaration order (part of the schema — see src/diag.zig’s Diagnostic struct):
| field | type | meaning |
|---|---|---|
code | string | The stable identifier. See Stability. |
severity | "error" | "warning" | Whether this diagnostic, by itself, fails the build. |
file | string | null | Source path, relative to the content/layouts root, when the diagnostic names one file. null when it doesn’t (e.g. a URL collision between two pages) or names more than one (folded into message instead). |
line | integer | null | 1-based line, when known. |
col | integer | null | 1-based column, when known. |
message | string | Human-readable headline. Not stable — see Stability. May be multi-line (embedded \n) when it wraps a foreign printer’s block output (SuperHTML/ziggy). |
help | string | null | A short inline hint, when the diagnostic has one. This is not the long form — that’s zigapagos explain-code <CODE>. |
Consuming the stream: skip what doesn’t parse
stderr under --format=json is not guaranteed to be pure NDJSON. Several things legitimately write to the same stream outside this mechanism:
- The Bun sidecar (SSR, CSS minification) inherits stderr directly and prints its own diagnostics on failure.
- Two scan-time advisories in
src/root.zigare prose: the site-wide-artifact alias warning (aliases: ["404.html"]on a non-root page) and the sectionless-directory warning (a content directory with pages but noindex.smd). Neither touchesany_prerendering_error— they are advisory only, and both are multi-linenote:-style blocks whose value is in the prose. - A missing
zigapagos.ziggyhitsfatal.helpError(), which prints the whole usage menu as prose, not a diagnostic (see below).
The consumer rule: skip a line that does not parse as JSON. Never fail the whole run on one. Every diagnostic this mechanism produces does parse; a non-parsing line means something outside it wrote to stderr, which is expected, not a bug in the stream.
fatal.helpError() stays prose
One fatal exit path is deliberately not a diagnostic: fatal.helpError() prints the full top-level usage menu (the Commands: / General Options: listing), not a single diagnostic — there is no sensible one-line NDJSON shape for “here is the whole command reference.” Concretely: running zigapagos release --format=json in a directory with no zigapagos.ziggy prints plain prose plus the usage menu on stderr, exit 1. This is the one place JSON mode still looks like text mode.
Stability
code is the stability guarantee. message and help are not.
- A
code, once shipped, is permanent.src/diag-codes.frozenis the append-only ledger backing this: a code is never renamed and never reused for a different meaning after retirement.src/diag.zig’stest "diag: ..."blocks enforce this against the frozen file on every build. messageis free-form prose. Several codes (ZP_SUPERMD,ZP_TEMPLATE_PARSE) wrap a foreign printer’s output (SuperMD, a gitignored dependency synced at upstream release tags; SuperHTML’s own error printer) — that text can and will reword across a sync. Match oncode, never onmessagecontent.help, when present, is a short fixed hint tied to the code, not independently versioned — treat it the same asmessage: informative, not a contract.
Run zigapagos explain-code <CODE> for the long-form explanation of any code, or zigapagos explain-code with no argument to list every registered code with a one-line summary. That listing is the authoritative, always-current table of codes; it is deliberately not duplicated into this page, which would drift.
explain-code writes to stderr, not stdout — same stream as the NDJSON diagnostics above, and the same convention as zigapagos languages. So zigapagos explain-code 2>&1 | grep ZP_LINK works and a bare | grep sees nothing.
explain-code --format=json emits the same information as NDJSON on stderr: one {"code","summary","explanation"} object per line (one line for a single CODE, one per registered code for the no-argument listing) — the machine-readable form of this registry.
Coverage
The NDJSON stream covers the whole prerender/analysis gate: every diagnostic that can set build.any_prerendering_error in src/root.zig (static asset checks, i18n parsing, content parsing, frontmatter/page analysis, duplicate translation keys, URL collisions, template parsing/linting), plus the warnings printed in that same region (ZP_EMPTY_PAGE, and the warning-severity PageAnalysisError kinds). On top of that, every fatal.msg / fatal.usageError call anywhere in the codebase emits as ZP_FATAL.
SuperHTML page-render failures emit ZP_PAGE_RENDER, one record per failed render job. The full diagnostic and evaluation trace are escaped inside message; concurrent workers cannot interleave records. file identifies the content page, including its content-directory prefix. line and col are null: SuperHTML exposes a prose trace, not a structured source span, and its template coordinates must not be attributed to the content page. Read the template locations in message for human debugging. Alternative and paginated render jobs can report the same content file more than once. Text mode and the dev-server error overlay keep the original trace.
Island-pass failures also emit structured records:
| Code | Cause and attribution |
|---|---|
ZP_ISLAND_SSR | A sidecar render failed. The message preserves the component src, route, JavaScript message and full stack when supplied by the sidecar. A failure the sidecar never described — a desync, a malformed response, a subprocess that died mid-render — carries the Zig error name in place of the JavaScript message, and no stack. |
ZP_ISLAND_PROPS | A content-island scripty:props expression failed. The message identifies the component, expression and evaluation error. |
ZP_ISLAND_SIDECAR_MISSING | A page uses islands without a configured sidecar. The message describes the required configuration. validate/explain deliberately suppress this check. |
ZP_ISLAND_RENDER | Another island-pass failure, such as malformed markup or props, without a detailed report. The message carries the error name. |
For these records, file is the content page and line/col are null: component stack coordinates must not be attributed to the page. Component, route and expression details stay in message; the diagnostic wire schema is unchanged. No component is invented for errors that supply none. The whole record, including long multiline traces, is emitted under the stderr lock. Disk builds still fail; memory builds retain the existing placeholder policy. Text-mode messages are unchanged except for the two the promotion to a user-facing record made inaccurate: the missing-sidecar message now names both the <island> and <z-island> spellings that reach it, and a render failure the sidecar never described now carries the Zig error name rather than an empty message. Both changes apply to text mode too, so the two modes stay in parity. A disk island pass stops at its first error, so these records are not an exhaustive inventory of every broken island on a page.
What stays prose, and why:
| Source | Why |
|---|---|
| Direct Bun sidecar output | Subprocess stderr is inherited; a component’s own console output is not converted to diagnostic records. |
The island props-check gate (src/islands/props_check.zig) | Its output is tsc’s own, passed through verbatim. |
fatal.helpError()’s usage menu | No one-line NDJSON shape for a command reference — see above. |
| The two scan-time advisories | Advisory-only multi-line note: blocks; see the consumer rule above. |
Config-validation failures (bad host_url, bad deploy_target, …) do reach the stream, as ZP_FATAL rather than under named codes of their own.
zigapagos doctor has its own stable check-id namespace (abs-url-meta, dangling-internal-link, …) and does not emit this stream. It has its own --format=json: one NDJSON object per finding on stdout (doctor’s report stream), shaped {"check","severity","file","message"} with severity one of "error"/"warning", followed by exactly one summary object {"errors","warnings","files","skipped"} as the last line. check follows the same stability rule as code here: stable once shipped; message is prose and is not. Doctor fatals (a bad DIR) do emit on this page’s stderr stream, as ZP_FATAL.
Checking the deployed files
Run zigapagos doctor public --strict after a production build to catch missing local href and src targets in emitted HTML. Both /assets/app.js and ../assets/app.js are checked. Document-relative URLs resolve from the directory of the emitted HTML file: guides/index.html resolves ./image.png to guides/image.png, and ../style.css to style.css. Query strings and fragments are removed before checking; percent-encoded filenames such as my%20image.png are decoded.
For a site deployed under /project/, pass --url-prefix=project. Relative links start within that prefix. Normalization happens before the prefix is removed, so ../../style.css from /project/guides/ correctly warns that it points outside the deployment, even if style.css exists in the output directory. Paths climbing above the URL root are conservatively reported instead of clamped to the root as browsers do. Symlinks, including symlinked ancestor directories, do not count as files in the deployed tree.
Doctor checks files, directory index.html pages, and extensionless .html pages. This assumes the host serves those conventions; it does not verify host rewrites, redirects, or client-side routes. Missing targets remain warnings because an application route may legitimately be served by a fallback shell. --strict turns those warnings into a failing exit status.
External and scheme-relative URLs, data:, mailto:, tel:, and empty, fragment-only, or query-only references are not fetched. This check does not inspect CSS URLs, srcset, JavaScript imports, or fragment IDs. HTML character references in paths, backslashes, and percent-encoded slashes are reported as unsupported rather than resolved under a potentially different spelling. A document containing <base href> cannot currently be audited for local links: doctor reports the limitation, counts that document in skipped, and exits nonzero even without --strict. A target-only <base target> does not affect the audit.
SuperMD errors carry one code, ZP_SUPERMD, not one per error kind. SuperMD’s error tags (scripty, html, duplicate_id, …) come from the supermd package, a gitignored dependency synced at upstream release tags, so one code per tag would couple this stable code space to that upstream tag set. The tag is carried inside message as "[<tag>] <text>"; only the outer ZP_SUPERMD code is a stability guarantee.