mikser-io-layouts 2.12.0 → 2.15.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/index.js CHANGED
@@ -245,12 +245,13 @@ export function layouts(userOptions = {}) {
245
245
  //
246
246
  // Two components:
247
247
  // own — the layout's own sidecar, `<name>.js`
248
- // shared — every OTHER .js under the folder, as one digest
248
+ // shared — ALL sidecar scripts under the folder, as one digest
249
+ // (its own included; `own` just names it separately)
249
250
  //
250
251
  // `shared` is coarse on purpose: a sidecar's own imports
251
252
  // (layouts/lib/context.js and friends) would otherwise need a module
252
- // graph walk. Treating any shared .js change as invalidating every
253
- // layout is what the folder can afford — tens of files, not
253
+ // graph walk. One global digest means any sidecar change invalidates
254
+ // EVERY layout, which is what the folder can afford — tens of files, not
254
255
  // thousands — and it is predictable, which a partial graph walk
255
256
  // would not be.
256
257
  // NOT every .js under the folder is a sidecar. `post.hbs.js` is a
@@ -310,8 +311,43 @@ export function layouts(userOptions = {}) {
310
311
  // and they stay out of the layouts NAME map so nothing resolves a
311
312
  // template to one. Rendering is not a risk either: layout matching
312
313
  // skips this collection outright.
313
- const sidecarPaths = (await globby('**/*.js', { cwd: runtime.options.layoutsFolder }))
314
- .filter(isSidecarScript)
314
+ const allScripts = await globby('**/*.js', { cwd: runtime.options.layoutsFolder })
315
+ const sidecarPaths = allScripts.filter(isSidecarScript)
316
+
317
+ // A `.js` that nothing will ever read.
318
+ //
319
+ // A sidecar is named after the LAYOUT, and a layout's name drops
320
+ // BOTH extensions: `robots.txt.liquid` is the layout `robots`, so
321
+ // its sidecar is `robots.js`. Write `robots.txt.js` and it is
322
+ // claimed by nothing — isSidecarScript rejects it because
323
+ // stripping `.js` leaves `.txt`, and the layout glob ignores every
324
+ // `.js` outright. The file is simply invisible: it does not fail,
325
+ // it never runs, and the layout renders with empty `data`.
326
+ //
327
+ // Downstream that shipped `Sitemap: /sitemap.xml` as a relative
328
+ // reference in a robots.txt — which in a robots.txt means nothing
329
+ // — on a green build.
330
+ //
331
+ // Only when a layout sits beside it under the same stem, which is
332
+ // what makes this a misnaming rather than a stray file. A dotted
333
+ // module a sidecar imports (`lib/data.json.js`) is claimed by
334
+ // nothing either, and is nobody's mistake; flagging it would be
335
+ // the noise that gets this line filtered out.
336
+ for (const script of allScripts) {
337
+ if (isSidecarScript(script)) continue
338
+ const stem = script.replace(/\.js$/, '')
339
+ const layout = paths.find(candidate =>
340
+ candidate === stem || candidate.startsWith(`${stem}.`))
341
+ if (!layout) continue
342
+ const name = path.basename(stem, path.extname(stem))
343
+ logger.warn({ code: 'layout-sidecar-misnamed', script, layout },
344
+ 'layouts/%s is not loaded as a sidecar and nothing else reads it. A sidecar is '
345
+ + 'named after the LAYOUT, and %s is the layout `%s` — both extensions are '
346
+ + 'dropped — so its sidecar has to be %s.js. The layout renders with empty data '
347
+ + 'until it is renamed.',
348
+ script, layout, name, path.join(path.dirname(stem) === '.' ? '' : path.dirname(stem), name))
349
+ }
350
+
315
351
  const inputs = await sidecarInputs()
316
352
  const scanned = new Set()
317
353
  const stats = { emitted: 0, skipped: 0, deleted: 0 }
@@ -380,6 +416,35 @@ export function layouts(userOptions = {}) {
380
416
  },
381
417
  }
382
418
  Object.assign(layout, await getFormatInfo(relativePath))
419
+
420
+ // Two files can resolve to ONE layout name, and this map is
421
+ // keyed by name — so the second one silently replaced the
422
+ // first and which one won depended on scan order.
423
+ //
424
+ // getFormatInfo strips the whole compound extension:
425
+ // sitemap.xml.liquid, sitemap.liquid and sitemap.xml.js are
426
+ // all named `sitemap`. A document asking for `layout: sitemap`
427
+ // then got whichever landed last, and if that was a `js`
428
+ // template it rendered nothing — no error, no warning, the
429
+ // build green and the page absent. Reported from a real
430
+ // migration, where the same shape worked for one layout and
431
+ // not another and no rule could be found, because the rule was
432
+ // directory order.
433
+ //
434
+ // Not resolved here, because picking a winner would be
435
+ // guessing at intent. Said out loud instead, naming both
436
+ // files, which is the thing that was missing.
437
+ const clash = layouts[layout.name]
438
+ if (clash && clash.id !== layout.id) {
439
+ logger.warn(
440
+ { code: 'layout-name-collision', name: layout.name, files: [clash.uri, layout.uri] },
441
+ 'Layouts %j is claimed by two files — %s and %s. Both reduce to the same layout name, '
442
+ + 'so one replaces the other and which one wins is directory order. A document asking '
443
+ + 'for this layout may render with the wrong template, or with a `js` template that '
444
+ + 'emits nothing at all. Rename one of them.',
445
+ layout.name, clash.uri, layout.uri)
446
+ }
447
+
383
448
  layouts[layout.name] = layout
384
449
  await createEntity(layout)
385
450
  stats.emitted++
package/lib/assembly.js CHANGED
@@ -27,10 +27,21 @@ import {
27
27
  observeConsumed,
28
28
  queryContext,
29
29
  useDatabase,
30
+ reportError,
30
31
  } from 'mikser-io'
31
32
  import { compileDestinationTemplate, sanitizeDestination, primaryDestination } from './destination.js'
32
33
  import { setSidecarStamp, sidecarHookInstalled } from './sidecar-modules.js'
33
34
 
35
+ // What the sidecar actually returned, for a message a reader can act on.
36
+ // `[object Object]` and `` are the two least useful things to print here.
37
+ function describePages(value) {
38
+ if (Array.isArray(value)) return `an array of ${value.length}`
39
+ if (value === null) return 'null'
40
+ if (typeof value === 'object') return `an object with keys ${Object.keys(value).join(', ') || '(none)'}`
41
+ if (typeof value === 'number') return `the number ${value}`
42
+ return `a ${typeof value} (${JSON.stringify(value)})`
43
+ }
44
+
34
45
  export function createOnBeforeRender({
35
46
  runtime, useLogger, useJournal,
36
47
  findById, findEntity, findEntities,
@@ -350,6 +361,56 @@ export function createOnBeforeRender({
350
361
  : null
351
362
 
352
363
  if (data?.pages) {
364
+ // `pages` is the pagination COUNT, and only a count.
365
+ //
366
+ // The guard above is truthiness and the loop below is
367
+ // `page < data.pages`, so a sidecar returning anything
368
+ // truthy that is not a positive number passed the first
369
+ // and ran zero times in the second. `{ pages: [] }`
370
+ // coerces to 0; `{ pages: [{…}] }` coerces to NaN. Both
371
+ // produced NO page: no file, no error, no warning, not
372
+ // counted in `Rendered:`, no manifest snapshot — a green
373
+ // build with the page simply absent, and --audit-output
374
+ // cannot see it either because an entity that never
375
+ // rendered has no snapshot to be missing.
376
+ //
377
+ // `pages` is also the obvious name for the list in a
378
+ // sitemap, which is exactly where it lands.
379
+ //
380
+ // Thrown rather than reinterpreted. The key has one
381
+ // meaning; guessing a second from the value's type is how
382
+ // a name comes to mean two things depending on what you
383
+ // put in it. This fails the render for this entity, which
384
+ // is counted, recorded and named.
385
+ if (!Number.isInteger(data.pages) || data.pages < 1) {
386
+ // Reported, not thrown. This runs in onBeforeRender,
387
+ // a lifecycle hook rather than a render, so an
388
+ // exception here tears the whole build down with a
389
+ // stack trace — one mistyped sidecar stopping every
390
+ // other page, which trades a silent failure for a
391
+ // total one.
392
+ //
393
+ // reportError, not logger.error. This entity could
394
+ // not be turned into output, which is what a render
395
+ // error IS — so it is counted in `errors`, recorded in
396
+ // mikser_failures, and makes a one-shot build exit 1.
397
+ // A coded logger.error would register as a fault, and
398
+ // a fault leaves the build green: "🟢 Mikser completed"
399
+ // beside a red line and exit 0 is the same green build
400
+ // the report was about.
401
+ const message =
402
+ `sidecar returned \`pages\` as ${describePages(data.pages)}. `
403
+ + '`pages` is the pagination COUNT — a positive integer — and is reserved '
404
+ + 'in the data a layout receives, so a list cannot be passed under it. '
405
+ + 'Rename it (`items`, `entries`, `urls`) and the layout renders once '
406
+ + 'with that list available.'
407
+ reportError(entity, new Error(message), { layout: entity.layout?.name ?? null })
408
+ logger.error({ code: 'layout-pages-not-a-count', id: entity.id,
409
+ layout: entity.layout?.name ?? null },
410
+ 'Layout %s: %s — %s produced no output.',
411
+ entity.layout?.name ?? '?', message, entity.id)
412
+ continue
413
+ }
353
414
  if (!_.endsWith(entity.name, entity.format)) {
354
415
  // Loop bound is `< data.pages` (not `data.pages - 1`).
355
416
  // With 4 pages and the old bound, iteration only ran
@@ -409,6 +470,19 @@ export function createOnBeforeRender({
409
470
  sidecarMetaReads: [...(sidecarTrack.metaReads ?? [])],
410
471
  sidecarConsumedReads: [...(sidecarTrack.consumedReads ?? [])].map(([k, v]) => [k, [...v]]) })
411
472
  }
473
+ } else {
474
+ // Pagination was asked for, and nothing was queued.
475
+ //
476
+ // The guard exists so an entity whose name already
477
+ // carries its format is not expanded twice, but it
478
+ // shares the shape that made the type confusion above
479
+ // so expensive: no task, no message, output absent on
480
+ // a green build. Whatever the right handling is, it is
481
+ // not silence.
482
+ logger.warn({ code: 'layout-pagination-skipped', id: entity.id },
483
+ 'Pagination was requested for %s (pages: %d) but its name already ends '
484
+ + 'with its format (%s), so no page was produced.',
485
+ entity.id, data.pages, entity.format)
412
486
  }
413
487
  } else {
414
488
  // Same derivation onProcessed persists onto the catalog
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io-layouts",
3
- "version": "2.12.0",
3
+ "version": "2.15.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",