mikser-io-layouts 2.6.1 → 2.8.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 +1 -1
- package/lib/assembly.js +25 -4
- package/lib/inspect.js +199 -2
- package/lib/matching.js +3 -5
- package/lib/mcp.js +221 -3
- package/package.json +2 -2
- package/test/check-entity.test.js +150 -0
- package/test/contract.test.js +185 -0
- package/test/unit.test.js +9 -3
package/index.js
CHANGED
|
@@ -102,7 +102,7 @@ export function layouts(userOptions = {}) {
|
|
|
102
102
|
// in the plugins array for this to fire; that constraint is
|
|
103
103
|
// documented in mikser-io's CLAUDE.md.
|
|
104
104
|
onLoaded(async () => {
|
|
105
|
-
registerMcpTools({ runtime, useLogger })
|
|
105
|
+
registerMcpTools({ runtime, useLogger, findEntity, findEntities, useDatabase, collection })
|
|
106
106
|
})
|
|
107
107
|
|
|
108
108
|
onSync(collection, async ({ action, context }) => {
|
package/lib/assembly.js
CHANGED
|
@@ -23,6 +23,7 @@ import _ from 'lodash'
|
|
|
23
23
|
import {
|
|
24
24
|
inputHashOf,
|
|
25
25
|
createTrack,
|
|
26
|
+
recordReads,
|
|
26
27
|
queryContext,
|
|
27
28
|
useDatabase,
|
|
28
29
|
} from 'mikser-io'
|
|
@@ -248,7 +249,13 @@ export function createOnBeforeRender({
|
|
|
248
249
|
// invalidations when a newly-added entity should make the
|
|
249
250
|
// listing change. partial slot disabled — sidecars don't
|
|
250
251
|
// load partials themselves.
|
|
251
|
-
|
|
252
|
+
// `meta: true` records which of the entity's own meta keys
|
|
253
|
+
// the sidecar reads. This is the half of a layout's contract
|
|
254
|
+
// that static parsing structurally cannot reach: a sidecar is
|
|
255
|
+
// plain JavaScript, so `row.meta?.hero?.tags` has no syntax
|
|
256
|
+
// for any template parser to find. Observing the read is the
|
|
257
|
+
// only way to know the key is required.
|
|
258
|
+
const sidecarTrack = createTrack({ partial: false, meta: true })
|
|
252
259
|
if (existsSync(sidecarPath)) {
|
|
253
260
|
try {
|
|
254
261
|
// Stamp = the shared sidecar digest, so the URL
|
|
@@ -270,9 +277,21 @@ export function createOnBeforeRender({
|
|
|
270
277
|
}
|
|
271
278
|
if (load) {
|
|
272
279
|
try {
|
|
280
|
+
// The sidecar sees a read-recording view of the
|
|
281
|
+
// entity. Only `meta` is wrapped — id, name and uri
|
|
282
|
+
// are plumbing, and recording them as content keys
|
|
283
|
+
// would bury the ones that matter.
|
|
284
|
+
//
|
|
285
|
+
// Prefixed `meta.` rather than `data.meta.`: these
|
|
286
|
+
// are the DOCUMENT's own keys, read before any
|
|
287
|
+
// transform the sidecar applies, while the template
|
|
288
|
+
// reads whatever the sidecar hands back.
|
|
289
|
+
const observed = entity?.meta
|
|
290
|
+
? { ...entity, meta: recordReads(entity.meta, 'meta', sidecarTrack.metaRead) }
|
|
291
|
+
: entity
|
|
273
292
|
data = await queryContext.run(
|
|
274
293
|
{ entityId: entity.id, track: sidecarTrack },
|
|
275
|
-
() => load({ entity, findEntity, findEntities, runtime, signal }),
|
|
294
|
+
() => load({ entity: observed, findEntity, findEntities, runtime, signal }),
|
|
276
295
|
)
|
|
277
296
|
} catch (err) {
|
|
278
297
|
logger.error('Layout sidecar %s load() threw: %s', sidecarPath.replace(runtime.options.workingFolder + '/', ''), err.message)
|
|
@@ -361,7 +380,8 @@ export function createOnBeforeRender({
|
|
|
361
380
|
postprocessor: entity.layout.postprocessor,
|
|
362
381
|
postprocessors: entity.layout.postprocessors ?? (entity.layout.postprocessor ? [entity.layout.postprocessor] : []),
|
|
363
382
|
tasks: entity.meta?.task || TASKS.INLINE,
|
|
364
|
-
}, { data, plugins, sidecarQueries: sidecarTrack.queries
|
|
383
|
+
}, { data, plugins, sidecarQueries: sidecarTrack.queries,
|
|
384
|
+
sidecarMetaReads: [...(sidecarTrack.metaReads ?? [])] })
|
|
365
385
|
}
|
|
366
386
|
}
|
|
367
387
|
} else {
|
|
@@ -388,7 +408,8 @@ export function createOnBeforeRender({
|
|
|
388
408
|
// when matching entities are added/modified/
|
|
389
409
|
// deleted. The paginated branch above already
|
|
390
410
|
// does this.
|
|
391
|
-
{ data, plugins, sidecarQueries: sidecarTrack.queries
|
|
411
|
+
{ data, plugins, sidecarQueries: sidecarTrack.queries,
|
|
412
|
+
sidecarMetaReads: [...(sidecarTrack.metaReads ?? [])] })
|
|
392
413
|
}
|
|
393
414
|
}
|
|
394
415
|
} // end per-layout for-loop
|
package/lib/inspect.js
CHANGED
|
@@ -65,6 +65,10 @@ export function createInspect({ runtime, findEntity, findEntities, useDatabase,
|
|
|
65
65
|
sampleEntities, useDatabase, layoutName: layout.name,
|
|
66
66
|
})
|
|
67
67
|
|
|
68
|
+
const contract = await buildContract({
|
|
69
|
+
layout, templateSource, runtime, findEntities, collection,
|
|
70
|
+
})
|
|
71
|
+
|
|
68
72
|
return {
|
|
69
73
|
layout: {
|
|
70
74
|
id: layout.id,
|
|
@@ -78,6 +82,7 @@ export function createInspect({ runtime, findEntity, findEntities, useDatabase,
|
|
|
78
82
|
references: {
|
|
79
83
|
runtime: runtimeRefs,
|
|
80
84
|
static: staticRefs,
|
|
85
|
+
contract,
|
|
81
86
|
},
|
|
82
87
|
samples: sampleEntities,
|
|
83
88
|
}
|
|
@@ -98,7 +103,7 @@ async function collectRuntimeRefs({ sampleEntities, useDatabase, layoutName }) {
|
|
|
98
103
|
}
|
|
99
104
|
|
|
100
105
|
const stmt = db.handle.prepare(`
|
|
101
|
-
SELECT destination, refClosure, renderedAt, inputHash, outputHash
|
|
106
|
+
SELECT destination, refClosure, metaReads, renderedAt, inputHash, outputHash
|
|
102
107
|
FROM mikser_snapshots
|
|
103
108
|
WHERE id = ?
|
|
104
109
|
ORDER BY renderedAt DESC
|
|
@@ -106,6 +111,11 @@ async function collectRuntimeRefs({ sampleEntities, useDatabase, layoutName }) {
|
|
|
106
111
|
`)
|
|
107
112
|
|
|
108
113
|
const perSample = []
|
|
114
|
+
// Union across samples. One document exercises one set of branches, so the
|
|
115
|
+
// keys a layout needs are only visible across several of them — which is
|
|
116
|
+
// also why this can never be a complete answer on its own, and why the
|
|
117
|
+
// static closure sits beside it.
|
|
118
|
+
const observed = new Set()
|
|
109
119
|
for (const sample of sampleEntities) {
|
|
110
120
|
const row = stmt.get(sample.id)
|
|
111
121
|
if (!row) {
|
|
@@ -116,6 +126,11 @@ async function collectRuntimeRefs({ sampleEntities, useDatabase, layoutName }) {
|
|
|
116
126
|
try {
|
|
117
127
|
refClosure = row.refClosure ? JSON.parse(row.refClosure) : []
|
|
118
128
|
} catch { /* malformed JSON: surface empty rather than throw */ }
|
|
129
|
+
let metaReads = []
|
|
130
|
+
try {
|
|
131
|
+
metaReads = row.metaReads ? JSON.parse(row.metaReads) : []
|
|
132
|
+
} catch { /* same: an unreadable column is not worth throwing over */ }
|
|
133
|
+
for (const path of metaReads) observed.add(path)
|
|
119
134
|
perSample.push({
|
|
120
135
|
entityId: sample.id,
|
|
121
136
|
destination: row.destination,
|
|
@@ -123,6 +138,7 @@ async function collectRuntimeRefs({ sampleEntities, useDatabase, layoutName }) {
|
|
|
123
138
|
inputHash: row.inputHash,
|
|
124
139
|
outputHash: row.outputHash,
|
|
125
140
|
refClosure,
|
|
141
|
+
metaReads,
|
|
126
142
|
})
|
|
127
143
|
}
|
|
128
144
|
|
|
@@ -130,7 +146,18 @@ async function collectRuntimeRefs({ sampleEntities, useDatabase, layoutName }) {
|
|
|
130
146
|
if (!everRendered) {
|
|
131
147
|
return { available: false, reason: `No sample entity using "${layoutName}" has rendered yet` }
|
|
132
148
|
}
|
|
133
|
-
return {
|
|
149
|
+
return {
|
|
150
|
+
available: true,
|
|
151
|
+
samples: perSample,
|
|
152
|
+
// Meta keys these renders ACTUALLY read, union of the samples.
|
|
153
|
+
//
|
|
154
|
+
// The counterpart to references.contract, and deliberately not merged
|
|
155
|
+
// with it: the contract says what the templates COULD read, this says
|
|
156
|
+
// what was read. Only this one sees a sidecar, because a sidecar is
|
|
157
|
+
// plain JavaScript; only the contract sees a branch no sample took.
|
|
158
|
+
// Reported apart so a caller can tell which of the two it is trusting.
|
|
159
|
+
metaReads: [...observed].sort(),
|
|
160
|
+
}
|
|
134
161
|
}
|
|
135
162
|
|
|
136
163
|
// Wrap the renderer's parser so a thrown error in one engine doesn't
|
|
@@ -148,3 +175,173 @@ function safeParseReferences(renderer, source, templateName) {
|
|
|
148
175
|
return { available: false, reason: `Renderer "${templateName}" parseReferences() threw: ${err.message}` }
|
|
149
176
|
}
|
|
150
177
|
}
|
|
178
|
+
|
|
179
|
+
// The contract of a layout AND everything it pulls in.
|
|
180
|
+
//
|
|
181
|
+
// `references.static` answers "what does this FILE mention", which is one file
|
|
182
|
+
// deep and in that file's own vocabulary. Neither is what an author needs. A
|
|
183
|
+
// page layout is mostly `{% include %}`, so its own file mentions almost
|
|
184
|
+
// nothing; and a section that opens `{% assign hero = data.meta.hero %}` then
|
|
185
|
+
// talks about `hero.tags`, which names nothing the author can write.
|
|
186
|
+
//
|
|
187
|
+
// So this walks the whole tree and rewrites every path back into the caller's
|
|
188
|
+
// terms. `hero.tags` inside sections/hero.liquid, reached through
|
|
189
|
+
// `{% render 'ui/tags', tags: hero.tags %}`, comes back out as
|
|
190
|
+
// `data.meta.hero.tags` — which IS the key in the document's front matter.
|
|
191
|
+
//
|
|
192
|
+
// Engine-agnostic by construction: it never looks at template syntax, only at
|
|
193
|
+
// what each renderer's own parseReferences() reports. An engine that exposes no
|
|
194
|
+
// parser contributes nothing and SAYS SO, in `incomplete`. A partial-looking
|
|
195
|
+
// contract reported as complete would be worse than no contract at all, which
|
|
196
|
+
// is the whole reason the caps below are reported rather than silently applied.
|
|
197
|
+
const MAX_DEPTH = 12
|
|
198
|
+
const MAX_TEMPLATES = 200
|
|
199
|
+
|
|
200
|
+
async function buildContract({ layout, templateSource, runtime, findEntities, collection }) {
|
|
201
|
+
const consumes = new Set()
|
|
202
|
+
const templates = []
|
|
203
|
+
const incomplete = []
|
|
204
|
+
const visited = new Set()
|
|
205
|
+
|
|
206
|
+
// A path rewritten through the substitutions in scope. Only the FIRST
|
|
207
|
+
// segment can be an alias — `hero.tags` where `hero` is bound to
|
|
208
|
+
// `data.meta.hero` is `data.meta.hero.tags`; the tail is property access on
|
|
209
|
+
// whatever that resolved to and never needs rewriting.
|
|
210
|
+
const resolve = (path, subs) => {
|
|
211
|
+
if (!path) return path
|
|
212
|
+
const [head, ...rest] = String(path).split('.')
|
|
213
|
+
// The head may carry the element marker a parser adds for a loop
|
|
214
|
+
// variable: `{% for t in tags %}{{ t.label }}` reports `tags[].label`.
|
|
215
|
+
// The binding is keyed by the plain name, so the marker comes off for
|
|
216
|
+
// the lookup and goes back on after it — otherwise every key read off
|
|
217
|
+
// a loop item stops dead at the partial boundary, which is precisely
|
|
218
|
+
// the hop this exists to cross.
|
|
219
|
+
const isElement = head.endsWith('[]')
|
|
220
|
+
const base = subs[isElement ? head.slice(0, -2) : head]
|
|
221
|
+
if (!base) return path
|
|
222
|
+
return [isElement ? `${base}[]` : base, ...rest].join('.')
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const layoutNamed = async (name) => {
|
|
226
|
+
const rows = (await findEntities({ name })) ?? []
|
|
227
|
+
return rows.find(e => e.collection === collection) ?? null
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function walk(name, template, source, bindings, depth) {
|
|
231
|
+
// Keyed by name AND bindings: one partial rendered with two different
|
|
232
|
+
// arguments has two different contracts, and collapsing them would
|
|
233
|
+
// lose one. Cyclic includes and runaway fan-out are bounded instead.
|
|
234
|
+
const key = `${name}|${JSON.stringify(bindings)}`
|
|
235
|
+
if (visited.has(key)) return
|
|
236
|
+
if (depth > MAX_DEPTH) {
|
|
237
|
+
incomplete.push({ template: name, reason: `include depth exceeded ${MAX_DEPTH}` })
|
|
238
|
+
return
|
|
239
|
+
}
|
|
240
|
+
if (visited.size >= MAX_TEMPLATES) {
|
|
241
|
+
incomplete.push({ template: name, reason: `stopped after ${MAX_TEMPLATES} templates` })
|
|
242
|
+
return
|
|
243
|
+
}
|
|
244
|
+
visited.add(key)
|
|
245
|
+
|
|
246
|
+
const renderer = runtime.renderers?.get(template)
|
|
247
|
+
if (typeof renderer?.parseReferences !== 'function') {
|
|
248
|
+
incomplete.push({
|
|
249
|
+
template: name,
|
|
250
|
+
reason: renderer
|
|
251
|
+
? `renderer "${template}" exposes no parseReferences(), so nothing under it was read`
|
|
252
|
+
: `no renderer registered for template "${template}"`,
|
|
253
|
+
})
|
|
254
|
+
return
|
|
255
|
+
}
|
|
256
|
+
const parsed = safeParseReferences(renderer, source, template)
|
|
257
|
+
if (!parsed.available || parsed.parseError) {
|
|
258
|
+
incomplete.push({ template: name, reason: parsed.reason ?? `parse error: ${parsed.parseError}` })
|
|
259
|
+
return
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Substitutions in scope: what this template was called with, then its
|
|
263
|
+
// own assigns layered on top — an assign can refer to an argument, so
|
|
264
|
+
// it is resolved against what is known so far.
|
|
265
|
+
const subs = { ...bindings }
|
|
266
|
+
for (const a of parsed.assigns ?? []) {
|
|
267
|
+
if (a?.key && a.from) subs[a.key] = resolve(a.from, subs)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const resolved = []
|
|
271
|
+
for (const v of parsed.variables ?? []) {
|
|
272
|
+
const r = resolve(v, subs)
|
|
273
|
+
consumes.add(r)
|
|
274
|
+
resolved.push(r)
|
|
275
|
+
}
|
|
276
|
+
templates.push({ template: name, bindings, consumes: resolved.sort() })
|
|
277
|
+
|
|
278
|
+
for (const p of parsed.partials ?? []) {
|
|
279
|
+
// Engines that have not been taught to report arguments still
|
|
280
|
+
// report NAMES, so the tree is still walked — just without the
|
|
281
|
+
// bindings that make aliases resolvable.
|
|
282
|
+
const isRich = p && typeof p === 'object'
|
|
283
|
+
const partialName = isRich ? p.name : p
|
|
284
|
+
if (!partialName) continue
|
|
285
|
+
|
|
286
|
+
const childBindings = {}
|
|
287
|
+
if (isRich) {
|
|
288
|
+
for (const [arg, path] of Object.entries(p.args ?? {})) {
|
|
289
|
+
childBindings[arg] = resolve(path, subs)
|
|
290
|
+
}
|
|
291
|
+
for (const alias of p.aliases ?? []) {
|
|
292
|
+
if (alias?.to && alias.from) childBindings[alias.to] = resolve(alias.from, subs)
|
|
293
|
+
}
|
|
294
|
+
} else {
|
|
295
|
+
incomplete.push({
|
|
296
|
+
template: partialName,
|
|
297
|
+
reason: `renderer "${template}" reports partial names but not their arguments, `
|
|
298
|
+
+ 'so anything reached through an argument is unresolved',
|
|
299
|
+
})
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const entity = await layoutNamed(partialName)
|
|
303
|
+
if (!entity) {
|
|
304
|
+
incomplete.push({ template: partialName, reason: 'no layout entity has this name' })
|
|
305
|
+
continue
|
|
306
|
+
}
|
|
307
|
+
let childSource
|
|
308
|
+
try {
|
|
309
|
+
childSource = await readFile(entity.uri, 'utf8')
|
|
310
|
+
} catch (err) {
|
|
311
|
+
incomplete.push({ template: partialName, reason: `unreadable (${entity.uri}): ${err.message}` })
|
|
312
|
+
continue
|
|
313
|
+
}
|
|
314
|
+
await walk(partialName, entity.template, childSource, childBindings, depth + 1)
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
await walk(layout.name, layout.template, templateSource, {}, 0)
|
|
319
|
+
|
|
320
|
+
// The subset an author actually writes. Everything else in `consumes` is
|
|
321
|
+
// render-time furniture — helpers, loop variables, plugin surfaces — and
|
|
322
|
+
// is kept, but it is not what a document's front matter is checked against.
|
|
323
|
+
// Entity meta, whichever root binding reached it.
|
|
324
|
+
//
|
|
325
|
+
// A template can arrive at the same keys by more than one name: `data` is
|
|
326
|
+
// whatever a layout sidecar returned, and the entity is ALSO bound under
|
|
327
|
+
// its own type, so `document.meta.title` and `data.meta.title` are the same
|
|
328
|
+
// document key. Matching only `data.` was lmed-shaped — a project with no
|
|
329
|
+
// sidecar writes `document.meta.` and would have got an empty contract,
|
|
330
|
+
// which reads exactly like a layout that needs nothing.
|
|
331
|
+
const META_ROOT = /^[A-Za-z_$][\w$]*\.meta\./
|
|
332
|
+
const meta = [...consumes]
|
|
333
|
+
.map(p => (META_ROOT.test(p) ? p.replace(META_ROOT, '') : (p.startsWith('meta.') ? p.slice(5) : null)))
|
|
334
|
+
.filter(Boolean)
|
|
335
|
+
|
|
336
|
+
return {
|
|
337
|
+
available: true,
|
|
338
|
+
// False whenever ANY branch could not be read. A contract that looks
|
|
339
|
+
// complete but silently omits a partial is the failure this exists to
|
|
340
|
+
// prevent, so the flag leads and the reasons are named.
|
|
341
|
+
complete: incomplete.length === 0,
|
|
342
|
+
meta: [...new Set(meta)].sort(),
|
|
343
|
+
consumes: [...consumes].sort(),
|
|
344
|
+
templates,
|
|
345
|
+
incomplete,
|
|
346
|
+
}
|
|
347
|
+
}
|
package/lib/matching.js
CHANGED
|
@@ -22,7 +22,7 @@ import path from 'node:path'
|
|
|
22
22
|
|
|
23
23
|
import _ from 'lodash'
|
|
24
24
|
import { primaryDestination } from './destination.js'
|
|
25
|
-
import {
|
|
25
|
+
import { isFullCycle } from 'mikser-io'
|
|
26
26
|
|
|
27
27
|
export function createOnProcessed({
|
|
28
28
|
runtime, useLogger, useJournal,
|
|
@@ -69,11 +69,9 @@ export function createOnProcessed({
|
|
|
69
69
|
// So 'post' does NOT match a document named 'post'; '@/post' and
|
|
70
70
|
// '/documents/post.md' both do.
|
|
71
71
|
const onName = pattern.startsWith('@/')
|
|
72
|
-
reportWarning('layout-pattern-no-match', {
|
|
73
|
-
pattern, layout: options.match[pattern], evaluated: matchTally.evaluated,
|
|
74
|
-
matchedAgainst: onName ? 'name' : 'id',
|
|
75
|
-
})
|
|
76
72
|
logger.warn(
|
|
73
|
+
{ code: 'layout-pattern-no-match', pattern, layout: options.match[pattern],
|
|
74
|
+
evaluated: matchTally.evaluated, matchedAgainst: onName ? 'name' : 'id' },
|
|
77
75
|
'Layout pattern %j (→ %j) matched none of the %d entities evaluated. ' +
|
|
78
76
|
'It is matched against entity.%s%s.',
|
|
79
77
|
pattern, options.match[pattern], matchTally.evaluated,
|
package/lib/mcp.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { z } from 'zod'
|
|
12
12
|
|
|
13
|
-
export function registerMcpTools({ runtime, useLogger }) {
|
|
13
|
+
export function registerMcpTools({ runtime, useLogger, findEntity, findEntities, useDatabase, collection }) {
|
|
14
14
|
const mcp = runtime.options.mcp
|
|
15
15
|
if (!mcp) return // mcp plugin not loaded — nothing to register
|
|
16
16
|
if (!runtime.options.layouts?.inspect) return // shouldn't happen if we run after our own inspect setup, but defensive
|
|
@@ -19,7 +19,19 @@ export function registerMcpTools({ runtime, useLogger }) {
|
|
|
19
19
|
|
|
20
20
|
mcp.simpleTool(
|
|
21
21
|
'mikser_layouts_inspect',
|
|
22
|
-
'Inspect a layout
|
|
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).',
|
|
23
35
|
{
|
|
24
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.'),
|
|
25
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.'),
|
|
@@ -33,8 +45,12 @@ export function registerMcpTools({ runtime, useLogger }) {
|
|
|
33
45
|
text: JSON.stringify({
|
|
34
46
|
...result,
|
|
35
47
|
notes: [
|
|
48
|
+
'references.contract is the one to read first: the whole layout tree walked, with partial arguments and renamings resolved, so `meta` lists document keys in the form a document writes them. `[]` marks an element — `hero.tags[].label` means each tag has a label, not that the list does.',
|
|
49
|
+
'references.contract.complete is false when any branch could not be read, and `incomplete` names each with a reason. An incomplete contract is still useful, but a key missing from it may only be missing because that branch was unreadable — do not treat absence as proof.',
|
|
50
|
+
'references.contract covers TEMPLATES. A layout sidecar is plain JavaScript and cannot be parsed, so a key only it reads will not appear there — look in references.runtime.metaReads instead.',
|
|
51
|
+
'references.runtime.metaReads is what renders actually READ, sidecars included, unioned across the samples. It sees a sidecar the contract cannot; it does not see a branch no sample took. The two are complements, not alternatives.',
|
|
36
52
|
'references.runtime is the precise answer from manifest snapshots — what the renderer actually touched in recent renders. Empty if the layout has never rendered.',
|
|
37
|
-
'references.static is the renderer-plugin
|
|
53
|
+
'references.static is the renderer-plugin walk of THIS FILE only, in this file\'s own vocabulary. references.contract is the same walk followed across every partial and resolved into the author\'s. Prefer the contract.',
|
|
38
54
|
'samples only includes entities with explicit meta.layout. Auto-matched layouts are not listed; use mikser_query_entities with a filename-pattern filter for those.',
|
|
39
55
|
],
|
|
40
56
|
}, null, 2),
|
|
@@ -50,5 +66,207 @@ export function registerMcpTools({ runtime, useLogger }) {
|
|
|
50
66
|
},
|
|
51
67
|
)
|
|
52
68
|
|
|
69
|
+
registerCheckTool({ runtime, findEntity, findEntities, useDatabase, collection, logger })
|
|
70
|
+
|
|
53
71
|
logger.debug('MCP tool registered: mikser_layouts_inspect (layouts plugin)')
|
|
54
72
|
}
|
|
73
|
+
|
|
74
|
+
// Meta keys the ENGINE owns. They appear on documents, are consumed by mikser
|
|
75
|
+
// itself rather than by any layout, and would otherwise be reported as unused on
|
|
76
|
+
// every entity — noise that teaches a reader to ignore the list.
|
|
77
|
+
const ENGINE_KEYS = new Set([
|
|
78
|
+
'layout', 'layouts', 'task', 'destination', 'format', 'template', 'href',
|
|
79
|
+
'lang', 'url', 'pages', 'parent', 'plugins', 'presets', 'refs', 'date',
|
|
80
|
+
'draft', 'stamp', 'postprocessor',
|
|
81
|
+
])
|
|
82
|
+
|
|
83
|
+
// A document's meta in the same vocabulary a layout contract uses: dotted
|
|
84
|
+
// paths, with `[]` for "each element of" rather than an index. Both sides have
|
|
85
|
+
// to speak it or the comparison is meaningless.
|
|
86
|
+
function flattenMeta(value, prefix, out) {
|
|
87
|
+
if (value === null || typeof value !== 'object') return out
|
|
88
|
+
if (Array.isArray(value)) {
|
|
89
|
+
// One `[]` for the whole array: the contract says "each tag has a
|
|
90
|
+
// label", never "tag 7 has a label", so element shapes are merged.
|
|
91
|
+
if (value.length) out.add(`${prefix}[]`)
|
|
92
|
+
for (const item of value) flattenMeta(item, `${prefix}[]`, out)
|
|
93
|
+
return out
|
|
94
|
+
}
|
|
95
|
+
for (const [k, v] of Object.entries(value)) {
|
|
96
|
+
const path = prefix ? `${prefix}.${k}` : k
|
|
97
|
+
out.add(path)
|
|
98
|
+
flattenMeta(v, path, out)
|
|
99
|
+
}
|
|
100
|
+
return out
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Observed reads carry the prefix of where they were seen — `meta.` for the
|
|
104
|
+
// document's own keys as a sidecar read them, `data.meta.` for what a template
|
|
105
|
+
// read. Both describe the same document, so both reduce to the bare key.
|
|
106
|
+
const bareKey = (p) => p.replace(/^[A-Za-z_$][\w$]*\.meta\./, '').replace(/^meta\./, '')
|
|
107
|
+
|
|
108
|
+
// Edit distance, capped: only used to ask "did they mean this one", so anything
|
|
109
|
+
// past a couple of edits is not a typo and the answer stops mattering.
|
|
110
|
+
function distance(a, b) {
|
|
111
|
+
if (Math.abs(a.length - b.length) > 3) return 99
|
|
112
|
+
const prev = Array.from({ length: b.length + 1 }, (_, i) => i)
|
|
113
|
+
for (let i = 1; i <= a.length; i++) {
|
|
114
|
+
let last = prev[0]
|
|
115
|
+
prev[0] = i
|
|
116
|
+
for (let j = 1; j <= b.length; j++) {
|
|
117
|
+
const tmp = prev[j]
|
|
118
|
+
prev[j] = Math.min(
|
|
119
|
+
prev[j] + 1,
|
|
120
|
+
prev[j - 1] + 1,
|
|
121
|
+
last + (a[i - 1] === b[j - 1] ? 0 : 1),
|
|
122
|
+
)
|
|
123
|
+
last = tmp
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return prev[b.length]
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function registerCheckTool({ runtime, findEntity, findEntities, useDatabase, collection, logger }) {
|
|
130
|
+
const mcp = runtime.options.mcp
|
|
131
|
+
|
|
132
|
+
// Which layout this entity ACTUALLY rendered with, taken from its most
|
|
133
|
+
// recent snapshot rather than predicted.
|
|
134
|
+
//
|
|
135
|
+
// Layout selection has three mechanisms — declared meta.layout, a match
|
|
136
|
+
// pattern, and the auto-layout peel ladder — and re-deriving them here
|
|
137
|
+
// would be a second implementation to keep in step with the first. The
|
|
138
|
+
// manifest already recorded the answer.
|
|
139
|
+
const layoutFromSnapshot = (id) => {
|
|
140
|
+
const db = useDatabase?.()
|
|
141
|
+
if (!db?.handle) return null
|
|
142
|
+
const row = db.handle.prepare(
|
|
143
|
+
'SELECT refClosure FROM mikser_snapshots WHERE id = ? ORDER BY renderedAt DESC LIMIT 1',
|
|
144
|
+
).get(id)
|
|
145
|
+
if (!row?.refClosure) return null
|
|
146
|
+
try {
|
|
147
|
+
return JSON.parse(row.refClosure).find(e => e.kind === 'layout')?.target ?? null
|
|
148
|
+
} catch { return null }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const readsFromSnapshot = (id) => {
|
|
152
|
+
const db = useDatabase?.()
|
|
153
|
+
if (!db?.handle) return []
|
|
154
|
+
const row = db.handle.prepare(
|
|
155
|
+
'SELECT metaReads FROM mikser_snapshots WHERE id = ? ORDER BY renderedAt DESC LIMIT 1',
|
|
156
|
+
).get(id)
|
|
157
|
+
if (!row?.metaReads) return []
|
|
158
|
+
try { return JSON.parse(row.metaReads) } catch { return [] }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
mcp.simpleTool(
|
|
162
|
+
'mikser_check_entity',
|
|
163
|
+
'Check a document against the contract of the layout that renders it, BEFORE the mistake ships.\n\n'
|
|
164
|
+
+ 'A mistyped key does not fail a build. The section it named simply does not render, the page goes out '
|
|
165
|
+
+ 'with a hole in it, and every signal reads clean. This is the check that catches it: what the layout '
|
|
166
|
+
+ 'tree needs, against what the document actually provides.\n\n'
|
|
167
|
+
+ 'Read `missing` first — keys the layout consumes that this document has no value for. Then '
|
|
168
|
+
+ '`likelyTypos`, which pairs a key you wrote against a key the layout wanted that is one or two edits '
|
|
169
|
+
+ 'away; a typo usually shows up as both at once, which is a far stronger signal than either alone.\n\n'
|
|
170
|
+
+ '`unused` is weaker evidence and says so: another layout may consume the key, and keys the engine '
|
|
171
|
+
+ 'itself owns are excluded. Treat it as a prompt to look, not a defect.\n\n'
|
|
172
|
+
+ 'Check `reliable` before trusting an absence. When false, some branch of the layout could not be read '
|
|
173
|
+
+ 'or the document has never rendered, and a key may be missing from the analysis rather than from the '
|
|
174
|
+
+ 'document.',
|
|
175
|
+
{
|
|
176
|
+
id: z.string().describe('Entity id, e.g. "/documents/en/index.md".'),
|
|
177
|
+
layout: z.string().optional()
|
|
178
|
+
.describe('Layout id to check against. Omit to use the layout this entity last rendered with, '
|
|
179
|
+
+ 'which is what the manifest recorded rather than a prediction.'),
|
|
180
|
+
},
|
|
181
|
+
async ({ id, layout }) => {
|
|
182
|
+
const entity = await findEntity({ id })
|
|
183
|
+
if (!entity) {
|
|
184
|
+
return { isError: true, content: [{ type: 'text', text:
|
|
185
|
+
`No entity ${JSON.stringify(id)}. Use mikser_query_entities to find its id.` }] }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Resolve the layout: explicit, then what it rendered with, then
|
|
189
|
+
// what it asked for. Each fallback is weaker, and which one was
|
|
190
|
+
// used is reported so the caller can judge.
|
|
191
|
+
let layoutId = layout ?? layoutFromSnapshot(id)
|
|
192
|
+
let source = layout ? 'given' : (layoutId ? 'last render' : null)
|
|
193
|
+
if (!layoutId && entity.meta?.layout) {
|
|
194
|
+
const named = (await findEntities({ name: entity.meta.layout })) ?? []
|
|
195
|
+
layoutId = named.find(e => e.collection === collection)?.id ?? null
|
|
196
|
+
source = layoutId ? 'meta.layout' : null
|
|
197
|
+
}
|
|
198
|
+
if (!layoutId) {
|
|
199
|
+
return { isError: true, content: [{ type: 'text', text:
|
|
200
|
+
`Cannot tell which layout renders ${id}: it has never rendered and declares no meta.layout. `
|
|
201
|
+
+ 'Pass `layout` explicitly, or build once so the manifest records it.' }] }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const inspected = await runtime.options.layouts.inspect(layoutId, { samples: 0 })
|
|
205
|
+
const contract = inspected.references?.contract ?? { meta: [], complete: false, incomplete: [] }
|
|
206
|
+
|
|
207
|
+
// Consumed is the UNION of both halves, and it has to be: the
|
|
208
|
+
// contract is blind to a sidecar (plain JavaScript, nothing to
|
|
209
|
+
// parse) and the observed reads are blind to a branch this document
|
|
210
|
+
// never took. Using either alone reports keys as unused that are
|
|
211
|
+
// demonstrably read.
|
|
212
|
+
const observed = readsFromSnapshot(id).map(bareKey)
|
|
213
|
+
const consumed = new Set([...(contract.meta ?? []), ...observed])
|
|
214
|
+
const provided = flattenMeta(entity.meta ?? {}, '', new Set())
|
|
215
|
+
|
|
216
|
+
const isUsed = (key) => consumed.has(key)
|
|
217
|
+
// A parent is used when anything under it is: a document
|
|
218
|
+
// providing `hero` for a layout that reads `hero.title` has not
|
|
219
|
+
// provided a spare key.
|
|
220
|
+
|| [...consumed].some(c => c.startsWith(`${key}.`) || c.startsWith(`${key}[`))
|
|
221
|
+
const isProvided = (key) => provided.has(key)
|
|
222
|
+
// And the reverse: a layout reading `hero.tags[]` is satisfied
|
|
223
|
+
// by a document that provided the array.
|
|
224
|
+
|| [...provided].some(p => p.startsWith(`${key}.`) || p.startsWith(`${key}[`))
|
|
225
|
+
|
|
226
|
+
const missing = [...consumed].filter(k => k && !isProvided(k)).sort()
|
|
227
|
+
const unused = [...provided]
|
|
228
|
+
.filter(k => !isUsed(k))
|
|
229
|
+
.filter(k => !ENGINE_KEYS.has(k.split('.')[0].replace(/\[\]$/, '')))
|
|
230
|
+
.sort()
|
|
231
|
+
|
|
232
|
+
// The pair. A mistyped key leaves a hole where the real key was
|
|
233
|
+
// wanted AND a stray key nothing reads, so matching one list
|
|
234
|
+
// against the other turns two weak signals into one strong one.
|
|
235
|
+
const likelyTypos = []
|
|
236
|
+
for (const wrote of unused) {
|
|
237
|
+
let best = null
|
|
238
|
+
for (const wanted of missing) {
|
|
239
|
+
const d = distance(wrote, wanted)
|
|
240
|
+
if (d > 0 && d <= 2 && (!best || d < best.distance)) best = { meant: wanted, distance: d }
|
|
241
|
+
}
|
|
242
|
+
if (best) likelyTypos.push({ wrote, ...best })
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const reliable = contract.complete === true && observed.length > 0
|
|
246
|
+
return {
|
|
247
|
+
content: [{ type: 'text', text: JSON.stringify({
|
|
248
|
+
entity: id,
|
|
249
|
+
layout: layoutId,
|
|
250
|
+
layoutFrom: source,
|
|
251
|
+
reliable,
|
|
252
|
+
missing,
|
|
253
|
+
likelyTypos,
|
|
254
|
+
unused,
|
|
255
|
+
counts: { consumed: consumed.size, provided: provided.size,
|
|
256
|
+
missing: missing.length, unused: unused.length },
|
|
257
|
+
contract: { complete: contract.complete, incomplete: contract.incomplete ?? [] },
|
|
258
|
+
observedReads: observed.length,
|
|
259
|
+
notes: [
|
|
260
|
+
'missing is the strong signal: the layout consumes the key and this document has no value for it.',
|
|
261
|
+
'likelyTypos pairs an unused key against a missing one within two edits. A real typo usually produces both, which is why the pair is worth more than either list.',
|
|
262
|
+
'unused is weak evidence. A key may be consumed by a DIFFERENT layout — a card rendered on a listing page is the common case — and keys the engine owns (href, lang, layout, task, ...) are excluded outright.',
|
|
263
|
+
'reliable is false when the contract could not be fully read, or when this entity has never rendered so nothing was observed. An absence is only evidence when reliable is true.',
|
|
264
|
+
'Coverage is the union of what the layout templates read (the contract) and what renders actually read (observed, which includes sidecars). Neither sees everything alone.',
|
|
265
|
+
],
|
|
266
|
+
}, null, 2) }],
|
|
267
|
+
}
|
|
268
|
+
},
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
logger.debug('MCP tool registered: mikser_check_entity (layouts plugin)')
|
|
272
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mikser-io-layouts",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.8.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.
|
|
21
|
+
"mikser-io": "^9.39.0",
|
|
22
22
|
"zod": "^4.0.0"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// Checking a document against the contract of the layout that renders it.
|
|
2
|
+
//
|
|
3
|
+
// A mistyped key does not fail a build. The section it named simply does not
|
|
4
|
+
// render, the page ships with a hole, and every signal reads clean. This is the
|
|
5
|
+
// check that catches it before it ships, and it exists because the two halves it
|
|
6
|
+
// needs — the layout contract and what renders actually read — are useless
|
|
7
|
+
// separately: the contract is blind to a sidecar, and observed reads are blind
|
|
8
|
+
// to a branch this document never took.
|
|
9
|
+
|
|
10
|
+
import { describe, it, before, after } from 'node:test'
|
|
11
|
+
import assert from 'node:assert/strict'
|
|
12
|
+
import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises'
|
|
13
|
+
import { tmpdir } from 'node:os'
|
|
14
|
+
import path from 'node:path'
|
|
15
|
+
|
|
16
|
+
import { createInspect } from '../lib/inspect.js'
|
|
17
|
+
import { registerCheckTool } from '../lib/mcp.js'
|
|
18
|
+
import * as liquid from 'mikser-io-render-liquid'
|
|
19
|
+
|
|
20
|
+
const LAYOUTS = {
|
|
21
|
+
'page.liquid': '<html>{% include "sections/hero" %}<p>{{ document.meta.summary }}</p></html>',
|
|
22
|
+
'sections/hero.liquid':
|
|
23
|
+
'{%- assign hero = document.meta.hero -%}<h1>{{ hero.title }}</h1>'
|
|
24
|
+
+ '{% for t in hero.tags %}<i>{{ t.label }}</i>{% endfor %}',
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const DOCS = {
|
|
28
|
+
'/documents/good.md': { summary: 'S', hero: { title: 'T', tags: [{ label: 'A' }] } },
|
|
29
|
+
// `heroo` instead of `hero`: nothing under it is read, and everything the
|
|
30
|
+
// layout wanted is absent. One mistake, visible from both sides.
|
|
31
|
+
'/documents/typo.md': { summary: 'S', heroo: { title: 'T' } },
|
|
32
|
+
// Provides a key no layout here consumes. Weak evidence on purpose.
|
|
33
|
+
'/documents/extra.md': { summary: 'S', hero: { title: 'T', tags: [{ label: 'A' }] }, card: { order: 2 } },
|
|
34
|
+
// Engine-owned keys must never be reported as unused.
|
|
35
|
+
'/documents/routing.md': { summary: 'S', href: '/r', lang: 'bg', task: 'worker',
|
|
36
|
+
hero: { title: 'T', tags: [{ label: 'A' }] } },
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let dir, check
|
|
40
|
+
|
|
41
|
+
before(async () => {
|
|
42
|
+
dir = await mkdtemp(path.join(tmpdir(), 'mikser-check-'))
|
|
43
|
+
const entities = []
|
|
44
|
+
for (const [rel, content] of Object.entries(LAYOUTS)) {
|
|
45
|
+
const uri = path.join(dir, rel)
|
|
46
|
+
await mkdir(path.dirname(uri), { recursive: true })
|
|
47
|
+
await writeFile(uri, content)
|
|
48
|
+
entities.push({ id: `/layouts/${rel}`, name: rel.replace(/\.liquid$/, ''), uri,
|
|
49
|
+
collection: 'layouts', template: 'liquid' })
|
|
50
|
+
}
|
|
51
|
+
for (const [id, meta] of Object.entries(DOCS)) {
|
|
52
|
+
entities.push({ id, name: id.slice(11), collection: 'documents', meta })
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// A stand-in manifest. Every document rendered with page.liquid, and read
|
|
56
|
+
// what a correct one would — so `unused` and `missing` are exercised
|
|
57
|
+
// without needing a real build in a unit test.
|
|
58
|
+
const observed = ['data.meta.summary', 'data.meta.hero', 'data.meta.hero.title',
|
|
59
|
+
'data.meta.hero.tags', 'data.meta.hero.tags[]', 'data.meta.hero.tags[].label']
|
|
60
|
+
const useDatabase = () => ({
|
|
61
|
+
handle: {
|
|
62
|
+
prepare: (sql) => ({
|
|
63
|
+
get: (id) => (DOCS[id]
|
|
64
|
+
? (sql.includes('refClosure')
|
|
65
|
+
? { refClosure: JSON.stringify([{ kind: 'layout', target: '/layouts/page.liquid' }]) }
|
|
66
|
+
: { metaReads: JSON.stringify(observed) })
|
|
67
|
+
: undefined),
|
|
68
|
+
}),
|
|
69
|
+
},
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
const findEntity = async ({ id }) => entities.find(e => e.id === id) ?? null
|
|
73
|
+
const findEntities = async (q) => entities.filter(e => q.name == null || e.name === q.name)
|
|
74
|
+
const runtime = {
|
|
75
|
+
options: {},
|
|
76
|
+
renderers: new Map([['liquid', { parseReferences: liquid.parseReferences }]]),
|
|
77
|
+
}
|
|
78
|
+
runtime.options.layouts = {
|
|
79
|
+
inspect: createInspect({ runtime, findEntity, findEntities, useDatabase, collection: 'layouts' }),
|
|
80
|
+
}
|
|
81
|
+
const registry = new Map()
|
|
82
|
+
runtime.options.mcp = { simpleTool: (name, _d, _s, handler) => registry.set(name, handler) }
|
|
83
|
+
registerCheckTool({ runtime, findEntity, findEntities, useDatabase,
|
|
84
|
+
collection: 'layouts', logger: { debug() {} } })
|
|
85
|
+
|
|
86
|
+
check = async (id) => JSON.parse((await registry.get('mikser_check_entity')({ id })).content[0].text)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
after(async () => { if (dir) await rm(dir, { recursive: true, force: true }) })
|
|
90
|
+
|
|
91
|
+
describe('mikser_check_entity', () => {
|
|
92
|
+
it('says nothing is wrong with a document that satisfies the layout', async () => {
|
|
93
|
+
// The assertion that matters most: no false positives. A check that
|
|
94
|
+
// cries wolf on a correct document is one nobody reads.
|
|
95
|
+
const r = await check('/documents/good.md')
|
|
96
|
+
assert.deepEqual(r.missing, [])
|
|
97
|
+
assert.deepEqual(r.unused, [])
|
|
98
|
+
assert.deepEqual(r.likelyTypos, [])
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('names the keys the layout wants and the document lacks', async () => {
|
|
102
|
+
const r = await check('/documents/typo.md')
|
|
103
|
+
assert.ok(r.missing.includes('hero.title'), `missing: ${r.missing.join(', ')}`)
|
|
104
|
+
assert.ok(r.missing.includes('hero.tags[].label'))
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('pairs the key written against the key meant', async () => {
|
|
108
|
+
// The point of the tool. A typo shows up as a hole AND a stray key, and
|
|
109
|
+
// matching one list against the other turns two weak signals into one
|
|
110
|
+
// strong one.
|
|
111
|
+
const r = await check('/documents/typo.md')
|
|
112
|
+
const pair = r.likelyTypos.find(t => t.wrote === 'heroo')
|
|
113
|
+
assert.ok(pair, `no pairing found in ${JSON.stringify(r.likelyTypos)}`)
|
|
114
|
+
assert.equal(pair.meant, 'hero')
|
|
115
|
+
assert.equal(pair.distance, 1)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('reports a key no layout reads, without calling it an error', async () => {
|
|
119
|
+
const r = await check('/documents/extra.md')
|
|
120
|
+
assert.ok(r.unused.includes('card'), `unused: ${r.unused.join(', ')}`)
|
|
121
|
+
assert.deepEqual(r.missing, [], 'an unused key is not a missing one')
|
|
122
|
+
assert.ok(r.notes.some(n => /DIFFERENT layout/.test(n)),
|
|
123
|
+
'the weakness of this signal has to travel with it')
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('never reports engine-owned keys as unused', async () => {
|
|
127
|
+
// href, lang and task are consumed by mikser, not by any layout. Listing
|
|
128
|
+
// them on every document would teach a reader to skip the list.
|
|
129
|
+
const r = await check('/documents/routing.md')
|
|
130
|
+
for (const key of ['href', 'lang', 'task']) {
|
|
131
|
+
assert.ok(!r.unused.includes(key), `${key} leaked into unused: ${r.unused.join(', ')}`)
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('counts a parent as provided when the layout reads through it', async () => {
|
|
136
|
+
const r = await check('/documents/good.md')
|
|
137
|
+
assert.ok(!r.missing.includes('hero'), 'hero is provided by providing its contents')
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('uses the layout the entity actually rendered with', async () => {
|
|
141
|
+
const r = await check('/documents/good.md')
|
|
142
|
+
assert.equal(r.layout, '/layouts/page.liquid')
|
|
143
|
+
assert.equal(r.layoutFrom, 'last render')
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('refuses an entity it cannot find rather than guessing', async () => {
|
|
147
|
+
const r = await check('/documents/nope.md').catch(() => null)
|
|
148
|
+
assert.equal(r, null, 'a missing entity must not produce a report')
|
|
149
|
+
})
|
|
150
|
+
})
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// The contract of a layout AND everything it pulls in.
|
|
2
|
+
//
|
|
3
|
+
// `references.static` reports what ONE FILE mentions, in that file's own
|
|
4
|
+
// vocabulary. For a real page layout that is close to useless: page.liquid is
|
|
5
|
+
// mostly `{% include %}` and mentions almost nothing itself, while the section
|
|
6
|
+
// that does the work opens `{% assign hero = data.meta.hero %}` and then talks
|
|
7
|
+
// about `hero.tags` — a name that appears in no document and that an author
|
|
8
|
+
// cannot write.
|
|
9
|
+
//
|
|
10
|
+
// So an agent checking a document against a layout had nothing to check
|
|
11
|
+
// against, which is how a typo'd key ships a page with a section missing.
|
|
12
|
+
//
|
|
13
|
+
// These pin the two hops that used to break the chain: a value passed AS AN
|
|
14
|
+
// ARGUMENT to a partial, and a value renamed by an alias. Both have to come
|
|
15
|
+
// back out in the author's terms — `data.meta.*` — or the contract cannot be
|
|
16
|
+
// diffed against a document's front matter.
|
|
17
|
+
|
|
18
|
+
import { describe, it, before, after } from 'node:test'
|
|
19
|
+
import assert from 'node:assert/strict'
|
|
20
|
+
import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises'
|
|
21
|
+
import { tmpdir } from 'node:os'
|
|
22
|
+
import path from 'node:path'
|
|
23
|
+
|
|
24
|
+
import { createInspect } from '../lib/inspect.js'
|
|
25
|
+
import * as liquid from 'mikser-io-render-liquid'
|
|
26
|
+
|
|
27
|
+
// page → sections/hero → ui/tags. The key under test, `hero.tags`, is reached
|
|
28
|
+
// through an alias in the middle file and an argument into the last one, so
|
|
29
|
+
// nothing but a resolved closure can find it.
|
|
30
|
+
const FILES = {
|
|
31
|
+
'page.liquid':
|
|
32
|
+
'<html><body>{% include "chrome/nav" %}'
|
|
33
|
+
+ '{% for section in data.meta.sections %}{% include "sections/hero" %}{% endfor %}'
|
|
34
|
+
+ '</body></html>',
|
|
35
|
+
'chrome/nav.liquid': '<nav>{{ data.meta.title }}</nav>',
|
|
36
|
+
'sections/hero.liquid':
|
|
37
|
+
'{%- assign hero = data.meta.hero -%}'
|
|
38
|
+
+ '<h1>{{ hero.title }}</h1>'
|
|
39
|
+
+ '{% if hero.background %}<img src="{{ hero.background }}">{% endif %}'
|
|
40
|
+
+ '{% render "ui/tags", tags: hero.tags, heading: hero.tagsTitle %}',
|
|
41
|
+
'ui/tags.liquid':
|
|
42
|
+
'<ul>{% for t in tags %}<li>{{ t.label }}<span>{{ t.icon }}</span></li>{% endfor %}</ul>',
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let dir, inspect, contract
|
|
46
|
+
|
|
47
|
+
before(async () => {
|
|
48
|
+
dir = await mkdtemp(path.join(tmpdir(), 'mikser-contract-'))
|
|
49
|
+
const entities = []
|
|
50
|
+
for (const [rel, content] of Object.entries(FILES)) {
|
|
51
|
+
const uri = path.join(dir, rel)
|
|
52
|
+
await mkdir(path.dirname(uri), { recursive: true })
|
|
53
|
+
await writeFile(uri, content)
|
|
54
|
+
const name = rel.replace(/\.liquid$/, '')
|
|
55
|
+
entities.push({ id: `/layouts/${rel}`, name, uri, collection: 'layouts', template: 'liquid', format: 'html' })
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// The real liquid parser, dispatched exactly the way render does it — by
|
|
59
|
+
// `entity.template`. Nothing here knows what liquid syntax looks like.
|
|
60
|
+
const runtime = { renderers: new Map([['liquid', { parseReferences: liquid.parseReferences }]]) }
|
|
61
|
+
|
|
62
|
+
inspect = createInspect({
|
|
63
|
+
runtime,
|
|
64
|
+
findEntity: async ({ id }) => entities.find(e => e.id === id) ?? null,
|
|
65
|
+
findEntities: async (q) => entities.filter(e => q.name == null || e.name === q.name),
|
|
66
|
+
useDatabase: () => null,
|
|
67
|
+
collection: 'layouts',
|
|
68
|
+
})
|
|
69
|
+
contract = (await inspect('/layouts/page.liquid', { samples: 0 })).references.contract
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
after(async () => { if (dir) await rm(dir, { recursive: true, force: true }) })
|
|
73
|
+
|
|
74
|
+
describe('the layout contract', () => {
|
|
75
|
+
it('follows a key through an ARGUMENT into the partial that consumes it', () => {
|
|
76
|
+
// `{% render "ui/tags", tags: hero.tags %}` — dropping the argument was
|
|
77
|
+
// the hole: ui/tags talks about `tags`, and nothing tied that back to
|
|
78
|
+
// the document key it was handed.
|
|
79
|
+
assert.ok(contract.meta.includes('hero.tags'),
|
|
80
|
+
`expected hero.tags in the contract, got: ${contract.meta.join(', ')}`)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('resolves an alias back into the vocabulary the author writes', () => {
|
|
84
|
+
// sections/hero says `hero.title`. No document has a `hero.title` at
|
|
85
|
+
// the root — it is `data.meta.hero.title`, and only the assign says so.
|
|
86
|
+
assert.ok(contract.meta.includes('hero.title'))
|
|
87
|
+
assert.ok(contract.meta.includes('hero.background'))
|
|
88
|
+
assert.ok(!contract.consumes.some(c => c === 'hero.title'),
|
|
89
|
+
'the unresolved local name must not survive into the contract')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('reaches keys two hops down, through an argument AND a loop', () => {
|
|
93
|
+
// ui/tags iterates what it was handed and reads `.label` off each item.
|
|
94
|
+
// Two rewrites away from the document: tags → hero.tags → an element.
|
|
95
|
+
assert.ok(contract.meta.includes('hero.tags[].label'),
|
|
96
|
+
`expected hero.tags[].label, got: ${contract.meta.join(', ')}`)
|
|
97
|
+
assert.ok(contract.meta.includes('hero.tags[].icon'))
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('marks an element as an element, not as the collection', () => {
|
|
101
|
+
// `hero.tags.label` would be a key that exists on no document — the
|
|
102
|
+
// label is on each tag, not on the list.
|
|
103
|
+
assert.ok(!contract.meta.includes('hero.tags.label'))
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('includes what the layout itself consumes, not only its partials', () => {
|
|
107
|
+
assert.ok(contract.meta.includes('sections'))
|
|
108
|
+
assert.ok(contract.meta.includes('title'), 'chrome/nav is walked too')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('reports itself COMPLETE when every branch was read', () => {
|
|
112
|
+
assert.equal(contract.complete, true,
|
|
113
|
+
`unexpected gaps: ${JSON.stringify(contract.incomplete)}`)
|
|
114
|
+
assert.deepEqual(contract.incomplete, [])
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('names every template it walked, so the closure is auditable', () => {
|
|
118
|
+
const walked = contract.templates.map(t => t.template).sort()
|
|
119
|
+
assert.deepEqual(walked, ['chrome/nav', 'page', 'sections/hero', 'ui/tags'])
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
// A contract that LOOKS complete but silently skipped a branch is worse than
|
|
124
|
+
// no contract: it invites an agent to trust a set difference that is missing
|
|
125
|
+
// half its right-hand side. So every gap has to be named, and `complete` has
|
|
126
|
+
// to go false the moment one exists.
|
|
127
|
+
describe('the layout contract, where it cannot see', () => {
|
|
128
|
+
const build = async (files, rendererFor) => {
|
|
129
|
+
const dir = await mkdtemp(path.join(tmpdir(), 'mikser-contract-gap-'))
|
|
130
|
+
const entities = []
|
|
131
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
132
|
+
const uri = path.join(dir, rel)
|
|
133
|
+
await mkdir(path.dirname(uri), { recursive: true })
|
|
134
|
+
await writeFile(uri, content)
|
|
135
|
+
entities.push({
|
|
136
|
+
id: `/layouts/${rel}`, name: rel.replace(/\.\w+$/, ''), uri,
|
|
137
|
+
collection: 'layouts', template: rel.endsWith('.liquid') ? 'liquid' : 'mystery',
|
|
138
|
+
format: 'html',
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
const inspect = createInspect({
|
|
142
|
+
runtime: { renderers: rendererFor },
|
|
143
|
+
findEntity: async ({ id }) => entities.find(e => e.id === id) ?? null,
|
|
144
|
+
findEntities: async (q) => entities.filter(e => q.name == null || e.name === q.name),
|
|
145
|
+
useDatabase: () => null,
|
|
146
|
+
collection: 'layouts',
|
|
147
|
+
})
|
|
148
|
+
const out = (await inspect('/layouts/page.liquid', { samples: 0 })).references.contract
|
|
149
|
+
await rm(dir, { recursive: true, force: true })
|
|
150
|
+
return out
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const liquidOnly = new Map([['liquid', { parseReferences: liquid.parseReferences }]])
|
|
154
|
+
|
|
155
|
+
it('says so when an included partial does not exist', async () => {
|
|
156
|
+
const c = await build({ 'page.liquid': '{% include "sections/absent" %}' }, liquidOnly)
|
|
157
|
+
assert.equal(c.complete, false)
|
|
158
|
+
assert.ok(c.incomplete.some(i => i.template === 'sections/absent'),
|
|
159
|
+
`expected the missing partial to be named, got ${JSON.stringify(c.incomplete)}`)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('says so when a partial is rendered by an engine that cannot be parsed', async () => {
|
|
163
|
+
// markdown and metatext are real cases: they render content, and have
|
|
164
|
+
// no parseReferences to expose. The closure must degrade loudly.
|
|
165
|
+
const c = await build({
|
|
166
|
+
'page.liquid': '{% include "sections/opaque" %}',
|
|
167
|
+
'sections/opaque.mystery': 'whatever this engine does',
|
|
168
|
+
}, new Map([
|
|
169
|
+
['liquid', { parseReferences: liquid.parseReferences }],
|
|
170
|
+
['mystery', { /* a renderer with no parser */ }],
|
|
171
|
+
]))
|
|
172
|
+
assert.equal(c.complete, false)
|
|
173
|
+
const gap = c.incomplete.find(i => i.template === 'sections/opaque')
|
|
174
|
+
assert.ok(gap, `expected the opaque partial to be named, got ${JSON.stringify(c.incomplete)}`)
|
|
175
|
+
assert.match(gap.reason, /parseReferences/)
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('still reports everything it COULD read, rather than giving up', async () => {
|
|
179
|
+
const c = await build({
|
|
180
|
+
'page.liquid': '{{ data.meta.title }}{% include "sections/absent" %}',
|
|
181
|
+
}, liquidOnly)
|
|
182
|
+
assert.equal(c.complete, false)
|
|
183
|
+
assert.ok(c.meta.includes('title'), 'a gap in one branch must not discard the others')
|
|
184
|
+
})
|
|
185
|
+
})
|
package/test/unit.test.js
CHANGED
|
@@ -158,9 +158,15 @@ describe('layouts plugin', () => {
|
|
|
158
158
|
const warnings = h.logs.filter(l =>
|
|
159
159
|
l.level === 'warn' && l.args.join(' ').includes('Layout pattern'))
|
|
160
160
|
assert.equal(warnings.length, 1, `expected one warning, got ${warnings.length}`)
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
|
|
161
|
+
// Structured first, sentence second — the call is
|
|
162
|
+
// logger.warn({ code, ... }, msg, ...values), and the fields are
|
|
163
|
+
// what the build report keys on. The harness records the args
|
|
164
|
+
// unformatted, so render the message the way the logger would.
|
|
165
|
+
const [fields, ...message] = warnings[0].args
|
|
166
|
+
assert.equal(fields.code, 'layout-pattern-no-match', 'carries the code the report is asserted on')
|
|
167
|
+
assert.equal(fields.pattern, 'nonesuch/*')
|
|
168
|
+
assert.equal(fields.matchedAgainst, 'id')
|
|
169
|
+
const text = format(...message)
|
|
164
170
|
assert.match(text, /nonesuch/, 'names the pattern that matched nothing')
|
|
165
171
|
assert.ok(!text.includes("'post'"), 'must not name the pattern that DID match')
|
|
166
172
|
assert.match(text, /matched against entity\.id/, 'names the field this pattern is matched against')
|