mikser-io-layouts 11.3.0 → 11.5.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.
@@ -23,6 +23,15 @@ on:
23
23
  jobs:
24
24
  publish:
25
25
  runs-on: ubuntu-latest
26
+ # A hung test must not cost six hours.
27
+ #
28
+ # Without this the job runs to GitHub's own limit, reporting nothing:
29
+ # a flaky teardown in one suite sat on `npm test` for seventeen
30
+ # minutes before anyone looked, and would have sat there all day.
31
+ # The longest suite in the family is a couple of minutes including
32
+ # install, so fifteen is generous and still fails while someone is
33
+ # still watching.
34
+ timeout-minutes: 15
26
35
  permissions:
27
36
  contents: read
28
37
  # The whole mechanism. Without it npm has nothing to verify.
package/README.md CHANGED
@@ -126,6 +126,33 @@ destination: '/cards/{{entity.name}}.html'
126
126
  ---
127
127
  ```
128
128
 
129
+ ### `match:` — a layout claiming its own entities
130
+
131
+ A layout can declare the pattern that selects it, instead of the config
132
+ naming it from the other side. These two are the same rule:
133
+
134
+ ```js
135
+ layouts({ match: { '@/blog/*': 'post-card' } })
136
+ ```
137
+
138
+ ```yaml
139
+ # layouts/post-card.html.hbs
140
+ ---
141
+ match: '@/blog/*' # or a list: ['@/blog/*', '@/news/*']
142
+ ---
143
+ ```
144
+
145
+ They are **peers**, not alternatives — an entity matched by a config pattern
146
+ and by a layout's own pattern gets both layouts, exactly as two config
147
+ patterns would. A document's own `meta.layout` still wins over both, and
148
+ auto-layout still only runs when nothing matched at all.
149
+
150
+ Patterns follow the same rule everywhere: `matchEntity` globs `entity.name`
151
+ only for a pattern starting `@/`; anything else globs `entity.id`, which
152
+ carries the `/<collection>/` prefix and the extension. A pattern that selects
153
+ nothing is reported once per run on a full cycle, naming the layout that
154
+ declared it.
155
+
129
156
  ```yaml
130
157
  # Pull from meta
131
158
  ---
package/lib/matching.js CHANGED
@@ -22,7 +22,22 @@ import path from 'node:path'
22
22
 
23
23
  import _ from 'lodash'
24
24
  import { primaryDestination } from './destination.js'
25
- import { isFullCycle } from 'mikser-io'
25
+ import { isFullCycle, readFrontMatter } from 'mikser-io'
26
+
27
+ // A layout as synced, with its front-matter block lifted off — attributes
28
+ // onto meta so `destination:` and `match:` still work, body as the template.
29
+ // Returns the entry untouched when there is no front matter, and when the
30
+ // block cannot be parsed: a renderer showing the author their own malformed
31
+ // YAML is more use than one silently dropping it.
32
+ function withoutFrontMatter(entry) {
33
+ const parsed = readFrontMatter(entry?.content)
34
+ if (!parsed || parsed.error) return entry
35
+ return {
36
+ ...entry,
37
+ meta: { ...(entry.meta ?? {}), ...parsed.attributes },
38
+ content: parsed.body,
39
+ }
40
+ }
26
41
 
