mikser-io 7.0.0 → 7.6.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/README.md CHANGED
@@ -116,6 +116,7 @@ The engine is what stays stable — the lifecycle, the catalog, the file-based c
116
116
  |---|---|
117
117
  | `data` | JSON snapshots of entities / context / catalog, written to disk for static serving |
118
118
  | `api` | REST endpoints with sift-backed queries, per-endpoint tokens, optional render, opt-in [per-query disk cache](./documentation/caching.md) for reverse-proxy failover |
119
+ | `preview` | In-memory render cache + `GET /preview/:filename` route. Companion to the [`mikser_preview`](./documentation/mcp.md) MCP tool — transient render bytes served at a clickable URL, no filesystem footprint |
119
120
 
120
121
  **Integrations:**
121
122
 
package/ngrok.ymlt ADDED
@@ -0,0 +1,3 @@
1
+ version: 3
2
+ agent:
3
+ authtoken: 3EcICPaDtSN54h63Y5v8g2zKOJ9_5oMnCdXyPmMmKVXH9ba1c
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "7.0.0",
3
+ "version": "7.6.0",
4
4
  "description": "<p align=\"center\"> <img src=\"mikser-lockup-stacked.svg\" alt=\"mikser\" width=\"198\" /> </p>",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -20,14 +20,14 @@
20
20
  "author": "",
21
21
  "license": "ISC",
22
22
  "dependencies": {
23
- "@budibase/handlebars-helpers": "^0.14.2",
23
+ "@budibase/handlebars-helpers": "^0.14.3",
24
24
  "@modelcontextprotocol/sdk": "^1.29.0",
25
25
  "await-semaphore": "^0.1.3",
26
- "axios": "^1.16.0",
26
+ "axios": "^1.17.0",
27
27
  "chokidar": "^5.0.0",
28
28
  "cli-progress": "^3.12.0",
29
- "commander": "^14.0.3",
30
- "dayjs": "^1.11.20",
29
+ "commander": "^15.0.0",
30
+ "dayjs": "^1.11.21",
31
31
  "deepdash": "^5.3.9",
32
32
  "escape-string-regexp": "^5.0.0",
33
33
  "execa": "^9.6.1",
@@ -43,18 +43,18 @@
43
43
  "minimatch": "^10.2.5",
44
44
  "node-cron": "^4.2.1",
45
45
  "p-map": "^7.0.4",
46
- "p-queue": "^9.2.0",
46
+ "p-queue": "^9.3.0",
47
47
  "pino": "^10.3.1",
48
48
  "pino-pretty": "^13.1.3",
49
49
  "piscina": "^5.1.4",
50
50
  "sift": "^17.1.3",
51
51
  "sqlite3": "^6.0.1",
52
52
  "truncate-stream": "^1.0.2",
53
- "yaml": "^2.8.4"
53
+ "yaml": "^2.9.0"
54
54
  },
55
55
  "optionalDependencies": {
56
56
  "express": "^5.2.1",
57
- "puppeteer": "^24.43.0"
57
+ "puppeteer": "^25.1.0"
58
58
  },
