mikser-io 9.20.0 → 9.21.1
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/docs/diagnostics.md +44 -4
- package/index.js +5 -5
- package/package.json +1 -1
- package/src/catalog.js +4 -2
- package/src/config.js +5 -7
- package/src/engine.js +1 -1
- package/src/explain.js +33 -5
- package/src/manifest.js +43 -11
- package/src/plugins/api.js +3 -2
- package/src/plugins/render/preset.js +2 -2
- package/src/postprocess.js +6 -6
- package/src/report.js +9 -2
- package/src/utils.js +68 -4
package/docs/diagnostics.md
CHANGED
|
@@ -35,10 +35,29 @@ npx mikser --explain /documents/en/posts/hello.md
|
|
|
35
35
|
```
|
|
36
36
|
|
|
37
37
|
It prints the entity's layout and **why that layout matched**, its
|
|
38
|
-
destination, its `inputHash
|
|
39
|
-
the
|
|
40
|
-
|
|
41
|
-
|
|
38
|
+
destination, its `inputHash` and the components that went into it,
|
|
39
|
+
whether the file on disk still agrees with the catalog, every recorded
|
|
40
|
+
render with its `refClosure`, and a verdict in plain words.
|
|
41
|
+
|
|
42
|
+
When an entity's inputs have moved since a render, the verdict names what
|
|
43
|
+
moved rather than only that something did:
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
would re-render — meta.title changed since it was last rendered
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
and the render line carries the same detail per snapshot:
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
rendered 2026-08-22 21:07:52 → /page-a.html [STALE: input hash moved since]
|
|
53
|
+
moved content, meta.weight (added)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
A snapshot written before per-input recording says so rather than
|
|
57
|
+
guessing. Note that `--explain` compares the CATALOG's entity against the
|
|
58
|
+
snapshot: if you have edited a file and not yet built, the verdict is
|
|
59
|
+
`source differs from the catalog` — the edit has not been imported yet, so
|
|
60
|
+
there is nothing to attribute. Build, then ask.
|
|
42
61
|
|
|
43
62
|
Each `refClosure` edge shows the name that was asked for and the entity
|
|
44
63
|
it bound to, and flags the ones that bound to nothing:
|
|
@@ -77,6 +96,27 @@ Four buckets, and the distinction between them is the point:
|
|
|
77
96
|
`never-rendered`, `inputs-changed`, `ref-changed`, `query-matched`,
|
|
78
97
|
`cache-disabled`, `postprocessor`, `force`, `no-manifest`.
|
|
79
98
|
|
|
99
|
+
`inputs-changed` carries a `changed` array naming **which** input moved,
|
|
100
|
+
so you do not have to go to the database to find out:
|
|
101
|
+
|
|
102
|
+
```json
|
|
103
|
+
{ "id": "/files/hero.jpg", "reason": "inputs-changed", "changed": ["checksum"] }
|
|
104
|
+
{ "id": "/documents/page.md", "reason": "inputs-changed", "changed": ["meta.title"] }
|
|
105
|
+
{ "id": "/documents/page.md", "reason": "inputs-changed", "changed": ["content"] }
|
|
106
|
+
{ "id": "/layouts/post.hbs", "reason": "inputs-changed", "changed": ["inputs.shared"] }
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`checksum` means the bytes on disk moved; `content` means the body did;
|
|
110
|
+
`meta.<field>` names the front-matter field; `inputs.<key>` is a declared
|
|
111
|
+
input such as a layout's sidecar digest. A field that appeared or vanished
|
|
112
|
+
reads as `meta.weight (added)` / `(removed)`, which is the answer when a
|
|
113
|
+
document gains or loses front-matter.
|
|
114
|
+
|
|
115
|
+
The array is absent on a first render — there is no prior snapshot to
|
|
116
|
+
compare against — and on `ref-changed`, because that is a dependency
|
|
117
|
+
moving rather than the entity's own inputs. Conflating the two would make
|
|
118
|
+
the attribution misleading.
|
|
119
|
+
|
|
80
120
|
`unchanged` is the interesting one. It means invalidation was coarser
|
|
81
121
|
than it needed to be — the render was scheduled, ran, and produced
|
|
82
122
|
nothing new. A high count is not a bug, but it tells you where the
|
package/index.js
CHANGED
|
@@ -50,11 +50,11 @@ export { renderHbs } from './src/plugins/render/hbs.js'
|
|
|
50
50
|
export { renderPreset } from './src/plugins/render/preset.js'
|
|
51
51
|
|
|
52
52
|
// Template helpers — these only install functions on `runtime` for templates
|
|
53
|
-
// to call. They render nothing,
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
// render throw.
|
|
53
|
+
// to call. They render nothing, and the names say so: two of the six
|
|
54
|
+
// factories in this folder are renderers and four are not, so each is named
|
|
55
|
+
// after the job it does rather than the object it concerns. Naming them all
|
|
56
|
+
// `render*` invites adding renderPreset() expecting a helper and watching
|
|
57
|
+
// every page render throw. There are no aliases for the older names.
|
|
58
58
|
export { assetUrlHelper } from './src/plugins/render/asset.js'
|
|
59
59
|
export { hrefUrlHelpers } from './src/plugins/render/href.js'
|
|
60
60
|
export { resourceUrlHelper } from './src/plugins/render/resource.js'
|
package/package.json
CHANGED
package/src/catalog.js
CHANGED
|
@@ -360,8 +360,10 @@ onFinalize(async () => {
|
|
|
360
360
|
}
|
|
361
361
|
})
|
|
362
362
|
|
|
363
|
-
// Snapshot the catalog as `{ version, entities: [...] }
|
|
364
|
-
//
|
|
363
|
+
// Snapshot the catalog as `{ version, entities: [...] }` — the schema
|
|
364
|
+
// version plus every entity body. O(N) in both time and memory, so it is
|
|
365
|
+
// for debugging and inspection, never a hot path; `iterateEntities`
|
|
366
|
+
// streams when the result set might be corpus-scale.
|
|
365
367
|
function exportCatalog() {
|
|
366
368
|
if (!db?.isOpen) return { version: null, entities: [] }
|
|
367
369
|
const rows = stmtAllData.all()
|
package/src/config.js
CHANGED
|
@@ -56,11 +56,9 @@ onLoad(async () => {
|
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
// files have nothing to bind to. The loader was removed when the
|
|
65
|
-
// plugins list stopped carrying names.
|
|
59
|
+
// Nothing else is loaded. There is deliberately no `config/<plugin>
|
|
60
|
+
// .config.js` channel: plugin options arrive as factory arguments
|
|
61
|
+
// (ADR-0010), and an entry in `plugins` is a factory call result — a
|
|
62
|
+
// closure or a descriptor — carrying no name to bind an auxiliary file
|
|
63
|
+
// to. A plugin wanting file-based config reads it itself.
|
|
66
64
|
})
|
package/src/engine.js
CHANGED
|
@@ -374,7 +374,7 @@ export async function setup(options) {
|
|
|
374
374
|
logger.debug('Manifest skip: %s → %s', entity.name || entity.id, entity.destination)
|
|
375
375
|
return
|
|
376
376
|
}
|
|
377
|
-
reportRendered(entity, decision.reason)
|
|
377
|
+
reportRendered(entity, decision.reason, decision.changed)
|
|
378
378
|
// Project reference-marker keys (`$author`, `$hero`, …)
|
|
379
379
|
// into their normalized form (`author`, `hero`) before
|
|
380
380
|
// the entity crosses into the renderer — applies whether
|
package/src/explain.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// needs knowledge a user of the tool should not need.
|
|
9
9
|
//
|
|
10
10
|
// Follows --verify's shape: report and exit, no build phases run.
|
|
11
|
-
import { inputHashOf, lookupKeys, checksum as fileChecksum } from './utils.js'
|
|
11
|
+
import { inputHashOf, inputPartsOf, diffInputParts, lookupKeys, checksum as fileChecksum } from './utils.js'
|
|
12
12
|
import { findEntity } from './catalog.js'
|
|
13
13
|
import runtime from './runtime.js'
|
|
14
14
|
|
|
@@ -28,6 +28,22 @@ async function resolve(reference) {
|
|
|
28
28
|
return like ?? null
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
// The verdict line names what moved when it can. That line is the one
|
|
32
|
+
// people read, so "the input hash differs" there is the answer stopping one
|
|
33
|
+
// step short of useful.
|
|
34
|
+
function renderVerdict(snapshots, currentHash, currentParts) {
|
|
35
|
+
const moved = new Set()
|
|
36
|
+
for (const snap of snapshots) {
|
|
37
|
+
if (snap.inputHash === currentHash || !snap.inputParts) continue
|
|
38
|
+
const d = diffInputParts(snap.inputParts, currentParts)
|
|
39
|
+
for (const key of [...d.changed, ...d.added, ...d.removed]) moved.add(key)
|
|
40
|
+
}
|
|
41
|
+
if (!moved.size) {
|
|
42
|
+
return 'would re-render — the entity\'s input hash differs from what it was last rendered at'
|
|
43
|
+
}
|
|
44
|
+
return `would re-render — ${[...moved].join(', ')} changed since it was last rendered`
|
|
45
|
+
}
|
|
46
|
+
|
|
31
47
|
export async function explain(reference) {
|
|
32
48
|
const entity = await resolve(reference)
|
|
33
49
|
if (!entity) {
|
|
@@ -43,6 +59,7 @@ export async function explain(reference) {
|
|
|
43
59
|
|
|
44
60
|
const snapshots = runtime.manifest?.snapshotsFor(entity.id) ?? []
|
|
45
61
|
const currentHash = inputHashOf(entity)
|
|
62
|
+
const currentParts = inputPartsOf(entity)
|
|
46
63
|
|
|
47
64
|
// The catalog is as of the LAST BUILD. If the file has been edited since,
|
|
48
65
|
// nothing here knows it yet — the hashes would all agree and the verdict
|
|
@@ -96,9 +113,11 @@ export async function explain(reference) {
|
|
|
96
113
|
// current hash; each snapshot carries the hash it was rendered at, so
|
|
97
114
|
// the two disagreeing IS the answer to "why did this change".
|
|
98
115
|
inputHash: currentHash,
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
116
|
+
// The components that actually went into the hash for THIS entity,
|
|
117
|
+
// read off the parts rather than restated — a hardcoded label drifts
|
|
118
|
+
// the moment the payload changes, and this one had.
|
|
119
|
+
inputHashOf: [...new Set(Object.keys(currentParts).map(k => k.split('.')[0]))].join('+')
|
|
120
|
+
|| 'nothing',
|
|
102
121
|
inputs: entity.inputs ?? null,
|
|
103
122
|
checksum: entity.checksum ?? null,
|
|
104
123
|
source,
|
|
@@ -109,6 +128,15 @@ export async function explain(reference) {
|
|
|
109
128
|
// The single most useful field: does this entity's current hash
|
|
110
129
|
// match what it was last rendered at?
|
|
111
130
|
stale: snap.inputHash !== currentHash,
|
|
131
|
+
// WHICH input moved, not merely that one did. This is the whole
|
|
132
|
+
// question behind "why did this re-render" — answering it from
|
|
133
|
+
// the recorded parts costs nothing, and not answering it sends
|
|
134
|
+
// the reader to a database query for something already here.
|
|
135
|
+
moved: snap.inputHash === currentHash
|
|
136
|
+
? null
|
|
137
|
+
: snap.inputParts
|
|
138
|
+
? diffInputParts(snap.inputParts, currentParts)
|
|
139
|
+
: 'unknown',
|
|
112
140
|
outputHash: snap.outputHash ?? null,
|
|
113
141
|
parent: snap.parent ?? null,
|
|
114
142
|
refClosure: (snap.refClosure ?? []).map(entry =>
|
|
@@ -136,7 +164,7 @@ export async function explain(reference) {
|
|
|
136
164
|
: snapshots.length === 0
|
|
137
165
|
? 'never rendered — no manifest snapshot. Either it has no layout, or its layout produced no destination.'
|
|
138
166
|
: snapshots.some(s => s.inputHash !== currentHash)
|
|
139
|
-
?
|
|
167
|
+
? renderVerdict(snapshots, currentHash, currentParts)
|
|
140
168
|
: 'would be SKIPPED — input hash unchanged. A dependency in refClosure changing is the only other thing that would re-render it.',
|
|
141
169
|
lookupKeys: lookupKeys(entity),
|
|
142
170
|
}
|
package/src/manifest.js
CHANGED
|
@@ -56,7 +56,7 @@ import { useLogger } from './engine.js'
|
|
|
56
56
|
import { onLoaded, onFinalize } from './lifecycle.js'
|
|
57
57
|
import { useJournal } from './journal.js'
|
|
58
58
|
import { OPERATION } from './constants.js'
|
|
59
|
-
import { extractRefs, inputHashOf } from './utils.js'
|
|
59
|
+
import { extractRefs, inputHashOf, inputPartsOf, diffInputParts } from './utils.js'
|
|
60
60
|
import { filterKey } from './track.js'
|
|
61
61
|
import { findById } from './catalog.js'
|
|
62
62
|
import { useDatabase, registerSchema } from './database/index.js'
|
|
@@ -74,6 +74,7 @@ export const SNAPSHOTS_SCHEMA = `
|
|
|
74
74
|
id TEXT NOT NULL,
|
|
75
75
|
destination TEXT NOT NULL,
|
|
76
76
|
inputHash TEXT,
|
|
77
|
+
inputParts TEXT,
|
|
77
78
|
outputHash TEXT,
|
|
78
79
|
refClosure TEXT,
|
|
79
80
|
renderedAt INTEGER,
|
|
@@ -97,6 +98,20 @@ function sha1(payload) {
|
|
|
97
98
|
}
|
|
98
99
|
|
|
99
100
|
// refClosure builder — same logic as before, no DB involvement.
|
|
101
|
+
// Which input moved, as a flat list of part names ('content',
|
|
102
|
+
// 'meta.title', 'checksum', 'inputs.shared'). Empty when the snapshot
|
|
103
|
+
// predates part recording — the combined hash still says the entity
|
|
104
|
+
// changed, and saying nothing is better than guessing which part.
|
|
105
|
+
function describeInputChange(entity, snapshot) {
|
|
106
|
+
if (!snapshot?.inputParts) return []
|
|
107
|
+
const { changed, added, removed } = diffInputParts(snapshot.inputParts, inputPartsOf(entity))
|
|
108
|
+
return [
|
|
109
|
+
...changed,
|
|
110
|
+
...added.map(key => `${key} (added)`),
|
|
111
|
+
...removed.map(key => `${key} (removed)`),
|
|
112
|
+
]
|
|
113
|
+
}
|
|
114
|
+
|
|
100
115
|
function buildRefClosure(entity, deps) {
|
|
101
116
|
const closure = []
|
|
102
117
|
const seen = new Set()
|
|
@@ -155,6 +170,11 @@ function buildSnapshot(entity, deps, outputHash) {
|
|
|
155
170
|
id: entity.id,
|
|
156
171
|
destination: entity.destination,
|
|
157
172
|
inputHash: inputHashOf(entity),
|
|
173
|
+
// Per-component hashes of the same payload the combined hash covers,
|
|
174
|
+
// so a later run can name WHICH input moved instead of only that one
|
|
175
|
+
// did. Without it `inputs-changed` sends the reader to a database
|
|
176
|
+
// query for something the tool already has in hand.
|
|
177
|
+
inputParts: inputPartsOf(entity),
|
|
158
178
|
refClosure: buildRefClosure(entity, deps),
|
|
159
179
|
renderedAt: Date.now(),
|
|
160
180
|
}
|
|
@@ -171,6 +191,7 @@ function rowToSnap(row) {
|
|
|
171
191
|
id: row.id,
|
|
172
192
|
destination: row.destination,
|
|
173
193
|
inputHash: row.inputHash ?? undefined,
|
|
194
|
+
inputParts: row.inputParts ? JSON.parse(row.inputParts) : undefined,
|
|
174
195
|
outputHash: row.outputHash ?? undefined,
|
|
175
196
|
refClosure: row.refClosure ? JSON.parse(row.refClosure) : undefined,
|
|
176
197
|
renderedAt: row.renderedAt ?? undefined,
|
|
@@ -183,6 +204,7 @@ function snapToRow(snap) {
|
|
|
183
204
|
id: snap.id,
|
|
184
205
|
destination: snap.destination,
|
|
185
206
|
inputHash: snap.inputHash ?? null,
|
|
207
|
+
inputParts: snap.inputParts ? JSON.stringify(snap.inputParts) : null,
|
|
186
208
|
outputHash: snap.outputHash ?? null,
|
|
187
209
|
refClosure: snap.refClosure ? JSON.stringify(snap.refClosure) : null,
|
|
188
210
|
renderedAt: snap.renderedAt ?? null,
|
|
@@ -203,10 +225,11 @@ function snapToRow(snap) {
|
|
|
203
225
|
// is the dominant shape. A page destination treated as a filesystem path
|
|
204
226
|
// would otherwise be looked for at the root of the disk.
|
|
205
227
|
//
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
228
|
+
// One definition for both callers. verify() and hashOutputFile ask the same
|
|
229
|
+
// question, and a private copy of the join in each is how one mistake
|
|
230
|
+
// becomes two symptoms — one loud (every asset reported missing, at its
|
|
231
|
+
// real and present path) and one silent (no outputHash recorded, leaving
|
|
232
|
+
// most snapshots presence-checked only).
|
|
210
233
|
export function resolveOutputPath(destination, outputFolder = runtime.options?.outputFolder) {
|
|
211
234
|
if (!destination) return undefined
|
|
212
235
|
const joined = path.join(outputFolder ?? '', destination)
|
|
@@ -230,18 +253,18 @@ export function createManifest(db) {
|
|
|
230
253
|
if (!db) throw new Error('createManifest: db is required')
|
|
231
254
|
|
|
232
255
|
const stmtLookupById = db.prepare(`
|
|
233
|
-
SELECT id, destination, inputHash, outputHash, refClosure, renderedAt, parent
|
|
256
|
+
SELECT id, destination, inputHash, inputParts, outputHash, refClosure, renderedAt, parent
|
|
234
257
|
FROM mikser_snapshots WHERE id = ? ORDER BY destination
|
|
235
258
|
`)
|
|
236
259
|
const stmtLookup = db.prepare(`
|
|
237
|
-
SELECT id, destination, inputHash, outputHash, refClosure, renderedAt, parent
|
|
260
|
+
SELECT id, destination, inputHash, inputParts, outputHash, refClosure, renderedAt, parent
|
|
238
261
|
FROM mikser_snapshots WHERE id = ? AND destination = ?
|
|
239
262
|
`)
|
|
240
263
|
const stmtUpsert = db.prepare(`
|
|
241
264
|
INSERT OR REPLACE INTO mikser_snapshots
|
|
242
|
-
(id, destination, inputHash, outputHash, refClosure, renderedAt, parent)
|
|
265
|
+
(id, destination, inputHash, inputParts, outputHash, refClosure, renderedAt, parent)
|
|
243
266
|
VALUES
|
|
244
|
-
(@id, @destination, @inputHash, @outputHash, @refClosure, @renderedAt, @parent)
|
|
267
|
+
(@id, @destination, @inputHash, @inputParts, @outputHash, @refClosure, @renderedAt, @parent)
|
|
245
268
|
`)
|
|
246
269
|
const stmtDeleteByPK = db.prepare(`
|
|
247
270
|
DELETE FROM mikser_snapshots WHERE id = ? AND destination = ?
|
|
@@ -257,7 +280,7 @@ export function createManifest(db) {
|
|
|
257
280
|
SELECT id, destination FROM mikser_snapshots WHERE parent = ?
|
|
258
281
|
`)
|
|
259
282
|
const stmtSelectAll = db.prepare(`
|
|
260
|
-
SELECT id, destination, inputHash, outputHash, refClosure, renderedAt, parent
|
|
283
|
+
SELECT id, destination, inputHash, inputParts, outputHash, refClosure, renderedAt, parent
|
|
261
284
|
FROM mikser_snapshots
|
|
262
285
|
`)
|
|
263
286
|
const stmtCount = db.prepare(`SELECT COUNT(*) AS c FROM mikser_snapshots`)
|
|
@@ -352,7 +375,16 @@ export function createManifest(db) {
|
|
|
352
375
|
if (entity?.meta?.cache === false) return { skip: false, reason: 'cache-disabled' }
|
|
353
376
|
const snapshot = this.lookup(entity)
|
|
354
377
|
if (!snapshot?.inputHash) return { skip: false, reason: 'never-rendered' }
|
|
355
|
-
if (inputHashOf(entity) !== snapshot.inputHash)
|
|
378
|
+
if (inputHashOf(entity) !== snapshot.inputHash) {
|
|
379
|
+
// Name the component that moved. "inputs-changed" alone is
|
|
380
|
+
// the answer to a question nobody asked — the reader wants to
|
|
381
|
+
// know WHICH input, and the recorded parts have it.
|
|
382
|
+
return {
|
|
383
|
+
skip: false,
|
|
384
|
+
reason: 'inputs-changed',
|
|
385
|
+
changed: describeInputChange(entity, snapshot),
|
|
386
|
+
}
|
|
387
|
+
}
|
|
356
388
|
if (!snapshot.refClosure?.length) return { skip: true, reason: 'unchanged' }
|
|
357
389
|
const sourceLang = entity?.meta?.lang ?? null
|
|
358
390
|
for (const entry of snapshot.refClosure) {
|
package/src/plugins/api.js
CHANGED
|
@@ -607,8 +607,9 @@ export function api(options = {}) {
|
|
|
607
607
|
// credential declaring `api:delete` still cannot delete on an
|
|
608
608
|
// endpoint whose `operations` omits it, and a bare token (which
|
|
609
609
|
// declares nothing, capabilities === null) is bounded by the
|
|
610
|
-
// endpoint alone
|
|
611
|
-
//
|
|
610
|
+
// endpoint alone, which is what makes a plain token still a
|
|
611
|
+
// complete answer: it grants the endpoint's own ceiling and
|
|
612
|
+
// nothing more.
|
|
612
613
|
const allow = (op) => (req, res, next) => {
|
|
613
614
|
if (!allowedOps.has(op)) {
|
|
614
615
|
return res.status(403).json({
|
|
@@ -15,8 +15,8 @@ import path from 'node:path'
|
|
|
15
15
|
// assets() runs presets and produces derivatives (the work)
|
|
16
16
|
// renderPreset() renders a preset-authored layout (this file)
|
|
17
17
|
//
|
|
18
|
-
// The helpers
|
|
19
|
-
// renderPreset keeps its name because it really is a renderer.
|
|
18
|
+
// The URL helpers carry `Helper` in their names for exactly this reason;
|
|
19
|
+
// renderPreset keeps its bare name because it really is a renderer.
|
|
20
20
|
export async function load({ entity, runtime }) {
|
|
21
21
|
if (!entity?.preset?.uri) return
|
|
22
22
|
const preset = await import(`${entity.preset.uri}?stamp=${Date.now()}`)
|
package/src/postprocess.js
CHANGED
|
@@ -253,10 +253,10 @@ export default async ({ entity, options, config, context, state, logger, port })
|
|
|
253
253
|
}
|
|
254
254
|
|
|
255
255
|
// Cleanup the renderer's origin (first stage's input) when it's
|
|
256
|
-
// a different path from the chain's final destination. The
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
//
|
|
256
|
+
// a different path from the chain's final destination. The chain
|
|
257
|
+
// contract puts this on the dispatcher rather than on layouts'
|
|
258
|
+
// onComplete, which is what lets onComplete stay agnostic about
|
|
259
|
+
// whether a postprocess output was written to disk at all.
|
|
260
260
|
if (entity.origin && entity.origin !== entity.destination) {
|
|
261
261
|
try { await unlink(path.join(options.outputFolder, entity.origin)) } catch {}
|
|
262
262
|
}
|
|
@@ -264,7 +264,7 @@ export default async ({ entity, options, config, context, state, logger, port })
|
|
|
264
264
|
// Return undefined so the engine's onPostprocess loop doesn't
|
|
265
265
|
// try to thread `result` back through layouts' onComplete (which
|
|
266
266
|
// would attempt a writeFile against a non-bytes value). The
|
|
267
|
-
// postprocess plugins
|
|
268
|
-
// for the engine to flush.
|
|
267
|
+
// postprocess plugins have written directly to disk by this point;
|
|
268
|
+
// there is nothing for the engine to flush.
|
|
269
269
|
return undefined
|
|
270
270
|
}
|
package/src/report.js
CHANGED
|
@@ -28,9 +28,16 @@ export function reportGated(count = 1) {
|
|
|
28
28
|
store().gated += count
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
export function reportRendered(entity, reason) {
|
|
31
|
+
export function reportRendered(entity, reason, changed) {
|
|
32
32
|
if (!runtime.options?.json) return
|
|
33
|
-
store().rendered.push({
|
|
33
|
+
store().rendered.push({
|
|
34
|
+
id: entity?.id,
|
|
35
|
+
destination: entity?.destination ?? null,
|
|
36
|
+
reason,
|
|
37
|
+
// Which input moved, when the reason is inputs-changed. Omitted
|
|
38
|
+
// rather than empty so a consumer can test for its presence.
|
|
39
|
+
...(changed?.length ? { changed } : {}),
|
|
40
|
+
})
|
|
34
41
|
}
|
|
35
42
|
|
|
36
43
|
// A render that RAN and produced bytes identical to what was already on
|
package/src/utils.js
CHANGED
|
@@ -15,9 +15,11 @@ import runtime from './runtime.js'
|
|
|
15
15
|
// seeding. Excludes volatile fields like stamp/time/uri so re-discovery
|
|
16
16
|
// on startup doesn't produce a different hash for an unchanged file.
|
|
17
17
|
// Pure: synchronous, no I/O, no engine state.
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
// The payload both `inputHashOf` and `inputPartsOf` describe. One
|
|
19
|
+
// definition, because a hash and an attribution of that hash that disagree
|
|
20
|
+
// about what went into it is worse than having no attribution.
|
|
21
|
+
function inputPayload(entity) {
|
|
22
|
+
return {
|
|
21
23
|
meta: entity.meta ?? null,
|
|
22
24
|
content: entity.content ?? null,
|
|
23
25
|
// The bytes' fingerprint, for entities whose content is not in
|
|
@@ -46,7 +48,69 @@ export function inputHashOf(entity) {
|
|
|
46
48
|
// an entity that HAS content is hashed on {meta, content} and its
|
|
47
49
|
// checksum is ignored. This is the seam that was missing.
|
|
48
50
|
inputs: entity.inputs ?? null,
|
|
49
|
-
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function inputHashOf(entity) {
|
|
55
|
+
if (!entity) return ''
|
|
56
|
+
return crypto.createHash('sha1').update(JSON.stringify(inputPayload(entity))).digest('hex')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Per-component hashes of the same payload, flat and one level deep:
|
|
60
|
+
//
|
|
61
|
+
// { 'meta.title': 'ab12cd34', content: '…', 'inputs.shared': '…' }
|
|
62
|
+
//
|
|
63
|
+
// Recorded alongside the combined hash so a later run can say WHICH input
|
|
64
|
+
// moved rather than only that one did. `inputs-changed` on its own sends
|
|
65
|
+
// the reader to a database query to answer something already known here.
|
|
66
|
+
//
|
|
67
|
+
// Components that are null are omitted rather than hashed, so a component
|
|
68
|
+
// appearing or disappearing reads as an added or removed key — which is
|
|
69
|
+
// the answer in its own right when a document gains front-matter or a
|
|
70
|
+
// layout gains a sidecar.
|
|
71
|
+
//
|
|
72
|
+
// Depth one: naming `meta.title` is the difference between a useful answer
|
|
73
|
+
// and "something under meta". Deeper nesting would grow the snapshot for
|
|
74
|
+
// diminishing returns — the key names the field to look at, and the field
|
|
75
|
+
// is then in front of you.
|
|
76
|
+
export function inputPartsOf(entity) {
|
|
77
|
+
if (!entity) return {}
|
|
78
|
+
const parts = {}
|
|
79
|
+
const short = (value) => crypto.createHash('sha1')
|
|
80
|
+
.update(JSON.stringify(value ?? null)).digest('hex').slice(0, 8)
|
|
81
|
+
const payload = inputPayload(entity)
|
|
82
|
+
for (const [component, value] of Object.entries(payload)) {
|
|
83
|
+
if (value == null) continue
|
|
84
|
+
if (component === 'meta' || component === 'inputs') {
|
|
85
|
+
if (typeof value !== 'object' || Array.isArray(value)) {
|
|
86
|
+
parts[component] = short(value)
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
for (const [key, inner] of Object.entries(value)) {
|
|
90
|
+
parts[`${component}.${key}`] = short(inner)
|
|
91
|
+
}
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
parts[component] = short(value)
|
|
95
|
+
}
|
|
96
|
+
return parts
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// What moved between two part maps. Returns the keys, split by how they
|
|
100
|
+
// differ, so a caller can say "content changed" or "meta.title added"
|
|
101
|
+
// without re-deriving the comparison.
|
|
102
|
+
export function diffInputParts(before, after) {
|
|
103
|
+
const from = before ?? {}
|
|
104
|
+
const to = after ?? {}
|
|
105
|
+
const changed = [], added = [], removed = []
|
|
106
|
+
for (const key of Object.keys(to)) {
|
|
107
|
+
if (!(key in from)) added.push(key)
|
|
108
|
+
else if (from[key] !== to[key]) changed.push(key)
|
|
109
|
+
}
|
|
110
|
+
for (const key of Object.keys(from)) {
|
|
111
|
+
if (!(key in to)) removed.push(key)
|
|
112
|
+
}
|
|
113
|
+
return { changed: changed.sort(), added: added.sort(), removed: removed.sort() }
|
|
50
114
|
}
|
|
51
115
|
|
|
52
116
|
// Canonical lookup variants for an entity — the same four forms the
|