mikser-io 10.11.0 → 10.13.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.
Files changed (76) hide show
  1. package/docs/architecture.md +12 -12
  2. package/docs/configuration.md +21 -32
  3. package/docs/diagnostics.md +122 -3
  4. package/docs/lifecycle.md +2 -2
  5. package/docs/overview.md +1 -1
  6. package/docs/plugins.md +30 -206
  7. package/docs/rendering.md +1 -1
  8. package/index.js +14 -12
  9. package/package.json +1 -1
  10. package/src/auth.js +17 -2
  11. package/src/catalog.js +2 -2
  12. package/src/changeset.js +2 -2
  13. package/src/config.js +2 -2
  14. package/src/database/index.js +1 -1
  15. package/src/engine/boot.js +300 -0
  16. package/src/engine/checks.js +226 -0
  17. package/src/engine/dispatch.js +97 -0
  18. package/src/engine/finalize.js +95 -0
  19. package/src/engine/index.js +150 -0
  20. package/src/engine/postprocess-cycle.js +223 -0
  21. package/src/engine/render-cycle.js +432 -0
  22. package/src/engine/report-only.js +189 -0
  23. package/src/engine/workers.js +46 -0
  24. package/src/explain.js +1 -1
  25. package/src/instance.js +2 -2
  26. package/src/journal.js +3 -3
  27. package/src/lifecycle.js +1 -1
  28. package/src/logger/index.js +39 -0
  29. package/src/logger/levels.js +180 -0
  30. package/src/logger/progress.js +224 -0
  31. package/src/logger/streams.js +361 -0
  32. package/src/manager.js +2 -2
  33. package/src/manifest/cycle.js +307 -0
  34. package/src/{manifest.js → manifest/index.js} +42 -752
  35. package/src/manifest/schema.js +73 -0
  36. package/src/manifest/snapshot.js +182 -0
  37. package/src/manifest/sources.js +57 -0
  38. package/src/manifest/statements.js +190 -0
  39. package/src/plugins/api.js +1 -1
  40. package/src/plugins/providers/http.js +1 -1
  41. package/src/plugins/render/href.js +1 -1
  42. package/src/plugins/render/resource.js +1 -1
  43. package/src/plugins/resources.js +1 -1
  44. package/src/plugins.js +1 -1
  45. package/src/postprocess.js +1 -1
  46. package/src/provenance.js +1 -1
  47. package/src/references.js +1 -1
  48. package/src/refs.js +4 -4
  49. package/src/render.js +2 -2
  50. package/src/report.js +1 -1
  51. package/src/roles.js +21 -5
  52. package/src/routes.js +1 -1
  53. package/src/runtime.js +1 -1
  54. package/src/search.js +1 -1
  55. package/src/server.js +1 -1
  56. package/src/source.js +1 -1
  57. package/src/subscriptions.js +1 -1
  58. package/src/use-logger.js +18 -0
  59. package/src/utils/entity.js +423 -0
  60. package/src/utils/errors.js +45 -0
  61. package/src/utils/expand.js +237 -0
  62. package/src/utils/hash.js +197 -0
  63. package/src/utils/index.js +19 -0
  64. package/src/utils/junk.js +109 -0
  65. package/src/utils/library.js +29 -0
  66. package/src/utils/net.js +33 -0
  67. package/src/utils/output.js +195 -0
  68. package/src/utils/refs.js +127 -0
  69. package/src/write.js +1 -1
  70. package/testing/harness.js +1 -1
  71. package/src/engine.js +0 -1672
  72. package/src/logger.js +0 -723
  73. package/src/plugins/assets.js +0 -1023
  74. package/src/plugins/render/asset.js +0 -83
  75. package/src/plugins/render/preset.js +0 -67
  76. package/src/utils.js +0 -1369
@@ -11,12 +11,12 @@ mikser-io/
11
11
  │
12
12
  └── src/
13
13
  ├── runtime.js Singleton object — global state and lifecycle coordination
14
- ├── engine.js setup(), CLI option parsing, render + postprocess dispatchers, lazy Piscina pools
14
+ ├── engine/ setup() and the lifecycle phases, one file each: boot, dispatch, render-cycle, postprocess-cycle, finalize; plus report-only, checks and workers. index.js wires them in order
15
15
  ├── lifecycle.js Hook registration functions + entity write helpers
16
16
  ├── journal.js Per-cycle queue persisted to mikser_journal (auto-persist on yielded entities; survives crashes so --resume can pick up)
17
17
  ├── catalog.js Persistent entity registry (mikser_entities sqlite table + 10k LRU on findById)
18
18
  ├── refs.js Inverse-reference graph (mikser_refs sqlite table)
19
- ├── manifest.js Render snapshots (mikser_snapshots sqlite table)
19
+ ├── manifest/ schema, snapshot build/parse, sources, prepared statements, the onLoaded/onFinalize cycle; index.js keeps createManifest
20
20
  ├── database/ createSqliteDatabase, registerSchema, useDatabase, sift→SQL translator, queryContext (AsyncLocalStorage)
21
21
  ├── subscriptions.js subscribe() primitive (journal-walk + graph-dispatch modes)
