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 +70 -5
- package/lib/assembly.js +74 -0
- package/package.json +1 -1
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 —
|
|
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.
|
|
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
|
|
314
|
-
|
|
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.
|
|
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",
|