mikser-io-layouts 1.0.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.
@@ -0,0 +1,716 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { mkdtemp, rm, access, mkdir, writeFile } from 'node:fs/promises'
4
+ import { tmpdir } from 'node:os'
5
+ import path from 'node:path'
6
+
7
+ import { layouts } from '../index.js'
8
+ import { frontMatter } from 'mikser-io'
9
+ import { createHarness } from './_harness.js'
10
+
11
+ async function withTempWorking(fn) {
12
+ const dir = await mkdtemp(path.join(tmpdir(), 'mikser-layouts-'))
13
+ try { return await fn(dir) }
14
+ finally { await rm(dir, { recursive: true, force: true }) }
15
+ }
16
+
17
+ describe('layouts plugin', () => {
18
+ it('registers all the expected hooks', () => {
19
+ const h = createHarness()
20
+ layouts()(h.core)
21
+ // Two onLoaded handlers — state init + MCP tool registration.
22
+ // MCP registration is a no-op when runtime.options.mcp isn't
23
+ // present (vector / schemas / preview use the same gating
24
+ // pattern). Before tools-with-domain moved, the registration
25
+ // lived in mikser-io-mcp.
26
+ assert.equal(h.hooks.loaded.length, 2)
27
+ assert.equal(h.hooks.import.length, 1)
28
+ assert.equal(h.hooks.processed.length, 1)
29
+ assert.equal(h.hooks.beforeRender.length, 1)
30
+ assert.equal(h.hooks.complete.length, 1)
31
+ assert.ok(h.sync.has('layouts'))
32
+ // The inspect primitive is exposed at factory-eval — before any
33
+ // onLoaded fires — so plugins listed after layouts in the
34
+ // plugins array can see it during their own onLoaded.
35
+ assert.equal(typeof h.runtime.options.layouts?.inspect, 'function')
36
+ })
37
+
38
+ it('initializes runtime.state.layouts on onLoaded with empty maps', async () => {
39
+ await withTempWorking(async (workingFolder) => {
40
+ const h = createHarness({ options: { workingFolder, outputFolder: path.join(workingFolder, 'out') } })
41
+ layouts()(h.core)
42
+ await h.runHook('loaded')
43
+ // sitemap + uriIndex retired — sitemap is now backed by
44
+ // the catalog's indexed meta_href column, queried via the
45
+ // worker-side read-only sqlite handle in src/render.js.
46
+ assert.deepEqual(h.runtime.state.layouts, { layouts: {} })
47
+ assert.equal(h.runtime.options.layoutsFolder, path.join(workingFolder, 'layouts'))
48
+ })
49
+ })
50
+
51
+ it('onSync CREATE registers a layout in state.layouts and writes a journal entry', async () => {
52
+ await withTempWorking(async (workingFolder) => {
53
+ const h = createHarness({ options: { workingFolder, outputFolder: path.join(workingFolder, 'out') } })
54
+ layouts()(h.core)
55
+ await h.runHook('loaded')
56
+ await h.runSync('layouts', { action: 'create', context: { relativePath: 'post.hbs' } })
57
+
58
+ const entry = h.journal.find(e => e.operation === 'create')
59
+ assert.ok(entry)
60
+ assert.equal(entry.entity.id, '/layouts/post.hbs')
61
+ assert.equal(entry.entity.template, 'hbs')
62
+ assert.equal(entry.entity.format, 'html')
63
+ assert.equal(entry.entity.name, 'post')
64
+ assert.ok(h.runtime.state.layouts.layouts['post'])
65
+ })
66
+ })
67
+
68
+ it('onSync CREATE for a sidecar .js layout drops .js from id', async () => {
69
+ await withTempWorking(async (workingFolder) => {
70
+ const h = createHarness({ options: { workingFolder, outputFolder: path.join(workingFolder, 'out') } })
71
+ layouts()(h.core)
72
+ await h.runHook('loaded')
73
+ await h.runSync('layouts', { action: 'create', context: { relativePath: 'post.hbs.js' } })
74
+ const entry = h.journal.find(e => e.operation === 'create')
75
+ assert.equal(entry.entity.id, '/layouts/post.hbs')
76
+ })
77
+ })
78
+
79
+ it('onSync DELETE removes the layout from state.layouts and writes a delete entry', async () => {
80
+ await withTempWorking(async (workingFolder) => {
81
+ const h = createHarness({ options: { workingFolder, outputFolder: path.join(workingFolder, 'out') } })
82
+ layouts()(h.core)
83
+ await h.runHook('loaded')
84
+ await h.runSync('layouts', { action: 'create', context: { relativePath: 'post.hbs' } })
85
+ assert.ok(h.runtime.state.layouts.layouts['post'])
86
+
87
+ await h.runSync('layouts', { action: 'delete', context: { relativePath: 'post.hbs' } })
88
+
89
+ assert.equal(h.runtime.state.layouts.layouts['post'], undefined)
90
+ assert.equal(h.journal.filter(e => e.operation === 'delete').length, 1)
91
+ })
92
+ })
93
+
94
+ it('onProcessed assigns a matched layout to the entity', async () => {
95
+ await withTempWorking(async (workingFolder) => {
96
+ const h = createHarness({
97
+ options: { workingFolder, outputFolder: path.join(workingFolder, 'out') },
98
+ })
99
+ layouts({ autoLayouts: true })(h.core)
100
+ await h.runHook('loaded')
101
+ // Seed a layout
102
+ await h.runSync('layouts', { action: 'create', context: { relativePath: 'post.hbs' } })
103
+
104
+ // Seed a document journal entry whose name matches 'post'
105
+ const doc = {
106
+ id: '/documents/post.md',
107
+ collection: 'documents',
108
+ name: 'post',
109
+ format: 'md',
110
+ meta: { lang: 'en' },
111
+ }
112
+ h.journal.push({ id: 99, entity: doc, operation: 'create', context: {}, options: {}, output: null })
113
+
114
+ await h.runHook('processed', { aborted: false })
115
+
116
+ assert.ok(doc.layout, 'entity should have been assigned a layout')
117
+ assert.equal(doc.layout.name, 'post')
118
+ // Sitemap presence is now equivalent to "the catalog has
119
+ // this entity with meta.href set" — verified end-to-end by
120
+ // the scenario tests against a real subprocess + sqlite.
121
+ // Unit-level we just check the entity got its layout.
122
+ })
123
+ })
124
+
125
+ it('warns when a render-requested entity (carries correlationId) matches no layout', async () => {
126
+ await withTempWorking(async (workingFolder) => {
127
+ const h = createHarness({
128
+ options: { workingFolder, outputFolder: path.join(workingFolder, 'out') },
129
+ })
130
+ layouts({ autoLayouts: true })(h.core)
131
+ await h.runHook('loaded')
132
+
133
+ // A render-submitted entity: no meta.layout, name won't
134
+ // auto-match any layout, and it carries useRenderer's
135
+ // correlationId under entity.options.
136
+ const rendered = {
137
+ id: '/archive/franchises/38f8.json',
138
+ collection: 'archive',
139
+ name: 'franchises/38f8',
140
+ format: 'json',
141
+ meta: { company: 'Acme' },
142
+ options: { correlationId: 'abc-123' },
143
+ }
144
+ h.journal.push({ id: 99, entity: rendered, operation: 'create', context: {}, options: {}, output: null })
145
+
146
+ await h.runHook('processed', { aborted: false })
147
+
148
+ assert.equal(rendered.layout, undefined, 'no layout should have matched')
149
+ const warned = h.logs.some(l =>
150
+ l.level === 'warn' && l.args.join(' ').includes('Render requested') && l.args.join(' ').includes('no layout matched'))
151
+ assert.ok(warned, 'should warn that the render-requested entity matched no layout')
152
+ })
153
+ })
154
+
155
+ it('stays silent for a normal layout-less entity (no correlationId)', async () => {
156
+ await withTempWorking(async (workingFolder) => {
157
+ const h = createHarness({
158
+ options: { workingFolder, outputFolder: path.join(workingFolder, 'out') },
159
+ })
160
+ layouts({ autoLayouts: true })(h.core)
161
+ await h.runHook('loaded')
162
+
163
+ // Same shape, but NOT a render request — no correlationId.
164
+ const plain = {
165
+ id: '/files/data/blob.json',
166
+ collection: 'files',
167
+ name: 'data/blob',
168
+ format: 'json',
169
+ meta: {},
170
+ }
171
+ h.journal.push({ id: 99, entity: plain, operation: 'create', context: {}, options: {}, output: null })
172
+
173
+ await h.runHook('processed', { aborted: false })
174
+
175
+ const warned = h.logs.some(l => l.level === 'warn' && l.args.join(' ').includes('Render requested'))
176
+ assert.equal(warned, false, 'normal layout-less files must not trigger the render warning')
177
+ })
178
+ })
179
+
180
+ // Layouts go through the frontmatter pipeline like documents do.
181
+ // The layouts plugin reads the file at sync time and populates
182
+ // entity.content; the frontmatter plugin then walks the journal at
183
+ // onProcess and lifts YAML into entity.meta. The two plugins
184
+ // compose via the journal contract alone — no cross-imports.
185
+ it('onImport reads layout content from disk so frontmatter can populate entity.meta', async () => {
186
+ await withTempWorking(async (workingFolder) => {
187
+ const layoutsFolder = path.join(workingFolder, 'layouts')
188
+ await mkdir(layoutsFolder, { recursive: true })
189
+ await writeFile(path.join(layoutsFolder, 'article.hbs'),
190
+ '---\nmatch: "@/articles/*"\nmcpUi:\n mode: preview\n description: "Article preview"\n actions: ["approve","reject"]\n---\n<article>{{title}}</article>\n')
191
+
192
+ const h = createHarness({ options: { workingFolder, outputFolder: path.join(workingFolder, 'out') } })
193
+ layouts()(h.core)
194
+ frontMatter()(h.core)
195
+ await h.runHook('loaded')
196
+ await h.runHook('import')
197
+
198
+ const created = h.journal.find(e => e.operation === 'create' && e.entity?.id === '/layouts/article.hbs')
199
+ assert.ok(created, 'expected a journal CREATE for the layout')
200
+ assert.ok(created.entity.content?.includes('match: "@/articles/*"'),
201
+ 'layout entity.content should carry the raw file bytes before frontmatter runs')
202
+
203
+ // Frontmatter plugin runs onProcess against the same journal,
204
+ // strips the YAML, and lifts the metadata into entity.meta.
205
+ await h.runHook('process')
206
+
207
+ assert.equal(created.entity.meta?.match, '@/articles/*')
208
+ assert.equal(created.entity.meta?.mcpUi?.mode, 'preview')
209
+ assert.equal(created.entity.meta?.mcpUi?.description, 'Article preview')
210
+ assert.deepEqual(created.entity.meta?.mcpUi?.actions, ['approve', 'reject'])
211
+ // entity.content is now the stripped body — no YAML, no leading ---.
212
+ assert.equal(created.entity.content.trim(), '<article>{{title}}</article>')
213
+ })
214
+ })
215
+
216
+ it('onProcessed attaches entity.layout from the catalog (post-frontmatter strip), not the raw state map', async () => {
217
+ // The layouts plugin's in-memory state map (runtime.state.layouts.layouts)
218
+ // is populated at sync time with the RAW file body — YAML and all.
219
+ // The front-matter plugin's onProcess pass updates the CATALOG
220
+ // entity's content (via updateEntry), not the state map. If
221
+ // onProcessed naively attached entity.layout = layouts[name],
222
+ // the renderer would receive the raw file via entity.layout.content
223
+ // and emit YAML verbatim. The fix: resolve the layout from the
224
+ // catalog by id, falling back to the state map only when the
225
+ // catalog hasn't caught up. This test pins that contract.
226
+ await withTempWorking(async (workingFolder) => {
227
+ const layoutsFolder = path.join(workingFolder, 'layouts')
228
+ await mkdir(layoutsFolder, { recursive: true })
229
+ await writeFile(path.join(layoutsFolder, 'article.hbs'),
230
+ '---\nmatch: "@/articles/*"\nmcpUi:\n mode: preview\n---\n<article>{{title}}</article>\n')
231
+
232
+ const h = createHarness({ options: { workingFolder, outputFolder: path.join(workingFolder, 'out') } })
233
+ layouts()(h.core)
234
+ frontMatter()(h.core)
235
+ await h.runHook('loaded')
236
+ await h.runHook('import')
237
+
238
+ // Add a document that explicitly names the layout — exercises
239
+ // the `entity.meta.layout = '...'` branch in onProcessed.
240
+ const doc = {
241
+ id: '/documents/article.md',
242
+ collection: 'documents',
243
+ type: 'document',
244
+ name: 'article',
245
+ meta: { layout: 'article' },
246
+ content: 'body',
247
+ }
248
+ h.journal.push({ id: 99, entity: doc, operation: 'create', context: {}, options: {}, output: null })
249
+
250
+ // process strips frontmatter on the catalog layout entity.
251
+ await h.runHook('process')
252
+ // processed walks the document journal and attaches the layout.
253
+ await h.runHook('processed')
254
+
255
+ // entity.layout MUST come from the catalog (clean) — not from
256
+ // the raw state map. If the raw state map leaked in, content
257
+ // would still start with `---\nmatch:...`.
258
+ assert.ok(doc.layout, 'expected onProcessed to attach a layout to the document')
259
+ assert.ok(!doc.layout.content?.includes('match: "@/articles/*"'),
260
+ 'attached layout content must be post-frontmatter-strip (catalog), not the raw state-map entry')
261
+ assert.equal(doc.layout.content.trim(), '<article>{{title}}</article>')
262
+ })
263
+ })
264
+
265
+ it('onSync CREATE reads layout content from disk so frontmatter can run on updates', async () => {
266
+ await withTempWorking(async (workingFolder) => {
267
+ const layoutsFolder = path.join(workingFolder, 'layouts')
268
+ await mkdir(layoutsFolder, { recursive: true })
269
+ await writeFile(path.join(layoutsFolder, 'card.hbs'),
270
+ '---\nmode: edit\n---\n<form>{{title}}</form>\n')
271
+
272
+ const h = createHarness({ options: { workingFolder, outputFolder: path.join(workingFolder, 'out') } })
273
+ layouts()(h.core)
274
+ frontMatter()(h.core)
275
+ await h.runHook('loaded')
276
+ await h.runSync('layouts', { action: 'create', context: { relativePath: 'card.hbs' } })
277
+ await h.runHook('process')
278
+
279
+ const created = h.journal.find(e => e.operation === 'create' && e.entity?.id === '/layouts/card.hbs')
280
+ assert.ok(created)
281
+ assert.equal(created.entity.meta?.mode, 'edit')
282
+ assert.equal(created.entity.content.trim(), '<form>{{title}}</form>')
283
+ })
284
+ })
285
+
286
+ it('onSync CREATE returns empty content (and stays silent at debug) when the file is missing', async () => {
287
+ // Sync events can synthetically arrive without a corresponding
288
+ // file (test harness usage; rare race in production). The plugin
289
+ // logs at debug and proceeds with empty content rather than
290
+ // throwing — downstream renderers surface the real issue.
291
+ await withTempWorking(async (workingFolder) => {
292
+ const h = createHarness({ options: { workingFolder, outputFolder: path.join(workingFolder, 'out') } })
293
+ layouts()(h.core)
294
+ await h.runHook('loaded')
295
+ await h.runSync('layouts', { action: 'create', context: { relativePath: 'ghost.hbs' } })
296
+
297
+ const created = h.journal.find(e => e.operation === 'create')
298
+ assert.ok(created)
299
+ assert.equal(created.entity.content, '')
300
+ assert.ok(h.logs.some(l => l.level === 'debug' && l.args.join(' ').includes('unreadable')),
301
+ 'expected a debug log noting the missing layout file')
302
+ })
303
+ })
304
+
305
+ it('onSync returns false when relativePath is missing', async () => {
306
+ const h = createHarness()
307
+ layouts()(h.core)
308
+ assert.equal(await h.runSync('layouts', { action: 'create', context: {} }), false)
309
+ })
310
+
311
+ it('onComplete writes the rendered output to disk by default', async () => {
312
+ await withTempWorking(async (workingFolder) => {
313
+ const outputFolder = path.join(workingFolder, 'out')
314
+ const h = createHarness({ options: { workingFolder, outputFolder } })
315
+ layouts()(h.core)
316
+ await h.runHook('loaded')
317
+
318
+ const entity = {
319
+ id: '/documents/page.md',
320
+ collection: 'documents',
321
+ name: 'page',
322
+ destination: '/page.html',
323
+ layout: { name: 'page', format: 'html' },
324
+ }
325
+ await h.runHook('complete', { entity, options: {}, output: { result: '<h1>Hi</h1>' } })
326
+
327
+ const written = path.join(outputFolder, 'page.html')
328
+ await assert.doesNotReject(() => access(written), 'expected the file to be written')
329
+ })
330
+ })
331
+
332
+ it('onComplete with entity.options.save === false skips the disk write (bytes-only mode)', async () => {
333
+ await withTempWorking(async (workingFolder) => {
334
+ const outputFolder = path.join(workingFolder, 'out')
335
+ const h = createHarness({ options: { workingFolder, outputFolder } })
336
+ layouts()(h.core)
337
+ await h.runHook('loaded')
338
+
339
+ const entity = {
340
+ id: '/documents/page.md',
341
+ collection: 'documents',
342
+ name: 'page',
343
+ destination: '/page.html',
344
+ layout: { name: 'page', format: 'html' },
345
+ options: { save: false },
346
+ }
347
+ await h.runHook('complete', { entity, options: {}, output: { result: '<h1>Hi</h1>' } })
348
+
349
+ const written = path.join(outputFolder, 'page.html')
350
+ await assert.rejects(() => access(written), 'expected NO file to be written')
351
+ })
352
+ })
353
+
354
+ // The cleanUrls × postprocess interaction lives in onBeforeRender's
355
+ // destination-assignment block. The engine's postprocess queue is
356
+ // now a plain changeExtension swap (no peek at runtime.config.layouts);
357
+ // the layouts plugin compensates by leaving destination flat
358
+ // (`/foo.html`) when the layout declares a postprocessor, so the
359
+ // engine's swap naturally produces `/foo.pdf`. If layouts ever goes
360
+ // back to writing `/foo/index.html` for a postprocessor-bearing
361
+ // layout, the engine emits `/foo/index.pdf` and the served-URL
362
+ // contract breaks. This test pins the decoupling.
363
+ describe('cleanUrls × postprocess destination assignment', () => {
364
+ // Harness for driving onBeforeRender against a single seeded
365
+ // entity. Stubs the refs surface so the incremental dispatch
366
+ // path picks the entity up via inverseClosureOf instead of the
367
+ // SQL --force path (which would need useDatabase).
368
+ async function runBeforeRender({ cleanUrls, postprocessor }) {
369
+ return withTempWorking(async (workingFolder) => {
370
+ const entity = {
371
+ id: '/documents/foo.md',
372
+ collection: 'documents',
373
+ type: 'document',
374
+ name: 'foo',
375
+ format: 'md',
376
+ time: 1,
377
+ meta: {},
378
+ layout: {
379
+ name: 'page',
380
+ template: 'hbs',
381
+ format: 'html',
382
+ ...(postprocessor ? { postprocessor } : {}),
383
+ },
384
+ }
385
+ const h = createHarness({
386
+ options: {
387
+ workingFolder,
388
+ outputFolder: path.join(workingFolder, 'out'),
389
+ force: false,
390
+ },
391
+ entities: [entity],
392
+ })
393
+ // Incremental-dispatch path: refs.inverseClosureOf returns
394
+ // the seed set; manifest is left undefined (the ?. chains
395
+ // tolerate it).
396
+ h.runtime.refs = {
397
+ inverseClosureOf: () => new Set([entity.id]),
398
+ }
399
+ layouts({ cleanUrls })(h.core)
400
+ await h.runHook('loaded')
401
+ // Seed a journal mutation so the incremental dispatch's
402
+ // useJournal walk finds it.
403
+ h.journal.push({
404
+ id: 99,
405
+ entity,
406
+ operation: 'create',
407
+ context: {},
408
+ options: {},
409
+ output: null,
410
+ })
411
+ await h.runHook('beforeRender', { aborted: false })
412
+ assert.equal(h.renderTasks.length, 1, 'expected one render task to be queued')
413
+ return h.renderTasks[0].entity.destination
414
+ })
415
+ }
416
+
417
+ it('cleanUrls on, no postprocessor → /foo/index.html (folder transform applied)', async () => {
418
+ const dest = await runBeforeRender({ cleanUrls: true, postprocessor: null })
419
+ assert.equal(dest, path.join('/foo', 'index.html'))
420
+ })
421
+
422
+ it('cleanUrls on, layout has postprocessor → /foo.html (folder transform skipped so engine swap yields /foo.<ext>)', async () => {
423
+ const dest = await runBeforeRender({ cleanUrls: true, postprocessor: 'pdf' })
424
+ assert.equal(dest, '/foo.html')
425
+ })
426
+
427
+ it('cleanUrls off, layout has postprocessor → /foo.html (basic shape, cleanUrls irrelevant)', async () => {
428
+ const dest = await runBeforeRender({ cleanUrls: false, postprocessor: 'pdf' })
429
+ assert.equal(dest, '/foo.html')
430
+ })
431
+ })
432
+
433
+ it('onComplete writes the intermediate to previewFolder (not outputFolder) when options.save:false and a postprocessor is configured', async () => {
434
+ await withTempWorking(async (workingFolder) => {
435
+ const outputFolder = path.join(workingFolder, 'out')
436
+ const previewFolder = path.join(workingFolder, 'runtime', 'preview')
437
+ const h = createHarness({ options: { workingFolder, outputFolder, previewFolder } })
438
+ layouts()(h.core)
439
+ await h.runHook('loaded')
440
+
441
+ // Intermediate: postprocessor is configured, no origin yet
442
+ // (we haven't entered the postprocess phase).
443
+ // options.save:false means the FINAL output is skipped, but
444
+ // the intermediate still needs to land somewhere so the
445
+ // postprocessor can read it — that "somewhere" is
446
+ // previewFolder, not outputFolder. Keeps outputFolder
447
+ // clean for previews.
448
+ const intermediate = {
449
+ id: '/documents/r.md',
450
+ collection: 'documents',
451
+ name: 'r',
452
+ destination: '/r.html',
453
+ layout: { name: 'r', format: 'html', postprocessor: 'pdf' },
454
+ options: { save: false },
455
+ }
456
+ await h.runHook('complete', { entity: intermediate, options: {}, output: { result: '<h1>R</h1>' } })
457
+
458
+ // Lands in previewFolder, not outputFolder.
459
+ await assert.doesNotReject(
460
+ () => access(path.join(previewFolder, 'r.html')),
461
+ 'intermediate must be written to previewFolder for postprocess to consume',
462
+ )
463
+ await assert.rejects(
464
+ () => access(path.join(outputFolder, 'r.html')),
465
+ 'intermediate must NOT appear in outputFolder during preview flow',
466
+ )
467
+ })
468
+ })
469
+ })
470
+
471
+ describe('layouts plugin: multi-layouts (matching)', () => {
472
+ it('meta.layouts array assigns multiple layouts to one entity', async () => {
473
+ await withTempWorking(async (workingFolder) => {
474
+ const h = createHarness({
475
+ options: { workingFolder, outputFolder: path.join(workingFolder, 'out') },
476
+ })
477
+ layouts()(h.core)
478
+ await h.runHook('loaded')
479
+ // Layout names are derived from the filename minus its
480
+ // template AND format extensions (see getFormatInfo). So
481
+ // `post.html.hbs` has name 'post'; `post-email.eml.hbs` has
482
+ // name 'post-email'. Distinct names → both land in the state
483
+ // map.
484
+ await h.runSync('layouts', { action: 'create', context: { relativePath: 'post.html.hbs' } })
485
+ await h.runSync('layouts', { action: 'create', context: { relativePath: 'post-email.eml.hbs' } })
486
+
487
+ const doc = {
488
+ id: '/documents/blog/welcome.md',
489
+ collection: 'documents',
490
+ name: 'blog/welcome',
491
+ format: 'md',
492
+ meta: { layouts: ['post', 'post-email'] },
493
+ }
494
+ h.journal.push({ id: 1, entity: doc, operation: 'create', context: {}, options: {}, output: null })
495
+ await h.runHook('processed', { aborted: false })
496
+
497
+ assert.equal(doc.layouts.length, 2)
498
+ assert.deepEqual(doc.layouts.map(l => l.name).sort(), ['post', 'post-email'])
499
+ assert.equal(doc.layout.name, 'post', 'entity.layout back-compat = first layout')
500
+ })
501
+ })
502
+
503
+ it('errors when both meta.layout and meta.layouts are set', async () => {
504
+ await withTempWorking(async (workingFolder) => {
505
+ const h = createHarness({
506
+ options: { workingFolder, outputFolder: path.join(workingFolder, 'out') },
507
+ })
508
+ layouts()(h.core)
509
+ await h.runHook('loaded')
510
+ await h.runSync('layouts', { action: 'create', context: { relativePath: 'post.hbs' } })
511
+
512
+ const doc = {
513
+ id: '/documents/blog/welcome.md',
514
+ collection: 'documents',
515
+ name: 'blog/welcome',
516
+ format: 'md',
517
+ meta: { layout: 'post', layouts: ['post'] },
518
+ }
519
+ h.journal.push({ id: 1, entity: doc, operation: 'create', context: {}, options: {}, output: null })
520
+ await h.runHook('processed', { aborted: false })
521
+
522
+ assert.deepEqual(doc.layouts, [], 'resolution failed → no layouts')
523
+ const erred = h.logs.some(l =>
524
+ l.level === 'error' && l.args.join(' ').includes('both \'meta.layout\' and \'meta.layouts\''))
525
+ assert.ok(erred, 'should log a clear error about the dual-key collision')
526
+ })
527
+ })
528
+
529
+ it('options.match patterns multi-match — every matching pattern contributes', async () => {
530
+ await withTempWorking(async (workingFolder) => {
531
+ const h = createHarness({
532
+ options: { workingFolder, outputFolder: path.join(workingFolder, 'out') },
533
+ })
534
+ layouts({ match: { '@/blog/*': 'post', '@/**': 'fallback' } })(h.core)
535
+ await h.runHook('loaded')
536
+ await h.runSync('layouts', { action: 'create', context: { relativePath: 'post.hbs' } })
537
+ await h.runSync('layouts', { action: 'create', context: { relativePath: 'fallback.hbs' } })
538
+
539
+ const doc = {
540
+ id: '/documents/blog/welcome.md',
541
+ collection: 'documents',
542
+ name: 'blog/welcome',
543
+ format: 'md',
544
+ meta: {},
545
+ }
546
+ h.journal.push({ id: 1, entity: doc, operation: 'create', context: {}, options: {}, output: null })
547
+ await h.runHook('processed', { aborted: false })
548
+
549
+ // Both patterns match `/documents/blog/welcome.md` — get both layouts.
550
+ assert.equal(doc.layouts.length, 2)
551
+ const names = doc.layouts.map(l => l.name).sort()
552
+ assert.deepEqual(names, ['fallback', 'post'])
553
+ })
554
+ })
555
+
556
+ it('autoLayouts stays single-match (no multi-match fanout)', async () => {
557
+ await withTempWorking(async (workingFolder) => {
558
+ const h = createHarness({
559
+ options: { workingFolder, outputFolder: path.join(workingFolder, 'out') },
560
+ })
561
+ layouts({ autoLayouts: true })(h.core)
562
+ await h.runHook('loaded')
563
+ // Single auto-layout candidate matches the entity name.
564
+ // The peel ladder is intentionally first-wins; the test
565
+ // verifies multi-match doesn't apply here (entity gets one
566
+ // layout, not many).
567
+ await h.runSync('layouts', { action: 'create', context: { relativePath: 'post.hbs' } })
568
+
569
+ const doc = {
570
+ id: '/documents/post.md',
571
+ collection: 'documents',
572
+ name: 'post',
573
+ format: 'md',
574
+ meta: {},
575
+ }
576
+ h.journal.push({ id: 1, entity: doc, operation: 'create', context: {}, options: {}, output: null })
577
+ await h.runHook('processed', { aborted: false })
578
+
579
+ assert.equal(doc.layouts.length, 1, 'autoLayouts is fallback-first-wins, not multi-match')
580
+ assert.equal(doc.layouts[0].name, 'post')
581
+ })
582
+ })
583
+ })
584
+
585
+ describe('layouts plugin: multi-layouts (collision)', () => {
586
+ it('drops both render tasks when two layouts produce the same destination', async () => {
587
+ await withTempWorking(async (workingFolder) => {
588
+ const outputFolder = path.join(workingFolder, 'out')
589
+ const doc = {
590
+ id: '/documents/blog/welcome.md',
591
+ collection: 'documents',
592
+ name: 'blog/welcome',
593
+ format: 'md',
594
+ meta: { layouts: ['post', 'post-card'] },
595
+ }
596
+ const h = createHarness({
597
+ options: { workingFolder, outputFolder },
598
+ entities: [doc],
599
+ })
600
+ // Incremental-dispatch path: refs.inverseClosureOf returns
601
+ // the seed; the dispatcher hydrates the entity by id.
602
+ h.runtime.refs = { inverseClosureOf: () => new Set([doc.id]) }
603
+ layouts()(h.core)
604
+ await h.runHook('loaded')
605
+ // Both layouts produce `.html` for the same entity name
606
+ // (post → /welcome.html and post-card → /welcome.html).
607
+ await h.runSync('layouts', { action: 'create', context: { relativePath: 'post.html.hbs' } })
608
+ await h.runSync('layouts', { action: 'create', context: { relativePath: 'post-card.html.hbs' } })
609
+
610
+ h.journal.push({ id: 1, entity: doc, operation: 'create', context: {}, options: {}, output: null })
611
+ await h.runHook('processed', { aborted: false })
612
+ await h.runHook('beforeRender', { aborted: false })
613
+
614
+ assert.equal(h.renderTasks.length, 0, 'collision must skip every task for the entity')
615
+ const erred = h.logs.some(l =>
616
+ l.level === 'error' && l.args.join(' ').includes('Layout collision'))
617
+ assert.ok(erred, 'should log the collision with both layout names')
618
+ })
619
+ })
620
+
621
+ it('no collision when two layouts produce different formats', async () => {
622
+ await withTempWorking(async (workingFolder) => {
623
+ const outputFolder = path.join(workingFolder, 'out')
624
+ const doc = {
625
+ id: '/documents/blog/welcome.md',
626
+ collection: 'documents',
627
+ name: 'blog/welcome',
628
+ format: 'md',
629
+ meta: { layouts: ['post', 'post-email'] },
630
+ }
631
+ const h = createHarness({
632
+ options: { workingFolder, outputFolder },
633
+ entities: [doc],
634
+ })
635
+ h.runtime.refs = { inverseClosureOf: () => new Set([doc.id]) }
636
+ layouts()(h.core)
637
+ await h.runHook('loaded')
638
+ await h.runSync('layouts', { action: 'create', context: { relativePath: 'post.html.hbs' } })
639
+ await h.runSync('layouts', { action: 'create', context: { relativePath: 'post-email.eml.hbs' } })
640
+
641
+ h.journal.push({ id: 1, entity: doc, operation: 'create', context: {}, options: {}, output: null })
642
+ await h.runHook('processed', { aborted: false })
643
+ await h.runHook('beforeRender', { aborted: false })
644
+
645
+ assert.equal(h.renderTasks.length, 2, 'one task per layout, different extensions')
646
+ const destinations = h.renderTasks.map(t => t.entity.destination).sort()
647
+ assert.deepEqual(destinations, ['/blog/welcome.eml', '/blog/welcome.html'])
648
+ })
649
+ })
650
+ })
651
+
652
+ describe('layouts plugin: multi-layouts (destination template)', () => {
653
+ it('frontmatter destination template overrides the default derivation', async () => {
654
+ await withTempWorking(async (workingFolder) => {
655
+ const outputFolder = path.join(workingFolder, 'out')
656
+ const doc = {
657
+ id: '/documents/blog/welcome.md',
658
+ collection: 'documents',
659
+ name: 'blog/welcome',
660
+ format: 'md',
661
+ meta: { layouts: ['post-card'] },
662
+ }
663
+ const h = createHarness({
664
+ options: { workingFolder, outputFolder },
665
+ entities: [doc],
666
+ })
667
+ h.runtime.refs = { inverseClosureOf: () => new Set([doc.id]) }
668
+ layouts()(h.core)
669
+ await h.runHook('loaded')
670
+ await h.runSync('layouts', { action: 'create', context: { relativePath: 'post-card.html.hbs' } })
671
+ // Inject meta.destination on the layout state entry (would
672
+ // normally come from the front-matter plugin processing the
673
+ // layout). resolveLayout falls back to stateEntry when the
674
+ // catalog mock doesn't have the layout row.
675
+ const cardLayout = h.runtime.state.layouts.layouts['post-card']
676
+ cardLayout.meta = { destination: '/cards/{{entity.name}}.html' }
677
+
678
+ h.journal.push({ id: 1, entity: doc, operation: 'create', context: {}, options: {}, output: null })
679
+ await h.runHook('processed', { aborted: false })
680
+ await h.runHook('beforeRender', { aborted: false })
681
+
682
+ assert.equal(h.renderTasks.length, 1)
683
+ assert.equal(h.renderTasks[0].entity.destination, '/cards/blog/welcome.html')
684
+ })
685
+ })
686
+
687
+ it('destination template that resolves to a path-traversal is rejected', async () => {
688
+ await withTempWorking(async (workingFolder) => {
689
+ const outputFolder = path.join(workingFolder, 'out')
690
+ const doc = {
691
+ id: '/documents/blog/x.md',
692
+ collection: 'documents',
693
+ name: 'blog/x',
694
+ format: 'md',
695
+ meta: { layouts: ['evil'] },
696
+ }
697
+ const h = createHarness({
698
+ options: { workingFolder, outputFolder },
699
+ entities: [doc],
700
+ })
701
+ h.runtime.refs = { inverseClosureOf: () => new Set([doc.id]) }
702
+ layouts()(h.core)
703
+ await h.runHook('loaded')
704
+ await h.runSync('layouts', { action: 'create', context: { relativePath: 'evil.html.hbs' } })
705
+ const evilLayout = h.runtime.state.layouts.layouts['evil']
706
+ evilLayout.meta = { destination: '../../../etc/passwd' }
707
+
708
+ h.journal.push({ id: 1, entity: doc, operation: 'create', context: {}, options: {}, output: null })
709
+ await h.runHook('processed', { aborted: false })
710
+ await assert.rejects(
711
+ () => h.runHook('beforeRender', { aborted: false }),
712
+ /path-traversal/,
713
+ )
714
+ })
715
+ })
716
+ })