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