mikser-io-layouts 2.6.1 → 2.7.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/lib/assembly.js +25 -4
- package/lib/inspect.js +191 -2
- package/lib/matching.js +3 -5
- package/lib/mcp.js +18 -2
- package/package.json +2 -2
- package/test/contract.test.js +185 -0
- package/test/unit.test.js +9 -3
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,165 @@ 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
|
+
const meta = [...consumes]
|
|
324
|
+
.filter(p => p.startsWith('data.meta.'))
|
|
325
|
+
.map(p => p.slice('data.meta.'.length))
|
|
326
|
+
.filter(Boolean)
|
|
327
|
+
|
|
328
|
+
return {
|
|
329
|
+
available: true,
|
|
330
|
+
// False whenever ANY branch could not be read. A contract that looks
|
|
331
|
+
// complete but silently omits a partial is the failure this exists to
|
|
332
|
+
// prevent, so the flag leads and the reasons are named.
|
|
333
|
+
complete: incomplete.length === 0,
|
|
334
|
+
meta: [...new Set(meta)].sort(),
|
|
335
|
+
consumes: [...consumes].sort(),
|
|
336
|
+
templates,
|
|
337
|
+
incomplete,
|
|
338
|
+
}
|
|
339
|
+
}
|
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
|
@@ -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),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mikser-io-layouts",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.7.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,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')
|