mikser-io-layouts 2.6.0 → 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 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
- const sidecarTrack = createTrack({ partial: false })
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 { available: true, samples: perSample }
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 { reportWarning, isFullCycle } from 'mikser-io'
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: template source, references (runtime: precise refClosure from recent renders via the manifest; static: variables / partials / iterations / helpers parsed from source by the renderer plugin), and sample entities currently targeting it. Use this to answer "what does this layout need from an entity" before drafting a preview render — saves a guess-and-render-empty cycle.',
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 AST walk of the template source — what the source mentions, including unexercised conditional branches. Empty when the renderer does not expose parseReferences (markdown / metatext / custom).',
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.6.0",
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,15 +18,16 @@
18
18
  },
19
19
  "homepage": "https://github.com/almero-digital-marketing/mikser-io-layouts#readme",
20
20
  "peerDependencies": {
21
- "mikser-io": "^9.25.0"
21
+ "mikser-io": "^9.39.0",
22
+ "zod": "^4.0.0"
22
23
  },
23
24
  "dependencies": {
24
25
  "globby": "^14.0.0",
25
26
  "handlebars": "^4.7.0",
26
- "lodash": "^4.17.0",
27
- "zod": "^4.4.3"
27
+ "lodash": "^4.17.0"
28
28
  },
29
29
  "devDependencies": {
30
- "mikser-io": "file:../mikser-io"
30
+ "mikser-io": "file:../mikser-io",
31
+ "zod": "^4.0.0"
31
32
  }
32
33
  }
@@ -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
- // The harness records (format, ...args) unformatted, so render it
162
- // the way the logger would before asserting on the message.
163
- const text = format(...warnings[0].args)
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')