22
22
  ├── source.js useSource — folder-of-files import pattern shared by documents/files (and used by sibling mikser-io-layouts)
@@ -24,11 +24,11 @@ mikser-io/
24
24
  ├── config.js Config file loading
25
25
  ├── plugins.js Plugin resolution and loading
26
26
  ├── manager.js File watching and cron scheduling
27
- ├── logger.js Progress bars and log formatting (wraps pino so progress lines don't get mangled); pino.multistream for third-party shipping
27
+ ├── logger/ streams.js (pino + pretty + multistream), levels.js (which level is in force), progress.js (the gauge and the records that replace it); index.js is the barrel
28
28
  ├── render.js Render worker function (runs in main or Piscina threads); ensureWorkerDb gives each worker a read-only sqlite handle
29
29
  ├── postprocess.js Postprocess worker function (runs in main or Piscina threads)
30
30
  ├── track.js Render-time dependency tracking (catalog queries auto-report via queryContext)
31
- ├── utils.js Checksum, normalize, matchEntity, changeExtension, AbortError, ExpandError, mimeForEntity, isTextEntity, readEntityContent, extractRefs, writeEntity, formatErrorContext
31
+ ├── utils/ entity, refs, hash, expand, output, errors, junk, net, library; index.js re-exports all 37
32
32
  ├── constants.js OPERATION, ACTION, TASKS enums
33
33
  │
34
34
  ├── plugins/ Built-in content source and transform plugins
@@ -72,13 +72,13 @@ runtime
72
72
  ├── state Arbitrary plugin state — catalog/refs/manifest are NOT here; they live as their own sqlite-backed facades below
73
73
  ├── catalog entity catalog facade (set by catalog.js) — findEntity / findEntities / iterateEntities / queryEntities / readEntity / subscribe / assertExpand. Backed by mikser_entities + 10k LRU on findById.
74
74
  ├── refs inverse-ref graph facade (set by refs.js) — inboundFor / outboundFor / allRefs / size / rename / subscribeGraph / inverseClosureOf. Backed by mikser_refs.
75
- ├── manifest render snapshot facade (set by manifest.js) — shouldSkip / record / recordedHashes. Backed by mikser_snapshots.
75
+ ├── manifest render snapshot facade (set by manifest/cycle.js) — shouldSkip / record / recordedHashes. Backed by mikser_snapshots.
76
76
  ├── lookupHref Sync href→entity lookup; available inside Piscina workers (each opens its own read-only sqlite handle on first task)
77
77
  ├── validators[] Array of validation functions
78
78
  ├── mutex Semaphore for process() serialisation
79
79
  ├── abortController Current run's AbortController
80
80
  │
81
- ├── engine Service objects (set by engine.js)
81
+ ├── engine Service objects (set by engine/index.js)
82
82
  │ ├── logger pino instance
83
83
  │ ├── commander Commander instance
84
84
  │ ├── renderWorkers Lazy Piscina pool (minThreads: 0, idleTimeout: 30_000)
@@ -155,7 +155,7 @@ mikser_journal (RENDER rows)
155
155
  │
156
156
  ▼
157
157
  [RENDER phase]
158
- engine.js dispatches RENDER rows → render() per row, dispatch mode
158
+ engine/render-cycle.js dispatches RENDER rows → render() per row, dispatch mode
159
159
  picked per row from { INLINE, SERIAL, WORKER }
160
160
  │
161
161
  ▼
@@ -173,7 +173,7 @@ mikser_journal (POSTPROCESS rows)
173
173
  │
174
174
  ▼
175
175
  [POSTPROCESS phase]
176
- engine.js dispatches POSTPROCESS rows → postprocess() per row
176
+ engine/render-cycle.js dispatches POSTPROCESS rows → postprocess() per row
177
177
  (same INLINE/SERIAL/WORKER dispatch model as render)
178
178
  │
179
179
  ▼
@@ -322,7 +322,7 @@ export { default as runtime } from './src/runtime.js'
322
322
  export * as constants from './src/constants.js'
323
323
 
324
324
  // Utilities
325
- export * from './src/utils.js' // checksum(), normalize(), matchEntity(), AbortError, etc.
325
+ export * from './src/utils/index.js' // checksum(), normalize(), matchEntity(), AbortError, etc.
326
326
 
327
327
  // Lifecycle hooks and entity operations
328
328
  export * from './src/lifecycle.js' // onXxx(), createEntity(), updateEntity(), deleteEntity(), renderEntity(), etc.
@@ -332,7 +332,7 @@ export * from './src/database/index.js' // useDatabase, registerSchema
332
332
  export * from './src/journal.js' // addEntry, addEntries, updateEntry, useJournal, clearJournal
333
333
  export * from './src/catalog.js' // findEntity, findEntities, iterateEntities, queryEntities, readEntity, assertExpand
334
334
  export * from './src/refs.js' // refExists (runtime.refs.* is the main surface)
335
- export * from './src/manifest.js' // (runtime.manifest.* is the main surface)
335
+ export * from './src/manifest/index.js' // (runtime.manifest.* is the main surface)
336
336
 
337
337
  // Dependency tracking + subscriptions
338
338
  export * from './src/track.js'
@@ -346,10 +346,10 @@ export * from './src/plugins.js'
346
346
  export * from './src/manager.js' // watch(), schedule(), createdHook/updatedHook/deletedHook/triggeredHook
347
347
 
348
348
  // Logger / progress
349
- export * from './src/logger.js' // useLogger, trackProgress, stopProgress, updateProgress, etc.
349
+ export * from './src/logger/index.js' // trackProgress, stopProgress, updateProgress, setLogLevel, etc.
350
350
 
351
351
  // setup() and render dispatcher entry
352
- export * from './src/engine.js' // setup()
352
+ export * from './src/engine/index.js' // setup()
353
353
  export * from './src/render.js' // useRenderer
354
354
 
355
355
  // Folder-of-files import helper
@@ -53,8 +53,8 @@ These options are part of `runtime.options` and apply to the engine itself.
53
53
  | `watch` | `-w, --watch` | boolean | `false` | Watch source folders for changes and rebuild incrementally. |
54
54
  | `force` | `-f, --force` | boolean | `false` | Rebuild everything; disable incremental dispatch. |
55
55
  | `resume` | `-R, --resume` | boolean | `false` | Continue from journal entries left by a previous interrupted run; skip the initial filesystem scan. The journal table survives crashes, so an interrupted cycle can be picked up by re-running with `--resume`. |
56
- | `verify` | `--audit-output` | boolean | `false` | Verify the output folder against the manifest snapshot — report drift instead of building. |
57
- | `log` | `-l, --log` | string | — | Log level for this run: `trace`, `debug`, `info`, `notice`, `warn`, `error`, `fatal`, `silent`. Replaces the old `--debug` / `--trace` booleans, which could not express "warnings only" and, in `--debug`'s case, did nothing at all — it moved the logger's level while the terminal stream kept the one it was built with. |
56
+ | — | `--audit-output` | boolean | `false` | Check every tree mikser writes into against the manifest snapshots — report drift instead of building. Command line only; there is no config key. |
57
+ | `log` | `-l, --log` | string | — | Log level for this run: `trace`, `debug`, `info`, `notice`, `warn`, `error`, `fatal`, `silent`. A level above `info` suppresses the version banner too. |
58
58
  | `logInstall` | `--log-install` | string | — | Set the level on a **running** instance, so its own rebuilds are verbose too — the case a per-request flag cannot serve. Expires after 30 minutes, dies with the process, and is disclosed in the build report under `logLevel`. |
59
59
  | `logReset` | `--log-reset` | boolean | `false` | Return a running instance to its configured level. |
60
60
  | `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. |
@@ -232,40 +232,29 @@ The renderers (`render-hbs`, `render-eta`, `render-liquid`) consume the stripped
232
232
 
233
233
  ECT layouts (`mikser-io-render-ect`) still file-load via ECT's own resolver — YAML at the top of `.ect` files renders as literal text. Pick `hbs` / `eta` / `liquid` for layouts that need self-describing metadata.
234
234
 
235
- ### `assets`
235
+ ### `assets` *(moved to `mikser-io-assets`)*
236
+
237
+ Preset derivatives are a sibling package as of 10.12.0. There is no `assets`
238
+ key in `mikser.config.js`: options are factory arguments like every other
239
+ plugin (ADR-0010).
236
240
 
237
241
  ```js
238
- export default {
239
- assets: {
240
- assetsFolder: 'assets', // Source folder for assets. Default: 'assets'
241
- outputFolder: '', // Output subfolder. Default: root
242
-
243
- // Preset definitions: preset name → match patterns (and optional config).
244
- // Two shapes accepted:
245
- presets: {
246
- // 1. Bare string or array — backwards-compatible match patterns.
247
- 'thumbnail': ['@/images/*'],
248
- 'hero': '@/images/hero*',
249
-
250
- // 2. Object with `match` and per-preset `options` — options merge
251
- // over the preset module's own defaults at render time
252
- // (config-side overrides win on overlap).
253
- 'medium-image': {
254
- match: ['@/images/*', '@/files/photos/*'],
255
- options: {
256
- width: 800,
257
- height: 600,
258
- quality: 80,
259
- },
260
- },
261
- }
262
- }
263
- }
264
- ```
242
+ import { assets, renderPreset, assetUrlHelper } from 'mikser-io-assets'
265
243
 
266
- Per-preset `options` from config let a generic preset module (e.g. a `resize` package that reads `options.width` / `options.height`) be reused with different parameters per project — no need to fork the module for each variant. Module-side defaults under `options` still apply for anything the config doesn't override.
244
+ plugins: [
245
+ assets({
246
+ presets: { web: { match: ['/files/media/**'] } },
247
+ // auditIgnore: ['**/*.poster.jpg'], // extras a preset writes
248
+ }),
249
+ renderPreset(),
250
+ assetUrlHelper(),
251
+ ]
252
+ ```
267
253
 
268
- Caveat: changing config-side options doesn't automatically invalidate already-rendered assets on disk. Bump the preset module's `revision` export to force a re-render, or run `mikser --clear`.
254
+ `--assets <folder>`, `--presets <folder>` and `--render-presets [name]` are
255
+ declared by that package, so a config without `assets()` does not have them —
256
+ the flag is refused by name rather than accepted and ignored. Full reference:
257
+ [mikser-io-assets](https://github.com/almero-digital-marketing/mikser-io-assets#readme).
269
258
 
270
259
  ### `resources`
271
260
 
@@ -344,8 +344,18 @@ sentence someone may later reword.
344
344
 
345
345
  ### `--audit-output`
346
346
 
347
- Walks the output folder against the recorded snapshots and reports drift
348
- instead of building. Four categories, and the split matters:
347
+ Walks every tree mikser writes into, against the recorded snapshots, and
348
+ reports drift instead of building.
349
+
350
+ Every tree, not just the output folder: preset derivatives live at the
351
+ working-folder root and reach the site through a symlink the walk does not
352
+ follow, so anything unclaimed there used to be invisible to the one check
353
+ whose job is "what is on disk that mikser did not record". Plugins declare
354
+ their trees on `runtime.options.auditRoots` as `{ path, ignore }`, which is
355
+ also how a plugin states what is bookkeeping rather than output — a `.md5`
356
+ beside every derivative would otherwise be one orphan per derivative.
357
+
358
+ Five categories, and the split matters:
349
359
 
350
360
  | Category | Meaning | Severity |
351
361
  | --- | --- | --- |
@@ -367,9 +377,17 @@ missing or mismatched, `1` if only warnings, `0` when clean, and `2` when
367
377
  there is no manifest to check against.
368
378
 
369
379
  ```bash
370
- npx mikser --audit-output || echo "output folder has drifted"
380
+ npx mikser --audit-output || echo "the output has drifted"
371
381
  ```
372
382
 
383
+ A preset that writes **more than one file** — a poster frame beside a video —
384
+ leaves the extras genuinely unclaimed, because the engine records the one
385
+ destination it handed over. They are reported, correctly, and that would be a
386
+ permanent non-zero exit for a legitimate preset. `assets({ auditIgnore: [...] })`
387
+ is how a project says those are expected. Stated rather than inferred:
388
+ guessing which unclaimed files are intentional is how a check learns to cry
389
+ wolf.
390
+
373
391
  A destination is resolved against the output folder first and, when that
374
392
  finds nothing, treated as a filesystem path — assets carry an absolute
375
393
  destination built from `assetsFolder`, which may sit outside the output
@@ -957,6 +975,107 @@ that follows is what clears it — an engine deciding on a subsystem's behalf
957
975
  that it has recovered would be inventing the one fact this surface exists to
958
976
  report honestly.
959
977
 
978
+ ## Codes
979
+
980
+ Every warning and fault carries a `code`, and the code is the stable part —
981
+ `--json` keys `warnings` and `faults` by it, `mikser_ping` surfaces faults by
982
+ it, and a check that greps message text breaks the first time someone
983
+ improves the wording. Assert on codes.
984
+
985
+ ```bash
986
+ npx mikser --json | jq '[.warnings[].code] | group_by(.) | map({(.[0]): length}) | add'
987
+ npx mikser --json | jq -e '[.warnings[] | select(.code == "reference-broken")] | length == 0'
988
+ ```
989
+
990
+ Severity is the level the record was logged at: `warn` becomes a warning in
991
+ the report, `error` **with a code** becomes a fault. An error without a code
992
+ is one render that threw, not a subsystem declaring it cannot work — that
993
+ distinction is the whole of [Faults](#faults) above.
994
+
995
+ ### What was written
996
+
997
+ | Code | Severity | Means |
998
+ | --- | --- | --- |
999
+ | `reference-broken` | warn | A `src` / `href` / `url()` in the emitted output resolves to no file. |
1000
+ | `reference-over-deep` | warn | It resolves only because a `..` run was floored at the site root. Loads today; breaks one nesting level deeper. |
1001
+ | `reference-wrong-base` | warn | Nothing at the path written, but the file exists elsewhere — resolved against the wrong site root. |
1002
+ | `reference-no-derivative` | warn | A reference to a preset derivative that nothing produced. |
1003
+ | `reference-broken-summary`, `reference-over-deep-summary` | warn | The counts, so a check can assert on one line rather than N. |
1004
+ | `asset-missing` | warn | A template asked for an asset URL that is not in the output. Complements the reference scan: this one sees URLs that never reach an HTML file — a feed, a sitemap — and knows the entity. |
1005
+ | `asset-missing-summary` | warn | The count. |
1006
+ | `destination-collision` | warn | Two or more entities wrote the same destination this cycle. Usually produces **no** mismatch, because each render hashes the file after writing and the loser reads the winner's bytes. |
1007
+ | `empty-output` | warn | A render succeeded and produced a zero-byte file. |
1008
+ | `output-drift` | warn | The same inputs produced different bytes than last time — a non-deterministic renderer, or an input nothing is tracking. |
1009
+ | `output-drift-summary` | warn | The count. |
1010
+
1011
+ ### Derivatives (`mikser-io-assets`)
1012
+
1013
+ | Code | Severity | Means |
1014
+ | --- | --- | --- |
1015
+ | `preset-unfinished` | warn | A derivative the manifest claims is on disk with no completed-render marker beside it — a derive that was killed. It is being re-derived; the file that was there is not trustworthy. |
1016
+ | `preset-no-match` | warn | A configured preset matched none of the entities evaluated this run. Phrased as what was observed: an incremental cycle legitimately evaluates a handful. |
1017
+ | `preset-unknown` | warn | `--render-presets` named a preset that is not configured. |
1018
+
1019
+ ### Configuration and stores
1020
+
1021
+ | Code | Severity | Means |
1022
+ | --- | --- | --- |
1023
+ | `config-coverage-partial` | warn | This Node build has no module loader hooks, so the config stamp covers the entry file alone. Editing an imported module will not invalidate the cache. |
1024
+ | `durable-open` | error | The durable store could not be opened. Auth grants and the change-set log are unavailable. |
1025
+ | `durable-migration` | error | A registered migration failed. |
1026
+ | `durable-gitignore` | error | The durable store could not be added to `.gitignore` — it holds credentials and the working folder is usually a repo. |
1027
+ | `change-set-log` | error | Writes still land on disk but cannot be listed or undone. |
1028
+
1029
+ ### Process and control
1030
+
1031
+ | Code | Severity | Means |
1032
+ | --- | --- | --- |
1033
+ | `instance-listen-failed` | warn | The control socket could not be opened, so other mikser commands in this folder will start their own engine instead of forwarding. |
1034
+ | `command-from-cli` | warn | A hook command was supplied on the command line. Deliberately loud: this build is running code that is not in the config. |
1035
+ | `command-hook-not-reached` | warn | A command was attached to a hook the cycle never reached. |
1036
+ | `command-install-without-instance` | warn | `--command-install` with nothing listening to install it on. |
1037
+ | `log-level-installed` | warn | This instance is running at a level installed with `--log-install` — not the configured level, and not this build asking for it. Carries its expiry. |
1038
+
1039
+ ### Sources and progress
1040
+
1041
+ | Code | Severity | Means |
1042
+ | --- | --- | --- |
1043
+ | `observer-bad-uri` | warn | An observer's `uri` is not an absolute URL, so no webhook can be routed to it. |
1044
+ | `untracked-file-read` | warn | A template read a file outside every folder mikser takes entities from, so it has no entity and changing it invalidates nothing. |
1045
+ | `progress` | info | A long phase reporting where it has got to. See [Progress](#progress). |
1046
+ | `progress-finished` | info | A phase completed: what it was, how much it carried, what it cost. |
1047
+ | `progress-unfinished` | warn | A phase stopped before finishing. |
1048
+
1049
+ ## Progress
1050
+
1051
+ Progress is **tracked always, drawn only where a bar belongs, and narrated
1052
+ only for phases that run long**. Off a TTY — CI, a pipe, `--json` — there is
1053
+ no bar, and the same facts arrive as records:
1054
+
1055
+ ```
1056
+ [progress-finished] Documents import finished: 5 in 1ms
1057
+ [progress] Rendering: 4500/6000 — /documents/p2356.md
1058
+ [progress-finished] Rendering finished: 6000 in 3.2s
1059
+ ```
1060
+
1061
+ Three rules, and each exists because the obvious alternative was wrong:
1062
+
1063
+ - **`progress-finished` is never gated.** Every phase says it ran and what it
1064
+ cost, at any duration. With the commentary throttled it is the only record
1065
+ most phases produce, and off a TTY it is the only place phase timings come
1066
+ from at all.
1067
+ - **The running commentary is one line per 30 seconds**, not per item and not
1068
+ per quartile. A quartile is a fraction of the *work*, so it says nothing
1069
+ about how often a line appears — four records in three seconds on a fast
1070
+ phase, four across an hour on a slow one.
1071
+ - **A duration is a measurement, never a zero.** Phases are timed with
1072
+ `performance.now()`, so `0.12ms` rather than the `0s` that a whole class of
1073
+ phases used to report.
1074
+
1075
+ A bar never starts while stdout carries a document (`--json`, `--tool`,
1076
+ `--tools`): the gauge writes to stdout, and forwarded to an instance it landed
1077
+ inside the JSON.
1078
+
960
1079
  ## When mikser is silent
961
1080
 
962
1081
  Silence is this engine's characteristic failure mode: a declaration that
package/docs/lifecycle.md CHANGED
@@ -286,9 +286,9 @@ onFinalized(async () => {
286
286
  ```
287
287
 
288
288
  **What Mikser does here:**
289
- - `catalog.js` / `refs.js` / `manifest.js` commit their per-cycle
289
+ - `catalog.js` / `refs.js` / `manifest/` commit their per-cycle
290
290
  transaction against `mikser.sqlite` (skipped if no changes)
291
- - `engine.js` cleans up broken symlinks in the output folder
291
+ - `engine/finalize.js` cleans up broken symlinks in the output folder
292
292
  - `journal.js` clears the per-cycle queue: `DELETE FROM mikser_journal`.
293
293
  Anything still in the table when the process exits is left for the
294
294
  next `--resume` run.
package/docs/overview.md CHANGED
@@ -98,7 +98,7 @@ The most common confusion when starting out is *which artifact lives where, at w
98
98
  | Asset variants | `assets/<preset>/` (preset outputs) | `assets` plugin | `onRender` |
99
99
  | Persistent entity registry | `mikser_entities` table in `runtime/mikser.sqlite` | `catalog.js` | `onPersist` |
100
100
  | Inverse-reference graph | `mikser_refs` table in `runtime/mikser.sqlite` | `refs.js` | `onPersist` |
101
- | Render snapshots (manifest) | `mikser_snapshots` table in `runtime/mikser.sqlite` | `manifest.js` | `onPersist` / `onFinalize` |
101
+ | Render snapshots (manifest) | `mikser_snapshots` table in `runtime/mikser.sqlite` | `manifest/` | `onPersist` / `onFinalize` |
102
102
  | Per-cycle operations (journal) | `mikser_journal` table in `runtime/mikser.sqlite` | every plugin | every phase |
103
103
  | Front-matter schemas | `schemas/` | `mikser-io-schemas` | `onValidate` |
104
104
  | Rendered HTML | `out/<route>.html` | post plugins + http server | `onRender` |
package/docs/plugins.md CHANGED
@@ -250,221 +250,44 @@ See the [mikser-io-layouts README](https://github.com/almero-digital-marketing/m
250
250
 
251
251
  ---
252
252
 
253
- ### `assets`
253
+ ### `assets` *(sibling: `mikser-io-assets`)*
254
254
 
255
- The assets plugin runs **user-authored preset modules** over binary inputs (images, video, audio, anything) and writes processed outputs. Each preset is a plain Node module — whatever you can call from Node, you can run as a build step. No DSL, no constrained options bag, no vendor pipeline to fight with.
255
+ Runs **user-authored preset modules** over binary inputs — images, video,
256
+ audio, anything — and writes the derived files. A preset is a plain Node
257
+ module: whatever you can call from Node you can run as a build step. No DSL,
258
+ no constrained options bag, no vendor pipeline to fight with. Image resizing
259
+ with sharp, video transcoding with ffmpeg, AI upscaling via Replicate,
260
+ watermarking with canvas — each is a preset. The plugin does not decide what
261
+ is possible; the preset does.
256
262
 
257
- That makes the assets plugin **the most powerful primitive in mikser**: an open-ended build-time processing layer with full Node capabilities. Image resizing with sharp, video transcoding with ffmpeg, AI upscaling via Replicate, watermarking with canvas, custom multi-output pipelines composed across all of the above — each is a preset module. The plugin doesn't decide what's possible; the preset does.
258
-
259
- #### Config
260
-
261
- ```js
262
- assets: {
263
- assetsFolder: 'assets', // working folder for presets — default 'assets'
264
- outputFolder: 'public', // where the processed outputs land — default root
265
-
266
- // Each preset name maps to a list of source globs. A source matches
267
- // multiple presets if needed (one image → thumbnail + hero + og-card).
268
- presets: {
269
- thumbnail: ['/files/images/*.{jpg,png}', '/resources/**/*.{jpg,png}'],
270
- hero: ['/files/images/hero-*.jpg'],
271
- 'video-web': ['/files/videos/*.mp4', '/resources/**/*.mp4'],
272
- 'image-2x': ['/files/images/*.jpg'],
273
- upscaled: ['/files/photos/raw-*.jpg'],
274
- },
275
- }
276
- ```
277
-
278
- `presets/<name>.js` next to your `mikser.config.js` is the preset module for that name.
279
-
280
- #### Where a preset comes from
281
-
282
- A preset name resolves in two places, local first:
283
-
284
- 1. **`presets/<name>.js`** in your project — the common case.
285
- 2. **An npm package `mikser-io-preset-<name>`** — when no local file exists, the plugin resolves the name from your project's `node_modules`. Install a shared preset (`npm install mikser-io-preset-thumbnail`) and reference it by name in `assets.presets` with no local file. Same resolution convention as `post-*` plugins.
286
-
287
- A local file always wins over an npm package of the same name — drop `presets/thumbnail.js` to override one preset from a package while leaving the rest. The two have different update lifetimes: local presets reload on file change in watch mode; npm presets are versioned by their package (bump the dependency to update). `node_modules` is never watched.
288
-
289
- If a configured preset name resolves to neither a local file nor an npm package, the plugin logs `Preset not found: <name> ...` and skips it — the rest of the build proceeds.
290
-
291
- #### Preset module shape
292
-
293
- A preset is a default-exported async function. It receives the entity being processed (with `source`, `destination`, `preset`, `name`, etc.), runs whatever code it needs, and resolves (or rejects) when done.
294
-
295
- ```js
296
- export const revision = 1 // bump to force re-render (cache-bust)
297
- export const format = 'webp' // output format hint (used in the destination filename)
298
-
299
- export default async ({ entity, runtime, logger }) => {
300
- // entity.source — input file on disk
301
- // entity.destination — where to write the result
302
- // entity.preset — config of the matching preset (name, source, options)
303
- // entity.name — original entity name (e.g. '/files/images/hero.jpg')
304
- // runtime / logger — mikser context, including runtime.options for paths
305
- }
306
- ```
307
-
308
- That's the whole contract. Three real examples follow.
309
-
310
- #### Example 1 — Video transcoding via ffmpeg
311
-
312
- A 720×1080 portrait MP4 at 600kbps for the web. fluent-ffmpeg streams progress events back into mikser's logger so the build progress bar reflects encoder progress in real time.
313
-
314
- ```js
315
- // presets/video-web.js
316
- import ffmpeg from 'fluent-ffmpeg'
317
-
318
- export const revision = 7
319
- export const format = 'mp4'
320
-
321
- export default ({ entity: { name, source, destination, preset }, logger }) => {
322
- return new Promise((resolve, reject) => {
323
- ffmpeg(source)
324
- .videoCodec('libx264')
325
- .size('810x1080')
326
- .videoBitrate(600)
327
- .outputOptions('-strict -2')
328
- .on('progress', ({ percent }) =>
329
- logger.trace(`Progress: [${preset.name}] ${name} ${Math.round(percent)}%`))
330
- .on('error', reject)
331
- .on('end', resolve)
332
- .save(destination)
333
- })
334
- }
335
- ```
336
-
337
- 10 lines of glue around ffmpeg. Every published video in the catalog gets transcoded; rebuilds skip unchanged inputs because the journal tracks file mtimes; bumping `revision` re-encodes everything (useful when you change the bitrate).
338
-
339
- #### Example 2 — Image variants via sharp
340
-
341
- Resize + format negotiation. Most projects want srcset variants in WebP and AVIF; this preset emits both with a single sharp pipeline.
342
-
343
- ```js
344
- // presets/image-2x.js
345
- import sharp from 'sharp'
346
- import { dirname, basename, extname, join } from 'node:path'
347
- import { mkdir } from 'node:fs/promises'
348
-
349
- export const revision = 3
350
-
351
- export default async ({ entity: { source, destination }, logger }) => {
352
- const dir = dirname(destination)
353
- const stem = basename(destination, extname(destination))
354
- await mkdir(dir, { recursive: true })
355
-
356
- const pipeline = sharp(source).rotate() // honor EXIF orientation
357
- // 2× variants for each format
358
- await Promise.all([
359
- pipeline.clone().resize({ width: 1600 }).webp({ quality: 85 }).toFile(join(dir, `${stem}@2x.webp`)),
360
- pipeline.clone().resize({ width: 1600 }).avif({ quality: 60 }).toFile(join(dir, `${stem}@2x.avif`)),
361
- pipeline.clone().resize({ width: 800 }).webp({ quality: 85 }).toFile(join(dir, `${stem}.webp`)),
362
- pipeline.clone().resize({ width: 800 }).avif({ quality: 60 }).toFile(join(dir, `${stem}.avif`)),
363
- ])
364
- logger.trace('image-2x emitted 4 variants for %s', source)
365
- }
263
+ ```bash
264
+ npm install mikser-io-assets
366
265
  ```
367
266
 
368
- One preset, four output files per input image. The render-href plugin can then rewrite `<img src="hero.jpg">` to the `@2x.webp` URL with a fallback `<source>` chain — but that's a render-time concern, not the asset plugin's.
369
-
370
- #### Example 3 — AI enhancement via Replicate
371
-
372
- The interesting one. Hand a raw photo to a model on Replicate (here, an image upscaler), poll for completion, fetch the result, write it to disk. The preset is a normal Node module — `fetch` is just `fetch`, no special bridging.
373
-
374
267
  ```js
375
- // presets/upscaled.js
376
- import { writeFile } from 'node:fs/promises'
377
-
378
- export const revision = 2
379
- export const format = 'jpg'
380
-
381
- const REPLICATE_MODEL = 'nightmareai/real-esrgan'
382
- const REPLICATE_VERSION = '...' // pin a model version
383
-
384
- export default async ({ entity: { source, destination }, logger }) => {
385
- // 1. Upload source — Replicate accepts a public URL or base64 data URI
386
- const data = await readFile(source)
387
- const dataUri = `data:image/jpeg;base64,${data.toString('base64')}`
388
-
389
- // 2. Kick off the prediction
390
- const start = await fetch('https://api.replicate.com/v1/predictions', {
391
- method: 'POST',
392
- headers: {
393
- 'authorization': `Token ${process.env.REPLICATE_TOKEN}`,
394
- 'content-type': 'application/json',
395
- },
396
- body: JSON.stringify({
397
- version: REPLICATE_VERSION,
398
- input: { image: dataUri, scale: 4 },
399
- }),
400
- }).then(r => r.json())
401
-
402
- // 3. Poll until succeeded
403
- let prediction = start
404
- while (prediction.status === 'starting' || prediction.status === 'processing') {
405
- await new Promise(r => setTimeout(r, 1500))
406
- prediction = await fetch(start.urls.get, {
407
- headers: { authorization: `Token ${process.env.REPLICATE_TOKEN}` },
408
- }).then(r => r.json())
409
- logger.trace('replicate %s: %s', start.id, prediction.status)
410
- }
411
- if (prediction.status !== 'succeeded') {
412
- throw new Error(`Replicate failed: ${prediction.error}`)
413
- }
268
+ import { assets, renderPreset, assetUrlHelper } from 'mikser-io-assets'
414
269
 
415
- // 4. Download the result and write it where mikser expects
416
- const buffer = Buffer.from(await fetch(prediction.output).then(r => r.arrayBuffer()))
417
- await writeFile(destination, buffer)
418
- }
270
+ plugins: [
271
+ assets({ presets: { web: { match: ['/files/media/**'] } } }),
272
+ renderPreset(), // the renderer presets dispatch through
273
+ assetUrlHelper(), // runtime.asset() for templates
274
+ ]
419
275
  ```
420
276
 
421
- What's unique here is that **none of this required a plugin to mikser**. The preset *is* the plugin code. You can call any third-party service, run any local model (via `@xenova/transformers`, llama.cpp, ONNX runtime, whatever), shell out to anything (`child_process` to a custom binary), or compose multiple steps:
277
+ `renderPreset()` is not optional. It was resolved implicitly while this code
278
+ lived in the engine, whose renderer lookup falls back to a path inside its own
279
+ `src/plugins/render/` — that path no longer holds it.
422
280
 
423
- ```js
424
- // presets/hero-deluxe.js — multi-step pipeline
425
- import sharp from 'sharp'
426
- import { upscaleViaReplicate } from './_replicate.js'
427
- import { applyWatermark } from './_watermark.js'
428
-
429
- export const revision = 1
430
- export default async ({ entity: { source, destination } }) => {
431
- const upscaled = await upscaleViaReplicate(source)
432
- const watermark = await applyWatermark(upscaled, { text: '© Acme Co 2026' })
433
- await sharp(watermark).resize({ width: 2400 }).avif({ quality: 70 }).toFile(destination)
434
- }
435
- ```
436
-
437
- This is the property no other SSG / CMS exposes at this level. Astro's `<Image>` resizes; that's it. Hugo's image processing has a fixed set of operations. Sanity's image CDN runs the transforms it decided to support. mikser's preset is whatever you write — including operations that don't exist anywhere else (upscaling specific to your domain, watermarks driven by per-image rules, multi-format outputs with vendor-specific encoders).
438
-
439
- #### Composition with `resources`
440
-
441
- The assets plugin processes whatever's on disk. Source files don't have to start in your repo — the **resources plugin** (next section) pulls them from external systems (company content servers, S3, vendor APIs) into the working folder so assets can then process them. That composition is where the "advanced pipeline" idea pays off — see the end-of-section example for the full chain.
442
-
443
- #### Watch support
281
+ Split out of core in 10.12.0: it encodes one recipe for producing files from
282
+ files, the way `layouts` encodes one for producing render tasks. What stayed
283
+ in the engine is substrate — the render track that records which asset URLs a
284
+ template asked for, and the finalize check that reports the ones nothing
285
+ produced, both of which work whether or not this package is installed.
444
286
 
445
- Yes — the plugin watches both the source files and the preset modules. Editing a preset re-processes every input that matches it; editing a source re-processes just that input.
446
-
447
-
448
- **Derivatives with no source are removed.** A derivative outlived its source:
449
- deleting a file removed its catalog row and its published copy and left the
450
- derived file, because the delete handler dropped the in-memory mapping and
451
- nothing on disk. Narrowing a preset's `match` left one the same way. A stale
452
- derivative passes every check — the url resolves and the bytes are there —
453
- so the only cleanup was `--clear`, or deleting it by hand.
454
-
455
- The post-cycle pass that already walks the revision markers now also asks, per
456
- derivative, whether it still has a source that this preset still covers. It is
457
- answered from the **catalog**, not from the delete event: a delete entry is
458
- sparse (`{ id, type, collection }`) in every source plugin, so it cannot say
459
- where the derivative went, while the catalog answers whatever route the orphan
460
- arrived by — including ones that predate the fix. Reported as
461
- `Assets removed: N derivative(s) with no source`.
462
-
463
- It does nothing when the catalog is empty. Every check concludes "no source,
464
- therefore orphan", and an empty catalog answers that for every derivative on
465
- the site, so a failed import would otherwise delete the whole assets folder.
466
-
467
- ---
287
+ See the [mikser-io-assets README](https://github.com/almero-digital-marketing/mikser-io-assets#readme)
288
+ for the full reference: preset module shape, `revision` and the
289
+ completed-render marker, recovery from an interrupted derive, `auditIgnore`,
290
+ `--render-presets`, and worked ffmpeg / sharp examples.
468
291
 
469
292
  ### `resources`
470
293
 
@@ -527,7 +350,8 @@ With this config:
527
350
 
528
351
  ```js
529
352
  // mikser.config.js
530
- import { files, resources, assets, api } from 'mikser-io'
353
+ import { files, resources, api } from 'mikser-io'
354
+ import { assets, renderPreset } from 'mikser-io-assets'
531
355
 
532
356
  export default {
533
357
  plugins: [
package/docs/rendering.md CHANGED
@@ -10,7 +10,7 @@ Rendering is triggered by RENDER operations in the journal. Each entry describes
10
10
  RENDER journal entries
11
11
  │
12
12
  ▼
13
- onRender hook (engine.js)
13
+ onRender hook (engine/render-cycle.js)
14
14
  │
15
15
  ├── For each unique entity:destination pair
16
16
  │