mikser-io 9.66.0 → 9.70.0
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/CLAUDE.md +30 -1
- package/app.js +43 -10
- package/docs/diagnostics.md +32 -4
- package/docs/rendering.md +29 -0
- package/package.json +1 -1
- package/src/engine.js +199 -104
- package/src/instance.js +98 -27
- package/src/plugins/assets.js +7 -0
- package/src/plugins/render/asset.js +15 -1
- package/src/plugins/render/resource.js +5 -1
- package/src/report.js +36 -0
- package/src/track.js +23 -1
package/CLAUDE.md
CHANGED
|
@@ -200,6 +200,28 @@ brevity.
|
|
|
200
200
|
of folder names written in the plugin. A hardcoded list misses every
|
|
201
201
|
collection a project registers through `sources()`, which produced 63
|
|
202
202
|
false warnings per build on a real site.
|
|
203
|
+
- `src/plugins/render/asset.js` / `resource.js` — URL helpers that BUILD
|
|
204
|
+
a path from a naming convention instead of looking an entity up, so
|
|
205
|
+
they cannot fail: a preset that never ran yields a well-formed link to
|
|
206
|
+
nothing on a green build. Each records its destination via
|
|
207
|
+
`track.asset()`; `engine.js` checks the collected set against
|
|
208
|
+
`outputFolder` in `onFinalized` (`reportAssetUse` / `assetUse` in
|
|
209
|
+
report.js, `reportMissingAssets` in engine.js), warning under
|
|
210
|
+
`asset-missing` / `asset-missing-summary`. `existsSync` FOLLOWS
|
|
211
|
+
symlinks, which is required — the assets folder is deployed into the
|
|
212
|
+
output as a symlink. Handlebars appends an options object to every
|
|
213
|
+
helper call, so a trailing optional arg (`format`) must be discarded
|
|
214
|
+
when it looks like one (`'hash' in format`); without that, `asset 'web'
|
|
215
|
+
'/x.jpg'` built `/assets/web/x.[object Object]` — the bug the finalize
|
|
216
|
+
check found on its first run, present since the helper took a format.
|
|
217
|
+
- Postprocess failures are RENDER ERRORS. The dispatcher's catch used to
|
|
218
|
+
log one uncoded `Postprocess error:` line and stop there — exit code 0,
|
|
219
|
+
nothing in `--json` `errors`, `🟢 Mikser completed`. A build missing
|
|
220
|
+
every PDF it was asked for reported success. It now calls `reportError`
|
|
221
|
+
with the failing `postprocessor`, so a stage that wrote no file counts
|
|
222
|
+
the same as a render that threw. `entity.origin` is deliberately NOT
|
|
223
|
+
unlinked on failure (it is on success): a retry needs it as input, and
|
|
224
|
+
for a converter it is real content.
|
|
203
225
|
- `render.js` / `postprocess.js` — Piscina worker entry points AND the
|
|
204
226
|
default-export functions the INLINE/SERIAL dispatcher calls directly.
|
|
205
227
|
Each receives entity + options + config + state; the WORKER path also
|
|
@@ -242,7 +264,14 @@ brevity.
|
|
|
242
264
|
EINVAL`. chmod 0600, so the filesystem permission is the access
|
|
243
265
|
decision. A forwarded build RESCANS (`runtime.rebuild()`), never
|
|
244
266
|
drains: a client can beat the inotify event for the file it just
|
|
245
|
-
wrote.
|
|
267
|
+
wrote. Report-only commands (`--tool`, `--tools`, `--verify`,
|
|
268
|
+
`--explain`) forward too — they read, so a local run damaged nothing,
|
|
269
|
+
but a catalogue another process is mid-write in is not one anyone can
|
|
270
|
+
answer from. `runReportOnly()` in engine.js is the one implementation
|
|
271
|
+
both paths call. `--server` / `--watch` are NOT forwardable — they ask
|
|
272
|
+
to BECOME the instance, and a running engine cannot open a port on
|
|
273
|
+
someone's behalf — so they exit 1 with a message when one is already
|
|
274
|
+
there. Exit code comes from `renderErrorCount()`, not
|
|
246
275
|
`process.exitCode` — the engine suppresses that in watch mode by
|
|
247
276
|
design. Config mismatch is refused by resolved PATH; config drift
|
|
248
277
|
under a running instance is detected by stat over `configCoverage`.
|
package/app.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
|
|
4
4
|
import { setup } from './index.js'
|
|
5
|
-
import { forward } from './src/instance.js'
|
|
5
|
+
import { forward, isInstanceLive } from './src/instance.js'
|
|
6
6
|
|
|
7
7
|
// Before anything is imported or read.
|
|
8
8
|
//
|
|
@@ -24,27 +24,60 @@ function locate(argv) {
|
|
|
24
24
|
}
|
|
25
25
|
return null
|
|
26
26
|
}
|
|
27
|
-
const has = (...names) => names.some(n => argv.includes(n))
|
|
27
|
+
const has = (...names) => names.some(n => argv.includes(n) || argv.some(a => names.some(x => a.startsWith(`${x}=`))))
|
|
28
|
+
|
|
29
|
+
// Asking to BECOME the instance, rather than asking one for something.
|
|
30
|
+
// Not forwardable in principle: a running engine cannot open a listener on
|
|
31
|
+
// someone else's behalf, and forwarding it built, printed "completed" and
|
|
32
|
+
// exited with nothing on the port.
|
|
33
|
+
const longRunning = has('--watch', '-w', '--server', '-s')
|
|
34
|
+
|
|
35
|
+
// What to ask the instance for. Report-only commands go over the same
|
|
36
|
+
// socket as a build: they read, so running them locally never damaged
|
|
37
|
+
// anything, but a catalogue being written by another process is not a
|
|
38
|
+
// catalogue anyone can answer from — a --verify against a half-finished
|
|
39
|
+
// cycle reports drift that is not there.
|
|
40
|
+
const tool = value('--tool')
|
|
41
|
+
const explain = value('--explain')
|
|
42
|
+
const request = has('--tools') ? { type: 'report', tools: true, json: has('--json') }
|
|
43
|
+
: tool ? { type: 'report', tool, toolArgs: value('--tool-args'), json: has('--json') }
|
|
44
|
+
: explain ? { type: 'report', explain, json: has('--json') }
|
|
45
|
+
: has('--verify') ? { type: 'report', verify: true, json: has('--json') }
|
|
46
|
+
: { type: 'build', clear: has('--clear') }
|
|
47
|
+
|
|
28
48
|
return {
|
|
49
|
+
longRunning,
|
|
29
50
|
workingFolder: value('--working-folder', '-i') ?? '.',
|
|
30
51
|
config: value('--config', '-c') ?? 'mikser.config.js',
|
|
31
|
-
clear: has('--clear'),
|
|
32
52
|
// Commander's negated form: `attach` is true unless --no-attach said so.
|
|
33
53
|
attach: has('--no-attach') ? false : true,
|
|
34
|
-
|
|
35
|
-
// tree, and their handlers exit the process themselves. Left local —
|
|
36
|
-
// the guard in setup() still says an instance is there.
|
|
37
|
-
reportOnly: has('--tool', '--tools', '--verify', '--explain'),
|
|
54
|
+
request,
|
|
38
55
|
}
|
|
39
56
|
}
|
|
40
57
|
|
|
41
58
|
async function main() {
|
|
42
59
|
const where = locate(process.argv.slice(2))
|
|
43
|
-
|
|
60
|
+
const workingFolder = path.resolve(where.workingFolder)
|
|
61
|
+
|
|
62
|
+
// A second server or watcher is the hazard this whole surface exists to
|
|
63
|
+
// remove, and it is the one shape that cannot be answered by forwarding.
|
|
64
|
+
// So it stops, rather than silently doing something else.
|
|
65
|
+
if (where.attach !== false && where.longRunning) {
|
|
66
|
+
if (await isInstanceLive(workingFolder)) {
|
|
67
|
+
process.stderr.write(
|
|
68
|
+
'mikser: another mikser is already running in this folder, and a server or watcher cannot be '
|
|
69
|
+
+ 'forwarded to it — it would have to open a port on your behalf.\n'
|
|
70
|
+
+ 'Stop that one, or pass --no-attach to run a second engine here (two engines share the '
|
|
71
|
+
+ 'catalogue and the output tree, with no lock between them).\n')
|
|
72
|
+
process.exit(1)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (where.attach !== false && !where.longRunning) {
|
|
44
77
|
const code = await forward({
|
|
45
|
-
workingFolder
|
|
78
|
+
workingFolder,
|
|
46
79
|
config: path.resolve(where.workingFolder, where.config),
|
|
47
|
-
|
|
80
|
+
request: where.request,
|
|
48
81
|
})
|
|
49
82
|
// null means nobody was listening — carry on exactly as before.
|
|
50
83
|
if (code !== null) process.exit(code)
|
package/docs/diagnostics.md
CHANGED
|
@@ -756,9 +756,21 @@ Three things it refuses or reports rather than guessing:
|
|
|
756
756
|
- **A config that moved under the instance.** It stats every file in
|
|
757
757
|
`config.files` and tells you to restart rather than building with a config
|
|
758
758
|
you have since edited.
|
|
759
|
+
- **A second server or watcher.** `mikser --server` and `mikser --watch` ask
|
|
760
|
+
to *become* the instance, which is not something a running one can do for
|
|
761
|
+
you — it would have to open a port in your process. They exit 1 and say so,
|
|
762
|
+
rather than building and leaving nothing on the port.
|
|
759
763
|
- **A folder held by someone else.** `--no-attach` runs a private engine
|
|
760
764
|
anyway — for checking that a cold start works — and says the folder is held.
|
|
761
765
|
|
|
766
|
+
`--tool`, `--tools`, `--verify` and `--explain` forward as well, and for a
|
|
767
|
+
different reason than builds do. They only read, so running one locally never
|
|
768
|
+
damaged anything — it just could not be trusted: on a large site a local
|
|
769
|
+
`--verify` reads a catalogue the instance is halfway through writing and
|
|
770
|
+
reports drift that is a cycle in progress. The instance has the settled state
|
|
771
|
+
and the config that produced it. Exit codes cross the socket unchanged, so
|
|
772
|
+
`--explain` still answers 3 for an entity that is not there.
|
|
773
|
+
|
|
762
774
|
A forwarded build **rescans**; it does not drain what the watcher happened to
|
|
763
775
|
queue. A client that writes a file and immediately asks can beat the file
|
|
764
776
|
event, and draining would then build without the change that prompted the
|
|
@@ -838,10 +850,11 @@ surfaces that turn silence into a statement:
|
|
|
838
850
|
- **A pattern or query that matched nothing** — several plugins warn on
|
|
839
851
|
this now (layout patterns, preset matching, null filters). The warnings
|
|
840
852
|
carry stable `code`s in `--json`.
|
|
841
|
-
- **Output that does not match the config** — the config's
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
853
|
+
- **Output that does not match the config** — the config's checksum
|
|
854
|
+
covers the local module graph it imports, not just its own bytes, so
|
|
855
|
+
editing a helper the config pulls in forces a rebuild the same way
|
|
856
|
+
editing the config does. A change *outside* that graph — inside an npm
|
|
857
|
+
dependency — still does not, so use `--force` after upgrading one.
|
|
845
858
|
- **A tool that answers emptily because it is broken** — check
|
|
846
859
|
[`faults`](#faults) before reading an empty result as a fact about the
|
|
847
860
|
site. This is the one case where the answer and the failure are the same
|
|
@@ -849,6 +862,21 @@ surfaces that turn silence into a statement:
|
|
|
849
862
|
- **A plugin that appears to do nothing** — `No plugins loaded` with a
|
|
850
863
|
config present is a warning naming the file. A config that fails to
|
|
851
864
|
load now exits non-zero rather than loading as empty.
|
|
865
|
+
- **A postprocess that could not run** — a stage whose external
|
|
866
|
+
dependency is absent (no chrome for a PDF, no binary for a conversion)
|
|
867
|
+
reports a fault naming the subsystem, and each page that wanted that
|
|
868
|
+
output fails as an ordinary render error. The fault says why once; the
|
|
869
|
+
errors say who, and set the exit code. A missing dependency does not
|
|
870
|
+
fail the whole build — the HTML is already written and correct — but it
|
|
871
|
+
does not pass as clean either.
|
|
872
|
+
- **A link to a file nothing produced** — helpers like `asset()` and
|
|
873
|
+
`resource()` *build* a URL from a naming convention rather than looking
|
|
874
|
+
an entity up, so they cannot fail: a preset that never ran, or a
|
|
875
|
+
template naming an extension the preset no longer emits, yields a
|
|
876
|
+
well-formed URL to nothing. Every such call is recorded on the render
|
|
877
|
+
track and checked against the output folder at finalize; what is missing
|
|
878
|
+
is warned under `asset-missing`, naming the path and the pages that
|
|
879
|
+
linked it.
|
|
852
880
|
|
|
853
881
|
## See also
|
|
854
882
|
|
package/docs/rendering.md
CHANGED
|
@@ -375,6 +375,25 @@ Generates relative URLs to transformed assets (output of the assets plugin). Ret
|
|
|
375
375
|
|
|
376
376
|
The underlying path is: `/{assetsFolder}/{preset}/{path}[.{format}]`
|
|
377
377
|
|
|
378
|
+
#### Checked against the output
|
|
379
|
+
|
|
380
|
+
The path is *built* from that convention, not looked up, so the helper
|
|
381
|
+
cannot fail — it returns a well-formed URL whether or not anything ever
|
|
382
|
+
produced the file. A preset that did not run, a source that is not
|
|
383
|
+
matched by the preset's patterns, or a template naming a format the
|
|
384
|
+
preset no longer emits all yield a link to nothing, on a build that
|
|
385
|
+
reports success.
|
|
386
|
+
|
|
387
|
+
So every `asset()` and `resource()` call records its destination on the
|
|
388
|
+
render track, and at finalize the engine checks each one against the
|
|
389
|
+
output folder. Anything missing is warned under the `asset-missing` code,
|
|
390
|
+
naming the path and the pages that linked it, with a summary under
|
|
391
|
+
`asset-missing-summary`. The check follows symlinks, so an assets folder
|
|
392
|
+
deployed into the output as a link resolves normally.
|
|
393
|
+
|
|
394
|
+
It is a warning, not an error: a build can legitimately link a file that
|
|
395
|
+
some later step supplies. What it removes is the silence.
|
|
396
|
+
|
|
378
397
|
---
|
|
379
398
|
|
|
380
399
|
### `render-resource` — CDN Resource Mapping
|
|
@@ -542,6 +561,16 @@ Return shapes the dispatcher accepts:
|
|
|
542
561
|
|
|
543
562
|
A chain is all-or-nothing. The first stage to throw or return `{success: false}` fails the entry; the dispatcher unlinks every intermediate it produced and the final destination, then re-throws. No partial output leaks.
|
|
544
563
|
|
|
564
|
+
The renderer's own output (`entity.origin`) is the exception: on success the
|
|
565
|
+
dispatcher unlinks it, on failure it stays. It is the input a retry needs, and
|
|
566
|
+
for a converter it is content in its own right — the HTML of a report whose PDF
|
|
567
|
+
could not be produced.
|
|
568
|
+
|
|
569
|
+
A failed entry counts as a **render error**: it lands in `errors` under `--json`
|
|
570
|
+
with the failing `postprocessor` named, and sets the exit code. It wrote no
|
|
571
|
+
file, so it has to count the same as a render that threw — otherwise a build
|
|
572
|
+
missing every PDF it was asked for reports success.
|
|
573
|
+
|
|
545
574
|
### Execution mode
|
|
546
575
|
|
|
547
576
|
Postprocess inherits the layout's `task:` setting. `task: worker` routes both render and postprocess through the lazy Piscina pool (right for CPU-heavy stages: PDF, image compose, big MJML compilations). Default INLINE is right for everything else.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mikser-io",
|
|
3
|
-
"version": "9.
|
|
3
|
+
"version": "9.70.0",
|
|
4
4
|
"description": "A mixer for content: entities in, configurable render pipelines, outputs of any kind. Static sites are the canonical recipe, not the definition — the same engine renders PDFs, emails and whatever a renderer plugin produces. Files are the source of truth, every lifecycle phase is observable, and the build graph is queryable by an agent.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"exports": {
|
package/src/engine.js
CHANGED
|
@@ -11,7 +11,7 @@ import { useJournal, updateEntry } from './journal.js'
|
|
|
11
11
|
import { globby } from 'globby'
|
|
12
12
|
import { OPERATION, TASKS } from './constants.js'
|
|
13
13
|
import { changeExtension, formatErrorContext, projectMeta, lookupKeys } from './utils.js'
|
|
14
|
-
import { reportRendered, reportSkipped, reportError, renderErrorCount, emitReport, finishCycle } from './report.js'
|
|
14
|
+
import { reportRendered, reportSkipped, reportError, renderErrorCount, emitReport, finishCycle, reportAssetUse, assetUse } from './report.js'
|
|
15
15
|
import { toolSchemas, invokeTool, toolResultText, toolResultFailed } from './tools.js'
|
|
16
16
|
import { registerBuiltinTools } from './builtin-tools.js'
|
|
17
17
|
import { useDatabase } from './database/index.js'
|
|
@@ -89,6 +89,164 @@ function workerSafeOptions(opts) {
|
|
|
89
89
|
return result
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
// Warn for anything a render linked to that is not in the output.
|
|
93
|
+
//
|
|
94
|
+
// Deliberately phrased as what was OBSERVED. Only entities that rendered this
|
|
95
|
+
// cycle recorded anything, so an incremental build checks the pages it built
|
|
96
|
+
// and says nothing about the rest — the same reasoning the assets plugin
|
|
97
|
+
// already applies to its preset warning, and for the same reason: a warning
|
|
98
|
+
// that overclaims gets filtered, and the filtered-out line is the real one.
|
|
99
|
+
async function reportMissingAssets(logger) {
|
|
100
|
+
const used = assetUse()
|
|
101
|
+
if (!used.length) return
|
|
102
|
+
const outputFolder = runtime.options.outputFolder
|
|
103
|
+
if (!outputFolder) return
|
|
104
|
+
|
|
105
|
+
const missing = []
|
|
106
|
+
for (const [destination, ids] of used) {
|
|
107
|
+
const file = path.join(outputFolder, destination.replace(/^\//, ''))
|
|
108
|
+
if (!existsSync(file)) missing.push([destination, ids])
|
|
109
|
+
}
|
|
110
|
+
if (!missing.length) return
|
|
111
|
+
|
|
112
|
+
// Capped, with the total alongside. One broken preset can be referenced by
|
|
113
|
+
// every page on the site, and a thousand lines of it buries whatever else
|
|
114
|
+
// the build said.
|
|
115
|
+
const SHOWN = 10
|
|
116
|
+
for (const [destination, ids] of missing.slice(0, SHOWN)) {
|
|
117
|
+
logger.warn({ code: 'asset-missing', destination, referencedBy: ids },
|
|
118
|
+
'Linked but not in the output: %s — referenced by %s', destination,
|
|
119
|
+
ids.slice(0, 3).join(', ') + (ids.length > 3 ? ` and ${ids.length - 3} more` : ''))
|
|
120
|
+
}
|
|
121
|
+
logger.warn({ code: 'asset-missing-summary', missing: missing.length, checked: used.length },
|
|
122
|
+
'%d of %d linked file(s) are not in the output%s. A URL helper builds the path rather than looking it '
|
|
123
|
+
+ 'up, so this is a link to something nothing produced — usually a preset that did not run, or a '
|
|
124
|
+
+ 'template naming an extension the preset no longer emits.',
|
|
125
|
+
missing.length, used.length,
|
|
126
|
+
missing.length > SHOWN ? `, ${SHOWN} shown` : '')
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// The report-only commands, as functions that RETURN their exit code.
|
|
130
|
+
//
|
|
131
|
+
// They used to be inline here and call process.exit, which is fine for a
|
|
132
|
+
// process whose only job is to answer one question and stop. It is not fine
|
|
133
|
+
// for the instance that has to answer the same question on behalf of a client
|
|
134
|
+
// and stay alive — and answering it there is the point, because a local run
|
|
135
|
+
// reads a catalogue another process is in the middle of writing.
|
|
136
|
+
//
|
|
137
|
+
// `request` carries the CLIENT's arguments. Reading runtime.options here would
|
|
138
|
+
// answer with the instance's own flags, which are whatever it happened to be
|
|
139
|
+
// started with.
|
|
140
|
+
export async function runReportOnly(request = {}) {
|
|
141
|
+
const logger = useLogger()
|
|
142
|
+
const {
|
|
143
|
+
tools = runtime.options.tools,
|
|
144
|
+
tool = runtime.options.tool,
|
|
145
|
+
toolArgs = runtime.options.toolArgs,
|
|
146
|
+
json = runtime.options.json,
|
|
147
|
+
explain = runtime.options.explain,
|
|
148
|
+
verify = runtime.options.verify,
|
|
149
|
+
} = request
|
|
150
|
+
|
|
151
|
+
if (tools) {
|
|
152
|
+
const schemas = toolSchemas()
|
|
153
|
+
if (json) {
|
|
154
|
+
process.stdout.write(JSON.stringify(schemas, null, 2) + '\n')
|
|
155
|
+
} else if (!schemas.length) {
|
|
156
|
+
logger.warn('No tools registered. The mcp plugin registers the standard set; '
|
|
157
|
+
+ 'this flag only lists and invokes what is registered.')
|
|
158
|
+
} else {
|
|
159
|
+
for (const schema of schemas) {
|
|
160
|
+
process.stdout.write(`${schema.name}\n ${String(schema.description).split('\n')[0]}\n`)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return 0
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (tool) {
|
|
167
|
+
// An empty catalog answers every question with a confident nothing —
|
|
168
|
+
// `null`, `total: 0`, "no render claims this destination" — all of
|
|
169
|
+
// which read as "the thing you asked about does not exist" when the
|
|
170
|
+
// truth is "nothing has been built here yet". Said once, before the
|
|
171
|
+
// answer, so it cannot be missed.
|
|
172
|
+
const entityCount = (() => {
|
|
173
|
+
try {
|
|
174
|
+
return useDatabase().handle
|
|
175
|
+
.prepare('SELECT count(*) AS n FROM mikser_entities').get()?.n ?? 0
|
|
176
|
+
} catch { return null }
|
|
177
|
+
})()
|
|
178
|
+
if (entityCount === 0 && !runtime.manifest?.size?.()) {
|
|
179
|
+
logger.warn('The catalog and manifest are empty — no build has run in this working '
|
|
180
|
+
+ 'folder. Tools answer from what the last build recorded, so this one will '
|
|
181
|
+
+ 'report nothing found. Run a build first.')
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
let args = {}
|
|
185
|
+
if (toolArgs) {
|
|
186
|
+
try {
|
|
187
|
+
args = JSON.parse(toolArgs)
|
|
188
|
+
} catch (err) {
|
|
189
|
+
logger.error('--tool-args is not valid JSON: %s', err.message)
|
|
190
|
+
return 3
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
let result
|
|
194
|
+
try {
|
|
195
|
+
result = await invokeTool(tool, args)
|
|
196
|
+
} catch (err) {
|
|
197
|
+
logger.error('%s', err.message)
|
|
198
|
+
return 3
|
|
199
|
+
}
|
|
200
|
+
process.stdout.write(toolResultText(result) + '\n')
|
|
201
|
+
// A tool that reports failure must not exit 0 — an agent reading CLI
|
|
202
|
+
// output has only the exit code to branch on.
|
|
203
|
+
return toolResultFailed(result) ? 1 : 0
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (explain) {
|
|
207
|
+
// Exit codes:
|
|
208
|
+
// 0 — the entity was found and described
|
|
209
|
+
// 3 — not in the catalog (distinct from --verify's 1/2, which are
|
|
210
|
+
// about output drift; "no such entity" is neither clean nor
|
|
211
|
+
// corrupt, it is a question that could not be answered)
|
|
212
|
+
const { explain: explainEntity, formatExplain } = await import('./explain.js')
|
|
213
|
+
const report = await explainEntity(explain)
|
|
214
|
+
process.stdout.write((json ? JSON.stringify(report, null, 2) : formatExplain(report)) + '\n')
|
|
215
|
+
return report.found ? 0 : 3
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (verify) {
|
|
219
|
+
if (!runtime.manifest) {
|
|
220
|
+
logger.error('Verify: no manifest available — nothing to check against')
|
|
221
|
+
return 2
|
|
222
|
+
}
|
|
223
|
+
const { verdict, missing, mismatched, unverifiable, orphaned, collisions } =
|
|
224
|
+
await runtime.manifest.verify()
|
|
225
|
+
const total = runtime.manifest.size()
|
|
226
|
+
|
|
227
|
+
for (const e of missing) logger.error('Missing: %s (entity %s)', e.destination, e.id)
|
|
228
|
+
for (const e of mismatched) logger.error('Mismatched: %s (entity %s)%s', e.destination, e.id,
|
|
229
|
+
e.writtenBy ? ` — the bytes on disk are ${e.writtenBy}'s` : '')
|
|
230
|
+
for (const e of unverifiable) logger.warn('No hash: %s (entity %s)', e.destination, e.id)
|
|
231
|
+
for (const e of orphaned) logger.warn('Orphan: %s', e.path)
|
|
232
|
+
// Named per destination: "two entities write here" is only actionable
|
|
233
|
+
// if you know which two.
|
|
234
|
+
for (const c of collisions) logger.warn('Collision: %s ← %s', c.destination, c.entities.join(', '))
|
|
235
|
+
|
|
236
|
+
// Level picked from the verdict, because the level IS the marker in
|
|
237
|
+
// pino-pretty's messageFormat: notice renders 🟢, warn 🟡, error 🔴. A
|
|
238
|
+
// fixed `notice` prints a green tick next to the word FAIL, which
|
|
239
|
+
// reads as success at a glance even though the exit code is right.
|
|
240
|
+
const report = verdict === 'FAIL' ? logger.error : verdict === 'WARN' ? logger.warn : logger.notice
|
|
241
|
+
report.call(logger,
|
|
242
|
+
'Verify %s: %d snapshots, %d missing, %d mismatched, %d unverifiable, %d orphaned, %d collisions',
|
|
243
|
+
verdict, total, missing.length, mismatched.length, unverifiable.length, orphaned.length, collisions.length)
|
|
244
|
+
return verdict === 'FAIL' ? 2 : verdict === 'WARN' ? 1 : 0
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return null // not a report-only request
|
|
248
|
+
}
|
|
249
|
+
|
|
92
250
|
export async function setup(options) {
|
|
93
251
|
runtime.options.threads = options?.threads !== undefined ? options.threads : 4
|
|
94
252
|
runtime.engine = {
|
|
@@ -291,57 +449,8 @@ export async function setup(options) {
|
|
|
291
449
|
// registry is complete. Nothing is imported, because this exits first,
|
|
292
450
|
// the same way --explain and --verify do.
|
|
293
451
|
if (runtime.options.tools || runtime.options.tool) {
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
if (runtime.options.json) {
|
|
297
|
-
process.stdout.write(JSON.stringify(schemas, null, 2) + '\n')
|
|
298
|
-
} else if (!schemas.length) {
|
|
299
|
-
logger.warn('No tools registered. The mcp plugin registers the standard set; '
|
|
300
|
-
+ 'this flag only lists and invokes what is registered.')
|
|
301
|
-
} else {
|
|
302
|
-
for (const schema of schemas) {
|
|
303
|
-
process.stdout.write(`${schema.name}\n ${String(schema.description).split('\n')[0]}\n`)
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
process.exit(0)
|
|
307
|
-
}
|
|
308
|
-
// An empty catalog answers every question with a confident
|
|
309
|
-
// nothing — `null`, `total: 0`, "no render claims this
|
|
310
|
-
// destination" — all of which read as "the thing you asked about
|
|
311
|
-
// does not exist" when the truth is "nothing has been built here
|
|
312
|
-
// yet". Said once, before the answer, so it cannot be missed.
|
|
313
|
-
const entityCount = (() => {
|
|
314
|
-
try {
|
|
315
|
-
return useDatabase().handle
|
|
316
|
-
.prepare('SELECT count(*) AS n FROM mikser_entities').get()?.n ?? 0
|
|
317
|
-
} catch { return null }
|
|
318
|
-
})()
|
|
319
|
-
if (entityCount === 0 && !runtime.manifest?.size?.()) {
|
|
320
|
-
logger.warn('The catalog and manifest are empty — no build has run in this working '
|
|
321
|
-
+ 'folder. Tools answer from what the last build recorded, so this one will '
|
|
322
|
-
+ 'report nothing found. Run a build first.')
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
let args = {}
|
|
326
|
-
if (runtime.options.toolArgs) {
|
|
327
|
-
try {
|
|
328
|
-
args = JSON.parse(runtime.options.toolArgs)
|
|
329
|
-
} catch (err) {
|
|
330
|
-
logger.error('--tool-args is not valid JSON: %s', err.message)
|
|
331
|
-
process.exit(3)
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
let result
|
|
335
|
-
try {
|
|
336
|
-
result = await invokeTool(runtime.options.tool, args)
|
|
337
|
-
} catch (err) {
|
|
338
|
-
logger.error('%s', err.message)
|
|
339
|
-
process.exit(3)
|
|
340
|
-
}
|
|
341
|
-
process.stdout.write(toolResultText(result) + '\n')
|
|
342
|
-
// A tool that reports failure must not exit 0 — an agent reading
|
|
343
|
-
// CLI output has only the exit code to branch on.
|
|
344
|
-
process.exit(toolResultFailed(result) ? 1 : 0)
|
|
452
|
+
const code = await runReportOnly()
|
|
453
|
+
if (code !== null) process.exit(code)
|
|
345
454
|
}
|
|
346
455
|
})
|
|
347
456
|
|
|
@@ -368,58 +477,11 @@ export async function setup(options) {
|
|
|
368
477
|
// 3 — not in the catalog (distinct from --verify's 1/2, which are
|
|
369
478
|
// about output drift; "no such entity" is neither clean nor
|
|
370
479
|
// corrupt, it is a question that could not be answered)
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
} else {
|
|
377
|
-
process.stdout.write(formatExplain(report) + '\n')
|
|
378
|
-
}
|
|
379
|
-
process.exit(report.found ? 0 : 3)
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
if (runtime.options.explain) {
|
|
383
|
-
const { explain, formatExplain } = await import('./explain.js')
|
|
384
|
-
const report = await explain(runtime.options.explain)
|
|
385
|
-
if (runtime.options.json) {
|
|
386
|
-
process.stdout.write(JSON.stringify(report, null, 2) + '\n')
|
|
387
|
-
} else {
|
|
388
|
-
process.stdout.write(formatExplain(report) + '\n')
|
|
389
|
-
}
|
|
390
|
-
process.exit(report.found ? 0 : 3)
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
if (runtime.options.verify) {
|
|
395
|
-
if (!runtime.manifest) {
|
|
396
|
-
logger.error('Verify: no manifest available — nothing to check against')
|
|
397
|
-
process.exit(2)
|
|
398
|
-
}
|
|
399
|
-
const { verdict, missing, mismatched, unverifiable, orphaned, collisions } =
|
|
400
|
-
await runtime.manifest.verify()
|
|
401
|
-
const total = runtime.manifest.size()
|
|
402
|
-
|
|
403
|
-
for (const e of missing) logger.error('Missing: %s (entity %s)', e.destination, e.id)
|
|
404
|
-
for (const e of mismatched) logger.error('Mismatched: %s (entity %s)%s', e.destination, e.id,
|
|
405
|
-
e.writtenBy ? ` — the bytes on disk are ${e.writtenBy}'s` : '')
|
|
406
|
-
for (const e of unverifiable) logger.warn('No hash: %s (entity %s)', e.destination, e.id)
|
|
407
|
-
for (const e of orphaned) logger.warn('Orphan: %s', e.path)
|
|
408
|
-
// Named per destination: "two entities write here" is only
|
|
409
|
-
// actionable if you know which two.
|
|
410
|
-
for (const c of collisions) logger.warn('Collision: %s ← %s', c.destination, c.entities.join(', '))
|
|
411
|
-
|
|
412
|
-
// Level picked from the verdict, because the level IS the marker
|
|
413
|
-
// in pino-pretty's messageFormat: notice renders 🟢, warn 🟡,
|
|
414
|
-
// error 🔴. A fixed `notice` prints a green tick next to the word
|
|
415
|
-
// FAIL, which reads as success at a glance even though the exit
|
|
416
|
-
// code is right.
|
|
417
|
-
const report = verdict === 'FAIL' ? logger.error : verdict === 'WARN' ? logger.warn : logger.notice
|
|
418
|
-
report.call(logger,
|
|
419
|
-
'Verify %s: %d snapshots, %d missing, %d mismatched, %d unverifiable, %d orphaned, %d collisions',
|
|
420
|
-
verdict, total, missing.length, mismatched.length, unverifiable.length, orphaned.length, collisions.length)
|
|
421
|
-
process.exit(verdict === 'FAIL' ? 2 : verdict === 'WARN' ? 1 : 0)
|
|
422
|
-
}
|
|
480
|
+
// The same three commands the instance answers over the socket —
|
|
481
|
+
// one implementation, so a forwarded --verify cannot disagree with a
|
|
482
|
+
// local one about what it checked.
|
|
483
|
+
const code = await runReportOnly()
|
|
484
|
+
if (code !== null) process.exit(code)
|
|
423
485
|
})
|
|
424
486
|
|
|
425
487
|
onRender(async (signal) => {
|
|
@@ -652,6 +714,15 @@ export async function setup(options) {
|
|
|
652
714
|
result = rendered?.output
|
|
653
715
|
if (rendered?.track) mergeTrack(track, rendered.track)
|
|
654
716
|
|
|
717
|
+
// Which files this entity's template linked to. Harvested
|
|
718
|
+
// here because this is the one place that has both the
|
|
719
|
+
// track and the entity that produced it — a worker's track
|
|
720
|
+
// has just been folded in, so a worker render is covered
|
|
721
|
+
// the same as an inline one.
|
|
722
|
+
for (const destination of track.assets ?? []) {
|
|
723
|
+
reportAssetUse(destination, renderEntity.id)
|
|
724
|
+
}
|
|
725
|
+
|
|
655
726
|
if (!signal.aborted) {
|
|
656
727
|
// Meta reads ride on `output`, which is already
|
|
657
728
|
// free-form JSON on the journal row, rather than in
|
|
@@ -989,6 +1060,17 @@ export async function setup(options) {
|
|
|
989
1060
|
if (!signal.aborted) {
|
|
990
1061
|
await updateEntry({ id, output: { success: false } })
|
|
991
1062
|
logger.error('Postprocess error: %s%s %s', entity.id, formatErrorContext(entity, err, runtime.options), err.message)
|
|
1063
|
+
// A failed postprocess wrote no file, so it has to
|
|
1064
|
+
// count the same as a failed render. It did not:
|
|
1065
|
+
// this line was the whole of it, the exit code
|
|
1066
|
+
// stayed 0, and `errors` in --json never mentioned
|
|
1067
|
+
// it — a build with output missing reporting
|
|
1068
|
+
// success, which is the exact shape of bug the
|
|
1069
|
+
// rest of this file exists to make impossible.
|
|
1070
|
+
reportError(entity, err, {
|
|
1071
|
+
postprocessor: options.postprocessor ?? null,
|
|
1072
|
+
layout: entity.layout?.id ?? null,
|
|
1073
|
+
})
|
|
992
1074
|
}
|
|
993
1075
|
logger.debug('Postprocess canceled')
|
|
994
1076
|
}
|
|
@@ -1045,6 +1127,19 @@ export async function setup(options) {
|
|
|
1045
1127
|
if (failed) logger.error('Mikser completed with %d render error%s', failed, failed === 1 ? '' : 's')
|
|
1046
1128
|
else logger.notice('Mikser completed')
|
|
1047
1129
|
|
|
1130
|
+
// Is everything the templates linked to actually there?
|
|
1131
|
+
//
|
|
1132
|
+
// The URL helpers build paths; they do not resolve them. So a preset
|
|
1133
|
+
// that never ran, a library that was not copied, or a template naming
|
|
1134
|
+
// an extension the preset stopped producing all yield a well-formed
|
|
1135
|
+
// link to a file that does not exist — and the only symptom is a
|
|
1136
|
+
// missing image on the deployed site, found by a person.
|
|
1137
|
+
//
|
|
1138
|
+
// Checked at the end of the cycle because that is the first moment the
|
|
1139
|
+
// answer is stable: derivatives are produced during the cycle, so
|
|
1140
|
+
// asking any earlier would report files that were about to appear.
|
|
1141
|
+
await reportMissingAssets(useLogger())
|
|
1142
|
+
|
|
1048
1143
|
// After the cycle, and only under --json. stdout has been kept clear
|
|
1049
1144
|
// for exactly this (the logger writes to stderr under --json), so the
|
|
1050
1145
|
// document is the only thing on it and can be piped to jq.
|
package/src/instance.js
CHANGED
|
@@ -34,6 +34,7 @@ import { chmod } from 'node:fs/promises'
|
|
|
34
34
|
import runtime from './runtime.js'
|
|
35
35
|
import { onLoaded } from './lifecycle.js'
|
|
36
36
|
import { renderErrorCount } from './report.js'
|
|
37
|
+
import { runReportOnly } from './engine.js'
|
|
37
38
|
|
|
38
39
|
// Where the endpoint lives.
|
|
39
40
|
//
|
|
@@ -66,7 +67,8 @@ export function socketPath(workingFolder) {
|
|
|
66
67
|
// Newline-delimited JSON, one object per line. Deliberately boring: both ends
|
|
67
68
|
// ship together, so there is nothing to negotiate and no version to carry.
|
|
68
69
|
//
|
|
69
|
-
// → { type: 'build',
|
|
70
|
+
// → { type: 'build', config, clear }
|
|
71
|
+
// → { type: 'report', config, tool, tools, toolArgs, explain, verify, json }
|
|
70
72
|
// ← { type: 'log', chunk } (zero or more, in order)
|
|
71
73
|
// ← { type: 'done', code }
|
|
72
74
|
// ← { type: 'refused', reason, detail }
|
|
@@ -97,7 +99,7 @@ function readFrames(socket, onFrame) {
|
|
|
97
99
|
// which case the caller proceeds exactly as it always did. Called before
|
|
98
100
|
// setup(), so a forwarded command never pays for importing the config or the
|
|
99
101
|
// plugin graph, which is most of what a one-shot spends its time on.
|
|
100
|
-
export function forward({ workingFolder, config,
|
|
102
|
+
export function forward({ workingFolder, config, request }) {
|
|
101
103
|
const endpoint = socketPath(workingFolder)
|
|
102
104
|
|
|
103
105
|
return new Promise((resolve) => {
|
|
@@ -116,7 +118,7 @@ export function forward({ workingFolder, config, clear }) {
|
|
|
116
118
|
resolve(null)
|
|
117
119
|
})
|
|
118
120
|
|
|
119
|
-
socket.on('connect', () => frame(socket, {
|
|
121
|
+
socket.on('connect', () => frame(socket, { ...request, config }))
|
|
120
122
|
|
|
121
123
|
readFrames(socket, (message) => {
|
|
122
124
|
if (message.type === 'log') {
|
|
@@ -147,6 +149,35 @@ export function forward({ workingFolder, config, clear }) {
|
|
|
147
149
|
})
|
|
148
150
|
}
|
|
149
151
|
|
|
152
|
+
// Is somebody already holding this folder?
|
|
153
|
+
//
|
|
154
|
+
// Used by the commands that cannot be forwarded because they are not requests
|
|
155
|
+
// at all — `--server` and `--watch` ask to BECOME the instance. Forwarding one
|
|
156
|
+
// builds and exits, and the port never opens: the caller asked for a server on
|
|
157
|
+
// 3010, got "completed", and has nothing listening.
|
|
158
|
+
//
|
|
159
|
+
// Same stale-socket rule as forward(): connect-and-fail means nobody is there,
|
|
160
|
+
// never a wait.
|
|
161
|
+
export function isInstanceLive(workingFolder) {
|
|
162
|
+
const endpoint = socketPath(workingFolder)
|
|
163
|
+
return new Promise((resolve) => {
|
|
164
|
+
const probe = net.connect(endpoint)
|
|
165
|
+
let settled = false
|
|
166
|
+
const answer = (live) => {
|
|
167
|
+
if (settled) return
|
|
168
|
+
settled = true
|
|
169
|
+
try { probe.destroy() } catch { /* already gone */ }
|
|
170
|
+
if (!live && process.platform !== 'win32' && existsSync(endpoint)) {
|
|
171
|
+
try { unlinkSync(endpoint) } catch { /* another client won the race */ }
|
|
172
|
+
}
|
|
173
|
+
resolve(live)
|
|
174
|
+
}
|
|
175
|
+
probe.on('connect', () => answer(true))
|
|
176
|
+
probe.on('error', () => answer(false))
|
|
177
|
+
setTimeout(() => answer(false), 1000).unref?.()
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
|
|
150
181
|
// ── server ──────────────────────────────────────────────────────────────
|
|
151
182
|
|
|
152
183
|
// Requests run one at a time, to completion.
|
|
@@ -219,29 +250,50 @@ async function configStale() {
|
|
|
219
250
|
return null
|
|
220
251
|
}
|
|
221
252
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
253
|
+
// Report-only commands, answered from the live catalogue.
|
|
254
|
+
//
|
|
255
|
+
// These read; they do not write, so running them locally was safe for the
|
|
256
|
+
// FILES. It was not safe for the ANSWER. A local --verify at ten thousand
|
|
257
|
+
// pages reads a catalogue the instance is in the middle of writing and reports
|
|
258
|
+
// drift that is a half-finished cycle, and a local --tool answers from
|
|
259
|
+
// whatever the last build left rather than from what is true now.
|
|
260
|
+
//
|
|
261
|
+
// The instance has the settled state and the config that produced it, so it is
|
|
262
|
+
// the only process that can answer correctly. Same guards as a build: wrong
|
|
263
|
+
// config refuses, drifted config refuses.
|
|
264
|
+
async function serveReport(socket, request, logger) {
|
|
265
|
+
const restore = captureOutput((chunk) => frame(socket, { type: 'log', chunk }))
|
|
266
|
+
let code = 0
|
|
267
|
+
try {
|
|
268
|
+
code = await runReportOnly(request) ?? 0
|
|
269
|
+
} catch (err) {
|
|
270
|
+
logger?.error('instance: forwarded report failed — %s', err.message)
|
|
271
|
+
code = 3
|
|
272
|
+
} finally {
|
|
273
|
+
restore()
|
|
232
274
|
}
|
|
275
|
+
frame(socket, { type: 'done', code })
|
|
276
|
+
}
|
|
233
277
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
return
|
|
243
|
-
}
|
|
278
|
+
function refuseConfig(socket, request, wrongConfig) {
|
|
279
|
+
frame(socket, {
|
|
280
|
+
type: 'refused',
|
|
281
|
+
reason: `this instance is running ${wrongConfig}, and you asked for ${path.resolve(request.config)}.`,
|
|
282
|
+
detail: 'Answering would use the wrong config — the accident this refusal exists to prevent. '
|
|
283
|
+
+ 'Stop that instance, or pass --no-attach to run your own.',
|
|
284
|
+
})
|
|
285
|
+
}
|
|
244
286
|
|
|
287
|
+
function refuseStale(socket, movedFile) {
|
|
288
|
+
frame(socket, {
|
|
289
|
+
type: 'refused',
|
|
290
|
+
reason: `this instance's config changed on disk since it started (${movedFile}).`,
|
|
291
|
+
detail: 'It is still running the old one. Restart it, and this command will reach an instance that '
|
|
292
|
+
+ 'matches what you edited.',
|
|
293
|
+
})
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async function serveBuild(socket, request, logger) {
|
|
245
297
|
const restore = captureOutput((chunk) => frame(socket, { type: 'log', chunk }))
|
|
246
298
|
let code = 0
|
|
247
299
|
try {
|
|
@@ -304,8 +356,20 @@ export function serveInstance() {
|
|
|
304
356
|
server = net.createServer((socket) => {
|
|
305
357
|
socket.on('error', () => { /* client vanished mid-request */ })
|
|
306
358
|
readFrames(socket, (request) => {
|
|
307
|
-
if (request.type !== 'build') return
|
|
308
|
-
chain = chain.then(
|
|
359
|
+
if (request.type !== 'build' && request.type !== 'report') return
|
|
360
|
+
chain = chain.then(async () => {
|
|
361
|
+
// Both kinds answer for the client's config, not the
|
|
362
|
+
// instance's — a report against the wrong config is the
|
|
363
|
+
// original incident, and it is wrong whether or not it
|
|
364
|
+
// writes anything.
|
|
365
|
+
const wrongConfig = configMismatch(request.config)
|
|
366
|
+
if (wrongConfig) return refuseConfig(socket, request, wrongConfig)
|
|
367
|
+
const movedFile = await configStale()
|
|
368
|
+
if (movedFile) return refuseStale(socket, movedFile)
|
|
369
|
+
return request.type === 'build'
|
|
370
|
+
? serveBuild(socket, request, logger)
|
|
371
|
+
: serveReport(socket, request, logger)
|
|
372
|
+
}).catch(() => {})
|
|
309
373
|
})
|
|
310
374
|
})
|
|
311
375
|
server.on('error', (err) => {
|
|
@@ -374,10 +438,17 @@ export async function warnIfHeld({ workingFolder, attached }) {
|
|
|
374
438
|
export function instanceControl() {
|
|
375
439
|
serveInstance()
|
|
376
440
|
onLoaded(async () => {
|
|
377
|
-
|
|
441
|
+
// Only --no-attach reaches this now. Everything else either forwarded
|
|
442
|
+
// (a build, a report) or refused before setup ran (a second server or
|
|
443
|
+
// watcher), so a process that is still here and not attaching is one
|
|
444
|
+
// that deliberately opted out — and that is exactly the case worth
|
|
445
|
+
// saying something about, long-running or not. The earlier gate
|
|
446
|
+
// skipped watch and server, which excluded `--no-attach --server`:
|
|
447
|
+
// the very command that puts two engines on one folder.
|
|
448
|
+
if (runtime.options.attach !== false) return
|
|
378
449
|
await warnIfHeld({
|
|
379
450
|
workingFolder: runtime.options.workingFolder,
|
|
380
|
-
attached:
|
|
451
|
+
attached: false,
|
|
381
452
|
})
|
|
382
453
|
})
|
|
383
454
|
}
|
package/src/plugins/assets.js
CHANGED
|
@@ -231,6 +231,13 @@ export function assets(options = {}) {
|
|
|
231
231
|
for (let entityPreset of assetsMap[entityToRender.id] || []) {
|
|
232
232
|
const entity = _.cloneDeep(entityToRender)
|
|
233
233
|
entity.preset = presets[entityPreset]
|
|
234
|
+
// Named in config but with no module in presets/ and no
|
|
235
|
+
// mikser-io-preset-* package: there is nothing to render with.
|
|
236
|
+
// Loading already reported this preset ('Preset not found'),
|
|
237
|
+
// and the finalize check names the links left dangling. A line
|
|
238
|
+
// per entity here would just be that fact a third time, once
|
|
239
|
+
// per matched file.
|
|
240
|
+
if (!entity.preset) continue
|
|
234
241
|
// Per-preset config options override the preset module's
|
|
235
242
|
// defaults. Looked up at render time so config edits are
|
|
236
243
|
// picked up on the next cycle without rebuilding the
|
|
@@ -15,7 +15,7 @@ import { changeExtension } from '../../utils.js'
|
|
|
15
15
|
// derivative may simply not have been generated, and this cannot tell.
|
|
16
16
|
// `meta.presets` (ADR-0011) is the looked-up answer where a caller has the
|
|
17
17
|
// entity; this helper exists for the case where they have a path.
|
|
18
|
-
export function load({ runtime, entity, state, options, logger }) {
|
|
18
|
+
export function load({ runtime, entity, state, options, logger, track }) {
|
|
19
19
|
const presets = state?.assets?.presets ?? {}
|
|
20
20
|
const warned = new Set()
|
|
21
21
|
const warnOnce = (key, code, message, ...args) => {
|
|
@@ -27,6 +27,15 @@ export function load({ runtime, entity, state, options, logger }) {
|
|
|
27
27
|
runtime.asset = (preset, url, format) => {
|
|
28
28
|
if (url[0] != '/') url = `/${url}`
|
|
29
29
|
|
|
30
|
+
// Handlebars appends its own options object to every helper call, so a
|
|
31
|
+
// two-argument `{{asset 'web' '/media/hero.jpg'}}` arrives here with a
|
|
32
|
+
// third. Taken as a format it stringifies, and the helper cheerfully
|
|
33
|
+
// built `hero.[object Object]` — a well-formed link to a file that
|
|
34
|
+
// could never exist. file.js strips it for the same reason; this one
|
|
35
|
+
// never did, and nothing noticed until the output check started
|
|
36
|
+
// looking at what these URLs point at.
|
|
37
|
+
if (format && typeof format === 'object' && 'hash' in format) format = undefined
|
|
38
|
+
|
|
30
39
|
const declared = presets[preset]?.format
|
|
31
40
|
|
|
32
41
|
// A preset name nothing declares. The URL still gets built — it is a
|
|
@@ -57,6 +66,11 @@ export function load({ runtime, entity, state, options, logger }) {
|
|
|
57
66
|
// happened to be rendering.
|
|
58
67
|
const relative = `${state.assets.assetsFolder}/${preset}${effective ? changeExtension(url, effective) : url}`
|
|
59
68
|
const destination = '/' + relative
|
|
69
|
+
// Recorded so the engine can check at the end of the cycle whether
|
|
70
|
+
// this file exists. It is the one thing this helper cannot answer for
|
|
71
|
+
// itself — it takes a path, not an entity, so there is nothing to look
|
|
72
|
+
// up and the URL is well-formed whether or not anything produced it.
|
|
73
|
+
track?.asset?.(destination)
|
|
60
74
|
const from = path.dirname(entity.destination || '/')
|
|
61
75
|
return { url: path.relative(from, destination) }
|
|
62
76
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import path from 'node:path'
|
|
2
2
|
|
|
3
|
-
export function load({ runtime, entity, state, options }) {
|
|
3
|
+
export function load({ runtime, entity, state, options, track }) {
|
|
4
4
|
runtime.resource = (url) => {
|
|
5
5
|
const { resourceLib } = state.resources
|
|
6
6
|
for (let library in resourceLib) {
|
|
@@ -9,6 +9,10 @@ export function load({ runtime, entity, state, options }) {
|
|
|
9
9
|
const name = url.replace(origin, `${resourceLib[library]}`)
|
|
10
10
|
const relative = url.replace(origin, `${state.resources.resourcesFolder}/${resourceLib[library]}`)
|
|
11
11
|
const destination = '/' + relative
|
|
12
|
+
// Same reason as the asset helper: this builds a URL rather
|
|
13
|
+
// than resolving one, so a library that was never copied
|
|
14
|
+
// yields a link to nothing on a green build.
|
|
15
|
+
track?.asset?.(destination)
|
|
12
16
|
const from = path.dirname(entity.destination || '/')
|
|
13
17
|
return { url: path.relative(from, destination), name }
|
|
14
18
|
}
|
package/src/report.js
CHANGED
|
@@ -115,6 +115,10 @@ export function resetReport() {
|
|
|
115
115
|
runtime.state.renderErrors = []
|
|
116
116
|
// Per cycle, unlike the wipe: what changed is a fact about THIS build.
|
|
117
117
|
runtime.state.changed = { ids: [], count: 0 }
|
|
118
|
+
// Per cycle too: an asset referenced by a page that did not re-render this
|
|
119
|
+
// time was not re-checked, and claiming otherwise would be the kind of
|
|
120
|
+
// completeness this codebase keeps having to walk back.
|
|
121
|
+
runtime.state.assetUse = new Map()
|
|
118
122
|
}
|
|
119
123
|
|
|
120
124
|
// Published on the runtime so runtime.js can start a fresh cycle for a
|
|
@@ -209,6 +213,38 @@ export function reportEvaluated(scope, { evaluated, of } = {}) {
|
|
|
209
213
|
store().evaluated[scope] = { evaluated: evaluated ?? 0, ...(Number.isFinite(of) ? { of } : {}) }
|
|
210
214
|
}
|
|
211
215
|
|
|
216
|
+
// Files a render linked to, and who linked to them.
|
|
217
|
+
//
|
|
218
|
+
// The URL helpers build paths rather than resolving them, so nothing they hand
|
|
219
|
+
// a template has been checked against a file: a preset that never ran, or one
|
|
220
|
+
// whose format changed under a template still naming the old extension,
|
|
221
|
+
// produces a well-formed link to nothing. The page renders, the build is
|
|
222
|
+
// green, and the image is missing until somebody looks at the site.
|
|
223
|
+
//
|
|
224
|
+
// Keyed by destination with the referencing entities attached, because "this
|
|
225
|
+
// file is missing" is only actionable next to "and these pages point at it".
|
|
226
|
+
//
|
|
227
|
+
// NOT gated on reportWanted(): the check that reads this runs on every build,
|
|
228
|
+
// and gating it would make a broken link visible only to someone who already
|
|
229
|
+
// suspected one.
|
|
230
|
+
export function reportAssetUse(destination, entityId) {
|
|
231
|
+
if (!destination) return
|
|
232
|
+
const store = assetUseStore()
|
|
233
|
+
if (!store.has(destination)) store.set(destination, new Set())
|
|
234
|
+
if (entityId) store.get(destination).add(entityId)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function assetUseStore() {
|
|
238
|
+
runtime.state ??= {}
|
|
239
|
+
runtime.state.assetUse ??= new Map()
|
|
240
|
+
return runtime.state.assetUse
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Everything referenced this cycle, as [destination, [entity ids]].
|
|
244
|
+
export function assetUse() {
|
|
245
|
+
return [...assetUseStore()].map(([destination, ids]) => [destination, [...ids]])
|
|
246
|
+
}
|
|
247
|
+
|
|
212
248
|
// An entity whose SOURCE did not change is gated at import and never becomes
|
|
213
249
|
// a render task at all — so it appears in neither `rendered` nor `skipped`,
|
|
214
250
|
// and the two lists would not reconcile with the corpus size without saying
|
package/src/track.js
CHANGED
|
@@ -40,7 +40,7 @@ export function filterKey(filter) {
|
|
|
40
40
|
// The returned object exposes `partials: Set<string>` and
|
|
41
41
|
// `queries: Array<filter | null>` directly. Consumers iterate either
|
|
42
42
|
// shape; both are owned by the track for the lifetime of the run.
|
|
43
|
-
export function createTrack({ partial = true, query = true, lookup = true, meta = false, consumed = false } = {}) {
|
|
43
|
+
export function createTrack({ partial = true, query = true, lookup = true, meta = false, consumed = false, assets = true } = {}) {
|
|
44
44
|
const track = {}
|
|
45
45
|
if (lookup) {
|
|
46
46
|
// Lookups a TEMPLATE made by name: runtime.href('/contacts'),
|
|
@@ -122,6 +122,26 @@ export function createTrack({ partial = true, query = true, lookup = true, meta
|
|
|
122
122
|
paths.add(path)
|
|
123
123
|
}
|
|
124
124
|
}
|
|
125
|
+
if (assets) {
|
|
126
|
+
// Files a template asked for by URL: a preset derivative, a resource
|
|
127
|
+
// from a library. Recorded so the engine can check at the end of the
|
|
128
|
+
// cycle whether the thing being linked to is actually in the output.
|
|
129
|
+
//
|
|
130
|
+
// The helpers BUILD these paths — nothing they return has been checked
|
|
131
|
+
// against a file — so a preset that never ran, or one whose format
|
|
132
|
+
// changed, produces a perfectly well-formed link to nothing. The page
|
|
133
|
+
// renders, the build is green, and the image is missing until a person
|
|
134
|
+
// notices.
|
|
135
|
+
//
|
|
136
|
+
// The output-relative destination, not the page-relative url the
|
|
137
|
+
// helper returns: `../../assets/web/hero.webp` cannot be resolved
|
|
138
|
+
// without knowing which page asked, and this is the form that maps
|
|
139
|
+
// straight onto a path under the output folder.
|
|
140
|
+
const assetRefs = new Set()
|
|
141
|
+
track.assets = assetRefs
|
|
142
|
+
track.asset = (destination) => { if (destination) assetRefs.add(destination) }
|
|
143
|
+
}
|
|
144
|
+
|
|
125
145
|
if (query) {
|
|
126
146
|
const queries = []
|
|
127
147
|
const queryKeys = new Set()
|
|
@@ -282,6 +302,7 @@ export function serializeTrack(track) {
|
|
|
282
302
|
partials: track.partials ? [...track.partials] : undefined,
|
|
283
303
|
queries: track.queries ? [...track.queries] : undefined,
|
|
284
304
|
metaReads: track.metaReads ? [...track.metaReads] : undefined,
|
|
305
|
+
assets: track.assets ? [...track.assets] : undefined,
|
|
285
306
|
lookups: track.lookups ? [...track.lookups].map(([k, v]) => [k, [...v]]) : undefined,
|
|
286
307
|
consumedReads: track.consumedReads
|
|
287
308
|
? [...track.consumedReads].map(([k, v]) => [k, [...v]]) : undefined,
|
|
@@ -296,6 +317,7 @@ export function mergeTrack(target, data) {
|
|
|
296
317
|
for (const p of data.partials ?? []) target.partial?.(p)
|
|
297
318
|
for (const q of data.queries ?? []) target.query?.(q)
|
|
298
319
|
for (const m of data.metaReads ?? []) target.metaRead?.(m)
|
|
320
|
+
for (const a of data.assets ?? []) target.asset?.(a)
|
|
299
321
|
for (const [name, ids] of data.lookups ?? []) target.lookup?.(name, ids)
|
|
300
322
|
for (const [id, paths] of data.consumedReads ?? []) {
|
|
301
323
|
for (const path of paths) target.consumedRead?.(id, path)
|