mikser-io-layouts 11.2.1 → 11.4.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/.github/workflows/publish.yml +9 -0
- package/README.md +27 -0
- package/lib/matching.js +131 -7
- package/package.json +1 -1
- package/test/unit.test.js +293 -0
|
@@ -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
|
@@ -43,9 +43,25 @@ export function createOnProcessed({
|
|
|
43
43
|
// author cannot see.
|
|
44
44
|
const matchTally = { evaluated: 0, hit: new Set(), reported: false }
|
|
45
45
|
|
|
46
|
+
// Patterns a LAYOUT declared for itself in its own front-matter, seen
|
|
47
|
+
// during the run. Recorded here rather than derived at report time
|
|
48
|
+
// because by then the cycle's resolved-layout reads are gone, and a
|
|
49
|
+
// layout deleted mid-run should not be reported as a dud pattern.
|
|
50
|
+
const declaredSeen = new Map()
|
|
51
|
+
|
|
52
|
+
// A pattern's identity for the tally. The same glob can legitimately
|
|
53
|
+
// appear in config and in a layout's front-matter; they are different
|
|
54
|
+
// rules and either can be the dead one.
|
|
55
|
+
const tallyKey = (pattern, layoutName) =>
|
|
56
|
+
layoutName ? `layout:${layoutName}:${pattern}` : `config:${pattern}`
|
|
57
|
+
|
|
46
58
|
function reportUnmatchedPatterns(logger) {
|
|
47
59
|
if (matchTally.reported) return
|
|
48
|
-
const patterns =
|
|
60
|
+
const patterns = [
|
|
61
|
+
...Object.keys(options.match || {}).map(pattern =>
|
|
62
|
+
({ pattern, layout: options.match[pattern], source: null })),
|
|
63
|
+
...Array.from(declaredSeen.values()),
|
|
64
|
+
]
|
|
49
65
|
if (!patterns.length || !matchTally.evaluated) return
|
|
50
66
|
// Only when the cycle evaluated everything. An incremental run
|
|
51
67
|
// re-evaluates whatever changed, so a healthy pattern matches none of
|
|
@@ -56,8 +72,8 @@ export function createOnProcessed({
|
|
|
56
72
|
if (!isFullCycle(runtime)) return
|
|
57
73
|
matchTally.reported = true
|
|
58
74
|
|
|
59
|
-
for (const pattern of patterns) {
|
|
60
|
-
if (matchTally.hit.has(pattern)) continue
|
|
75
|
+
for (const { pattern, layout: layoutName, source } of patterns) {
|
|
76
|
+
if (matchTally.hit.has(tallyKey(pattern, source))) continue
|
|
61
77
|
// Phrased as what was observed: an incremental cycle only
|
|
62
78
|
// re-evaluates changed entities, so a pattern can legitimately
|
|
63
79
|
// match nothing in a run of three. The count lets the reader
|
|
@@ -69,12 +85,17 @@ export function createOnProcessed({
|
|
|
69
85
|
// So 'post' does NOT match a document named 'post'; '@/post' and
|
|
70
86
|
// '/documents/post.md' both do.
|
|
71
87
|
const onName = pattern.startsWith('@/')
|
|
88
|
+
// Where the pattern is written decides where the fix goes, and
|
|
89
|
+
// the two places look nothing alike. Without this the reader of
|
|
90
|
+
// a front-matter typo goes hunting through the config.
|
|
91
|
+
const declaredIn = source ? ` declared in layouts/${source}` : ''
|
|
72
92
|
logger.warn(
|
|
73
|
-
{ code: 'layout-pattern-no-match', pattern, layout:
|
|
93
|
+
{ code: 'layout-pattern-no-match', pattern, layout: layoutName,
|
|
94
|
+
declaredIn: source || 'layouts.match',
|
|
74
95
|
evaluated: matchTally.evaluated, matchedAgainst: onName ? 'name' : 'id' },
|
|
75
|
-
'Layout pattern %j (→ %j) matched none of the %d entities evaluated. ' +
|
|
96
|
+
'Layout pattern %j (→ %j)%s matched none of the %d entities evaluated. ' +
|
|
76
97
|
'It is matched against entity.%s%s.',
|
|
77
|
-
pattern,
|
|
98
|
+
pattern, layoutName, declaredIn, matchTally.evaluated,
|
|
78
99
|
onName ? 'name' : 'id',
|
|
79
100
|
onName ? '' : " — prefix a pattern with '@/' to match entity.name instead, " +
|
|
80
101
|
'since an id looks like /<collection>/<name>.<ext>')
|
|
@@ -104,6 +125,42 @@ export function createOnProcessed({
|
|
|
104
125
|
return (await findEntity({ id: stateEntry.id })) || stateEntry
|
|
105
126
|
}
|
|
106
127
|
|
|
128
|
+
// `match:` in a layout's own front-matter — the colocated twin of a
|
|
129
|
+
// `layouts({ match: … })` rule, and documented alongside
|
|
130
|
+
// `destination:` since before either worked. `destination` was
|
|
131
|
+
// implemented; this was not, and a layout carrying it matched
|
|
132
|
+
// nothing, logged nothing, and looked configured. Reported after it
|
|
133
|
+
// cost a consumer the longest detour of their session.
|
|
134
|
+
//
|
|
135
|
+
// Read through resolveLayout for the same reason destination is
|
|
136
|
+
// (lib/assembly.js): the state map holds the file as synced, before
|
|
137
|
+
// front-matter has been lifted into meta. The raw entry has no
|
|
138
|
+
// `meta.match` to find.
|
|
139
|
+
//
|
|
140
|
+
// Built once per cycle rather than per entity: one findEntity per
|
|
141
|
+
// layout over a 5-20 entry map is nothing, the same work repeated
|
|
142
|
+
// for every document in the catalog is not.
|
|
143
|
+
let declaredPatterns = null
|
|
144
|
+
async function layoutDeclaredPatterns() {
|
|
145
|
+
if (declaredPatterns) return declaredPatterns
|
|
146
|
+
declaredPatterns = []
|
|
147
|
+
for (const name of Object.keys(layouts)) {
|
|
148
|
+
const declared = (await resolveLayout(name))?.meta?.match
|
|
149
|
+
if (!declared) continue
|
|
150
|
+
for (const pattern of Array.isArray(declared) ? declared : [declared]) {
|
|
151
|
+
if (typeof pattern !== 'string' || !pattern) {
|
|
152
|
+
logger.warn(
|
|
153
|
+
'Layout %j declares a non-string `match` (%j) — expected a glob, or an array of them.',
|
|
154
|
+
name, pattern)
|
|
155
|
+
continue
|
|
156
|
+
}
|
|
157
|
+
declaredPatterns.push({ pattern, layout: name, source: name })
|
|
158
|
+
declaredSeen.set(tallyKey(pattern, name), { pattern, layout: name, source: name })
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return declaredPatterns
|
|
162
|
+
}
|
|
163
|
+
|
|
107
164
|
// Resolve the set of layouts that should render `entity` this
|
|
108
165
|
// cycle. Returns an array — empty if none matched. Rules:
|
|
109
166
|
// 1. meta.layout (string) and meta.layouts (array) can't both
|
|
@@ -153,7 +210,7 @@ export function createOnProcessed({
|
|
|
153
210
|
matchTally.evaluated++
|
|
154
211
|
for (const pattern in options.match || []) {
|
|
155
212
|
if (matchEntity(entity, pattern)) {
|
|
156
|
-
matchTally.hit.add(pattern)
|
|
213
|
+
matchTally.hit.add(tallyKey(pattern, null))
|
|
157
214
|
const name = options.match[pattern]
|
|
158
215
|
const layout = await resolveLayout(name)
|
|
159
216
|
if (layout && !matched.find(l => l.name === layout.name)) {
|
|
@@ -168,6 +225,22 @@ export function createOnProcessed({
|
|
|
168
225
|
}
|
|
169
226
|
}
|
|
170
227
|
|
|
228
|
+
// Then the patterns each layout declared for itself. A peer of
|
|
229
|
+
// the config rules, not a fallback: both are "a pattern claimed
|
|
230
|
+
// this entity", and an entity legitimately matched by one of
|
|
231
|
+
// each gets both layouts, exactly as two config patterns would.
|
|
232
|
+
for (const { pattern, layout: name, source } of await layoutDeclaredPatterns()) {
|
|
233
|
+
if (!matchEntity(entity, pattern)) continue
|
|
234
|
+
matchTally.hit.add(tallyKey(pattern, source))
|
|
235
|
+
const layout = await resolveLayout(name)
|
|
236
|
+
if (layout && !matched.find(l => l.name === layout.name)) {
|
|
237
|
+
// Distinguishable from a config pattern in `--explain`:
|
|
238
|
+
// the same glob can be written in either place, and the
|
|
239
|
+
// fix goes to a different file depending on which.
|
|
240
|
+
matched.push({ ...layout, matchedBy: `${pattern} (layouts/${name})` })
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
171
244
|
// Auto-layout: only as a fallback when no pattern matched.
|
|
172
245
|
// The peel ladder is intentionally first-wins — it's a
|
|
173
246
|
// most-specific-name search, not a multi-match.
|
|
@@ -203,12 +276,28 @@ export function createOnProcessed({
|
|
|
203
276
|
switch (operation) {
|
|
204
277
|
case OPERATION.CREATE:
|
|
205
278
|
case OPERATION.UPDATE:
|
|
279
|
+
let resolutionFailed = false
|
|
206
280
|
try {
|
|
207
281
|
entity.layouts = await resolveLayoutsForEntity(entity)
|
|
208
282
|
} catch (err) {
|
|
209
283
|
logger.error('Layout resolution for %s: %s', entity.id, err.message)
|
|
210
284
|
entity.layouts = []
|
|
285
|
+
resolutionFailed = true
|
|
211
286
|
}
|
|
287
|
+
|
|
288
|
+
// Whether the AUTHOR asked for a layout. Used further
|
|
289
|
+
// down to tell an abandoned page from a typo.
|
|
290
|
+
//
|
|
291
|
+
// Captured here, before the mirror below writes
|
|
292
|
+
// meta.layout for the pattern-matched and auto-layout
|
|
293
|
+
// paths, so the field still answers one question. Read
|
|
294
|
+
// after it, "the author named a layout" and "we recorded
|
|
295
|
+
// the one we chose" are the same value. That is a
|
|
296
|
+
// legibility choice, not a fix: the mirror only writes
|
|
297
|
+
// when a layout was found, so the `layouts.length === 0`
|
|
298
|
+
// term below already excludes every entity it touched.
|
|
299
|
+
const declaredALayout = Boolean(
|
|
300
|
+
entity.meta?.layout || entity.meta?.layouts?.length)
|
|
212
301
|
// Back-compat alias. Most existing downstream code
|
|
213
302
|
// reads `entity.layout`; keep it pointing at the
|
|
214
303
|
// first matched layout so it stays useful. The
|
|
@@ -275,6 +364,41 @@ export function createOnProcessed({
|
|
|
275
364
|
)
|
|
276
365
|
}
|
|
277
366
|
|
|
367
|
+
// Tell the manifest this entity produces nothing, so the
|
|
368
|
+
// snapshots it used to hold stop claiming files nobody
|
|
369
|
+
// writes any more.
|
|
370
|
+
//
|
|
371
|
+
// Take `layout:` out of a document's front matter and the
|
|
372
|
+
// page stayed on disk with a snapshot still vouching for
|
|
373
|
+
// it — and because the file still matched the hash its own
|
|
374
|
+
// render recorded, --audit-output read OK while the site
|
|
375
|
+
// served a page the source no longer asked for. The
|
|
376
|
+
// manifest cannot see this by itself: with no render task
|
|
377
|
+
// there is nothing to compare against, and an asset whose
|
|
378
|
+
// preset threw looks exactly the same from there. This
|
|
379
|
+
// hook is where it IS knowable, so this is where it is
|
|
380
|
+
// said.
|
|
381
|
+
//
|
|
382
|
+
// Only for an entity that declared nothing and matched
|
|
383
|
+
// nothing. The two excluded cases are both errors, and
|
|
384
|
+
// mikser keeps the last good output through an error
|
|
385
|
+
// rather than taking the page down:
|
|
386
|
+
//
|
|
387
|
+
// declared but unresolvable — `layout: page` with no
|
|
388
|
+
// `page` layout, already warned about by
|
|
389
|
+
// resolveLayout. Renaming a layout file would
|
|
390
|
+
// otherwise delete every page that names it, on the
|
|
391
|
+
// strength of a typo.
|
|
392
|
+
// resolution threw — meta.layout and meta.layouts both
|
|
393
|
+
// set, say. Nothing was decided, so nothing follows.
|
|
394
|
+
//
|
|
395
|
+
// Optional-called: the peer floor is core ^11.1.0 and
|
|
396
|
+
// recordNoOutput is newer than that, so on an older
|
|
397
|
+
// engine this is simply the behaviour that came before.
|
|
398
|
+
if (entity.layouts.length === 0 && !declaredALayout && !resolutionFailed) {
|
|
399
|
+
runtime.manifest?.recordNoOutput?.(entity.id)
|
|
400
|
+
}
|
|
401
|
+
|
|
278
402
|
// meta.postprocessor (string) / meta.postprocessors
|
|
279
403
|
// (array) override is read at task-build time (in
|
|
280
404
|
// onBeforeRender) because the layout entity is
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mikser-io-layouts",
|
|
3
|
-
"version": "11.
|
|
3
|
+
"version": "11.4.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",
|
package/test/unit.test.js
CHANGED
|
@@ -210,6 +210,193 @@ describe('layouts plugin', () => {
|
|
|
210
210
|
})
|
|
211
211
|
})
|
|
212
212
|
|
|
213
|
+
it('a layout matches through `match:` in its own front-matter', async () => {
|
|
214
|
+
// Documented in the README beside `destination:` and never
|
|
215
|
+
// implemented. A layout carrying it produced no render task, logged
|
|
216
|
+
// nothing, and looked correctly configured — reported as the longest
|
|
217
|
+
// single detour of a consumer's session.
|
|
218
|
+
//
|
|
219
|
+
// The catalog entity is what carries parsed front-matter; the state
|
|
220
|
+
// map holds the file as synced. Seeding both is what a real build
|
|
221
|
+
// does, and is why reading the state map alone found no `match`.
|
|
222
|
+
await withTempWorking(async (workingFolder) => {
|
|
223
|
+
const catalog = []
|
|
224
|
+
const h = createHarness({
|
|
225
|
+
entities: catalog,
|
|
226
|
+
options: { workingFolder, outputFolder: path.join(workingFolder, 'out'), force: true },
|
|
227
|
+
})
|
|
228
|
+
layouts({ autoLayouts: false })(h.core)
|
|
229
|
+
await h.runHook('loaded')
|
|
230
|
+
await h.runSync('layouts', { action: 'create', context: { relativePath: 'post-card.hbs' } })
|
|
231
|
+
catalog.push({
|
|
232
|
+
id: h.runtime.state.layouts.layouts['post-card'].id,
|
|
233
|
+
collection: 'layouts', name: 'post-card',
|
|
234
|
+
meta: { match: '@/blog/*' },
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
const doc = { id: '/documents/blog/welcome.md', collection: 'documents', name: 'blog/welcome', format: 'md', meta: {} }
|
|
238
|
+
h.journal.push({ id: 1, entity: doc, operation: 'create', context: {}, options: {}, output: null })
|
|
239
|
+
await h.runHook('processed', { aborted: false })
|
|
240
|
+
|
|
241
|
+
assert.deepEqual(doc.layouts.map(l => l.name), ['post-card'])
|
|
242
|
+
assert.match(doc.layouts[0].matchedBy, /layouts\/post-card/,
|
|
243
|
+
'explain must say the rule came from the layout, not the config')
|
|
244
|
+
})
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
it('a layout can declare several patterns', async () => {
|
|
248
|
+
await withTempWorking(async (workingFolder) => {
|
|
249
|
+
const catalog = []
|
|
250
|
+
const h = createHarness({
|
|
251
|
+
entities: catalog,
|
|
252
|
+
options: { workingFolder, outputFolder: path.join(workingFolder, 'out'), force: true },
|
|
253
|
+
})
|
|
254
|
+
layouts({ autoLayouts: false })(h.core)
|
|
255
|
+
await h.runHook('loaded')
|
|
256
|
+
await h.runSync('layouts', { action: 'create', context: { relativePath: 'card.hbs' } })
|
|
257
|
+
catalog.push({
|
|
258
|
+
id: h.runtime.state.layouts.layouts['card'].id,
|
|
259
|
+
collection: 'layouts', name: 'card',
|
|
260
|
+
meta: { match: ['@/blog/*', '@/news/*'] },
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
const news = { id: '/documents/news/launch.md', collection: 'documents', name: 'news/launch', format: 'md', meta: {} }
|
|
264
|
+
h.journal.push({ id: 1, entity: news, operation: 'create', context: {}, options: {}, output: null })
|
|
265
|
+
await h.runHook('processed', { aborted: false })
|
|
266
|
+
|
|
267
|
+
assert.deepEqual(news.layouts.map(l => l.name), ['card'])
|
|
268
|
+
})
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
it('a front-matter pattern that selected nothing warns, naming the layout', async () => {
|
|
272
|
+
// The silence is the whole finding: without this the author of a
|
|
273
|
+
// typo'd front-matter pattern gets a green build and empty pages.
|
|
274
|
+
// Naming the layout matters because the same glob can be written in
|
|
275
|
+
// the config, and the fix lives in a different file.
|
|
276
|
+
await withTempWorking(async (workingFolder) => {
|
|
277
|
+
const catalog = []
|
|
278
|
+
const h = createHarness({
|
|
279
|
+
entities: catalog,
|
|
280
|
+
options: { workingFolder, outputFolder: path.join(workingFolder, 'out'), force: true },
|
|
281
|
+
})
|
|
282
|
+
layouts({ autoLayouts: false })(h.core)
|
|
283
|
+
await h.runHook('loaded')
|
|
284
|
+
await h.runSync('layouts', { action: 'create', context: { relativePath: 'post-card.hbs' } })
|
|
285
|
+
catalog.push({
|
|
286
|
+
id: h.runtime.state.layouts.layouts['post-card'].id,
|
|
287
|
+
collection: 'layouts', name: 'post-card',
|
|
288
|
+
meta: { match: '@/blogg/*' }, // typo
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
const doc = { id: '/documents/blog/welcome.md', collection: 'documents', name: 'blog/welcome', format: 'md', meta: {} }
|
|
292
|
+
h.journal.push({ id: 1, entity: doc, operation: 'create', context: {}, options: {}, output: null })
|
|
293
|
+
await h.runHook('processed', { aborted: false })
|
|
294
|
+
|
|
295
|
+
const warnings = h.logs.filter(l =>
|
|
296
|
+
l.level === 'warn' && l.args.join(' ').includes('Layout pattern'))
|
|
297
|
+
assert.equal(warnings.length, 1, `expected one warning, got ${warnings.length}`)
|
|
298
|
+
const [fields, ...message] = warnings[0].args
|
|
299
|
+
assert.equal(fields.code, 'layout-pattern-no-match')
|
|
300
|
+
assert.equal(fields.pattern, '@/blogg/*')
|
|
301
|
+
assert.equal(fields.declaredIn, 'post-card', 'says which layout wrote the pattern')
|
|
302
|
+
assert.match(format(...message), /layouts\/post-card/,
|
|
303
|
+
'the sentence points at the file to edit')
|
|
304
|
+
})
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
it('reports the one dud front-matter pattern among working ones', async () => {
|
|
308
|
+
// The shape this actually takes in a project: several layouts each
|
|
309
|
+
// claiming their own section, one of them typo'd. A working pattern
|
|
310
|
+
// elsewhere must not absolve the dead one — that would make the
|
|
311
|
+
// warning useless in exactly the configuration it is for.
|
|
312
|
+
await withTempWorking(async (workingFolder) => {
|
|
313
|
+
const catalog = []
|
|
314
|
+
const h = createHarness({
|
|
315
|
+
entities: catalog,
|
|
316
|
+
options: { workingFolder, outputFolder: path.join(workingFolder, 'out'), force: true },
|
|
317
|
+
})
|
|
318
|
+
layouts({ autoLayouts: false })(h.core)
|
|
319
|
+
await h.runHook('loaded')
|
|
320
|
+
await h.runSync('layouts', { action: 'create', context: { relativePath: 'post-card.hbs' } })
|
|
321
|
+
await h.runSync('layouts', { action: 'create', context: { relativePath: 'news-card.hbs' } })
|
|
322
|
+
catalog.push({
|
|
323
|
+
id: h.runtime.state.layouts.layouts['post-card'].id,
|
|
324
|
+
collection: 'layouts', name: 'post-card',
|
|
325
|
+
meta: { match: '@/blog/*' }, // works
|
|
326
|
+
})
|
|
327
|
+
catalog.push({
|
|
328
|
+
id: h.runtime.state.layouts.layouts['news-card'].id,
|
|
329
|
+
collection: 'layouts', name: 'news-card',
|
|
330
|
+
meta: { match: '@/newss/*' }, // typo
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
const doc = { id: '/documents/blog/welcome.md', collection: 'documents', name: 'blog/welcome', format: 'md', meta: {} }
|
|
334
|
+
h.journal.push({ id: 1, entity: doc, operation: 'create', context: {}, options: {}, output: null })
|
|
335
|
+
await h.runHook('processed', { aborted: false })
|
|
336
|
+
|
|
337
|
+
assert.deepEqual(doc.layouts.map(l => l.name), ['post-card'], 'the good pattern still matched')
|
|
338
|
+
const warnings = h.logs.filter(l =>
|
|
339
|
+
l.level === 'warn' && l.args.join(' ').includes('Layout pattern'))
|
|
340
|
+
assert.equal(warnings.length, 1, `expected exactly one warning, got ${warnings.length}`)
|
|
341
|
+
assert.equal(warnings[0].args[0].declaredIn, 'news-card', 'names the dud, not the working one')
|
|
342
|
+
})
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
it('a front-matter pattern is a peer of a config pattern, not a fallback', async () => {
|
|
346
|
+
// Two rules claiming one entity give two layouts, exactly as two
|
|
347
|
+
// config patterns would. Making front-matter a fallback would mean
|
|
348
|
+
// adding a config rule silently switched a layout off.
|
|
349
|
+
await withTempWorking(async (workingFolder) => {
|
|
350
|
+
const catalog = []
|
|
351
|
+
const h = createHarness({
|
|
352
|
+
entities: catalog,
|
|
353
|
+
options: { workingFolder, outputFolder: path.join(workingFolder, 'out'), force: true },
|
|
354
|
+
})
|
|
355
|
+
layouts({ autoLayouts: false, match: { '@/blog/*': 'post' } })(h.core)
|
|
356
|
+
await h.runHook('loaded')
|
|
357
|
+
await h.runSync('layouts', { action: 'create', context: { relativePath: 'post.hbs' } })
|
|
358
|
+
await h.runSync('layouts', { action: 'create', context: { relativePath: 'post-card.hbs' } })
|
|
359
|
+
catalog.push({
|
|
360
|
+
id: h.runtime.state.layouts.layouts['post-card'].id,
|
|
361
|
+
collection: 'layouts', name: 'post-card',
|
|
362
|
+
meta: { match: '@/blog/*' },
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
const doc = { id: '/documents/blog/welcome.md', collection: 'documents', name: 'blog/welcome', format: 'md', meta: {} }
|
|
366
|
+
h.journal.push({ id: 1, entity: doc, operation: 'create', context: {}, options: {}, output: null })
|
|
367
|
+
await h.runHook('processed', { aborted: false })
|
|
368
|
+
|
|
369
|
+
assert.deepEqual(doc.layouts.map(l => l.name).sort(), ['post', 'post-card'])
|
|
370
|
+
})
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
it('an author-declared meta.layout still wins over a front-matter pattern', async () => {
|
|
374
|
+
// Precedence is unchanged: what the document asks for beats what a
|
|
375
|
+
// layout claims, the same way it beats a config pattern.
|
|
376
|
+
await withTempWorking(async (workingFolder) => {
|
|
377
|
+
const catalog = []
|
|
378
|
+
const h = createHarness({
|
|
379
|
+
entities: catalog,
|
|
380
|
+
options: { workingFolder, outputFolder: path.join(workingFolder, 'out'), force: true },
|
|
381
|
+
})
|
|
382
|
+
layouts({ autoLayouts: false })(h.core)
|
|
383
|
+
await h.runHook('loaded')
|
|
384
|
+
await h.runSync('layouts', { action: 'create', context: { relativePath: 'post.hbs' } })
|
|
385
|
+
await h.runSync('layouts', { action: 'create', context: { relativePath: 'post-card.hbs' } })
|
|
386
|
+
catalog.push({
|
|
387
|
+
id: h.runtime.state.layouts.layouts['post-card'].id,
|
|
388
|
+
collection: 'layouts', name: 'post-card',
|
|
389
|
+
meta: { match: '@/blog/*' },
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
const doc = { id: '/documents/blog/welcome.md', collection: 'documents', name: 'blog/welcome', format: 'md', meta: { layout: 'post' } }
|
|
393
|
+
h.journal.push({ id: 1, entity: doc, operation: 'create', context: {}, options: {}, output: null })
|
|
394
|
+
await h.runHook('processed', { aborted: false })
|
|
395
|
+
|
|
396
|
+
assert.deepEqual(doc.layouts.map(l => l.name), ['post'])
|
|
397
|
+
})
|
|
398
|
+
})
|
|
399
|
+
|
|
213
400
|
it('stays silent about an unmatched pattern on an incremental cycle', async () => {
|
|
214
401
|
// The inverse of the test above, and the reason the gate exists. An
|
|
215
402
|
// incremental run re-evaluates only what changed, so a pattern that
|
|
@@ -728,6 +915,112 @@ describe('layouts plugin: multi-layouts (matching)', () => {
|
|
|
728
915
|
})
|
|
729
916
|
})
|
|
730
917
|
|
|
918
|
+
describe('layouts plugin: reporting that an entity renders nothing', () => {
|
|
919
|
+
// The manifest keeps a snapshot per (entity, destination) — its claim that
|
|
920
|
+
// the entity produced that file. An entity that STOPS producing output
|
|
921
|
+
// leaves the claim behind, and the page with it: still on disk, still
|
|
922
|
+
// matching the hash its own render recorded, so --audit-output reads OK
|
|
923
|
+
// while the site serves something the source no longer asks for.
|
|
924
|
+
//
|
|
925
|
+
// The manifest cannot see this alone. With no render task there is nothing
|
|
926
|
+
// to compare a claim against, and an asset whose preset threw looks
|
|
927
|
+
// identical from there — so guessing deletes the good derivative a failed
|
|
928
|
+
// preset exists to keep. This hook knows, so this hook says.
|
|
929
|
+
//
|
|
930
|
+
// What must NOT be reported is the whole difficulty. Both exclusions are
|
|
931
|
+
// errors, and mikser's rule through an error is that the last good output
|
|
932
|
+
// survives rather than the page coming down.
|
|
933
|
+
async function report(files, doc, options = {}) {
|
|
934
|
+
return withTempWorking(async (workingFolder) => {
|
|
935
|
+
const h = createHarness({
|
|
936
|
+
options: { workingFolder, outputFolder: path.join(workingFolder, 'out') },
|
|
937
|
+
})
|
|
938
|
+
const reported = []
|
|
939
|
+
h.runtime.manifest = { recordNoOutput: (id) => reported.push(id) }
|
|
940
|
+
layouts(options)(h.core)
|
|
941
|
+
await h.runHook('loaded')
|
|
942
|
+
for (const relativePath of files) {
|
|
943
|
+
await h.runSync('layouts', { action: 'create', context: { relativePath } })
|
|
944
|
+
}
|
|
945
|
+
h.journal.push({ id: 1, entity: doc, operation: 'create', context: {}, options: {}, output: null })
|
|
946
|
+
await h.runHook('processed', { aborted: false })
|
|
947
|
+
return { reported, doc, logs: h.logs }
|
|
948
|
+
})
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
const plainDoc = (meta) => ({
|
|
952
|
+
id: '/documents/blog/welcome.md',
|
|
953
|
+
collection: 'documents',
|
|
954
|
+
name: 'blog/welcome',
|
|
955
|
+
format: 'md',
|
|
956
|
+
meta,
|
|
957
|
+
})
|
|
958
|
+
|
|
959
|
+
it('reports an entity that declared nothing and matched nothing', async () => {
|
|
960
|
+
const { reported, doc } = await report(['post.hbs'], plainDoc({}))
|
|
961
|
+
assert.deepEqual(doc.layouts, [], 'precondition: nothing resolved')
|
|
962
|
+
assert.deepEqual(reported, ['/documents/blog/welcome.md'])
|
|
963
|
+
})
|
|
964
|
+
|
|
965
|
+
it('stays quiet when a declared layout merely cannot be found', async () => {
|
|
966
|
+
// A typo, or a layout file renamed. The entity still says which layout
|
|
967
|
+
// it wants, so this is an error to fix — not a page to withdraw.
|
|
968
|
+
// Reporting it would delete every page naming a layout the moment that
|
|
969
|
+
// layout's filename changed.
|
|
970
|
+
const { reported, doc, logs } = await report(['post.hbs'], plainDoc({ layout: 'pst' }))
|
|
971
|
+
assert.deepEqual(doc.layouts, [], 'precondition: nothing resolved')
|
|
972
|
+
assert.ok(logs.some(l => l.args.join(' ').includes('Layout not found')),
|
|
973
|
+
'precondition: it really did fail to resolve')
|
|
974
|
+
assert.deepEqual(reported, [])
|
|
975
|
+
})
|
|
976
|
+
|
|
977
|
+
it('stays quiet when resolution threw', async () => {
|
|
978
|
+
// meta.layout and meta.layouts both set. Nothing was decided, so
|
|
979
|
+
// nothing follows from it.
|
|
980
|
+
const { reported, doc } = await report(
|
|
981
|
+
['post.hbs'], plainDoc({ layout: 'post', layouts: ['post'] }))
|
|
982
|
+
assert.deepEqual(doc.layouts, [], 'precondition: resolution failed')
|
|
983
|
+
assert.deepEqual(reported, [])
|
|
984
|
+
})
|
|
985
|
+
|
|
986
|
+
it('stays quiet when a layout did match', async () => {
|
|
987
|
+
const { reported, doc } = await report(['post.hbs'], plainDoc({ layout: 'post' }))
|
|
988
|
+
assert.equal(doc.layouts.length, 1, 'precondition: it resolved')
|
|
989
|
+
assert.deepEqual(reported, [])
|
|
990
|
+
})
|
|
991
|
+
|
|
992
|
+
it('stays quiet when a CONFIG PATTERN matched and the author declared nothing', async () => {
|
|
993
|
+
// The case that pins down which question `declaredALayout` answers.
|
|
994
|
+
// The mirror block writes meta.layout for pattern-matched and
|
|
995
|
+
// auto-layout entities, so if it were read after that, "the author
|
|
996
|
+
// named a layout" and "we recorded the one we chose" would be the same
|
|
997
|
+
// field — and an entity that rendered perfectly well would be reported
|
|
998
|
+
// as producing nothing, taking its own fresh output off disk.
|
|
999
|
+
const { reported, doc } = await report(
|
|
1000
|
+
['post.hbs'], plainDoc({}), { match: { '@/blog/*': 'post' } })
|
|
1001
|
+
assert.equal(doc.layouts.length, 1, 'precondition: the pattern matched')
|
|
1002
|
+
assert.equal(doc.meta.layout, 'post', 'precondition: and the mirror recorded it')
|
|
1003
|
+
assert.deepEqual(reported, [])
|
|
1004
|
+
})
|
|
1005
|
+
|
|
1006
|
+
it('does not require the engine to offer recordNoOutput at all', async () => {
|
|
1007
|
+
// The peer floor is core ^11.1.0 and recordNoOutput is newer, so an
|
|
1008
|
+
// entity that renders nothing on an older engine has to be a quiet
|
|
1009
|
+
// no-op rather than a crash mid-cycle.
|
|
1010
|
+
await withTempWorking(async (workingFolder) => {
|
|
1011
|
+
const h = createHarness({
|
|
1012
|
+
options: { workingFolder, outputFolder: path.join(workingFolder, 'out') },
|
|
1013
|
+
})
|
|
1014
|
+
assert.equal(h.runtime.manifest, undefined, 'precondition: no manifest on the runtime')
|
|
1015
|
+
layouts()(h.core)
|
|
1016
|
+
await h.runHook('loaded')
|
|
1017
|
+
await h.runSync('layouts', { action: 'create', context: { relativePath: 'post.hbs' } })
|
|
1018
|
+
h.journal.push({ id: 1, entity: plainDoc({}), operation: 'create', context: {}, options: {}, output: null })
|
|
1019
|
+
await h.runHook('processed', { aborted: false })
|
|
1020
|
+
})
|
|
1021
|
+
})
|
|
1022
|
+
})
|
|
1023
|
+
|
|
731
1024
|
describe('layouts plugin: multi-layouts (collision)', () => {
|
|
732
1025
|
it('drops both render tasks when two layouts produce the same destination', async () => {
|
|
733
1026
|
await withTempWorking(async (workingFolder) => {
|