@piercebarney/whs-eleventy 2026.9.2 → 2026.9.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/_project.js +15 -1
- package/lib/compliance.js +15 -5
- package/lib/content-check.js +76 -24
- package/lib/doctor.js +21 -1
- package/package.json +1 -1
- package/standard/CHANGELOG.md +468 -0
- package/standard/core.md +52 -17
package/lib/_project.js
CHANGED
|
@@ -22,6 +22,20 @@ function tryProjectRequire(rel, fallback = null) {
|
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
// The content-arm -> build-arm escalation drawer (core.md#content-model): one
|
|
26
|
+
// `YYYY-MM-DD-<slug>.md` per blocked structural request, triaged and deleted by
|
|
27
|
+
// the build arm. Returns the open notes' filenames (README.md excluded), sorted.
|
|
28
|
+
function openRequests(root = ROOT) {
|
|
29
|
+
try {
|
|
30
|
+
return fs
|
|
31
|
+
.readdirSync(path.join(root, "requests"))
|
|
32
|
+
.filter((f) => f.endsWith(".md") && f.toLowerCase() !== "readme.md")
|
|
33
|
+
.sort();
|
|
34
|
+
} catch {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
25
39
|
// Locate the standard's text (core.md, CHANGELOG.md) for the drift check:
|
|
26
40
|
// 1. WHS_STANDARD env var (the authoring repo points this at itself)
|
|
27
41
|
// 2. the copy bundled into this package at publish time (standard/)
|
|
@@ -39,4 +53,4 @@ function resolveStandard() {
|
|
|
39
53
|
return null;
|
|
40
54
|
}
|
|
41
55
|
|
|
42
|
-
module.exports = { ROOT, projectRequire, tryProjectRequire, resolveStandard };
|
|
56
|
+
module.exports = { ROOT, projectRequire, tryProjectRequire, openRequests, resolveStandard };
|
package/lib/compliance.js
CHANGED
|
@@ -152,7 +152,9 @@ const CHECKS = {
|
|
|
152
152
|
}
|
|
153
153
|
const protocol =
|
|
154
154
|
has("CONTENT.md") && /"content-check"/.test(read("package.json") || "")
|
|
155
|
-
? "
|
|
155
|
+
? has("requests")
|
|
156
|
+
? "agent content-ops protocol present (CONTENT.md, content-check, requests/)"
|
|
157
|
+
: "CONTENT.md + content-check present, but no requests/ escalation drawer"
|
|
156
158
|
: "no CONTENT.md / content-check — add it if content is agent-edited";
|
|
157
159
|
return {
|
|
158
160
|
status: MANUAL,
|
|
@@ -412,14 +414,22 @@ const CHECKS = {
|
|
|
412
414
|
},
|
|
413
415
|
|
|
414
416
|
privacy: () => {
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
417
|
+
// The privacy page may be a standalone src/privacy.njk or a pages.json
|
|
418
|
+
// entry rendered by a src/pages/ shell — check the built output, with a
|
|
419
|
+
// source fallback for when _site isn't built yet.
|
|
420
|
+
const built = has("_site/privacy/index.html");
|
|
421
|
+
const source =
|
|
422
|
+
has("src/privacy.njk") ||
|
|
423
|
+
/["']slug["']\s*:\s*["']privacy["']/.test(read("src/content/pages.json") || "");
|
|
424
|
+
if (!built && !source) return { status: FAIL, note: "no privacy page" };
|
|
425
|
+
// The third-party disclosure must be generated from _data/thirdparties.js,
|
|
426
|
+
// never hand-maintained — the loop lives in the privacy shell or an include.
|
|
427
|
+
if (grepSrc(/for\s+t\s+in\s+thirdparties/).length === 0)
|
|
418
428
|
return {
|
|
419
429
|
status: FAIL,
|
|
420
430
|
note: "privacy third-party list not rendered from _data/thirdparties.js",
|
|
421
431
|
};
|
|
422
|
-
return { status: PASS, note: "third-party list
|
|
432
|
+
return { status: PASS, note: "privacy page present; third-party list from the manifest" };
|
|
423
433
|
},
|
|
424
434
|
|
|
425
435
|
ads: () => {
|
package/lib/content-check.js
CHANGED
|
@@ -4,42 +4,94 @@
|
|
|
4
4
|
//
|
|
5
5
|
// whs content-check
|
|
6
6
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
7
|
+
// A **staged** file outside the reserved content set is an error — a `content:`
|
|
8
|
+
// commit can't carry a code/structure change. An unstaged stray only warns (it
|
|
9
|
+
// won't be in the commit unless staged). Also reports the open requests/ notes
|
|
10
|
+
// (the content-arm → build-arm escalation channel, core.md#content-model).
|
|
9
11
|
|
|
10
12
|
const { execSync } = require("node:child_process");
|
|
13
|
+
const { openRequests } = require("./_project.js");
|
|
11
14
|
|
|
12
|
-
// Files an agent following CONTENT.md
|
|
13
|
-
|
|
15
|
+
// Files an agent following CONTENT.md may touch — the reserved content set plus
|
|
16
|
+
// its own escalation drawer. Everything else (the *.schema.json editor aids,
|
|
17
|
+
// schema.js, the page shells, the includes, config) is a build-arm change.
|
|
18
|
+
const RESERVED_SET = [
|
|
19
|
+
/^src\/content\/(?!.*\.schema\.json$)[^/]+\.json$/,
|
|
20
|
+
/^src\/_data\/nav\.js$/,
|
|
21
|
+
/^src\/_data\/glossary\.js$/,
|
|
22
|
+
/^requests\//,
|
|
23
|
+
];
|
|
14
24
|
|
|
15
|
-
|
|
25
|
+
// One `git status --porcelain` line → { path, staged }. `staged` is true when
|
|
26
|
+
// the index column (X) holds a real status letter — i.e. the change is part of
|
|
27
|
+
// the next commit. Untracked ("??") and worktree-only (" M") are not staged.
|
|
28
|
+
function parsePorcelain(out) {
|
|
29
|
+
return out
|
|
30
|
+
.split("\n")
|
|
31
|
+
.filter(Boolean)
|
|
32
|
+
.map((l) => {
|
|
33
|
+
const x = l[0];
|
|
34
|
+
const rest = l.slice(3);
|
|
35
|
+
const path = rest.includes(" -> ") ? rest.split(" -> ")[1] : rest;
|
|
36
|
+
return { path: path.trim(), staged: x !== " " && x !== "?" };
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function changedEntries() {
|
|
16
41
|
try {
|
|
17
|
-
|
|
18
|
-
return out
|
|
19
|
-
.split("\n")
|
|
20
|
-
.map((l) => l.slice(3).trim())
|
|
21
|
-
.filter(Boolean)
|
|
22
|
-
.map((f) => (f.includes(" -> ") ? f.split(" -> ")[1] : f));
|
|
42
|
+
return parsePorcelain(execSync("git status --porcelain", { encoding: "utf8" }));
|
|
23
43
|
} catch {
|
|
24
44
|
return [];
|
|
25
45
|
}
|
|
26
46
|
}
|
|
27
47
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
console.warn("\n⚠ changes outside the content set — these are code changes, not content:");
|
|
31
|
-
for (const f of stray) console.warn(` ${f}`);
|
|
32
|
-
console.warn(" Commit them separately (a Claude Code task), or confirm they're intentional.\n");
|
|
48
|
+
function strayFiles(paths) {
|
|
49
|
+
return paths.filter((f) => !RESERVED_SET.some((re) => re.test(f)));
|
|
33
50
|
}
|
|
34
51
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
52
|
+
function splitStray(entries) {
|
|
53
|
+
return {
|
|
54
|
+
staged: strayFiles(entries.filter((e) => e.staged).map((e) => e.path)),
|
|
55
|
+
unstaged: strayFiles(entries.filter((e) => !e.staged).map((e) => e.path)),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function main() {
|
|
60
|
+
const { staged: stagedStray, unstaged: unstagedStray } = splitStray(changedEntries());
|
|
61
|
+
|
|
62
|
+
if (stagedStray.length) {
|
|
63
|
+
console.error("\n✗ staged changes outside the reserved content set (CONTENT.md):");
|
|
64
|
+
for (const f of stagedStray) console.error(` ${f}`);
|
|
65
|
+
console.error(
|
|
66
|
+
"\n A `content:` commit can't carry a code or structure change. Unstage them\n" +
|
|
67
|
+
" (git restore --staged <file>), or file a requests/ note for the build arm.\n",
|
|
68
|
+
);
|
|
42
69
|
process.exit(1);
|
|
43
70
|
}
|
|
71
|
+
if (unstagedStray.length) {
|
|
72
|
+
console.warn("\n⚠ uncommitted changes outside the reserved set (not staged):");
|
|
73
|
+
for (const f of unstagedStray) console.warn(` ${f}`);
|
|
74
|
+
console.warn(" They won't ride a `content:` commit unless you stage them.\n");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const reqs = openRequests();
|
|
78
|
+
if (reqs.length) {
|
|
79
|
+
console.log(`\nℹ ${reqs.length} open request(s) in requests/ awaiting the build arm.\n`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const steps = ["lint", "validate", "build", "links"];
|
|
83
|
+
for (const s of steps) {
|
|
84
|
+
process.stdout.write(`content-check: npm run ${s}\n`);
|
|
85
|
+
try {
|
|
86
|
+
execSync(`npm run ${s}`, { stdio: "inherit" });
|
|
87
|
+
} catch {
|
|
88
|
+
console.error(`\ncontent-check: '${s}' failed — fix it before committing.`);
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
console.log("\ncontent-check: ok — lint · validate · build · links");
|
|
44
93
|
}
|
|
45
|
-
|
|
94
|
+
|
|
95
|
+
module.exports = { strayFiles, parsePorcelain, splitStray, RESERVED_SET };
|
|
96
|
+
|
|
97
|
+
if (require.main === module) main();
|
package/lib/doctor.js
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
const fs = require("node:fs");
|
|
22
22
|
const path = require("node:path");
|
|
23
23
|
const { execSync } = require("node:child_process");
|
|
24
|
-
const { ROOT, projectRequire } = require("./_project.js");
|
|
24
|
+
const { ROOT, projectRequire, openRequests } = require("./_project.js");
|
|
25
25
|
const { asRegexMap } = require("./header-expect.js");
|
|
26
26
|
|
|
27
27
|
const CACHE = path.join(ROOT, ".cache", "doctor.json");
|
|
@@ -376,6 +376,26 @@ function main() {
|
|
|
376
376
|
}
|
|
377
377
|
|
|
378
378
|
if (preflight) {
|
|
379
|
+
// Open content-arm → build-arm requests gate the deploy: a conditional
|
|
380
|
+
// hard stop (core.md#content-model). Zero → ship; one or more → refuse
|
|
381
|
+
// unless --ack-requests says the operator has reviewed them.
|
|
382
|
+
const reqs = openRequests();
|
|
383
|
+
if (reqs.length && !args.includes("--ack-requests")) {
|
|
384
|
+
console.error(
|
|
385
|
+
`\ndoctor: ${reqs.length} open request(s) in requests/ — the content arm is ` +
|
|
386
|
+
`waiting on\nthe build arm. Clear them, or re-run the deploy with ` +
|
|
387
|
+
`--ack-requests to ship anyway:`,
|
|
388
|
+
);
|
|
389
|
+
reqs.forEach((r) => console.error(` - requests/${r}`));
|
|
390
|
+
console.error("\nRefusing to continue.");
|
|
391
|
+
process.exit(1);
|
|
392
|
+
}
|
|
393
|
+
console.log(
|
|
394
|
+
reqs.length
|
|
395
|
+
? `\nOpen requests: ${reqs.length} — acknowledged (--ack-requests).`
|
|
396
|
+
: "\nOpen requests: 0.",
|
|
397
|
+
);
|
|
398
|
+
|
|
379
399
|
const cr = complianceRegressions();
|
|
380
400
|
if (!cr.checked) {
|
|
381
401
|
console.log(
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@piercebarney/whs-eleventy",
|
|
3
|
-
"version": "2026.9.
|
|
3
|
+
"version": "2026.9.3",
|
|
4
4
|
"description": "The web house style's Eleventy + Netlify tooling — the compliance sweep, the infra doctor, the link/CSP integrity check, and the a11y scan, shared by every project on the stack.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"whs": "cli.js"
|
package/standard/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,463 @@ Format: `## <date>` → `### core: <slug>` / `### <stack>` entries.
|
|
|
8
8
|
|
|
9
9
|
---
|
|
10
10
|
|
|
11
|
+
## 2026-09-03 — the content-arm interface: a project and its coding arm
|
|
12
|
+
|
|
13
|
+
A project built to the house style is often only part of a larger operation —
|
|
14
|
+
an editor-in-chief (a Claude Project, ideation + editorial context) that hands
|
|
15
|
+
work to a **coding arm** (Claude Code for structure, Cowork for content) in the
|
|
16
|
+
repo. This release makes that hand-off a defined interface rather than a set of
|
|
17
|
+
conventions the operator holds in their head. The model: editor-in-chief →
|
|
18
|
+
typesetter → press. The typesetter turns editorial intent into validated data
|
|
19
|
+
plus a `content:` commit on `main`; only a person runs the press (deploy —
|
|
20
|
+
`#ci-cd`, unchanged).
|
|
21
|
+
|
|
22
|
+
Grew out of a design conversation, filed and worked through in
|
|
23
|
+
`feedback/2026-09-02-content-arm-interface.md` (now consumed into this entry).
|
|
24
|
+
Six changes shipped together as one release; each is summarised below.
|
|
25
|
+
|
|
26
|
+
**Rejected along the way**, recorded so they aren't re-litigated: standalone-page
|
|
27
|
+
prose as **Markdown files** (lost to JSON — reuses the existing validator and
|
|
28
|
+
render path, zero new parsing; Markdown ergonomics are what `add-ons/pages/` is
|
|
29
|
+
for); an **always-on "I reviewed the requests" checkbox** on deploy (lost to a
|
|
30
|
+
conditional hard-stop — friction only when the drawer is non-empty, so the
|
|
31
|
+
checkbox never trains into a reflex); **Cowork as the default content owner**
|
|
32
|
+
(lost to "Claude Code owns everything unless a project reserves a slice" — right
|
|
33
|
+
for the common small-site case); a **`schedule` trigger** on the deploy workflow
|
|
34
|
+
(lost — a timed deploy removes the person the open-requests gate assumes).
|
|
35
|
+
|
|
36
|
+
### core: content-model
|
|
37
|
+
|
|
38
|
+
- **Content-surface split.** Every user-visible string is data — standalone
|
|
39
|
+
pages (About, a policy page, a marketing page) included, not just collection
|
|
40
|
+
entries. The only prose that may stay in a template is copy bound to a
|
|
41
|
+
mechanism that same template implements (a generated disclosure list's
|
|
42
|
+
heading, a consent notice that must track real behaviour); the impl doc names
|
|
43
|
+
those cases. Test: could a non-developer rewrite this sentence without risking
|
|
44
|
+
a guarantee?
|
|
45
|
+
- **Ownership states.** By default the party that builds the site may also edit
|
|
46
|
+
its content. A project running a *separate* content operation **reserves** it:
|
|
47
|
+
one authoritative file at the repo root names the reserving party and the
|
|
48
|
+
exact set it owns (any granularity — all content, or named paths). The builder
|
|
49
|
+
treats the reserved set as read-only and routes changes it needs there through
|
|
50
|
+
an escalation channel, never a direct edit.
|
|
51
|
+
- The agent content-ops protocol now specifies: a pre-commit content check that
|
|
52
|
+
**errors** on a *staged* file outside the reserved set (an unstaged stray only
|
|
53
|
+
warns — so an `npm update` pull can't hard-fail a live project on incidental
|
|
54
|
+
worktree churn), and an escalation channel so a blocked structural request is
|
|
55
|
+
recorded, not lost.
|
|
56
|
+
|
|
57
|
+
### core: adopting
|
|
58
|
+
|
|
59
|
+
- **Editorial operation** spec: where an LLM agent maintains content after
|
|
60
|
+
launch, the project ships `CONTENT.md` (reserved set + protocol) and a voice
|
|
61
|
+
guide; the editor-in-chief connects to the repo through a *thin, stable*
|
|
62
|
+
instruction (read those two files each session, follow `CONTENT.md`, escalate,
|
|
63
|
+
never deploy) with the detail kept in the repo, re-read each session.
|
|
64
|
+
`CONTENT.md` carries a paste-once "setting up" section.
|
|
65
|
+
- A concept brief that cannot honestly fit an offered stack is a `feedback/`
|
|
66
|
+
note *before* the build — resolved as a new binding or the nearest stack with
|
|
67
|
+
the compromise recorded. `concept-brief.md`'s `framework:` gains a `needs
|
|
68
|
+
discussion` option.
|
|
69
|
+
|
|
70
|
+
### stacks/eleventy-netlify.md
|
|
71
|
+
|
|
72
|
+
- `#content-model`: two content files — `content/guides.json` (a collection,
|
|
73
|
+
rendered via a `<dir>/<dir>.11tydata.js` directory-data file) and
|
|
74
|
+
`content/pages.json` (the standalone pages; `src/pages/` holds a one-line
|
|
75
|
+
shell each, prose is the JSON `body`). Mechanism-bound sections are
|
|
76
|
+
`{ include }` markers in `body`, dispatched by `src/_includes/prose.njk` and
|
|
77
|
+
validated against a known set. `add-ons/pages/` extends `body` with typed
|
|
78
|
+
layout blocks. The `src/` tree snippet and adopting narrative corrected to
|
|
79
|
+
match (no more `src/index.njk`).
|
|
80
|
+
- New **escalation channel**: a `requests/` directory (one
|
|
81
|
+
`YYYY-MM-DD-<slug>.md` per blocked structural ask; the build arm makes the
|
|
82
|
+
change and deletes the note). `whs content-check` counts open requests;
|
|
83
|
+
`bin/deploy`'s pre-flight refuses to ship while any are open unless
|
|
84
|
+
`--ack-requests`.
|
|
85
|
+
- `#ci-cd`: one sanctioned five-step release reached two ways — the
|
|
86
|
+
**`deploy.yml` `workflow_dispatch` button** (recommended: off your machine,
|
|
87
|
+
logged) or `bin/deploy` locally (the offline path). `workflow_dispatch` only,
|
|
88
|
+
no `schedule`. Secrets as repo secrets. The workflow's open-requests gate
|
|
89
|
+
writes the count to the run summary and fails pending `acknowledge_requests`.
|
|
90
|
+
|
|
91
|
+
### templates/eleventy-netlify
|
|
92
|
+
|
|
93
|
+
- `src/{index,about,privacy}.njk` → `src/pages/{home,about,privacy}.njk` thin
|
|
94
|
+
shells + `src/content/pages.json` + `pages.schema.json`; `schema.js` gains a
|
|
95
|
+
per-domain validator dispatch. `src/_includes/{prose,guide-list,thirdparty-disclosure}.njk`.
|
|
96
|
+
- New root files: `CONTENT.md` (rewritten — reserved-set framing, the "setting
|
|
97
|
+
up the editorial project" section, the `requests/` escalation flow),
|
|
98
|
+
`VOICE.md` (seeded from the brief's *Feel* line, read by both arms),
|
|
99
|
+
`requests/README.md`.
|
|
100
|
+
- `bin/deploy` accepts `--ack-requests`; `.github/workflows/deploy.yml` reworked
|
|
101
|
+
(see `#ci-cd`); `bin/init` / `TEMPLATE.md` / `README.md` updated for the new
|
|
102
|
+
layout and the editorial-project hand-off.
|
|
103
|
+
- New add-on **`add-ons/pages/`** — typed marketing blocks (`hero` /
|
|
104
|
+
`feature-list` / `cta`) the content arm composes into a landing page with no
|
|
105
|
+
template change; the build arm owns the block types.
|
|
106
|
+
- `add-ons/glossary/` + `add-ons/calculator/` corrected for the `pages.json`
|
|
107
|
+
split (the removed `policy` tag → `collections.page`; no `src/index.njk`).
|
|
108
|
+
|
|
109
|
+
### tooling
|
|
110
|
+
|
|
111
|
+
`@piercebarney/whs-eleventy` 2026.9.2 → 2026.9.3:
|
|
112
|
+
|
|
113
|
+
- `content-check`: errors on a **staged** file outside the reserved set (unstaged
|
|
114
|
+
→ warn); prints the open-`requests/` count. `RESERVED_SET` is `content/*.json`
|
|
115
|
+
(not the `*.schema.json` aids or `schema.js`) + `_data/{nav,glossary}.js` +
|
|
116
|
+
`requests/`. **Projects pulling this via `npm update` get the stricter
|
|
117
|
+
behaviour** — a `content:` commit can no longer carry a staged code change.
|
|
118
|
+
- `doctor --deploy-preflight`: a conditional hard stop on open `requests/` —
|
|
119
|
+
refuses unless `--ack-requests`.
|
|
120
|
+
- `_project.js` `openRequests()`; `compliance` `content-model` note also reports
|
|
121
|
+
whether `requests/` is present.
|
|
122
|
+
- The `privacy` compliance check no longer hard-codes `src/privacy.njk` — it
|
|
123
|
+
checks the built `/privacy/` output and the third-party loop wherever it lives.
|
|
124
|
+
- New fixture tests: the `privacy` check, `openRequests()`, the `content-check`
|
|
125
|
+
reserved-set + porcelain-parse + staged/unstaged split.
|
|
126
|
+
|
|
127
|
+
### stacks/phoenix.md · sveltekit.md
|
|
128
|
+
|
|
129
|
+
- `phoenix.md#content-model`: the "a reviewed component carrying editorial prose
|
|
130
|
+
is acceptable" escape removed — it contradicted the sharpened core Spec.
|
|
131
|
+
- Filed `feedback/2026-09-03-pages-model-diverges-across-stacks.md`: the
|
|
132
|
+
standalone-pages model is now three shapes (eleventy rich `body` list,
|
|
133
|
+
sveltekit flat `string[]`, phoenix none) — converge or document the split, at
|
|
134
|
+
triage.
|
|
135
|
+
|
|
136
|
+
## 2026-09-02 — a starter template for the SvelteKit binding, moved out of parked
|
|
137
|
+
|
|
138
|
+
`stacks/sveltekit.md` was a parked stub — every mirrored section's "How
|
|
139
|
+
(SvelteKit)" was `TODO`, not offered by the `#adopting` flow. A real need
|
|
140
|
+
(plain Svelte, static output, deployed to Netlify) forced the decisions:
|
|
141
|
+
there is no durable, officially-maintained framework for "pure Svelte, no
|
|
142
|
+
SvelteKit, multi-page static site" (the community tools that do this are
|
|
143
|
+
small single-maintainer projects), so the resolution is SvelteKit +
|
|
144
|
+
`adapter-static`, used minimally as a build tool with no server ever
|
|
145
|
+
touched. Built `templates/sveltekit/` the same way `templates/phoenix/` was
|
|
146
|
+
built: every mechanism claim verified against a real `sv create` scaffold
|
|
147
|
+
and real `vite build` output, not written from the binding's prose alone.
|
|
148
|
+
|
|
149
|
+
### core: adopting
|
|
150
|
+
|
|
151
|
+
- Q1's framework list gains SvelteKit as a real (draft) option — "only when
|
|
152
|
+
a project specifically needs it; a project with no Svelte requirement
|
|
153
|
+
should still default to Eleventy + Netlify" — replacing the previous
|
|
154
|
+
"treat as other" parked-stub instruction.
|
|
155
|
+
|
|
156
|
+
### stacks/sveltekit.md
|
|
157
|
+
|
|
158
|
+
- **Every `TODO` "How (SvelteKit)" section resolved or explicitly marked as
|
|
159
|
+
a real, flagged gap** — not left as a hint. The `PARKED STUB` banner and
|
|
160
|
+
`open-questions-for-later` section are both removed; every open question
|
|
161
|
+
they held is now answered inline in the relevant section, with the
|
|
162
|
+
reasoning kept (see `where-sveltekit-fights-the-grain`'s per-item
|
|
163
|
+
resolutions).
|
|
164
|
+
- **A real, previously-undocumented SvelteKit architecture change found and
|
|
165
|
+
corrected:** a separate `svelte.config.js` is no longer generated or
|
|
166
|
+
required — `sv create`'s current scaffold (`@sveltejs/kit@2.63`) passes
|
|
167
|
+
the full `KitConfig` (adapter, `csp`, everything) directly into the
|
|
168
|
+
`sveltekit()` Vite plugin call in `vite.config.ts`. The old
|
|
169
|
+
`sveltekit-config` section (titled "svelte.config.js & vite.config.js")
|
|
170
|
+
assumed two files; corrected to one.
|
|
171
|
+
- **Content pipeline resolved: plain JSON + zod**, not
|
|
172
|
+
`@content-collections/core` or `mdsvex` — checked
|
|
173
|
+
`templates/eleventy-netlify/src/content/schema.js` directly first: its
|
|
174
|
+
proven pattern is a hand-rolled, zero-dependency validator, so this is a
|
|
175
|
+
deliberate deviation kept at the shape level (JSON + a build-time
|
|
176
|
+
validator that hard-fails printing every error), not a copy of the exact
|
|
177
|
+
mechanism. Verified the "print every error" contract by deliberately
|
|
178
|
+
breaking content three ways at once and confirming all five resulting
|
|
179
|
+
errors surfaced together — an earlier draft of the validator only ran its
|
|
180
|
+
structural (duplicate-slug / dangling-reference) checks inside the
|
|
181
|
+
schema-success branch, silently dropping them whenever a field-level error
|
|
182
|
+
also fired; fixed to run unconditionally.
|
|
183
|
+
- **CSS default resolved: Pico classless**, not Tailwind — matching
|
|
184
|
+
`eleventy-netlify`'s default; Svelte's component-scoped `<style>` blocks
|
|
185
|
+
compile to a real extracted stylesheet (verified in build output, not
|
|
186
|
+
inlined), so they coexist cleanly with a strict `style-src 'self'` and no
|
|
187
|
+
Tailwind/PostCSS step is needed.
|
|
188
|
+
- **Netlify Forms compatibility with `adapter-static` verified end to end**,
|
|
189
|
+
not assumed from a blog post: a `data-netlify="true"` form with
|
|
190
|
+
`netlify-honeypot` and a hidden `form-name` input, inspected in the actual
|
|
191
|
+
prerendered `contact.html` output and confirmed to survive Svelte's
|
|
192
|
+
compilation intact. This is load-bearing, not incidental — Netlify's
|
|
193
|
+
deploy-time bot only ever parses the built static HTML.
|
|
194
|
+
- **`csp: { mode: 'hash' }` verified against real build output**: a real
|
|
195
|
+
computed `sha256-` hash appears in the built `<meta
|
|
196
|
+
http-equiv="content-security-policy">` tag. Also found and documented:
|
|
197
|
+
`frame-ancestors` is silently absent from that tag regardless of being
|
|
198
|
+
listed in the config — the CSP spec doesn't allow that directive in a
|
|
199
|
+
`<meta>` tag at all — so `netlify.toml`'s global header block is the only
|
|
200
|
+
place it (and every other baseline header) actually takes effect.
|
|
201
|
+
- **The Netlify per-context-headers landmine inherited and fixed
|
|
202
|
+
identically** to `stacks/eleventy-netlify.md#noindex`'s already-fixed
|
|
203
|
+
version, same root cause (a pre-built-artifact deploy bypasses Netlify's
|
|
204
|
+
build-context detection): `noindex`'s layer 2 (the header backstop)
|
|
205
|
+
doesn't apply to this stack's deploy model, stated plainly. Distinguished
|
|
206
|
+
from `security-headers`, which is **not** broken the same way — a global,
|
|
207
|
+
non-context header block doesn't need context detection at all, and is
|
|
208
|
+
confirmed working in production on `eleventy-netlify`'s own live
|
|
209
|
+
`netlify.toml`.
|
|
210
|
+
- **A real, previously-undocumented SvelteKit 2.26+ feature found and
|
|
211
|
+
documented as a genuine advantage**: `resolve()` from `$app/paths` gives
|
|
212
|
+
compile-time internal-link validation (a typo'd route is a real
|
|
213
|
+
TypeScript error), enforced automatically by
|
|
214
|
+
`eslint-plugin-svelte`'s `no-navigation-without-resolve` rule (already
|
|
215
|
+
active from `sv create`'s own `eslint` add-on) — closer to Phoenix's `~p`
|
|
216
|
+
sigil than this binding's own prior "SvelteKit does not check `href`
|
|
217
|
+
strings at build time" assumption, which was wrong.
|
|
218
|
+
- OG-image generation, a brand-token layer, and a theme toggle are
|
|
219
|
+
documented as deliberately deferred/not-yet-built, matching
|
|
220
|
+
`templates/phoenix/`'s `og.gen`/`icons.gen` treatment — TODO banners, not
|
|
221
|
+
silently assumed done.
|
|
222
|
+
|
|
223
|
+
### templates/sveltekit (new)
|
|
224
|
+
|
|
225
|
+
- New starter template: a real `sv create` scaffold (`--template minimal
|
|
226
|
+
--types ts --add prettier eslint vitest sveltekit-adapter=adapter:static`)
|
|
227
|
+
with plain-JSON+zod content (`guides`/`pages`, mirroring
|
|
228
|
+
`eleventy-netlify`'s collection shape at the structural level), the
|
|
229
|
+
verified Netlify-Forms contact page, prerendered `sitemap.xml`/
|
|
230
|
+
`robots.txt` routes, an env-gated `noindex` layer, a `netlify.toml` global
|
|
231
|
+
header block, and `bin/deploy` (Netlify CLI artifact upload, `--no-build`,
|
|
232
|
+
no bypass on prod — mirrors `eleventy-netlify`'s `bin/deploy` exactly, not
|
|
233
|
+
independently rediscovered). `npm run check` runs only real, off-the-shelf
|
|
234
|
+
steps (lint, content validation, `svelte-check`, vitest, build) —
|
|
235
|
+
`a11y`/link-check tooling stays `TODO`, matching `templates/phoenix/`'s
|
|
236
|
+
precedent; no tooling-package counterpart to `@piercebarney/whs-eleventy`
|
|
237
|
+
exists for this stack yet. No `bin/init` yet. Verified via a full
|
|
238
|
+
cold-cache `npm ci && npm run check` run, twice (mid-build and again from
|
|
239
|
+
the template's final repo location after the move).
|
|
240
|
+
- `index.json`'s `sveltekit.status`: `parked` → `draft`;
|
|
241
|
+
`sveltekit.template` → `templates/sveltekit/`.
|
|
242
|
+
- `README.md`'s doc-map table and framework-question wording updated for
|
|
243
|
+
both the stack-file row and the new template row.
|
|
244
|
+
- `templates/verify.sh` and `standard.yml` gain an `npm run check`-only
|
|
245
|
+
branch for SvelteKit-shaped templates (keyed on `vite.config.ts`, checked
|
|
246
|
+
ahead of the generic `package.json` branch) — no `compliance --strict`
|
|
247
|
+
call, same deferred-tooling reasoning as Phoenix's branch.
|
|
248
|
+
- `templates/new-project.sh`'s rsync excludes gain `build`/`.svelte-kit`.
|
|
249
|
+
|
|
250
|
+
### tooling
|
|
251
|
+
|
|
252
|
+
- Not touched this phase — no `@piercebarney/whs-sveltekit` package exists;
|
|
253
|
+
`a11y`/link-check stay design-only TODOs, explicitly deferred, same as
|
|
254
|
+
Phoenix's.
|
|
255
|
+
|
|
256
|
+
---
|
|
257
|
+
|
|
258
|
+
## 2026-09-02 — Render as the Phoenix binding's default free-launch host
|
|
259
|
+
|
|
260
|
+
Netlify can't run this stack at all — LiveView needs a persistent BEAM node
|
|
261
|
+
(long-lived WebSocket connections, a warm Postgres pool), not a static
|
|
262
|
+
artifact or a stateless function. `stacks/phoenix.md` previously named only
|
|
263
|
+
Gigalixir/Fly, neither of which offers a genuinely free, no-credit-card,
|
|
264
|
+
no-domain-to-attach launch anymore (Fly deprecated its free tier; Gigalixir
|
|
265
|
+
requires a card at signup even though the free tier itself isn't charged).
|
|
266
|
+
Verified against Render's own docs (`render.com/docs/free`,
|
|
267
|
+
`/docs/blueprint-spec`, `/docs/deploy-phoenix`, `/docs/cli`) and its reference
|
|
268
|
+
app (`github.com/render-examples/phoenix_hello`) rather than asserted from
|
|
269
|
+
memory — pricing/free-tier terms move fast enough that a remembered claim
|
|
270
|
+
isn't trustworthy here.
|
|
271
|
+
|
|
272
|
+
### stacks/phoenix.md
|
|
273
|
+
|
|
274
|
+
- **Render is now the documented default host** (`stack-baseline`, `ci-cd`,
|
|
275
|
+
`secrets`) — free with no credit card at signup, a live `onrender.com`
|
|
276
|
+
subdomain the moment the first deploy finishes, no domain to attach. The
|
|
277
|
+
real tradeoff, stated plainly: Render's free Postgres is **deleted 30 days
|
|
278
|
+
after creation**, and the free plan allows only **one** free database per
|
|
279
|
+
workspace — it doesn't comfortably fund the doc's existing `dev`/`staging`/
|
|
280
|
+
`prod` three-environment shape. Gigalixir/Fly stay documented as the
|
|
281
|
+
graduate-to-production path once that matters.
|
|
282
|
+
- `ci-cd`'s `git_hooks` auto-install description corrected in the same pass:
|
|
283
|
+
there is no `auto_install: true` config key (none exists in the library's
|
|
284
|
+
schema) — merely having `git_hooks` as a `:dev` dependency is what triggers
|
|
285
|
+
the install. Documented the gotcha this project hit directly: that install
|
|
286
|
+
walks up to whatever repo `git rev-parse --show-toplevel` finds, so running
|
|
287
|
+
`mix deps.get`/`mix compile` from a checkout that isn't its own git repo
|
|
288
|
+
yet installs hooks into the *enclosing* repo instead — the fix is a
|
|
289
|
+
`File.dir?(".git")` guard around the config.
|
|
290
|
+
- A new **"Not verifiable without a live Render account"** note in `ci-cd`
|
|
291
|
+
(same category as the existing Gigalixir-buildpack caveat): whether
|
|
292
|
+
`preDeployCommand` failure genuinely blocks promotion under every failure
|
|
293
|
+
mode, `RENDER_EXTERNAL_HOSTNAME`'s exact format, `force_ssl`/HSTS behind
|
|
294
|
+
Render's proxy, cold-start-vs-health-check timing, and Cachex/Hammer's
|
|
295
|
+
per-node behavior past one instance — flagged, not asserted.
|
|
296
|
+
- `bin/check`'s sterility allowlist gains `render.com` / `onrender.com`.
|
|
297
|
+
|
|
298
|
+
### templates/phoenix
|
|
299
|
+
|
|
300
|
+
- **`render.yaml`** (new) — a Blueprint declaring the web service + Postgres
|
|
301
|
+
in-repo: `autoDeployTrigger: off` (a push to `main` never deploys on its
|
|
302
|
+
own — `bin/deploy` is the deliberate trigger), `buildCommand: ./build.sh`,
|
|
303
|
+
`startCommand` pointing at the release's `bin/server`, `preDeployCommand`
|
|
304
|
+
running the release's `Release.migrate` eval before the new instance is
|
|
305
|
+
promoted, `healthCheckPath` wired to the existing `HealthController`,
|
|
306
|
+
`envVars` using `generateValue`/`fromDatabase`/`sync: false` so no secret
|
|
307
|
+
value is ever written to the yaml.
|
|
308
|
+
- **`build.sh`** (new) — verified verbatim against
|
|
309
|
+
`github.com/render-examples/phoenix_hello`'s own script (`mix deps.get
|
|
310
|
+
--only prod`, `MIX_ENV=prod mix compile`, `assets.build` + `assets.deploy`,
|
|
311
|
+
`mix phx.gen.release`, `mix release --overwrite`). `rel/` added to
|
|
312
|
+
`.gitignore` — nothing it generates is committed.
|
|
313
|
+
- **`bin/deploy`** rewritten around the `render` CLI (`render deploys create
|
|
314
|
+
<service-id> --commit <sha> --wait --confirm`) in place of the previous
|
|
315
|
+
git-push-to-a-Gigalixir/Fly-remote mechanism; still no `$OPTS` on `prod` —
|
|
316
|
+
that omission is still the no-bypass enforcement, just against a different
|
|
317
|
+
underlying command. `bin/migrate` / `bin/remote` document the real (if not
|
|
318
|
+
fully non-interactive) `render ssh` + release-eval commands in place of
|
|
319
|
+
their previous host-agnostic stub text.
|
|
320
|
+
- **`config/runtime.exs`** now falls back to Render's auto-populated
|
|
321
|
+
`RENDER_EXTERNAL_HOSTNAME` when `PHX_HOST` isn't set, so the `onrender.com`
|
|
322
|
+
subdomain resolves correctly with zero manual config.
|
|
323
|
+
- `AGENTS.md`'s Deploy section and `TEMPLATE.md`'s "what a fresh checkout
|
|
324
|
+
still owns" updated to match; a stale `content/resources/article.ex` path
|
|
325
|
+
(the real file has no `resources/` segment) corrected in `AGENTS.md` while
|
|
326
|
+
in the same section.
|
|
327
|
+
- None of this has been verified against a live Render account — see
|
|
328
|
+
`stacks/phoenix.md#ci-cd`'s new "Not verifiable" note above for exactly
|
|
329
|
+
what that would still need to confirm.
|
|
330
|
+
|
|
331
|
+
---
|
|
332
|
+
|
|
333
|
+
## 2026-09-02 — a starter template for the Phoenix binding, and hardening the doc it's built from
|
|
334
|
+
|
|
335
|
+
Built `templates/phoenix/` the same way `templates/eleventy-netlify/` was
|
|
336
|
+
proven out: generic placeholder content, independent of any real business,
|
|
337
|
+
every mechanism claim held to compile-it-and-curl-it verification rather than
|
|
338
|
+
plausible-looking prose. That process found `stacks/phoenix.md` — despite
|
|
339
|
+
being "grounded in a real production codebase" — still carried several
|
|
340
|
+
fabricated or imprecise APIs the same way the earlier `Plug.CSP` finding did.
|
|
341
|
+
|
|
342
|
+
### stacks/phoenix.md
|
|
343
|
+
|
|
344
|
+
- **`Oban.Cron` corrected to `Oban.Plugins.Cron`** (all three occurrences) —
|
|
345
|
+
the real plugin module, confirmed against Oban's fetched source.
|
|
346
|
+
- **`ash_phoenix`/`AshPhoenix.Form` now named** as the required bridge between
|
|
347
|
+
an Ash resource and a LiveView `<.form>` — the doc's `forms` section needed
|
|
348
|
+
it to write a contact form at all and never named it.
|
|
349
|
+
- `contexts-and-ash`: `config :app, ash_domains: [...]` is now documented as
|
|
350
|
+
required (a real compile warning otherwise, fatal under
|
|
351
|
+
`--warnings-as-errors`); `authorizers: [Ash.Policy.Authorizer]` is now
|
|
352
|
+
stated as required alongside a resource's `policies do` block;
|
|
353
|
+
`Ash.Seed.seed!/2` is documented as the policy-bypassing seed mechanism
|
|
354
|
+
(`Ash.Changeset.for_create` + `Ash.create!` correctly raises
|
|
355
|
+
`Ash.Error.Forbidden` with no actor — that's the policy working, not a bug,
|
|
356
|
+
but it means seed scripts need `Ash.Seed`); `simple_sat` recommended over
|
|
357
|
+
`picosat_elixir` as the SAT-solver backend for policy authorization — pure
|
|
358
|
+
Elixir, no C compiler dependency, more portable for a template baseline.
|
|
359
|
+
- `theme`: notes that `mix phx.new`'s generated anti-FOUC inline script ships
|
|
360
|
+
with no nonce, which a strict CSP rejects outright — the template's script
|
|
361
|
+
now carries one.
|
|
362
|
+
- `structured-data`: **critical finding** —
|
|
363
|
+
`<script type="application/ld+json">{raw(@json_ld)}</script>` renders the
|
|
364
|
+
literal text `{raw(@json_ld)}`, not the JSON, because HEEx's tokenizer
|
|
365
|
+
treats a `<script>` tag's body as opaque text and never invokes the `{}`
|
|
366
|
+
expression parser inside it. The classic EEx `<%= raw(@json_ld) %>` tag is
|
|
367
|
+
the fix — its tokenizer runs before HEEx's tag-aware parsing, so it still
|
|
368
|
+
works inside `<script>`. Verified by curling a real page and parsing the
|
|
369
|
+
response as JSON, both before (fails) and after (parses) the fix.
|
|
370
|
+
- `sitemap-robots`: documents Cachex's real child-spec form
|
|
371
|
+
(`{Cachex, name: :x}`), flags that its cache is per-node in-memory — an
|
|
372
|
+
`Ash.Notifier`-triggered invalidation on the node that handled a write does
|
|
373
|
+
not reach a sibling node on a horizontally-scaled deploy — and calls out
|
|
374
|
+
the `robots.txt`/`static_paths()` shadowing gotcha: `Plug.Static`'s default
|
|
375
|
+
generated `static_paths()` includes `"robots.txt"`, which silently serves
|
|
376
|
+
the static file over a dynamic `RobotsController` route with no error.
|
|
377
|
+
- `forms`: documents Hammer v7's real supervised-module API
|
|
378
|
+
(`use Hammer, backend: :ets`, `hit/3`) in place of the older functional
|
|
379
|
+
API; notes `get_connect_info/2` can only be called from `mount/3`, not from
|
|
380
|
+
a later `handle_event/3`; and flags that `Phoenix.LiveViewTest`'s simulated
|
|
381
|
+
connections don't derive `peer_data` from `conn.remote_ip` — a test-only
|
|
382
|
+
session key is the working escape hatch, not a `conn` override.
|
|
383
|
+
- `domain-tests`: documents the Ecto Sandbox + LiveView-process visibility
|
|
384
|
+
gap — an `async: true` test's exclusive sandbox ownership isn't
|
|
385
|
+
automatically shared with a LiveView's own GenServer process, so a test can
|
|
386
|
+
observe a write as having succeeded while its own query still sees zero
|
|
387
|
+
rows; `Ecto.Adapters.SQL.Sandbox.allow/3` after `live/2` is the fix.
|
|
388
|
+
- `the-gate`: documents two Mix config gotchas that make `mix check` behave
|
|
389
|
+
differently from `mix test` run directly — `preferred_envs: [check: :test]`
|
|
390
|
+
(without it, a custom alias's `mix test` step runs in `:dev`) and
|
|
391
|
+
`dialyzer: [plt_add_apps: [:ex_unit]]` (without it, Dialyzer fails once
|
|
392
|
+
`test/support/*.ex`'s `ExUnit` macro calls are analyzed under `:test`).
|
|
393
|
+
- `security-headers`: notes Sobelow's `Config.Headers` and `XSS.Raw` checks
|
|
394
|
+
false-positive on this stack's CSP plug and JSON-LD `raw()` call
|
|
395
|
+
respectively, and that `.sobelow-conf` is the documented way to silence
|
|
396
|
+
them with reasoning attached, not a blanket `--skip`.
|
|
397
|
+
- `repo-hygiene`: notes `mix phx.new` (1.8.x) now auto-generates its own
|
|
398
|
+
`AGENTS.md` with ecosystem usage-rules content — a project's house-style
|
|
399
|
+
block merges into that file rather than displacing it, still one prose-doc
|
|
400
|
+
slot.
|
|
401
|
+
- `stack-baseline`: `ash_phoenix` documented as a required separate
|
|
402
|
+
dependency (not auto-installed by `mix igniter.install ash`); Boundary
|
|
403
|
+
noted as optional, not baseline; Tailwind + DaisyUI noted as shipping by
|
|
404
|
+
default from `phx.new` 1.8.x; added a standing warning that a written
|
|
405
|
+
version pin in this doc can drift from what Hex resolves today — re-check
|
|
406
|
+
at scaffold time, don't trust the pin.
|
|
407
|
+
- `og-image` / `assets`: gain a `TODO — no reference implementation exists`
|
|
408
|
+
banner matching `a11y` / `internal-links` / `the-gate` / `compliance`'s
|
|
409
|
+
existing treatment — `mix og.gen` / `mix icons.gen` don't exist yet;
|
|
410
|
+
`templates/phoenix/` ships one hand-placed placeholder OG image instead.
|
|
411
|
+
- `adopting`: "No starter template for this stack yet" replaced with a
|
|
412
|
+
pointer to `templates/phoenix/`; the new-project checklist updated to
|
|
413
|
+
reference every fix above.
|
|
414
|
+
|
|
415
|
+
### templates/phoenix (new)
|
|
416
|
+
|
|
417
|
+
- New starter template: an Ash domain layer (`Content.Article`, a policy,
|
|
418
|
+
and an `excerpt` calculation as the domain-tests sample), an `Inbox.Message`
|
|
419
|
+
contact-form target with a honeypot validation/change pair, the corrected
|
|
420
|
+
CSP-nonce security-headers plug, cached sitemap/robots controllers, an
|
|
421
|
+
`/audit` LiveView, an `AshPhoenix.Form`-backed contact form (honeypot +
|
|
422
|
+
Hammer rate limit), `.github/workflows/ci.yml`, `bin/deploy`. `mix check`
|
|
423
|
+
runs only real steps (compile --warnings-as-errors, format, credo --strict,
|
|
424
|
+
dialyzer, sobelow --config, deps.audit, ash.codegen --check, test) —
|
|
425
|
+
`a11y` / `links.check` / `whs.check` stay TODO, not faked into the alias.
|
|
426
|
+
No `bin/init` yet — `TEMPLATE.md` covers filling in the placeholders by
|
|
427
|
+
hand. Verified via a full cold-cache `mix check` run (fresh deps, fresh
|
|
428
|
+
Postgres, fresh compile) from the template's final repo location, twice.
|
|
429
|
+
- **`config/dev.exs`'s `git_hooks` config is now guarded on `File.dir?(".git")`.**
|
|
430
|
+
Found the hard way: `git_hooks`' `only: [:dev]` dependency auto-installs
|
|
431
|
+
hooks on `mix deps.get`/`mix compile` by walking up to whatever repo
|
|
432
|
+
`git rev-parse --show-toplevel` finds — since `templates/phoenix/` has no
|
|
433
|
+
`.git` of its own inside this monorepo, running `templates/verify.sh`
|
|
434
|
+
installed real `pre-commit`/`pre-push` hooks into *the standard's own*
|
|
435
|
+
`.git/hooks`, breaking `git commit` here until removed by hand. The guard
|
|
436
|
+
only configures `git_hooks` once the directory has been copied out into a
|
|
437
|
+
real project and `git init`'d — which is when the auto-install is actually
|
|
438
|
+
wanted.
|
|
439
|
+
- `index.json`'s `frameworks.phoenix.template` → `templates/phoenix/`;
|
|
440
|
+
`status` stays `draft` — that should mean full gate parity including a
|
|
441
|
+
compliance sweep, and the Elixir tooling package (the `whs-eleventy`
|
|
442
|
+
counterpart) is still a later phase.
|
|
443
|
+
- `README.md`'s doc-map table gets a dedicated `templates/phoenix/` row; the
|
|
444
|
+
`eleventy-netlify` row's "phoenix: no template" note is removed.
|
|
445
|
+
- `templates/verify.sh` and `.github/workflows/standard.yml` gain a
|
|
446
|
+
`mix.exs`-keyed branch (`mix deps.get`, `ecto.create`/`migrate` for dev and
|
|
447
|
+
test, `mix check`) — no `compliance --strict` call, since that tooling
|
|
448
|
+
doesn't exist for this stack yet.
|
|
449
|
+
- `templates/new-project.sh` now checks for `bin/init` before running it and
|
|
450
|
+
prints a clear message instead of crashing when a stack (Phoenix, for now)
|
|
451
|
+
doesn't have one yet.
|
|
452
|
+
- `bin/set-version` and `bin/check`'s version-agreement check now also cover
|
|
453
|
+
a template's `AGENTS.md` `standard-version` pin, not just `CLAUDE.md` —
|
|
454
|
+
needed because `templates/phoenix/` carries the house-style block in
|
|
455
|
+
`AGENTS.md` (`mix phx.new` generates its own `AGENTS.md`; the one-prose-doc
|
|
456
|
+
slot rule means the house-style block merges into it rather than adding a
|
|
457
|
+
second file), and the two checks previously only looked at `CLAUDE.md`.
|
|
458
|
+
- `bin/check`'s sterility allowlist gains the Phoenix-ecosystem domains the
|
|
459
|
+
template's docs legitimately reference (`daisyui.com`, `tailwindcss.com`,
|
|
460
|
+
`heroicons.com`, `phoenixframework.org`, `elixirforum.com`, `fly.io`,
|
|
461
|
+
`github.io`, `hexdocs.pm`, `hex.pm`, `mozilla.org`) and broadens the prior
|
|
462
|
+
single-path `github.com/piercebarney/web-house-style` entry to a generic
|
|
463
|
+
`github.com`, matching how `netlify.com`/`netlify.app` are already allowed
|
|
464
|
+
generically rather than scoped to one account.
|
|
465
|
+
|
|
466
|
+
---
|
|
467
|
+
|
|
11
468
|
## 2026-09-02 — closing the gap between the compliance grid and the Contracts it checks
|
|
12
469
|
|
|
13
470
|
A pass through every chapter's actual enforcement, prompted by an outside
|
|
@@ -31,6 +488,17 @@ consistency defects the same pass turned up.
|
|
|
31
488
|
to `# recommended — enables standard-version drift tracking (#compliance)`
|
|
32
489
|
— `#compliance`'s own check already treats it as required; the doc
|
|
33
490
|
contradicted the code.
|
|
491
|
+
- **A "Feedback to the standard" spec, shipped as `feedback/README.md`** (a
|
|
492
|
+
minimal dated-note inbox — what is wrong · which project ·
|
|
493
|
+
`standard-version`), plus a House-style directive bullet and an
|
|
494
|
+
anti-pattern: a standard defect (a misfiring check, a `core.md` ↔ impl-doc
|
|
495
|
+
contradiction, a Contract that doesn't survive a real codebase) is a note
|
|
496
|
+
filed there, not a session-transcript casualty. `bin/check`'s sterile grep
|
|
497
|
+
covers the committed inbox; `bin/check` does not gate on it. Deliberately
|
|
498
|
+
minimal — no `bin/feedback` scaffold or CLI subcommand this pass; an
|
|
499
|
+
earlier, more automated version (a GitHub-issue subcommand) was built and
|
|
500
|
+
reverted as over-built for a one-adopting-project reality. Triage empties
|
|
501
|
+
the inbox into a `CHANGELOG` change or a one-line `wontfix`.
|
|
34
502
|
|
|
35
503
|
### docs — drift-guard registry sync
|
|
36
504
|
|
package/standard/core.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Web project house style — CORE (stack-agnostic)
|
|
2
2
|
|
|
3
|
-
**Version:** 2026-09-
|
|
3
|
+
**Version:** 2026-09-03 · **Status:** active
|
|
4
4
|
|
|
5
5
|
This is the stack-agnostic contract every web project follows, regardless of
|
|
6
6
|
framework, host, or CSS system. It says **what** must be true, with concrete
|
|
@@ -94,8 +94,13 @@ contents, and uses one mechanism per concern.
|
|
|
94
94
|
a build-time schema gate hard-fails the build on invalid content.
|
|
95
95
|
|
|
96
96
|
**Specs.**
|
|
97
|
-
-
|
|
98
|
-
|
|
97
|
+
- **Every** user-visible string is authored as data — collection entries and
|
|
98
|
+
standalone pages alike (an About page, a policy page, a marketing page).
|
|
99
|
+
Templates render data and nothing else. The only prose that may live in a
|
|
100
|
+
template is copy bound to a mechanism that same template implements — a
|
|
101
|
+
generated disclosure list's heading, a consent notice whose wording must track
|
|
102
|
+
real behaviour; the impl doc names these cases. The test: could a non-developer
|
|
103
|
+
rewrite this sentence without risking a guarantee? If yes, it is data.
|
|
99
104
|
- A validator runs before the build completes and **exits non-zero** on any
|
|
100
105
|
error, printing every error (not just the first), each as `path: expected X`.
|
|
101
106
|
- Validation covers: required keys present; correct types; enum values; **unique
|
|
@@ -107,11 +112,21 @@ a build-time schema gate hard-fails the build on invalid content.
|
|
|
107
112
|
- Where non-developers edit content, provide editor-time validation (a schema
|
|
108
113
|
the editor understands) in addition to the build gate; a Git-backed CMS over
|
|
109
114
|
the same data files is acceptable — a separate database for site content is
|
|
110
|
-
not.
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
+
not.
|
|
116
|
+
- **Ownership.** By default the party that builds the site may also edit its
|
|
117
|
+
content. A project that runs a **separate content operation** — a non-developer,
|
|
118
|
+
or an LLM agent editing the data files directly — **reserves** that content:
|
|
119
|
+
one file at the repo root names the reserving party and the exact set it owns,
|
|
120
|
+
and is authoritative for every party. The builder treats the reserved set as
|
|
121
|
+
read-only and routes any change it needs there through the project's escalation
|
|
122
|
+
channel, never a direct edit. The reserved set is enumerated at whatever
|
|
123
|
+
granularity the project needs — all content, or named collections/paths.
|
|
124
|
+
- An **LLM agent** editing reserved content follows a documented per-project
|
|
125
|
+
content-ops protocol: it changes only the reserved set; a pre-commit content
|
|
126
|
+
check (the content-relevant slice of the gate) **errors** on any staged change
|
|
127
|
+
outside that set; a `content:` commit convention; and an escalation channel for
|
|
128
|
+
anything structural, so a blocked request is recorded, not lost. See your
|
|
129
|
+
stack's impl doc.
|
|
115
130
|
- **Drafts** are a data flag. A draft renders in local and preview builds
|
|
116
131
|
(reviewable) but is excluded from the production build and the sitemap.
|
|
117
132
|
Publishing is removing the flag.
|
|
@@ -121,7 +136,9 @@ a build-time schema gate hard-fails the build on invalid content.
|
|
|
121
136
|
|
|
122
137
|
**Rationale.** Content that lives in templates can't be validated, can't be
|
|
123
138
|
edited safely by non-developers, and drifts from its own stated facts (a
|
|
124
|
-
calculator whose copy says "18%" while the code uses 15%).
|
|
139
|
+
calculator whose copy says "18%" while the code uses 15%). A written, authoritative
|
|
140
|
+
reserved set is what lets a builder and a separate content operation work the
|
|
141
|
+
same repo without overwriting each other or leaving a request unanswered.
|
|
125
142
|
|
|
126
143
|
**Verify.** The gate fails the build on invalid content. The audit page shows
|
|
127
144
|
validation status.
|
|
@@ -1283,12 +1300,14 @@ records the result. "The gate passing" is the definition of compliant.
|
|
|
1283
1300
|
project), before non-trivial work. Ask five questions with defaults:
|
|
1284
1301
|
|
|
1285
1302
|
1. **Framework?** Eleventy + Netlify *(default)* · Elixir/Phoenix *(when a
|
|
1286
|
-
database or server-side state is in scope — `#beyond-this-standard`)* ·
|
|
1287
|
-
*(
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1303
|
+
database or server-side state is in scope — `#beyond-this-standard`)* ·
|
|
1304
|
+
SvelteKit *(only when a project specifically needs it — Svelte components
|
|
1305
|
+
without SvelteKit has no durable static-site tooling, so SvelteKit +
|
|
1306
|
+
`adapter-static`, used minimally as a build tool with no server, is the
|
|
1307
|
+
fallback even for a "just static Svelte" need; a project with no Svelte
|
|
1308
|
+
requirement should still default to Eleventy + Netlify)* · other *(→ no
|
|
1309
|
+
binding exists; use this file as principles and do what is idiomatic for
|
|
1310
|
+
the technology)*.
|
|
1292
1311
|
2. **CSS system?** Pico classless *(default)* · Pico class-based · Tailwind ·
|
|
1293
1312
|
Tailwind + component library · vanilla tokens. (Options are bound per stack
|
|
1294
1313
|
in the impl doc's `styling` section.)
|
|
@@ -1317,7 +1336,10 @@ stack with no template, and the annotated inventory of what the template
|
|
|
1317
1336
|
contains. Only `eleventy-netlify` has a template today; the others follow the
|
|
1318
1337
|
checklist. The recommended input to the flow is a filled
|
|
1319
1338
|
`templates/concept-brief.md` (the idea, the answers above, a concept-level
|
|
1320
|
-
brand, the content model, layout notes) — `README.md` has the hand-off.
|
|
1339
|
+
brand, the content model, layout notes) — `README.md` has the hand-off. If the
|
|
1340
|
+
brief cannot honestly fit an offered stack, that is a `feedback/` note (below)
|
|
1341
|
+
filed *before* the build — resolved as a new binding, or the nearest stack with
|
|
1342
|
+
the compromise recorded.
|
|
1321
1343
|
|
|
1322
1344
|
**Record** the result near the top of the project's `.claude/CLAUDE.md` /
|
|
1323
1345
|
`AGENTS.md` as a `## House style` section — a directive paragraph plus a data
|
|
@@ -1359,13 +1381,26 @@ governs it and follow that chapter — the slugs are the index (a `<script>` →
|
|
|
1359
1381
|
- production-url: https://example.com
|
|
1360
1382
|
- content-type: tool # tool | article (article => feeds)
|
|
1361
1383
|
- publishing-rate: ~5 pages/week
|
|
1362
|
-
- standard-version: 2026-09-
|
|
1384
|
+
- standard-version: 2026-09-03 # recommended — enables standard-version drift tracking (#compliance)
|
|
1363
1385
|
```
|
|
1364
1386
|
|
|
1365
1387
|
If the section is absent, the assistant's first action is to run the flow above
|
|
1366
1388
|
and propose it. Each impl doc's `adopting` section fills in the gate command and
|
|
1367
1389
|
adds any stack-specific `CLAUDE.md` prose (e.g. the generated-assets note).
|
|
1368
1390
|
|
|
1391
|
+
**Editorial operation.** Where content is maintained after launch by an LLM
|
|
1392
|
+
agent rather than by in-repo development (`#content-model` — a *reserved*
|
|
1393
|
+
content set), two files ship at the project root: `CONTENT.md` (the reserved set
|
|
1394
|
+
plus the per-project content-ops protocol) and a voice guide (see the impl
|
|
1395
|
+
doc). The operation's editor-in-chief — the human's standing ideation/editorial
|
|
1396
|
+
context, typically a Claude Project — connects to the repo through a **thin,
|
|
1397
|
+
stable instruction**: check out the repo; read `CONTENT.md` and the voice guide
|
|
1398
|
+
at the start of each session; follow `CONTENT.md`; escalate through its channel;
|
|
1399
|
+
never deploy. The detail stays in the repo, re-read each session — not in the
|
|
1400
|
+
instruction — and `CONTENT.md` carries a paste-once "setting up" section for
|
|
1401
|
+
exactly this. The build arm treats the reserved set as read-only and escalates
|
|
1402
|
+
into the same channel.
|
|
1403
|
+
|
|
1369
1404
|
**Onboarding an existing project** — after recording the section, run
|
|
1370
1405
|
`#compliance`. Every `FAIL` and `MANUAL` becomes a migration-backlog item,
|
|
1371
1406
|
worked incrementally (the impl doc's migration path is the ordered version of
|