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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "9.12.0",
3
+ "version": "9.14.0",
4
4
  "description": "<p align=\"center\"> <img src=\"mikser-lockup-stacked.svg\" alt=\"mikser\" width=\"198\" /> </p>",
5
5
  "main": "index.js",
6
6
  "exports": {
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
- if (!options.postprocessor && runtime.manifest?.shouldSkip(entity, mutatedRefs, currentHashes, mutatedEntities)) {
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
- console.info('\x1b[1mmikser\x1b[22;5;38;2;255;63;0m.\x1b[0m %s\n', packageInfo.version)
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
- process.stdout.write(chunk, enc)
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
- if (entity?.meta?.cache === false) return false
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.
@@ -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 — ' +
@@ -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.wokrkingFolder)
35
- const subprocess = execaCommand(command, { cwd: runtime.options.wokrkingFolder, all: true })
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.wokrkingFolder)
43
- const subprocess = execaCommand(command, { cwd: runtime.options.wokrkingFolder, all: true })
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