mikser-io 9.80.0 → 9.83.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 +17 -2
- package/docs/diagnostics.md +41 -3
- package/package.json +1 -1
- package/src/engine.js +67 -11
- package/src/instance.js +88 -10
- package/src/plugins/assets.js +95 -9
- package/src/references.js +26 -0
package/app.js
CHANGED
|
@@ -43,10 +43,25 @@ 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',
|
|
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,
|
|
59
|
+
// The flags this process was invoked with, forwarded so the INSTANCE
|
|
60
|
+
// can reject an unknown one. This pre-parser reads the few options it
|
|
61
|
+
// needs and passes the rest along, so commander never runs on a
|
|
62
|
+
// forwarded invocation — and a typo was accepted in silence, built,
|
|
63
|
+
// and reported success.
|
|
64
|
+
argv,
|
|
50
65
|
workingFolder: value('--working-folder', '-i') ?? '.',
|
|
51
66
|
config: value('--config', '-c') ?? 'mikser.config.js',
|
|
52
67
|
request,
|
|
@@ -74,7 +89,7 @@ async function main() {
|
|
|
74
89
|
const code = await forward({
|
|
75
90
|
workingFolder,
|
|
76
91
|
config: path.resolve(where.workingFolder, where.config),
|
|
77
|
-
request: where.request,
|
|
92
|
+
request: { ...where.request, argv: where.argv },
|
|
78
93
|
})
|
|
79
94
|
// null means nobody was listening — carry on exactly as before.
|
|
80
95
|
if (code !== null) process.exit(code)
|
package/docs/diagnostics.md
CHANGED
|
@@ -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
|
|
@@ -914,14 +927,39 @@ surfaces that turn silence into a statement:
|
|
|
914
927
|
evidence:
|
|
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
|
-
html and css.
|
|
918
|
-
|
|
919
|
-
|
|
930
|
+
html and css. This one sees paths written by hand, not just helper
|
|
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.
|
|
920
937
|
- **The render track**, which records every `asset()` / `resource()`
|
|
921
938
|
call and tests the destination it built. This catches a url that
|
|
922
939
|
never reaches an html file at all — one emitted into a feed or a
|
|
923
940
|
sitemap — and warns under `asset-missing`. Where both can see the
|
|
924
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.
|
|
925
963
|
- **A link that works only by accident** — a url with one `..` too many
|
|
926
964
|
still loads, because a browser discards a climb above the origin root
|
|
927
965
|
rather than failing. Reported under `reference-over-deep`, separately
|
package/package.json
CHANGED
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,15 +116,62 @@ 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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
+
for (const { target } of broken.slice(0, SHOWN)) {
|
|
132
|
+
// Site-relative, because that is what the assets folder is named
|
|
133
|
+
// relative to. A build that deploys out/<lang> as its own domain
|
|
134
|
+
// root resolves this target to `a/derived/web/...`, and a plain
|
|
135
|
+
// prefix test on the assets folder sees `a` and gives up — the
|
|
136
|
+
// check goes quiet on exactly the multi-site builds where a
|
|
137
|
+
// derivative is shared into each root.
|
|
138
|
+
const root = siteRootFor(target, siteRoots)
|
|
139
|
+
const local = root ? target.slice(root.length).replace(/^\/+/, '') : target
|
|
140
|
+
try { reasons.set(target, await explain(local)) } catch { /* never break the report */ }
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
for (const { url, target, files, elsewhere } of broken.slice(0, SHOWN)) {
|
|
145
|
+
const reason = reasons.get(target)
|
|
146
|
+
// Two different problems wear the same symptom. A target whose file
|
|
147
|
+
// exists elsewhere in the output is a base that is wrong, not an asset
|
|
148
|
+
// that is missing — and saying which one saves the reader the search.
|
|
149
|
+
if (reason) {
|
|
150
|
+
logger.warn({ code: 'reference-no-derivative', url, target, files, reason },
|
|
151
|
+
'No derivative was produced: %s (from %s) — %s',
|
|
152
|
+
url, named(files), reason)
|
|
153
|
+
} else if (elsewhere?.length) {
|
|
154
|
+
logger.warn({ code: 'reference-wrong-base', url, target, files, elsewhere },
|
|
155
|
+
'Points at the wrong place: %s (from %s) — nothing at %s, but the file is at %s',
|
|
156
|
+
url, named(files), target, elsewhere.join(', '))
|
|
157
|
+
} else {
|
|
158
|
+
logger.warn({ code: 'reference-broken', url, target, files },
|
|
159
|
+
'Resolves to nothing, and nothing produced it: %s (from %s) — %s',
|
|
160
|
+
url, named(files), target)
|
|
161
|
+
}
|
|
122
162
|
}
|
|
123
163
|
if (broken.length) {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
164
|
+
const explained = broken.slice(0, SHOWN).filter(b => reasons.get(b.target)).length
|
|
165
|
+
const misplaced = broken.filter(b => b.elsewhere?.length && !reasons.get(b.target)).length
|
|
166
|
+
logger.warn({
|
|
167
|
+
code: 'reference-broken-summary',
|
|
168
|
+
broken: broken.length, wrongBase: misplaced, noDerivative: explained, checked,
|
|
169
|
+
},
|
|
170
|
+
'%d of %d reference(s) in the output resolve to nothing%s. No derivative produced: %d. '
|
|
171
|
+
+ 'Wrong base (the file exists elsewhere): %d. Never produced: %d. A URL helper builds the '
|
|
172
|
+
+ 'path rather than looking it up, so none of them can fail at the point it is written.',
|
|
173
|
+
broken.length, checked, broken.length > SHOWN ? `, ${SHOWN} shown` : '',
|
|
174
|
+
explained, misplaced, broken.length - misplaced - explained)
|
|
128
175
|
}
|
|
129
176
|
|
|
130
177
|
// Grouped by how FAR each climbed, because a site whose every over-deep url
|
|
@@ -199,10 +246,19 @@ async function reportMissingAssets(logger, alreadyReported = new Set()) {
|
|
|
199
246
|
// every page on the site, and a thousand lines of it buries whatever else
|
|
200
247
|
// the build said.
|
|
201
248
|
const SHOWN = 10
|
|
249
|
+
const explain = runtime.state?.assets?.explainMissing
|
|
202
250
|
for (const [destination, ids] of missing.slice(0, SHOWN)) {
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
251
|
+
// Same question, same answer, wherever the symptom surfaces. This path
|
|
252
|
+
// sees urls that never reach an html file at all — a sitemap, a feed —
|
|
253
|
+
// which the output scan cannot look at.
|
|
254
|
+
let reason = null
|
|
255
|
+
if (explain) {
|
|
256
|
+
try { reason = await explain(destination) } catch { /* never break the report */ }
|
|
257
|
+
}
|
|
258
|
+
logger.warn({ code: 'asset-missing', destination, referencedBy: ids, reason },
|
|
259
|
+
'Linked but not in the output: %s — referenced by %s%s', destination,
|
|
260
|
+
ids.slice(0, 3).join(', ') + (ids.length > 3 ? ` and ${ids.length - 3} more` : ''),
|
|
261
|
+
reason ? `. ${reason[0].toUpperCase()}${reason.slice(1)}` : '')
|
|
206
262
|
}
|
|
207
263
|
logger.warn({ code: 'asset-missing-summary', missing: missing.length, checked: used.length },
|
|
208
264
|
'%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
|
-
|
|
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
|
|
@@ -286,6 +297,39 @@ function refuseConfig(socket, request, wrongConfig) {
|
|
|
286
297
|
})
|
|
287
298
|
}
|
|
288
299
|
|
|
300
|
+
// An option the instance's own commander does not recognise.
|
|
301
|
+
//
|
|
302
|
+
// A forwarded invocation never reaches commander: app.js pre-parses the few
|
|
303
|
+
// flags it needs and forwards the rest, so `mikser --bogus` against a running
|
|
304
|
+
// watcher built normally and exited 0, while the same command with nothing
|
|
305
|
+
// listening was rejected outright. Same words, two answers, and the quiet one
|
|
306
|
+
// is the one that looks like it worked.
|
|
307
|
+
//
|
|
308
|
+
// parseOptions REPORTS unknowns without applying anything, which is what makes
|
|
309
|
+
// this safe to run against the live instance's option table.
|
|
310
|
+
function refuseUnknownFlags(socket, request) {
|
|
311
|
+
const argv = Array.isArray(request.argv) ? request.argv : null
|
|
312
|
+
if (!argv?.length) return false
|
|
313
|
+
const commander = runtime.engine?.commander
|
|
314
|
+
if (!commander) return false
|
|
315
|
+
let unknown = []
|
|
316
|
+
try {
|
|
317
|
+
unknown = commander.parseOptions([...argv]).unknown ?? []
|
|
318
|
+
} catch {
|
|
319
|
+
return false // never let a probe refuse a legitimate build
|
|
320
|
+
}
|
|
321
|
+
unknown = unknown.filter(a => a.startsWith('-'))
|
|
322
|
+
if (!unknown.length) return false
|
|
323
|
+
frame(socket, {
|
|
324
|
+
type: 'refused',
|
|
325
|
+
reason: `unknown option ${unknown.map(u => `'${u}'`).join(', ')}.`,
|
|
326
|
+
detail: 'Forwarded to the instance running in this folder, which does not recognise it. '
|
|
327
|
+
+ 'The same command with nothing listening would have been rejected too — this says so '
|
|
328
|
+
+ 'rather than building as though the flag had been understood.',
|
|
329
|
+
})
|
|
330
|
+
return true
|
|
331
|
+
}
|
|
332
|
+
|
|
289
333
|
function refuseStale(socket, movedFile) {
|
|
290
334
|
frame(socket, {
|
|
291
335
|
type: 'refused',
|
|
@@ -295,8 +339,37 @@ function refuseStale(socket, movedFile) {
|
|
|
295
339
|
})
|
|
296
340
|
}
|
|
297
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
|
+
|
|
298
371
|
async function serveBuild(socket, request, logger) {
|
|
299
|
-
const restore = captureOutput((chunk) => frame(socket, { type: 'log', chunk }))
|
|
372
|
+
const restore = captureOutput((chunk, stream) => frame(socket, { type: 'log', chunk, stream }))
|
|
300
373
|
let code = 0
|
|
301
374
|
try {
|
|
302
375
|
// Fire the pending debounce rather than waiting it out.
|
|
@@ -323,7 +396,11 @@ async function serveBuild(socket, request, logger) {
|
|
|
323
396
|
const priorRenderPresets = runtime.options.renderPresets
|
|
324
397
|
if (request.renderPresets !== undefined) runtime.options.renderPresets = request.renderPresets
|
|
325
398
|
try {
|
|
326
|
-
|
|
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())
|
|
327
404
|
} finally {
|
|
328
405
|
runtime.options.renderPresets = priorRenderPresets
|
|
329
406
|
}
|
|
@@ -373,6 +450,7 @@ export function serveInstance() {
|
|
|
373
450
|
// instance's — a report against the wrong config is the
|
|
374
451
|
// original incident, and it is wrong whether or not it
|
|
375
452
|
// writes anything.
|
|
453
|
+
if (refuseUnknownFlags(socket, request)) return
|
|
376
454
|
const wrongConfig = configMismatch(request.config)
|
|
377
455
|
if (wrongConfig) return refuseConfig(socket, request, wrongConfig)
|
|
378
456
|
const movedFile = await configStale()
|
package/src/plugins/assets.js
CHANGED
|
@@ -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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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
|
@@ -173,5 +173,31 @@ export async function checkReferences(outputFolder, { siteRoots = [] } = {}) {
|
|
|
173
173
|
}
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
+
// "It was never written" and "it is written somewhere else" are different
|
|
177
|
+
// problems with one symptom, and they were reported with one sentence.
|
|
178
|
+
//
|
|
179
|
+
// A missing target whose FILE exists elsewhere in the output is almost
|
|
180
|
+
// always a base problem: the url was built from the wrong root, or with a
|
|
181
|
+
// segment too many. Naming where the file actually is turns "resolves to
|
|
182
|
+
// nothing" into the answer. A target that exists nowhere really was never
|
|
183
|
+
// produced — a preset that did not run, an extension nothing emits.
|
|
184
|
+
//
|
|
185
|
+
// Indexed only when something is broken, so a clean build pays nothing.
|
|
186
|
+
if (broken.size) {
|
|
187
|
+
const byName = new Map()
|
|
188
|
+
for (const file of await globby('**/*', {
|
|
189
|
+
cwd: outputFolder, followSymbolicLinks: true, onlyFiles: true, suppressErrors: true,
|
|
190
|
+
})) {
|
|
191
|
+
const name = path.basename(file)
|
|
192
|
+
if (!byName.has(name)) byName.set(name, [])
|
|
193
|
+
byName.get(name).push(file)
|
|
194
|
+
}
|
|
195
|
+
for (const entry of broken.values()) {
|
|
196
|
+
const elsewhere = (byName.get(path.basename(entry.target)) ?? [])
|
|
197
|
+
.filter(f => f !== entry.target)
|
|
198
|
+
if (elsewhere.length) entry.elsewhere = elsewhere.slice(0, 3)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
176
202
|
return { broken: [...broken.values()], overDeep: [...overDeepRefs.values()], checked }
|
|
177
203
|
}
|