mikser-io-layouts 2.15.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)
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.15.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 () => {