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 +20 -2
- package/README.md +75 -46
- package/docs/api-reference.md +11 -0
- package/docs/diagnostics.md +30 -0
- package/package.json +1 -1
- package/src/catalog.js +27 -0
- package/src/config.js +114 -16
- package/src/database/index.js +10 -0
- package/src/plugins/assets.js +20 -0
- package/src/plugins/observer.js +49 -10
- package/src/plugins/render/file.js +118 -10
- package/src/render.js +11 -1
- package/src/report.js +99 -2
- package/src/source.js +5 -1
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. `
|
|
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`.
|
|
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
|
|
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
|
-
|
|
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
|
-
**
|
|
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
|
|
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,
|
|
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
|
-
**
|
|
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
|
-
**
|
|
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
|
-
**
|
|
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
|
-
**
|
|
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
|
|
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
|
-
**
|
|
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
|
|
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.**
|
|
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
|
-
**
|
|
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
|
|
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.**
|
|
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
|
-
|
|
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
|
-
**
|
|
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
|
|
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
|
|
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
|
|
111
|
-
- write new content
|
|
112
|
-
- render
|
|
113
|
-
- **
|
|
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
|
-
|
|
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
|
-
###
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
241
|
-
- **
|
|
242
|
-
- **AI-
|
|
243
|
-
- **
|
|
244
|
-
- **
|
|
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
|
-
**
|
|
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** |
|
|
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
|
-
|
|
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
|
|
package/docs/api-reference.md
CHANGED
|
@@ -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
|
|
package/docs/diagnostics.md
CHANGED
|
@@ -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.
|
|
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
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
package/src/database/index.js
CHANGED
|
@@ -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
|
package/src/plugins/assets.js
CHANGED
|
@@ -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)
|
package/src/plugins/observer.js
CHANGED
|
@@ -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:
|
|
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:
|
|
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
|
-
|
|
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
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { globbySync } from 'globby'
|
|
4
|
+
import picomatch from 'picomatch'
|
|
3
5
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
|
11
|
-
|
|
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
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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 = {
|
|
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 ??= {
|
|
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,
|