mikser-io 9.67.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 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
@@ -246,7 +268,10 @@ brevity.
246
268
  `--explain`) forward too — they read, so a local run damaged nothing,
247
269
  but a catalogue another process is mid-write in is not one anyone can
248
270
  answer from. `runReportOnly()` in engine.js is the one implementation
249
- both paths call. Exit code comes from `renderErrorCount()`, not
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
250
275
  `process.exitCode` — the engine suppresses that in watch mode by
251
276
  design. Config mismatch is refused by resolved PATH; config drift
252
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
  //
@@ -26,6 +26,12 @@ function locate(argv) {
26
26
  }
27
27
  const has = (...names) => names.some(n => argv.includes(n) || argv.some(a => names.some(x => a.startsWith(`${x}=`))))
28
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
+
29
35
  // What to ask the instance for. Report-only commands go over the same
30
36
  // socket as a build: they read, so running them locally never damaged
31
37
  // anything, but a catalogue being written by another process is not a
@@ -40,6 +46,7 @@ function locate(argv) {
40
46
  : { type: 'build', clear: has('--clear') }
41
47
 
42
48
  return {
49
+ longRunning,
43
50
  workingFolder: value('--working-folder', '-i') ?? '.',
44
51
  config: value('--config', '-c') ?? 'mikser.config.js',
45
52
  // Commander's negated form: `attach` is true unless --no-attach said so.
@@ -50,9 +57,25 @@ function locate(argv) {
50
57
 
51
58
  async function main() {
52
59
  const where = locate(process.argv.slice(2))
53
- if (where.attach !== false) {
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) {
54
77
  const code = await forward({
55
- workingFolder: path.resolve(where.workingFolder),
78
+ workingFolder,
56
79
  config: path.resolve(where.workingFolder, where.config),
57
80
  request: where.request,
58
81
  })
@@ -756,6 +756,10 @@ 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
 
@@ -846,10 +850,11 @@ surfaces that turn silence into a statement:
846
850
  - **A pattern or query that matched nothing** — several plugins warn on
847
851
  this now (layout patterns, preset matching, null filters). The warnings
848
852
  carry stable `code`s in `--json`.
849
- - **Output that does not match the config** — the config's bytes take
850
- part in cache invalidation, so a config change forces a rebuild; but a
851
- module the config *imports* does not. If you changed a helper the config
852
- pulls in, use `--force`.
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.
853
858
  - **A tool that answers emptily because it is broken** — check
854
859
  [`faults`](#faults) before reading an empty result as a fact about the
855
860
  site. This is the one case where the answer and the failure are the same
@@ -857,6 +862,21 @@ surfaces that turn silence into a statement:
857
862
  - **A plugin that appears to do nothing** — `No plugins loaded` with a
858
863
  config present is a warning naming the file. A config that fails to
859
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.
860
880
 
861
881
  ## See also
862
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.67.0",
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,43 @@ 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
+
92
129
  // The report-only commands, as functions that RETURN their exit code.
93
130
  //
94
131
  // They used to be inline here and call process.exit, which is fine for a
@@ -677,6 +714,15 @@ export async function setup(options) {
677
714
  result = rendered?.output
678
715
  if (rendered?.track) mergeTrack(track, rendered.track)
679
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
+
680
726
  if (!signal.aborted) {
681
727
  // Meta reads ride on `output`, which is already
682
728
  // free-form JSON on the journal row, rather than in
@@ -1014,6 +1060,17 @@ export async function setup(options) {
1014
1060
  if (!signal.aborted) {
1015
1061
  await updateEntry({ id, output: { success: false } })
1016
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
+ })
1017
1074
  }
1018
1075
  logger.debug('Postprocess canceled')
1019
1076
  }
@@ -1070,6 +1127,19 @@ export async function setup(options) {
1070
1127
  if (failed) logger.error('Mikser completed with %d render error%s', failed, failed === 1 ? '' : 's')
1071
1128
  else logger.notice('Mikser completed')
1072
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
+
1073
1143
  // After the cycle, and only under --json. stdout has been kept clear
1074
1144
  // for exactly this (the logger writes to stderr under --json), so the
1075
1145
  // document is the only thing on it and can be piped to jq.
package/src/instance.js CHANGED
@@ -149,6 +149,35 @@ export function forward({ workingFolder, config, request }) {
149
149
  })
150
150
  }
151
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
+
152
181
  // ── server ──────────────────────────────────────────────────────────────
153
182
 
154
183
  // Requests run one at a time, to completion.
@@ -409,10 +438,17 @@ export async function warnIfHeld({ workingFolder, attached }) {
409
438
  export function instanceControl() {
410
439
  serveInstance()
411
440
  onLoaded(async () => {
412
- if (runtime.options.watch || runtime.options.server) return
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
413
449
  await warnIfHeld({
414
450
  workingFolder: runtime.options.workingFolder,
415
- attached: runtime.options.attach,
451
+ attached: false,
416
452
  })
417
453
  })
418
454
  }
@@ -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)