mikser-io 9.12.0 → 9.14.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/index.js +1 -0
- package/package.json +1 -1
- package/src/engine.js +47 -2
- package/src/explain.js +194 -0
- package/src/logger.js +7 -1
- package/src/manifest.js +42 -10
- package/src/plugins/assets.js +2 -0
- package/src/plugins/commands.js +4 -4
- package/src/plugins/render/preset.js +18 -0
- package/src/report.js +76 -0
- package/src/source.js +4 -0
package/index.js
CHANGED
|
@@ -2,6 +2,7 @@ export { default as runtime } from './src/runtime.js'
|
|
|
2
2
|
export * as constants from './src/constants.js'
|
|
3
3
|
export * from './src/utils.js'
|
|
4
4
|
export * from './src/auth.js'
|
|
5
|
+
export * from './src/report.js'
|
|
5
6
|
export * from './src/lifecycle.js'
|
|
6
7
|
export * from './src/database/index.js'
|
|
7
8
|
export * from './src/journal.js'
|
package/package.json
CHANGED
package/src/engine.js
CHANGED
|
@@ -10,6 +10,7 @@ import { useJournal, updateEntry } from './journal.js'
|
|
|
10
10
|
import { globby } from 'globby'
|
|
11
11
|
import { OPERATION, TASKS } from './constants.js'
|
|
12
12
|
import { changeExtension, formatErrorContext, projectMeta, lookupKeys } from './utils.js'
|
|
13
|
+
import { reportRendered, reportSkipped, emitReport } from './report.js'
|
|
13
14
|
import render from './render.js'
|
|
14
15
|
import postprocess, { loadPlugin as loadPostPlugin } from './postprocess.js'
|
|
15
16
|
import map from 'p-map'
|
|
@@ -115,6 +116,8 @@ export async function setup(options) {
|
|
|
115
116
|
.option('-f --force', 'rebuild everything; disable incremental dispatch', false)
|
|
116
117
|
.option('-R --resume', 'continue from journal entries left by a previous interrupted run; skip the initial filesystem scan', false)
|
|
117
118
|
.option('--verify', 'verify output folder against manifest; report drift instead of building', false)
|
|
119
|
+
.option('--explain <entity>', 'explain one entity — layout, destination, hashes, refClosure, and whether a build would re-render it. Accepts an id, a meta.href, or an id without its extension. Reports instead of building.')
|
|
120
|
+
.option('--json', 'machine-readable output (with --explain, and for a build\'s render/skip/warning report)', false)
|
|
118
121
|
.option('-d --debug', 'display debug statements')
|
|
119
122
|
.option('-t --trace', 'display trace statements')
|
|
120
123
|
.option('-e --runtime-folder <folder>', 'set mikser runtime folder relative to working folder', 'runtime')
|
|
@@ -226,6 +229,25 @@ export async function setup(options) {
|
|
|
226
229
|
// corruption, but state is messy)
|
|
227
230
|
// 2 — errors (missing or mismatched files — output is
|
|
228
231
|
// actually wrong on disk)
|
|
232
|
+
// --explain: report on one entity and exit, like --verify. Placed
|
|
233
|
+
// before it because a caller reaching for both means the explain.
|
|
234
|
+
//
|
|
235
|
+
// Exit codes:
|
|
236
|
+
// 0 — the entity was found and described
|
|
237
|
+
// 3 — not in the catalog (distinct from --verify's 1/2, which are
|
|
238
|
+
// about output drift; "no such entity" is neither clean nor
|
|
239
|
+
// corrupt, it is a question that could not be answered)
|
|
240
|
+
if (runtime.options.explain) {
|
|
241
|
+
const { explain, formatExplain } = await import('./explain.js')
|
|
242
|
+
const report = await explain(runtime.options.explain)
|
|
243
|
+
if (runtime.options.json) {
|
|
244
|
+
process.stdout.write(JSON.stringify(report, null, 2) + '\n')
|
|
245
|
+
} else {
|
|
246
|
+
process.stdout.write(formatExplain(report) + '\n')
|
|
247
|
+
}
|
|
248
|
+
process.exit(report.found ? 0 : 3)
|
|
249
|
+
}
|
|
250
|
+
|
|
229
251
|
if (runtime.options.verify) {
|
|
230
252
|
if (!runtime.manifest) {
|
|
231
253
|
logger.error('Verify: no manifest available — nothing to check against')
|
|
@@ -331,13 +353,21 @@ export async function setup(options) {
|
|
|
331
353
|
// next run. A postprocess-aware manifest that also
|
|
332
354
|
// skips when the postprocess output is current would
|
|
333
355
|
// close the gap — not yet implemented.
|
|
334
|
-
|
|
356
|
+
const decision = options.postprocessor
|
|
357
|
+
// A postprocessor consumes the intermediate rendered
|
|
358
|
+
// file, so skipping would leave its input missing.
|
|
359
|
+
? { skip: false, reason: 'postprocessor' }
|
|
360
|
+
: runtime.manifest?.skipDecision(entity, mutatedRefs, currentHashes, mutatedEntities)
|
|
361
|
+
?? { skip: false, reason: 'no-manifest' }
|
|
362
|
+
if (decision.skip) {
|
|
335
363
|
skipped++
|
|
336
364
|
entry.output = { success: true, skipped: 'manifest' }
|
|
337
365
|
await updateEntry({ id, output: entry.output })
|
|
366
|
+
reportSkipped(entity, decision.reason)
|
|
338
367
|
logger.debug('Manifest skip: %s → %s', entity.name || entity.id, entity.destination)
|
|
339
368
|
return
|
|
340
369
|
}
|
|
370
|
+
reportRendered(entity, decision.reason)
|
|
341
371
|
// Project reference-marker keys (`$author`, `$hero`, …)
|
|
342
372
|
// into their normalized form (`author`, `hero`) before
|
|
343
373
|
// the entity crosses into the renderer — applies whether
|
|
@@ -716,6 +746,10 @@ export async function setup(options) {
|
|
|
716
746
|
}
|
|
717
747
|
}
|
|
718
748
|
logger.notice('Mikser completed')
|
|
749
|
+
// After the cycle, and only under --json. stdout has been kept clear
|
|
750
|
+
// for exactly this (the logger writes to stderr under --json), so the
|
|
751
|
+
// document is the only thing on it and can be piped to jq.
|
|
752
|
+
emitReport()
|
|
719
753
|
})
|
|
720
754
|
|
|
721
755
|
onCancelled(async () => {
|
|
@@ -723,7 +757,18 @@ export async function setup(options) {
|
|
|
723
757
|
logger.notice('Mikser restarted')
|
|
724
758
|
})
|
|
725
759
|
|
|
726
|
-
|
|
760
|
+
// Banner to stderr under --json, for the same reason the logger goes
|
|
761
|
+
// there: stdout must contain only the document.
|
|
762
|
+
//
|
|
763
|
+
// argv directly, not runtime.options: commander parses in a lifecycle
|
|
764
|
+
// hook, which runs after setup() returns, so options.json is still
|
|
765
|
+
// undefined here. The logger has no such problem — it writes during the
|
|
766
|
+
// run, by which time options exist.
|
|
767
|
+
if (runtime.options?.json || process.argv.includes('--json')) {
|
|
768
|
+
process.stderr.write(`mikser. ${packageInfo.version}\n`)
|
|
769
|
+
} else {
|
|
770
|
+
console.info('\x1b[1mmikser\x1b[22;5;38;2;255;63;0m.\x1b[0m %s\n', packageInfo.version)
|
|
771
|
+
}
|
|
727
772
|
return runtime
|
|
728
773
|
}
|
|
729
774
|
|
package/src/explain.js
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// `--explain <entity-id>` — what happened to one entity, and why.
|
|
2
|
+
//
|
|
3
|
+
// Assembly, not new machinery: every line comes from state the engine already
|
|
4
|
+
// keeps (catalog, manifest snapshots, inputHashOf, the layouts matcher's
|
|
5
|
+
// output). It exists because the question asked most often about a build is
|
|
6
|
+
// "why did this NOT change?", and until now the only way to answer it was to
|
|
7
|
+
// read plugin source and hand-query runtime/mikser.sqlite — which works, and
|
|
8
|
+
// needs knowledge a user of the tool should not need.
|
|
9
|
+
//
|
|
10
|
+
// Follows --verify's shape: report and exit, no build phases run.
|
|
11
|
+
import { inputHashOf, lookupKeys, checksum as fileChecksum } from './utils.js'
|
|
12
|
+
import { findEntity } from './catalog.js'
|
|
13
|
+
import runtime from './runtime.js'
|
|
14
|
+
|
|
15
|
+
const shortHash = (h) => (h ? String(h).slice(0, 8) : null)
|
|
16
|
+
const when = (ms) => (ms ? new Date(ms).toISOString().replace('T', ' ').slice(0, 19) : null)
|
|
17
|
+
|
|
18
|
+
// Resolve loosely: an id, a meta.href, or an id without its extension. The
|
|
19
|
+
// same extension-tolerant resolution refs and the catalog already use — so a
|
|
20
|
+
// caller can paste whatever form they have in front of them.
|
|
21
|
+
async function resolve(reference) {
|
|
22
|
+
const direct = await findEntity({ id: reference })
|
|
23
|
+
if (direct) return direct
|
|
24
|
+
const byHref = await findEntity({ 'meta.href': reference })
|
|
25
|
+
if (byHref) return byHref
|
|
26
|
+
// id-minus-extension: /documents/bg/index → /documents/bg/index.md
|
|
27
|
+
const like = await findEntity({ id: { $regex: `^${reference.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\.[^./]+$` } })
|
|
28
|
+
return like ?? null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function explain(reference) {
|
|
32
|
+
const entity = await resolve(reference)
|
|
33
|
+
if (!entity) {
|
|
34
|
+
return {
|
|
35
|
+
found: false,
|
|
36
|
+
reference,
|
|
37
|
+
// The likeliest reasons, in the order they actually happen.
|
|
38
|
+
hint: 'Not in the catalog. Either nothing imported it (check the source plugin\'s folder and extensions), '
|
|
39
|
+
+ 'it was filtered as junk (see the `junk` config), or the id is spelled differently — '
|
|
40
|
+
+ 'try the meta.href or the id without its extension.',
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const snapshots = runtime.manifest?.snapshotsFor(entity.id) ?? []
|
|
45
|
+
const currentHash = inputHashOf(entity)
|
|
46
|
+
|
|
47
|
+
// The catalog is as of the LAST BUILD. If the file has been edited since,
|
|
48
|
+
// nothing here knows it yet — the hashes would all agree and the verdict
|
|
49
|
+
// would say "skipped", which is true of the catalog and misleading about
|
|
50
|
+
// the next build. So check the file too, and say which is being reported.
|
|
51
|
+
//
|
|
52
|
+
// A caveat rather than a bug: some plugins compose a checksum from more
|
|
53
|
+
// than one file (layouts folds in its .js sidecar), so a difference does
|
|
54
|
+
// not always mean the entity's own source moved. Both values are reported
|
|
55
|
+
// and the wording avoids claiming more than is known.
|
|
56
|
+
let source = null
|
|
57
|
+
if (entity.uri) {
|
|
58
|
+
try {
|
|
59
|
+
const onDisk = await fileChecksum(entity.uri)
|
|
60
|
+
source = {
|
|
61
|
+
uri: entity.uri,
|
|
62
|
+
catalogChecksum: entity.checksum ?? null,
|
|
63
|
+
fileChecksum: onDisk,
|
|
64
|
+
differs: entity.checksum != null && entity.checksum !== onDisk,
|
|
65
|
+
}
|
|
66
|
+
} catch (err) {
|
|
67
|
+
source = { uri: entity.uri, error: err.code === 'ENOENT' ? 'file is gone' : err.message }
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
found: true,
|
|
73
|
+
id: entity.id,
|
|
74
|
+
collection: entity.collection,
|
|
75
|
+
type: entity.type,
|
|
76
|
+
name: entity.name,
|
|
77
|
+
// The layouts matcher records both the layout and the pattern that
|
|
78
|
+
// claimed the entity; with several layouts, all of them.
|
|
79
|
+
layouts: (entity.layouts ?? (entity.layout ? [entity.layout] : [])).map(l => ({
|
|
80
|
+
name: l?.name ?? null,
|
|
81
|
+
matchedBy: l?.matchedBy ?? entity.meta?.layoutMatch ?? null,
|
|
82
|
+
format: l?.format ?? null,
|
|
83
|
+
postprocessors: l?.postprocessors ?? (l?.postprocessor ? [l.postprocessor] : []),
|
|
84
|
+
// The layout's OWN declared inputs — its .js sidecar, and the
|
|
85
|
+
// digest covering everything the sidecar imports. Surfaced on the
|
|
86
|
+
// page's report rather than only the layout's, because "does
|
|
87
|
+
// editing this helper module invalidate my page" is asked about
|
|
88
|
+
// the page. Not seeing it is what makes people build a workaround
|
|
89
|
+
// for something already handled.
|
|
90
|
+
inputs: l?.inputs ?? null,
|
|
91
|
+
})),
|
|
92
|
+
destination: entity.destination ?? null,
|
|
93
|
+
lang: entity.meta?.lang ?? null,
|
|
94
|
+
href: entity.meta?.href ?? null,
|
|
95
|
+
// Why a render would or would not happen. `inputHash` is the entity's
|
|
96
|
+
// current hash; each snapshot carries the hash it was rendered at, so
|
|
97
|
+
// the two disagreeing IS the answer to "why did this change".
|
|
98
|
+
inputHash: currentHash,
|
|
99
|
+
inputHashOf: entity.checksum && entity.meta == null && entity.content == null
|
|
100
|
+
? 'checksum'
|
|
101
|
+
: 'meta+content+inputs',
|
|
102
|
+
inputs: entity.inputs ?? null,
|
|
103
|
+
checksum: entity.checksum ?? null,
|
|
104
|
+
source,
|
|
105
|
+
renders: snapshots.map(snap => ({
|
|
106
|
+
destination: snap.destination,
|
|
107
|
+
renderedAt: when(snap.renderedAt),
|
|
108
|
+
inputHash: snap.inputHash,
|
|
109
|
+
// The single most useful field: does this entity's current hash
|
|
110
|
+
// match what it was last rendered at?
|
|
111
|
+
stale: snap.inputHash !== currentHash,
|
|
112
|
+
outputHash: snap.outputHash ?? null,
|
|
113
|
+
parent: snap.parent ?? null,
|
|
114
|
+
refClosure: (snap.refClosure ?? []).map(entry =>
|
|
115
|
+
entry.kind === 'query'
|
|
116
|
+
? { kind: 'query', filter: entry.filter }
|
|
117
|
+
: { kind: entry.kind, target: entry.target, hash: shortHash(entry.hash) }),
|
|
118
|
+
})),
|
|
119
|
+
// What a plain build would do next, stated plainly.
|
|
120
|
+
verdict: source?.error === 'file is gone'
|
|
121
|
+
? 'source file is gone — a build would DELETE this entity and unlink its output'
|
|
122
|
+
: source?.differs
|
|
123
|
+
? 'source differs from the catalog — a build would re-import it first, then re-render. '
|
|
124
|
+
+ '(Some plugins compose a checksum from several files, so verify before concluding.)'
|
|
125
|
+
: snapshots.length === 0
|
|
126
|
+
? 'never rendered — no manifest snapshot. Either it has no layout, or its layout produced no destination.'
|
|
127
|
+
: snapshots.some(s => s.inputHash !== currentHash)
|
|
128
|
+
? 'would re-render — the entity\'s input hash differs from what it was last rendered at'
|
|
129
|
+
: 'would be SKIPPED — input hash unchanged. A dependency in refClosure changing is the only other thing that would re-render it.',
|
|
130
|
+
lookupKeys: lookupKeys(entity),
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Human-readable rendering. Deliberately aligned columns rather than prose:
|
|
135
|
+
// the point is to be scanned, and to be diffable between two runs.
|
|
136
|
+
export function formatExplain(report) {
|
|
137
|
+
const out = []
|
|
138
|
+
const row = (label, value) => out.push(`${label.padEnd(12)}${value}`)
|
|
139
|
+
|
|
140
|
+
if (!report.found) {
|
|
141
|
+
out.push(`not found ${report.reference}`)
|
|
142
|
+
out.push('')
|
|
143
|
+
out.push(report.hint)
|
|
144
|
+
return out.join('\n')
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
row('entity', `${report.id} (${report.collection}/${report.type})`)
|
|
148
|
+
if (report.href) row('href', report.href)
|
|
149
|
+
if (report.lang) row('lang', report.lang)
|
|
150
|
+
if (report.layouts.length) {
|
|
151
|
+
for (const l of report.layouts) {
|
|
152
|
+
row('layout', `${l.name ?? '(unnamed)'}${l.matchedBy ? ` (matched ${JSON.stringify(l.matchedBy)})` : ''}`
|
|
153
|
+
+ (l.postprocessors?.length ? ` → ${l.postprocessors.join(' → ')}` : ''))
|
|
154
|
+
const li = l.inputs && Object.entries(l.inputs).filter(([, v]) => v != null)
|
|
155
|
+
if (li?.length) {
|
|
156
|
+
out.push(` inputs ${li.map(([k, v]) => `${k} ${shortHash(v)}`).join(', ')}`)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
} else {
|
|
160
|
+
row('layout', 'none matched — this entity is not rendered')
|
|
161
|
+
}
|
|
162
|
+
row('destination', report.destination ?? '(none — never assigned)')
|
|
163
|
+
row('inputHash', `${shortHash(report.inputHash)} = ${report.inputHashOf}`)
|
|
164
|
+
if (report.source) {
|
|
165
|
+
row('source', report.source.error
|
|
166
|
+
? `${report.source.uri} [${report.source.error}]`
|
|
167
|
+
: `${report.source.uri}${report.source.differs ? ' [DIFFERS from the catalog — not yet re-imported]' : ''}`)
|
|
168
|
+
}
|
|
169
|
+
if (report.inputs) {
|
|
170
|
+
const parts = Object.entries(report.inputs)
|
|
171
|
+
.filter(([, v]) => v != null)
|
|
172
|
+
.map(([k, v]) => `${k} ${shortHash(v)}`)
|
|
173
|
+
if (parts.length) row('inputs', parts.join(', '))
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (!report.renders.length) {
|
|
177
|
+
row('rendered', 'never')
|
|
178
|
+
}
|
|
179
|
+
for (const r of report.renders) {
|
|
180
|
+
row('rendered', `${r.renderedAt ?? 'unknown'} → ${r.destination}`
|
|
181
|
+
+ (r.stale ? ' [STALE: input hash moved since]' : ' [current]'))
|
|
182
|
+
const closure = r.refClosure
|
|
183
|
+
row('refClosure', `${closure.length} edge${closure.length === 1 ? '' : 's'}`)
|
|
184
|
+
for (const e of closure) {
|
|
185
|
+
out.push(e.kind === 'query'
|
|
186
|
+
? ` query ${JSON.stringify(e.filter)}`
|
|
187
|
+
: ` ${e.kind.padEnd(10)} ${e.target}${e.hash ? ` ${e.hash}` : ''}`)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
out.push('')
|
|
192
|
+
out.push(report.verdict)
|
|
193
|
+
return out.join('\n')
|
|
194
|
+
}
|
package/src/logger.js
CHANGED
|
@@ -122,7 +122,13 @@ function createTerminalStream() {
|
|
|
122
122
|
return new Writable({
|
|
123
123
|
write(chunk, enc, cb) {
|
|
124
124
|
if (gauge) gauge.disable()
|
|
125
|
-
|
|
125
|
+
// --json puts a machine-readable document on stdout, so every
|
|
126
|
+
// log line has to go somewhere else or the document cannot be
|
|
127
|
+
// parsed. stderr rather than silence: the operator still sees
|
|
128
|
+
// the build, and `mikser --explain x --json | jq` still works —
|
|
129
|
+
// which is the entire point of the flag.
|
|
130
|
+
const out = runtime.options?.json ? process.stderr : process.stdout
|
|
131
|
+
out.write(chunk, enc)
|
|
126
132
|
if (gauge) gauge.enable()
|
|
127
133
|
cb()
|
|
128
134
|
},
|
package/src/manifest.js
CHANGED
|
@@ -180,6 +180,10 @@ async function hashOutputFile(destination) {
|
|
|
180
180
|
export function createManifest(db) {
|
|
181
181
|
if (!db) throw new Error('createManifest: db is required')
|
|
182
182
|
|
|
183
|
+
const stmtLookupById = db.prepare(`
|
|
184
|
+
SELECT id, destination, inputHash, outputHash, refClosure, renderedAt, parent
|
|
185
|
+
FROM mikser_snapshots WHERE id = ? ORDER BY destination
|
|
186
|
+
`)
|
|
183
187
|
const stmtLookup = db.prepare(`
|
|
184
188
|
SELECT id, destination, inputHash, outputHash, refClosure, renderedAt, parent
|
|
185
189
|
FROM mikser_snapshots WHERE id = ? AND destination = ?
|
|
@@ -251,23 +255,51 @@ export function createManifest(db) {
|
|
|
251
255
|
return rowToSnap(stmtLookup.get(query.id, query.destination))
|
|
252
256
|
},
|
|
253
257
|
|
|
258
|
+
// EVERY snapshot for an entity, not just the one at a known
|
|
259
|
+
// destination. An entity can have several — one per matched layout,
|
|
260
|
+
// one per paginated page — and a caller asking "what happened to
|
|
261
|
+
// this?" does not know the destinations in advance. That is exactly
|
|
262
|
+
// the position an operator (or an agent) is in when a page did not
|
|
263
|
+
// change and they want to know why.
|
|
264
|
+
snapshotsFor(id) {
|
|
265
|
+
if (!id) return []
|
|
266
|
+
return stmtLookupById.all(id).map(rowToSnap)
|
|
267
|
+
},
|
|
268
|
+
|
|
254
269
|
// Should this render be skipped? See the original docstring in
|
|
255
270
|
// the prior NDJSON-backed implementation — logic is unchanged,
|
|
256
271
|
// backing storage is the only thing that changed.
|
|
272
|
+
// Boolean wrapper, kept because that is what the render loop asked
|
|
273
|
+
// for first. skipDecision carries the same logic plus WHY, which is
|
|
274
|
+
// what a machine-readable build report needs — "Rendered: 16" is a
|
|
275
|
+
// number nobody can assert on.
|
|
257
276
|
shouldSkip(entity, mutatedRefs, currentHashes, mutatedEntities) {
|
|
258
|
-
|
|
277
|
+
return this.skipDecision(entity, mutatedRefs, currentHashes, mutatedEntities).skip
|
|
278
|
+
},
|
|
279
|
+
|
|
280
|
+
// { skip, reason } — reason is set either way, and the vocabulary is
|
|
281
|
+
// stable so it can be asserted against:
|
|
282
|
+
//
|
|
283
|
+
// unchanged nothing this render depends on moved
|
|
284
|
+
// never-rendered no snapshot: first build, or it never rendered
|
|
285
|
+
// inputs-changed the entity's own hash moved
|
|
286
|
+
// ref-changed a $-ref or partial it depends on moved
|
|
287
|
+
// query-matched an entity matching a recorded query mutated
|
|
288
|
+
// cache-disabled meta.cache === false
|
|
289
|
+
skipDecision(entity, mutatedRefs, currentHashes, mutatedEntities) {
|
|
290
|
+
if (entity?.meta?.cache === false) return { skip: false, reason: 'cache-disabled' }
|
|
259
291
|
const snapshot = this.lookup(entity)
|
|
260
|
-
if (!snapshot?.inputHash) return false
|
|
261
|
-
if (inputHashOf(entity) !== snapshot.inputHash) return false
|
|
262
|
-
if (!snapshot.refClosure?.length) return true
|
|
292
|
+
if (!snapshot?.inputHash) return { skip: false, reason: 'never-rendered' }
|
|
293
|
+
if (inputHashOf(entity) !== snapshot.inputHash) return { skip: false, reason: 'inputs-changed' }
|
|
294
|
+
if (!snapshot.refClosure?.length) return { skip: true, reason: 'unchanged' }
|
|
263
295
|
const sourceLang = entity?.meta?.lang ?? null
|
|
264
296
|
for (const entry of snapshot.refClosure) {
|
|
265
297
|
if (entry.kind === 'query') {
|
|
266
|
-
if (!entry.filter) return false
|
|
298
|
+
if (!entry.filter) return { skip: false, reason: 'query-matched' }
|
|
267
299
|
if (!mutatedEntities?.size) continue
|
|
268
300
|
const matcher = sift(entry.filter)
|
|
269
301
|
for (const mutated of mutatedEntities.values()) {
|
|
270
|
-
if (matcher(mutated)) return false
|
|
302
|
+
if (matcher(mutated)) return { skip: false, reason: 'query-matched' }
|
|
271
303
|
}
|
|
272
304
|
continue
|
|
273
305
|
}
|
|
@@ -286,13 +318,13 @@ export function createManifest(db) {
|
|
|
286
318
|
continue
|
|
287
319
|
}
|
|
288
320
|
}
|
|
289
|
-
if (!entry.hash) return false
|
|
321
|
+
if (!entry.hash) return { skip: false, reason: 'ref-changed' }
|
|
290
322
|
const currentHash = currentHashes?.get(entry.target)
|
|
291
323
|
if (currentHash === undefined) continue
|
|
292
|
-
if (currentHash === null) return false
|
|
293
|
-
if (currentHash !== entry.hash) return false
|
|
324
|
+
if (currentHash === null) return { skip: false, reason: 'ref-changed' }
|
|
325
|
+
if (currentHash !== entry.hash) return { skip: false, reason: 'ref-changed' }
|
|
294
326
|
}
|
|
295
|
-
return true
|
|
327
|
+
return { skip: true, reason: 'unchanged' }
|
|
296
328
|
},
|
|
297
329
|
|
|
298
330
|
// Record a successful render. Single INSERT OR REPLACE.
|
package/src/plugins/assets.js
CHANGED
|
@@ -5,6 +5,7 @@ import { createRequire } from 'node:module'
|
|
|
5
5
|
import { globby } from 'globby'
|
|
6
6
|
import _ from 'lodash'
|
|
7
7
|
import map from 'p-map'
|
|
8
|
+
import { reportWarning } from '../report.js'
|
|
8
9
|
|
|
9
10
|
// Normalize a `options.presets[name]` value to a consistent
|
|
10
11
|
// { matches, options } shape so callers don't have to inspect which form
|
|
@@ -124,6 +125,7 @@ export function assets(options = {}) {
|
|
|
124
125
|
for (const preset of configured) {
|
|
125
126
|
if (matchTally.matched.has(preset)) continue
|
|
126
127
|
const { matches } = normalizePresetConfig(options.presets[preset])
|
|
128
|
+
reportWarning('preset-no-match', { preset, evaluated: matchTally.evaluated, patterns: matches })
|
|
127
129
|
logger.warn(
|
|
128
130
|
'Assets preset %j matched none of the %d entities evaluated (patterns: %s). ' +
|
|
129
131
|
'Patterns run against entity.id, which files({ outputFolder }) does NOT prefix — ' +
|
package/src/plugins/commands.js
CHANGED
|
@@ -31,16 +31,16 @@ export function commands(options = {}) {
|
|
|
31
31
|
if (_.endsWith(command, '&')) {
|
|
32
32
|
command = command.slice(0, -1)
|
|
33
33
|
if (!running[command]) {
|
|
34
|
-
logger.info('Command: %s', command, runtime.options.
|
|
35
|
-
const subprocess = execaCommand(command, { cwd: runtime.options.
|
|
34
|
+
logger.info('Command: %s', command, runtime.options.workingFolder)
|
|
35
|
+
const subprocess = execaCommand(command, { cwd: runtime.options.workingFolder, all: true })
|
|
36
36
|
eachLine(subprocess.all, line => logger.info(line))
|
|
37
37
|
running[command] = subprocess
|
|
38
38
|
.then(() => delete running[command])
|
|
39
39
|
.catch(err => logger.error(err, 'Command error'))
|
|
40
40
|
}
|
|
41
41
|
} else {
|
|
42
|
-
logger.info('Command: %s', command, runtime.options.
|
|
43
|
-
const subprocess = execaCommand(command, { cwd: runtime.options.
|
|
42
|
+
logger.info('Command: %s', command, runtime.options.workingFolder)
|
|
43
|
+
const subprocess = execaCommand(command, { cwd: runtime.options.workingFolder, all: true })
|
|
44
44
|
await eachLine(subprocess.all, line => logger.debug(line))
|
|
45
45
|
await subprocess
|
|
46
46
|
}
|
|
@@ -1,7 +1,25 @@
|
|
|
1
1
|
import { mkdir } from 'node:fs/promises'
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
|
|
4
|
+
// A renderer's `load` runs for EVERY entity in the cycle, not only the ones
|
|
5
|
+
// this renderer will render — that is deliberate and is how renderAsset
|
|
6
|
+
// installs runtime.asset() for all templates. So this has to tolerate an
|
|
7
|
+
// entity that has no preset, rather than assume it is looking at one.
|
|
8
|
+
//
|
|
9
|
+
// Without the guard, adding renderPreset() to a project's plugin list made
|
|
10
|
+
// every page render throw on `entity.preset.uri` — and a crash reads as
|
|
11
|
+
// "you have found something real", which is a much more expensive wrong
|
|
12
|
+
// signal than a no-op. The names invite exactly that mistake:
|
|
13
|
+
//
|
|
14
|
+
// renderAsset() provides runtime.asset() to templates (a URL helper)
|
|
15
|
+
// assets() runs presets and produces derivatives (the work)
|
|
16
|
+
// renderPreset() renders a preset-authored layout (this file)
|
|
17
|
+
//
|
|
18
|
+
// All three are named after the object they concern rather than the job they
|
|
19
|
+
// do, so reasoning "the one that RUNS presets must be renderPreset" is wrong
|
|
20
|
+
// but not unreasonable.
|
|
4
21
|
export async function load({ entity, runtime }) {
|
|
22
|
+
if (!entity?.preset?.uri) return
|
|
5
23
|
const preset = await import(`${entity.preset.uri}?stamp=${Date.now()}`)
|
|
6
24
|
runtime.preset = preset.default
|
|
7
25
|
}
|
package/src/report.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// The machine-readable build report behind `--json`.
|
|
2
|
+
//
|
|
3
|
+
// `Rendered: 16` is a number nobody can assert on. Verifying "did my change
|
|
4
|
+
// land" without this means diffing the output folder against a snapshot taken
|
|
5
|
+
// beforehand — a lot of ceremony for one question.
|
|
6
|
+
//
|
|
7
|
+
// The valuable field is `reason`, not the counts. And a stable `code` on a
|
|
8
|
+
// warning matters more than its prose: it lets a caller assert "this build
|
|
9
|
+
// produced no preset-no-match" instead of grepping a sentence that may be
|
|
10
|
+
// reworded — which is exactly the kind of assertion that should not break
|
|
11
|
+
// when someone improves the wording.
|
|
12
|
+
import runtime from './runtime.js'
|
|
13
|
+
|
|
14
|
+
function store() {
|
|
15
|
+
runtime.state ??= {}
|
|
16
|
+
runtime.state.report ??= { rendered: [], skipped: [], warnings: [], gated: 0 }
|
|
17
|
+
return runtime.state.report
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// An entity whose SOURCE did not change is gated at import and never becomes
|
|
21
|
+
// a render task at all — so it appears in neither `rendered` nor `skipped`,
|
|
22
|
+
// and the two lists would not reconcile with the corpus size without saying
|
|
23
|
+
// so. Counted rather than listed: on a 14k-entity site the list would be
|
|
24
|
+
// almost the whole catalog on almost every build, which is noise in a
|
|
25
|
+
// document meant to answer "did my change land".
|
|
26
|
+
export function reportGated(count = 1) {
|
|
27
|
+
if (!runtime.options?.json) return
|
|
28
|
+
store().gated += count
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function reportRendered(entity, reason) {
|
|
32
|
+
if (!runtime.options?.json) return
|
|
33
|
+
store().rendered.push({ id: entity?.id, destination: entity?.destination ?? null, reason })
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function reportSkipped(entity, reason) {
|
|
37
|
+
if (!runtime.options?.json) return
|
|
38
|
+
store().skipped.push({ id: entity?.id, destination: entity?.destination ?? null, reason })
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Called ALONGSIDE logger.warn, not instead of it: a human reading the
|
|
42
|
+
// terminal still needs the sentence, and the sentence is where the
|
|
43
|
+
// explanation lives. This carries the assertable part.
|
|
44
|
+
//
|
|
45
|
+
// `code` is the contract. Add fields freely; renaming a code is a breaking
|
|
46
|
+
// change to anyone asserting on it.
|
|
47
|
+
export function reportWarning(code, fields = {}) {
|
|
48
|
+
if (!runtime.options?.json) return
|
|
49
|
+
store().warnings.push({ code, ...fields })
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function buildReport() {
|
|
53
|
+
const report = store()
|
|
54
|
+
return {
|
|
55
|
+
rendered: report.rendered,
|
|
56
|
+
skipped: report.skipped,
|
|
57
|
+
warnings: report.warnings,
|
|
58
|
+
summary: {
|
|
59
|
+
rendered: report.rendered.length,
|
|
60
|
+
// Renders that were CONSIDERED and skipped by the manifest.
|
|
61
|
+
skipped: report.skipped.length,
|
|
62
|
+
// Entities gated at import because their source was unchanged, so
|
|
63
|
+
// no render was ever scheduled. Different question, different
|
|
64
|
+
// number — see reportGated.
|
|
65
|
+
gated: report.gated,
|
|
66
|
+
warnings: report.warnings.length,
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Emitted once, after the cycle, on stdout — which the logger has vacated
|
|
72
|
+
// under --json precisely so this can be the only thing there.
|
|
73
|
+
export function emitReport() {
|
|
74
|
+
if (!runtime.options?.json) return
|
|
75
|
+
process.stdout.write(JSON.stringify(buildReport(), null, 2) + '\n')
|
|
76
|
+
}
|
package/src/source.js
CHANGED
|
@@ -44,6 +44,7 @@ import pMap from 'p-map'
|
|
|
44
44
|
import runtime from './runtime.js'
|
|
45
45
|
import { ACTION } from './constants.js'
|
|
46
46
|
import { checksum as fileChecksum, checksumOf, junkIgnore } from './utils.js'
|
|
47
|
+
import { reportGated } from './report.js'
|
|
47
48
|
import { findById, findEntities, checksumsByCollection } from './catalog.js'
|
|
48
49
|
import { useDatabase } from './database/index.js'
|
|
49
50
|
|
|
@@ -450,6 +451,9 @@ export function useSource(core, options) {
|
|
|
450
451
|
const chksum = await gateChecksum(file, id, { reload, priorChecksums, bytes })
|
|
451
452
|
if (chksum === null) {
|
|
452
453
|
if (stats) stats.skipped++
|
|
454
|
+
// Never becomes a render task, so it would otherwise be invisible
|
|
455
|
+
// in the --json report. Counted, not listed.
|
|
456
|
+
reportGated()
|
|
453
457
|
return
|
|
454
458
|
}
|
|
455
459
|
|