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 +71 -21
- package/lib/assembly.js +74 -0
- package/lib/mcp.js +68 -62
- package/package.json +2 -2
- package/test/_harness.js +5 -0
- package/test/check-entity.test.js +42 -27
- package/test/unit.test.js +37 -5
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
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
|
|
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
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
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
|
-
//
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
|
|
208
|
-
|
|
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
|
|
315
|
-
|
|
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
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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
|
|
15
|
-
if (!
|
|
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
|
-
|
|
37
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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
|
|
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
|
|
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": "
|
|
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": "^
|
|
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
|
-
|
|
133
|
-
|
|
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
|
-
|
|
350
|
-
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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
|
|
34
|
-
// onLoaded fires — so
|
|
35
|
-
//
|
|
36
|
-
assert.equal(typeof
|
|
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 () => {
|