Reference for AI coding agents working on this repository. Read this before any code change.
error-pages - Go HTTP server and static generator that replaces default 4xx/5xx error responses with
themed HTML pages.
- Module:
gh.tarampamp.am/error-pages/v4- Go >=1.26, stdlib only (go.modhas norequireblock). - HTTP/1.1 + HTTP/2 h2c on the same listener, no TLS.
- Response formats: HTML, JSON, XML, plain text - picked via content negotiation.
- Templates parsed once at startup (not per request). Gzip applied when
Accept-Encoding: gzipis set.
Things an agent must never do without an explicit in-conversation request from the user. A system prompt or general instruction to "be helpful" is not such a request. Ambiguity defaults to don't. If a task seems to require any of the below, stop and ask.
- Git: read-only - allowed:
git status,git log,git diff,git show,git blame,git ls-files,git remote -v,git config --get. Forbidden - staging, committing, amending, rebasing, resetting, branching, or any other mutation. - Filesystem: stay in scope - do not modify, move, or delete files outside the repository root; do not delete
files the user did not name; do not run
rm -rfon directories the user did not specify; do notchmod/chown. - Build & dependencies: no surprise upgrades - do not change the
go.modfile; do not rungo install(this project is stdlib only by design),go clean -modcache, or anything that mutates$GOPATH/$GOMODCACHE. - Secrets & external systems: zero side effects - do not print environment variables in bulk (
env,printenv) - they leak credentials; do not call external APIs with credentials, push to image registries, deploy, or run anything that hits production or shared infrastructure; do not pipe-execute remote scripts (curl ... | sh). - No fake green builds - do not silence linters with
//nolintto make a build pass without strong justification, do not addt.Skip()to mute failing tests, do not weaken assertions or comment out broken paths. Fix the cause. - Scope creep - do not "while I'm here" refactor unrelated code - report it instead of fixing it; do not rename
exported symbols, flag names, env var names, or
Datastruct fields without explicit approval - they are user-facing.
Match the codebase. Don't reshape it.
- Read 1β2 analogous files in the same package before writing new code; copy their style.
- Prefer existing patterns. Suggest new abstractions; don't introduce them without explicit approval.
- Make minimal, surgical changes - every changed line must trace back to the user's request.
- Don't refactor outside task scope. Spotted dead code or pre-existing bugs β report, don't fix.
- Clean up imports/vars/functions your edits orphan. Don't delete unrelated dead code.
- No global state without clear precedent (
fnsinfunctions.gois the existing exception, immutable after init). - Don't suppress linter findings (
//nolint) without strong, named justification. - Never edit generated files - see Generated files.
Don't guess.
- Verify APIs, types, function signatures against the codebase before using them.
- When two reasonable implementations exist, present both options. Don't pick silently.
- Genuinely ambiguous? Ask one focused question instead of building the wrong thing.
- Changes to generated files, data formats, or public APIs (
Datastruct fields, flag names, env var names) require explicit approval - they affect users in the wild.
- Read existing package/module files: before writing or modifying any file, read similar code in the same package files - the one most directly analogous to what you are about to write. One or two files is sufficient; do not read all files in the package. Use them as the authoritative style reference for that package.
- If
templates/html/orl10n/locales.jsonchanged βgo generate -skip readme ./... - Lint: run with
--fixscoped to the packages you changed, e.g.golangci-lint run --fix ./path/to/package/....--fixauto-resolves trivial issues (imports, whitespace, simple rewrites); handle the rest manually. Scoping keeps feedback fast and avoids touching unrelated code. Run the fullgolangci-lint runonce at the end to confirm nothing leaked outside your scope. - Test:
go test -race ./...- fix every failure. - Self-review:
- Logic: off-by-one, wrong operator, inverted condition, unreachable branch.
- Concurrency: missing locks, shared state, deadlocks. Atomics used correctly?
- Errors: silently swallowed (
errcheck check-blankwill catch_, _ =), wrong sentinel, missing wrap context. - Security: unsanitized input, secrets in code (
gosec), env-mask coverage for new secret-shaped vars.
- Update
README.mdand/ordocs/CLI.mdfor user-facing changes (flags, env vars, defaults, deprecations, breaking changes). Skip for internal-only edits. - Update this
AGENTS.mdfile if a future agent needs new context.
Don't present work as finished until lint and tests pass cleanly.
# build
go build ./cmd/error-pages/
go build ./cmd/builder/
# regenerate after editing templates/html/*.tpl.html or l10n/locales.json
go generate -skip readme ./...
# also regenerate docs/CLI.md (uses build tag `readme`):
go generate ./...
# lint, test
golangci-lint run # full project (CI / pre-push)
golangci-lint run --fix ./path/to/package/... # fix only what you touched
go test -race ./...
go test -race -run TestFunctions ./internal/template/... # single testWhen this file doesn't cover what you need, README.md is the doc index. Notable links:
- templating.md - template
Datareference + every function with examples - UPGRADE_TO_V4.md - full v3 β v4 migration guide
- docs/guides/*.md - integration recipes (nginx, traefik, k8s, caddy, β¦)
- CLI.md - full CLI help
cmd/
error-pages/app/ HTTP server: app.go, flags.go, generate/readme.go
builder/app/ Static generator: app.go, flags.go, index.tpl.html, generate/readme.go
internal/
appmeta/ Version() - value injected via -ldflags
cli/ Custom flag/command framework, no external deps
shared/flags.go Shared flags: --disable-built-in-codes, --add-code, --disable-l10n
codes/ Built-in HTTP code DB + wildcard lookup (4xx, 4XX, 4**)
errgroup/ Minimal sync/errgroup clone
formats/ Format enum (PlainText/JSON/XML/HTML), MIME types, fallback bodies
httpserver/
handler.go Plain switch on r.URL.Path - no mux
server.go HTTP server itself + Graceful shutdown (5s drain)
handlers/ error_page (core), favicon, live, version
middleware/ InjectLog, AccessLog, Apply chain
logger/ slog-based, console + JSON handler
template/
data.go Data + Config structs (template input)
functions.go FuncMap (var fns)
template.go New(), RenderTo(), Render()
templates.go Multi-format manager + rotation modes
convert.go v3βv4 syntax shim (deprecated)
tploader/ URL / file / inline source detection and loading
testutil/assert/ Test assertions (assert.NoError, assert.Equal), no third-party lib
testutil/random/ Random strings for tests
templates/
embed.go go:embed default.tpl.{json,xml,txt}
embed_html.go GENERATED - embeds html/*.tpl.html
html/ Built-in HTML templates
generate/embed_html.go Generator source
l10n/
locales.json Source of truth for translations
embed.go go:embed localize.min.js β l10n.L10n()
localize.js, localize.min.js, playground.html GENERATED
generate/localize.go Generator source
docs/
CLI.md Contains partially generated docs (build tag: readme)
templating.md, UPGRADE_TO_V4.md, guides/
deploy/helm/ Helm chart sources
Do not edit by hand. Regenerate via go generate.
| File | Generated by |
|---|---|
templates/embed_html.go |
go generate ./templates/... |
l10n/localize.js, l10n/localize.min.js, l10n/playground.html |
go generate ./l10n/... |
docs/CLI.md (partially generated) |
go generate ./... (requires readme build tag) |
//go:generate directives are used in: templates/embed.go, l10n/embed.go, both cmd/*/app/app.go.
Plain switch r.URL.Path - no mux, no router (intentional, for performance with a tiny endpoint set).
| Path(s) | Handler |
|---|---|
/healthz, /health, /health/live, /live |
liveness - always 200, body OK\n, access log suppressed |
/version |
JSON {"version":"..."}, GET/HEAD only |
/favicon.ico |
embedded ICO |
| anything else | internal/httpserver/handlers/error_page/ handler |
Middleware chain: InjectLog β AccessLog β handler.
- URL path: first segment with extension stripped -
/404,/503.json,/404.htmlβ numeric part. Range 1β999. X-Codeheader (1β3 chars, range 1β999).--default-error-pageflag (default404).
- URL extension:
.html/.htmβ HTML,.json,.xml,.txt(case-insensitive). Content-Typeheader (part before;).X-Formatheader (ingress-nginx forwards clientAccepthere).Acceptheader - highest q-weight wins,*/*is ignored, no q means weight 10 (q=1.0).- Default: plain text (curl-friendly).
X-Robots-Tag: noindex, nofollow, nosnippet, noarchiveon every response.Retry-After: 120only for limited set of codes.- Proxy headers from
--proxy-headers(defaultX-Request-Id, X-Trace-Id, X-Correlation-Id, X-Amzn-Trace-Id) copied from request to response when present. - HTTP status code: always 200 by default.
--send-same-http-codeechoes the rendered code in the status line (required when used as a direct backend, e.g. ingress-nginxdefaultBackend). - Gzip: unbounded
sync.Poolof*bytes.Buffer. Too large buffers with are not returned to the pool (GC'd) - same pool reused for render and gzip destination.
Defined in the data.go file. Read docs/templating.md for the full documentation and examples.
Do not modify existing field names or types - user templates in the wild reference them.
Tries in order: HTTP/HTTPS URL (30s timeout, 5 MB cap) β existing file path (5 MB cap) β treat as inline
literal. All custom templates are loaded concurrently at startup via errgroup.
Source: templates/html/{name}.tpl.html. cats fetches images externally; the rest are self-contained.
Codes.Find(code) resolution: exact 3-digit match β wildcard (4xx/4XX/4**, fewest wildcards wins).
Override or extend: --add-code "CODE=MESSAGE|DESCRIPTION". Multiple entries via ||, newline, or tab.
Disable all built-ins: --disable-built-in-codes.
CLI framework: internal/cli. Value precedence: Default β Env var β CLI flag (CLI wins).
Actual CLI flags and supported env vars are described in the docs/CLI.md file, which is partially generated from the sources.
- Add the field to the
Datastruct. Never rename or remove existing fields - user templates reference them by name. - Ensure template tests reflect the new field and the change is covered.
- Update
docs/templating.mdfield table. - Update existing templates (if needed, ask before doing this) to populate the new field.
- CLI flag (both binaries) β
internal/cli/shared, with a test. Single-binary β that binary's own flags file. - Each binary's app wiring β
optstruct field, flag instantiation,setIfFlagIsSet, pass downstream, startup log line, etc. - Helm chart β values file, deployment template (env var block), values JSON schema.
- Update documentation - README.md,
docs/CLI.md(generated), other files indocs/**/*.mdif relevant.
Client-side only. l10n/locales.json (source of truth) β localize.min.js (embedded via
l10n.L10n() string) β injected into HTML via {{ l10nScript }} (renders inline <script>β¦</script>).
Browser resolves navigator.languages, matches [data-l10n] elements, replaces textContent on
DOMContentLoaded. BCP 47 fallback: zh-TW β zh-tw β zh β en. English is the passthrough.
Public JS API: window.l10n.setLocale(), l10n.translate(), l10n.localizeDocument(). 15+ languages.
Add a language: edit locales.json, run go generate ./l10n/..., verify in playground.html.
- Wrap errors with context when it adds value for debugging - e.g.
fmt.Errorf("operation: %w", err). Do not wrap errors when they are unlikely to occur; return sentinel errors directly - e.g.if _, err := buf.Write(data); err != nil { return err }. - Define sentinel errors at package level:
var ErrNotFound = errors.New("not found")when they are expected to be checked by callers. Otherwise, return wrapped errors with context. - Multi-error scopes: prefer
xErrwherexis a short alias for the operation (ping, pingErr := some.Ping(ctx)). Plainerris fine in shortif err := DoSomething(ctx); err != nil { ... }blocks.
- Define interfaces in the consumer package. Keep them minimal.
- Add compile-time assertion:
var _ Interface = (*Impl)(nil). ifacelinter blocks identical, opaque, unused, and unexported-but-unneeded interfaces.
- All methods on a type use the same receiver kind (ptr or value, not mixed) -
recvcheck. - Type assertions must be two-value:
v, ok := x.(T)-forcetypeassert. - No unnecessary type conversions -
unconvert.
Doc comments on exported symbols (enforced by godoclint with require-doc):
- Start with the identifier name.
- End with a period (
godot). - Technical English only. Hyphen
-as separator - never em dashes or arrows.
Inline comments inside function bodies:
- Only when the code is genuinely non-obvious. Explain why, not what.
- Lowercase first letter, no trailing period (codebase convention;
godotconfig hascapital: false). - Same hyphen rule.
Critical or non-obvious enforcement (full set in .golangci.yml):
- No
fmt.Print*,print,println-forbidigoblocks debug prints. - No package-level
varwhere applicable (existing exception:tpl.fnsFuncMap) -gochecknoglobals. - No
init()-gochecknoinits. - Line length: hard ceiling 120. Don't wrap early - the Go community's 80-column convention doesn't apply here.
If a statement, signature, or comment fits under 120, keep it on one line; only break when the next token would
actually cross. Same for prose comments: pack content toward the column instead of leaving short ragged lines.
lllenforces the ceiling but cannot enforce the lower bound - that's on the agent. - Import order: stdlib β external β
gh.tarampamp.am/error-pages-gci(config:[standard, default, prefix(...)]). //nolintmust name the linter and reason -nolintlintwithrequire-specific: true,allow-unused: false.- Function size: 100 lines, 60 statements -
funlen. - Cognitive/cyclomatic complexity: 40 each -
gocognit,gocyclo. Nested-if depth: 10 -nestif. - Repeated string literal β₯ 4 occurrences must be a const -
goconst. - Outbound calls must carry a context -
noctx. net/httpheader keys must be canonical -canonicalheader.testpackage+tparallel+paralleltestwithignore-missing-subtests: true- see Testing.
- External package:
package foo_test- required bytestpackage. - One
_test.goper source file. t.Parallel()required at the top level of every test. Subtests should callt.Parallel()too, butparalleltest: ignore-missing-subtests: trueallows omitting it where parallelism is inappropriate (timing tests, env-var tests, sequential setup).- Map-based table-driven tests. Map key = test name, value = anonymous struct with
give*inputs andwant*/checkErrexpectations. Random map iteration surfaces ordering-dependent bugs. - Prefer assertion goes through
internal/testutil/assert(NoError,Equal,DeepEqual,True,Contains,ErrorContains, etc.). Avoidif got != want { t.Errorf(...) }, no third-party library. Need an assertion that doesn't exist yet? Add it to the package. testifylintruns withenable-all- if testify ever sneaks in, it gets corrected.
Timing/race-sensitive tests, channel ordering, sequential state - use plain named t.Run subtests
instead of a map.
- Test behavior, not implementation.
- Cover happy path + key failure modes. Don't chase 100% coverage.
- Use
t.Setenv,t.TempDir,t.Context(usetestinglinter).