mikser-io 9.58.0 → 9.61.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/CLAUDE.md CHANGED
@@ -158,7 +158,12 @@ brevity.
158
158
  - `server.js` — Express bring-up: CLI flags (`--server`, `--cors`,
159
159
  `--no-cors`), trust-proxy, CORS (with extensible header arrays for
160
160
  plugins to push onto), late-binding static mount + listen.
161
- - `report.js` — the `--json` build report. `warnings` is a VIEW of
161
+ - `report.js` — the `--json` build report. `invalidated` says WHY the
162
+ build did work (`nothing` / `sources` / `config` / `version` / `clear`),
163
+ recorded where each is decided — `reportWipe` in database/index.js,
164
+ `reportChanged` in source.js as the complement of `reportGated`.
165
+ `evaluated` is what a subsystem looked at vs what exists
166
+ (`reportEvaluated`), generalised from assets' matchTally. `warnings` is a VIEW of
162
167
  `logger.warn`; `faults` is a view of `logger.error` **carrying a
163
168
  `code`** — a subsystem declaring it cannot work, deduped by that code,
164
169
  never cleared per cycle, and surfaced in `mikser_ping`. The log call is
@@ -182,6 +187,14 @@ brevity.
182
187
  `isRefKey`, `writeEntity`, `matchEntity`, `getFormatInfo`,
183
188
  `changeExtension`, `checksum`, `normalize`, `formatErrorContext`,
184
189
  `formatLogArgs`, `ExpandError`, `AbortError`.
190
+ - `src/plugins/render/file.js` — the template filesystem helpers
191
+ (`readFile`, `jsonFile`, `glob`). Every read RECORDS a query edge by
192
+ default; `{ track: false }` opts out. Keyed on **`id`, never `uri`** —
193
+ for a `files` entity `uri` is the DEPLOYED path, so a uri edge matches
194
+ nothing for the commonest case. `glob` records the PATTERN as a regex
195
+ over ids, not the matched paths, so a file appearing later still
196
+ invalidates. Paths resolve against `options.workingFolder` — the
197
+ render-time `runtime` is a per-render projection with no options on it.
185
198
  - `render.js` / `postprocess.js` — Piscina worker entry points AND the
186
199
  default-export functions the INLINE/SERIAL dispatcher calls directly.
187
200
  Each receives entity + options + config + state; the WORKER path also
@@ -197,7 +210,12 @@ brevity.
197
210
  `descriptor.options` and arrive as the `config` arg to
198
211
  `load`/`render`/`setup`/`postprocess`/`teardown`.
199
212
  - `config.js` — loads `mikser.config.js` at `onLoad` into
