stylegen: generate html/style.json from a compact html/style.yaml #168

Merged
art merged 8 commits from style-generator into main 2026-08-19 22:42:20 +00:00
Owner

Make html/style.json a generated artifact (from a compact html/style.yaml)

html/style.json becomes a generated artifact, gitignored and produced by make build /
make style. The source of truth is html/style.yaml, in two parts:

  • a roads table — one row per class, columns minzoom / colours / zoom-keyed width ramps —
    which stylegen/ expands into the 36 repetitive road line layers. The invariants that used to
    live as 36 near-identical hand-maintained JSON blocks (underpass has no casing; within a level
    casings → service → fills → unpaved; short id prefixes; unpaved dasharray) are encoded once in
    stylegen/roads.go.
  • the hand-authored base — landcover, water, buildings, labels, symbols, background — as
    plain YAML layer entries, spliced around a roads_marker.

Editing a road class is now a few lines in the table, or a yq one-liner, instead of paging
through a 1300-line JSON file.

The generator refuses what it does not understand

Everything the first version silently dropped is now an error, because a silent drop in a style
generator is a map that renders and is wrong:

before now
a level without opacity: → line-opacity: 0, every road at that level invisible absent means opaque (*float64)
levels: {bridge: …} → dropped, no layers unknown level "bridge"
a class defined but missing from class_order → never drawn missing from class_order (it would never draw)
an unknown top-level or class key → dropped strict decoding names the key
no roads: section → nil-pointer panic an error
a base layer sharing a generated id → MapLibre keeps the last duplicate layer id

This is what makes yq safe on style.yaml: aim a write at a path that does not exist and the
build stops rather than deleting a layer.

The table is now a table

Width ramps are flow maps keyed by zoom — fill_widths: {12: 0.7, 13: 1.2, …} — instead of four
lines per stop. The roads block goes from 248 lines to 69, one stop is addressable
(yq -i '.roads.classes.minor.fill_widths."17" = 7.0'), and style.yaml is normalised to yq's
own output format so that edit is a two-line diff rather than a reformat of the whole file.
docs/style.md carries the idioms.

Safety

  • Migration equivalence (the one-time question): the generated style is identical to the
    pre-generator html/style.json — 90 layers, same order, zero differing layers — re-verified
    after the rewrite and after the table compaction.
  • What is asserted going forward are the invariants, not a frozen copy: draw order within a
    level, no underpass casing, the base layers preserved in order with the roads spliced at the
    marker, no layer left transparent, unique ids, the unescaped < / >= operators, and that
    unknown keys are rejected. Each was verified to fail on a mutation introducing the fault it
    names, and — unlike the golden fixture this replaces — all of them survive a deliberate colour
    or width change. A frozen copy of the output could only fail: it failed on every real edit as
    loudly as on a regression, named neither the layer nor the remedy, and the only way past it was
    to overwrite it, after which it asserted nothing but determinism.
  • make test works on a fresh checkout. It validated html/style.json without depending on
    anything that generates it, so on a clean clone the file did not exist and make test failed.
    style is split into style-gen (pure Go) and style-validate (npm); make test depends on
    style-gen, which also keeps the unit tests offline.
  • The MapLibre style-spec version is pinned. @latest inside make build means someone
    else's release can fail a build that changed nothing.
  • make build, make test, ./scripts/style-validate.sh all pass, from a clean tree.

Also

docs/superpowers/ is removed — plan artifacts from a planning tool, not project documentation.
What was durable in them is in the docs AGENTS.md actually indexes.

Merges cleanly with #169; the two were built and tested merged together.

