mikser-io 9.7.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.
@@ -59,6 +59,7 @@ These options are part of `runtime.options` and apply to the engine itself.
59
59
  | `threads` | — | number | `4` | Worker thread count for the Piscina pools (`renderWorkers`, `postprocessWorkers`). Both pools are lazy (`minThreads: 0` + `idleTimeout: 30_000`) so INLINE-only workloads spin up zero workers. |
60
60
  | `server` | `-s, --server [port]` | number\|boolean | — | When set, the engine creates a shared Express app on `runtime.options.app` and listens on the given port (default `3001`) after all plugins have mounted their routes. Plugins like `api` attach to it instead of starting their own server. The `outputFolder` is also served as a static catch-all route at `/` (plugin routes match first; anything that doesn't match falls through to the rendered output). Requires `express` to be installed. |
61
61
  | `junk` | — | array\|false | built-in list | OS and file-manager litter, filtered out of both the scan and the watcher. The dot-prefixed files (`.DS_Store`, `._*`) were already invisible — globby defaults to `dot: false` and the watcher ignores leading dots — but the Windows ones are **not** dotfiles: `Thumbs.db` and `desktop.ini` were measurably scanned *and* watched, and became entities. The list is deliberately conservative (OS/file-manager artifacts and application lock files only, no `*.tmp`, `*.bak` or editor backups), because a filter that silently drops content is worse than the litter it prevents. `false` disables it; an array replaces it. See `isJunkPath` / `JUNK_IGNORE` in `src/utils.js`. Plugins that write metadata next to content add their own patterns with `registerJunk({ ignore, match })` — the engine provides the mechanism and the plugin the knowledge of what its files are called (`mikser-io-webdav` registers `*.nephelemeta`). Plugin registrations survive an array override, since narrowing the OS list is not a request to start importing a library's sidecars. |
62
+ | — (plugin) | — | object | — | `sources({ styles: { folder: 'styles', extensions: ['css'] } })` registers build inputs as catalog entities, one collection per key. A sidecar can then read them with `findEntities()`, whose queries land in the render's `refClosure` — so editing, adding or removing a part re-renders the bundle and nothing else. Reading the same files with `fs` instead works for one build and silently breaks watch, because the engine has no dependency on a file it never saw. Nothing is linked into `outputFolder`: these are inputs, not output. Named `sources` rather than `inputs` because `entity.inputs` already means something adjacent — bytes an output depends on without being entities at all. |
62
63
  | `cors` / `no-cors` | `--cors` / `--no-cors` | boolean | — | Toggle CORS on the engine's shared Express app. See `src/server.js` for the extensible header arrays plugins push onto. |
63
64
  | `server.requestTimeout` | — | number | node default (`300000`) | Milliseconds a single request may take, set on the underlying `http.Server`. Node's 5-minute default is effectively an **upload size limit expressed in seconds** — a large file over a slow link is indistinguishable from a stalled request, so it is cut off and the caller sees a truncated write rather than a readable error. Only reachable from the `http.Server`, which the engine owns, so a plugin that mounts an upload surface (`mikser-io-webdav`, `forms` with large attachments) cannot raise it for itself. `0` disables the cap: reasonable on a trusted-network build server, bad facing the internet, where it removes the only bound on how long a client can hold a connection open doing nothing. `headersTimeout` is clamped to stay at or below it. The server is also exposed as `runtime.options.httpServer`. |
64
65
  | `url` | `-u, --url <url>` | string | — | Public URL where this mikser is reachable (e.g. `https://blog.me.com`). Validated, trailing slash stripped, stamped on `runtime.options.url`. Read by webhook-capable plugins for push-vs-poll gating (`url.startsWith('https://')`); used by anything that surfaces absolute URLs externally — MCP preview URLs returned to agents, forms share links, email tracking pixels. Plugins that just need internal URLs keep using `runtime.options.port`. |
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'
@@ -24,6 +25,7 @@ export * from './src/routes.js'
24
25
  // for the v9 plugin shape.
25
26
  export { api } from './src/plugins/api.js'
26
27
  export { assets } from './src/plugins/assets.js'
28
+ export { sources } from './src/plugins/sources.js'
27
29
  export { commands } from './src/plugins/commands.js'
28
30
  export { data } from './src/plugins/data.js'
29
31
  export { documents } from './src/plugins/documents.js'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "9.7.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/config.js CHANGED
@@ -1,12 +1,34 @@
1
1
  import runtime from './runtime.js'
2
2
  import { useLogger } from './engine.js'
3
3
  import { onLoad } from './lifecycle.js'
4
+ import { checksum } from './utils.js'
4
5
  import path from 'node:path'
5
6
 
6
7
  onLoad(async () => {
7
8
  const logger = useLogger()
8
9
  const configFile = path.resolve(runtime.options.config)
9
10
  logger.info('Config: %s', configFile)
11
+
12
+ // Stamp the config so a change to it can invalidate the derived cache.
13
+ //
14
+ // Without this, editing mikser.config.js invalidated NOTHING: flipping
15
+ // an option that changes every page's destination reported "36 unchanged"
16
+ // and left the previous output in place. The config was genuinely read —
17
+ // --force applied it immediately — it simply took part in no
18
+ // invalidation, so the only symptom was output that did not match the
19
+ // config, with nothing saying so.
20
+ //
21
+ // The file's bytes only. A config that imports other modules will not
22
+ // notice a change in those, which is a real limit worth knowing rather
23
+ // than a reason to hash the whole module graph.
24
+ try {
25
+ runtime.options.configChecksum = await checksum(configFile)
26
+ } catch {
27
+ // No config file is a legitimate state (defaults all the way down);
28
+ // absent stamp means "nothing to compare", not "changed".
29
+ runtime.options.configChecksum = null
30
+ }
31
+
10
32
  try {
11
33
  const config = await import(configFile)
12
34
  if (typeof config.default == 'function') {
@@ -247,10 +247,38 @@ export function createSqliteDatabase({
247
247
  handle = new Database(dbPath)
248
248
  setupConnection()
249
249
 
250
- const recorded = handle.prepare('SELECT value FROM mikser_meta WHERE key = ?')
251
- .get('schema_version')?.value
250
+ const stmtMeta = handle.prepare('SELECT value FROM mikser_meta WHERE key = ?')
251
+ const recorded = stmtMeta.get('schema_version')?.value
252
+
253
+ // A config change invalidates the cache for the same reason a version
254
+ // change does: the derived state was computed under different rules.
255
+ //
256
+ // Before this, editing mikser.config.js invalidated nothing — flipping
257
+ // an option that changes every page's destination reported "36
258
+ // unchanged" and left the previous output in place. The config was
259
+ // read; it simply took part in no invalidation, so the only symptom
260
+ // was output that did not match the config and nothing saying so.
261
+ //
262
+ // Treated exactly like a version mismatch rather than something
263
+ // narrower: a config edit can change how sources are PARSED (a mapper
264
+ // transform, documents() options) as well as how they are rendered,
265
+ // so invalidating only the render manifest would still leave stale
266
+ // entities. Per ADR-0002 the files are the source of truth, so
267
+ // rebuilding is always safe — just slower.
268
+ const recordedConfig = stmtMeta.get('config_checksum')?.value
269
+ const currentConfig = runtime.options.configChecksum ?? null
270
+ const configChanged = Boolean(recordedConfig && currentConfig && recordedConfig !== currentConfig)
271
+
252
272
  let upgradedFromVersion = null
253
- if (recorded && recorded !== version) {
273
+ if (configChanged && !(recorded && recorded !== version)) {
274
+ logger?.warn(
275
+ 'Config changed since the last run. Wiping the cache and rebuilding from sources ' +
276
+ '(files are the source of truth — no source data is affected). Note this tracks the ' +
277
+ 'bytes of %s only: a change in a module it imports is not seen.',
278
+ runtime.options.config,
279
+ )
280
+ }
281
+ if ((recorded && recorded !== version) || configChanged) {
254
282
  // Schema mismatch on upgrade or downgrade. Per ADR-0002 the
255
283
  // files on disk are the source of truth and this database
256
284
  // is a derived cache, so the right behavior is to wipe the
@@ -261,10 +289,12 @@ export function createSqliteDatabase({
261
289
  // expect a cold-start rebuild on this run. No data loss
262
290
  // beyond the cache itself; everything in mikser.sqlite is
263
291
  // recoverable from the working folder.
264
- logger?.warn(
265
- 'Database schema mismatch: stored=%s, current=%s. Wiping the cache and rebuilding from sources (files are the source of truth — no source data is affected).',
266
- recorded, version,
267
- )
292
+ if (recorded && recorded !== version) {
293
+ logger?.warn(
294
+ 'Database schema mismatch: stored=%s, current=%s. Wiping the cache and rebuilding from sources (files are the source of truth — no source data is affected).',
295
+ recorded, version,
296
+ )
297
+ }
268
298
  handle.close()
269
299
  handle = null
270
300
 
@@ -277,12 +307,16 @@ export function createSqliteDatabase({
277
307
  }
278
308
  }
279
309
 
280
- upgradedFromVersion = recorded
310
+ // Non-null marks the provisioning context as firstRun/upgraded,
311
+ // which is the right shape for a config change too: what the
312
+ // provisioners see is an empty-state database either way.
313
+ upgradedFromVersion = recorded ?? 'config'
281
314
  handle = new Database(dbPath)
282
315
  setupConnection()
283
316
  }
284
- handle.prepare('INSERT OR REPLACE INTO mikser_meta (key, value) VALUES (?, ?)')
285
- .run('schema_version', version)
317
+ const stmtStamp = handle.prepare('INSERT OR REPLACE INTO mikser_meta (key, value) VALUES (?, ?)')
318
+ stmtStamp.run('schema_version', version)
319
+ if (currentConfig) stmtStamp.run('config_checksum', currentConfig)
286
320
 
287
321
  // Build provisioning context. firstRun is true when the file
288
322
  // didn't exist before this open OR when the schema mismatch
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
@@ -86,19 +87,54 @@ export function assets(options = {}) {
86
87
  const type = 'preset'
87
88
  const checksumMap = new Set()
88
89
 
90
+ // A preset that matches nothing builds green and says nothing, which is
91
+ // how a mistyped pattern ships. `files({ outputFolder })` prefixes `name`
92
+ // and `meta.url` but NOT `id`, and `match` runs against `id` — so
93
+ // '/files/media/devices/**' looks right and matches zero. Tallied here,
94
+ // reported once per process at onFinalize.
95
+ const matchTally = { evaluated: 0, matched: new Set(), reported: false }
96
+
89
97
  async function getEntityPresets(entity) {
90
98
  const entityPresets = []
99
+ matchTally.evaluated++
91
100
  for (let preset in (options.presets || {})) {
92
101
  const { matches } = normalizePresetConfig(options.presets[preset])
93
102
  for (let match of matches) {
94
103
  if (matchEntity(entity, match)) {
95
104
  entityPresets.push(preset)
105
+ matchTally.matched.add(preset)
96
106
  }
97
107
  }
98
108
  }
99
109
  return entityPresets
100
110
  }
101
111
 
112
+ // Report presets that matched none of the entities this run evaluated.
113
+ //
114
+ // Deliberately phrased as what was OBSERVED rather than "this preset is
115
+ // broken": an incremental cycle only re-evaluates changed entities, so a
116
+ // preset can legitimately match nothing in a run of three. The count and
117
+ // the --force hint let the reader tell the two apart, which a bare
118
+ // "matched nothing" would not. Once per process, so watch mode is quiet.
119
+ function reportUnmatchedPresets(logger) {
120
+ if (matchTally.reported) return
121
+ const configured = Object.keys(options.presets || {})
122
+ if (!configured.length || !matchTally.evaluated) return
123
+ matchTally.reported = true
124
+
125
+ for (const preset of configured) {
126
+ if (matchTally.matched.has(preset)) continue
127
+ const { matches } = normalizePresetConfig(options.presets[preset])
128
+ reportWarning('preset-no-match', { preset, evaluated: matchTally.evaluated, patterns: matches })
129
+ logger.warn(
130
+ 'Assets preset %j matched none of the %d entities evaluated (patterns: %s). ' +
131
+ 'Patterns run against entity.id, which files({ outputFolder }) does NOT prefix — ' +
132
+ 'the prefix appears on name and meta.url only. On an incremental run unchanged ' +
133
+ 'entities are not re-evaluated; use --force to check the whole catalog.',
134
+ preset, matchTally.evaluated, matches.join(', '))
135
+ }
136
+ }
137
+
102
138
  // Resolve a preset name to an importable module location. Local
103
139
  // files in presetsFolder win; names with no local file fall back to
104
140
  // an npm package named `mikser-io-preset-<name>`, resolved from the
@@ -437,6 +473,8 @@ export function assets(options = {}) {
437
473
  const logger = useLogger()
438
474
  const { presets } = runtime.state.assets
439
475
 
476
+ reportUnmatchedPresets(logger)
477
+
440
478
  let revisions = await globby('**/*.md5', { cwd: runtime.options.assetsFolder })
441
479
  for (let revision of revisions) {
442
480
  const [preset] = revision.split(path.sep)
@@ -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
  }
@@ -0,0 +1,69 @@
1
+ // Register build inputs — styles/, js/ — as catalog entities.
2
+ //
3
+ // The gap this fills: a site that bundles assets needs a layout sidecar to
4
+ // read those files. Reading them with `fs` works for a one-shot build and
5
+ // SILENTLY breaks watch — the engine has no dependency on a file it never
6
+ // saw, so editing styles/sections/hero.css rebuilds nothing and the page is
7
+ // stale with nothing saying so. The first build is correct, which is what
8
+ // makes it expensive: the mistake only surfaces later as "why didn't my edit
9
+ // take effect", and the obvious workaround is the broken one.
10
+ //
11
+ // Registered as entities instead, a sidecar reads them with findEntities(),
12
+ // whose queries are recorded into the render's refClosure — so touching one
13
+ // part re-renders the bundle and nothing else, by the same mechanism that
14
+ // re-renders a page when a document it references changes.
15
+ //
16
+ // Deliberately NOT the `files` plugin: these are inputs, not output. Nothing
17
+ // is linked into out/, no meta.url is stamped, and they take no part in
18
+ // ADR-0011 served-path resolution. A stylesheet part is not a thing the site
19
+ // serves; it is a thing the site is built from.
20
+ //
21
+ // Named `sources` rather than `inputs` because `entity.inputs` already means
22
+ // something adjacent but different — bytes an entity's output depends on
23
+ // without them being entities at all (see inputHashOf). Two spellings of
24
+ // "input" meaning two things would be worse than a slightly generic name.
25
+ import path from 'node:path'
26
+ import { useSource } from '../source.js'
27
+
28
+ export function sources(options = {}) {
29
+ const collections = Object.entries(options)
30
+ return (core) => {
31
+ const { useLogger } = core
32
+ if (!collections.length) {
33
+ // No collections is a legitimate config (a flag turned them all
34
+ // off); nothing to register, and nothing to complain about.
35
+ return
36
+ }
37
+ for (const [collection, config] of collections) {
38
+ const {
39
+ folder = collection,
40
+ extensions = ['*'],
41
+ ignore = [],
42
+ // Content is the point — a sidecar bundling CSS needs the
43
+ // bytes, not just the path. Overridable for a collection
44
+ // that only needs to be *known* (an image manifest, say).
45
+ content = true,
46
+ load,
47
+ // Code-shaped, so the extension stays in `id` (two parts of
48
+ // the same name in different languages must not collide) and
49
+ // `name` keeps the folder-relative path without it.
50
+ stripExtensionFromId = false,
51
+ } = config ?? {}
52
+
53
+ useSource(core, {
54
+ collection,
55
+ type: 'source',
56
+ folder,
57
+ extensions,
58
+ ignore,
59
+ content,
60
+ stripExtensionFromId,
61
+ load: load ?? (async () => ({})),
62
+ progress: `${collection.replace(/^./, c => c.toUpperCase())} import`,
63
+ })
64
+ }
65
+ useLogger?.()?.debug('Sources registered: %s', collections.map(([c]) => c).join(', '))
66
+ }
67
+ }
68
+
69
+ export default sources
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
@@ -43,7 +43,8 @@ import { globby } from 'globby'
43
43
  import pMap from 'p-map'
44
44
  import runtime from './runtime.js'
45
45
  import { ACTION } from './constants.js'
46
- import { checksum as fileChecksum, junkIgnore } from './utils.js'
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
 
@@ -74,7 +75,14 @@ const SCAN_CONCURRENCY = 16
74
75
  // no SQL) instead of per-file findById. The single-file chokidar
75
76
  // event handler (no scan context) doesn't pass it and falls back to
76
77
  // findById, which is fine for low-frequency one-off mutations.
77
- export async function gateChecksum(file, id, { reload = false, priorChecksums } = {}) {
78
+ // `bytes`, when given, is content the caller has ALREADY read: the checksum
79
+ // is derived from those exact bytes rather than from a second, independent
80
+ // read. That is the whole point — two reads of a file being written can
81
+ // disagree, and the losing combination (empty content + a checksum correct
82
+ // for the finished file) is permanent, because every later sync then
83
+ // short-circuits on "unchanged".
84
+ export async function gateChecksum(file, id, { reload = false, priorChecksums, bytes } = {}) {
85
+ const compute = () => (bytes !== undefined ? checksumOf(bytes) : fileChecksum(file))
78
86
  const canGate = !reload
79
87
  && !runtime.options.force
80
88
  && !runtime.catalog?.cacheInvalidated
@@ -83,12 +91,12 @@ export async function gateChecksum(file, id, { reload = false, priorChecksums }
83
91
  ? priorChecksums.get(id)
84
92
  : findById(id)?.checksum
85
93
  if (priorChecksum) {
86
- const current = await fileChecksum(file)
94
+ const current = await compute()
87
95
  if (priorChecksum === current) return null
88
96
  return current
89
97
  }
90
98
  }
91
- return await fileChecksum(file)
99
+ return await compute()
92
100
  }
93
101
 
94
102
  // Delete sweep. After a scan, find every catalog entity in `collection`
@@ -263,9 +271,17 @@ export function useSource(core, options) {
263
271
  const prefix = idPrefix ?? `/${collection}`
264
272
  const cap = collection.replace(/^./, c => c.toUpperCase())
265
273
  const progressLabel = progress ?? `${cap} import`
274
+ // A SINGLE extension must not go through brace syntax: `**/*.{css}`
275
+ // matches NOTHING in minimatch/globby — a one-element brace is not
276
+ // expanded — so a source declaring one extension silently imported zero
277
+ // files and reported "Styles loaded: 0" as though the folder were empty.
278
+ // Same shape as the other silent-declaration failures: green build,
279
+ // nothing there.
266
280
  const pattern = extensions.includes('*')
267
281
  ? '**/*'
268
- : `**/*.{${extensions.join(',')}}`
282
+ : extensions.length === 1
283
+ ? `**/*.${extensions[0]}`
284
+ : `**/*.{${extensions.join(',')}}`
269
285
 
270
286
  // Hot-reload — chokidar dispatches into onSync(collection) when
271
287
  // a file inside the folder changes.
@@ -413,9 +429,31 @@ export function useSource(core, options) {
413
429
  : `${prefix}/${relativePath.replace(/\\/g, '/')}`
414
430
  scanned?.add(id)
415
431
 
416
- const chksum = await gateChecksum(file, id, { reload, priorChecksums })
432
+ // When this source stores content, read the file ONCE and let both
433
+ // the checksum and the stored body come from the same bytes. The
434
+ // previous shape hashed the file in gateChecksum and then read it
435
+ // again below — two reads, and a torn write between them persisted
436
+ // an empty body next to a valid checksum, permanently.
437
+ //
438
+ // No extra cost: the gate already read the whole file to hash it.
439
+ // Only for `content` sources; large media goes through files.js,
440
+ // which stores no body and must not be slurped into memory.
441
+ let bytes
442
+ if (content) {
443
+ try {
444
+ bytes = await readFile(file)
445
+ } catch (err) {
446
+ logger.warn('%s read failed for %s: %s', collection, name, err.message)
447
+ return
448
+ }
449
+ }
450
+
451
+ const chksum = await gateChecksum(file, id, { reload, priorChecksums, bytes })
417
452
  if (chksum === null) {
418
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()
419
457
  return
420
458
  }
421
459
 
@@ -430,12 +468,9 @@ export function useSource(core, options) {
430
468
  checksum: chksum,
431
469
  }
432
470
  if (content) {
433
- try {
434
- base.content = await readFile(file, 'utf8')
435
- } catch (err) {
436
- logger.warn('%s read failed for %s: %s', collection, name, err.message)
437
- return
438
- }
471
+ // Decoded from the bytes the checksum was taken over — not
472
+ // re-read. See the gate above.
473
+ base.content = bytes.toString('utf8')
439
474
  }
440
475
  try {
441
476
  const extra = await load({ file, name, relativePath, entity: base })
package/src/utils.js CHANGED
@@ -1,9 +1,8 @@
1
1
  import crypto from 'node:crypto'
2
- import { hashFile, hash } from 'hasha'
3
- import { stat, readFile, writeFile, mkdir, unlink } from 'node:fs/promises'
2
+ import { createHash } from 'node:crypto'
3
+ import { hashFile } from 'hasha'
4
+ import { stat, readFile, writeFile, mkdir, unlink, open } from 'node:fs/promises'
4
5
  import { createRequire } from 'node:module'
5
- import TruncateStream from 'truncate-stream'
6
- import { createReadStream } from 'node:fs'
7
6
  import _ from 'lodash'
8
7
  import { minimatch } from 'minimatch'
9
8
  import path from 'path'
@@ -27,6 +26,18 @@ export function inputHashOf(entity) {
27
26
  return crypto.createHash('sha1').update(JSON.stringify({
28
27
  meta: entity.meta ?? null,
29
28
  content: entity.content ?? null,
29
+ // `inputs` is how a plugin declares bytes that are NOT part of the
30
+ // entity's own content but that its output depends on. Whatever is
31
+ // put here participates in the hash, so a change to it invalidates
32
+ // every consumer through the normal refClosure path.
33
+ //
34
+ // The case that needed it: a layout's `.js` sidecar. It is the
35
+ // entity's data layer, it is not its content, and it was in no hash
36
+ // at all — so editing it re-rendered nothing, silently, in a fresh
37
+ // build. Moving the layout's gate `checksum` was not enough, because
38
+ // an entity that HAS content is hashed on {meta, content} and its
39
+ // checksum is ignored. This is the seam that was missing.
40
+ inputs: entity.inputs ?? null,
30
41
  })).digest('hex')
31
42
  }
32
43
 
@@ -301,17 +312,68 @@ export class AbortError extends Error {
301
312
  }
302
313
  }
303
314
 
315
+ const CHECKSUM_MAX_BYTES = 300 * 1024
316
+
317
+ // Checksum from bytes the CALLER ALREADY HAS — no I/O, and therefore no
318
+ // second read to disagree with the first.
319
+ //
320
+ // The hazard this exists to remove: a plugin that stores content does
321
+ //
322
+ // meta: { body: await readFile(source, 'utf8') },
323
+ // checksum: await checksum(source),
324
+ //
325
+ // which is two independent reads of the same file. A watcher firing on the
326
+ // truncate half of a write gets '' from readFile while checksum(), a moment
327
+ // later, sees the finished file. The entity is stored with an empty body and
328
+ // a checksum that is CORRECT FOR THE FINAL CONTENT — so every later sync
329
+ // short-circuits on "unchanged" and the empty body is permanent. Only
330
+ // --clear recovers it, and nothing anywhere reports a problem.
331
+ //
332
+ // Byte-compatible with checksum(uri) below, deliberately: the two must be
333
+ // interchangeable or swapping a caller over would invalidate its catalog.
334
+ export function checksumOf(content) {
335
+ const buf = Buffer.isBuffer(content) ? content : Buffer.from(String(content), 'utf8')
336
+ if (buf.length < CHECKSUM_MAX_BYTES) {
337
+ return createHash('md5').update(buf).digest('hex')
338
+ }
339
+ const head = createHash('md5').update(buf.subarray(0, CHECKSUM_MAX_BYTES)).digest('hex')
340
+ const tail = createHash('md5').update(buf.subarray(buf.length - CHECKSUM_MAX_BYTES)).digest('hex')
341
+ return `${buf.length}:${head}:${tail}`
342
+ }
343
+
344
+ // Checksum a file by path.
345
+ //
346
+ // Files under 300 KB are hashed whole. Larger ones are hashed at both ends
347
+ // plus their length, which reads 600 KB instead of gigabytes — the point of
348
+ // the truncation is that a 1.4 GB video must not be streamed on every cycle.
349
+ //
350
+ // The TAIL is new. The previous form was `size + md5(first 300KB)`, which
351
+ // silently misses any change beyond byte 307200 that preserves the file's
352
+ // length: the checksum matches, the sync reports "unchanged", and the edit
353
+ // is dropped exactly as permanently as the torn-read case above. Hashing
354
+ // both ends does not make this collision-proof — nothing short of a full
355
+ // hash does — but it turns "any late edit of the same length" into
356
+ // "a late edit that also collides on 128 bits".
304
357
  export async function checksum(uri) {
305
- const maxBytes = 300 * 1024
306
358
  const { size } = await stat(uri)
307
- if (size < maxBytes) {
359
+ if (size < CHECKSUM_MAX_BYTES) {
308
360
  return await hashFile(uri, { algorithm: 'md5' })
309
- } else {
310
- const truncate = new TruncateStream({ maxBytes })
311
- const fileStream = createReadStream(uri)
312
- fileStream.pipe(truncate)
313
- const checksum = size.toString() + ':' + await hash(truncate, { algorithm: 'md5' })
314
- return checksum
361
+ }
362
+ const head = await hashRange(uri, 0, CHECKSUM_MAX_BYTES)
363
+ const tail = await hashRange(uri, size - CHECKSUM_MAX_BYTES, CHECKSUM_MAX_BYTES)
364
+ return `${size}:${head}:${tail}`
365
+ }
366
+
367
+ // md5 of `length` bytes starting at `start`. A positional read, so the
368
+ // bytes in between are never touched.
369
+ async function hashRange(uri, start, length) {
370
+ const handle = await open(uri, 'r')
371
+ try {
372
+ const buf = Buffer.allocUnsafe(length)
373
+ const { bytesRead } = await handle.read(buf, 0, length, start)
374
+ return createHash('md5').update(buf.subarray(0, bytesRead)).digest('hex')
375
+ } finally {
376
+ await handle.close()
315
377
  }
316
378
  }
317
379