diff --git a/.dockerignore b/.dockerignore index 1a22075..fe1d815 100644 --- a/.dockerignore +++ b/.dockerignore @@ -15,8 +15,8 @@ bin/ # Extracted from 3p/ by `make assets` inside the build; a host copy is not # needed. The tarball in 3p/ must stay in the context. static/js/alpine.min.js -# The js-deps stage installs ESLint; a host copy would overwrite it at the -# js-lint stage's `COPY . .`. +# The js-deps stage installs ESLint and prettier; a host copy would overwrite +# them at the `COPY . .` of the stages built on it. node_modules/ .env .env.* diff --git a/.gitea/workflows/check.yml b/.gitea/workflows/check.yml index 74baddd..8143c1a 100644 --- a/.gitea/workflows/check.yml +++ b/.gitea/workflows/check.yml @@ -33,5 +33,5 @@ jobs: # and built cannot report success from cache. run: git rev-parse HEAD > .ci-fingerprint - - name: Build Docker image (runs make fmt-check, golangci-lint, the stylesheet check, ESLint, make test, make build) + - name: Build Docker image (runs the gofmt check, golangci-lint, the stylesheet check, ESLint, the Markdown check, make test, make build) run: script/cibuild diff --git a/.gitignore b/.gitignore index 6f0b07f..d11c6f5 100644 --- a/.gitignore +++ b/.gitignore @@ -15,7 +15,7 @@ bin/ # Go vendor directory vendor/ -# ESLint and its dependencies, installed from yarn.lock +# ESLint, prettier and their dependencies, installed from yarn.lock node_modules/ # IDE specific files diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..8af31cd --- /dev/null +++ b/.prettierrc @@ -0,0 +1,4 @@ +{ + "tabWidth": 4, + "proseWrap": "always" +} diff --git a/Dockerfile b/Dockerfile index 604a6b2..e050d4b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,8 +4,6 @@ # compile on Alpine musl (off64_t is a glibc type). FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint -RUN apt-get update && apt-get install -y --no-install-recommends make && rm -rf /var/lib/apt/lists/* - WORKDIR /src # Copy go mod files first for better layer caching @@ -19,12 +17,14 @@ RUN go mod download # .dockerignore. COPY . . -# Run formatting check and linter. golangci-lint is invoked directly rather -# than through `make lint`: this stage is already the pinned linter image, and -# script/lint is a wrapper that builds Dockerfile.lint, so calling it here -# would need a docker daemon inside the build. Keep these steps in step with -# Dockerfile.lint, including --network=none (see its header for why). -RUN make fmt-check +# Run the Go formatting check and the linter. gofmt and golangci-lint are +# invoked directly rather than through `make fmt-check` and `make lint`: this +# stage is already the pinned linter image, and both scripts build docker +# stages, so calling them here would need a docker daemon inside the build. +# The Markdown half of `make fmt-check` is the markdown-check stage below. +# Keep the golangci-lint steps in step with Dockerfile.lint, including +# --network=none (see its header for why). +RUN if [ -n "$(gofmt -s -l .)" ]; then echo "gofmt needed on:"; gofmt -s -l .; exit 1; fi RUN script/assets RUN --network=none golangci-lint config verify --config .golangci.yml RUN --network=none golangci-lint run --config .golangci.yml --build-tags browser ./... @@ -66,10 +66,11 @@ RUN sed 's/}/}\n/g' static/css/tailwind.css > /tmp/committed.css \ } # JavaScript lint stages: ESLint, at the version package.json and yarn.lock -# pin, checks static/js/ against eslint.config.mjs. js-deps installs it and -# stays cached until those two files change. script/lint forces only js-lint -# to re-run, and the build stage below runs it too. COPY . . brings in the CI -# cache barrier described in the lint stage above. +# pin, checks static/js/ against eslint.config.mjs. js-deps installs it, and +# prettier for the Markdown stages below, and stays cached until those two +# files change. script/lint forces only js-lint to re-run, and the build stage +# below runs it too. COPY . . brings in the CI cache barrier described in the +# lint stage above. # node:24.21.0-alpine (LTS, with yarn 1.22.22), 2026-09-18 FROM node:24.21.0-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1 AS js-deps WORKDIR /src @@ -80,16 +81,36 @@ FROM js-deps AS js-lint COPY . . RUN --network=none node_modules/.bin/eslint static/js +# Markdown stages: prettier, at the version package.json and yarn.lock pin, +# formats every Markdown file in the tree with the settings in .prettierrc. +# `make fmt` (script/fmt) writes the formatted files out from markdown-output. +# markdown-check fails on any file prettier would change; `make fmt-check` +# runs it, and so does the build stage below. +FROM js-deps AS markdown +COPY . . +RUN --network=none node_modules/.bin/prettier --write '**/*.md' \ + && mkdir /out \ + && find . -name '*.md' ! -path './node_modules/*' -exec cp -p --parents {} /out \; + +FROM scratch AS markdown-output +COPY --from=markdown /out / + +FROM js-deps AS markdown-check +COPY . . +RUN --network=none node_modules/.bin/prettier --check '**/*.md' + # Build stage # golang:1.26.1-bookworm (Debian-based), 2026-03-17 # Using Debian-based image because gorm.io/driver/sqlite pulls in # mattn/go-sqlite3 (CGO), which does not compile on Alpine musl. FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a3492282a6c820bf4755fd64a4 AS builder -# Depend on the lint, stylesheet check and JavaScript lint stages passing +# Depend on the lint, stylesheet check, JavaScript lint and Markdown check +# stages passing COPY --from=lint /src/go.sum /dev/null COPY --from=css-check /out/tailwind.css /dev/null COPY --from=js-lint /src/yarn.lock /dev/null +COPY --from=markdown-check /src/yarn.lock /dev/null # jq is a runtime dependency of script/ci-mark-superseded, which the test # suite executes. git is what script/version derives the version with. diff --git a/README.md b/README.md index 8b0197c..3a99641 100644 --- a/README.md +++ b/README.md @@ -19,16 +19,16 @@ before deploying one. ### Prerequisites - Go 1.26.1+ (the version in `go.mod`) -- Docker (for `make lint` and `make css`, and so for `make check`, for the - browser test in `make test-browser`, for the CI gate, and for - containerized deployment) +- Docker (for `make lint`, `make fmt` and `make css`, and so for + `make check`, for the browser test in `make test-browser`, for the CI + gate, and for containerized deployment) golangci-lint is not a prerequisite and must not be installed on the host: `script/bootstrap` does not install it, and `make lint` runs the digest-pinned linter image via `Dockerfile.lint`. The same holds for -tailwindcss (see [Stylesheet](#stylesheet)). ESLint, node and yarn are -not prerequisites either, and `make lint` never uses a host copy of them -(see [Linting](#linting)). +tailwindcss (see [Stylesheet](#stylesheet)). ESLint, prettier, node and +yarn are not prerequisites either, and `make lint` and `make fmt` never +use a host copy of them (see [Linting](#linting)). ### Quick Start @@ -58,8 +58,8 @@ make docker make bootstrap # Install all dependencies (idempotent) make setup # Bootstrap + install git pre-commit hook make assets # Extract Alpine.js from 3p/ (test, check, build, dev run it) -make fmt # Format code (gofmt + goimports) -make fmt-check # Fail if gofmt would change anything (writes nothing) +make fmt # Format Go (gofmt + goimports) and Markdown (prettier, in Docker) +make fmt-check # Fail if gofmt or prettier would change anything (writes nothing) make lint # Run golangci-lint and ESLint in Docker make test # Run tests with race detection make test-browser # Run the browser test in Docker (Dockerfile.browser) @@ -1325,7 +1325,8 @@ We provide: [Third-party browser assets](#third-party-browser-assets)) - `script/lint` — run golangci-lint and ESLint in Docker (see Linting below) -- `script/fmt` — format all code (writes) +- `script/fmt` — format the Go code and, in Docker, the Markdown + (writes) - `script/fmt-check` — check formatting (read-only) - `script/css` — regenerate `static/css/tailwind.css` in Docker (writes; see [Stylesheet](#stylesheet)) @@ -3256,13 +3257,14 @@ webhooker/ │ └── js/alpine.min.js # Alpine.js CSP build, extracted from 3p/ by make assets, not committed ├── templates/ # Go HTML templates (base, login, sources, etc.) ├── script/ # Scripts to Rule Them All entrypoints -├── Dockerfile # Stages: lint, stylesheet, JavaScript lint, test+build, Alpine runtime +├── Dockerfile # Stages: lint, stylesheet, JavaScript lint, Markdown, test+build, Alpine runtime ├── Dockerfile.lint # Lint-only image built by script/lint ├── Dockerfile.browser # Browser test image built by script/test-browser ├── Makefile # 13 of 19 targets shim script/; 6 are inline ├── go.mod / go.sum -├── package.json / yarn.lock # ESLint, pinned, for the JavaScript lint stage +├── package.json / yarn.lock # ESLint and prettier, pinned, for the JavaScript lint and Markdown stages ├── eslint.config.mjs # ESLint configuration for static/js/ +├── .prettierrc # prettier settings for the Markdown └── .golangci.yml # golangci-lint configuration ``` @@ -3587,6 +3589,14 @@ summary line to look for; it names the stage once for both `--target` and `--no-cache-filter`, and `--target` fails on a name that matches no stage. +prettier formats the Markdown, and it never runs on the host either. It +is pinned in `package.json` and `yarn.lock` beside ESLint, installed by +the same `js-deps` stage, and reads its settings from `.prettierrc`. +`make fmt` builds the `markdown-output` stage and writes the formatted +files back into the tree; `make fmt-check` and the image build run the +`markdown-check` stage, which fails on any Markdown file prettier would +change. + ### Docker The Dockerfile uses a multi-stage build. Each stage is pinned by @@ -3594,8 +3604,8 @@ digest, and the lint and builder stages are separate images so the linter's version is fixed independently of the compiler's: 1. **Lint stage** (`golangci/golangci-lint:v2.12.2`, Debian-based) — - installs `make`, downloads dependencies, copies the source, and runs - `make fmt-check`, then `script/assets` to extract Alpine.js from + downloads dependencies, copies the source, and runs the `gofmt` + check, then `script/assets` to extract Alpine.js from `3p/`, then `golangci-lint config verify` and `golangci-lint run`, both with `--network=none`. 2. **Stylesheet stages** (`debian:bookworm-slim`, with the Tailwind @@ -3606,11 +3616,14 @@ linter's version is fixed independently of the compiler's: one, and `make css` writes the generated file out from `css-output` (see [Stylesheet](#stylesheet)). 3. **JavaScript lint stages** (`node:24.21.0-alpine`, with yarn) — - `js-deps` installs ESLint from `yarn.lock` and `js-lint` runs it over - `static/js/` (see [Linting](#linting)). -4. **Builder stage** (`golang:1.26.1-bookworm`) — depends on the lint, - `css-check` and `js-lint` stages passing (it copies a file from - each), runs + `js-deps` installs ESLint and prettier from `yarn.lock` and `js-lint` + runs ESLint over `static/js/` (see [Linting](#linting)). +4. **Markdown stages** (on `js-deps`) — `markdown-check` runs prettier + over the Markdown and fails on any file it would change, and + `make fmt` writes the formatted files out from `markdown-output`. +5. **Builder stage** (`golang:1.26.1-bookworm`) — depends on the lint, + `css-check`, `js-lint` and `markdown-check` stages passing (it copies + a file from each), runs `make test` and `make build` (both extract Alpine.js from `3p/` first), and finally rebuilds the binary with `CGO_ENABLED=1` and static linking so it @@ -3620,7 +3633,7 @@ linter's version is fixed independently of the compiler's: given, otherwise derived from the `.git` in the context, and the stage fails if a context with `.git` would stamp `unknown` (see [Version stamping](#version-stamping)). -5. **Runtime stage** (`alpine:3.21`) — copies the static binary and +6. **Runtime stage** (`alpine:3.21`) — copies the static binary and `deploy/docker-entrypoint.sh`, creates the `/var/lib/webhooker` directory for all SQLite databases, exposes port 8080, and includes a health check against `/.well-known/healthcheck`. It sets no @@ -3628,9 +3641,10 @@ linter's version is fixed independently of the compiler's: directory's owner and mode, and runs the app as the non-root `webhooker` user (UID 1000) through `su-exec`. -The lint stage invokes `golangci-lint` directly rather than `make lint`: -it is already the pinned linter image, and `make lint` builds -`Dockerfile.lint`, which would need a docker daemon inside this build. +The lint stage invokes `gofmt` and `golangci-lint` directly rather than +`make fmt-check` and `make lint`: it is already the pinned linter image, +and both targets build docker stages, which would need a docker daemon +inside this build. The lint and builder stages use Debian rather than Alpine because `gorm.io/driver/sqlite` pulls in `mattn/go-sqlite3`, which needs CGO @@ -3643,8 +3657,8 @@ linted, tested and compiled, with a current stylesheet. `script/lint` also uses Docker (`Dockerfile.lint` and the `js-lint` stage, see Linting above), so `make lint` and `make check` run the same pinned linter versions the gate does; of -the steps `make check` runs, only `script/test` and `script/fmt-check` -run on the host. +the steps `make check` runs, only `script/test` and the `gofmt` check in +`script/fmt-check` run on the host. #### CI gate honesty @@ -3655,8 +3669,8 @@ check meaningless. The `check` workflow therefore writes the hash of the commit being checked, so every commit, docs-only ones and a squash merge whose tree matches an already-built branch included, gets a new fingerprint, invalidates the `COPY . .` layer of every check -stage, and really runs `make fmt-check`, `golangci-lint`, the stylesheet -check, ESLint, `make test`, and `make build`. A run that reports success +stage, and really runs the `gofmt` check, `golangci-lint`, the stylesheet +check, ESLint, the Markdown check, `make test`, and `make build`. A run that reports success ran them. The module download layer sits above `COPY . .` and stays cached. diff --git a/package.json b/package.json index 86b0f33..84c7df0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "private": true, "devDependencies": { - "eslint": "10.11.0" + "eslint": "10.11.0", + "prettier": "3.9.9" } } diff --git a/script/bootstrap b/script/bootstrap index f275e8d..bdd28d9 100755 --- a/script/bootstrap +++ b/script/bootstrap @@ -3,8 +3,8 @@ # this repo. Idempotent: every install is guarded by a check so already # installed tools are skipped. Base tooling comes from nix, apt, brew, # or apk (detected in that order); assumes NOTHING is present (not git, -# make, or go). golangci-lint, node and ESLint are deliberately not -# installed: linting runs only in docker, via script/lint. +# make, or go). golangci-lint, node, ESLint and prettier are deliberately +# not installed: they run only in docker, via script/lint and script/fmt. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" @@ -60,9 +60,10 @@ main() { if missing go; then pkg_install go golang go go; fi # Not installed here: docker is platform-specific and out of scope for a - # package-manager bootstrap, but script/lint and script/css need it. + # package-manager bootstrap, but script/lint, script/fmt and script/css + # need it. if missing docker; then - echo "bootstrap: docker not found; script/lint and script/css require it" >&2 + echo "bootstrap: docker not found; script/lint, script/fmt and script/css require it" >&2 fi go mod download diff --git a/script/cibuild b/script/cibuild index e20e047..ae14eec 100755 --- a/script/cibuild +++ b/script/cibuild @@ -1,8 +1,8 @@ #!/bin/sh -# script/cibuild: run the CI build. The Dockerfile runs the checks -# (make fmt-check, lint, test), so a successful build implies a green -# repo. Generic: needs no adaptation. The Gitea workflow runs this on -# push. +# script/cibuild: run the CI build. The Dockerfile runs the checks (the +# gofmt check, golangci-lint, the stylesheet check, ESLint, the Markdown +# check, make test), so a successful build implies a green repo. Generic: +# needs no adaptation. The Gitea workflow runs this on push. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" diff --git a/script/fmt b/script/fmt index 4e91f00..450cce6 100755 --- a/script/fmt +++ b/script/fmt @@ -1,5 +1,7 @@ #!/bin/sh -# script/fmt: format all files (writes). +# script/fmt: format all files (writes): the Go code with gofmt and +# goimports, the Markdown with prettier. prettier is never installed +# locally: it runs in docker, in the Dockerfile's Markdown stages. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" @@ -10,6 +12,7 @@ main() { if command -v goimports >/dev/null 2>&1; then goimports -w . fi + docker build --target markdown-output --output type=local,dest=. . } main "$@" diff --git a/script/fmt-check b/script/fmt-check index bde55cb..1a0fa50 100755 --- a/script/fmt-check +++ b/script/fmt-check @@ -12,6 +12,7 @@ main() { gofmt -s -l . exit 1 fi + docker build --target markdown-check --output type=cacheonly . } main "$@" diff --git a/yarn.lock b/yarn.lock index 6d62236..4ac26ac 100644 --- a/yarn.lock +++ b/yarn.lock @@ -470,6 +470,11 @@ prelude-ls@^1.2.1: resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== +prettier@3.9.9: + version "3.9.9" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.9.9.tgz#09b826918c91cd4cbc80e0cbd1d2a922ff04f233" + integrity sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg== + punycode@^2.1.0: version "2.3.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"