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.
- package/LICENSE +21 -0
- package/README.md +171 -0
- package/index.js +346 -0
- package/lib/assembly.js +399 -0
- package/lib/destination.js +33 -0
- package/lib/inspect.js +150 -0
- package/lib/matching.js +212 -0
- package/lib/mcp.js +54 -0
- package/package.json +29 -0
- package/test/_harness.js +243 -0
- package/test/unit.test.js +716 -0
|
@@ -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
|
+
})
|