mikser-io-layouts 2.13.0 → 3.1.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/index.js CHANGED
@@ -22,6 +22,8 @@ import {
22
22
  checksum as fileChecksum, checksumOf,
23
23
  useDatabase,
24
24
  writeOutput, reportUnchanged,
25
+ provideService,
26
+ cliOption,
25
27
  } from 'mikser-io'
26
28
 
27
29
  import { createInspect } from './lib/inspect.js'
@@ -87,20 +89,28 @@ export function layouts(userOptions = {}) {
87
89
  // The old `runtime.state.layouts.sitemap` in-memory map + `uriIndex`
88
90
  // are gone: every entity's "sitemap presence" IS its catalog row.
89
91
 
90
- // Expose the layouts inspection surface for other plugins (the
91
- // mikser-io-mcp plugin wraps inspect() as the mikser_layouts_inspect
92
- // tool). Done at factory-eval time — before any onLoaded fires — so
93
- // a later plugin's onLoaded can see it. Matches the preview plugin
94
- // pattern (`runtime.options.preview = { store, get, stats, config }`).
95
- runtime.options.layouts = {
92
+ // Offer the inspection surface as a service. A consumer asks core
93
+ // for 'layouts' and never names this package; before, it read
94
+ // runtime.options.layouts directly, which coupled the two through an
95
+ // object neither owns and cost the `--layouts` flag its name.
96
+ //
97
+ // Provided at factory-eval time — before any hook runs — so a
98
+ // consumer's onLoaded sees it whatever the plugin order.
99
+ // The flag this package could not have until runtime.options.layouts
100
+ // stopped holding an API object: commander derives the property from
101
+ // the long flag, so `--layouts` would have landed a folder string on
102
+ // top of inspect(), after the load phase, breaking a consumer three
103
+ // plugins away.
104
+ cliOption('--layouts <folder>',
105
+ 'folder holding the layout templates, relative to the working folder (default: layouts)')
106
+
107
+ provideService('layouts', {
96
108
  inspect: createInspect({ runtime, findEntity, findEntities, useDatabase, collection }),
97
- }
109
+ }, { plugin: 'mikser-io-layouts' })
98
110
 
99
- // MCP tool registration. Gated on runtime.options.mcp — when the
100
- // mcp plugin isn't loaded, this is a no-op (vector / schemas /
101
- // preview use the same pattern). The mcp plugin must be FIRST
102
- // in the plugins array for this to fire; that constraint is
103
- // documented in mikser-io's CLAUDE.md.
111
+ // Tool registration. Registers against core's tool registry, which
112
+ // exists whether or not the mcp plugin is installed — so there is no
113
+ // gate to write and no ordering rule to obey.
104
114
  onLoaded(async () => {
105
115
  registerMcpTools({ runtime, useLogger, findEntity, findEntities, useDatabase, collection })
106
116
  })
@@ -199,13 +209,18 @@ export function layouts(userOptions = {}) {
199
209
  layouts: {},
200
210
  }
201
211
 
202
- // Folder name resolved here (config override or default to the
203
- // collection name) and used immediately to build the absolute
204
- // path. No need to keep the bare folder-name string on
205
- // runtime.options — runtime.options.layoutsFolder is the only
206
- // useful form downstream.
207
- const layoutsFolderName = options.layoutsFolder || collection
208
- runtime.options.layoutsFolder = path.join(runtime.options.workingFolder, layoutsFolderName)
212
+ // --layouts wins, then the config, then the collection name.
213
+ //
214
+ // Read HERE rather than at construction: the option table is not
215
+ // parsed until every plugin has been constructed and had its
216
+ // chance to declare, so a value read in the factory is always
217
+ // undefined. An absolute value is taken as given; anything else
218
+ // is relative to the working folder, like every other folder flag.
219
+ const layoutsFolderName =
220
+ runtime.options.layouts ?? options.layoutsFolder ?? collection
221
+ runtime.options.layoutsFolder = path.isAbsolute(layoutsFolderName)
222
+ ? layoutsFolderName
223
+ : path.join(runtime.options.workingFolder, layoutsFolderName)
209
224
  runtime.options.layoutsStateFolder = path.join(runtime.options.outputFolder, 'state')
210
225
 
211
226
  logger.debug('Layouts folder: %s', runtime.options.layoutsFolder)
@@ -311,8 +326,43 @@ export function layouts(userOptions = {}) {
311
326
  // and they stay out of the layouts NAME map so nothing resolves a
312
327
  // template to one. Rendering is not a risk either: layout matching
313
328
  // skips this collection outright.
314
- const sidecarPaths = (await globby('**/*.js', { cwd: runtime.options.layoutsFolder }))
315
- .filter(isSidecarScript)
329
+ const allScripts = await globby('**/*.js', { cwd: runtime.options.layoutsFolder })
330
+ const sidecarPaths = allScripts.filter(isSidecarScript)
331
+
332
+ // A `.js` that nothing will ever read.
333
+ //
334
+ // A sidecar is named after the LAYOUT, and a layout's name drops
335
+ // BOTH extensions: `robots.txt.liquid` is the layout `robots`, so
336
+ // its sidecar is `robots.js`. Write `robots.txt.js` and it is
337
+ // claimed by nothing — isSidecarScript rejects it because
338
+ // stripping `.js` leaves `.txt`, and the layout glob ignores every
339
+ // `.js` outright. The file is simply invisible: it does not fail,
340
+ // it never runs, and the layout renders with empty `data`.
341
+ //
342
+ // Downstream that shipped `Sitemap: /sitemap.xml` as a relative
343
+ // reference in a robots.txt — which in a robots.txt means nothing
344
+ // — on a green build.
345
+ //
346
+ // Only when a layout sits beside it under the same stem, which is
347
+ // what makes this a misnaming rather than a stray file. A dotted
348
+ // module a sidecar imports (`lib/data.json.js`) is claimed by
349
+ // nothing either, and is nobody's mistake; flagging it would be
350
+ // the noise that gets this line filtered out.
351
+ for (const script of allScripts) {
352
+ if (isSidecarScript(script)) continue
353
+ const stem = script.replace(/\.js$/, '')
354
+ const layout = paths.find(candidate =>
355
+ candidate === stem || candidate.startsWith(`${stem}.`))
356
+ if (!layout) continue
357
+ const name = path.basename(stem, path.extname(stem))
358
+ logger.warn({ code: 'layout-sidecar-misnamed', script, layout },
359
+ 'layouts/%s is not loaded as a sidecar and nothing else reads it. A sidecar is '
360
+ + 'named after the LAYOUT, and %s is the layout `%s` — both extensions are '
361
+ + 'dropped — so its sidecar has to be %s.js. The layout renders with empty data '
362
+ + 'until it is renamed.',
363
+ script, layout, name, path.join(path.dirname(stem) === '.' ? '' : path.dirname(stem), name))
364
+ }
365
+
316
366
  const inputs = await sidecarInputs()
317
367
  const scanned = new Set()
318
368
  const stats = { emitted: 0, skipped: 0, deleted: 0 }
package/lib/assembly.js CHANGED
@@ -27,10 +27,21 @@ import {
27
27
  observeConsumed,
28
28
  queryContext,
29
29
  useDatabase,
30
+ reportError,
30
31
  } from 'mikser-io'
31
32
  import { compileDestinationTemplate, sanitizeDestination, primaryDestination } from './destination.js'
32
33
  import { setSidecarStamp, sidecarHookInstalled } from './sidecar-modules.js'
33
34
 
35
+ // What the sidecar actually returned, for a message a reader can act on.
36
+ // `[object Object]` and `` are the two least useful things to print here.
37
+ function describePages(value) {
38
+ if (Array.isArray(value)) return `an array of ${value.length}`
39
+ if (value === null) return 'null'
40
+ if (typeof value === 'object') return `an object with keys ${Object.keys(value).join(', ') || '(none)'}`
41
+ if (typeof value === 'number') return `the number ${value}`
42
+ return `a ${typeof value} (${JSON.stringify(value)})`
43
+ }
44
+
34
45
  export function createOnBeforeRender({
35
46
  runtime, useLogger, useJournal,
36
47
  findById, findEntity, findEntities,
@@ -350,6 +361,56 @@ export function createOnBeforeRender({
350
361
  : null
351
362
 
352
363
  if (data?.pages) {
364
+ // `pages` is the pagination COUNT, and only a count.
365
+ //
366
+ // The guard above is truthiness and the loop below is
367
+ // `page < data.pages`, so a sidecar returning anything
368
+ // truthy that is not a positive number passed the first
369
+ // and ran zero times in the second. `{ pages: [] }`
370
+ // coerces to 0; `{ pages: [{…}] }` coerces to NaN. Both
371
+ // produced NO page: no file, no error, no warning, not
372
+ // counted in `Rendered:`, no manifest snapshot — a green
373
+ // build with the page simply absent, and --audit-output
374
+ // cannot see it either because an entity that never
375
+ // rendered has no snapshot to be missing.
376
+ //
377
+ // `pages` is also the obvious name for the list in a
378
+ // sitemap, which is exactly where it lands.
379
+ //
380
+ // Thrown rather than reinterpreted. The key has one
381
+ // meaning; guessing a second from the value's type is how
382
+ // a name comes to mean two things depending on what you
383
+ // put in it. This fails the render for this entity, which
384
+ // is counted, recorded and named.
385
+ if (!Number.isInteger(data.pages) || data.pages < 1) {
386
+ // Reported, not thrown. This runs in onBeforeRender,
387
+ // a lifecycle hook rather than a render, so an
388
+ // exception here tears the whole build down with a
389
+ // stack trace — one mistyped sidecar stopping every
390
+ // other page, which trades a silent failure for a
391
+ // total one.
392
+ //
393
+ // reportError, not logger.error. This entity could
394
+ // not be turned into output, which is what a render
395
+ // error IS — so it is counted in `errors`, recorded in
396
+ // mikser_failures, and makes a one-shot build exit 1.
397
+ // A coded logger.error would register as a fault, and
398
+ // a fault leaves the build green: "🟢 Mikser completed"
399
+ // beside a red line and exit 0 is the same green build
400
+ // the report was about.
401
+ const message =
402
+ `sidecar returned \`pages\` as ${describePages(data.pages)}. `
403
+ + '`pages` is the pagination COUNT — a positive integer — and is reserved '
404
+ + 'in the data a layout receives, so a list cannot be passed under it. '
405
+ + 'Rename it (`items`, `entries`, `urls`) and the layout renders once '
406
+ + 'with that list available.'
407
+ reportError(entity, new Error(message), { layout: entity.layout?.name ?? null })
408
+ logger.error({ code: 'layout-pages-not-a-count', id: entity.id,
409
+ layout: entity.layout?.name ?? null },
410
+ 'Layout %s: %s — %s produced no output.',
411
+ entity.layout?.name ?? '?', message, entity.id)
412
+ continue
413
+ }
353
414
  if (!_.endsWith(entity.name, entity.format)) {
354
415
  // Loop bound is `< data.pages` (not `data.pages - 1`).
355
416
  // With 4 pages and the old bound, iteration only ran
@@ -409,6 +470,19 @@ export function createOnBeforeRender({
409
470
  sidecarMetaReads: [...(sidecarTrack.metaReads ?? [])],
410
471
  sidecarConsumedReads: [...(sidecarTrack.consumedReads ?? [])].map(([k, v]) => [k, [...v]]) })
411
472
  }
473
+ } else {
474
+ // Pagination was asked for, and nothing was queued.
475
+ //
476
+ // The guard exists so an entity whose name already
477
+ // carries its format is not expanded twice, but it
478
+ // shares the shape that made the type confusion above
479
+ // so expensive: no task, no message, output absent on
480
+ // a green build. Whatever the right handling is, it is
481
+ // not silence.
482
+ logger.warn({ code: 'layout-pagination-skipped', id: entity.id },
483
+ 'Pagination was requested for %s (pages: %d) but its name already ends '
484
+ + 'with its format (%s), so no page was produced.',
485
+ entity.id, data.pages, entity.format)
412
486
  }
413
487
  } else {
414
488
  // Same derivation onProcessed persists onto the catalog
package/lib/mcp.js CHANGED
@@ -1,44 +1,47 @@
1
- // MCP tool surface for layouts. Registered against the mcp substrate
2
- // at `runtime.options.mcp` when present — same gating pattern other
3
- // siblings use (vector, schemas, preview). When mcp isn't loaded, the
4
- // registration is a no-op.
1
+ // The tool surface for layouts. Registered against the ENGINE's tool
2
+ // registry, which exists whether or not the mcp plugin is installed — so
3
+ // there is no `if (!runtime.options.mcp) return` gate to write, and no rule
4
+ // about where mcp sits in the plugins array.
5
5
  //
6
- // One tool: `mikser_layouts_inspect`. Surfaces the inspect() primitive
7
- // already exposed at `runtime.options.layouts.inspect`. The schema and
8
- // author-facing description live here because they're MCP-flavor; the
9
- // underlying data shape is owned by lib/inspect.js.
6
+ // The engine stores BARE names; the mcp plugin prefixes on the way out, so a
7
+ // session still sees `mikser_layouts_inspect` and the CLI sees
8
+ // `layouts_inspect`. One registration, both surfaces.
9
+ //
10
+ // Surfaces the inspect() primitive this package offers as the `layouts`
11
+ // service. The schema and author-facing description live here because
12
+ // they're MCP-flavor; the underlying data shape is owned by lib/inspect.js.
10
13
 
11
14
  import { z } from 'zod'
15
+ import { registerTool, useService } from 'mikser-io'
12
16
 
13
17
  export function registerMcpTools({ runtime, useLogger, findEntity, findEntities, useDatabase, collection }) {
14
- const mcp = runtime.options.mcp
15
- if (!mcp) return // mcp plugin not loaded — nothing to register
16
- if (!runtime.options.layouts?.inspect) return // shouldn't happen if we run after our own inspect setup, but defensive
18
+ const layouts = useService('layouts')
19
+ if (!layouts?.inspect) return // our own factory has not run yet
17
20
 
18
21
  const logger = useLogger()
19
-
20
- mcp.simpleTool(
21
- 'mikser_layouts_inspect',
22
- 'Inspect a layout and everything it pulls in. Answers "what does this layout need from a document" '
23
- + 'before you write one — which saves a guess-and-render-empty cycle, and is the only way to catch a '
24
- + 'mistyped key before it ships a page with a section silently missing.\n\n'
25
- + 'START WITH references.contract.meta. It is the list of document meta keys the WHOLE layout tree '
26
- + 'consumes, resolved through includes and renamings into the vocabulary a document actually writes — '
27
- + 'so `data.meta.hero.tags` in a partial three files down comes back as `hero.tags`. Compare it against '
28
- + 'the meta you are about to write: a key in the contract with no value is a gap, and a key you wrote '
29
- + 'that appears nowhere in it is usually a typo.\n\n'
30
- + 'Check references.contract.complete before trusting a comparison. When false, some branch could not be '
31
- + 'read and references.contract.incomplete names each one with a reason.\n\n'
32
- + 'Also returns the template source, sample entities targeting the layout, and both reference views: '
33
- + 'runtime (what recent renders actually touched) and static (what the source mentions, including '
34
- + 'branches no render has taken).',
22
+ registerTool('layouts_inspect',
35
23
  {
36
- id: z.string().describe('Layout id, e.g. "/layouts/reports/royalty.html-pdf.liquid". Use mikser_query_entities with { collection: "layouts" } to discover ids.'),
37
- 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.'),
24
+ description: 'Inspect a layout and everything it pulls in. Answers "what does this layout need from a document" '
25
+ + 'before you write one — which saves a guess-and-render-empty cycle, and is the only way to catch a '
26
+ + 'mistyped key before it ships a page with a section silently missing.\n\n'
27
+ + 'START WITH references.contract.meta. It is the list of document meta keys the WHOLE layout tree '
28
+ + 'consumes, resolved through includes and renamings into the vocabulary a document actually writes — '
29
+ + 'so `data.meta.hero.tags` in a partial three files down comes back as `hero.tags`. Compare it against '
30
+ + 'the meta you are about to write: a key in the contract with no value is a gap, and a key you wrote '
31
+ + 'that appears nowhere in it is usually a typo.\n\n'
32
+ + 'Check references.contract.complete before trusting a comparison. When false, some branch could not be '
33
+ + 'read and references.contract.incomplete names each one with a reason.\n\n'
34
+ + 'Also returns the template source, sample entities targeting the layout, and both reference views: '
35
+ + 'runtime (what recent renders actually touched) and static (what the source mentions, including '
36
+ + 'branches no render has taken).',
37
+ inputSchema: {
38
+ id: z.string().describe('Layout id, e.g. "/layouts/reports/royalty.html-pdf.liquid". Use mikser_query_entities with { collection: "layouts" } to discover ids.'),
39
+ 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.'),
40
+ },
38
41
  },
39
42
  async ({ id, samples = 3 }) => {
40
43
  try {
41
- const result = await runtime.options.layouts.inspect(id, { samples })
44
+ const result = await layouts.inspect(id, { samples })
42
45
  return {
43
46
  content: [{
44
47
  type: 'text',
@@ -127,7 +130,10 @@ function distance(a, b) {
127
130
  }
128
131
 
129
132
  export function registerCheckTool({ runtime, findEntity, findEntities, useDatabase, collection, logger }) {
130
- const mcp = runtime.options.mcp
133
+ // Resolved here rather than in the handlers: this runs from onLoaded, by
134
+ // which point every factory — including our own, which provides it — has
135
+ // run.
136
+ const layouts = useService('layouts')
131
137
 
132
138
  const ok = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] })
133
139
 
@@ -258,7 +264,7 @@ export function registerCheckTool({ runtime, findEntity, findEntities, useDataba
258
264
  // the entity names one. `schemaKey` is the plugin's own config — commonly
259
265
  // `meta.layout`, so a layout and its schema share a name.
260
266
  const schemaFor = (entity) => {
261
- const api = runtime.options.schemas
267
+ const api = useService('schemas')
262
268
  if (!api?.lookup) return null
263
269
  const key = runtime.options.schemasKey ?? runtime.options.schemaKey ?? 'meta.schema'
264
270
  const name = key.split('.').reduce((o, k) => o?.[k], entity)
@@ -382,35 +388,35 @@ export function registerCheckTool({ runtime, findEntity, findEntities, useDataba
382
388
  return [...(affected ?? [])].filter(cid => cid !== entity.id).sort()
383
389
  } catch { return [] }
384
390
  }
385
-
386
- mcp.simpleTool(
387
- 'mikser_check_entity',
388
- 'Check a document against the contract of the layout that renders it, BEFORE the mistake ships.\n\n'
389
- + 'A mistyped key does not fail a build. The section it named simply does not render, the page goes out '
390
- + 'with a hole in it, and every signal reads clean. This is the check that catches it.\n\n'
391
- + 'It classifies the entity first, because not every document is a page. `kind` is one of:\n'
392
- + ' page — renders through a layout; checked against that layout\'s contract.\n'
393
- + ' data — never renders, but other entities pull it in through a query. Reports who consumes it. '
394
- + 'A missing key here surfaces as a hole in THOSE pages, not in this file, and no amount of building '
395
- + 'will make it render.\n'
396
- + ' unreferenced — never renders and nothing in the catalog reads it. Not an error: the catalog is '
397
- + 'readable over the API, so an external consumer may depend on it.\n\n'
398
- + 'For a page, read `missing` first — keys the layout consumes UNCONDITIONALLY that this document has '
399
- + 'no value for. That is the only list meaning "this is probably wrong". Then `likelyTypos`, which '
400
- + 'pairs a key you wrote against a key the layout wanted within two edits; a typo produces both at '
401
- + 'once, which is far stronger than either alone.\n\n'
402
- + 'Everything else means "you may want to look", never "this is broken": `missingOptional` is guarded '
403
- + 'by the layout and safe to omit, `unused` may be consumed by a different layout, and '
404
- + '`unresolvedSections` names a section that matched no template under this layout.\n\n'
405
- + 'The contract is scoped to the partials this entity ACTUALLY rendered with. Unscoped, a page using '
406
- + 'two of twenty sections would be checked against all twenty and a real omission would be one line in '
407
- + 'three hundred.\n\n'
408
- + 'Check `reliable` before trusting an absence.',
391
+ registerTool('check_entity',
409
392
  {
410
- id: z.string().describe('Entity id, e.g. "/documents/en/index.md".'),
411
- layout: z.string().optional()
412
- .describe('Layout id to check against. Omit to use the layout this entity last rendered with, '
413
- + 'which is what the manifest recorded rather than a prediction.'),
393
+ description: 'Check a document against the contract of the layout that renders it, BEFORE the mistake ships.\n\n'
394
+ + 'A mistyped key does not fail a build. The section it named simply does not render, the page goes out '
395
+ + 'with a hole in it, and every signal reads clean. This is the check that catches it.\n\n'
396
+ + 'It classifies the entity first, because not every document is a page. `kind` is one of:\n'
397
+ + ' page — renders through a layout; checked against that layout\'s contract.\n'
398
+ + ' data — never renders, but other entities pull it in through a query. Reports who consumes it. '
399
+ + 'A missing key here surfaces as a hole in THOSE pages, not in this file, and no amount of building '
400
+ + 'will make it render.\n'
401
+ + ' unreferenced — never renders and nothing in the catalog reads it. Not an error: the catalog is '
402
+ + 'readable over the API, so an external consumer may depend on it.\n\n'
403
+ + 'For a page, read `missing` first — keys the layout consumes UNCONDITIONALLY that this document has '
404
+ + 'no value for. That is the only list meaning "this is probably wrong". Then `likelyTypos`, which '
405
+ + 'pairs a key you wrote against a key the layout wanted within two edits; a typo produces both at '
406
+ + 'once, which is far stronger than either alone.\n\n'
407
+ + 'Everything else means "you may want to look", never "this is broken": `missingOptional` is guarded '
408
+ + 'by the layout and safe to omit, `unused` may be consumed by a different layout, and '
409
+ + '`unresolvedSections` names a section that matched no template under this layout.\n\n'
410
+ + 'The contract is scoped to the partials this entity ACTUALLY rendered with. Unscoped, a page using '
411
+ + 'two of twenty sections would be checked against all twenty and a real omission would be one line in '
412
+ + 'three hundred.\n\n'
413
+ + 'Check `reliable` before trusting an absence.',
414
+ inputSchema: {
415
+ id: z.string().describe('Entity id, e.g. "/documents/en/index.md".'),
416
+ layout: z.string().optional()
417
+ .describe('Layout id to check against. Omit to use the layout this entity last rendered with, '
418
+ + 'which is what the manifest recorded rather than a prediction.'),
419
+ },
414
420
  },
415
421
  async ({ id, layout }) => {
416
422
  const entity = await findEntity({ id })
@@ -527,7 +533,7 @@ export function registerCheckTool({ runtime, findEntity, findEntities, useDataba
527
533
  // statically to every section in the project, so an unscoped
528
534
  // contract for a two-section page is the union of the catalogue and
529
535
  // a real omission is one line in three hundred.
530
- const inspected = await runtime.options.layouts.inspect(layoutId, {
536
+ const inspected = await layouts.inspect(layoutId, {
531
537
  samples: 0,
532
538
  partials: usedPartials && usedPartials.length ? usedPartials : null,
533
539
  })
@@ -576,7 +582,7 @@ export function registerCheckTool({ runtime, findEntity, findEntities, useDataba
576
582
  // section look dead, and one such page reported 118 of them
577
583
  // while its longer sibling reported none. The document's only
578
584
  // difference was using fewer sections.
579
- const wide = await runtime.options.layouts.inspect(layoutId, { samples: 0 })
585
+ const wide = await layouts.inspect(layoutId, { samples: 0 })
580
586
  const anyLayout = new Set([
581
587
  ...(wide.references?.contract?.meta ?? []),
582
588
  ...observed,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io-layouts",
3
- "version": "2.13.0",
3
+ "version": "3.1.0",
4
4
  "description": "SSG-flavor task-production policy for mikser-io. Multi-match layout assignment, per-layout destinations, postprocess chain attachment, layout inspection.",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -18,7 +18,7 @@
18
18
  },
19
19
  "homepage": "https://github.com/almero-digital-marketing/mikser-io-layouts#readme",
20
20
  "peerDependencies": {
21
- "mikser-io": "^9.40.0",
21
+ "mikser-io": "^10.0.0",
22
22
  "zod": "^4.0.0"
23
23
  },
24
24
  "dependencies": {
package/test/_harness.js CHANGED
@@ -9,6 +9,7 @@ import _ from 'lodash'
9
9
  import {
10
10
  runtime as realRuntime,
11
11
  matchEntity, normalize, changeExtension, getFormatInfo, checksum, AbortError,
12
+ resetServices,
12
13
  } from 'mikser-io'
13
14
 
14
15
  const OPERATION = {
@@ -33,6 +34,10 @@ export function createHarness({
33
34
  entities = [],
34
35
  journal = [],
35
36
  } = {}) {
37
+ // A fresh harness is a fresh world, and the service registry is module
38
+ // state that would otherwise carry the previous test's provider into this
39
+ // one — where providing it again is (correctly) an error.
40
+ resetServices()
36
41
  const hookNames = [
37
42
  'load', 'loaded', 'import', 'imported',
38
43
  'process', 'processed', 'persist', 'persisted',
@@ -16,6 +16,7 @@ import path from 'node:path'
16
16
  import { createInspect } from '../lib/inspect.js'
17
17
  import { registerCheckTool } from '../lib/mcp.js'
18
18
  import * as liquid from 'mikser-io-render-liquid'
19
+ import { provideService, resetServices, invokeTool } from 'mikser-io'
19
20
 
20
21
  // Shaped like a real project: a page shell with chrome, and sections dispatched
21
22
  // through a registry. The registry is the reason scoping matters — statically it
@@ -129,15 +130,22 @@ before(async () => {
129
130
  : new Set()),
130
131
  },
131
132
  }
132
- runtime.options.layouts = {
133
- inspect: createInspect({ runtime, findEntity, findEntities, useDatabase, collection: 'layouts' }),
133
+ // Each fixture activates itself immediately before it is used. The tool
134
+ // registry is one global list now, so a later describe's registration
135
+ // replaces an earlier one — and one test here deliberately reaches back
136
+ // to the no-schema fixture from inside the schema describe.
137
+ const activate = () => {
138
+ resetServices()
139
+ provideService('layouts', {
140
+ inspect: createInspect({ runtime, findEntity, findEntities, useDatabase, collection: 'layouts' }),
141
+ })
142
+ registerCheckTool({ runtime, findEntity, findEntities, useDatabase,
143
+ collection: 'layouts', logger: { debug() {} } })
144
+ }
145
+ check = async (id) => {
146
+ activate()
147
+ return JSON.parse((await invokeTool('check_entity', { id })).content[0].text)
134
148
  }
135
- const registry = new Map()
136
- runtime.options.mcp = { simpleTool: (name, _d, _s, handler) => registry.set(name, handler) }
137
- registerCheckTool({ runtime, findEntity, findEntities, useDatabase,
138
- collection: 'layouts', logger: { debug() {} } })
139
-
140
- check = async (id) => JSON.parse((await registry.get('mikser_check_entity')({ id })).content[0].text)
141
149
  })
142
150
 
143
151
  after(async () => { if (dir) await rm(dir, { recursive: true, force: true }) })
@@ -346,15 +354,19 @@ describe('mikser_check_entity: with a declared schema', () => {
346
354
  options: { schemas: { lookup: (n) => (n === 'article' ? schema : undefined), names: () => ['article'] } },
347
355
  renderers: new Map([['liquid', { parseReferences: liquid.parseReferences }]]),
348
356
  }
349
- runtime.options.layouts = {
350
- inspect: createInspect({ runtime, findEntity, findEntities, useDatabase, collection: 'layouts' }),
357
+ const activate = () => {
358
+ resetServices()
359
+ provideService('layouts', {
360
+ inspect: createInspect({ runtime, findEntity, findEntities, useDatabase, collection: 'layouts' }),
361
+ })
362
+ provideService('schemas', runtime.options.schemas)
363
+ registerCheckTool({ runtime, findEntity, findEntities, useDatabase,
364
+ collection: 'layouts', logger: { debug() {} } })
365
+ }
366
+ checkWithSchema = async (id) => {
367
+ activate()
368
+ return JSON.parse((await invokeTool('check_entity', { id })).content[0].text)
351
369
  }
352
- const registry = new Map()
353
- runtime.options.mcp = { simpleTool: (n, _d, _s, h) => registry.set(n, h) }
354
- registerCheckTool({ runtime, findEntity, findEntities, useDatabase,
355
- collection: 'layouts', logger: { debug() {} } })
356
- checkWithSchema = async (id) =>
357
- JSON.parse((await registry.get('mikser_check_entity')({ id })).content[0].text)
358
370
  })
359
371
 
360
372
  after(async () => { if (dir2) await rm(dir2, { recursive: true, force: true }) })
@@ -442,17 +454,20 @@ describe('mikser_check_entity: peer comparison', () => {
442
454
  const findEntities = async (q) => entities.filter(e =>
443
455
  Object.entries(q ?? {}).every(([k, v]) => k.split('.').reduce((o, x) => o?.[x], e) === v))
444
456
  const runtime = { options: {}, renderers: new Map() }
445
- runtime.options.layouts = { inspect: async () => ({ references: {} }) }
446
- const registry = new Map()
447
- runtime.options.mcp = { simpleTool: (n, _d, _s, h) => registry.set(n, h) }
448
- registerCheckTool({
449
- runtime, findEntity, findEntities,
450
- // No snapshots: these never render, so they are data documents.
451
- useDatabase: () => ({ handle: { prepare: () => ({ all: () => [], get: () => undefined }) } }),
452
- collection: 'layouts', logger: { debug() {} },
453
- })
454
- peerCheck = async (id) =>
455
- JSON.parse((await registry.get('mikser_check_entity')({ id })).content[0].text)
457
+ const activate = () => {
458
+ resetServices()
459
+ provideService('layouts', { inspect: async () => ({ references: {} }) })
460
+ registerCheckTool({
461
+ runtime, findEntity, findEntities,
462
+ // No snapshots: these never render, so they are data documents.
463
+ useDatabase: () => ({ handle: { prepare: () => ({ all: () => [], get: () => undefined }) } }),
464
+ collection: 'layouts', logger: { debug() {} },
465
+ })
466
+ }
467
+ peerCheck = async (id) => {
468
+ activate()
469
+ return JSON.parse((await invokeTool('check_entity', { id })).content[0].text)
470
+ }
456
471
  })
457
472
 
458
473
  it('reports a key most siblings carry and this record lacks', async () => {
package/test/unit.test.js CHANGED
@@ -6,7 +6,7 @@ import { tmpdir } from 'node:os'
6
6
  import path from 'node:path'
7
7
 
8
8
  import { layouts } from '../index.js'
9
- import { frontMatter } from 'mikser-io'
9
+ import { frontMatter, useService } from 'mikser-io'
10
10
  import { createHarness } from './_harness.js'
11
11
 
12
12
  async function withTempWorking(fn) {
@@ -30,10 +30,42 @@ describe('layouts plugin', () => {
30
30
  assert.equal(h.hooks.beforeRender.length, 1)
31
31
  assert.equal(h.hooks.complete.length, 1)
32
32
  assert.ok(h.sync.has('layouts'))
33
- // The inspect primitive is exposed at factory-eval — before any
34
- // onLoaded fires — so plugins listed after layouts in the
35
- // plugins array can see it during their own onLoaded.
36
- assert.equal(typeof h.runtime.options.layouts?.inspect, 'function')
33
+ // The inspect primitive is offered at factory-eval — before any
34
+ // onLoaded fires — so a consumer sees it during its own onLoaded
35
+ // whatever the plugin order.
36
+ assert.equal(typeof useService('layouts')?.inspect, 'function')
37
+ })
38
+
39
+ it('puts the layouts folder where --layouts says', async () => {
40
+ // The flag could not exist while runtime.options.layouts held the
41
+ // inspect API: commander names an option after its long flag, so this
42
+ // would have overwritten that object after the load phase and broken
43
+ // a consumer somewhere else entirely.
44
+ await withTempWorking(async (workingFolder) => {
45
+ const h = createHarness({ options: {
46
+ workingFolder, outputFolder: path.join(workingFolder, 'out'), layouts: 'tmpl' } })
47
+ layouts()(h.core)
48
+ await h.runHook('loaded')
49
+ assert.equal(h.runtime.options.layoutsFolder, path.join(workingFolder, 'tmpl'))
50
+ })
51
+ })
52
+
53
+ it('lets --layouts win over the config, and takes an absolute path as given', async () => {
54
+ await withTempWorking(async (workingFolder) => {
55
+ const h = createHarness({ options: {
56
+ workingFolder, outputFolder: path.join(workingFolder, 'out'), layouts: 'from-cli' } })
57
+ layouts({ layoutsFolder: 'from-config' })(h.core)
58
+ await h.runHook('loaded')
59
+ assert.equal(h.runtime.options.layoutsFolder, path.join(workingFolder, 'from-cli'))
60
+ })
61
+ await withTempWorking(async (workingFolder) => {
62
+ const absolute = path.join(workingFolder, 'elsewhere')
63
+ const h = createHarness({ options: {
64
+ workingFolder, outputFolder: path.join(workingFolder, 'out'), layouts: absolute } })
65
+ layouts()(h.core)
66
+ await h.runHook('loaded')
67
+ assert.equal(h.runtime.options.layoutsFolder, absolute)
68
+ })
37
69
  })
38
70
 
39
71
  it('initializes runtime.state.layouts on onLoaded with empty maps', async () => {