# Make html/style.json a generated artifact (from a compact html/style.yaml) `html/style.json` becomes a **generated artifact**, gitignored and produced by `make build` / `make style`. The source of truth is `html/style.yaml`, in two parts: - a **roads table** — one row per class, columns `minzoom` / colours / zoom-keyed width ramps — which `stylegen/` expands into the 36 repetitive road line layers. The invariants that used to live as 36 near-identical hand-maintained JSON blocks (underpass has no casing; within a level casings → service → fills → unpaved; short id prefixes; unpaved dasharray) are encoded once in `stylegen/roads.go`. - the **hand-authored base** — landcover, water, buildings, labels, symbols, background — as plain YAML layer entries, spliced around a `roads_marker`. Editing a road class is now a few lines in the table, or a `yq` one-liner, instead of paging through a 1300-line JSON file. ## The generator refuses what it does not understand Everything the first version silently dropped is now an error, because a silent drop in a style generator is a map that renders and is wrong: | before | now | |---|---| | a level without `opacity:` → `line-opacity: 0`, every road at that level invisible | absent means opaque (`*float64`) | | `levels: {bridge: …}` → dropped, no layers | `unknown level "bridge"` | | a class defined but missing from `class_order` → never drawn | `missing from class_order (it would never draw)` | | an unknown top-level or class key → dropped | strict decoding names the key | | no `roads:` section → nil-pointer panic | an error | | a base layer sharing a generated id → MapLibre keeps the last | `duplicate layer id` | This is what makes `yq` safe on `style.yaml`: aim a write at a path that does not exist and the build stops rather than deleting a layer. ## The table is now a table Width ramps are flow maps keyed by zoom — `fill_widths: {12: 0.7, 13: 1.2, …}` — instead of four lines per stop. The roads block goes from **248 lines to 69**, one stop is addressable (`yq -i '.roads.classes.minor.fill_widths."17" = 7.0'`), and `style.yaml` is normalised to yq's own output format so that edit is a **two-line diff** rather than a reformat of the whole file. `docs/style.md` carries the idioms. ## Safety - **Migration equivalence** (the one-time question): the generated style is identical to the pre-generator `html/style.json` — 90 layers, same order, zero differing layers — re-verified after the rewrite and after the table compaction. - **What is asserted going forward** are the invariants, not a frozen copy: draw order within a level, no underpass casing, the base layers preserved in order with the roads spliced at the marker, no layer left transparent, unique ids, the unescaped `<` / `>=` operators, and that unknown keys are rejected. Each was verified to fail on a mutation introducing the fault it names, and — unlike the golden fixture this replaces — all of them survive a deliberate colour or width change. A frozen copy of the output could only fail: it failed on every real edit as loudly as on a regression, named neither the layer nor the remedy, and the only way past it was to overwrite it, after which it asserted nothing but determinism. - **`make test` works on a fresh checkout.** It validated `html/style.json` without depending on anything that generates it, so on a clean clone the file did not exist and `make test` failed. `style` is split into `style-gen` (pure Go) and `style-validate` (npm); `make test` depends on `style-gen`, which also keeps the unit tests offline. - The MapLibre style-spec version is **pinned**. `@latest` inside `make build` means someone else's release can fail a build that changed nothing. - `make build`, `make test`, `./scripts/style-validate.sh` all pass, from a clean tree. ## Also `docs/superpowers/` is removed — plan artifacts from a planning tool, not project documentation. What was durable in them is in the docs AGENTS.md actually indexes. Merges cleanly with #169; the two were built and tested merged together.
The first half of the sliceable-configs design: make html/style.json a generated
artifact. A Go stylegen expands a compact roads table (class x level x casing/fill,
plus per-level unpaved overlays) into the 38 repetitive road layers, spliced into a
hand-authored base, and emits style.json. Golden-tested byte-for-byte against the
current style.json so the migration is lossless; artifact is gitignored, make build
regenerates it, make test drift-checks it. The config-side locality (directory loader
+ extends + canonical enums) is a separate, follow-on plan.
html/style.yaml becomes the source of truth: the 54 non-road layers pass
through verbatim, the 36 road layers are collapsed into a `roads:` table
(levels/classes/service/unpaved/class_order) with a `roads_marker` insertion
point. Generate() parses the YAML, splices roads.expand() output into the
layers, and marshals clean 4-space JSON. TestGenerateReproducesCurrentStyle
deep-equals the generated output against testdata/style.json after both are
parsed as JSON.

roads.go expanded to reproduce the fixture faithfully: per-level ordering is
casings, service, fills, unpaved (Task 2 emitted interleaved casing+fill);
level opacity now applies to service and unpaved (underpass 0.75, normal/over
1.0) instead of a hardcoded 0.75 on unpaved; dasharray is []float64 because
the real style uses 2.5. main.go carries through `name` (fixture has it).
roads_test.go updated to the corrected ordering.
The style generator now runs as part of `make build` (built in stylegen/,
run from the repo root so it finds html/style.yaml), and its golden test runs
in `make test` as the drift check. html/style.json is gitignored and untracked;
the committed fixture stylegen/testdata/style.json stays the frozen reference.
Go's json.MarshalIndent HTML-escapes <, >, & by default, so the < in the
station_dots filter and >= in waterway_labels serialized as \u003c / \u003e=.
MapLibre's gl-style-validate reads expression operators without decoding JSON
string escapes and reported "Unknown expression \u003c", exiting 1. The
structural golden test cannot see this (both forms decode to "<" after
parsing), so wire scripts/style-validate.sh into the style: target (run by
build:) and into test: as the guard for this regression.
art-bot force-pushed style-generator from e5161fe5ff to 011c7b72a8 2026-08-19 22:22:03 +00:00 Compare
art merged commit 011c7b72a8 into main 2026-08-19 22:42:20 +00:00
art deleted branch style-generator 2026-08-19 22:42:20 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
art/ismap!168
No description provided.