mikser-io 9.16.0 → 9.18.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/package.json +1 -1
- package/src/config.js +20 -3
- package/src/manifest.js +13 -0
- package/src/plugins/files.js +28 -5
- package/src/plugins.js +15 -1
- package/src/report.js +16 -1
- package/src/utils.js +63 -1
package/package.json
CHANGED
package/src/config.js
CHANGED
|
@@ -3,6 +3,7 @@ import { useLogger } from './engine.js'
|
|
|
3
3
|
import { onLoad } from './lifecycle.js'
|
|
4
4
|
import { checksum } from './utils.js'
|
|
5
5
|
import path from 'node:path'
|
|
6
|
+
import { existsSync } from 'node:fs'
|
|
6
7
|
|
|
7
8
|
onLoad(async () => {
|
|
8
9
|
const logger = useLogger()
|
|
@@ -29,15 +30,31 @@ onLoad(async () => {
|
|
|
29
30
|
runtime.options.configChecksum = null
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
|
|
33
|
+
// Absence is decided by looking for the file, NOT by catching
|
|
34
|
+
// ERR_MODULE_NOT_FOUND from the import.
|
|
35
|
+
//
|
|
36
|
+
// Node raises that same code for "the config file is missing" and for
|
|
37
|
+
// "the config file exists and something IT imports is missing" — a
|
|
38
|
+
// mistyped package name, a renamed local module, a dependency that
|
|
39
|
+
// was never installed. Catching the code swallowed both, so a config
|
|
40
|
+
// with one bad import loaded as `{}` and the build reported "No
|
|
41
|
+
// plugins loaded" and exited 0: a green build with an empty output
|
|
42
|
+
// folder, one line away from having printed the config's path.
|
|
43
|
+
//
|
|
44
|
+
// Every other config failure was already loud — a syntax error or a
|
|
45
|
+
// throw during evaluation both exit 1. Module resolution was the one
|
|
46
|
+
// silent case, so this brings it in line rather than inventing a new
|
|
47
|
+
// policy.
|
|
48
|
+
if (!existsSync(configFile)) {
|
|
49
|
+
logger.debug('No config file at %s — using defaults', configFile)
|
|
50
|
+
} else {
|
|
51
|
+
// No catch: any failure loading a config that EXISTS is fatal.
|
|
33
52
|
const config = await import(configFile)
|
|
34
53
|
if (typeof config.default == 'function') {
|
|
35
54
|
runtime.config = await config.default(runtime)
|
|
36
55
|
} else if (typeof config.default == 'object') {
|
|
37
56
|
runtime.config = config.default
|
|
38
57
|
}
|
|
39
|
-
} catch (err) {
|
|
40
|
-
if (err.code != 'ERR_MODULE_NOT_FOUND') throw err
|
|
41
58
|
}
|
|
42
59
|
|
|
43
60
|
// v8 used to walk `runtime.config.plugins` looking for matching
|
package/src/manifest.js
CHANGED
|
@@ -306,7 +306,20 @@ export function createManifest(db) {
|
|
|
306
306
|
// ref-changed a $-ref or partial it depends on moved
|
|
307
307
|
// query-matched an entity matching a recorded query mutated
|
|
308
308
|
// cache-disabled meta.cache === false
|
|
309
|
+
// force --force: skip nothing, ask nothing
|
|
309
310
|
skipDecision(entity, mutatedRefs, currentHashes, mutatedEntities) {
|
|
311
|
+
// --force means "ignore what you think you know". THREE gates can
|
|
312
|
+
// stop a render — source.js's import checksum gate, layouts'
|
|
313
|
+
// dispatch filter, and this one — and force reached only the
|
|
314
|
+
// first two. Since this one runs last, a forced build re-imported
|
|
315
|
+
// everything (gated=0), re-dispatched everything, and then
|
|
316
|
+
// dropped all of it here with reason `unchanged`: rendered=0, and
|
|
317
|
+
// a summary that read like a successful build.
|
|
318
|
+
//
|
|
319
|
+
// That made --force useless in exactly the situation it exists
|
|
320
|
+
// for, which is when the invalidation graph is under suspicion —
|
|
321
|
+
// including the advice the preset no-match warning gives.
|
|
322
|
+
if (runtime.options?.force) return { skip: false, reason: 'force' }
|
|
310
323
|
if (entity?.meta?.cache === false) return { skip: false, reason: 'cache-disabled' }
|
|
311
324
|
const snapshot = this.lookup(entity)
|
|
312
325
|
if (!snapshot?.inputHash) return { skip: false, reason: 'never-rendered' }
|
package/src/plugins/files.js
CHANGED
|
@@ -86,25 +86,41 @@ export function files(options = {}) {
|
|
|
86
86
|
link: await link(source)
|
|
87
87
|
})
|
|
88
88
|
break
|
|
89
|
-
case ACTION.UPDATE:
|
|
89
|
+
case ACTION.UPDATE: {
|
|
90
90
|
const current = await findEntity({ id })
|
|
91
|
-
|
|
91
|
+
// `checksum` is the source-checksum FUNCTION from the
|
|
92
|
+
// plugin context, not a value. Comparing the stored
|
|
93
|
+
// string against it was never equal, so the guard
|
|
94
|
+
// always passed and every sync re-wrote the entity —
|
|
95
|
+
// `synced = false` was unreachable.
|
|
96
|
+
const currentChecksum = await checksum(source)
|
|
97
|
+
if (current?.checksum != currentChecksum) {
|
|
92
98
|
await updateEntity({
|
|
93
99
|
id,
|
|
94
100
|
uri,
|
|
95
|
-
name
|
|
101
|
+
// `name` — the prefixed form, as CREATE uses.
|
|
102
|
+
// This read `relativePath`, so an update
|
|
103
|
+
// dropped the outputFolder prefix while
|
|
104
|
+
// meta.url two lines down kept it. The assets
|
|
105
|
+
// plugin builds preset destinations from
|
|
106
|
+
// `name`, so a file replaced under watch had
|
|
107
|
+
// its derivatives written somewhere else than
|
|
108
|
+
// the same file freshly imported, and
|
|
109
|
+
// meta.presets recorded the wrong path.
|
|
110
|
+
name,
|
|
96
111
|
collection,
|
|
97
112
|
type,
|
|
98
113
|
format,
|
|
99
114
|
source,
|
|
100
115
|
meta: { url: '/' + name },
|
|
101
|
-
checksum:
|
|
116
|
+
checksum: currentChecksum,
|
|
102
117
|
link: await link(source)
|
|
103
118
|
})
|
|
104
119
|
} else {
|
|
105
120
|
synced = false
|
|
106
121
|
}
|
|
107
122
|
break
|
|
123
|
+
}
|
|
108
124
|
case ACTION.DELETE:
|
|
109
125
|
await removeLink(relativePath)
|
|
110
126
|
await deleteEntity({
|
|
@@ -143,6 +159,13 @@ export function files(options = {}) {
|
|
|
143
159
|
// journal with phantom mutations and triggering downstream
|
|
144
160
|
// re-dispatch of aggregate layouts whose recorded query deps
|
|
145
161
|
// matched the collection.
|
|
162
|
+
// --force (and a wiped catalog) must defeat the gate, the same
|
|
163
|
+
// way source.js's gateChecksum lets them defeat its own. This
|
|
164
|
+
// plugin carries a second, independent gate, and it honoured
|
|
165
|
+
// neither — so no amount of forcing ever re-derived a file's
|
|
166
|
+
// name / meta.url / meta.presets, and a catalog holding bad
|
|
167
|
+
// `files` rows had no repair path short of deleting them.
|
|
168
|
+
const forced = runtime.options.force || runtime.catalog?.cacheInvalidated
|
|
146
169
|
const priorChecksums = checksumsByCollection(collection)
|
|
147
170
|
await pMap(paths, async relativePath => {
|
|
148
171
|
const { uri, source } = await ensureLink(relativePath)
|
|
@@ -159,7 +182,7 @@ export function files(options = {}) {
|
|
|
159
182
|
// the journal stays accurate (mutations = actual changes),
|
|
160
183
|
// and downstream aggregate-layout invalidation isn't fired
|
|
161
184
|
// spuriously.
|
|
162
|
-
if (priorChecksums.get(id) === newChecksum) return
|
|
185
|
+
if (!forced && priorChecksums.get(id) === newChecksum) return
|
|
163
186
|
await createEntity({
|
|
164
187
|
id,
|
|
165
188
|
uri,
|
package/src/plugins.js
CHANGED
|
@@ -87,7 +87,21 @@ onLoad(() => {
|
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
if (!factoryEntries.length && !registeredRenderers && !registeredPostprocessors) {
|
|
90
|
-
|
|
90
|
+
// "No plugins loaded" is a legitimate state for a project with no
|
|
91
|
+
// config at all, and a near-certain mistake for one that HAS a
|
|
92
|
+
// config — the two printed the same line, so a config that
|
|
93
|
+
// produced no plugins looked like a deliberate choice. Say which
|
|
94
|
+
// case this is.
|
|
95
|
+
if (runtime.options.configChecksum) {
|
|
96
|
+
logger.warn(
|
|
97
|
+
'No plugins loaded, but a config was read from %s — ' +
|
|
98
|
+
'it exported no `plugins` array, or the array was empty. ' +
|
|
99
|
+
'Nothing will be built.',
|
|
100
|
+
runtime.options.config,
|
|
101
|
+
)
|
|
102
|
+
} else {
|
|
103
|
+
logger.info('No plugins loaded')
|
|
104
|
+
}
|
|
91
105
|
return
|
|
92
106
|
}
|
|
93
107
|
|
package/src/report.js
CHANGED
|
@@ -13,7 +13,7 @@ import runtime from './runtime.js'
|
|
|
13
13
|
|
|
14
14
|
function store() {
|
|
15
15
|
runtime.state ??= {}
|
|
16
|
-
runtime.state.report ??= { rendered: [], skipped: [], warnings: [], gated: 0 }
|
|
16
|
+
runtime.state.report ??= { rendered: [], skipped: [], unchanged: [], warnings: [], gated: 0 }
|
|
17
17
|
return runtime.state.report
|
|
18
18
|
}
|
|
19
19
|
|
|
@@ -33,6 +33,17 @@ export function reportRendered(entity, reason) {
|
|
|
33
33
|
store().rendered.push({ id: entity?.id, destination: entity?.destination ?? null, reason })
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
// A render that RAN and produced bytes identical to what was already on
|
|
37
|
+
// disk. Distinct from both other outcomes and the interesting one of the
|
|
38
|
+
// three: `rendered` means the output moved, `skipped` means the manifest
|
|
39
|
+
// decided not to look, and this means invalidation was coarser than it
|
|
40
|
+
// needed to be. Nothing downstream should have been disturbed, and the
|
|
41
|
+
// count is the measure of how much conservative invalidation costs.
|
|
42
|
+
export function reportUnchanged(entity) {
|
|
43
|
+
if (!runtime.options?.json) return
|
|
44
|
+
store().unchanged.push({ id: entity?.id, destination: entity?.destination ?? null })
|
|
45
|
+
}
|
|
46
|
+
|
|
36
47
|
export function reportSkipped(entity, reason) {
|
|
37
48
|
if (!runtime.options?.json) return
|
|
38
49
|
store().skipped.push({ id: entity?.id, destination: entity?.destination ?? null, reason })
|
|
@@ -54,9 +65,13 @@ export function buildReport() {
|
|
|
54
65
|
return {
|
|
55
66
|
rendered: report.rendered,
|
|
56
67
|
skipped: report.skipped,
|
|
68
|
+
unchanged: report.unchanged,
|
|
57
69
|
warnings: report.warnings,
|
|
58
70
|
summary: {
|
|
59
71
|
rendered: report.rendered.length,
|
|
72
|
+
// Of those renders, how many produced bytes identical to
|
|
73
|
+
// what was already on disk — see reportUnchanged.
|
|
74
|
+
unchanged: report.unchanged.length,
|
|
60
75
|
// Renders that were CONSIDERED and skipped by the manifest.
|
|
61
76
|
skipped: report.skipped.length,
|
|
62
77
|
// Entities gated at import because their source was unchanged, so
|
package/src/utils.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import crypto from 'node:crypto'
|
|
2
2
|
import { createHash } from 'node:crypto'
|
|
3
3
|
import { hashFile } from 'hasha'
|
|
4
|
-
import { stat, readFile, writeFile, mkdir, unlink, open } from 'node:fs/promises'
|
|
4
|
+
import { stat, lstat, readFile, writeFile, mkdir, unlink, open } from 'node:fs/promises'
|
|
5
5
|
import { createRequire } from 'node:module'
|
|
6
6
|
import _ from 'lodash'
|
|
7
7
|
import { minimatch } from 'minimatch'
|
|
@@ -932,6 +932,68 @@ export function useCollection(runtime, name) {
|
|
|
932
932
|
}
|
|
933
933
|
}
|
|
934
934
|
|
|
935
|
+
// Write `bytes` to `file`, unless the file already holds exactly those
|
|
936
|
+
// bytes. Returns true if it wrote, false if the file was already correct.
|
|
937
|
+
//
|
|
938
|
+
// Invalidation is deliberately conservative: an entity that merely READ
|
|
939
|
+
// another entity re-renders when that one changes, because the engine
|
|
940
|
+
// cannot know which field was read. That is the right default, and it
|
|
941
|
+
// means renders regularly produce byte-identical output. Writing anyway
|
|
942
|
+
// moves mtime, and three things downstream key off the file rather than
|
|
943
|
+
// its contents:
|
|
944
|
+
//
|
|
945
|
+
// - live reload watches the output folder, so editing one photograph
|
|
946
|
+
// reloaded the browser on pages that had not changed
|
|
947
|
+
// - rsync, `aws s3 sync` and most CDN tools compare size plus mtime,
|
|
948
|
+
// so unchanged pages re-upload
|
|
949
|
+
// - `find out -newer` cannot answer "what did this build change?"
|
|
950
|
+
//
|
|
951
|
+
// Doing it here rather than narrowing the dependency edges fixes every
|
|
952
|
+
// conservative-invalidation case at once, and stays correct as the graph
|
|
953
|
+
// gets more precise instead of becoming redundant.
|
|
954
|
+
//
|
|
955
|
+
// Ordering matters: the size check comes first so the common
|
|
956
|
+
// output-really-changed case never pays for a read, and lstat (not stat)
|
|
957
|
+
// because a destination that is currently a SYMLINK has to be replaced
|
|
958
|
+
// by a real file even when the bytes behind it match — the type of the
|
|
959
|
+
// destination is part of the output, not just its contents.
|
|
960
|
+
export async function writeOutput(file, bytes) {
|
|
961
|
+
// Size first, and WITHOUT materialising a buffer: Buffer.byteLength
|
|
962
|
+
// measures a string in place, while Buffer.from copies it (~250µs for
|
|
963
|
+
// a 1MB page, against ~25µs for the lstat). Since a size mismatch is
|
|
964
|
+
// the common outcome on a build that changed something, the cheap
|
|
965
|
+
// path must not pay for the expensive one.
|
|
966
|
+
const size = Buffer.isBuffer(bytes) ? bytes.length : Buffer.byteLength(bytes)
|
|
967
|
+
let identical = false
|
|
968
|
+
try {
|
|
969
|
+
const info = await lstat(file)
|
|
970
|
+
if (info.isFile() && info.size === size) {
|
|
971
|
+
const existing = await readFile(file)
|
|
972
|
+
identical = Buffer.isBuffer(bytes)
|
|
973
|
+
? bytes.equals(existing)
|
|
974
|
+
: existing.equals(Buffer.from(bytes))
|
|
975
|
+
}
|
|
976
|
+
} catch (err) {
|
|
977
|
+
// Missing or unreadable — fall through and write. Anything else
|
|
978
|
+
// is a bug in this function, and swallowing it would look
|
|
979
|
+
// exactly like "the file wasn't there": a missing `lstat` import
|
|
980
|
+
// made every comparison fail open, so the skip silently never
|
|
981
|
+
// happened while the tests still passed.
|
|
982
|
+
if (err.code !== 'ENOENT' && err.code !== 'EACCES') throw err
|
|
983
|
+
}
|
|
984
|
+
if (identical) return false
|
|
985
|
+
await mkdir(path.dirname(file), { recursive: true })
|
|
986
|
+
// Unlink first so an existing hard link or symlink at this path is
|
|
987
|
+
// broken rather than written through.
|
|
988
|
+
try {
|
|
989
|
+
await unlink(file)
|
|
990
|
+
} catch { /* not there, or not removable — writeFile will say so */ }
|
|
991
|
+
// Pass the original value through — writeFile encodes a string
|
|
992
|
+
// directly, so converting first would add a copy for nothing.
|
|
993
|
+
await writeFile(file, bytes)
|
|
994
|
+
return true
|
|
995
|
+
}
|
|
996
|
+
|
|
935
997
|
// ── operating-system and file-manager litter ────────────────────────────
|
|
936
998
|
//
|
|
937
999
|
// Exposing a source folder over a network filesystem (mikser-io-webdav) or
|