mikser-io-layouts 1.0.0 → 2.3.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/README.md +38 -2
- package/index.js +113 -4
- package/lib/assembly.js +8 -23
- package/lib/destination.js +82 -1
- package/lib/matching.js +103 -3
- package/package.json +5 -2
- package/test/unit.test.js +305 -1
package/README.md
CHANGED
|
@@ -30,9 +30,11 @@ export default {
|
|
|
30
30
|
|
|
31
31
|
## Factory options
|
|
32
32
|
|
|
33
|
+
Every value below is the **default**; pass an option only to change it.
|
|
34
|
+
|
|
33
35
|
```js
|
|
34
36
|
layouts({
|
|
35
|
-
layoutsFolder: 'layouts',
|
|
37
|
+
layoutsFolder: 'layouts',
|
|
36
38
|
|
|
37
39
|
// Each pattern that matches contributes a render task. Multi-match:
|
|
38
40
|
// an entity at /blog/welcome.md matches both '@/blog/*' (post) AND
|
|
@@ -44,10 +46,21 @@ layouts({
|
|
|
44
46
|
},
|
|
45
47
|
|
|
46
48
|
autoLayouts: true, // Auto-match by name (single-match fallback; see below)
|
|
47
|
-
cleanUrls: true
|
|
49
|
+
cleanUrls: true, // /page → /page/index.html, so the served URL is /page/
|
|
48
50
|
})
|
|
49
51
|
```
|
|
50
52
|
|
|
53
|
+
`cleanUrls` already special-cases names ending in `index` (`index` stays
|
|
54
|
+
`/index.html`, it does not become `/index/index.html`). Reaching for a
|
|
55
|
+
`destination:` template to get pretty URLs reinvents this — and breaks index
|
|
56
|
+
pages, because the template fully overrides the transform.
|
|
57
|
+
|
|
58
|
+
`match` has no default: with no patterns and `autoLayouts` on, layouts are
|
|
59
|
+
found by name.
|
|
60
|
+
|
|
61
|
+
```js
|
|
62
|
+
```
|
|
63
|
+
|
|
51
64
|
## Per-entity selection — `meta.layout` / `meta.layouts`
|
|
52
65
|
|
|
53
66
|
An entity can override match-based assignment via frontmatter:
|
|
@@ -82,6 +95,29 @@ Cross-directory auto-matching is intentionally not supported — pair `posts/art
|
|
|
82
95
|
|
|
83
96
|
A layout's frontmatter can declare a `destination:` template. The template is Handlebars (path-shaped — substitutions only, no body rendering); it gets `{ entity }` as the context, is compiled once and cached, and the result is path-sanitized (`..` segments rejected). This fully overrides the default `entity.name + .format` derivation (and the cleanUrls folder transform).
|
|
84
97
|
|
|
98
|
+
A small set of path-shaping helpers is available, so a destination can be
|
|
99
|
+
derived from *part* of a value rather than only interpolating whole ones:
|
|
100
|
+
|
|
101
|
+
| helper | |
|
|
102
|
+
| --- | --- |
|
|
103
|
+
| `after v sep` / `before v sep` | split at the **first** separator |
|
|
104
|
+
| `afterLast v sep` / `beforeLast v sep` | split at the **last** separator |
|
|
105
|
+
| `replace v search replacement` | literal, every occurrence — not a regex |
|
|
106
|
+
| `dirname v` / `basename v` | POSIX path parts |
|
|
107
|
+
| `lower v` / `upper v` | case |
|
|
108
|
+
|
|
109
|
+
The case they exist for is per-language output roots. With three languages in
|
|
110
|
+
one catalog under `documents/{lang}/…`, `entity.name` is `bg/kontakti` and each
|
|
111
|
+
language wants its own document root:
|
|
112
|
+
|
|
113
|
+
```yaml
|
|
114
|
+
destination: /{{ after entity.name '/' }}/index.html # → /kontakti/index.html
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
They are registered on an **isolated** Handlebars instance, so nothing appears
|
|
118
|
+
in your own `renderHbs` layouts. Anything more than path slicing belongs in the
|
|
119
|
+
layout's `.js` sidecar rather than in a template.
|
|
120
|
+
|
|
85
121
|
```yaml
|
|
86
122
|
# layouts/post-card.html.hbs
|
|
87
123
|
---
|
package/index.js
CHANGED
|
@@ -19,6 +19,7 @@ import _ from 'lodash'
|
|
|
19
19
|
import {
|
|
20
20
|
gateChecksum, sweepDeleted, scanSummary,
|
|
21
21
|
checksumsByCollection,
|
|
22
|
+
checksum as fileChecksum, checksumOf,
|
|
22
23
|
useDatabase,
|
|
23
24
|
} from 'mikser-io'
|
|
24
25
|
|
|
@@ -27,7 +28,16 @@ import { createOnProcessed } from './lib/matching.js'
|
|
|
27
28
|
import { createOnBeforeRender } from './lib/assembly.js'
|
|
28
29
|
import { registerMcpTools } from './lib/mcp.js'
|
|
29
30
|
|
|
30
|
-
export function layouts(
|
|
31
|
+
export function layouts(userOptions = {}) {
|
|
32
|
+
// cleanUrls DEFAULTS TO TRUE. It previously had no default, so unset it
|
|
33
|
+
// was falsy and pages landed at `hera.html` instead of `hera/index.html`
|
|
34
|
+
// — while the README's options block presented `cleanUrls: true` in a way
|
|
35
|
+
// that reads as a defaults table. There was no warning, the site worked,
|
|
36
|
+
// and the wrong URL shape was everywhere: the kind of thing discovered
|
|
37
|
+
// after canonical tags and hreflang are wired against the shape you
|
|
38
|
+
// assumed. Defaulting it matches both the README and what almost every
|
|
39
|
+
// site wants; pass `cleanUrls: false` for flat output.
|
|
40
|
+
const options = { cleanUrls: true, ...userOptions }
|
|
31
41
|
return ({
|
|
32
42
|
runtime,
|
|
33
43
|
onLoaded,
|
|
@@ -95,7 +105,29 @@ export function layouts(options = {}) {
|
|
|
95
105
|
|
|
96
106
|
onSync(collection, async ({ action, context }) => {
|
|
97
107
|
if (!context.relativePath) return false
|
|
108
|
+
const logger = useLogger()
|
|
98
109
|
const { relativePath } = context
|
|
110
|
+
|
|
111
|
+
// A .js file under the layouts folder is a SIDECAR (or something
|
|
112
|
+
// a sidecar imports), never a layout. It used to have its `.js`
|
|
113
|
+
// stripped and be created as an entity, which produced a phantom
|
|
114
|
+
// `/layouts/page` — holding JS source as its content — beside the
|
|
115
|
+
// real `/layouts/page.liquid`.
|
|
116
|
+
//
|
|
117
|
+
// Sidecars are inputs to a layout's checksum instead (see
|
|
118
|
+
// sidecarInputs above), so the right response to one changing is
|
|
119
|
+
// to re-run the layouts scan: it recomputes every composite and
|
|
120
|
+
// emits an UPDATE for whichever layouts actually moved, which is
|
|
121
|
+
// what makes their dependents re-render.
|
|
122
|
+
if (_.endsWith(relativePath, '.js') && isSidecarScript(relativePath)) {
|
|
123
|
+
logger.debug('Layouts sidecar changed (%s) — rescanning layouts', relativePath)
|
|
124
|
+
await rescanLayouts()
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// A JS-authored layout (post.hbs.js) keeps its historical
|
|
129
|
+
// behaviour: the trailing .js is dropped so the id is
|
|
130
|
+
// /layouts/post.hbs.
|
|
99
131
|
let id = path.join(`/${collection}`, relativePath)
|
|
100
132
|
if (_.endsWith(id, '.js')) id = id.replace(new RegExp('.js$'), '')
|
|
101
133
|
|
|
@@ -187,10 +219,72 @@ export function layouts(options = {}) {
|
|
|
187
219
|
}
|
|
188
220
|
})
|
|
189
221
|
|
|
190
|
-
|
|
222
|
+
// A layout's `.js` sidecar is where a mikser site's data layer lives,
|
|
223
|
+
// and it was the one file in the project that could be edited without
|
|
224
|
+
// effect: `.js` is excluded from the scan below, so the sidecar was
|
|
225
|
+
// not an entity, not watched as an input, and not part of any hash.
|
|
226
|
+
// Nothing depended on it, so nothing re-rendered — silently, and
|
|
227
|
+
// looking exactly like a bug in your own code.
|
|
228
|
+
//
|
|
229
|
+
// Fixed by folding sidecars into the LAYOUT's checksum rather than
|
|
230
|
+
// making them entities. A sidecar is an input to a layout, not a
|
|
231
|
+
// thing a site has; giving it an entity would put JS source in the
|
|
232
|
+
// catalog and (via the id-stripping in onSync) a phantom
|
|
233
|
+
// `/layouts/page` alongside the real `/layouts/page.liquid`.
|
|
234
|
+
//
|
|
235
|
+
// Two components:
|
|
236
|
+
// own — the layout's own sidecar, `<name>.js`
|
|
237
|
+
// shared — every OTHER .js under the folder, as one digest
|
|
238
|
+
//
|
|
239
|
+
// `shared` is coarse on purpose: a sidecar's own imports
|
|
240
|
+
// (layouts/lib/context.js and friends) would otherwise need a module
|
|
241
|
+
// graph walk. Treating any shared .js change as invalidating every
|
|
242
|
+
// layout is what the folder can afford — tens of files, not
|
|
243
|
+
// thousands — and it is predictable, which a partial graph walk
|
|
244
|
+
// would not be.
|
|
245
|
+
// NOT every .js under the folder is a sidecar. `post.hbs.js` is a
|
|
246
|
+
// LAYOUT — a template written as JS, whose id drops the trailing .js
|
|
247
|
+
// to become /layouts/post.hbs. `post.js` is the sidecar for layout
|
|
248
|
+
// `post`. The discriminator is whether stripping `.js` leaves a
|
|
249
|
+
// further extension:
|
|
250
|
+
//
|
|
251
|
+
// post.js → post no extension → sidecar
|
|
252
|
+
// lib/context.js → lib/context no extension → sidecar-adjacent
|
|
253
|
+
// post.hbs.js → post.hbs .hbs → layout, leave alone
|
|
254
|
+
//
|
|
255
|
+
// Getting this wrong turns every JS-authored layout into a
|
|
256
|
+
// non-entity, which is why it is a rule and not a guess.
|
|
257
|
+
const isSidecarScript = (rel) => path.extname(rel.replace(/\.js$/, '')) === ''
|
|
258
|
+
|
|
259
|
+
async function sidecarInputs() {
|
|
260
|
+
const scriptPaths = (await globby('**/*.js', { cwd: runtime.options.layoutsFolder }))
|
|
261
|
+
.filter(isSidecarScript)
|
|
262
|
+
const own = new Map()
|
|
263
|
+
const shared = []
|
|
264
|
+
for (const rel of scriptPaths.sort()) {
|
|
265
|
+
const sum = await fileChecksum(path.join(runtime.options.layoutsFolder, rel))
|
|
266
|
+
own.set(rel.replace(/\.js$/, ''), sum)
|
|
267
|
+
shared.push(`${rel}:${sum}`)
|
|
268
|
+
}
|
|
269
|
+
return { own, sharedDigest: shared.length ? checksumOf(shared.join('\n')) : '' }
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// The value the checksum gate compares. Passed as `bytes` so the gate
|
|
273
|
+
// keeps owning the --force / cache-invalidated / prior-checksum
|
|
274
|
+
// logic instead of this plugin reimplementing it.
|
|
275
|
+
async function layoutInputBytes(uri, name, inputs) {
|
|
276
|
+
const template = await fileChecksum(uri)
|
|
277
|
+
const sidecar = inputs.own.get(name) ?? ''
|
|
278
|
+
return Buffer.from(`${template}:${sidecar}:${inputs.sharedDigest}`, 'utf8')
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Named so onSync can re-run it when a sidecar changes. A function
|
|
282
|
+
// declaration, so it is hoisted above the onSync registration above.
|
|
283
|
+
async function rescanLayouts() {
|
|
191
284
|
const { layouts } = runtime.state.layouts
|
|
192
285
|
const logger = useLogger()
|
|
193
286
|
const paths = await globby('**/*', { cwd: runtime.options.layoutsFolder, ignore: ['**/*.js'] })
|
|
287
|
+
const inputs = await sidecarInputs()
|
|
194
288
|
const scanned = new Set()
|
|
195
289
|
const stats = { emitted: 0, skipped: 0, deleted: 0 }
|
|
196
290
|
|
|
@@ -212,7 +306,11 @@ export function layouts(options = {}) {
|
|
|
212
306
|
const id = path.join('/layouts', relativePath)
|
|
213
307
|
scanned.add(id)
|
|
214
308
|
|
|
215
|
-
const
|
|
309
|
+
const name = relativePath.replace(path.extname(relativePath), '')
|
|
310
|
+
const chksum = await gateChecksum(uri, id, {
|
|
311
|
+
priorChecksums,
|
|
312
|
+
bytes: await layoutInputBytes(uri, name, inputs),
|
|
313
|
+
})
|
|
216
314
|
if (chksum === null) {
|
|
217
315
|
stats.skipped++
|
|
218
316
|
continue
|
|
@@ -225,6 +323,15 @@ export function layouts(options = {}) {
|
|
|
225
323
|
type,
|
|
226
324
|
content: await readLayoutContent(uri),
|
|
227
325
|
checksum: chksum,
|
|
326
|
+
// Declared so inputHashOf folds the sidecars into this
|
|
327
|
+
// layout's input hash. Without it the gate checksum
|
|
328
|
+
// moves but the hash does not — an entity that has
|
|
329
|
+
// content is hashed on {meta, content} — so consumers
|
|
330
|
+
// still skip and the sidecar edit reaches nothing.
|
|
331
|
+
inputs: {
|
|
332
|
+
sidecar: inputs.own.get(name) ?? null,
|
|
333
|
+
shared: inputs.sharedDigest || null,
|
|
334
|
+
},
|
|
228
335
|
}
|
|
229
336
|
Object.assign(layout, await getFormatInfo(relativePath))
|
|
230
337
|
layouts[layout.name] = layout
|
|
@@ -250,7 +357,9 @@ export function layouts(options = {}) {
|
|
|
250
357
|
// protection as CSV-emitted documents).
|
|
251
358
|
|
|
252
359
|
logger.info(scanSummary({ cap: 'Layouts', loaded: paths.length, ...stats }))
|
|
253
|
-
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
onImport(rescanLayouts)
|
|
254
363
|
|
|
255
364
|
// Matching + assembly are extracted to lib/ for cognitive load
|
|
256
365
|
// (~185 + ~360 lines respectively). They still close over the
|
package/lib/assembly.js
CHANGED
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
queryContext,
|
|
27
27
|
useDatabase,
|
|
28
28
|
} from 'mikser-io'
|
|
29
|
-
import { compileDestinationTemplate, sanitizeDestination } from './destination.js'
|
|
29
|
+
import { compileDestinationTemplate, sanitizeDestination, primaryDestination } from './destination.js'
|
|
30
30
|
|
|
31
31
|
export function createOnBeforeRender({
|
|
32
32
|
runtime, useLogger, useJournal,
|
|
@@ -342,28 +342,13 @@ export function createOnBeforeRender({
|
|
|
342
342
|
}
|
|
343
343
|
}
|
|
344
344
|
} else {
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
// stone has no served URL), and leaving it flat
|
|
353
|
-
// means engine.onPostprocess's plain extension swap
|
|
354
|
-
// produces `/foo.<ext>` naturally. Postprocess is
|
|
355
|
-
// decoupled from cleanUrls by where the decision
|
|
356
|
-
// gets made — not by the engine knowing about it.
|
|
357
|
-
if (options.cleanUrls
|
|
358
|
-
&& !_.endsWith(entity.name, 'index')
|
|
359
|
-
&& entity.layout.format == 'html'
|
|
360
|
-
&& !entity.layout.postprocessor
|
|
361
|
-
) {
|
|
362
|
-
entity.destination = path.join(entity.destination, `index.${entity.layout.format}`)
|
|
363
|
-
} else {
|
|
364
|
-
entity.destination += `.${entity.layout.format}`
|
|
365
|
-
}
|
|
366
|
-
}
|
|
345
|
+
// Same derivation onProcessed persists onto the catalog
|
|
346
|
+
// entity — one function, so a stored destination and the
|
|
347
|
+
// file actually written cannot disagree. Drift here would
|
|
348
|
+
// produce links pointing at files that are not there.
|
|
349
|
+
entity.destination = primaryDestination({
|
|
350
|
+
entity, layout: entity.layout, options, endsWith: _.endsWith,
|
|
351
|
+
})
|
|
367
352
|
if (entity.destination) {
|
|
368
353
|
queueTask(entity, {
|
|
369
354
|
renderer: entity.layout.template,
|
package/lib/destination.js
CHANGED
|
@@ -11,9 +11,48 @@ import handlebars from 'handlebars'
|
|
|
11
11
|
|
|
12
12
|
const destinationTemplateCache = new Map()
|
|
13
13
|
|
|
14
|
+
// An ISOLATED Handlebars instance. Registering helpers on the shared default
|
|
15
|
+
// export would leak them into anything else in the process using handlebars —
|
|
16
|
+
// including a project's own renderHbs layouts, where a helper named `replace`
|
|
17
|
+
// appearing from nowhere would be a genuinely confusing surprise.
|
|
18
|
+
const hbs = handlebars.create()
|
|
19
|
+
|
|
20
|
+
const str = (value) => (value == null ? '' : String(value))
|
|
21
|
+
|
|
22
|
+
// A destination template can only interpolate whole values without these, so
|
|
23
|
+
// a path cannot be derived from PART of entity.name. The case that needs it:
|
|
24
|
+
// a catalog holding three languages under documents/{lang}/…, where
|
|
25
|
+
// entity.name is `bg/kontakti` and the wanted destination is
|
|
26
|
+
// `/kontakti/index.html` — name minus the language segment. Without helpers
|
|
27
|
+
// that is inexpressible, and the workarounds are a sidecar mutating the
|
|
28
|
+
// entity mid-assembly (a side effect where there should be none) or one
|
|
29
|
+
// documents() per language (which empties the catalog of the other languages
|
|
30
|
+
// and breaks the hreflang and language switcher that needed one catalog).
|
|
31
|
+
//
|
|
32
|
+
// Deliberately a small, path-shaped set. These are for slicing a path, not a
|
|
33
|
+
// template language: anything more belongs in the layout's .js sidecar.
|
|
34
|
+
const helpers = {
|
|
35
|
+
// `{{ after entity.name '/' }}` bg/kontakti → kontakti
|
|
36
|
+
after: (value, sep) => { const s = str(value); const i = s.indexOf(str(sep)); return i < 0 ? s : s.slice(i + str(sep).length) },
|
|
37
|
+
// `{{ before entity.name '/' }}` bg/kontakti → bg
|
|
38
|
+
before: (value, sep) => { const s = str(value); const i = s.indexOf(str(sep)); return i < 0 ? s : s.slice(0, i) },
|
|
39
|
+
afterLast: (value, sep) => { const s = str(value); const i = s.lastIndexOf(str(sep)); return i < 0 ? s : s.slice(i + str(sep).length) },
|
|
40
|
+
beforeLast: (value, sep) => { const s = str(value); const i = s.lastIndexOf(str(sep)); return i < 0 ? s : s.slice(0, i) },
|
|
41
|
+
// Literal, every occurrence — NOT a regex, because a destination is a
|
|
42
|
+
// path and a stray metacharacter should not silently change the match.
|
|
43
|
+
replace: (value, search, replacement) => str(value).split(str(search)).join(str(replacement)),
|
|
44
|
+
dirname: (value) => path.posix.dirname(str(value)),
|
|
45
|
+
basename: (value, ext) => path.posix.basename(str(value), typeof ext === 'string' ? ext : undefined),
|
|
46
|
+
lower: (value) => str(value).toLowerCase(),
|
|
47
|
+
upper: (value) => str(value).toUpperCase(),
|
|
48
|
+
}
|
|
49
|
+
for (const [name, fn] of Object.entries(helpers)) hbs.registerHelper(name, fn)
|
|
50
|
+
|
|
51
|
+
export const destinationHelpers = Object.keys(helpers)
|
|
52
|
+
|
|
14
53
|
export function compileDestinationTemplate(template) {
|
|
15
54
|
if (!destinationTemplateCache.has(template)) {
|
|
16
|
-
destinationTemplateCache.set(template,
|
|
55
|
+
destinationTemplateCache.set(template, hbs.compile(template))
|
|
17
56
|
}
|
|
18
57
|
return destinationTemplateCache.get(template)
|
|
19
58
|
}
|
|
@@ -31,3 +70,45 @@ export function sanitizeDestination(p) {
|
|
|
31
70
|
}
|
|
32
71
|
return leading + normalized
|
|
33
72
|
}
|
|
73
|
+
|
|
74
|
+
// The PRIMARY destination for an entity under one layout — page 1 of a
|
|
75
|
+
// paginated set, or the only output of an unpaginated one.
|
|
76
|
+
//
|
|
77
|
+
// Extracted so it can run in TWO places that must agree:
|
|
78
|
+
//
|
|
79
|
+
// onProcessed (matching.js) persists it onto the catalog entity, because
|
|
80
|
+
// the catalog is only ever written through the
|
|
81
|
+
// journal and onProcessed is where that happens.
|
|
82
|
+
// Without this, entity.destination existed only
|
|
83
|
+
// as a per-render-task field and was never
|
|
84
|
+
// stored — so runtime.href() had nothing to
|
|
85
|
+
// resolve a TARGET page's URL from, and returned
|
|
86
|
+
// the whole entity instead of { url }.
|
|
87
|
+
// onBeforeRender (assembly) derives each task's destination, including the
|
|
88
|
+
// per-page variants this function does not cover.
|
|
89
|
+
//
|
|
90
|
+
// Two copies of this derivation would drift, and the failure mode of drift is
|
|
91
|
+
// a URL that resolves to a file that is not there.
|
|
92
|
+
export function primaryDestination({ entity, layout, options = {}, endsWith }) {
|
|
93
|
+
const template = layout?.meta?.destination
|
|
94
|
+
if (template) {
|
|
95
|
+
return sanitizeDestination(compileDestinationTemplate(template)({ entity }))
|
|
96
|
+
}
|
|
97
|
+
// A name already carrying its format is a passthrough — the source file
|
|
98
|
+
// IS the output shape (e.g. `robots.txt`).
|
|
99
|
+
if (endsWith(entity.name, entity.format)) return entity.destination ?? null
|
|
100
|
+
|
|
101
|
+
const base = '/' + entity.name
|
|
102
|
+
// cleanUrls turns `foo` into `foo/index.html` so the served URL is
|
|
103
|
+
// `/foo/`. Skipped when the layout declares a postprocessor: that HTML
|
|
104
|
+
// render is a throwaway stepping stone with no served URL, and leaving
|
|
105
|
+
// it flat lets the engine's extension swap produce `/foo.<ext>`.
|
|
106
|
+
if (options.cleanUrls
|
|
107
|
+
&& !endsWith(entity.name, 'index')
|
|
108
|
+
&& layout?.format === 'html'
|
|
109
|
+
&& !layout?.postprocessor
|
|
110
|
+
) {
|
|
111
|
+
return path.posix.join(base, `index.${layout.format}`)
|
|
112
|
+
}
|
|
113
|
+
return `${base}.${layout?.format}`
|
|
114
|
+
}
|
package/lib/matching.js
CHANGED
|
@@ -20,12 +20,63 @@
|
|
|
20
20
|
|
|
21
21
|
import path from 'node:path'
|
|
22
22
|
|
|
23
|
+
import _ from 'lodash'
|
|
24
|
+
import { primaryDestination } from './destination.js'
|
|
25
|
+
import { reportWarning } from 'mikser-io'
|
|
26
|
+
|
|
23
27
|
export function createOnProcessed({
|
|
24
28
|
runtime, useLogger, useJournal,
|
|
25
29
|
findEntity, matchEntity, collection,
|
|
26
30
|
OPERATION,
|
|
27
31
|
options,
|
|
28
32
|
}) {
|
|
33
|
+
// A `match` pattern that selects nothing is silent — no warn, no debug —
|
|
34
|
+
// and the build goes green with those pages simply absent. The patterns
|
|
35
|
+
// run against entity.name, so on a localized site they carry real content
|
|
36
|
+
// ('@/*/{aparati,devices}/index'); adding a language means adding its
|
|
37
|
+
// segment, and missing one loses a whole section while the other
|
|
38
|
+
// languages still look right.
|
|
39
|
+
//
|
|
40
|
+
// Tallied across the process, reported once. Same reasoning as the
|
|
41
|
+
// assets-preset case in core: a deliberate declaration that selected
|
|
42
|
+
// nothing is essentially always a mistake, and it is exactly what the
|
|
43
|
+
// author cannot see.
|
|
44
|
+
const matchTally = { evaluated: 0, hit: new Set(), reported: false }
|
|
45
|
+
|
|
46
|
+
function reportUnmatchedPatterns(logger) {
|
|
47
|
+
if (matchTally.reported) return
|
|
48
|
+
const patterns = Object.keys(options.match || {})
|
|
49
|
+
if (!patterns.length || !matchTally.evaluated) return
|
|
50
|
+
matchTally.reported = true
|
|
51
|
+
|
|
52
|
+
for (const pattern of patterns) {
|
|
53
|
+
if (matchTally.hit.has(pattern)) continue
|
|
54
|
+
// Phrased as what was observed: an incremental cycle only
|
|
55
|
+
// re-evaluates changed entities, so a pattern can legitimately
|
|
56
|
+
// match nothing in a run of three. The count lets the reader
|
|
57
|
+
// tell that apart from a typo.
|
|
58
|
+
// The name/id rule is the thing people get wrong, and it is not
|
|
59
|
+
// what it looks like: matchEntity() globs entity.name ONLY for a
|
|
60
|
+
// pattern beginning '@/' — every other pattern globs entity.id,
|
|
61
|
+
// which carries the '/<collection>/' prefix and the extension.
|
|
62
|
+
// So 'post' does NOT match a document named 'post'; '@/post' and
|
|
63
|
+
// '/documents/post.md' both do.
|
|
64
|
+
const onName = pattern.startsWith('@/')
|
|
65
|
+
reportWarning('layout-pattern-no-match', {
|
|
66
|
+
pattern, layout: options.match[pattern], evaluated: matchTally.evaluated,
|
|
67
|
+
matchedAgainst: onName ? 'name' : 'id',
|
|
68
|
+
})
|
|
69
|
+
logger.warn(
|
|
70
|
+
'Layout pattern %j (→ %j) matched none of the %d entities evaluated. ' +
|
|
71
|
+
'It is matched against entity.%s%s. On an incremental run unchanged ' +
|
|
72
|
+
'entities are not re-evaluated; use --force to check the whole catalog.',
|
|
73
|
+
pattern, options.match[pattern], matchTally.evaluated,
|
|
74
|
+
onName ? 'name' : 'id',
|
|
75
|
+
onName ? '' : " — prefix a pattern with '@/' to match entity.name instead, " +
|
|
76
|
+
'since an id looks like /<collection>/<name>.<ext>')
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
29
80
|
return async function onProcessedHandler(signal) {
|
|
30
81
|
const logger = useLogger()
|
|
31
82
|
const { layouts } = runtime.state.layouts
|
|
@@ -79,7 +130,13 @@ export function createOnProcessed({
|
|
|
79
130
|
for (const name of declared) {
|
|
80
131
|
const layout = await resolveLayout(name)
|
|
81
132
|
if (layout) {
|
|
82
|
-
|
|
133
|
+
// meta.layout / meta.layouts in the document's own
|
|
134
|
+
// frontmatter. Labelled so --explain says the choice
|
|
135
|
+
// came from the content, not from config — which is
|
|
136
|
+
// where you would otherwise go looking.
|
|
137
|
+
if (!resolved.find(l => l.name === layout.name)) {
|
|
138
|
+
resolved.push({ ...layout, matchedBy: 'meta.layout' })
|
|
139
|
+
}
|
|
83
140
|
} else {
|
|
84
141
|
logger.warn('Layout not found for %s: %s', entity.collection, entity.id, name)
|
|
85
142
|
}
|
|
@@ -89,12 +146,20 @@ export function createOnProcessed({
|
|
|
89
146
|
|
|
90
147
|
// Multi-match over config patterns.
|
|
91
148
|
const matched = []
|
|
149
|
+
matchTally.evaluated++
|
|
92
150
|
for (const pattern in options.match || []) {
|
|
93
151
|
if (matchEntity(entity, pattern)) {
|
|
152
|
+
matchTally.hit.add(pattern)
|
|
94
153
|
const name = options.match[pattern]
|
|
95
154
|
const layout = await resolveLayout(name)
|
|
96
155
|
if (layout && !matched.find(l => l.name === layout.name)) {
|
|
97
|
-
|
|
156
|
+
// Record WHICH pattern claimed it. With several
|
|
157
|
+
// patterns able to match one entity, "why did this
|
|
158
|
+
// page get that layout" is otherwise only answerable
|
|
159
|
+
// by re-running the matcher by hand — and it is the
|
|
160
|
+
// question asked whenever the answer is surprising.
|
|
161
|
+
// Surfaced by `mikser --explain`.
|
|
162
|
+
matched.push({ ...layout, matchedBy: pattern })
|
|
98
163
|
}
|
|
99
164
|
}
|
|
100
165
|
}
|
|
@@ -115,7 +180,11 @@ export function createOnProcessed({
|
|
|
115
180
|
const autoLayout = candidates.find(name => layouts[name])
|
|
116
181
|
if (autoLayout) {
|
|
117
182
|
const layout = await resolveLayout(autoLayout)
|
|
118
|
-
|
|
183
|
+
// Labelled so `mikser --explain` can distinguish "a
|
|
184
|
+
// pattern you wrote chose this" from "the name happened
|
|
185
|
+
// to match a layout file" — a distinction that decides
|
|
186
|
+
// whether the fix is in the config or in a filename.
|
|
187
|
+
if (layout) matched.push({ ...layout, matchedBy: 'auto-layout' })
|
|
119
188
|
logger.debug('Auto layout matched %s -> %s for %s', entity.name, autoLayout, entity.id)
|
|
120
189
|
} else {
|
|
121
190
|
logger.trace('Auto layout no match for %s tried: %s', entity.id, candidates.join(', '))
|
|
@@ -150,6 +219,34 @@ export function createOnProcessed({
|
|
|
150
219
|
// Author-declared cases already have meta.layout
|
|
151
220
|
// (or meta.layouts) set; this covers the
|
|
152
221
|
// pattern-match / auto-layout paths.
|
|
222
|
+
// Persist the primary destination.
|
|
223
|
+
//
|
|
224
|
+
// entity.destination used to exist ONLY as a field the
|
|
225
|
+
// render-task builder set per task, and was never written
|
|
226
|
+
// back — so the catalog had none, and runtime.href()
|
|
227
|
+
// (which needs the TARGET's destination to make a
|
|
228
|
+
// relative URL) fell through and returned the whole
|
|
229
|
+
// entity instead of { url }. The catalog is only written
|
|
230
|
+
// through the journal, and this hook is where that
|
|
231
|
+
// happens, so it has to be derived here.
|
|
232
|
+
//
|
|
233
|
+
// "Primary" means page 1 / the sole output of the FIRST
|
|
234
|
+
// matched layout — the same choice entity.layout already
|
|
235
|
+
// makes. Paginated pages 2..n keep their own per-task
|
|
236
|
+
// destinations; a single stored field cannot describe
|
|
237
|
+
// several outputs, and the one a link should point at is
|
|
238
|
+
// the first.
|
|
239
|
+
if (entity.layouts.length) {
|
|
240
|
+
try {
|
|
241
|
+
const destination = primaryDestination({
|
|
242
|
+
entity, layout: entity.layouts[0], options, endsWith: _.endsWith,
|
|
243
|
+
})
|
|
244
|
+
if (destination) entity.destination = destination
|
|
245
|
+
} catch (err) {
|
|
246
|
+
logger.warn('Destination for %s: %s', entity.id, err.message)
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
153
250
|
if (entity.layouts.length && !entity.meta?.layout && !entity.meta?.layouts) {
|
|
154
251
|
entity.meta = entity.meta || {}
|
|
155
252
|
if (entity.layouts.length === 1) {
|
|
@@ -208,5 +305,8 @@ export function createOnProcessed({
|
|
|
208
305
|
// generator JSON.stringifies the entity post-yield and
|
|
209
306
|
// UPDATEs the row if it diverged from the original).
|
|
210
307
|
}
|
|
308
|
+
|
|
309
|
+
// After the journal is drained, so `evaluated` is the whole cycle.
|
|
310
|
+
reportUnmatchedPatterns(logger)
|
|
211
311
|
}
|
|
212
312
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mikser-io-layouts",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.3.0",
|
|
4
4
|
"description": "SSG-flavor task-production policy for mikser-io. Multi-match layout assignment, per-layout destinations, postprocess chain attachment, layout inspection.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -18,12 +18,15 @@
|
|
|
18
18
|
},
|
|
19
19
|
"homepage": "https://github.com/almero-digital-marketing/mikser-io-layouts#readme",
|
|
20
20
|
"peerDependencies": {
|
|
21
|
-
"mikser-io": "^9.
|
|
21
|
+
"mikser-io": "^9.14.0"
|
|
22
22
|
},
|
|
23
23
|
"dependencies": {
|
|
24
24
|
"globby": "^14.0.0",
|
|
25
25
|
"handlebars": "^4.7.0",
|
|
26
26
|
"lodash": "^4.17.0",
|
|
27
27
|
"zod": "^4.4.3"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"mikser-io": "file:../mikser-io"
|
|
28
31
|
}
|
|
29
32
|
}
|
package/test/unit.test.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, it } from 'node:test'
|
|
2
2
|
import assert from 'node:assert/strict'
|
|
3
|
+
import { format } from 'node:util'
|
|
3
4
|
import { mkdtemp, rm, access, mkdir, writeFile } from 'node:fs/promises'
|
|
4
5
|
import { tmpdir } from 'node:os'
|
|
5
6
|
import path from 'node:path'
|
|
@@ -122,6 +123,74 @@ describe('layouts plugin', () => {
|
|
|
122
123
|
})
|
|
123
124
|
})
|
|
124
125
|
|
|
126
|
+
it('warns once about a match pattern that selected nothing', async () => {
|
|
127
|
+
// A pattern selecting zero entities used to be completely silent —
|
|
128
|
+
// no warn, no debug — so a typo'd or missing language segment lost a
|
|
129
|
+
// whole section while the build went green.
|
|
130
|
+
await withTempWorking(async (workingFolder) => {
|
|
131
|
+
const h = createHarness({
|
|
132
|
+
options: { workingFolder, outputFolder: path.join(workingFolder, 'out') },
|
|
133
|
+
})
|
|
134
|
+
layouts({
|
|
135
|
+
autoLayouts: false,
|
|
136
|
+
match: {
|
|
137
|
+
// matchEntity globs entity.name only for '@/' patterns;
|
|
138
|
+
// everything else globs entity.id. Both forms here, so
|
|
139
|
+
// the test pins the real rule rather than the loose
|
|
140
|
+
// "patterns match name" summary.
|
|
141
|
+
'@/post': 'post', // matches name
|
|
142
|
+
'/documents/post.md': 'post', // matches id
|
|
143
|
+
'nonesuch/*': 'section', // matches neither
|
|
144
|
+
},
|
|
145
|
+
})(h.core)
|
|
146
|
+
await h.runHook('loaded')
|
|
147
|
+
await h.runSync('layouts', { action: 'create', context: { relativePath: 'post.hbs' } })
|
|
148
|
+
await h.runSync('layouts', { action: 'create', context: { relativePath: 'section.hbs' } })
|
|
149
|
+
|
|
150
|
+
const doc = { id: '/documents/post.md', collection: 'documents', name: 'post', format: 'md', meta: {} }
|
|
151
|
+
h.journal.push({ id: 1, entity: doc, operation: 'create', context: {}, options: {}, output: null })
|
|
152
|
+
await h.runHook('processed', { aborted: false })
|
|
153
|
+
|
|
154
|
+
const warnings = h.logs.filter(l =>
|
|
155
|
+
l.level === 'warn' && l.args.join(' ').includes('Layout pattern'))
|
|
156
|
+
assert.equal(warnings.length, 1, `expected one warning, got ${warnings.length}`)
|
|
157
|
+
// The harness records (format, ...args) unformatted, so render it
|
|
158
|
+
// the way the logger would before asserting on the message.
|
|
159
|
+
const text = format(...warnings[0].args)
|
|
160
|
+
assert.match(text, /nonesuch/, 'names the pattern that matched nothing')
|
|
161
|
+
assert.ok(!text.includes("'post'"), 'must not name the pattern that DID match')
|
|
162
|
+
assert.match(text, /matched against entity\.id/, 'names the field this pattern is matched against')
|
|
163
|
+
assert.match(text, /'@\/'/, "points at the '@/' prefix for name matching")
|
|
164
|
+
assert.match(text, /--force/, 'tells the reader how to check the whole catalog')
|
|
165
|
+
})
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('does not repeat the pattern warning on a second cycle', async () => {
|
|
169
|
+
// Watch mode runs onProcessed per cycle; a warning per cycle would be
|
|
170
|
+
// noise, so it is reported once per process.
|
|
171
|
+
await withTempWorking(async (workingFolder) => {
|
|
172
|
+
const h = createHarness({
|
|
173
|
+
options: { workingFolder, outputFolder: path.join(workingFolder, 'out') },
|
|
174
|
+
})
|
|
175
|
+
layouts({ autoLayouts: false, match: { 'nonesuch/*': 'section' } })(h.core)
|
|
176
|
+
await h.runHook('loaded')
|
|
177
|
+
await h.runSync('layouts', { action: 'create', context: { relativePath: 'section.hbs' } })
|
|
178
|
+
|
|
179
|
+
for (const id of [1, 2]) {
|
|
180
|
+
h.journal.push({
|
|
181
|
+
id,
|
|
182
|
+
entity: { id: `/documents/p${id}.md`, collection: 'documents', name: `p${id}`, format: 'md', meta: {} },
|
|
183
|
+
operation: 'create', context: {}, options: {}, output: null,
|
|
184
|
+
})
|
|
185
|
+
await h.runHook('processed', { aborted: false })
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const warnings = h.logs.filter(l =>
|
|
189
|
+
l.level === 'warn' && l.args.join(' ').includes('Layout pattern'))
|
|
190
|
+
assert.equal(warnings.length, 1, 'warned once, not once per cycle')
|
|
191
|
+
})
|
|
192
|
+
})
|
|
193
|
+
|
|
125
194
|
it('warns when a render-requested entity (carries correlationId) matches no layout', async () => {
|
|
126
195
|
await withTempWorking(async (workingFolder) => {
|
|
127
196
|
const h = createHarness({
|
|
@@ -644,7 +713,9 @@ describe('layouts plugin: multi-layouts (collision)', () => {
|
|
|
644
713
|
|
|
645
714
|
assert.equal(h.renderTasks.length, 2, 'one task per layout, different extensions')
|
|
646
715
|
const destinations = h.renderTasks.map(t => t.entity.destination).sort()
|
|
647
|
-
|
|
716
|
+
// cleanUrls defaults to true and applies to `html` ONLY, so the
|
|
717
|
+
// page folds into a folder while the email keeps its flat name.
|
|
718
|
+
assert.deepEqual(destinations, ['/blog/welcome.eml', '/blog/welcome/index.html'])
|
|
648
719
|
})
|
|
649
720
|
})
|
|
650
721
|
})
|
|
@@ -714,3 +785,236 @@ describe('layouts plugin: multi-layouts (destination template)', () => {
|
|
|
714
785
|
})
|
|
715
786
|
})
|
|
716
787
|
})
|
|
788
|
+
|
|
789
|
+
describe('layouts plugin: destination is persisted (mikser-io#1)', () => {
|
|
790
|
+
// entity.destination used to exist only as a per-render-task field and
|
|
791
|
+
// was never written back, so the catalog had none — and runtime.href(),
|
|
792
|
+
// which needs the TARGET's destination to build a relative URL, fell
|
|
793
|
+
// through and returned the whole entity instead of { url }.
|
|
794
|
+
async function processOne({ layoutFile, entity, options }) {
|
|
795
|
+
return withTempWorking(async (workingFolder) => {
|
|
796
|
+
const h = createHarness({
|
|
797
|
+
options: { workingFolder, outputFolder: path.join(workingFolder, 'out') },
|
|
798
|
+
})
|
|
799
|
+
layouts(options)(h.core)
|
|
800
|
+
await h.runHook('loaded')
|
|
801
|
+
await h.runSync('layouts', { action: 'create', context: { relativePath: layoutFile } })
|
|
802
|
+
h.journal.push({ id: 1, entity, operation: 'create', context: {}, options: {}, output: null })
|
|
803
|
+
await h.runHook('processed', { aborted: false })
|
|
804
|
+
return entity
|
|
805
|
+
})
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
const doc = (name) => ({
|
|
809
|
+
id: `/documents/${name}.md`, collection: 'documents', name, format: 'md', meta: {},
|
|
810
|
+
})
|
|
811
|
+
|
|
812
|
+
it('stamps a cleanUrls destination onto the entity', async () => {
|
|
813
|
+
const out = await processOne({
|
|
814
|
+
layoutFile: 'page.hbs',
|
|
815
|
+
entity: { ...doc('contacts'), meta: { layout: 'page' } },
|
|
816
|
+
options: { cleanUrls: true },
|
|
817
|
+
})
|
|
818
|
+
assert.equal(out.destination, '/contacts/index.html')
|
|
819
|
+
})
|
|
820
|
+
|
|
821
|
+
it('stamps a flat destination when cleanUrls is off', async () => {
|
|
822
|
+
const out = await processOne({
|
|
823
|
+
layoutFile: 'page.hbs',
|
|
824
|
+
entity: { ...doc('contacts'), meta: { layout: 'page' } },
|
|
825
|
+
options: { cleanUrls: false },
|
|
826
|
+
})
|
|
827
|
+
assert.equal(out.destination, '/contacts.html')
|
|
828
|
+
})
|
|
829
|
+
|
|
830
|
+
it('leaves an index page flat — cleanUrls already special-cases it', async () => {
|
|
831
|
+
const out = await processOne({
|
|
832
|
+
layoutFile: 'page.hbs',
|
|
833
|
+
entity: { ...doc('index'), meta: { layout: 'page' } },
|
|
834
|
+
options: { cleanUrls: true },
|
|
835
|
+
})
|
|
836
|
+
assert.equal(out.destination, '/index.html')
|
|
837
|
+
})
|
|
838
|
+
|
|
839
|
+
it('is absent when no layout matched, rather than a bogus path', async () => {
|
|
840
|
+
const out = await processOne({
|
|
841
|
+
layoutFile: 'page.hbs',
|
|
842
|
+
entity: doc('orphan'),
|
|
843
|
+
options: { autoLayouts: false, match: {} },
|
|
844
|
+
})
|
|
845
|
+
assert.equal(out.destination, undefined)
|
|
846
|
+
})
|
|
847
|
+
})
|
|
848
|
+
|
|
849
|
+
describe('layouts plugin: cleanUrls default (layouts#1)', () => {
|
|
850
|
+
// It had no default, so unset it was falsy and pages landed at
|
|
851
|
+
// `hera.html` — while the README's options block read as a defaults
|
|
852
|
+
// table showing `cleanUrls: true`. No warning, site works, wrong URL
|
|
853
|
+
// shape everywhere.
|
|
854
|
+
async function destinationFor(options) {
|
|
855
|
+
return withTempWorking(async (workingFolder) => {
|
|
856
|
+
const h = createHarness({
|
|
857
|
+
options: { workingFolder, outputFolder: path.join(workingFolder, 'out') },
|
|
858
|
+
})
|
|
859
|
+
layouts(options)(h.core)
|
|
860
|
+
await h.runHook('loaded')
|
|
861
|
+
await h.runSync('layouts', { action: 'create', context: { relativePath: 'page.hbs' } })
|
|
862
|
+
const entity = {
|
|
863
|
+
id: '/documents/hera.md', collection: 'documents', name: 'hera',
|
|
864
|
+
format: 'md', meta: { layout: 'page' },
|
|
865
|
+
}
|
|
866
|
+
h.journal.push({ id: 1, entity, operation: 'create', context: {}, options: {}, output: null })
|
|
867
|
+
await h.runHook('processed', { aborted: false })
|
|
868
|
+
return entity.destination
|
|
869
|
+
})
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
it('defaults to on', async () => {
|
|
873
|
+
assert.equal(await destinationFor({}), '/hera/index.html')
|
|
874
|
+
})
|
|
875
|
+
|
|
876
|
+
it('false still gives flat output', async () => {
|
|
877
|
+
assert.equal(await destinationFor({ cleanUrls: false }), '/hera.html')
|
|
878
|
+
})
|
|
879
|
+
|
|
880
|
+
it('explicit true is the same as the default', async () => {
|
|
881
|
+
assert.equal(await destinationFor({ cleanUrls: true }), '/hera/index.html')
|
|
882
|
+
})
|
|
883
|
+
})
|
|
884
|
+
|
|
885
|
+
describe('layouts plugin: destination template helpers (layouts#3)', () => {
|
|
886
|
+
// Without helpers a template can only interpolate whole values, so a
|
|
887
|
+
// path cannot be derived from PART of entity.name — which blocks
|
|
888
|
+
// per-language output roots on a catalog holding several languages.
|
|
889
|
+
const render = async (tpl, entity) => {
|
|
890
|
+
const { compileDestinationTemplate } = await import('../lib/destination.js')
|
|
891
|
+
return compileDestinationTemplate(tpl)({ entity })
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
it('strips a leading language segment — the case that needed this', async () => {
|
|
895
|
+
assert.equal(
|
|
896
|
+
await render("/{{ after entity.name '/' }}/index.html", { name: 'bg/kontakti' }),
|
|
897
|
+
'/kontakti/index.html')
|
|
898
|
+
})
|
|
899
|
+
|
|
900
|
+
it('keeps the rest of a nested path intact', async () => {
|
|
901
|
+
assert.equal(
|
|
902
|
+
await render("/{{ after entity.name '/' }}/index.html", { name: 'bg/web/booking' }),
|
|
903
|
+
'/web/booking/index.html')
|
|
904
|
+
})
|
|
905
|
+
|
|
906
|
+
it('replace is literal, not a regex', async () => {
|
|
907
|
+
// A destination is a path; a stray metacharacter must not silently
|
|
908
|
+
// change what matches.
|
|
909
|
+
assert.equal(await render("/{{ replace entity.name '.' '-' }}", { name: 'a.b.c' }), '/a-b-c')
|
|
910
|
+
assert.equal(await render("/{{ replace entity.name 'a.c' 'X' }}", { name: 'abc' }), '/abc')
|
|
911
|
+
})
|
|
912
|
+
|
|
913
|
+
it('covers the path-shaping set', async () => {
|
|
914
|
+
assert.equal(await render('/{{ before entity.name "/" }}', { name: 'bg/x' }), '/bg')
|
|
915
|
+
assert.equal(await render('/{{ afterLast entity.name "/" }}', { name: 'a/b/c' }), '/c')
|
|
916
|
+
assert.equal(await render('/{{ beforeLast entity.name "/" }}', { name: 'a/b/c' }), '/a/b')
|
|
917
|
+
assert.equal(await render('/{{ dirname entity.name }}', { name: 'a/b/c' }), '/a/b')
|
|
918
|
+
assert.equal(await render('/{{ basename entity.name }}', { name: 'a/b/c' }), '/c')
|
|
919
|
+
assert.equal(await render('/{{ lower entity.name }}', { name: 'A/B' }), '/a/b')
|
|
920
|
+
assert.equal(await render('/{{ upper entity.name }}', { name: 'a' }), '/A')
|
|
921
|
+
})
|
|
922
|
+
|
|
923
|
+
it('handles a missing value without emitting "undefined" into a path', async () => {
|
|
924
|
+
assert.equal(await render("/{{ after entity.nope '/' }}x", { name: 'a' }), '/x')
|
|
925
|
+
})
|
|
926
|
+
|
|
927
|
+
it('does not register helpers on the shared handlebars instance', async () => {
|
|
928
|
+
// Registering on the default export would leak `replace` into a
|
|
929
|
+
// project's own renderHbs layouts, appearing from nowhere.
|
|
930
|
+
const handlebars = (await import('handlebars')).default
|
|
931
|
+
for (const name of ['after', 'before', 'replace', 'dirname', 'basename']) {
|
|
932
|
+
assert.equal(handlebars.helpers[name], undefined, `${name} leaked globally`)
|
|
933
|
+
}
|
|
934
|
+
})
|
|
935
|
+
|
|
936
|
+
it('still rejects path traversal after helpers run', async () => {
|
|
937
|
+
const { compileDestinationTemplate, sanitizeDestination } = await import('../lib/destination.js')
|
|
938
|
+
const out = compileDestinationTemplate("/{{ after entity.name '/' }}")({ entity: { name: 'x/../../etc/passwd' } })
|
|
939
|
+
assert.throws(() => sanitizeDestination(out), /path-traversal/)
|
|
940
|
+
})
|
|
941
|
+
})
|
|
942
|
+
|
|
943
|
+
describe('layouts plugin: a sidecar is a tracked input (layouts#4)', () => {
|
|
944
|
+
// The sidecar is where a mikser site's data layer lives, and it was the
|
|
945
|
+
// one file that could be edited without effect: `.js` is excluded from
|
|
946
|
+
// the scan, so it was not an entity, not watched as an input, and not
|
|
947
|
+
// part of any hash. Nothing depended on it, so nothing re-rendered —
|
|
948
|
+
// silently, looking exactly like a bug in your own code.
|
|
949
|
+
async function scan(files, { existing = [] } = {}) {
|
|
950
|
+
return withTempWorking(async (workingFolder) => {
|
|
951
|
+
const layoutsFolder = path.join(workingFolder, 'layouts')
|
|
952
|
+
await mkdir(path.join(layoutsFolder, 'lib'), { recursive: true })
|
|
953
|
+
for (const [rel, body] of Object.entries(files)) {
|
|
954
|
+
await writeFile(path.join(layoutsFolder, rel), body)
|
|
955
|
+
}
|
|
956
|
+
const h = createHarness({
|
|
957
|
+
options: { workingFolder, outputFolder: path.join(workingFolder, 'out') },
|
|
958
|
+
entities: existing,
|
|
959
|
+
})
|
|
960
|
+
layouts()(h.core)
|
|
961
|
+
await h.runHook('loaded')
|
|
962
|
+
await h.runHook('import')
|
|
963
|
+
const created = h.journal.filter(e => ['create', 'update'].includes(e.operation))
|
|
964
|
+
return { h, layoutsFolder, emitted: created.map(e => e.entity), checksums:
|
|
965
|
+
Object.fromEntries(created.map(e => [e.entity.name, e.entity.checksum])) }
|
|
966
|
+
})
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
it('folds the sidecar into the layout checksum, so editing it changes the hash', async () => {
|
|
970
|
+
const before = await scan({ 'page.liquid': '<p>x</p>', 'page.js': 'export const load = () => ({ a: 1 })' })
|
|
971
|
+
const after = await scan({ 'page.liquid': '<p>x</p>', 'page.js': 'export const load = () => ({ a: 2 })' })
|
|
972
|
+
assert.ok(before.checksums.page, 'the layout was emitted')
|
|
973
|
+
assert.notEqual(before.checksums.page, after.checksums.page,
|
|
974
|
+
'a sidecar edit must change the layout checksum')
|
|
975
|
+
})
|
|
976
|
+
|
|
977
|
+
it('an unchanged sidecar leaves the checksum alone', async () => {
|
|
978
|
+
const a = await scan({ 'page.liquid': '<p>x</p>', 'page.js': 'export const load = () => ({})' })
|
|
979
|
+
const b = await scan({ 'page.liquid': '<p>x</p>', 'page.js': 'export const load = () => ({})' })
|
|
980
|
+
assert.equal(a.checksums.page, b.checksums.page)
|
|
981
|
+
})
|
|
982
|
+
|
|
983
|
+
it('a SHARED script under the folder invalidates every layout', async () => {
|
|
984
|
+
// A sidecar's own imports (layouts/lib/context.js and friends) would
|
|
985
|
+
// otherwise need a module-graph walk. Treating any shared .js change
|
|
986
|
+
// as invalidating every layout is what a layouts folder can afford,
|
|
987
|
+
// and it is predictable — which a partial graph walk would not be.
|
|
988
|
+
const before = await scan({
|
|
989
|
+
'page.liquid': '<p>x</p>', 'post.liquid': '<p>y</p>',
|
|
990
|
+
'lib/context.js': 'export const chrome = 1',
|
|
991
|
+
})
|
|
992
|
+
const after = await scan({
|
|
993
|
+
'page.liquid': '<p>x</p>', 'post.liquid': '<p>y</p>',
|
|
994
|
+
'lib/context.js': 'export const chrome = 2',
|
|
995
|
+
})
|
|
996
|
+
assert.notEqual(before.checksums.page, after.checksums.page)
|
|
997
|
+
assert.notEqual(before.checksums.post, after.checksums.post)
|
|
998
|
+
})
|
|
999
|
+
|
|
1000
|
+
it('does not make an entity out of a sidecar', async () => {
|
|
1001
|
+
// Giving it one would put JS source in the catalog, and via onSync's
|
|
1002
|
+
// id-stripping produce a phantom /layouts/page beside the real
|
|
1003
|
+
// /layouts/page.liquid.
|
|
1004
|
+
const { emitted } = await scan({ 'page.liquid': '<p>x</p>', 'page.js': 'export const load = () => ({})' })
|
|
1005
|
+
assert.deepEqual(emitted.map(e => e.id).sort(), ['/layouts/page.liquid'])
|
|
1006
|
+
})
|
|
1007
|
+
|
|
1008
|
+
it('still treats a JS-AUTHORED layout as a layout, not a sidecar', async () => {
|
|
1009
|
+
// post.hbs.js is a template written as JS; its id drops the trailing
|
|
1010
|
+
// .js. Only a .js whose remaining name has no extension is a sidecar.
|
|
1011
|
+
await withTempWorking(async (workingFolder) => {
|
|
1012
|
+
const h = createHarness({ options: { workingFolder, outputFolder: path.join(workingFolder, 'out') } })
|
|
1013
|
+
layouts()(h.core)
|
|
1014
|
+
await h.runHook('loaded')
|
|
1015
|
+
await h.runSync('layouts', { action: 'create', context: { relativePath: 'post.hbs.js' } })
|
|
1016
|
+
const entry = h.journal.find(e => e.operation === 'create')
|
|
1017
|
+
assert.equal(entry.entity.id, '/layouts/post.hbs')
|
|
1018
|
+
})
|
|
1019
|
+
})
|
|
1020
|
+
})
|