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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "9.16.0",
3
+ "version": "9.18.0",
4
4
  "description": "<p align=\"center\"> <img src=\"mikser-lockup-stacked.svg\" alt=\"mikser\" width=\"198\" /> </p>",
5
5
  "main": "index.js",
6
6
  "exports": {
package/src/config.js CHANGED
@@ -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
- try {
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' }
@@ -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
- if (current?.checksum != checksum) {
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: relativePath,
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: await checksum(source),
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
- logger.info('No plugins loaded')
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