mikser-io 9.58.0 → 9.59.2
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 +8 -0
- package/README.md +75 -46
- package/docs/api-reference.md +11 -0
- package/package.json +1 -1
- package/src/plugins/observer.js +49 -10
- package/src/plugins/render/file.js +118 -10
- package/src/render.js +11 -1
package/CLAUDE.md
CHANGED
|
@@ -182,6 +182,14 @@ brevity.
|
|
|
182
182
|
`isRefKey`, `writeEntity`, `matchEntity`, `getFormatInfo`,
|
|
183
183
|
`changeExtension`, `checksum`, `normalize`, `formatErrorContext`,
|
|
184
184
|
`formatLogArgs`, `ExpandError`, `AbortError`.
|
|
185
|
+
- `src/plugins/render/file.js` — the template filesystem helpers
|
|
186
|
+
(`readFile`, `jsonFile`, `glob`). Every read RECORDS a query edge by
|
|
187
|
+
default; `{ track: false }` opts out. Keyed on **`id`, never `uri`** —
|
|
188
|
+
for a `files` entity `uri` is the DEPLOYED path, so a uri edge matches
|
|
189
|
+
nothing for the commonest case. `glob` records the PATTERN as a regex
|
|
190
|
+
over ids, not the matched paths, so a file appearing later still
|
|
191
|
+
invalidates. Paths resolve against `options.workingFolder` — the
|
|
192
|
+
render-time `runtime` is a per-render projection with no options on it.
|
|
185
193
|
- `render.js` / `postprocess.js` — Piscina worker entry points AND the
|
|
186
194
|
default-export functions the INLINE/SERIAL dispatcher calls directly.
|
|
187
195
|
Each receives entity + options + config + state; the WORKER path also
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mikser-io",
|
|
3
|
-
"version": "9.
|
|
3
|
+
"version": "9.59.2",
|
|
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/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}`]
|