200
- `runtime.config`. v9 holds only engine-level keys (`server`,
213
+ `runtime.config`. The cache-invalidating stamp covers the config's whole
214
+ local module graph, captured via `module.registerHooks` during the import
215
+ (Node 22.15+; older runtimes fall back to the entry file and warn). Scoped
216
+ to the config's own directory — `node_modules` alone is not enough of a
217
+ filter, because a workspace symlinks its siblings outside it. Coverage is
218
+ published at `runtime.options.configCoverage` and in the build report. v9 holds only engine-level keys (`server`,
201
219
  `logging`, `catalog` if tuned) plus the `plugins` array — all
202
220
  plugin options moved to the factory call site (ADR-0010).
203
221
  - `plugins.js` — dispatches v9 plugin entries at `onLoad`. Each
package/README.md CHANGED
@@ -6,17 +6,21 @@
6
6
 
7
7
  # Mikser
8
8
 
9
- **Mikser is the AI-native content engine.** It mixes content from anywhere — markdown files, Google Sheets, your ERP, a CMS, any API — into one live catalog, links it together with references, and ships it to any frontend. Edit a price in your ERP or a cell in a spreadsheet, and every page that uses it updates within seconds — in whatever framework you built the site with.
9
+ **Mikser is the AI-native content engine.** It mixes content from anywhere — markdown files, Google Sheets, your ERP, a CMS, any API — into one live index of everything you publish, keeps track of how the pieces point at each other, and ships it to any frontend. Edit a price in your ERP or a cell in a spreadsheet, and every page that uses it updates within seconds — in whatever framework you built the site with.
10
10
 
11
- **Framework-agnostic on both ends.** Headless CMSes (Sanity, Contentful) free you from the database but lock authoring into their UI. Frameworks like Astro let you bring any frontend but lock you into the framework. Mikser frees both: **any source in, any frontend out.** Provider plugins turn external systems into content sources; the catalog is served over plain HTTP, with idiomatic [Vue](https://github.com/almero-digital-marketing/mikser-io-sdk-vue) / [React](https://github.com/almero-digital-marketing/mikser-io-sdk-react) / [Svelte](https://github.com/almero-digital-marketing/mikser-io-sdk-svelte) SDKs on top (`useDocument`, `useDocuments`, multilingual `useHref`, live SSE). It's also AI-native: agents read and write the same catalog over MCP using the same calls a frontend developer uses — there's no separate "AI API" to keep in sync.
11
+ Two words appear throughout, and they mean what they sound like. The **catalog** is that index: everything mikser currently knows about your content, in one place you can ask questions of. A **reference** is one piece of content pointing at another an article at its author, a product page at the price behind it, a landing page at the photo it uses. Mikser knowing those links is what makes most of the rest possible.
12
12
 
13
- **References merge sources into one call.** Mikser tracks how entities reference each other an article's author, the marketing copy and ERP price behind a product page, the images a landing page uses. Fetch the product and pull its marketing copy, price, and hero image along with it in one round-trip. Change any of them and every page subscribed to a reference touching it re-renders live, without pollingand mikser invalidates *exactly* the pages that depend on it, nothing more.
13
+ **Framework-agnostic on both ends.** Headless CMSes (Sanity, Contentful) free you from the database but lock authoring into their UI. Frameworks like Astro let you bring any frontend but lock you into the framework. Mikser frees both: **any source in, any frontend out.** Small adapters turn outside systems into content sources; the catalog is served over ordinary HTTP, with [Vue](https://github.com/almero-digital-marketing/mikser-io-sdk-vue) / [React](https://github.com/almero-digital-marketing/mikser-io-sdk-react) / [Svelte](https://github.com/almero-digital-marketing/mikser-io-sdk-svelte) libraries on top that make it feel native, including live updates pushed to the browser. It's also AI-native: agents read and write that same catalog through MCP the open standard AI assistants use to talk to outside tools — with the same calls a frontend developer makes. There's no separate "AI API" to keep in sync.
14
+
15
+ **References merge sources into one call.** Because mikser knows how the pieces point at each other, you can fetch a product page and pull its marketing copy, its price and its hero image along with it in one round-trip — even though those three came from three different systems. Change any one of them and every page that uses it updates by itself, without anything asking repeatedly whether something changed — and only the pages that actually depend on it, nothing more.
16
+
17
+ **And it can answer for what an agent did.** The reason to hesitate before letting an AI edit a real website isn't that it's hard to set up — it's *it'll break something and nobody will notice until a client calls*. Mikser is built so you don't have to take the agent's word for it: point at any text on the finished site and it tells you which file wrote it, ask before a change and it lists the pages that will be affected, and it refuses the edit outright if someone else touched the file in the meantime. [What an agent can ask](#what-an-agent-can-ask) is the short version.
14
18
 
15
19
  **Your content stays yours.** Source files live on disk as `.md` / `.yml` — diffable, version-controllable, portable on day one and year ten. No database lock-in, no proprietary export. And it's the content layer, not your whole backend: business logic, accounts, and transactions stay in their own services; mikser handles the part that's actually content — rendered to HTML, PDF, email, and other formats from the same source.
16
20
 
17
- Built for Node.js around a strict lifecycle and a composable plugin system: every document, asset, and template flows through the same pipeline, and plugins hook in at any phase. MIT-licensed, runs on Node, zero hosted dependencies. **The portability promise is the architecture, not a feature.**
21
+ Built for Node.js around a fixed sequence of build steps and a plugin system: every document, image and template goes through the same pipeline, and a plugin can attach to any step in it. MIT-licensed, runs on Node, zero hosted dependencies. **The portability promise is the architecture, not a feature.**
18
22
 
19
- > **New to mikser?** Read the [Architecture Overview](./docs/overview.md) — one document, end-to-end walkthrough of how a file becomes a deployed page across all twenty lifecycle phases. It's the doc most projects need first.
23
+ > **New to mikser?** Read the [Architecture Overview](./docs/overview.md) — one document, start to finish, on how a file becomes a published page. It's the doc most projects need first.
20
24
 
21
25
  ## Where it fits
22
26
 
@@ -34,23 +38,21 @@ Build mikser into the parts of your application that are content-shaped. Keep th
34
38
 
35
39
  ## Why mikser
36
40
 
37
- **Your content stays yours.** Source files live on disk as `.md`, `.yml`, `.html` with YAML front-matter. The build output is plain static files. No database lock-in, no proprietary export format. The whole content tree is copyable, diffable, and version-controllable with gityour site is portable on day one and on year ten.
38
-
39
- **Static-first with a built-in live channel.** Most content engines pick one side: static-site generators (Hugo, Eleventy, Jekyll) are fast but rebuild-only; headless CMSes (Sanity, Contentful, Strapi) are live but every page is an API round-trip. Mikser composes both — content publishes as static files by default (fast first paint, no API on the happy path), and the live channel arrives on top, so edits show up in connected clients without a refresh and without losing the static advantage.
41
+ **Fast pages that still update live.** Most tools make you choose. Site generators (Hugo, Eleventy, Jekyll) produce fast pages but only change when you rebuild them; hosted CMSes (Sanity, Contentful, Strapi) update instantly but every page view waits on their API. Mikser does both: pages are published as real files, so they load fast and don't depend on anything being up, and updates arrive on top of that an edit shows up in an open browser without a refresh.
40
42
 
41
- **Incremental builds that scale.** Mikser tracks every entity in a journal. When a file changes, only the affected entities re-process — not the whole site graph. On 10k+ documents this dramatically outpaces tools that rebuild more on every change.
43
+ **It only rebuilds what changed.** When a file changes, mikser works out what actually depends on it and redoes only that — not the whole site. On a site with ten thousand pages that is the difference between a rebuild you wait for and one you don't notice.
42
44
 
43
- **Concurrent rendering.** Renders run async by default and CPU-heavy layouts (MJML compile, image processing, custom transforms) opt into a Piscina worker pool per layout via `task: worker` in frontmatter. Multi-format outputs (HTML, PDF, MJML email, etc.) generate from the same source; the pool is lazy — no workers spawn until they're asked for.
45
+ **Heavy work runs in parallel.** Pages render concurrently, and anything genuinely slow compiling an email template, processing an image can be moved onto separate CPU cores by adding one line to the template. HTML, PDF and email all come from the same source document.
44
46
 
45
47
  **Asset pipelines are whatever Node can do.** Most static frameworks (Astro, Next.js, Hugo) ship image optimization and stop there — video transcoding, AI upscaling, watermarking all need a separate service. Mikser runs user-written modules over binary inputs: ~10 lines around `sharp` resize an image, ~10 around `fluent-ffmpeg` transcode a video, ~30 around the Replicate API upscale with AI. Anything an npm package can do, your pipeline can do — including pulling uploads from a DAM or CDN through the same flow.
46
48
 
47
- **One lifecycle, everything composes.** Plugins hook into 20+ named lifecycle phases. A search-indexing plugin shares the same journal iteration as an email-rendering plugin and a PDF-postprocessing plugin no glue code, no orchestration layer. The engine doesn't know which plugins are loaded; plugins don't have to know about each other.
49
+ **Everything composes.** A build is a fixed sequence of named steps, and a plugin attaches to whichever ones it needs. A search-indexing plugin, an email renderer and a PDF generator all see the same run without knowing about each other, and without any glue code holding them together.
48
50
 
49
51
  **Run anywhere.** The same CLI handles one-shot builds, watch-mode dev loops, and a long-running HTTP server with a shared Express app. `npx mikser` ships a static site; `mikser --watch` is the dev loop; `mikser --server` exposes a live admin/API.
50
52
 
51
- **Outages don't take you down.** Headless CMSes (Contentful, Sanity, Strapi) treat the API as the source of truth — when it blinks, every frontend errors out. Mikser inverts that: reads become static files on disk, the live channel layers on top. A reverse proxy keeps serving the files when mikser blips. Visitors don't notice; live updates pause until mikser returns.
53
+ **Outages don't take the site down.** With a hosted CMS, the API *is* the site — when it blinks, every page errors. Mikser publishes real files to disk and layers live updates on top, so if mikser itself stops, the files keep being served. Visitors see nothing; live updates simply resume when it's back.
52
54
 
53
- **Library mode.** Mikser is also a library. `useRenderer`, `useCollection`, and direct lifecycle hooks let you embed the engine inside an existing Node app instead of running it as a CLI — plugins like `vector` add their own primitives the same way.
55
+ **Use it as a library.** Mikser doesn't have to be a command you run. You can embed the engine inside an existing Node application and drive it directly.
54
56
 
55
57
  **Open source.** MIT-licensed, on GitHub, no telemetry, no auth wall, no SaaS dependency. What you see is what runs.
56
58
 
@@ -58,15 +60,15 @@ Build mikser into the parts of your application that are content-shaped. Keep th
58
60
 
59
61
  A real content stack pulls from more than one system. Marketing copy lives in a CMS or a spreadsheet. Prices and stock live in an ERP. Hero images live in a DAM. Editorial pages live in markdown files in the repo. Most teams either pick one tool and contort the rest to fit it, or build sync services that copy everything into one database — and then more sync services when things drift out of sync.
60
62
 
61
- Mikser is built around a different bet: **one queryable substrate that any source can pour into, and any frontend can read from.**
63
+ Mikser is built around a different bet: **one place any source can pour into, and any frontend can read from.**
62
64
 
63
- **Any source.** Provider plugins let you treat external systems as content sources. A Google Sheet, an ERP feed, a Drive folder, a Notion database, an HTTP webhook, the local repo's `.md` files each becomes a stream of entities flowing into mikser's catalog with the same shape. The authoring tool doesn't change. The editorial workflow doesn't change. The team writing product copy in Google Sheets keeps writing product copy in Google Sheets — mikser just notices when they save.
65
+ **Any source.** Small adapters let you treat outside systems as content sources. A Google Sheet, an ERP feed, a Drive folder, a Notion database, the `.md` files in your repo each pours into the catalog in the same shape, so everything downstream treats them alike. The authoring tool doesn't change. The editorial workflow doesn't change. The team writing product copy in Google Sheets keeps writing product copy in Google Sheets — mikser just notices when they save.
64
66
 
65
- **Cross-source composition through references.** Mikser tracks references between entities the way a graph database does. A product page can declare `$marketing` pointing at a row pulled from a spreadsheet, `$pricing` at a record pulled from your ERP, `$hero` at an asset pulled from your DAM. A single API call from your frontend asks for the product page *with* its referenced data — and gets the page, the marketing copy, the price, and the hero image merged into one response. One round trip, not three or four, and no consumer-side join logic to maintain.
67
+ **Pieces from different systems, joined.** A product page can point at a row from a spreadsheet for its copy, a record from your ERP for its price, and a photo from your asset library — and one request from your frontend returns all four together. One round trip instead of four, and no stitching code on your side to keep working.
66
68
 
67
- **Live updates are uniform across every source.** Edit a cell in the spreadsheet, change a price in the ERP, replace an asset in the DAM — the pages depending on those entities update within seconds, in every frontend connected to mikser's live channel. You don't write per-source invalidation; mikser already knows which pages reference what.
69
+ **Live updates work the same way whatever the source.** Edit a cell in the spreadsheet, change a price in the ERP, swap a photo in the asset library — the pages that use them update within seconds, everywhere. You don't write anything to make that happen for each source; mikser already knows which pages use what.
68
70
 
69
- **Your frontend is whatever you want.** Mikser exposes the catalog over HTTP (and over MCP, for AI agents). You query it from React, Vue, Svelte, SvelteKit, Next.js, an iOS app, a kiosk — anything that speaks HTTP. The framework SDKs (`mikser-io-sdk-react`, `mikser-io-sdk-vue`, `mikser-io-sdk-svelte`) make the calls feel native, but they're optional. Headless CMSes free you from the database lock. Frameworks like Astro free you from one kind of authoring lock. Mikser frees you from both at once: **any source on the input side, any framework on the output side.**
71
+ **Your frontend is whatever you want.** The catalog is served over ordinary HTTP (and over MCP, for AI agents). You read it from React, Vue, Svelte, SvelteKit, Next.js, an iOS app, a kiosk — anything that can make a web request. The framework SDKs (`mikser-io-sdk-react`, `mikser-io-sdk-vue`, `mikser-io-sdk-svelte`) make the calls feel native, but they're optional. Headless CMSes free you from the database lock. Frameworks like Astro free you from one kind of authoring lock. Mikser frees you from both at once: **any source on the input side, any framework on the output side.**
70
72
 
71
73
  What this looks like in practice:
72
74
 
@@ -78,15 +80,15 @@ Adding a source is mechanical. See [`mikser-io-csv`](https://github.com/almero-d
78
80
 
79
81
  ## Built for AI-assisted development
80
82
 
81
- Files-as-source isn't just a portability story it makes the project unusually friendly to AI coding agents. There's a static-time half (the agent reads your tree the way it reads any repo no DB connection, no schema upload, no sandboxed query layer to learn) and a runtime half (when the agent needs to write or render, mikser ships its own MCP server in core, so it talks to the live engine instead of a parallel REST shim you have to maintain).
83
+ That last section was about an agent looking after a site that already exists. This one is about building one in the first place where keeping content in files turns out to matter for a second reason: a coding assistant can read your whole project the way it reads any repository, with no database to connect to and no schema to be told about. And when it needs to write something or render a preview, it talks to the running engine directly rather than through a separate API somebody has to maintain.
82
84
 
83
85
  **Zero infra friction for discovery.** An agent can `rg "type: product"` across the content tree to find every product doc in a second. No DB connection, no API token, no schema file to parse.
84
86
 
85
87
  **The schema emerges from examples, not a definition file.** Front-matter shows what fields exist *in the docs that exist*. Markdown + YAML are overwhelmingly well-represented in AI training data, so the model "speaks" them fluently and infers structure from real documents better than from a schema definition.
86
88
 
87
- **Determinism shortens the iteration loop.** Save a file watcher fires predictable rebuild. No DB triggers, no surprise cache invalidation, no API quotas. The agent's mental model of "what happens next" can be precise instead of probabilistic.
89
+ **What happens next is predictable.** Save a file, the build runs, the output changes no database triggers firing elsewhere, no cache clearing itself at an awkward moment, no rate limits. An assistant can reason about the result instead of guessing at it.
88
90
 
89
- **The SDK's `.d.ts` is the read-side contract.** When the agent writes frontend query code, the operator subset and envelope shape are right there in types — a step-change for code generation quality versus "go read the REST API docs."
91
+ **The types are the documentation.** When an assistant writes frontend code against mikser, the shape of every query and response is described in TypeScript types it can read directly which produces markedly better generated code than pointing it at API docs and hoping.
90
92
 
91
93
  **Plugin-by-example.** Authoring a new plugin? There are 15+ existing ones in the same shape to pattern-match against. Convention is dense enough that new plugins look like the old ones without coaching.
92
94
 
@@ -101,20 +103,20 @@ Files-as-source isn't just a portability story — it makes the project unusuall
101
103
 
102
104
  The runtime half — the agent driving the live engine, not just reading the tree — gets its own section below.
103
105
 
104
- The honest caveat: this advantage is real on **content-shaped work** — adding pages, restructuring collections, generating new layouts, building frontends. It doesn't make mikser better for non-content tasks (concurrency bugs in the worker pool, database tuning elsewhere in your stack); those are plain Node debugging like anywhere else. The visibility advantage also degrades past ~10k documents at that scale the agent queries via the SDK instead of grepping the tree, which is still good but less "see everything at once."
106
+ The honest caveat: this helps with **content work** — adding pages, reorganising sections, building frontends. It doesn't make mikser better at everything else in your stack; that's ordinary debugging like anywhere. And the "read the whole project at once" advantage fades past roughly ten thousand documents, where an assistant queries the catalog instead still good, less panoramic.
105
107
 
106
108
  ## Control mikser from your AI agent
107
109
 
108
110
  Install the [`mikser-io-mcp`](https://github.com/almero-digital-marketing/mikser-io-mcp) plugin and any MCP-speaking client — Claude Desktop, Claude Code, ChatGPT, custom agents — connects to the running engine. From inside a chat, your AI can:
109
111
 
110
- - read every entity in the catalog
111
- - write new content files (markdown, layouts, configuration) writes land on disk and the next cycle picks them up
112
- - render any layout for preview without touching the output folder
113
- - **surface interactive UI inline in the conversation** you author the UI as a normal mikser layout with YAML frontmatter (`mcpUi: { mode, actions }`). The agent reads `mikser://mcp-ui/modes` to discover what UIs your project supports, calls `mikser_preview_ui` to render one against an entity, and the host displays the result as a sandboxed iframe in the chat. Buttons in the UI deliver their click back as a separate MCP tool turn — the iframe sends a JSON-RPC `tools/call` to the host over `postMessage` per the [MCP Apps spec](https://github.com/modelcontextprotocol/ext-apps), which the host bridges into a real `mikser_ui_action` invocation. The agent sees a structured `{action, entityId, payload}` result; if you declared `mcpUi.handler.url`, mikser forwards the action to your webhook first and uses its response. No separate UI framework, no glue code; layouts are still just layouts
114
- - watch every build log as it streams past
115
- - introspect engine state — current lifecycle phase, effective config, recent log buffer
112
+ - read anything in the catalog
113
+ - write new content pages, templates, settings. The file lands on disk and the next build picks it up.
114
+ - render a page just to look at it, without publishing anything
115
+ - **show you a real interface inside the chat.** Instead of describing a change, the agent can render an actual editable panel a form, a preview with Approve and Reject buttons and you click it in the conversation. Pressing a button sends your answer straight back to the agent as its next step.
116
116
 
117
- Plugins extend the tool surface the same way they mount HTTP routes; install the plugin, the agent gets new verbs. No glue code, no per-project agent wiring.
117
+ You build those panels as ordinary mikser templates; there is no separate UI framework and no glue code. Under the hood they follow the [MCP Apps spec](https://github.com/modelcontextprotocol/ext-apps): the panel renders in a sandboxed frame, a click travels back as a real tool call, and if you point it at a webhook of your own, mikser forwards the action there first and uses the reply.
118
+
119
+ Plugins add to what the agent can do the same way they add web routes: install one, and the agent has new abilities. Nothing to wire up per project.
118
120
 
119
121
  ```js
120
122
  // mikser.config.js
@@ -132,21 +134,46 @@ mikser --server # MCP mounts at /mcp on the same port
132
134
 
133
135
  What that feels like in practice: *"draft three hero-section variants and show me previews"* — three layouts written, three previews returned inline, one chat turn. *"Why did the build break?"* — the agent reads the rolling log buffer and answers from the same view your terminal sees. *"Update this article's tone and show me the preview"* — the agent edits the file and surfaces the rendered article inline; you click Approve or Reject, the agent acts on your choice. Operator, AI, and any observer dashboard share the same engine because mikser is single-tenant by design.
134
136
 
135
- ### Editing is the easy part — verification is where it pays off
137
+ ### What an agent can ask
138
+
139
+ When an agent changes ten files you can read them all. At two hundred you can't — and that's exactly the point where letting AI do the work starts to be worth it. Most systems leave the checking to you. Mikser lets the agent check its own work first, because the engine kept a record of what it did and can be asked about it afterwards.
140
+
141
+ **"Where did this come from?"** — point at any text on the finished site and get back the file that produced it, and the line in that file. Nothing else needs to happen: the engine noted it while building. Without this, finding which file writes a particular button means opening the site in a browser, poking at the page source and guessing at filenames.
142
+
143
+ **"What else is using this?"** — before removing a photo, a page or a person, ask what still points at it. The answer is a list, not a search that might have missed something.
144
+
145
+ **"What will this change touch?"** — ask before writing, not after. You get back the pages that would be rebuilt and why each one — so nobody discovers on Monday that editing a shared snippet quietly changed forty pages.
146
+
147
+ **"Did it actually work?"** — the site as it really is on disk, compared against what the engine believes it published. "The build said it succeeded" and "the site is actually up to date" are two different claims, and this checks the second one.
136
148
 
137
- When an AI agent edits ten files, the next question is: *did it do what I asked?* When it edits two hundred, you can't read them all yourself — and that's exactly the scale where AI editing starts being interesting. Most content systems leave verification to the human (read the diff, check the preview, hope you caught the issues). Mikser turns the questions a reviewer would ask into things the agent can answer for itself:
149
+ Alongside those: search the whole site for anything that still reads the old way, and because everything is ordinary files the same change history and one-command rollback your developers already use for code.
138
150
 
139
- - **"Did I update every article that needed it?"** semantic search finds anything that still matches the old tone or phrasing the agent was supposed to change.
140
- - **"What else mentions this person, product, or topic?"** — mikser knows how content references content. "Show me every page that mentions Dick" returns the list instantly, no full-tree scan.
141
- - **"Did anything break?"** — if a reference points at something that no longer exists, mikser surfaces it as a warning. The build either completes cleanly or doesn't.
142
- - **"Can I see what this looks like before publishing?"** — render any single page or section on demand, no full rebuild, no staging deploy. With an `mcpUi` layout, the agent surfaces the rendered preview *inside the chat* with approve/reject controls; one click sends the result back as the tool response.
143
- - **"What changed since I last looked?"** — `git diff`. The catalog is plain files, so the audit trail is the same one your engineers already use for code.
144
- - **"Roll back this batch?"** — `git checkout`. Atomic. No database migration to undo, no version-history-feature to learn.
151
+ What this changes: reviewing AI work stops being *read every single change* and becomes *spot-check where the agent was least sure.*
145
152
 
146
- The shift this enables: AI review stops being *"read every change"* and becomes *"spot-check the agent's confidence."* The agent verifies its own work; the human samples and approves. That's the workflow that lets a content team actually use AI at scale — change the tone across the entire site in a morning, ship it after a coffee.
153
+ ### What stops an agent breaking your site
154
+
155
+ The other half is the engine saying no. Not politely stepping aside — actually refusing to do things it can tell are wrong:
156
+
157
+ - **It won't overwrite somebody else's work.** If a person, or another agent, changed the file since this one read it, the edit is refused rather than quietly replacing what they wrote.
158
+ - **Undo takes back one piece of work, not everything since.** Documents added afterwards stay. And if taking a change back would leave a link pointing at a page that no longer exists, it refuses — even though the file change itself would have gone through fine. That broken link is the failure nobody spots until it's live.
159
+ - **Nothing is deleted quietly.** A delete first lists what still points at the thing being removed. Uploaded files go to a recycle folder rather than being erased, so a wrong call is recoverable.
160
+ - **People only get the parts of the site they should have.** You decide who may change the words, who may change the design, who may do both. When an agent hits that boundary it doesn't just fail — it says which role could do the thing, which is a sentence the person can forward to whoever can. That's the difference between a dead end and a handoff.
161
+ - **If something is broken, it says so.** "No results" because a feature has failed and "no results" because there genuinely aren't any look identical everywhere else. Here, the agent can tell which one it's looking at — and so can you.
147
162
 
148
163
  Full tool reference and twelve worked scenarios in the [`mikser-io-mcp` plugin docs](https://github.com/almero-digital-marketing/mikser-io-mcp#readme).
149
164
 
165
+ ## The handoff
166
+
167
+ The shape this is built for: a developer builds the site, hands it to the client, and the client points their own agent at it. From then on changes happen inside the structure that was designed, rather than the agent reinventing it.
168
+
169
+ What makes that work is that the boundary is real, not a convention everyone agrees to respect. You decide which parts of the site each kind of person may change — the words, the pictures, the design, the templates — and you write the description of each role in your own words. Mikser hands that description to whoever is asking. A site might describe its editor role as:
170
+
171
+ > Pages, text and images. Can read the templates and styles to see how a page is built, but not change them — so nothing edited here can break the site.
172
+
173
+ An agent connecting as that role sees exactly that sentence, what it may change, what it may only look at, and what the other roles on the site can do. When it reaches the edge of what it's allowed, it stops and names the role that could do the thing instead — so the answer is *ask your developer about the design system*, not a blank error the agent might try to route around. There is no way for it to ask for more access, and there won't be.
174
+
175
+ So the developer's structure holds because the engine holds it, the client gets an agent that can genuinely change the content, and when something is out of bounds you get a sentence with a name in it rather than a broken page nobody noticed.
176
+
150
177
  ## Plugins on top of the engine
151
178
 
152
179
  The engine is what stays stable — the lifecycle, the catalog, the file-based content model. Plugins are independent npm packages sitting on the plugin API: some are essential to the SSG workflow, some give external systems HTTP access to the catalog, some are integrations that earn their keep on real projects, and some are probes that test how far the lifecycle stretches without touching the core. Install what a project needs; drop what it doesn't.
@@ -237,11 +264,11 @@ For a working starter — config with a real plugin set, sample `documents/`, ex
237
264
  The shape mikser fits cleanly:
238
265
 
239
266
  - **Marketing sites with editorial teams** — content authors work in files (via their editor, a Git client, or `mikser-io-decap`), engineers ship features without negotiating with a CMS schema, the site stays portable.
240
- - **Multilingual publishing platforms** — the `useHref()` / `useAlternates()` pattern decouples logical references from per-locale URLs. One source tree, many language deployments.
241
- - **Content-heavy product catalogues** — `documents` + `mikser-io-schemas` + `data` plugin + a Frontend Framework = typed product listings with live updates, semantic search via `vector`, and static-CDN-friendly JSON snapshots all at once.
242
- - **AI-augmented media pipelines** — `assets` plugin presets call out to Replicate / OpenAI / local models to upscale images, transcribe audio, transcode video. The pipeline is JS code, so anything Node can do is in scope.
243
- - **Mixed-output publishing** — the same source document renders to HTML, PDF (via `post-pdf`), MJML email (via `post-mjml`), and JSON snapshots. One catalog, many output formats, all concurrent.
244
- - **Headless backends for static frontends** pair the `api` plugin with `sdk-api` for SSE-driven live frontends; pair the `data` plugin output with any static host for pre-rendered consumption.
267
+ - **Multilingual publishing** — link to a page by what it *is*, and each language gets the right URL automatically. One source tree, many language sites.
268
+ - **Large product catalogues** — product listings that update live, search by meaning rather than exact words, and pre-built data files a CDN can serve from the same source.
269
+ - **AI-assisted media handling** — upscale images, transcribe audio, transcode video, on the way in. The pipeline is ordinary JavaScript, so anything Node can do is available to it.
270
+ - **One source, several formats** — the same document becomes a web page, a PDF and an email, all from one edit.
271
+ - **A content backend for a frontend you already have** serve it live over HTTP, or export flat files for any static host.
245
272
 
246
273
  The shape mikser **doesn't** fit cleanly: anything with non-technical content authors who can't or won't work with files, anything with non-content business logic at the core, anything needing multi-tenant / per-user auth. Those aren't bugs — they're outside the design envelope. See [`decisions/0001-content-layer-not-the-app.md`](./docs/decisions/0001-content-layer-not-the-app.md) for the explicit scope decision.
247
274
 
@@ -256,7 +283,7 @@ What you get from how this project is built:
256
283
 
257
284
  ## Mikser among static site generators
258
285
 
259
- **The only SSG that's both fast enough for daily use and deep enough for AI agents to drive.**
286
+ Mikser is often evaluated against static site generators, so here is the honest placement. **It does not win on speed** — Hugo does, and that is worth knowing. What mikser has that none of them do is a build the engine can answer questions about afterwards.
260
287
 
261
288
  | SSG | Speed | Feature surface | The tradeoff |
262
289
  |---|---|---|---|
@@ -264,9 +291,11 @@ What you get from how this project is built:
264
291
  | Eleventy | OK | Broad, no introspection | Flexible but slow at corpus scale |
265
292
  | Astro | OK | Modern, framework-coupled | Tied to a frontend framework |
266
293
  | Next.js SSG | meh | Full framework | Framework first, content second |
267
- | **Mikser** | Incremental beats Hugo's full rebuild | Broad, deeply observable, AI-native | None of the speed-vs-features kind |
294
+ | **Mikser** | Fast enough that watch-mode rebuilds feel instant | Broad, and queryable after the fact | Not the fastest cold build |
295
+
296
+ Hugo wins a full cold rebuild. Most cycles aren't full cold rebuilds — CI deploys, watch-mode edits, "ran mikser, nothing changed" — and there the persistent manifest skips what is still current. But speed is not the axis worth choosing mikser on, and a reader who evaluates it as a faster SSG will miss what it is for.
268
297
 
269
- Every other SSG asks you to trade something raw speed for features (Hugo), features for framework lock-in (Astro, Next), introspection for any of the above (Eleventy). Mikser doesn't make that trade. Hugo still wins a full cold rebuild and that's worth knowing — but most cycles aren't full cold rebuilds. CI deploys, watch-mode edits, "ran mikser, nothing changed" — these are the daily case, where Mikser's persistent manifest skips what's still current while Hugo rebuilds everything from scratch. The rest of the feature surface (MCP introspection on every lifecycle phase, files-as-source-of-truth, 20-phase composability, multi-format outputs from one corpus) is what no other "fast" SSG carries.
298
+ **The comparison that actually matters is different.** If you are handing a site to someone whose agent will edit it, the real alternative is a headless CMS with an MCP wrapper: structured content, and no way to check what the agent did to it. That is the gap [What an agent can ask](#what-an-agent-can-ask) describes, and it is not a speed question.
270
299
 
271
300
  ## Acknowledgments
272
301
 
@@ -1112,6 +1112,17 @@ debris; `isJunkPath(filePath)` asks.
1112
1112
 
1113
1113
  Plugin factories — `yaml()`, `json()`, `frontMatter()`, `assets()`,
1114
1114
  `resources()`, `shares()`, `observer()`, `mapper()`, `commands()`,
1115
+
1116
+ `observer({ <name>: { readMany, readOne?, uri?, cron?, collection?, type? } })`
1117
+ pulls entities from an external API and keeps the catalog in step with it:
1118
+ `readMany` supplies the records, each becomes an entity under
1119
+ `/observer/<collection>/<record.id>` with the record as its meta, and anything
1120
+ the source no longer returns is deleted. Writes are gated on a checksum of the
1121
+ meta, so a frequent cron over unchanged records writes nothing. `readOne` is
1122
+ the single-record path a webhook uses. `uri` is OPTIONAL — give it the
1123
+ collection's endpoint and mikser can route a webhook to it by origin and record
1124
+ where each entity came from; leave it out for a source with no addressable URL,
1125
+ and the entities are synthetic, with the record as their whole content.
1115
1126
  `renderHbs()` — are configured rather than called, and live in
1116
1127
  [configuration.md](configuration.md).
1117
1128
 
@@ -26,6 +26,9 @@ engine source, the entry point is missing and belongs on this page.
26
26
  | I am an agent reading CLI output, not speaking MCP | [`--tools` / `--tool`](#the-two-agent-workflows) |
27
27
  | Did my schema validate anything at all? | [`schemas.names()`](#schemasnames--schemaslookup) |
28
28
  | A tool answered emptily — is it broken, or is there nothing to find? | [`faults`](#faults) |
29
+ | I edited the build and nothing rebuilt | `--json` → `config.files` |
30
+ | Why did this build do any work at all? | `--json` → `invalidated` |
31
+ | Did my new pattern get a chance to match? | `--json` → `evaluated` |
29
32
 
30
33
  ## Command line
31
34
 
@@ -150,6 +153,33 @@ The buckets, and the distinction between them is the point:
150
153
  | `warnings` | everything that went through `logger.warn` this cycle, with its `code` |
151
154
  | `faults` | subsystems that reported they **cannot work** — see [Faults](#faults) |
152
155
 
156
+ `invalidated` says why the build did anything, which the counts never did —
157
+ `0 rendered` reads the same whether nothing needed doing or something did and
158
+ the engine failed to notice:
159
+
160
+ | cause | means |
161
+ | --- | --- |
162
+ | `nothing` | the engine looked and there was no work. A finding, not an absence — check `summary.gated` to see how much it looked at |
163
+ | `sources` | these files changed, named in `changed` (capped, with `truncated` when there were more) |
164
+ | `config` | the config or a module it imports moved, so the cache was wiped |
165
+ | `version` | the engine version moved, with `from` and `to` |
166
+ | `clear` | you passed `--clear` |
167
+
168
+ A wipe outranks changed sources: once the cache goes, every file is a changed
169
+ file and listing them all is noise.
170
+
171
+ `evaluated` says what each subsystem actually looked at, against what exists —
172
+ `{ assets: { evaluated: 0, of: 397 } }`. A new pattern that never had the
173
+ chance to match anything otherwise looks exactly like a run with nothing to do.
174
+
175
+ Each report also carries `config`: the files the config stamp spans, and
176
+ whether that coverage is `complete`. The stamp is what makes a config edit
177
+ invalidate the cache, and it covers the config's whole local module graph —
178
+ not just the entry file. So if you keep the build in `config/pipeline.js` and
179
+ import it from both a dev and a prod config, editing the pipeline invalidates,
180
+ which is the case that matters. `config.files` is there so *"I edited the build
181
+ and nothing rebuilt"* is answerable by reading rather than by experiment.
182
+
153
183
  Each report also carries `cycleId`, `startedAt` and `finishedAt`. Under
154
184
  `--watch` two consecutive reports are otherwise indistinguishable, so
155
185
  "is this my edit's cycle or the one before it" has no answer without the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "9.58.0",
3
+ "version": "9.61.0",
4
4
  "description": "A mixer for content: entities in, configurable render pipelines, outputs of any kind. Static sites are the canonical recipe, not the definition — the same engine renders PDFs, emails and whatever a renderer plugin produces. Files are the source of truth, every lifecycle phase is observable, and the build graph is queryable by an agent.",
5
5
  "main": "index.js",
6
6
  "exports": {
package/src/catalog.js CHANGED
@@ -536,6 +536,33 @@ export async function findEntities(query) {
536
536
  return shim.all().filter(m)
537
537
  }
538
538
 
539
+ // How many entities match, without materializing any of them.
540
+ //
541
+ // findEntities parses the JSON body of every row it returns, which is the
542
+ // wrong price for a number. A COUNT(*) with the same pushed-down WHERE is one
543
+ // query and no parsing — the difference between asking "how big is the
544
+ // catalog" costing nothing and costing a full scan.
545
+ //
546
+ // Returns null when the query cannot be answered in SQL alone. A residual
547
+ // JS-side clause would require fetching the rows to test them, which is
548
+ // exactly what this exists to avoid, and guessing a number would be worse than
549
+ // admitting there isn't one.
550
+ export function countEntities(query) {
551
+ if (!db?.isOpen) {
552
+ const shim = mapStub()
553
+ if (!shim) return 0
554
+ if (!query) return shim.all().length
555
+ const m = typeof query === 'function' ? query : sift(query)
556
+ return shim.all().filter(m).length
557
+ }
558
+ if (!query) return stmtCount.get().c
559
+
560
+ const t = siftToSql(query)
561
+ // A residual matcher means part of the filter never reached SQL.
562
+ if (residualMatcher(query, t.jsFilter)) return null
563
+ return db.prepare(`SELECT COUNT(*) AS c FROM mikser_entities ${t.sql}`).get(...t.params).c
564
+ }
565
+
539
566
  // Streaming variant of findEntities. Same query shape, same sift→SQL
540
567
  // translation, but yields entities chunk-by-chunk so peak memory is
541
568
  // O(chunk × entity) instead of O(corpus × entity).
package/src/config.js CHANGED
@@ -1,10 +1,74 @@
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
+ import { checksum, checksumOf } from './utils.js'
5
5
  import path from 'node:path'
6
+ import nodeModule from 'node:module'
7
+ import { fileURLToPath } from 'node:url'
6
8
  import { existsSync } from 'node:fs'
7
9
 
10
+ // Every local module the config actually pulls in, recorded as it loads.
11
+ //
12
+ // The stamp used to be the entry file's bytes alone, and that is inverted
13
+ // against significance the moment a project has more than one config — which
14
+ // is as soon as it has a dev one and a prod one. Both import the module that
15
+ // decides how the site is built; neither IS that module. So a comment in the
16
+ // thin wrapper wiped the catalog, and rewriting the pipeline that processes
17
+ // every asset changed nothing and rebuilt nothing, on a green build.
18
+ //
19
+ // Node's own loader hook rather than parsing import statements. The resolver
20
+ // already knows the answer exactly, including transitive imports and dynamic
21
+ // ones that actually ran, and a regex over source is the kind of thing that
22
+ // silently misses a case — which here means silently not invalidating, the
23
+ // exact failure being fixed.
24
+ //
25
+ // Scoped to files under the config's own directory. `node_modules` is not
26
+ // enough of a filter on its own: a workspace symlinks its siblings, so
27
+ // `mikser-io` itself resolves to a real path outside node_modules and the
28
+ // engine's entire source tree would land in the stamp.
29
+ function captureConfigGraph(root) {
30
+ const files = new Set()
31
+ let capturing = false
32
+
33
+ // Node 22.15+. Older runtimes keep the previous behaviour rather than a
34
+ // worse guess, and `configCoverage` says which one is in force.
35
+ if (typeof nodeModule.registerHooks !== 'function') {
36
+ return { files, supported: false, start() {}, stop() {} }
37
+ }
38
+ nodeModule.registerHooks({
39
+ load(url, context, next) {
40
+ if (capturing && url.startsWith('file:')) {
41
+ const file = fileURLToPath(url)
42
+ if (!file.includes(`${path.sep}node_modules${path.sep}`)
43
+ && !path.relative(root, file).startsWith('..')) {
44
+ files.add(file)
45
+ }
46
+ }
47
+ return next(url, context)
48
+ },
49
+ })
50
+ return {
51
+ files,
52
+ supported: true,
53
+ start() { capturing = true },
54
+ stop() { capturing = false },
55
+ }
56
+ }
57
+
58
+ // One stamp over the whole set, path-qualified and order-independent.
59
+ //
60
+ // Path as well as content, so moving a module between two files with the same
61
+ // bytes still counts as a change.
62
+ async function stampGraph(files) {
63
+ const parts = []
64
+ for (const file of [...files].sort()) {
65
+ try {
66
+ parts.push(`${file}:${await checksum(file)}`)
67
+ } catch { /* vanished between load and stat — the next cycle sees it */ }
68
+ }
69
+ return parts.length ? checksumOf(parts.join('\n')) : null
70
+ }
71
+
8
72
  onLoad(async () => {
9
73
  const logger = useLogger()
10
74
  const configFile = path.resolve(runtime.options.config)
@@ -19,16 +83,10 @@ onLoad(async () => {
19
83
  // invalidation, so the only symptom was output that did not match the
20
84
  // config, with nothing saying so.
21
85
  //
22
- // The file's bytes only. A config that imports other modules will not
23
- // notice a change in those, which is a real limit worth knowing rather
24
- // than a reason to hash the whole module graph.
25
- try {
26
- runtime.options.configChecksum = await checksum(configFile)
27
- } catch {
28
- // No config file is a legitimate state (defaults all the way down);
29
- // absent stamp means "nothing to compare", not "changed".
30
- runtime.options.configChecksum = null
31
- }
86
+ // Computed AFTER the import, from what the import actually loaded see
87
+ // captureConfigGraph. Before it, there is nothing to hash but the entry
88
+ // file, which is the bug.
89
+ const graph = captureConfigGraph(path.dirname(configFile))
32
90
 
33
91
  // Absence is decided by looking for the file, NOT by catching
34
92
  // ERR_MODULE_NOT_FOUND from the import.
@@ -48,14 +106,54 @@ onLoad(async () => {
48
106
  logger.debug('No config file at %s — using defaults', configFile)
49
107
  } else {
50
108
  // No catch: any failure loading a config that EXISTS is fatal.
51
- const config = await import(configFile)
52
- if (typeof config.default == 'function') {
53
- runtime.config = await config.default(runtime)
54
- } else if (typeof config.default == 'object') {
55
- runtime.config = config.default
109
+ graph.start()
110
+ try {
111
+ const config = await import(configFile)
112
+ if (typeof config.default == 'function') {
113
+ runtime.config = await config.default(runtime)
114
+ } else if (typeof config.default == 'object') {
115
+ runtime.config = config.default
116
+ }
117
+ } finally {
118
+ graph.stop()
119
+ }
120
+ }
121
+
122
+ // The stamp, and what it covers.
123
+ //
124
+ // Coverage is published because "I edited the build and nothing rebuilt"
125
+ // was only answerable by experiment. It is the difference between a limit
126
+ // that is documented and one that is visible at the moment it bites.
127
+ //
128
+ // Absent stamp means "nothing to compare", not "changed" — no config file
129
+ // is a legitimate state, defaults all the way down.
130
+ const covered = [...graph.files]
131
+ runtime.options.configCoverage = {
132
+ files: covered.sort(),
133
+ // False on a runtime without loader hooks, where the stamp is the
134
+ // entry file alone and a change to anything it imports is invisible.
135
+ complete: graph.supported,
136
+ }
137
+ if (covered.length) {
138
+ runtime.options.configChecksum = await stampGraph(covered)
139
+ logger.debug('Config checksum spans %d file(s): %s', covered.length, covered.join(', '))
140
+ } else {
141
+ try {
142
+ runtime.options.configChecksum = existsSync(configFile) ? await checksum(configFile) : null
143
+ } catch {
144
+ runtime.options.configChecksum = null
56
145
  }
57
146
  }
58
147
 
148
+ // Said once, at the only moment it can be acted on. A project whose build
149
+ // lives in a module the stamp cannot reach gets a rebuild it did not ask
150
+ // for rather than silence it cannot diagnose.
151
+ if (existsSync(configFile) && !graph.supported) {
152
+ logger.warn({ code: 'config-coverage-partial' },
153
+ 'This Node build has no module loader hooks, so the config stamp covers %s alone. Editing a module '
154
+ + 'it imports will NOT invalidate anything — run with --force after such a change.', configFile)
155
+ }
156
+
59
157
  // Nothing else is loaded. There is deliberately no `config/<plugin>
60
158
  // .config.js` channel: plugin options arrive as factory arguments
61
159
  // (ADR-0010), and an entry in `plugins` is a factory call result — a
@@ -47,6 +47,7 @@ import { mkdirSync, unlinkSync, existsSync, readFileSync, writeFileSync } from '
47
47
  import Database from 'better-sqlite3'
48
48
  import runtime from '../runtime.js'
49
49
  import { isReportOnlyRun } from '../tools.js'
50
+ import { reportWipe } from '../report.js'
50
51
  import { onLoaded } from '../lifecycle.js'
51
52
  import packageInfo from '../../package.json' with { type: 'json' }
52
53
 
@@ -355,6 +356,15 @@ export function createSqliteDatabase({
355
356
  } else if (forceWipe) {
356
357
  logger?.info('Clearing the cache and rebuilding from sources.')
357
358
  }
359
+ // Recorded where the decision is made. The report otherwise shows
360
+ // a cold build and no reason for it, and "everything rebuilt" is
361
+ // the same output whether the version moved, the config moved, or
362
+ // someone passed --clear.
363
+ reportWipe(
364
+ recorded && recorded !== version ? 'version' : forceWipe ? 'clear' : 'config',
365
+ recorded && recorded !== version ? { from: recorded, to: version } : {},
366
+ )
367
+
358
368
  // Unlink, rather than dropping table by table.
359
369
  //
360
370
  // The wipe used to have to know which tables to keep, because
@@ -1,3 +1,5 @@
1
+ import { reportEvaluated } from '../report.js'
2
+ import { countEntities } from '../catalog.js'
1
3
  import path from 'node:path'
2
4
  import { mkdir, writeFile, unlink, rm, readFile, symlink, } from 'fs/promises'
3
5
  import { existsSync } from 'node:fs'
@@ -482,6 +484,24 @@ export function assets(options = {}) {
482
484
 
483
485
  reportUnmatchedPresets(logger)
484
486
 
487
+ // How much of the catalog this run actually looked at.
488
+ //
489
+ // The warning above only fires on a full cycle, because on an
490
+ // incremental one a healthy preset legitimately matches nothing. That
491
+ // is correct and it leaves the reverse question unanswered: a NEW
492
+ // pattern that never had the chance to match anything looks exactly
493
+ // like a run with nothing to do. `evaluated 4 of 397` answers it
494
+ // without needing a warning to decide whether to fire.
495
+ try {
496
+ // COUNT(*), not a fetch: the denominator is a number, and paying a
497
+ // full scan and a JSON.parse per row to produce it would make the
498
+ // diagnostic cost more than the thing it diagnoses.
499
+ reportEvaluated('assets', {
500
+ evaluated: matchTally.evaluated,
501
+ of: countEntities({ collection: { $ne: collection } }),
502
+ })
503
+ } catch { /* a count is not worth failing a build over */ }
504
+
485
505
  let revisions = await globby('**/*.md5', { cwd: runtime.options.assetsFolder })
486
506
  for (let revision of revisions) {
487
507
  const [preset] = revision.split(path.sep)
@@ -2,6 +2,38 @@ import path from 'path'
2
2
  import { hash } from 'hasha'
3
3
  import _ from 'lodash'
4
4
 
5
+ // Where the record came from, when that is knowable.
6
+ //
7
+ // `uri` is optional: an observer reading from an SDK, a local queue or
8
+ // anything without an addressable endpoint has nothing meaningful to put here.
9
+ // The empty string is the established shape for a synthetic entity whose meta
10
+ // IS its content — the same one csv row entities use — and the source sweep
11
+ // scopes on it, so inventing a path like `/7` would be worse than saying
12
+ // nothing.
13
+ function entityUri(base, id) {
14
+ return base ? `${base}/${id}` : ''
15
+ }
16
+
17
+ // The origin to register a webhook sync under, or null if there is none.
18
+ //
19
+ // This used to be `new URL(options[name].uri).origin` inline, which made `uri`
20
+ // silently mandatory: leaving it out threw ERR_INVALID_URL out of onLoaded and
21
+ // took the whole build down at startup, with nothing in the message naming the
22
+ // observer or the option responsible. An absent uri is a legitimate config; a
23
+ // malformed one is a mistake, and only the second deserves to stop anything.
24
+ function originOf(uri, observerName, logger) {
25
+ if (!uri) return null
26
+ try {
27
+ return new URL(uri).origin
28
+ } catch {
29
+ logger?.warn?.({ code: 'observer-bad-uri' },
30
+ 'Observer [%s] has uri: %j, which is not an absolute URL — so no webhook can be routed to it by '
31
+ + 'origin. Give it a full URL like https://api.example.com/things, or drop the option if this '
32
+ + 'observer is not reachable over HTTP.', observerName, uri)
33
+ return null
34
+ }
35
+ }
36
+
5
37
  export function observer(options = {}) {
6
38
  return ({
7
39
  runtime,
@@ -48,7 +80,7 @@ export function observer(options = {}) {
48
80
  recent.add(id)
49
81
  const entity = normalize({
50
82
  id,
51
- uri: `${uri}/${meta.id}`,
83
+ uri: entityUri(uri, meta.id),
52
84
  name,
53
85
  collection,
54
86
  type,
@@ -110,7 +142,7 @@ export function observer(options = {}) {
110
142
  const name = path.join(collection, meta.name || meta.id.toString())
111
143
  const entity = normalize({
112
144
  id,
113
- uri: `${uri}/${meta.id}`,
145
+ uri: entityUri(uri, meta.id),
114
146
  name,
115
147
  collection,
116
148
  type,
@@ -130,7 +162,12 @@ export function observer(options = {}) {
130
162
  } else {
131
163
  if (current) {
132
164
  logger.debug('Observer delete: %s', id)
133
- await deleteEntity(entity)
165
+ // `current`, not `entity` — the latter is built inside the
166
+ // branch above and is not in scope here, so this threw
167
+ // ReferenceError into the surrounding catch and became one
168
+ // log line. A record deleted upstream stayed in the
169
+ // catalog and went on rendering.
170
+ await deleteEntity(current)
134
171
  }
135
172
  }
136
173
  } catch (err) {
@@ -155,13 +192,15 @@ export function observer(options = {}) {
155
192
  }
156
193
  })
157
194
 
158
- const { origin } = new URL(options[observerName].uri)
159
- onSync(origin, async ({ context }) => {
160
- if (context.uri) {
161
- logger.debug('Syncing observer: [%s] %s', observerName, context.uri)
162
- return syncEntities(observerName)
163
- }
164
- })
195
+ const origin = originOf(options[observerName].uri, observerName, logger)
196
+ if (origin) {
197
+ onSync(origin, async ({ context }) => {
198
+ if (context.uri) {
199
+ logger.debug('Syncing observer: [%s] %s', observerName, context.uri)
200
+ return syncEntities(observerName)
201
+ }
202
+ })
203
+ }
165
204
  }
166
205
  })
167
206
 
@@ -1,17 +1,125 @@
1
1
  import { readFileSync } from 'node:fs'
2
- import { globby } from 'globby'
2
+ import path from 'node:path'
3
+ import { globbySync } from 'globby'
4
+ import picomatch from 'picomatch'
3
5
 
4
- export function load({ runtime }) {
5
- runtime.readFile = (file) => {
6
- const relativePath = file.name || file
7
- return readFileSync(relativePath, { encoding: 'utf8' })
6
+ // Filesystem helpers a template can call, and the dependency each one creates.
7
+ //
8
+ // A template that reads a file depends on that file. Until these recorded it,
9
+ // the render's refClosure named none of what it read, so editing the file
10
+ // rebuilt nothing and the output went quietly stale — the same hole lookupHref
11
+ // had before it started recording, and the same failure: a green build and a
12
+ // site that is wrong.
13
+ //
14
+ // So every read records an edge by default. The asymmetry decides it: not
15
+ // recording fails silently and is found weeks later by a person; over-recording
16
+ // costs one rebuild nobody notices. Pass `{ track: false }` for a read that
17
+ // genuinely is not a dependency.
18
+ //
19
+ // WHAT is recorded differs per helper, and the difference matters:
20
+ //
21
+ // readFile / jsonFile record the resolved PATH. One file, one edge.
22
+ //
23
+ // glob records the PATTERN, not the paths it matched. Recording the matches
24
+ // would rebuild when a matched file changes but NOT when a new file appears,
25
+ // and appearing is half of what a glob is for. A pattern-derived edge covers
26
+ // both, because it is re-evaluated against whatever exists at the time.
27
+ //
28
+ // Both are `query` edges on `uri`, which is an indexed column — the same
29
+ // mechanism a sidecar's findEntities() already uses, rather than a second one.
30
+
31
+ // Relative to the WORKING FOLDER, not to wherever the process happens to have
32
+ // been started. `readFile('styles/base.css')` used to resolve against cwd,
33
+ // which quietly worked in development and broke under any launcher that starts
34
+ // mikser elsewhere.
35
+ //
36
+ // The working folder comes from the `options` the engine hands every render
37
+ // plugin. Not from `runtime.options` — the `runtime` a render sees is a small
38
+ // projection built per render, and it has no options on it.
39
+ function resolvePath(workingFolder, file) {
40
+ const name = file?.name ?? file
41
+ if (typeof name !== 'string' || !name.length) return null
42
+ if (path.isAbsolute(name)) return name
43
+ return path.join(workingFolder ?? '.', name)
44
+ }
45
+
46
+ // The entity id a path would have, or null if it could not have one.
47
+ //
48
+ // Keyed on `id`, NOT on `uri`. It is tempting to match the filesystem path
49
+ // against `uri` and it is wrong: `uri` means the source file for a document or
50
+ // a layout, but for a `files` entity it is where the file was DEPLOYED to —
51
+ // under the output folder. An edge on uri therefore matches nothing for
52
+ // exactly the case these helpers are most used for, reading parts out of
53
+ // `files/`. Ids are source-relative everywhere, so they are the one key that
54
+ // answers the same question for every collection.
55
+ function entityIdFor(workingFolder, resolved) {
56
+ if (!workingFolder || !resolved) return null
57
+ const rel = path.relative(workingFolder, resolved)
58
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null
59
+ return '/' + rel.split(path.sep).join('/')
60
+ }
61
+
62
+ // Whether an entity could exist for this path at all.
63
+ //
64
+ // mikser can only invalidate on entities: a file under no source folder is not
65
+ // watched, has no entity, and no edge can bring it back. That limit is fine —
66
+ // what is not fine is it being invisible, because "tracked it" and "there was
67
+ // nothing to track" read identically from a template. Said once per path.
68
+ const warnedOutside = new Set()
69
+ function warnIfUntrackable(options, resolved, logger) {
70
+ const folders = ['documentsFolder', 'filesFolder', 'assetsFolder', 'resourcesFolder', 'dataFolder']
71
+ .map(key => options?.[key]).filter(Boolean)
72
+ if (!folders.length) return // nothing configured to compare against
73
+ if (folders.some(folder => !path.relative(folder, resolved).startsWith('..'))) return
74
+ if (warnedOutside.has(resolved)) return
75
+ warnedOutside.add(resolved)
76
+ logger?.warn?.({ code: 'untracked-file-read' },
77
+ 'A template read %s, which is outside every content folder — so it has no entity, nothing watches it, '
78
+ + 'and changing it will NOT rebuild the pages that read it. Move it under a content folder if that '
79
+ + 'matters, or pass { track: false } to say the staleness is intended.', resolved)
80
+ }
81
+
82
+ export function load({ runtime, options, track, logger }) {
83
+ const workingFolder = options?.workingFolder
84
+ const record = (resolved, opts) => {
85
+ if (opts?.track === false || !resolved) return
86
+ warnIfUntrackable(options, resolved, logger)
87
+ const id = entityIdFor(workingFolder, resolved)
88
+ if (id) track?.query?.({ id })
89
+ }
90
+
91
+ runtime.readFile = (file, opts) => {
92
+ const resolved = resolvePath(workingFolder, file)
93
+ record(resolved, opts)
94
+ return readFileSync(resolved, { encoding: 'utf8' })
8
95
  }
9
- runtime.jsonFile = (file) => {
10
- const relativePath = file.name || file
11
- return JSON.parse(readFileSync(relativePath, { encoding: 'utf8' }))
96
+ runtime.jsonFile = (file, opts) => {
97
+ const resolved = resolvePath(workingFolder, file)
98
+ record(resolved, opts)
99
+ return JSON.parse(readFileSync(resolved, { encoding: 'utf8' }))
12
100
  }
13
- runtime.glob = (pattern, options = {}) => {
14
- return globby.sync(pattern, options)
101
+
102
+ // `globby.sync` does not exist — the export is `globbySync`, so every call
103
+ // here threw TypeError. Loudly, at least, which is why nobody had stale
104
+ // output from it: the helper simply never worked.
105
+ runtime.glob = (pattern, opts = {}) => {
106
+ const patterns = (Array.isArray(pattern) ? pattern : [pattern]).filter(Boolean)
107
+ const base = opts.cwd ?? workingFolder ?? '.'
108
+ const resolved = patterns.map(p => (path.isAbsolute(p) ? p : path.join(base, p)))
109
+
110
+ if (opts.track !== false && track?.query) {
111
+ for (const p of resolved) {
112
+ // The PATTERN as a regex over entity ids, so a file that did
113
+ // not exist when this render ran still matches once it appears
114
+ // — which recording the matched paths could never do.
115
+ const idPattern = entityIdFor(workingFolder, p)
116
+ if (!idPattern) continue
117
+ try {
118
+ track.query({ id: { $regex: picomatch.makeRe(idPattern).source } })
119
+ } catch { /* an unparseable pattern records nothing; the glob still runs */ }
120
+ }
121
+ }
122
+ return globbySync(resolved, { ...opts, cwd: undefined })
15
123
  }
16
124
 
17
125
  // Stringify an arbitrary value as a JSON literal. Use with the
package/src/render.js CHANGED
@@ -308,7 +308,17 @@ export default async ({ entity, options, config, context, state, logger, port, t
308
308
  for (let pluginName of pluginsToLoad) {
309
309
  const plugin = await loadPlugin(pluginName)
310
310
  plugins[pluginName] = plugin
311
- if (plugin?.load) await plugin.load({ entity, options, config: plugin.options, context, runtime, state, logger })
311
+ // `track` goes to load(), not only to render().
312
+ //
313
+ // A helper plugin publishes functions a template calls DURING the
314
+ // render — readFile, glob — and those reads are dependencies exactly
315
+ // as much as a partial or a lookup is. Without this they could not
316
+ // record even if they wanted to, so a template that read ten files
317
+ // produced a refClosure naming none of them: the build stays green
318
+ // and the output goes stale, which is the same hole lookupHref had.
319
+ if (plugin?.load) {
320
+ await plugin.load({ entity, options, config: plugin.options, context, runtime, state, logger, track })
321
+ }
312
322
  }
313
323
 
314
324
  const rendererPlugin = plugins[`render-${renderer}`]
package/src/report.js CHANGED
@@ -109,8 +109,12 @@ export function resetReport() {
109
109
  const previous = runtime.state.cycle
110
110
  if (previous && !previous.finishedAt) finishCycle()
111
111
  runtime.state.cycle = { id: nextCycleId(), startedAt: Date.now(), finishedAt: null }
112
- runtime.state.report = { rendered: [], skipped: [], unchanged: [], errors: [], warnings: [], gated: 0 }
112
+ runtime.state.report = {
113
+ rendered: [], skipped: [], unchanged: [], errors: [], warnings: [], gated: 0, evaluated: {},
114
+ }
113
115
  runtime.state.renderErrors = []
116
+ // Per cycle, unlike the wipe: what changed is a fact about THIS build.
117
+ runtime.state.changed = { ids: [], count: 0 }
114
118
  }
115
119
 
116
120
  // End of a cycle: stamp it, file it, and wake anyone waiting on it.
@@ -136,10 +140,70 @@ function store() {
136
140
  // Without this the build everyone looks at first reports cycleId: null.
137
141
  runtime.state ??= {}
138
142
  runtime.state.cycle ??= { id: 1, startedAt: Date.now(), finishedAt: null }
139
- runtime.state.report ??= { rendered: [], skipped: [], unchanged: [], errors: [], warnings: [], gated: 0 }
143
+ runtime.state.report ??= {
144
+ rendered: [], skipped: [], unchanged: [], errors: [], warnings: [], gated: 0, evaluated: {},
145
+ }
146
+ runtime.state.report.evaluated ??= {}
140
147
  return runtime.state.report
141
148
  }
142
149
 
150
+ // Why this cycle did any work at all.
151
+ //
152
+ // The counts say what happened; they never said what STARTED it. "0 rendered"
153
+ // is the same line whether nothing needed doing, or something needed doing and
154
+ // the engine could not tell — which is the difference between a build you can
155
+ // trust and one you have to reproduce by hand.
156
+ //
157
+ // Two halves, because there are two ways work begins. A WIPE is process-level:
158
+ // the version moved, the config moved, `--clear` was passed, and everything is
159
+ // rebuilt from source. Otherwise it is the sources that changed since last
160
+ // time, which is per cycle.
161
+ //
162
+ // `nothing` is a first-class answer, not an absence. It means the engine
163
+ // looked and there was genuinely no work — which is the one thing an operator
164
+ // most wants distinguished from a build that silently did not notice.
165
+
166
+ // Set once, by whoever decided to wipe. Not gated on reportWanted: it happens
167
+ // at database open, which may be before a reader has asked for a report, and
168
+ // it is one small object.
169
+ export function reportWipe(cause, detail = {}) {
170
+ runtime.state ??= {}
171
+ runtime.state.wipe = { cause, ...detail }
172
+ }
173
+
174
+ // One source whose bytes moved. The complement of reportGated: between them
175
+ // every file the engine looked at is accounted for.
176
+ export function reportChanged(id) {
177
+ if (!reportWanted() || !id) return
178
+ const store = changedStore()
179
+ store.count++
180
+ // Capped. On a cold build this is the whole corpus, and the cause already
181
+ // says so — the list is for the incremental case, where naming the three
182
+ // files that moved is the entire answer.
183
+ if (store.ids.length < CHANGED_LIMIT) store.ids.push(id)
184
+ }
185
+
186
+ const CHANGED_LIMIT = 50
187
+
188
+ function changedStore() {
189
+ runtime.state ??= {}
190
+ runtime.state.changed ??= { ids: [], count: 0 }
191
+ return runtime.state.changed
192
+ }
193
+
194
+ // What a subsystem looked at, against what it could have looked at.
195
+ //
196
+ // Generalised from the assets plugin, which already warns when a configured
197
+ // preset matched none of the entities a full cycle evaluated. That reasoning —
198
+ // an incremental cycle only re-evaluates what changed, so matching nothing can
199
+ // be perfectly healthy — is not specific to presets, and neither is the
200
+ // question it answers. "assets evaluated 4 of 397" is the line that tells you
201
+ // instantly that a new pattern never had the chance to match anything.
202
+ export function reportEvaluated(scope, { evaluated, of } = {}) {
203
+ if (!reportWanted() || !scope) return
204
+ store().evaluated[scope] = { evaluated: evaluated ?? 0, ...(Number.isFinite(of) ? { of } : {}) }
205
+ }
206
+
143
207
  // An entity whose SOURCE did not change is gated at import and never becomes
144
208
  // a render task at all — so it appears in neither `rendered` nor `skipped`,
145
209
  // and the two lists would not reconcile with the corpus size without saying
@@ -309,6 +373,25 @@ export function renderErrorCount() {
309
373
  return errorStore().length
310
374
  }
311
375
 
376
+ // The cause, and enough detail to act on it.
377
+ //
378
+ // A wipe outranks changed sources: when the cache went, everything is a
379
+ // changed source and saying so is noise. `nothing` is returned rather than
380
+ // omitted, because an absent field reads as "not recorded" and this is a
381
+ // finding.
382
+ function invalidation() {
383
+ const wipe = runtime.state?.wipe
384
+ if (wipe) return wipe
385
+ const { ids, count } = changedStore()
386
+ if (!count) return { cause: 'nothing' }
387
+ return {
388
+ cause: 'sources',
389
+ changed: ids,
390
+ count,
391
+ ...(count > ids.length ? { truncated: count - ids.length } : {}),
392
+ }
393
+ }
394
+
312
395
  export function buildReport() {
313
396
  const report = store()
314
397
  const cycle = runtime.state?.cycle
@@ -325,6 +408,18 @@ export function buildReport() {
325
408
  // failed build, whatever the other counts say.
326
409
  errors: errorStore(),
327
410
  warnings: report.warnings,
411
+ // Why this build did any work — see reportWipe / reportChanged.
412
+ invalidated: invalidation(),
413
+ // What each subsystem looked at, against what it could have.
414
+ ...(Object.keys(report.evaluated ?? {}).length ? { evaluated: report.evaluated } : {}),
415
+ // Which files the config stamp spans.
416
+ //
417
+ // "I edited the build and nothing rebuilt" was only answerable by
418
+ // experiment: the stamp covered the entry file, real projects put the
419
+ // build in a module it imports, and nothing said which. Published
420
+ // rather than documented, because a limit you can see at the moment it
421
+ // bites is a different thing from one written down elsewhere.
422
+ ...(runtime.options?.configCoverage ? { config: runtime.options.configCoverage } : {}),
328
423
  // Named conditions reported at error level: a subsystem saying it
329
424
  // cannot work, as opposed to `errors`, which is a render that threw.
330
425
  // Carried whole rather than filtered to this cycle — a fault raised at
@@ -345,6 +440,8 @@ export function buildReport() {
345
440
  gated: report.gated,
346
441
  warnings: report.warnings.length,
347
442
  faults: faults().length,
443
+ // Sources whose bytes moved this cycle. The complement of `gated`.
444
+ changed: changedStore().count,
348
445
  },
349
446
  }
350
447
  }
package/src/source.js CHANGED
@@ -44,7 +44,7 @@ import pMap from 'p-map'
44
44
  import runtime from './runtime.js'
45
45
  import { ACTION } from './constants.js'
46
46
  import { checksum as fileChecksum, checksumOf, junkIgnore } from './utils.js'
47
- import { reportGated } from './report.js'
47
+ import { reportGated, reportChanged } from './report.js'
48
48
  import { findById, findEntities, checksumsByCollection } from './catalog.js'
49
49
  import { useDatabase } from './database/index.js'
50
50
 
@@ -456,6 +456,10 @@ export function useSource(core, options) {
456
456
  reportGated()
457
457
  return
458
458
  }
459
+ // Past the gate means the bytes are new or different — the complement
460
+ // of reportGated, so between them every file looked at is accounted
461
+ // for and "why did this build do anything" has an answer.
462
+ reportChanged(id)
459
463
 
460
464
  const base = {
461
465
  id,