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 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', // default: '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 // /page.html → /page/index.html
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(options = {}) {
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
- onImport(async () => {
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 chksum = await gateChecksum(uri, id, { priorChecksums })
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
- if (destinationTemplate) {
346
- entity.destination = sanitizeDestination(destinationTemplate({ entity }))
347
- } else if (!_.endsWith(entity.name, entity.format)) {
348
- // cleanUrls turns `foo` into `foo/index.html` so the
349
- // served URL is `/foo/`. Skip the transform when the
350
- // layout declares a postprocessor — the HTML render
351
- // is a throwaway stepping stone (a PDF stepping
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,
@@ -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, handlebars.compile(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
- if (!resolved.find(l => l.name === layout.name)) resolved.push(layout)
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
- matched.push(layout)
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
- if (layout) matched.push(layout)
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": "1.0.0",
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.0.0"
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
- assert.deepEqual(destinations, ['/blog/welcome.eml', '/blog/welcome.html'])
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
+ })