mikser-io 9.81.0 → 9.84.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/app.js CHANGED
@@ -43,7 +43,16 @@ function locate(argv) {
43
43
  : tool ? { type: 'report', tool, toolArgs: value('--tool-args'), json: has('--json') }
44
44
  : explain ? { type: 'report', explain, json: has('--json') }
45
45
  : has('--audit-output') ? { type: 'report', auditOutput: true, json: has('--json') }
46
- : { type: 'build', clear: has('--clear'), renderPresets: has('--render-presets') ? (value('--render-presets') ?? true) : undefined }
46
+ : { type: 'build',
47
+ clear: has('--clear'),
48
+ // Not a flag that happens to be set — the client's OUTPUT
49
+ // CONTRACT. `--json` promises the document on stdout and every
50
+ // log line on stderr, and BOTH halves are decided by the process
51
+ // that does the writing. Forwarded, that is the instance, which
52
+ // was started without the flag. So the contract has to travel
53
+ // with the request or it is not honoured at all.
54
+ json: has('--json'),
55
+ renderPresets: has('--render-presets') ? (value('--render-presets') ?? true) : undefined }
47
56
 
48
57
  return {
49
58
  longRunning,
@@ -796,6 +796,19 @@ queue. A client that writes a file and immediately asks can beat the file
796
796
  event, and draining would then build without the change that prompted the
797
797
  request.
798
798
 
799
+ `--json`, `--tool` and `--tools` keep their stream contract across the socket:
800
+ the document on stdout, the log on stderr, so `mikser --json | jq` means the
801
+ same thing whether or not something is listening. Both halves of that contract
802
+ are decided by the process that writes, which when forwarding is the instance —
803
+ so the flag travels with the request, and each captured chunk is replayed on
804
+ the stream the instance actually used. The contract applies to that one request
805
+ and is restored afterwards: asking for a document does not put the instance
806
+ into json mode for its own output or for the next caller.
807
+
808
+ `--audit-output` is the exception, and it is the same with or without an
809
+ instance: it reports through the log and writes no document, so `--json` there
810
+ only moves its output to stderr.
811
+
799
812
  ## Faults
800
813
 
801
814
  A **fault** is a subsystem saying it cannot do its job — as opposed to an
@@ -915,16 +928,38 @@ surfaces that turn silence into a statement:
915
928
  - **The emitted output**, read back and resolved the way a browser
916
929
  would — `src`, `href`, `poster`, `srcset` and CSS `url()` across
917
930
  html and css. This one sees paths written by hand, not just helper
918
- output, and it separates two problems that share a symptom:
919
- `reference-wrong-base` when the file exists elsewhere in the output
920
- (the url was built from the wrong root, and the report names where
921
- the file actually is), `reference-broken` when nothing produced it
922
- at all.
931
+ output, and it separates three problems that share a symptom:
932
+ `reference-no-derivative` when the assets plugin can say why the file
933
+ is not there, `reference-wrong-base` when the file exists elsewhere in
934
+ the output (the url was built from the wrong root, and the report names
935
+ where the file actually is), `reference-broken` when nothing produced
936
+ it at all.
923
937
  - **The render track**, which records every `asset()` / `resource()`
924
938
  call and tests the destination it built. This catches a url that
925
939
  never reaches an html file at all — one emitted into a feed or a
926
940
  sitemap — and warns under `asset-missing`. Where both can see the
927
941
  same file, the output scan reports it and this one stays quiet.
942
+
943
+ A url under the assets folder gets a cause rather than a guess. Whether a
944
+ preset covers a file is decided by `match` against the entity id, which is
945
+ not visible from a url, so the assets plugin is asked and the answer is one
946
+ of four: the preset name is not configured; the preset does not cover this
947
+ file (with the `match` that decided it, and any preset that *does* cover it,
948
+ since the fix is then in the template); the preset covers it and the
949
+ derivative still is not there, so a render failed; or there is no source
950
+ file under that name at all. It travels into `--json` as `reason`.
951
+
952
+ This matters most where it looks least like itself. `files()` copies the
953
+ source into the output, so a derivative that was never produced leaves a
954
+ file of the same name sitting elsewhere — and the wrong-base search finds
955
+ it and reports a misplaced file with complete confidence. The base is
956
+ right; the derivative does not exist. A real answer wins over the
957
+ heuristic wherever there is one.
958
+
959
+ `asset()` looks nothing up. It takes a path rather than an entity and is
960
+ the hottest call in a render, so it stays a string operation; the question
961
+ is answered once per missing destination after the cycle, and only when
962
+ something is already wrong.
928
963
  - **A link that works only by accident** — a url with one `..` too many
929
964
  still loads, because a browser discards a climb above the origin root
930
965
  rather than failing. Reported under `reference-over-deep`, separately
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "9.81.0",
3
+ "version": "9.84.0",
4
4
  "files": [
5
5
  "app.js",
6
6
  "index.js",
package/src/engine.js CHANGED
@@ -10,7 +10,7 @@ import { instanceControl } from './instance.js'
10
10
  import { useJournal, updateEntry } from './journal.js'
11
11
  import { globby } from 'globby'
12
12
  import { OPERATION, TASKS } from './constants.js'
13
- import { changeExtension, formatErrorContext, projectMeta, lookupKeys } from './utils.js'
13
+ import { changeExtension, formatErrorContext, projectMeta, lookupKeys, siteRootFor } from './utils.js'
14
14
  import { reportRendered, reportSkipped, reportError, renderErrorCount, emitReport, finishCycle, reportAssetUse, assetUse } from './report.js'
15
15
  import { checkReferences } from './references.js'
16
16
  import { toolSchemas, invokeTool, toolResultText, toolResultFailed } from './tools.js'
@@ -116,11 +116,56 @@ async function reportBrokenReferences(logger) {
116
116
  const named = (files) =>
117
117
  files.slice(0, 3).join(', ') + (files.length > 3 ? ` and ${files.length - 3} more` : '')
118
118
 
119
- for (const { url, target, files, elsewhere } of broken.slice(0, SHOWN)) {
119
+ // Ask the assets plugin before guessing.
120
+ //
121
+ // A derivative that was never produced has its SOURCE sitting in the
122
+ // output — files() copies it there — so the same-name search finds it and
123
+ // "the file is at media/icons/logo.svg" reads as a base that is off by a
124
+ // folder. It is not: the base is right and the derivative does not exist,
125
+ // because the preset does not cover that file. Confidently naming the
126
+ // wrong cause is worse than naming none, so a real answer wins over the
127
+ // heuristic wherever there is one.
128
+ const explain = runtime.state?.assets?.explainMissing
129
+ const reasons = new Map()
130
+ if (explain) {
131
+ // Every broken entry, not the ones that happen to sort first. The cap
132
+ // below is about how much gets PRINTED; asking only within it made the
133
+ // answer depend on the position of the entry that had one, so a site
134
+ // with ten unrelated broken urls reported "No derivative produced: 0"
135
+ // while holding the cause of the eleventh. Cheap to ask: anything not
136
+ // under the assets folder is rejected on the first path segment, and
137
+ // the catalog index behind it is built once and only if something gets
138
+ // past that.
139
+ for (const { target } of broken) {
140
+ // Site-relative, because that is what the assets folder is named
141
+ // relative to. A build that deploys out/<lang> as its own domain
142
+ // root resolves this target to `a/derived/web/...`, and a plain
143
+ // prefix test on the assets folder sees `a` and gives up — the
144
+ // check goes quiet on exactly the multi-site builds where a
145
+ // derivative is shared into each root.
146
+ const root = siteRootFor(target, siteRoots)
147
+ const local = root ? target.slice(root.length).replace(/^\/+/, '') : target
148
+ try { reasons.set(target, await explain(local)) } catch { /* never break the report */ }
149
+ }
150
+ }
151
+
152
+ // A stated cause outranks a guess in what gets printed, not only in how it
153
+ // is worded. The cap exists so one broken preset cannot bury the rest of
154
+ // the build — it must not bury the answer instead.
155
+ const ordered = reasons.size
156
+ ? [...broken].sort((a, b) => (reasons.get(b.target) ? 1 : 0) - (reasons.get(a.target) ? 1 : 0))
157
+ : broken
158
+
159
+ for (const { url, target, files, elsewhere } of ordered.slice(0, SHOWN)) {
160
+ const reason = reasons.get(target)
120
161
  // Two different problems wear the same symptom. A target whose file
121
162
  // exists elsewhere in the output is a base that is wrong, not an asset
122
163
  // that is missing — and saying which one saves the reader the search.
123
- if (elsewhere?.length) {
164
+ if (reason) {
165
+ logger.warn({ code: 'reference-no-derivative', url, target, files, reason },
166
+ 'No derivative was produced: %s (from %s) — %s',
167
+ url, named(files), reason)
168
+ } else if (elsewhere?.length) {
124
169
  logger.warn({ code: 'reference-wrong-base', url, target, files, elsewhere },
125
170
  'Points at the wrong place: %s (from %s) — nothing at %s, but the file is at %s',
126
171
  url, named(files), target, elsewhere.join(', '))
@@ -131,16 +176,17 @@ async function reportBrokenReferences(logger) {
131
176
  }
132
177
  }
133
178
  if (broken.length) {
134
- const misplaced = broken.filter(b => b.elsewhere?.length).length
179
+ const explained = broken.filter(b => reasons.get(b.target)).length
180
+ const misplaced = broken.filter(b => b.elsewhere?.length && !reasons.get(b.target)).length
135
181
  logger.warn({
136
182
  code: 'reference-broken-summary',
137
- broken: broken.length, wrongBase: misplaced, checked,
183
+ broken: broken.length, wrongBase: misplaced, noDerivative: explained, checked,
138
184
  },
139
- '%d of %d reference(s) in the output resolve to nothing%s. Wrong base (the file exists '
140
- + 'elsewhere): %d. Never produced: %d. A URL helper builds the path rather than looking '
141
- + 'it up, so neither kind can fail at the point it is written.',
185
+ '%d of %d reference(s) in the output resolve to nothing%s. No derivative produced: %d. '
186
+ + 'Wrong base (the file exists elsewhere): %d. Never produced: %d. A URL helper builds the '
187
+ + 'path rather than looking it up, so none of them can fail at the point it is written.',
142
188
  broken.length, checked, broken.length > SHOWN ? `, ${SHOWN} shown` : '',
143
- misplaced, broken.length - misplaced)
189
+ explained, misplaced, broken.length - misplaced - explained)
144
190
  }
145
191
 
146
192
  // Grouped by how FAR each climbed, because a site whose every over-deep url
@@ -215,10 +261,19 @@ async function reportMissingAssets(logger, alreadyReported = new Set()) {
215
261
  // every page on the site, and a thousand lines of it buries whatever else
216
262
  // the build said.
217
263
  const SHOWN = 10
264
+ const explain = runtime.state?.assets?.explainMissing
218
265
  for (const [destination, ids] of missing.slice(0, SHOWN)) {
219
- logger.warn({ code: 'asset-missing', destination, referencedBy: ids },
220
- 'Linked but not in the output: %sreferenced by %s', destination,
221
- ids.slice(0, 3).join(', ') + (ids.length > 3 ? ` and ${ids.length - 3} more` : ''))
266
+ // Same question, same answer, wherever the symptom surfaces. This path
267
+ // sees urls that never reach an html file at all a sitemap, a feed —
268
+ // which the output scan cannot look at.
269
+ let reason = null
270
+ if (explain) {
271
+ try { reason = await explain(destination) } catch { /* never break the report */ }
272
+ }
273
+ logger.warn({ code: 'asset-missing', destination, referencedBy: ids, reason },
274
+ 'Linked but not in the output: %s — referenced by %s%s', destination,
275
+ ids.slice(0, 3).join(', ') + (ids.length > 3 ? ` and ${ids.length - 3} more` : ''),
276
+ reason ? `. ${reason[0].toUpperCase()}${reason.slice(1)}` : '')
222
277
  }
223
278
  logger.warn({ code: 'asset-missing-summary', missing: missing.length, checked: used.length },
224
279
  '%d of %d linked file(s) are not in the output%s. A URL helper builds the path rather than looking it '
package/src/instance.js CHANGED
@@ -125,8 +125,11 @@ export function forward({ workingFolder, config, request }) {
125
125
  readFrames(socket, (message) => {
126
126
  if (message.type === 'log') {
127
127
  // The instance's output for THIS request, on the stream it
128
- // would have used locally.
129
- process.stderr.write(message.chunk)
128
+ // would have used locally — which requires knowing which
129
+ // stream that was. An instance too old to say defaults to
130
+ // stderr, which is what every frame used to mean.
131
+ const out = message.stream === 'stdout' ? process.stdout : process.stderr
132
+ out.write(message.chunk)
130
133
  } else if (message.type === 'refused') {
131
134
  answered = true
132
135
  process.stderr.write(`mikser: ${message.reason}\n`)
@@ -197,14 +200,22 @@ let server = null
197
200
  // adding a log transport means the client sees precisely what it would have
198
201
  // seen locally, formatting and all, with no second rendering of the same
199
202
  // records to keep in step.
203
+ // WHICH stream, not merely that something was written.
204
+ //
205
+ // stdout and stderr are not two ways of saying the same thing: under --json,
206
+ // --tool and --tools, stdout carries a machine-readable document and stderr
207
+ // carries the log, and the split is the entire value of those flags. Capturing
208
+ // both into one undifferentiated stream throws that away, and the client can
209
+ // only guess — it guessed stderr, so every forwarded document landed where no
210
+ // consumer looks while the command exited 0.
200
211
  function captureOutput(onChunk) {
201
212
  const originals = [process.stdout.write, process.stderr.write]
202
- const patch = (stream, original) => function (chunk, encoding, callback) {
203
- try { onChunk(typeof chunk === 'string' ? chunk : chunk.toString()) } catch { /* client gone */ }
213
+ const patch = (stream, original, name) => function (chunk, encoding, callback) {
214
+ try { onChunk(typeof chunk === 'string' ? chunk : chunk.toString(), name) } catch { /* client gone */ }
204
215
  return original.call(stream, chunk, encoding, callback)
205
216
  }
206
- process.stdout.write = patch(process.stdout, originals[0])
207
- process.stderr.write = patch(process.stderr, originals[1])
217
+ process.stdout.write = patch(process.stdout, originals[0], 'stdout')
218
+ process.stderr.write = patch(process.stderr, originals[1], 'stderr')
208
219
  return () => {
209
220
  process.stdout.write = originals[0]
210
221
  process.stderr.write = originals[1]
@@ -264,10 +275,10 @@ async function configStale() {
264
275
  // the only process that can answer correctly. Same guards as a build: wrong
265
276
  // config refuses, drifted config refuses.
266
277
  async function serveReport(socket, request, logger) {
267
- const restore = captureOutput((chunk) => frame(socket, { type: 'log', chunk }))
278
+ const restore = captureOutput((chunk, stream) => frame(socket, { type: 'log', chunk, stream }))
268
279
  let code = 0
269
280
  try {
270
- code = await runReportOnly(request) ?? 0
281
+ code = await withRequestOutput(request, () => runReportOnly(request)) ?? 0
271
282
  } catch (err) {
272
283
  logger?.error('instance: forwarded report failed — %s', err.message)
273
284
  code = 3
@@ -328,8 +339,37 @@ function refuseStale(socket, movedFile) {
328
339
  })
329
340
  }
330
341
 
342
+ // The client's output contract, applied to the instance for one request.
343
+ //
344
+ // `--json` is answered by two pieces of code that both read runtime.options:
345
+ // the logger picks its stream per line, and emitReport() writes nothing at all
346
+ // unless the option is set. The instance was started without the flag, so a
347
+ // forwarded `--json` was answered under the INSTANCE's contract — a build
348
+ // emitted no document whatsoever, and a report emitted one into the same
349
+ // stream as the log it was supposed to be separated from.
350
+ //
351
+ // Tighten only, never loosen. A client asking for a document states something
352
+ // the instance cannot know; a client not asking states nothing, and silencing
353
+ // an instance that was itself started under `--json` would take a document
354
+ // away from whatever is reading ITS stdout.
355
+ //
356
+ // Per request and restored after, like renderPresets: one client's flag does
357
+ // not put the instance into json mode for everyone.
358
+ async function withRequestOutput(request, run) {
359
+ const prior = {}
360
+ for (const key of ['json', 'tool', 'tools']) {
361
+ prior[key] = runtime.options[key]
362
+ if (request[key]) runtime.options[key] = request[key]
363
+ }
364
+ try {
365
+ return await run()
366
+ } finally {
367
+ Object.assign(runtime.options, prior)
368
+ }
369
+ }
370
+
331
371
  async function serveBuild(socket, request, logger) {
332
- const restore = captureOutput((chunk) => frame(socket, { type: 'log', chunk }))
372
+ const restore = captureOutput((chunk, stream) => frame(socket, { type: 'log', chunk, stream }))
333
373
  let code = 0
334
374
  try {
335
375
  // Fire the pending debounce rather than waiting it out.
@@ -356,7 +396,11 @@ async function serveBuild(socket, request, logger) {
356
396
  const priorRenderPresets = runtime.options.renderPresets
357
397
  if (request.renderPresets !== undefined) runtime.options.renderPresets = request.renderPresets
358
398
  try {
359
- await runtime.rebuild()
399
+ // The report is emitted by the cycle itself, from inside
400
+ // rebuild() — the same call a one-shot makes. Nothing here
401
+ // re-implements it: the contract is what decides whether it
402
+ // writes, so setting the contract is the whole fix.
403
+ await withRequestOutput(request, () => runtime.rebuild())
360
404
  } finally {
361
405
  runtime.options.renderPresets = priorRenderPresets
362
406
  }
@@ -97,19 +97,100 @@ export function assets(options = {}) {
97
97
  // reported once per process at onFinalize.
98
98
  const matchTally = { evaluated: 0, matched: new Set(), reported: false }
99
99
 
100
+ // Which presets select this entity. No counters, no side effects.
101
+ //
102
+ // Split out because the same question gets asked twice for different
103
+ // reasons: once as files enter the catalog, where the answer drives the
104
+ // render and feeds the unmatched-preset tally, and once after the cycle to
105
+ // explain a derivative that is not there. The second must not move the
106
+ // counters the first reports on.
107
+ //
108
+ // `some`, not a push per matching pattern. Two patterns that both cover a
109
+ // file used to name their preset twice in the list; the second render was
110
+ // gated by the checksum, so nothing rendered twice — but "which presets
111
+ // cover this file" is a question with one answer, and the explainer below
112
+ // puts that answer in front of a reader.
113
+ function presetsSelecting(entity) {
114
+ const selected = []
115
+ for (const preset in (options.presets || {})) {
116
+ const { matches } = normalizePresetConfig(options.presets[preset])
117
+ if (matches.some(match => matchEntity(entity, match))) selected.push(preset)
118
+ }
119
+ return selected
120
+ }
121
+
100
122
  async function getEntityPresets(entity) {
101
- const entityPresets = []
102
123
  matchTally.evaluated++
103
- for (let preset in (options.presets || {})) {
104
- const { matches } = normalizePresetConfig(options.presets[preset])
105
- for (let match of matches) {
106
- if (matchEntity(entity, match)) {
107
- entityPresets.push(preset)
108
- matchTally.matched.add(preset)
109
- }
124
+ const selected = presetsSelecting(entity)
125
+ for (const preset of selected) matchTally.matched.add(preset)
126
+ return selected
127
+ }
128
+
129
+ // Why a linked derivative is not in the output.
130
+ //
131
+ // The engine detects the CONSEQUENCE — it knows what the output points at
132
+ // and what exists on disk — but it cannot name the cause, because whether
133
+ // a preset covers a file is decided by `match` against the entity id and
134
+ // none of that is visible from a url. So one sentence covered a mistyped
135
+ // preset name, a preset that did not run, and a file no preset was ever
136
+ // asked to cover. The last is the common one, the only one whose fix is in
137
+ // the config rather than in the template, and the one the reader is least
138
+ // likely to guess.
139
+ //
140
+ // Answered here rather than in the helper: `asset()` takes a path, not an
141
+ // entity, and it is the hottest call in a render. Nothing is looked up
142
+ // when the url is built. This runs at most a handful of times, after
143
+ // everything has settled, and only when something is already wrong.
144
+ let sourceIndex = null
145
+ let sourceIndexCycle
146
+ async function explainMissing(destination) {
147
+ const assetsName = runtime.options.assets
148
+ const parts = String(destination).replace(/^\/+/, '').split('/')
149
+ if (!assetsName || parts[0] !== assetsName || parts.length < 3) return null
150
+ const preset = parts[1]
151
+ const name = parts.slice(2).join('/')
152
+
153
+ const configured = Object.keys(options.presets || {}).sort()
154
+ if (!options.presets?.[preset]) {
155
+ return `no preset named '${preset}' is configured (configured: ${configured.join(', ') || 'none'})`
156
+ }
157
+
158
+ // The derivative wears the PRESET's extension, so the source is found
159
+ // by stem — `media/hero.webp` came from `media/hero.jpg`.
160
+ const stem = (value) => value.slice(0, value.length - path.extname(value).length)
161
+
162
+ // Built once per cycle and only on this path: a build with nothing
163
+ // broken never walks the catalog for it.
164
+ const cycle = runtime.state?.cycle?.id ?? null
165
+ if (sourceIndexCycle !== cycle) { sourceIndex = null; sourceIndexCycle = cycle }
166
+ if (!sourceIndex) {
167
+ sourceIndex = new Map()
168
+ for await (const candidate of iterateEntities({ collection: { $ne: collection } })) {
169
+ if (typeof candidate.name !== 'string') continue
170
+ const key = stem(candidate.name)
171
+ if (!sourceIndex.has(key)) sourceIndex.set(key, [])
172
+ sourceIndex.get(key).push(candidate)
110
173
  }
111
174
  }
112
- return entityPresets
175
+
176
+ const source = (sourceIndex.get(stem(name)) ?? [])[0]
177
+ if (!source) {
178
+ return `no source file is named '${stem(name)}' — nothing would produce this derivative `
179
+ + 'under any preset'
180
+ }
181
+
182
+ const owns = presetsSelecting(source)
183
+ if (owns.includes(preset)) {
184
+ return `preset '${preset}' does cover ${source.id}, so the derivative should be there — it was `
185
+ + 'not produced this cycle (the preset render failed, or the preset changed and only '
186
+ + '--render-presets re-derives what is already in the catalog)'
187
+ }
188
+ const { matches } = normalizePresetConfig(options.presets[preset])
189
+ return `preset '${preset}' does not cover ${source.id} — its match is `
190
+ + `${matches.join(', ') || '(none)'}`
191
+ + (owns.length
192
+ ? `. Presets that do cover it: ${owns.join(', ')}`
193
+ : '. No configured preset covers it')
113
194
  }
114
195
 
115
196
  // Report presets that matched none of the entities this run evaluated.
@@ -315,6 +396,11 @@ export function assets(options = {}) {
315
396
  runtime.state.assets = {
316
397
  presets: {},
317
398
  assetsMap: {},
399
+ // The engine asks this when a linked derivative is not on disk.
400
+ // Published on state rather than imported, so the engine keeps
401
+ // knowing nothing about presets and says nothing when this plugin
402
+ // is not loaded.
403
+ explainMissing,
318
404
  assetsFolder: options.outputFolder
319
405
  ? path.join(options.outputFolder, assetsName)
320
406
  : assetsName,
package/src/references.js CHANGED
@@ -38,6 +38,9 @@ const ATTR = /(?:src|href|poster|data-bg)\s*=\s*["']([^"']*)["']/gi
38
38
  const SRCSET = /(?:img|image)?srcset\s*=\s*["']([^"']*)["']/gi
39
39
  // css url(), in a stylesheet and in an inline style attribute alike.
40
40
  const CSS_URL = /url\(\s*(['"]?)([^'")]*)\1\s*\)/gi
41
+ // The same, but inside a CUSTOM PROPERTY declaration — which resolves from a
42
+ // different place, see below.
43
+ const CUSTOM_PROP_URL = /--[\w-]+\s*:\s*[^;{}]*?url\(\s*(['"]?)([^'")]*)\1\s*\)/gi
41
44
 
42
45
  // A url this check has nothing to say about: another origin, an inline
43
46
  // payload, a fragment or an in-page action. `//host/path` is protocol-relative
@@ -88,6 +91,10 @@ export { siteRootFor }
88
91
  export function extractReferences(rawSource) {
89
92
  const source = decodeEntities(rawSource)
90
93
  const found = new Set()
94
+ // Collected first, so a url that appears in a custom property is known to
95
+ // be one however else it is matched — CSS_URL sees it too.
96
+ const custom = new Set()
97
+ for (const [, , url] of source.matchAll(CUSTOM_PROP_URL)) custom.add(url)
91
98
  for (const [, url] of source.matchAll(ATTR)) found.add(url)
92
99
  for (const [, , url] of source.matchAll(CSS_URL)) found.add(url)
93
100
  for (const [, list] of source.matchAll(SRCSET)) {
@@ -96,7 +103,9 @@ export function extractReferences(rawSource) {
96
103
  if (url) found.add(url)
97
104
  }
98
105
  }
99
- return [...found].filter(u => !isExternal(u))
106
+ return [...found]
107
+ .filter(u => !isExternal(u))
108
+ .map(url => ({ url, customProperty: custom.has(url) }))
100
109
  }
101
110
 
102
111
  // Resolve the way a browser does, which is the whole point.
@@ -133,9 +142,18 @@ export function resolveUrl(pageDir, url, { root = '' } = {}) {
133
142
  // { url, target, files } — the target with the pages that named it, because
134
143
  // "this is missing" is only actionable next to "and these link it".
135
144
  export async function checkReferences(outputFolder, { siteRoots = [] } = {}) {
145
+ // Following symlinks, because that is what gets served.
146
+ //
147
+ // files() emits by symlinking the source into the output, so a stylesheet
148
+ // is usually a link rather than a copy — and skipping links meant no
149
+ // symlinked html or css was ever read. Every `url()` inside a bundle went
150
+ // unchecked on any site built that way, silently, while the check reported
151
+ // a confident total. The same-name index below has always followed them,
152
+ // which is how a file could be FOUND elsewhere by a scan that would not
153
+ // READ it.
136
154
  const files = await globby(SCANNED, {
137
155
  cwd: outputFolder,
138
- followSymbolicLinks: false,
156
+ followSymbolicLinks: true,
139
157
  suppressErrors: true,
140
158
  })
141
159
 
@@ -146,6 +164,30 @@ export async function checkReferences(outputFolder, { siteRoots = [] } = {}) {
146
164
  // site — one lookup each.
147
165
  const exists = new Map()
148
166
 
167
+ // Where a `url()` inside a CUSTOM PROPERTY resolves from.
168
+ //
169
+ // Not the page. A custom property is substituted where it is USED, and the
170
+ // url resolves against the stylesheet doing the substituting — so
171
+ //
172
+ // <span style="--icon-btn-src:url(&quot;../media/icons/x.svg&quot;)">
173
+ //
174
+ // on a page three directories deep is CORRECT when the bundle that reads
175
+ // var(--icon-btn-src) sits at styles/. Resolving it from the page reports
176
+ // a base problem for markup that ships and works, and fifteen such lines
177
+ // are how the reader learns to skim past this check entirely.
178
+ //
179
+ // Which stylesheet substitutes it is not knowable from the bytes — any
180
+ // rule using the variable does — so every emitted stylesheet is a
181
+ // candidate base, and one that resolves is enough to stay quiet. A url
182
+ // that resolves from none of them is still reported: it is missing
183
+ // wherever it is read from.
184
+ const styleBases = files
185
+ .filter(file => file.endsWith('.css'))
186
+ .map((file) => {
187
+ const root = siteRootFor(file, siteRoots)
188
+ return { root, dir: path.dirname(file).slice(root.length).replace(/^\/+/, '') }
189
+ })
190
+
149
191
  for (const file of files) {
150
192
  let source
151
193
  try { source = await readFile(path.join(outputFolder, file), 'utf8') }
@@ -155,13 +197,23 @@ export async function checkReferences(outputFolder, { siteRoots = [] } = {}) {
155
197
  // The page's directory, relative to its own site root.
156
198
  const pageDir = path.dirname(file).slice(root.length).replace(/^\/+/, '')
157
199
 
158
- for (const url of extractReferences(source)) {
200
+ for (const { url, customProperty } of extractReferences(source)) {
159
201
  const { target, overDeep, floored } = resolveUrl(pageDir, url, { root })
160
202
  checked++
161
203
 
162
204
  if (!exists.has(target)) {
163
205
  exists.set(target, existsSync(path.join(outputFolder, target)))
164
206
  }
207
+
208
+ // Resolved from a stylesheet instead, and correct there.
209
+ if (!exists.get(target) && customProperty && styleBases.some((base) => {
210
+ const from = resolveUrl(base.dir, url, { root: base.root }).target
211
+ if (!exists.has(from)) {
212
+ exists.set(from, existsSync(path.join(outputFolder, from)))
213
+ }
214
+ return exists.get(from)
215
+ })) continue
216
+
165
217
  // Broken outranks over-deep: a url that resolves nowhere is the
166
218
  // failure, and adding that it is also one level too deep is noise.
167
219
  const bucket = !exists.get(target) ? broken : (overDeep ? overDeepRefs : null)