mikser-io 9.78.0 → 9.80.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/app.js CHANGED
@@ -43,7 +43,7 @@ function locate(argv) {
43
43
  : tool ? { type: 'report', tool, toolArgs: value('--tool-args'), json: has('--json') }
44
44
  : explain ? { type: 'report', explain, json: has('--json') }
45
45
  : has('--audit-output') ? { type: 'report', auditOutput: true, json: has('--json') }
46
- : { type: 'build', clear: has('--clear') }
46
+ : { type: 'build', clear: has('--clear'), renderPresets: has('--render-presets') ? (value('--render-presets') ?? true) : undefined }
47
47
 
48
48
  return {
49
49
  longRunning,
@@ -882,6 +882,23 @@ surfaces that turn silence into a statement:
882
882
  - **A plugin that appears to do nothing** — `No plugins loaded` with a
883
883
  config present is a warning naming the file. A config that fails to
884
884
  load now exits non-zero rather than loading as empty.
885
+ - **A derivative that did not re-render.** A preset is re-evaluated when
886
+ its definition moves — its `revision`, its module, or its `match`
887
+ patterns, all of which the preset entity now carries. Nothing else
888
+ schedules it: an entity whose source and preset both stood still is
889
+ skipped, which is what keeps a no-op build from scanning the corpus.
890
+ When the cause is outside all of that — a preset edited without bumping
891
+ `revision`, an image library upgraded under the build, a marker deleted
892
+ by hand — `--render-presets [name]` re-derives regardless. It fails
893
+ loudly if no assets plugin is loaded to act on it.
894
+ - **A derivative for a source that is gone.** The assets folder sits at
895
+ the working-folder root, outside both `outputFolder` and the runtime
896
+ folder, and it is symlinked INTO the output — so an orphaned derivative
897
+ is still served, and `find out -type f` cannot see it (that tree is
898
+ reached through a link; use `find -L`). `--clear` removes the folder and
899
+ re-derives what still has a source. An ordinary build does not: the
900
+ files plugin performs no delete sweep, so a source deleted from disk
901
+ stays in the catalog and its derivative stays with it.
885
902
  - **A postprocess that could not run** — a stage whose external
886
903
  dependency is absent (no chrome for a PDF, no binary for a conversion)
887
904
  reports a fault naming the subsystem, and each page that wanted that
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "9.78.0",
3
+ "version": "9.80.0",
4
4
  "files": [
5
5
  "app.js",
6
6
  "index.js",
package/src/engine.js CHANGED
@@ -408,6 +408,7 @@ export async function setup(options) {
408
408
  .option('-c --config <file>', 'set mikser mikser.config.js location', './mikser.config.js')
409
409
  .option('-m --mode <mode>', 'set mikser runtime mode', 'development')
410
410
  .option('-r --clear', 'clear current state before execution', false)
411
+ .option('--render-presets [name]', 're-render preset derivatives whose sources and revisions are unchanged; with a name, only that preset')
411
412
  .option('-o --output-folder <folder>', 'set mikser output folder relative to working folder', 'out')
412
413
  .option('-w --watch', 'watch entities for changes', false)
413
414
  .option('-f --force', 'rebuild everything; disable incremental dispatch', false)
@@ -1261,6 +1262,20 @@ export async function setup(options) {
1261
1262
  // Checked at the end of the cycle because that is the first moment the
1262
1263
  // answer is stable: derivatives are produced during the cycle, so
1263
1264
  // asking any earlier would report files that were about to appear.
1265
+ // --render-presets with nothing to consume it.
1266
+ //
1267
+ // The flag is implemented by the assets plugin, so without that plugin
1268
+ // it reaches nobody: the build runs normally, nothing is re-derived,
1269
+ // and the operator is left to notice. Checked here rather than at
1270
+ // onLoaded because the engine's own onLoaded is registered first and
1271
+ // runs before any plugin has set itself up.
1272
+ if (runtime.options.renderPresets && !runtime.state?.assets?.renderPresetsHandled) {
1273
+ useLogger().error({ code: 'render-presets-unhandled' },
1274
+ '--render-presets was passed, but no assets plugin is loaded to act on it. '
1275
+ + 'Nothing was re-derived. Add assets() to the plugins array, or drop the flag.')
1276
+ process.exitCode = 1
1277
+ }
1278
+
1264
1279
  const brokenTargets = await reportBrokenReferences(useLogger())
1265
1280
  await reportMissingAssets(useLogger(), brokenTargets)
1266
1281
 
package/src/instance.js CHANGED
@@ -69,7 +69,7 @@ export function socketPath(workingFolder) {
69
69
  // Newline-delimited JSON, one object per line. Deliberately boring: both ends
70
70
  // ship together, so there is nothing to negotiate and no version to carry.
71
71
  //
72
- // → { type: 'build', config, clear }
72
+ // → { type: 'build', config, clear, renderPresets }
73
73
  // → { type: 'report', config, tool, tools, toolArgs, explain, auditOutput, json }
74
74
  // ← { type: 'log', chunk } (zero or more, in order)
75
75
  // ← { type: 'done', code }
@@ -316,7 +316,17 @@ async function serveBuild(socket, request, logger) {
316
316
  // change that prompted the request — the watermark bug wearing a
317
317
  // different hat. Rescanning makes a forwarded build mean what a
318
318
  // one-shot means, which is what every existing caller assumes.
319
- await runtime.rebuild()
319
+ // Applied for THIS cycle only. A flag the forwarded path drops is a
320
+ // silent no-op, which is the failure this surface exists to remove —
321
+ // and restoring it after keeps the watcher from forcing every later
322
+ // rebuild.
323
+ const priorRenderPresets = runtime.options.renderPresets
324
+ if (request.renderPresets !== undefined) runtime.options.renderPresets = request.renderPresets
325
+ try {
326
+ await runtime.rebuild()
327
+ } finally {
328
+ runtime.options.renderPresets = priorRenderPresets
329
+ }
320
330
 
321
331
  // From the render-error count, NOT from process.exitCode.
322
332
  //
@@ -173,9 +173,30 @@ export function assets(options = {}) {
173
173
  // the (revision, format, options) export contract so onImport and
174
174
  // onSync stay in sync. Cache-busts local presets so watch-mode edits
175
175
  // reload; npm presets import once (their version is the cache key).
176
+ // Presets whose effective definition moved this cycle: a bumped revision,
177
+ // an edited module, or widened patterns.
178
+ //
179
+ // The fan-out this gates is a full catalog scan, and onImport writes every
180
+ // preset entity on every build — so gating on "a preset is in the journal"
181
+ // ran that scan every cycle, including a no-op watch rebuild, where
182
+ // responsiveness matters most.
183
+ const changedPresets = new Set()
184
+
185
+ async function notePresetChange(preset) {
186
+ const prior = await findEntity({ id: preset.id })
187
+ const moved = !prior
188
+ || prior.checksum !== preset.checksum
189
+ || JSON.stringify(prior.matches ?? null) !== JSON.stringify(preset.matches ?? null)
190
+ if (moved) changedPresets.add(preset.name)
191
+ return moved
192
+ }
193
+
176
194
  async function buildPreset({ name, uri, watchable }) {
177
195
  const cacheBust = watchable ? `?stamp=${Date.now()}` : ''
178
- const { revision = 1, format, options } = await import(`${uri}${cacheBust}`)
196
+ // `options` here would SHADOW the plugin's own config, which the
197
+ // patterns below need. The module's export and the factory argument
198
+ // are two different things that were both called options.
199
+ const { revision = 1, format, options: moduleOptions } = await import(`${uri}${cacheBust}`)
179
200
  return {
180
201
  id: `/presets/${name}`,
181
202
  collection,
@@ -185,7 +206,17 @@ export function assets(options = {}) {
185
206
  source: uri,
186
207
  format,
187
208
  checksum: revision,
188
- options,
209
+ // The patterns this preset selects by, carried on the entity.
210
+ //
211
+ // They are half of what decides which files a preset owns, and
212
+ // they live in CONFIG rather than in the module — so `revision`
213
+ // alone cannot say the preset's effective definition moved.
214
+ // Widening a pattern was a silent no-op: match is evaluated as a
215
+ // file ENTERS the catalog, so a wider one left everything already
216
+ // in it alone, the build stayed green and the derivative never
217
+ // appeared.
218
+ matches: normalizePresetConfig(options.presets?.[name]).matches,
219
+ options: moduleOptions,
189
220
  }
190
221
  }
191
222
 
@@ -251,7 +282,13 @@ export function assets(options = {}) {
251
282
  destination = changeExtension(destination, entity.preset.format)
252
283
  }
253
284
  entity.destination = path.join(runtime.options.assetsFolder, entityPreset, destination)
254
- const ignore = await isPresetRendered(entity)
285
+ // Two gates sit between a scheduled entity and a render: the
286
+ // manifest's "its source did not change", and this plugin's
287
+ // marker. A forced render clears both — a marker at the
288
+ // current revision is exactly what --render-presets exists to
289
+ // disregard.
290
+ const forced = rendererChanged.has(entityToRender.id)
291
+ const ignore = forced ? false : await isPresetRendered(entity)
255
292
  tasks.push({
256
293
  entity,
257
294
  options: {
@@ -261,7 +298,7 @@ export function assets(options = {}) {
261
298
  // The preset itself moved this cycle, so the manifest's
262
299
  // "its source is unchanged" is true and beside the
263
300
  // point. See the skip decision in engine.js.
264
- rendererChanged: rendererChanged.has(entityToRender.id),
301
+ rendererChanged: forced,
265
302
  ignore
266
303
  }
267
304
  })
@@ -291,6 +328,28 @@ export function assets(options = {}) {
291
328
  runtime.options.assets = options.assetsFolder || 'assets'
292
329
  runtime.options.assetsFolder = path.join(runtime.options.workingFolder, runtime.options.assets)
293
330
  logger.debug('Assets folder: %s', runtime.options.assetsFolder)
331
+
332
+ // --clear means "throw away what was derived and build it again", and
333
+ // derivatives are derived — but this folder sits at the working-folder
334
+ // root, outside both outputFolder and runtimeFolder, so the engine's
335
+ // clear never reached it. Anything here whose source had gone stayed
336
+ // forever: still on disk, still SERVED through the symlink below, and
337
+ // invisible to `find out -type f` because that tree is reached through
338
+ // a link. The only way out was deleting the folder by hand.
339
+ //
340
+ // Cleared here rather than in the engine because the path is not known
341
+ // until this plugin resolves it — the engine's clear runs before any
342
+ // plugin's onLoaded.
343
+ //
344
+ // Whole folder, not a sweep of orphans: identifying an orphan means
345
+ // mapping a derivative back to a source, which is the id/name mapping
346
+ // that has bitten this plugin twice. --clear already accepts a full
347
+ // rebuild, so re-deriving is the honest cost of asking for one.
348
+ if (runtime.options.clear) {
349
+ await rm(runtime.options.assetsFolder, { recursive: true, force: true })
350
+ logger.info('Assets cleared: %s', runtime.options.assetsFolder)
351
+ }
352
+
294
353
  await mkdir(runtime.options.assetsFolder, { recursive: true })
295
354
 
296
355
  let link = path.join(runtime.options.outputFolder, runtime.options.assets)
@@ -375,6 +434,7 @@ export function assets(options = {}) {
375
434
  const uri = path.join(runtime.options.presetsFolder, relativePath)
376
435
  try {
377
436
  const preset = await buildPreset({ name, uri, watchable: true })
437
+ await notePresetChange(preset)
378
438
  await createEntity(preset)
379
439
  presets[name] = preset
380
440
  } catch (err) {
@@ -399,6 +459,7 @@ export function assets(options = {}) {
399
459
  }
400
460
  try {
401
461
  const preset = await buildPreset({ name, uri: resolved.uri, watchable: resolved.watchable })
462
+ await notePresetChange(preset)
402
463
  await createEntity(preset)
403
464
  presets[name] = preset
404
465
  logger.debug('Preset loaded from npm: mikser-io-preset-%s', name)
@@ -458,10 +519,49 @@ export function assets(options = {}) {
458
519
  }
459
520
 
460
521
  const entitiesToRender = new Map()
461
- // Entities scheduled because their PRESET changed, not their source.
522
+ // Entities that must render regardless of markers or manifest: their
523
+ // preset moved, or --render-presets asked for them.
462
524
  const presetMoved = new Set()
525
+
526
+ // --render-presets [name]: re-derive though nothing moved.
527
+ //
528
+ // The escape hatch for what the incremental machinery cannot see — a
529
+ // preset edited without bumping `revision`, a marker deleted by hand,
530
+ // an image library upgraded underneath the build. --clear reaches the
531
+ // same end only by rebuilding the whole site, and only at startup, so
532
+ // it cannot be asked of a running watcher at all.
533
+ const wanted = runtime.options.renderPresets
534
+ if (wanted) {
535
+ // Consumed here and nowhere else. The engine checks this at the
536
+ // end of the cycle: a flag that reaches no plugin has to say so,
537
+ // rather than building normally and leaving the operator to
538
+ // notice nothing was re-derived.
539
+ runtime.state.assets.renderPresetsHandled = true
540
+
541
+ const known = Object.keys(runtime.state.assets.presets)
542
+ const names = wanted === true ? known : [wanted]
543
+ for (const name of names.filter(n => !known.includes(n))) {
544
+ useLogger().warn({ code: 'preset-unknown', preset: name },
545
+ '--render-presets asked for %j, which is not configured. Known: %s',
546
+ name, known.join(', ') || '(none)')
547
+ }
548
+ const selected = names.filter(n => known.includes(n))
549
+ if (selected.length) {
550
+ for await (const candidate of iterateEntities({ collection: { $ne: collection } })) {
551
+ const candidatePresets = await getEntityPresets(candidate)
552
+ if (!candidatePresets.some(n => selected.includes(n))) continue
553
+ assetsMap[candidate.id] ??= candidatePresets
554
+ presetMoved.add(candidate.id)
555
+ entitiesToRender.set(candidate.id, candidate)
556
+ }
557
+ useLogger().info('Presets re-rendering: %s (%d source(s))',
558
+ selected.join(', '), entitiesToRender.size)
559
+ }
560
+ }
463
561
  await map(useJournal('Assets provision', [OPERATION.CREATE, OPERATION.UPDATE], signal), async ({ entity }) => {
464
562
  if (entity.collection == collection) {
563
+ // Only when this preset's definition actually moved.
564
+ if (!changedPresets.has(entity.name)) return
465
565
  // A preset moved — its `revision` was bumped, or its module
466
566
  // changed. Everything that uses it has to re-render.
467
567
  //