59
59
  "devDependencies": {
60
60
  "fluent-ffmpeg": "^2.1.3",
package/src/api.js CHANGED
@@ -37,10 +37,20 @@ export function useRenderer(runtime, { defaultTimeout = 30_000 } = {}) {
37
37
  const remaining = new Map(batch.map(b => [b.correlationId, b]))
38
38
  const completedHooks = runtime.hooks.completed
39
39
  const hook = async (entry) => {
40
- const cid = entry.entity?._correlationId
40
+ const cid = entry.entity?.options?.correlationId
41
41
  if (!cid) return
42
42
  const item = remaining.get(cid)
43
43
  if (!item) return
44
+ // Only resolve on the FINAL completion. For postprocessor-
45
+ // equipped entities the engine fires runtime.complete twice:
46
+ // once after render (intermediate bytes, entity.origin not
47
+ // set) and once after postprocess (final bytes, entity.origin
48
+ // set to the intermediate destination). useRenderer's
49
+ // contract is "return the pipeline's final output", so we
50
+ // skip the intermediate fire and wait for the final.
51
+ const hasPostprocessor = entry.entity?.layout?.postprocessor
52
+ const isFinal = !hasPostprocessor || entry.entity?.origin != null
53
+ if (!isFinal) return
44
54
  remaining.delete(cid)
45
55
  clearTimeout(item.timer)
46
56
  item.resolve({ output: entry.output, entity: entry.entity })
@@ -99,13 +109,24 @@ export function useRenderer(runtime, { defaultTimeout = 30_000 } = {}) {
99
109
  * skip the final disk write; the bytes still come back via
100
110
  * `output.result` for you to pipe wherever you want (HTTP
101
111
  * response, S3, …). For layouts with a postprocessor (e.g.
102
- * `*.html-pdf.*`), the intermediate file is still written so the
103
- * postprocessor can consume it; only the FINAL output is skipped.
112
+ * `*.html-pdf.*`), the intermediate is written to a scratch path
113
+ * under `runtime.options.previewFolder` (engine-owned, never
114
+ * in outputFolder) so the postprocessor can consume it; only
115
+ * the FINAL output is skipped from disk. `output.result` is
116
+ * always the FINAL pipeline output — PDF bytes for a
117
+ * `*.html-pdf.*` layout, MJML-derived HTML for
118
+ * `*.html-mjml.*`, etc., not the intermediate.
104
119
  *
105
120
  * The rendered output's bytes are always returned in `output.result`
106
121
  * regardless of either flag — `save` only affects whether they also
107
122
  * end up on disk.
108
123
  *
124
+ * Per-entity engine state (correlation id, control flags) lives at
125
+ * `entity.options.*` — same noun mikser uses for engine config
126
+ * (`runtime.options`) and plugin params, scoped to one entity's
127
+ * pass through the lifecycle. Consumers should not set
128
+ * `entity.options.correlationId` themselves; useRenderer owns it.
129
+ *
109
130
  * @param {object} entity - any entity-shaped object
110
131
  * @param {object} [opts]
111
132
  * @param {number} [opts.timeout] - override the default timeout
@@ -116,12 +137,25 @@ export function useRenderer(runtime, { defaultTimeout = 30_000 } = {}) {
116
137
  async function render(entity, { timeout = defaultTimeout, catalog = true, save = true } = {}) {
117
138
  const result = await new Promise((resolve, reject) => {
118
139
  const correlationId = randomUUID()
119
- const stamped = { ...entity, _correlationId: correlationId }
120
- // Only stamp _save when explicitly opting out — keeps the
121
- // entity object clean for the common case.
122
- if (save === false) stamped._save = false
140
+ // Engine-set fields live under entity.options. The caller's
141
+ // render(entity, { save: false }) becomes
142
+ // entity.options.save = false here same noun mikser uses
143
+ // for engine config (runtime.options) and plugin params,
144
+ // just scoped to one entity's pass through the lifecycle.
145
+ //
146
+ // Only set `save` when explicitly opting out — leaves the
147
+ // entity.options as a clean { correlationId } in the common
148
+ // case rather than carrying a redundant save:true.
149
+ const prepared = {
150
+ ...entity,
151
+ options: {
152
+ ...entity.options,
153
+ correlationId,
154
+ ...(save === false ? { save: false } : {}),
155
+ },
156
+ }
123
157
  pending.push({
124
- entity: stamped,
158
+ entity: prepared,
125
159
  correlationId,
126
160
  timeout,
127
161
  resolve,
package/src/engine.js CHANGED
@@ -75,6 +75,12 @@ export async function setup(options) {
75
75
 
76
76
  runtime.options.runtimeFolder = path.join(runtime.options.workingFolder, runtime.options.runtimeFolder || 'runtime')
77
77
  runtime.options.outputFolder = path.join(runtime.options.workingFolder, runtime.options.outputFolder || 'out')
78
+ // Scratch path for intermediate render artifacts when a caller
79
+ // opted out of disk writes via render({ save: false }) but the
80
+ // layout has a postprocessor that still needs to read the
81
+ // intermediate. Lives under runtimeFolder so it's engine-owned
82
+ // and never appears in outputFolder.
83
+ runtime.options.previewFolder = path.join(runtime.options.runtimeFolder, 'preview')
78
84
 
79
85
  if (runtime.options.clear) {
80
86
  try {
@@ -415,7 +421,22 @@ export async function setup(options) {
415
421
  origin: entity.destination,
416
422
  destination
417
423
  },
418
- options: { postprocessor: options.postprocessor, tasks: options.tasks },
424
+ options: {
425
+ postprocessor: options.postprocessor,
426
+ tasks: options.tasks,
427
+ // When the originating render call passed
428
+ // `save: false`, the layouts plugin wrote the
429
+ // intermediate into runtime.options.previewFolder
430
+ // rather than outputFolder. Postprocess plugins
431
+ // resolve `entity.origin` against
432
+ // `options.outputFolder`, so swap it here so
433
+ // they look in the right place. No change for
434
+ // normal builds (entity.options.save unset →
435
+ // outputFolder).
436
+ ...(entity.options?.save === false
437
+ ? { outputFolder: runtime.options.previewFolder }
438
+ : {}),
439
+ },
419
440
  context
420
441
  })
421
442
  }
package/src/mcp.js CHANGED
@@ -22,7 +22,6 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
22
22
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
23
23
  import packageInfo from '../package.json' with { type: 'json' }
24
24
  import runtime from './runtime.js'
25
- import { onLoaded } from './lifecycle.js'
26
25
 
27
26
  let pinoLevelToMcp = (pinoLevel) => {
28
27
  if (pinoLevel >= 50) return 'error'
@@ -63,8 +62,6 @@ export function createMcpSubstrate() {
63
62
  }
64
63
 
65
64
  const substrate = {
66
- // ---- plugin-facing surface (mirrors McpServer) -----------
67
-
68
65
  // The SDK's register* methods take different argument counts
69
66
  // (3 for tools, 4 for resources, 3 for prompts). We spread the
70
67
  // recorded args verbatim — substrate doesn't peek at the
@@ -97,8 +94,6 @@ export function createMcpSubstrate() {
97
94
  return substrate.registerTool(name, { description, inputSchema }, handler)
98
95
  },
99
96
 
100
- // ---- engine-facing surface -------------------------------
101
-
102
97
  // Create a fresh McpServer pre-loaded with every recorded
103
98
  // registration. Called by the transport mount per new session.
104
99
  _createServer() {
@@ -113,8 +108,6 @@ export function createMcpSubstrate() {
113
108
  _detach(server) { activeServers.delete(server) },
114
109
  _activeServerCount() { return activeServers.size },
115
110
 
116
- // ---- notification broadcast ------------------------------
117
-
118
111
  // Send a logging-message notification to every connected
119
112
  // client. The SDK's per-session level filtering applies.
120
113
  broadcastLog(params) {
@@ -129,8 +122,6 @@ export function createMcpSubstrate() {
129
122
  }
130
123
  },
131
124
 
132
- // ---- rolling log buffer ----------------------------------
133
-
134
125
  // Called by wireLoggerToMcp on every log call. Records a
135
126
  // monotonic seq number so clients can poll "give me lines
136
127
  // since seq=N" against mikser://logs/recent.
@@ -238,13 +229,34 @@ export function createMcpSubstrate() {
238
229
  }),
239
230
  )
240
231
 
232
+ // mikser://server — single-shot answer to "where do I put output
233
+ // so the user can see it?" Combines server state (running? on what
234
+ // URL?) and the path conventions agents should write to for
235
+ // preview-style outputs.
236
+ substrate.registerResource(
237
+ 'mikser-server',
238
+ 'mikser://server',
239
+ {
240
+ title: 'HTTP server location and preview conventions',
241
+ description: 'Where the running engine is reachable (URL, MCP path, preview path prefix) and what folder it serves. The single resource an agent needs to answer "where can the user see this output?"',
242
+ mimeType: 'application/json',
243
+ },
244
+ async (uri) => ({
245
+ contents: [{
246
+ uri: uri.href,
247
+ mimeType: 'application/json',
248
+ text: JSON.stringify(serverInfo(), null, 2),
249
+ }],
250
+ }),
251
+ )
252
+
241
253
  // Built-in liveness/identity tool. Also ensures tools/list works
242
254
  // before any plugin has registered (McpServer only advertises
243
255
  // tools/list capability after at least one registration).
244
256
  substrate.registerTool(
245
257
  'mikser_ping',
246
258
  {
247
- description: 'Return mikser engine identity and the current lifecycle phase. Use to confirm the connection is live before issuing other tool calls.',
259
+ description: 'Return mikser engine identity, current lifecycle phase, and (if --server is on) where the HTTP server is reachable. Use to confirm the connection is live before issuing other tool calls and to learn the base URL for preview outputs.',
248
260
  inputSchema: {},
249
261
  },
250
262
  async () => ({
@@ -258,6 +270,7 @@ export function createMcpSubstrate() {
258
270
  workingFolder: runtime.options.workingFolder,
259
271
  outputFolder: runtime.options.outputFolder,
260
272
  activeClients: substrate._activeServerCount(),
273
+ server: serverInfo(),
261
274
  }, null, 2),
262
275
  }],
263
276
  }),
@@ -266,6 +279,41 @@ export function createMcpSubstrate() {
266
279
  return substrate
267
280
  }
268
281
 
282
+ // Derive a stable snapshot of "where outputs are visible to the user."
283
+ // Three cases:
284
+ // 1. --server is on → engine owns Express, knows port → full URL
285
+ // 2. external app → caller supplied runtime.options.app; URL not
286
+ // visible to engine (port unknown), but the
287
+ // outputFolder and path conventions are still
288
+ // useful for preview-writing tools
289
+ // 3. no server → only the static folder layout applies; an
290
+ // agent should not try to advertise a URL
291
+ //
292
+ // Kept as a function rather than a const so each call re-reads
293
+ // runtime.options — covers the case where --server flips on after
294
+ // the substrate was created (rare but possible programmatically).
295
+ function serverInfo() {
296
+ const opts = runtime.options
297
+ const hasInternalServer = opts.server != null && opts.port != null
298
+ const hasExternalApp = opts.app && !hasInternalServer
299
+
300
+ const base = hasInternalServer
301
+ ? `http://localhost:${opts.port}`
302
+ : null
303
+
304
+ return {
305
+ running: hasInternalServer ? 'internal' : (hasExternalApp ? 'external' : 'none'),
306
+ port: opts.port ?? null,
307
+ url: base,
308
+ serves: opts.outputFolder ?? null,
309
+ mcpPath: opts.mcpPath ?? null,
310
+ mcpUrl: base && opts.mcpPath ? `${base}${opts.mcpPath}` : null,
311
+ // Preview URLs are returned directly by mikser_preview (api
312
+ // plugin), so we don't advertise a path convention here —
313
+ // doing so would be a lie when the api plugin isn't loaded.
314
+ }
315
+ }
316
+
269
317
  /**
270
318
  * Mount the MCP substrate on an Express app at the given path
271
319
  * (default `/mcp`). Each connecting client gets its own
@@ -375,11 +423,15 @@ function pinoLevelNumber(name) {
375
423
  return { trace: 10, debug: 20, info: 30, warn: 40, error: 50, fatal: 60 }[name] ?? 30
376
424
  }
377
425
 
378
- // Convenience for plugins that want to gate their tool registrations
379
- // behind "is MCP active?". Equivalent to checking runtime.options.mcp
380
- // directly but reads better at the call site.
381
- export function whenMcpActive(callback) {
382
- onLoaded(async () => {
383
- if (runtime.options.mcp) await callback(runtime.options.mcp)
384
- })
385
- }
426
+ // (whenMcpActive was removed in v7.4.0 plugins now use the same
427
+ // inline pattern as Express route registration: gate on the runtime
428
+ // option inside whichever lifecycle hook makes sense.
429
+ //
430
+ // onLoaded(async () => {
431
+ // if (runtime.options.mcp) {
432
+ // runtime.options.mcp.simpleTool(...)
433
+ // }
434
+ // })
435
+ //
436
+ // Reveals timing, matches the Express pattern, doesn't lock the
437
+ // registration into onLoaded.)
@@ -1,5 +1,5 @@
1
1
  import path from 'node:path'
2
- import { access, writeFile, mkdir, rm } from 'node:fs/promises'
2
+ import { access, writeFile, readFile, mkdir, rm } from 'node:fs/promises'
3
3
  import { createHash } from 'node:crypto'
4
4
  import _ from 'lodash'
5
5
  import sift from 'sift'
@@ -333,6 +333,11 @@ export default ({
333
333
  const globalPageSize = runtime.config.api?.pageSize ?? 10
334
334
  const globalRenderTimeout = runtime.config.api?.renderTimeout ?? 30_000
335
335
 
336
+ // Preview workflow (render → cache → URL) lives in its own
337
+ // plugin (src/plugins/preview.js) as of v7.3.0. The api plugin
338
+ // stays focused on REST catalog access; preview is a separate
339
+ // domain. To use mikser_preview, load `preview` alongside `api`.
340
+
336
341
  // cachedEndpoints is hoisted above (shared with onFinalize).
337
342
  // Per-endpoint setup loop pushes into it when cache: true is set.
338
343
 
@@ -637,9 +642,9 @@ export default ({
637
642
  ep.token ? '[token]' : '[public]')
638
643
  }
639
644
 
640
- // ---- MCP tool registrations -----------------------------------
641
- // MCP is in-process: whoever can reach the /mcp transport already
642
- // controls the engine. So the tool surface mirrors the *admin*-
645
+ // MCP tool registrations. MCP is in-process: whoever can reach
646
+ // the /mcp transport already controls the engine. So the tool
647
+ // surface mirrors the *admin*-
643
648
  // shape (list/query/read/update/delete/render) without the HTTP
644
649
  // endpoint's token gate or query scope — the catalog is global.
645
650
  // Tools register once (not per HTTP endpoint); plugin-author
@@ -695,11 +700,12 @@ export default ({
695
700
 
696
701
  mcp.simpleTool(
697
702
  'mikser_read_entity',
698
- 'Read a single entity by its catalog id (e.g. "/documents/about.md"). Returns the full entity record or null when not found.',
703
+ 'Read a single entity by its catalog id (e.g. "/documents/about.md"). Returns the full entity record or null when not found. Pass include: ["content"] to also fetch the source file content from disk — useful for reading a layout template, document frontmatter+body, or any text-format source without dropping out to the filesystem.',
699
704
  {
700
705
  id: z.string().describe('Catalog id of the entity to read.'),
706
+ include: z.array(z.enum(['content'])).optional().describe('Optional list of extra fields to populate. Currently only "content" is supported: reads the file at entity.uri and attaches it as .content (text formats only — md, html, yml, liquid, hbs, eta, json, css, js, svg, xml, mjml).'),
701
707
  },
702
- async ({ id }) => {
708
+ async ({ id, include }) => {
703
709
  try {
704
710
  if (!id) return fail('id is required')
705
711
  const { items } = await runQuery({
@@ -708,7 +714,37 @@ export default ({
708
714
  scope: null,
709
715
  findEntities,
710
716
  })
711
- return ok(items[0] ?? null)
717
+ const entity = items[0]
718
+ if (!entity) return ok(null)
719
+
720
+ if (include?.includes('content') && entity.uri) {
721
+ // Heuristic — read content only for text-like
722
+ // formats. Binary types (png, pdf, mp4, etc.)
723
+ // get a marker so the caller knows to use a
724
+ // different tool (mikser_render, or fetch
725
+ // directly) rather than expecting bytes back.
726
+ const TEXT_EXTS = new Set([
727
+ 'md', 'markdown', 'html', 'htm', 'xhtml',
728
+ 'yml', 'yaml', 'json', 'jsonc',
729
+ 'txt', 'csv', 'tsv',
730
+ 'css', 'js', 'mjs', 'cjs', 'ts',
731
+ 'liquid', 'hbs', 'handlebars', 'eta', 'mustache',
732
+ 'svg', 'xml', 'mjml', 'rss', 'atom',
733
+ 'aml',
734
+ ])
735
+ const ext = path.extname(entity.uri).slice(1).toLowerCase()
736
+ if (TEXT_EXTS.has(ext)) {
737
+ try {
738
+ entity.content = await readFile(entity.uri, 'utf8')
739
+ } catch (err) {
740
+ entity.contentError = err.message
741
+ }
742
+ } else {
743
+ entity.contentSkipped = `Non-text format (.${ext}). Use mikser_render to materialize output or read the file directly at entity.uri.`
744
+ }
745
+ }
746
+
747
+ return ok(entity)
712
748
  } catch (err) {
713
749
  logger.error('MCP mikser_read_entity error: %s', err.message)
714
750
  return fail(err.message)
@@ -755,7 +791,7 @@ export default ({
755
791
 
756
792
  mcp.simpleTool(
757
793
  'mikser_render',
758
- 'Render a transient entity through the engine pipeline (parse → layouts → resources → render → postprocess) and return the produced bytes. Use this for "preview this layout against this data" without writing the entity to disk. Set options.save=false to skip the disk write; options.catalog=false to prune the catalog row after rendering.',
794
+ 'Render a transient entity through the engine pipeline (parse → layouts → resources → render → postprocess) and return the FINAL produced bytes. Use this for "preview this layout against this data" without writing the entity to disk. The returned bytes are the pipeline\'s final output — PDF for a `*.html-pdf.*` layout, MJML-derived HTML for `*.html-mjml.*`, etc. Set options.save=false to skip the disk write; options.catalog=false to prune the catalog row after rendering.',
759
795
  {
760
796
  entity: z.record(z.any()).describe('Entity shape with at least { id, collection } and any meta/content the renderer needs.'),
761
797
  options: z.record(z.any()).optional().describe('Renderer options: { save: false, catalog: false, renderer: "...", postprocessor: "..." }.'),
@@ -798,7 +834,7 @@ export default ({
798
834
  },
799
835
  )
800
836
 
801
- logger.info('MCP tools registered: list/read/update/delete/render')
837
+ logger.info('MCP tools registered: list/read/update/delete/render (api plugin)')
802
838
  }
803
839
  })
804
840
 
@@ -1,8 +1,82 @@
1
1
  import path from 'node:path'
2
- import { mkdir, writeFile, unlink, rmdir } from 'node:fs/promises'
2
+ import { mkdir, writeFile, unlink, rmdir, readFile } from 'node:fs/promises'
3
3
  import { existsSync } from 'node:fs'
4
4
  import { globby } from 'globby'
5
5
  import _ from 'lodash'
6
+ import { z } from 'zod'
7
+
8
+ // Liquid / Handlebars / Eta keywords we don't want surfaced as
9
+ // "variables this layout references." Anything that looks like a path
10
+ // (`document.meta.X`) survives; bare keywords filter out.
11
+ const TEMPLATE_KEYWORDS = new Set([
12
+ 'if', 'else', 'elsif', 'endif', 'unless', 'endunless',
13
+ 'for', 'endfor', 'each', 'break', 'continue', 'in', 'of',
14
+ 'case', 'when', 'endcase', 'switch',
15
+ 'capture', 'endcapture', 'assign', 'include', 'layout', 'block',
16
+ 'endblock', 'comment', 'endcomment', 'raw', 'endraw',
17
+ 'true', 'false', 'nil', 'null', 'and', 'or', 'not', 'with',
18
+ ])
19
+
20
+ // Naive multi-engine template scan. Hits liquid (`{{ X }}`, `{% ... %}`),
21
+ // handlebars (`{{ X }}`, `{{#each X}}`), and eta (`<%= X %>`,
22
+ // `<% for X of Y %>`). Returns up to three buckets:
23
+ // variables — bare identifier paths used in output position
24
+ // includes — referenced sub-templates (liquid `include` / `layout`)
25
+ // iterations — `for X in Y` / `each X` shapes so the caller knows
26
+ // where array fields are expected
27
+ //
28
+ // "Naive" is the right word: regex pass, not an AST walk. False positives
29
+ // possible; per-engine plugins can register smarter parsers later.
30
+ function parseTemplateReferences(source) {
31
+ const empty = { variables: [], includes: [], iterations: [] }
32
+ if (typeof source !== 'string' || !source) return empty
33
+
34
+ const variables = new Set()
35
+ const includes = new Set()
36
+ const iterations = []
37
+
38
+ // {{ X.Y.Z }} and <%= X.Y.Z %> — output expressions. Capture leading
39
+ // identifier path; ignore filters (`| upcase`), pipes, and anything
40
+ // after a space.
41
+ const outputExpr = /(?:\{\{=?|<%=)\s*([^}%|]+?)(?:\s*\||\s*\}\}|\s*-?%>)/g
42
+ for (const m of source.matchAll(outputExpr)) {
43
+ const expr = m[1].trim()
44
+ const ident = expr.match(/^[a-zA-Z_$][\w$]*(?:\.[a-zA-Z_$][\w$]*)*/)
45
+ if (ident && !TEMPLATE_KEYWORDS.has(ident[0])) {
46
+ variables.add(ident[0])
47
+ }
48
+ }
49
+
50
+ // Liquid include / layout — sub-template references.
51
+ for (const m of source.matchAll(/\{%\s*include\s+['"]([^'"]+)['"]/g)) {
52
+ includes.add(m[1])
53
+ }
54
+ for (const m of source.matchAll(/\{%\s*layout\s+['"]([^'"]+)['"]/g)) {
55
+ includes.add(m[1])
56
+ }
57
+
58
+ // Liquid `{% for X in Y %}` and `{% for X in Y.Z %}`.
59
+ for (const m of source.matchAll(/\{%\s*for\s+(\w+)\s+in\s+([\w.]+)/g)) {
60
+ iterations.push({ item: m[1], collection: m[2] })
61
+ variables.add(m[2])
62
+ }
63
+ // Handlebars `{{#each X.Y}}`.
64
+ for (const m of source.matchAll(/\{\{#each\s+([\w.]+)/g)) {
65
+ iterations.push({ item: '(each)', collection: m[1] })
66
+ variables.add(m[1])
67
+ }
68
+ // Eta `<% for (const X of Y) { %>` / `<% for X of Y %>`.
69
+ for (const m of source.matchAll(/<%\s*for\s*\(?\s*(?:const|let|var)?\s*(\w+)\s+of\s+([\w.]+)/g)) {
70
+ iterations.push({ item: m[1], collection: m[2] })
71
+ variables.add(m[2])
72
+ }
73
+
74
+ return {
75
+ variables: Array.from(variables).sort(),
76
+ includes: Array.from(includes).sort(),
77
+ iterations,
78
+ }
79
+ }
6
80
 
7
81
  export default ({
8
82
  runtime,
@@ -370,22 +444,29 @@ export default ({
370
444
  onComplete(async ({ entity, options, output }) => {
371
445
  const logger = useLogger()
372
446
  if (entity.layout && !options?.ignore && output.result != null) {
373
- // `_save: false` (stamped by useRenderer when called with
374
- // { save: false }) opts out of writing the final output to
375
- // disk. The bytes still come back to the caller via
376
- // output.result. Strict equality — only the literal `false`
377
- // opts out, matching the catalog-flag pattern.
447
+ // `entity.options.save === false` (set by useRenderer when
448
+ // called with { save: false }) opts out of writing the
449
+ // FINAL output to disk. The bytes still come back to the
450
+ // caller via output.result. Strict equality — only the
451
+ // literal `false` opts out, matching the catalog-flag pattern.
378
452
  //
379
453
  // The intermediate file (when a postprocessor will run next)
380
- // must still be written so the postprocessor can consume it,
381
- // so we only honour `_save: false` for the FINAL output —
382
- // detected as either "no postprocessor configured" or "we're
383
- // running on the postprocess side already" (origin set).
454
+ // must still exist somewhere on disk so the postprocessor
455
+ // can consume it. For save:true, that's outputFolder; for
456
+ // save:false, that's runtime.options.previewFolder an
457
+ // engine-owned scratch path under runtimeFolder, never
458
+ // exposed in user-visible outputFolder. The postprocess
459
+ // task's outputFolder is rewritten in engine.js so post
460
+ // plugins resolve `entity.origin` against the same base.
384
461
  const isFinal = !entity.layout.postprocessor || entity.origin != null
385
- const skipWrite = entity._save === false && isFinal
462
+ const previewMode = entity.options?.save === false
463
+ const skipWrite = previewMode && isFinal
464
+ const writeBase = (previewMode && !isFinal)
465
+ ? runtime.options.previewFolder
466
+ : runtime.options.outputFolder
386
467
 
387
468
  if (!skipWrite) {
388
- const destinationFile = path.join(runtime.options.outputFolder, entity.destination)
469
+ const destinationFile = path.join(writeBase, entity.destination)
389
470
  await mkdir(path.dirname(destinationFile), { recursive: true })
390
471
  try {
391
472
  await unlink(destinationFile)
@@ -401,7 +482,14 @@ export default ({
401
482
  // wrote to (post plugins that produce the same extension as
402
483
  // the renderer's output — e.g. MJML→HTML on `*.html-mjml.*`
403
484
  // layouts). Otherwise we'd delete our own final file.
404
- const originFile = path.join(runtime.options.outputFolder, entity.origin)
485
+ //
486
+ // For preview flow (entity.options.save === false) the
487
+ // intermediate lived in previewFolder; for normal flow
488
+ // it lived in outputFolder. Pick the right base.
489
+ const originBase = previewMode
490
+ ? runtime.options.previewFolder
491
+ : runtime.options.outputFolder
492
+ const originFile = path.join(originBase, entity.origin)
405
493
  try {
406
494
  await unlink(originFile)
407
495
  } catch { }
@@ -418,6 +506,94 @@ export default ({
418
506
  }
419
507
  })
420
508
 
509
+ // mikser_inspect_layout lives in the layouts plugin (not core)
510
+ // because "what does a layout expect?" is layout-specific knowledge.
511
+ // Follows ADR-0006: domain logic → plugin; the MCP substrate stays
512
+ // in core.
513
+ //
514
+ // Gating on runtime.options.mcp inside onLoaded matches the Express
515
+ // pattern (gating on runtime.options.app for route mounts) — same
516
+ // shape, no special wrapper. The check happens once per boot.
517
+ onLoaded(() => {
518
+ if (!runtime.options.mcp) return
519
+ const mcp = runtime.options.mcp
520
+ mcp.simpleTool(
521
+ 'mikser_inspect_layout',
522
+ 'Inspect a layout: template source, variables it references, the postprocessor it produces, and sample entities currently using it. Use this to answer "what data does this layout need?" before drafting a preview render — saves a guess-and-render-empty cycle.',
523
+ {
524
+ id: z.string().describe('Layout id, e.g. "/layouts/reports/royalty.html-pdf.liquid". Use mikser_list_entities with { collection: "layouts" } to discover ids.'),
525
+ samples: z.number().int().min(0).max(10).optional().describe('How many existing entities currently using this layout to include as data-shape examples. Default 3. Only entities with explicit meta.layout match; auto-matched layouts are not surfaced.'),
526
+ },
527
+ async ({ id, samples = 3 }) => {
528
+ const logger = useLogger()
529
+ try {
530
+ const layout = await findEntity({ id })
531
+ if (!layout || layout.collection !== collection) {
532
+ return {
533
+ isError: true,
534
+ content: [{ type: 'text', text: `Layout not found: ${id}` }],
535
+ }
536
+ }
537
+
538
+ let template = ''
539
+ try {
540
+ template = await readFile(layout.uri, 'utf8')
541
+ } catch (err) {
542
+ return {
543
+ isError: true,
544
+ content: [{
545
+ type: 'text',
546
+ text: `Layout entity exists but template file unreadable (${layout.uri}): ${err.message}`,
547
+ }],
548
+ }
549
+ }
550
+
551
+ const references = parseTemplateReferences(template)
552
+
553
+ let sampleEntities = []
554
+ if (samples > 0) {
555
+ const all = await findEntities()
556
+ sampleEntities = all
557
+ .filter(e => e.meta?.layout === layout.name)
558
+ .slice(0, samples)
559
+ .map(e => ({ id: e.id, name: e.name, meta: e.meta }))
560
+ }
561
+
562
+ return {
563
+ content: [{
564
+ type: 'text',
565
+ text: JSON.stringify({
566
+ layout: {
567
+ id: layout.id,
568
+ name: layout.name,
569
+ uri: layout.uri,
570
+ format: layout.format,
571
+ template: layout.template,
572
+ postprocessor: layout.postprocessor ?? null,
573
+ },
574
+ templateSource: template,
575
+ references,
576
+ samples: sampleEntities,
577
+ notes: [
578
+ 'references.variables is a naive regex pass across liquid/handlebars/eta — false positives possible, but covers the common `{{ document.meta.X }}` and `<%= entity.X %>` patterns.',
579
+ 'samples only includes entities with explicit meta.layout. Auto-matched layouts are not listed; use mikser_list_entities with a filename-pattern filter for those.',
580
+ ],
581
+ }, null, 2),
582
+ }],
583
+ }
584
+ } catch (err) {
585
+ logger.error('MCP mikser_inspect_layout error: %s', err.message)
586
+ return {
587
+ isError: true,
588
+ content: [{ type: 'text', text: err.message }],
589
+ }
590
+ }
591
+ },
592
+ )
593
+ const logger = useLogger()
594
+ logger.info('MCP tool registered: mikser_inspect_layout (layouts plugin)')
595
+ })
596
+
421
597
  return {
422
598
  collection,
423
599
  type
@@ -0,0 +1,206 @@
1
+ // Preview plugin. Owns the "render an entity transiently and surface
2
+ // the bytes at a clickable URL" workflow. Three responsibilities:
3
+ //
4
+ // 1. An in-memory cache (Map<filename, { bytes, mime, expiresAt, size }>)
5
+ // with LRU eviction past a configurable byte cap.
6
+ // 2. An Express GET /preview/:filename route that serves cache entries.
7
+ // 3. The mikser_preview MCP tool, registered on the substrate when
8
+ // --mcp is active.
9
+ //
10
+ // Lives outside the api plugin because preview is not a REST catalog
11
+ // concern — it's a render-and-cache workflow whose lifetime is the
12
+ // process, not the catalog. Lives outside core because it's domain
13
+ // (a workflow), not transport substrate. Per ADR-0006: plugin.
14
+ //
15
+ // Composes independently: needs `runtime.options.app` (any Express
16
+ // app — engine-supplied or external) and `useRenderer` (a core helper).
17
+ // Does NOT need the api plugin. Loading just `preview` + `mcp` is
18
+ // a valid combination for AI-driven workflows that skip the REST API.
19
+ //
20
+ // Library-mode surface: this plugin exposes
21
+ // runtime.options.preview = { store, get, stats }
22
+ // so any plugin or programmatic caller can stash bytes and get a URL
23
+ // back without going through MCP. The mikser_preview tool is a thin
24
+ // wrapper over this surface.
25
+
26
+ import path from 'node:path'
27
+ import { randomUUID } from 'node:crypto'
28
+ import { z } from 'zod'
29
+ import { useRenderer } from '../api.js'
30
+ import { mimeForEntity } from './api.js'
31
+
32
+ export default ({
33
+ runtime,
34
+ onLoaded,
35
+ useLogger,
36
+ }) => {
37
+ // Factory-scope cache. One per engine instance. Module-scope would
38
+ // share across multiple engines in the same Node process, which is
39
+ // a scenario mikser doesn't really support.
40
+ const previews = new Map() // filename → { bytes, mime, expiresAt, size }
41
+ let bytesInUse = 0
42
+
43
+ // Config knobs with sensible defaults. The defaults match the
44
+ // values 7.2.x shipped under the api plugin so existing callers
45
+ // see no change in behavior — just a different plugin owner.
46
+ const config = () => ({
47
+ maxBytes: runtime.config.preview?.maxBytes ?? (100 * 1024 * 1024),
48
+ defaultTtl: runtime.config.preview?.defaultTtl ?? 600,
49
+ ttlMin: runtime.config.preview?.ttlMin ?? 30,
50
+ ttlMax: runtime.config.preview?.ttlMax ?? 3600,
51
+ path: runtime.config.preview?.path ?? '/preview',
52
+ })
53
+
54
+ // Three primitives the rest of the plugin (and library-mode
55
+ // callers) build on: store(), get(), stats(). All operate against
56
+ // the closed-over `previews` Map and `bytesInUse` counter.
57
+ function store({ filename, bytes, mime, ttlMs }) {
58
+ const size = Buffer.isBuffer(bytes) ? bytes.length : Buffer.byteLength(bytes)
59
+ const cfg = config()
60
+ // LRU evict until there's room. JS Map preserves insertion
61
+ // order — `keys().next().value` is the oldest entry.
62
+ while (bytesInUse + size > cfg.maxBytes && previews.size > 0) {
63
+ const oldestKey = previews.keys().next().value
64
+ const oldest = previews.get(oldestKey)
65
+ bytesInUse -= oldest.size
66
+ previews.delete(oldestKey)
67
+ }
68
+ previews.set(filename, { bytes, mime, expiresAt: Date.now() + ttlMs, size })
69
+ bytesInUse += size
70
+ return { filename, size, expiresAt: Date.now() + ttlMs }
71
+ }
72
+
73
+ function get(filename) {
74
+ const entry = previews.get(filename)
75
+ if (!entry) return null
76
+ if (entry.expiresAt < Date.now()) {
77
+ bytesInUse -= entry.size
78
+ previews.delete(filename)
79
+ return null
80
+ }
81
+ // Touch — move to tail for LRU recency.
82
+ previews.delete(filename)
83
+ previews.set(filename, entry)
84
+ return entry
85
+ }
86
+
87
+ function stats() {
88
+ return {
89
+ count: previews.size,
90
+ bytesInUse,
91
+ maxBytes: config().maxBytes,
92
+ utilization: bytesInUse / config().maxBytes,
93
+ }
94
+ }
95
+
96
+ // Expose the cache surface at runtime.options.preview so other
97
+ // plugins / library callers can use it without going through MCP.
98
+ // Done at factory-eval time (before any onLoaded fires) so a
99
+ // later plugin's onLoad / onLoaded can already see it.
100
+ runtime.options.preview = { store, get, stats }
101
+
102
+ // HTTP route: served regardless of whether MCP is on, so previews
103
+ // are also reachable from library-mode callers that stored bytes
104
+ // via runtime.options.preview.store() directly.
105
+ onLoaded(async () => {
106
+ const logger = useLogger()
107
+ const app = runtime.options.app
108
+ if (!app) {
109
+ // No HTTP app means previews can still be stored
110
+ // programmatically but the URL won't be reachable. That's
111
+ // a valid configuration for batch / one-shot uses; we
112
+ // just don't mount the route.
113
+ logger.debug('Preview plugin: no runtime.options.app — cache available, route not mounted')
114
+ return
115
+ }
116
+
117
+ const cfg = config()
118
+ const routePath = `${cfg.path.replace(/\/$/, '')}/:filename`
119
+ app.get(routePath, (req, res) => {
120
+ const entry = get(req.params.filename)
121
+ if (!entry) {
122
+ res.status(404).type('text/plain').send('Preview expired or not found')
123
+ return
124
+ }
125
+ res.type(entry.mime).send(entry.bytes)
126
+ })
127
+ logger.info('Preview route mounted: %s (cache cap: %d MB)', cfg.path, Math.round(cfg.maxBytes / 1024 / 1024))
128
+ })
129
+
130
+ // Gating on runtime.options.mcp inside onLoaded matches the route-
131
+ // mount pattern above. Same shape as `if (!app)`: check the flag
132
+ // in the hook, register if present. No special wrapper needed.
133
+ onLoaded(() => {
134
+ if (!runtime.options.mcp) return
135
+ const mcp = runtime.options.mcp
136
+ const { render: previewRender } = useRenderer(runtime, {
137
+ defaultTimeout: runtime.config.preview?.renderTimeout ?? 30_000,
138
+ })
139
+
140
+ mcp.simpleTool(
141
+ 'mikser_preview',
142
+ 'Render an entity through the engine pipeline AND surface the FINAL output as a clickable URL served by the running --server. Use this instead of mikser_render when the user needs to see the result in a browser. The URL serves the pipeline\'s final output — PDF for a `*.html-pdf.*` layout, MJML-derived HTML for `*.html-mjml.*`, etc. Requires --server. Previews live in memory (not on disk, never under outputFolder) and auto-expire — default 10 minutes, clamped 30..3600 seconds.',
143
+ {
144
+ entity: z.record(z.any()).describe('Entity shape with at least { id, collection } and any meta/content the renderer needs. Same shape as mikser_render.'),
145
+ options: z.record(z.any()).optional().describe('Renderer options. Same as mikser_render, plus { expiresInSeconds: number = 600 } controlling preview TTL.'),
146
+ },
147
+ async ({ entity = {}, options = {} }) => {
148
+ const logger = useLogger()
149
+ const ok = (data) => ({
150
+ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
151
+ })
152
+ const fail = (msg) => ({
153
+ isError: true,
154
+ content: [{ type: 'text', text: msg }],
155
+ })
156
+
157
+ try {
158
+ if (!runtime.options.port) {
159
+ return fail('mikser_preview requires --server to be running so the preview URL is reachable. Use mikser_render to get raw bytes inline instead.')
160
+ }
161
+
162
+ const { expiresInSeconds = config().defaultTtl, ...renderOptions } = options ?? {}
163
+ const { output, entity: rendered } = await previewRender(entity, {
164
+ ...renderOptions,
165
+ save: false,
166
+ catalog: false,
167
+ })
168
+ const result = output?.result
169
+ if (result == null) {
170
+ return fail('Render produced no output. Check that the entity has a resolvable layout and the layout matched a registered renderer.')
171
+ }
172
+
173
+ const destExt = path.extname(rendered.destination || '').slice(1)
174
+ const ext = destExt || 'html'
175
+ const filename = `${randomUUID()}.${ext}`
176
+ const mime = mimeForEntity(rendered) ?? 'application/octet-stream'
177
+ const cfg = config()
178
+ const ttlSec = Math.max(cfg.ttlMin, Math.min(cfg.ttlMax, expiresInSeconds))
179
+
180
+ store({ filename, bytes: result, mime, ttlMs: ttlSec * 1000 })
181
+
182
+ const url = `http://localhost:${runtime.options.port}${cfg.path}/${filename}`
183
+ const bytes = Buffer.isBuffer(result) ? result.length : Buffer.byteLength(result)
184
+
185
+ logger.info('MCP mikser_preview cached %s (%d bytes, ttl %ds): %s', filename, bytes, ttlSec, url)
186
+
187
+ return ok({
188
+ previewUrl: url,
189
+ mimeType: mime,
190
+ bytes,
191
+ expiresInSeconds: ttlSec,
192
+ instructions: 'Open previewUrl in a browser to view. The preview lives in mikser memory and auto-expires after expiresInSeconds — re-run mikser_preview to refresh.',
193
+ })
194
+ } catch (err) {
195
+ logger.error('MCP mikser_preview error: %s', err.message)
196
+ return fail(err.message)
197
+ }
198
+ },
199
+ )
200
+
201
+ const logger = useLogger()
202
+ logger.info('MCP tool registered: mikser_preview (preview plugin)')
203
+ })
204
+
205
+ return { name: 'preview' }
206
+ }