27
42
  export function createOnProcessed({
28
43
  runtime, useLogger, useJournal,
@@ -43,9 +58,25 @@ export function createOnProcessed({
43
58
  // author cannot see.
44
59
  const matchTally = { evaluated: 0, hit: new Set(), reported: false }
45
60
 
61
+ // Patterns a LAYOUT declared for itself in its own front-matter, seen
62
+ // during the run. Recorded here rather than derived at report time
63
+ // because by then the cycle's resolved-layout reads are gone, and a
64
+ // layout deleted mid-run should not be reported as a dud pattern.
65
+ const declaredSeen = new Map()
66
+
67
+ // A pattern's identity for the tally. The same glob can legitimately
68
+ // appear in config and in a layout's front-matter; they are different
69
+ // rules and either can be the dead one.
70
+ const tallyKey = (pattern, layoutName) =>
71
+ layoutName ? `layout:${layoutName}:${pattern}` : `config:${pattern}`
72
+
46
73
  function reportUnmatchedPatterns(logger) {
47
74
  if (matchTally.reported) return
48
- const patterns = Object.keys(options.match || {})
75
+ const patterns = [
76
+ ...Object.keys(options.match || {}).map(pattern =>
77
+ ({ pattern, layout: options.match[pattern], source: null })),
78
+ ...Array.from(declaredSeen.values()),
79
+ ]
49
80
  if (!patterns.length || !matchTally.evaluated) return
50
81
  // Only when the cycle evaluated everything. An incremental run
51
82
  // re-evaluates whatever changed, so a healthy pattern matches none of
@@ -56,8 +87,8 @@ export function createOnProcessed({
56
87
  if (!isFullCycle(runtime)) return
57
88
  matchTally.reported = true
58
89
 
59
- for (const pattern of patterns) {
60
- if (matchTally.hit.has(pattern)) continue
90
+ for (const { pattern, layout: layoutName, source } of patterns) {
91
+ if (matchTally.hit.has(tallyKey(pattern, source))) continue
61
92
  // Phrased as what was observed: an incremental cycle only
62
93
  // re-evaluates changed entities, so a pattern can legitimately
63
94
  // match nothing in a run of three. The count lets the reader
@@ -69,12 +100,17 @@ export function createOnProcessed({
69
100
  // So 'post' does NOT match a document named 'post'; '@/post' and
70
101
  // '/documents/post.md' both do.
71
102
  const onName = pattern.startsWith('@/')
103
+ // Where the pattern is written decides where the fix goes, and
104
+ // the two places look nothing alike. Without this the reader of
105
+ // a front-matter typo goes hunting through the config.
106
+ const declaredIn = source ? ` declared in layouts/${source}` : ''
72
107
  logger.warn(
73
- { code: 'layout-pattern-no-match', pattern, layout: options.match[pattern],
108
+ { code: 'layout-pattern-no-match', pattern, layout: layoutName,
109
+ declaredIn: source || 'layouts.match',
74
110
  evaluated: matchTally.evaluated, matchedAgainst: onName ? 'name' : 'id' },
75
- 'Layout pattern %j (→ %j) matched none of the %d entities evaluated. ' +
111
+ 'Layout pattern %j (→ %j)%s matched none of the %d entities evaluated. ' +
76
112
  'It is matched against entity.%s%s.',
77
- pattern, options.match[pattern], matchTally.evaluated,
113
+ pattern, layoutName, declaredIn, matchTally.evaluated,
78
114
  onName ? 'name' : 'id',
79
115
  onName ? '' : " — prefix a pattern with '@/' to match entity.name instead, " +
80
116
  'since an id looks like /<collection>/<name>.<ext>')
@@ -101,7 +137,57 @@ export function createOnProcessed({
101
137
  async function resolveLayout(name) {
102
138
  const stateEntry = layouts[name]
103
139
  if (!stateEntry) return undefined
104
- return (await findEntity({ id: stateEntry.id })) || stateEntry
140
+ const fromCatalog = await findEntity({ id: stateEntry.id })
141
+ if (fromCatalog) return fromCatalog
142
+ // The catalog has not caught up — a sync race, or a setup where
143
+ // the front-matter plugin has not run. The state map holds the
144
+ // file AS SYNCED, so its content still carries the front-matter
145
+ // block, and handing that to a renderer has now failed twice in
146
+ // two different ways: Handlebars emitted the YAML into the page,
147
+ // and LiquidJS refuses to parse it at all, reporting a syntax
148
+ // error at the front-matter line and taking the layout down.
149
+ //
150
+ // Resolving from the catalog was the fix for the first. This is
151
+ // the same fix for the path that bypasses it — stripping through
152
+ // core's reader rather than a regex here, so there is one
153
+ // implementation of what front matter is.
154
+ return withoutFrontMatter(stateEntry)
155
+ }
156
+
157
+ // `match:` in a layout's own front-matter — the colocated twin of a
158
+ // `layouts({ match: … })` rule, and documented alongside
159
+ // `destination:` since before either worked. `destination` was
160
+ // implemented; this was not, and a layout carrying it matched
161
+ // nothing, logged nothing, and looked configured. Reported after it
162
+ // cost a consumer the longest detour of their session.
163
+ //
164
+ // Read through resolveLayout for the same reason destination is
165
+ // (lib/assembly.js): the state map holds the file as synced, before
166
+ // front-matter has been lifted into meta. The raw entry has no
167
+ // `meta.match` to find.
168
+ //
169
+ // Built once per cycle rather than per entity: one findEntity per
170
+ // layout over a 5-20 entry map is nothing, the same work repeated
171
+ // for every document in the catalog is not.
172
+ let declaredPatterns = null
173
+ async function layoutDeclaredPatterns() {
174
+ if (declaredPatterns) return declaredPatterns
175
+ declaredPatterns = []
176
+ for (const name of Object.keys(layouts)) {
177
+ const declared = (await resolveLayout(name))?.meta?.match
178
+ if (!declared) continue
179
+ for (const pattern of Array.isArray(declared) ? declared : [declared]) {
180
+ if (typeof pattern !== 'string' || !pattern) {
181
+ logger.warn(
182
+ 'Layout %j declares a non-string `match` (%j) — expected a glob, or an array of them.',
183
+ name, pattern)
184
+ continue
185
+ }
186
+ declaredPatterns.push({ pattern, layout: name, source: name })
187
+ declaredSeen.set(tallyKey(pattern, name), { pattern, layout: name, source: name })
188
+ }
189
+ }
190
+ return declaredPatterns
105
191
  }
106
192
 
107
193
  // Resolve the set of layouts that should render `entity` this
@@ -153,7 +239,7 @@ export function createOnProcessed({
153
239
  matchTally.evaluated++
154
240
  for (const pattern in options.match || []) {
155
241
  if (matchEntity(entity, pattern)) {
156
- matchTally.hit.add(pattern)
242
+ matchTally.hit.add(tallyKey(pattern, null))
157
243
  const name = options.match[pattern]
158
244
  const layout = await resolveLayout(name)
159
245
  if (layout && !matched.find(l => l.name === layout.name)) {
@@ -168,6 +254,22 @@ export function createOnProcessed({
168
254
  }
169
255
  }
170
256
 
257
+ // Then the patterns each layout declared for itself. A peer of
258
+ // the config rules, not a fallback: both are "a pattern claimed
259
+ // this entity", and an entity legitimately matched by one of
260
+ // each gets both layouts, exactly as two config patterns would.
261
+ for (const { pattern, layout: name, source } of await layoutDeclaredPatterns()) {
262
+ if (!matchEntity(entity, pattern)) continue
263
+ matchTally.hit.add(tallyKey(pattern, source))
264
+ const layout = await resolveLayout(name)
265
+ if (layout && !matched.find(l => l.name === layout.name)) {
266
+ // Distinguishable from a config pattern in `--explain`:
267
+ // the same glob can be written in either place, and the
268
+ // fix goes to a different file depending on which.
269
+ matched.push({ ...layout, matchedBy: `${pattern} (layouts/${name})` })
270
+ }
271
+ }
272
+
171
273
  // Auto-layout: only as a fallback when no pattern matched.
172
274
  // The peel ladder is intentionally first-wins — it's a
173
275
  // most-specific-name search, not a multi-match.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io-layouts",
3
- "version": "11.3.0",
3
+ "version": "11.5.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": "^11.1.0",
21
+ "mikser-io": "^11.10.0",
22
22
  "zod": "^4.0.0"
23
23
  },
24
24
  "dependencies": {
@@ -27,7 +27,7 @@
27
27
  "lodash": "^4.17.0"
28
28
  },
29
29
  "devDependencies": {
30
- "mikser-io": "^11.1.0",
30
+ "mikser-io": "^11.10.0",
31
31
  "mikser-io-render-liquid": "^11.0.1",
32
32
  "zod": "^4.0.0"
33
33
  }
package/test/unit.test.js CHANGED
@@ -210,6 +210,268 @@ describe('layouts plugin', () => {
210
210
  })
211
211
  })
212
212
 
213
+ it('never hands a renderer a layout with its front matter still attached', async () => {
214
+ // resolveLayout falls back to the state map when the catalog has not
215
+ // caught up, and the state map holds the file AS SYNCED. That raw
216
+ // text has failed twice, in two engines: Handlebars emitted the YAML
217
+ // into the page, and LiquidJS refuses to parse it at all — reported
218
+ // as "invalid range syntax" at line 2, the front-matter line, which
219
+ // takes the whole layout down.
220
+ //
221
+ // The catalog is deliberately EMPTY here, which is the fallback.
222
+ await withTempWorking(async (workingFolder) => {
223
+ const h = createHarness({
224
+ entities: [],
225
+ options: { workingFolder, outputFolder: path.join(workingFolder, 'out'), force: true },
226
+ })
227
+ layouts({ autoLayouts: false })(h.core)
228
+ await h.runHook('loaded')
229
+ await h.runSync('layouts', { action: 'create', context: { relativePath: 'post.hbs' } })
230
+ // The file as synced: front matter still on it.
231
+ h.runtime.state.layouts.layouts['post'].content =
232
+ '---\ndestination: "/{{ entity.name }}.html"\n---\n<h1>body</h1>\n'
233
+
234
+ const doc = { id: '/documents/hello.md', collection: 'documents', name: 'hello', format: 'md', meta: { layout: 'post' } }
235
+ h.journal.push({ id: 1, entity: doc, operation: 'create', context: {}, options: {}, output: null })
236
+ await h.runHook('processed', { aborted: false })
237
+
238
+ assert.ok(!doc.layout.content.trimStart().startsWith('---'),
239
+ `front matter reached the renderer: ${JSON.stringify(doc.layout.content.slice(0, 40))}`)
240
+ assert.match(doc.layout.content, /<h1>body<\/h1>/, 'the template survived')
241
+ })
242
+ })
243
+
244
+ it('lifts the fallback layout\'s front matter onto meta, so destination still works', async () => {
245
+ // Stripping without lifting would trade a parse error for a silently
246
+ // ignored `destination:` — the state-map entry carries no meta at all
247
+ // otherwise, which is why a front-matter `match:` saw nothing there.
248
+ await withTempWorking(async (workingFolder) => {
249
+ const h = createHarness({
250
+ entities: [],
251
+ options: { workingFolder, outputFolder: path.join(workingFolder, 'out'), force: true },
252
+ })
253
+ layouts({ autoLayouts: false })(h.core)
254
+ await h.runHook('loaded')
255
+ await h.runSync('layouts', { action: 'create', context: { relativePath: 'post.hbs' } })
256
+ h.runtime.state.layouts.layouts['post'].content =
257
+ '---\ndestination: "/cards/{{entity.name}}.html"\n---\n<h1>body</h1>\n'
258
+
259
+ const doc = { id: '/documents/hello.md', collection: 'documents', name: 'hello', format: 'md', meta: { layout: 'post' } }
260
+ h.journal.push({ id: 1, entity: doc, operation: 'create', context: {}, options: {}, output: null })
261
+ await h.runHook('processed', { aborted: false })
262
+
263
+ assert.equal(doc.layout.meta.destination, '/cards/{{entity.name}}.html')
264
+ })
265
+ })
266
+
267
+ it('leaves a malformed front-matter block alone rather than dropping it', async () => {
268
+ // Unparseable YAML shown to the author beats silently discarded.
269
+ await withTempWorking(async (workingFolder) => {
270
+ const h = createHarness({
271
+ entities: [],
272
+ options: { workingFolder, outputFolder: path.join(workingFolder, 'out'), force: true },
273
+ })
274
+ layouts({ autoLayouts: false })(h.core)
275
+ await h.runHook('loaded')
276
+ await h.runSync('layouts', { action: 'create', context: { relativePath: 'post.hbs' } })
277
+ const broken = "---\ndestination: '/{{ before (after entity.name '/') '_' }}'\n---\n<h1>body</h1>\n"
278
+ h.runtime.state.layouts.layouts['post'].content = broken
279
+
280
+ const doc = { id: '/documents/hello.md', collection: 'documents', name: 'hello', format: 'md', meta: { layout: 'post' } }
281
+ h.journal.push({ id: 1, entity: doc, operation: 'create', context: {}, options: {}, output: null })
282
+ await h.runHook('processed', { aborted: false })
283
+
284
+ assert.equal(doc.layout.content, broken, 'left as written')
285
+ })
286
+ })
287
+
288
+ it('a layout matches through `match:` in its own front-matter', async () => {
289
+ // Documented in the README beside `destination:` and never
290
+ // implemented. A layout carrying it produced no render task, logged
291
+ // nothing, and looked correctly configured — reported as the longest
292
+ // single detour of a consumer's session.
293
+ //
294
+ // The catalog entity is what carries parsed front-matter; the state
295
+ // map holds the file as synced. Seeding both is what a real build
296
+ // does, and is why reading the state map alone found no `match`.
297
+ await withTempWorking(async (workingFolder) => {
298
+ const catalog = []
299
+ const h = createHarness({
300
+ entities: catalog,
301
+ options: { workingFolder, outputFolder: path.join(workingFolder, 'out'), force: true },
302
+ })
303
+ layouts({ autoLayouts: false })(h.core)
304
+ await h.runHook('loaded')
305
+ await h.runSync('layouts', { action: 'create', context: { relativePath: 'post-card.hbs' } })
306
+ catalog.push({
307
+ id: h.runtime.state.layouts.layouts['post-card'].id,
308
+ collection: 'layouts', name: 'post-card',
309
+ meta: { match: '@/blog/*' },
310
+ })
311
+
312
+ const doc = { id: '/documents/blog/welcome.md', collection: 'documents', name: 'blog/welcome', format: 'md', meta: {} }
313
+ h.journal.push({ id: 1, entity: doc, operation: 'create', context: {}, options: {}, output: null })
314
+ await h.runHook('processed', { aborted: false })
315
+
316
+ assert.deepEqual(doc.layouts.map(l => l.name), ['post-card'])
317
+ assert.match(doc.layouts[0].matchedBy, /layouts\/post-card/,
318
+ 'explain must say the rule came from the layout, not the config')
319
+ })
320
+ })
321
+
322
+ it('a layout can declare several patterns', async () => {
323
+ await withTempWorking(async (workingFolder) => {
324
+ const catalog = []
325
+ const h = createHarness({
326
+ entities: catalog,
327
+ options: { workingFolder, outputFolder: path.join(workingFolder, 'out'), force: true },
328
+ })
329
+ layouts({ autoLayouts: false })(h.core)
330
+ await h.runHook('loaded')
331
+ await h.runSync('layouts', { action: 'create', context: { relativePath: 'card.hbs' } })
332
+ catalog.push({
333
+ id: h.runtime.state.layouts.layouts['card'].id,
334
+ collection: 'layouts', name: 'card',
335
+ meta: { match: ['@/blog/*', '@/news/*'] },
336
+ })
337
+
338
+ const news = { id: '/documents/news/launch.md', collection: 'documents', name: 'news/launch', format: 'md', meta: {} }
339
+ h.journal.push({ id: 1, entity: news, operation: 'create', context: {}, options: {}, output: null })
340
+ await h.runHook('processed', { aborted: false })
341
+
342
+ assert.deepEqual(news.layouts.map(l => l.name), ['card'])
343
+ })
344
+ })
345
+
346
+ it('a front-matter pattern that selected nothing warns, naming the layout', async () => {
347
+ // The silence is the whole finding: without this the author of a
348
+ // typo'd front-matter pattern gets a green build and empty pages.
349
+ // Naming the layout matters because the same glob can be written in
350
+ // the config, and the fix lives in a different file.
351
+ await withTempWorking(async (workingFolder) => {
352
+ const catalog = []
353
+ const h = createHarness({
354
+ entities: catalog,
355
+ options: { workingFolder, outputFolder: path.join(workingFolder, 'out'), force: true },
356
+ })
357
+ layouts({ autoLayouts: false })(h.core)
358
+ await h.runHook('loaded')
359
+ await h.runSync('layouts', { action: 'create', context: { relativePath: 'post-card.hbs' } })
360
+ catalog.push({
361
+ id: h.runtime.state.layouts.layouts['post-card'].id,
362
+ collection: 'layouts', name: 'post-card',
363
+ meta: { match: '@/blogg/*' }, // typo
364
+ })
365
+
366
+ const doc = { id: '/documents/blog/welcome.md', collection: 'documents', name: 'blog/welcome', format: 'md', meta: {} }
367
+ h.journal.push({ id: 1, entity: doc, operation: 'create', context: {}, options: {}, output: null })
368
+ await h.runHook('processed', { aborted: false })
369
+
370
+ const warnings = h.logs.filter(l =>
371
+ l.level === 'warn' && l.args.join(' ').includes('Layout pattern'))
372
+ assert.equal(warnings.length, 1, `expected one warning, got ${warnings.length}`)
373
+ const [fields, ...message] = warnings[0].args
374
+ assert.equal(fields.code, 'layout-pattern-no-match')
375
+ assert.equal(fields.pattern, '@/blogg/*')
376
+ assert.equal(fields.declaredIn, 'post-card', 'says which layout wrote the pattern')
377
+ assert.match(format(...message), /layouts\/post-card/,
378
+ 'the sentence points at the file to edit')
379
+ })
380
+ })
381
+
382
+ it('reports the one dud front-matter pattern among working ones', async () => {
383
+ // The shape this actually takes in a project: several layouts each
384
+ // claiming their own section, one of them typo'd. A working pattern
385
+ // elsewhere must not absolve the dead one — that would make the
386
+ // warning useless in exactly the configuration it is for.
387
+ await withTempWorking(async (workingFolder) => {
388
+ const catalog = []
389
+ const h = createHarness({
390
+ entities: catalog,
391
+ options: { workingFolder, outputFolder: path.join(workingFolder, 'out'), force: true },
392
+ })
393
+ layouts({ autoLayouts: false })(h.core)
394
+ await h.runHook('loaded')
395
+ await h.runSync('layouts', { action: 'create', context: { relativePath: 'post-card.hbs' } })
396
+ await h.runSync('layouts', { action: 'create', context: { relativePath: 'news-card.hbs' } })
397
+ catalog.push({
398
+ id: h.runtime.state.layouts.layouts['post-card'].id,
399
+ collection: 'layouts', name: 'post-card',
400
+ meta: { match: '@/blog/*' }, // works
401
+ })
402
+ catalog.push({
403
+ id: h.runtime.state.layouts.layouts['news-card'].id,
404
+ collection: 'layouts', name: 'news-card',
405
+ meta: { match: '@/newss/*' }, // typo
406
+ })
407
+
408
+ const doc = { id: '/documents/blog/welcome.md', collection: 'documents', name: 'blog/welcome', format: 'md', meta: {} }
409
+ h.journal.push({ id: 1, entity: doc, operation: 'create', context: {}, options: {}, output: null })
410
+ await h.runHook('processed', { aborted: false })
411
+
412
+ assert.deepEqual(doc.layouts.map(l => l.name), ['post-card'], 'the good pattern still matched')
413
+ const warnings = h.logs.filter(l =>
414
+ l.level === 'warn' && l.args.join(' ').includes('Layout pattern'))
415
+ assert.equal(warnings.length, 1, `expected exactly one warning, got ${warnings.length}`)
416
+ assert.equal(warnings[0].args[0].declaredIn, 'news-card', 'names the dud, not the working one')
417
+ })
418
+ })
419
+
420
+ it('a front-matter pattern is a peer of a config pattern, not a fallback', async () => {
421
+ // Two rules claiming one entity give two layouts, exactly as two
422
+ // config patterns would. Making front-matter a fallback would mean
423
+ // adding a config rule silently switched a layout off.
424
+ await withTempWorking(async (workingFolder) => {
425
+ const catalog = []
426
+ const h = createHarness({
427
+ entities: catalog,
428
+ options: { workingFolder, outputFolder: path.join(workingFolder, 'out'), force: true },
429
+ })
430
+ layouts({ autoLayouts: false, match: { '@/blog/*': 'post' } })(h.core)
431
+ await h.runHook('loaded')
432
+ await h.runSync('layouts', { action: 'create', context: { relativePath: 'post.hbs' } })
433
+ await h.runSync('layouts', { action: 'create', context: { relativePath: 'post-card.hbs' } })
434
+ catalog.push({
435
+ id: h.runtime.state.layouts.layouts['post-card'].id,
436
+ collection: 'layouts', name: 'post-card',
437
+ meta: { match: '@/blog/*' },
438
+ })
439
+
440
+ const doc = { id: '/documents/blog/welcome.md', collection: 'documents', name: 'blog/welcome', format: 'md', meta: {} }
441
+ h.journal.push({ id: 1, entity: doc, operation: 'create', context: {}, options: {}, output: null })
442
+ await h.runHook('processed', { aborted: false })
443
+
444
+ assert.deepEqual(doc.layouts.map(l => l.name).sort(), ['post', 'post-card'])
445
+ })
446
+ })
447
+
448
+ it('an author-declared meta.layout still wins over a front-matter pattern', async () => {
449
+ // Precedence is unchanged: what the document asks for beats what a
450
+ // layout claims, the same way it beats a config pattern.
451
+ await withTempWorking(async (workingFolder) => {
452
+ const catalog = []
453
+ const h = createHarness({
454
+ entities: catalog,
455
+ options: { workingFolder, outputFolder: path.join(workingFolder, 'out'), force: true },
456
+ })
457
+ layouts({ autoLayouts: false })(h.core)
458
+ await h.runHook('loaded')
459
+ await h.runSync('layouts', { action: 'create', context: { relativePath: 'post.hbs' } })
460
+ await h.runSync('layouts', { action: 'create', context: { relativePath: 'post-card.hbs' } })
461
+ catalog.push({
462
+ id: h.runtime.state.layouts.layouts['post-card'].id,
463
+ collection: 'layouts', name: 'post-card',
464
+ meta: { match: '@/blog/*' },
465
+ })
466
+
467
+ const doc = { id: '/documents/blog/welcome.md', collection: 'documents', name: 'blog/welcome', format: 'md', meta: { layout: 'post' } }
468
+ h.journal.push({ id: 1, entity: doc, operation: 'create', context: {}, options: {}, output: null })
469
+ await h.runHook('processed', { aborted: false })
470
+
471
+ assert.deepEqual(doc.layouts.map(l => l.name), ['post'])
472
+ })
473
+ })
474
+
213
475
  it('stays silent about an unmatched pattern on an incremental cycle', async () => {
214
476
  // The inverse of the test above, and the reason the gate exists. An
215
477
  // incremental run re-evaluates only what changed, so a pattern that