mikser-io-layouts 2.11.2 → 2.13.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
@@ -123,6 +123,9 @@ export function layouts(userOptions = {}) {
123
123
  // what makes their dependents re-render.
124
124
  if (_.endsWith(relativePath, '.js') && isSidecarScript(relativePath)) {
125
125
  logger.debug('Layouts sidecar changed (%s) — rescanning layouts', relativePath)
126
+ // The rescan re-catalogues the sidecar itself and re-emits
127
+ // whichever layouts its digest moved, so both the entity and
128
+ // its dependents follow from one call.
126
129
  await rescanLayouts()
127
130
  return
128
131
  }
@@ -242,12 +245,13 @@ export function layouts(userOptions = {}) {
242
245
  //
243
246
  // Two components:
244
247
  // own — the layout's own sidecar, `<name>.js`
245
- // 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)
246
250
  //
247
251
  // `shared` is coarse on purpose: a sidecar's own imports
248
252
  // (layouts/lib/context.js and friends) would otherwise need a module
249
- // graph walk. Treating any shared .js change as invalidating every
250
- // 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
251
255
  // thousands — and it is predictable, which a partial graph walk
252
256
  // would not be.
253
257
  // NOT every .js under the folder is a sidecar. `post.hbs.js` is a
@@ -292,6 +296,23 @@ export function layouts(userOptions = {}) {
292
296
  const { layouts } = runtime.state.layouts
293
297
  const logger = useLogger()
294
298
  const paths = await globby('**/*', { cwd: runtime.options.layoutsFolder, ignore: ['**/*.js'] })
299
+ // Sidecars are catalogued too — as entities of their own, not as
300
+ // layouts. They are where a site's data layer lives (`data.children`
301
+ // on this project is an entire catalog listing), and until now they
302
+ // were the one source file no tool could read, search or resolve:
303
+ // an agent could see what a template does but not what feeds it.
304
+ //
305
+ // They are scanned in the SAME pass so the checksum gate and the
306
+ // delete sweep cover them; a separate pass would sweep them away.
307
+ //
308
+ // The original objection to giving them entities was the id
309
+ // stripping — `page.js` becoming a phantom `/layouts/page` beside
310
+ // the real `/layouts/page.liquid`. Keeping the `.js` removes that,
311
+ // and they stay out of the layouts NAME map so nothing resolves a
312
+ // template to one. Rendering is not a risk either: layout matching
313
+ // skips this collection outright.
314
+ const sidecarPaths = (await globby('**/*.js', { cwd: runtime.options.layoutsFolder }))
315
+ .filter(isSidecarScript)
295
316
  const inputs = await sidecarInputs()
296
317
  const scanned = new Set()
297
318
  const stats = { emitted: 0, skipped: 0, deleted: 0 }
@@ -309,11 +330,29 @@ export function layouts(userOptions = {}) {
309
330
  // before the loop; the gate hits a Map.get instead of a per-
310
331
  // file SQL lookup.
311
332
  const priorChecksums = checksumsByCollection(collection)
312
- for (let relativePath of paths) {
333
+ for (let relativePath of [...paths, ...sidecarPaths]) {
313
334
  const uri = path.join(runtime.options.layoutsFolder, relativePath)
314
335
  const id = path.join('/layouts', relativePath)
315
336
  scanned.add(id)
316
337
 
338
+ // A sidecar keeps its extension in both id and name, so it can
339
+ // never be mistaken for the layout it sits beside, and it is
340
+ // NOT put in the layouts map — that map answers "which template
341
+ // renders this", and a sidecar renders nothing.
342
+ if (sidecarPaths.includes(relativePath)) {
343
+ const chksum = await gateChecksum(uri, id, { priorChecksums })
344
+ if (chksum === null) { stats.skipped++; continue }
345
+ await createEntity({
346
+ id, uri, collection,
347
+ type: 'sidecar',
348
+ name: relativePath,
349
+ content: await readLayoutContent(uri),
350
+ checksum: chksum,
351
+ })
352
+ stats.emitted++
353
+ continue
354
+ }
355
+
317
356
  const name = relativePath.replace(path.extname(relativePath), '')
318
357
  const chksum = await gateChecksum(uri, id, {
319
358
  priorChecksums,
@@ -342,6 +381,35 @@ export function layouts(userOptions = {}) {
342
381
  },
343
382
  }
344
383
  Object.assign(layout, await getFormatInfo(relativePath))
384
+
385
+ // Two files can resolve to ONE layout name, and this map is
386
+ // keyed by name — so the second one silently replaced the
387
+ // first and which one won depended on scan order.
388
+ //
389
+ // getFormatInfo strips the whole compound extension:
390
+ // sitemap.xml.liquid, sitemap.liquid and sitemap.xml.js are
391
+ // all named `sitemap`. A document asking for `layout: sitemap`
392
+ // then got whichever landed last, and if that was a `js`
393
+ // template it rendered nothing — no error, no warning, the
394
+ // build green and the page absent. Reported from a real
395
+ // migration, where the same shape worked for one layout and
396
+ // not another and no rule could be found, because the rule was
397
+ // directory order.
398
+ //
399
+ // Not resolved here, because picking a winner would be
400
+ // guessing at intent. Said out loud instead, naming both
401
+ // files, which is the thing that was missing.
402
+ const clash = layouts[layout.name]
403
+ if (clash && clash.id !== layout.id) {
404
+ logger.warn(
405
+ { code: 'layout-name-collision', name: layout.name, files: [clash.uri, layout.uri] },
406
+ 'Layouts %j is claimed by two files — %s and %s. Both reduce to the same layout name, '
407
+ + 'so one replaces the other and which one wins is directory order. A document asking '
408
+ + 'for this layout may render with the wrong template, or with a `js` template that '
409
+ + 'emits nothing at all. Rename one of them.',
410
+ layout.name, clash.uri, layout.uri)
411
+ }
412
+
345
413
  layouts[layout.name] = layout
346
414
  await createEntity(layout)
347
415
  stats.emitted++
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io-layouts",
3
- "version": "2.11.2",
3
+ "version": "2.13.0",
4
4
  "description": "SSG-flavor task-production policy for mikser-io. Multi-match layout assignment, per-layout destinations, postprocess chain attachment, layout inspection.",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/test/unit.test.js CHANGED
@@ -1042,12 +1042,47 @@ describe('layouts plugin: a sidecar is a tracked input (layouts#4)', () => {
1042
1042
  assert.notEqual(before.checksums.post, after.checksums.post)
1043
1043
  })
1044
1044
 
1045
- it('does not make an entity out of a sidecar', async () => {
1046
- // Giving it one would put JS source in the catalog, and via onSync's
1047
- // id-stripping produce a phantom /layouts/page beside the real
1048
- // /layouts/page.liquid.
1045
+ it('catalogues a sidecar as an entity of its own', async () => {
1046
+ // A sidecar is where a site's data layer lives, and it used to be the
1047
+ // one source file no tool could read, search or resolve: an agent could
1048
+ // see what a template does but not what feeds it.
1049
1049
  const { emitted } = await scan({ 'page.liquid': '<p>x</p>', 'page.js': 'export const load = () => ({})' })
1050
- assert.deepEqual(emitted.map(e => e.id).sort(), ['/layouts/page.liquid'])
1050
+ assert.deepEqual(emitted.map(e => e.id).sort(), ['/layouts/page.js', '/layouts/page.liquid'])
1051
+ })
1052
+
1053
+ it('keeps the .js, so a sidecar cannot be mistaken for its layout', async () => {
1054
+ // The original objection to cataloguing it: stripping the extension
1055
+ // produces a phantom /layouts/page beside the real
1056
+ // /layouts/page.liquid, and `page` is how layouts are resolved.
1057
+ const { emitted } = await scan({ 'page.liquid': '<p>x</p>', 'page.js': 'export const load = () => ({})' })
1058
+ const sidecar = emitted.find(e => e.id === '/layouts/page.js')
1059
+ assert.equal(sidecar.name, 'page.js', 'the name keeps its extension too')
1060
+ assert.equal(sidecar.type, 'sidecar')
1061
+ assert.ok(!emitted.some(e => e.id === '/layouts/page'), 'no phantom')
1062
+ })
1063
+
1064
+ it('carries the source, so it can be read and searched', async () => {
1065
+ const source = 'export const load = () => ({ children: [] })'
1066
+ const { emitted } = await scan({ 'page.liquid': '<p>x</p>', 'page.js': source })
1067
+ assert.equal(emitted.find(e => e.id === '/layouts/page.js').content, source)
1068
+ })
1069
+
1070
+ it('does not put a sidecar in the layouts name map', async () => {
1071
+ // That map answers "which template renders this". A sidecar renders
1072
+ // nothing, and an entry there would shadow a real layout called `page`.
1073
+ await withTempWorking(async (workingFolder) => {
1074
+ const h = createHarness({ options: { workingFolder, outputFolder: path.join(workingFolder, 'out') } })
1075
+ await mkdir(path.join(workingFolder, 'layouts'), { recursive: true })
1076
+ await writeFile(path.join(workingFolder, 'layouts', 'page.liquid'), '<p>x</p>')
1077
+ await writeFile(path.join(workingFolder, 'layouts', 'page.js'), 'export const load = () => ({})')
1078
+ layouts()(h.core)
1079
+ await h.runHook('loaded')
1080
+ await h.runHook('import')
1081
+ const map = h.runtime.state.layouts.layouts
1082
+ assert.ok(map.page, 'the real layout is there')
1083
+ assert.equal(map.page.id, '/layouts/page.liquid')
1084
+ assert.equal(map['page.js'], undefined, 'the sidecar is not')
1085
+ })
1051
1086
  })
1052
1087
 
1053
1088
  it('still treats a JS-AUTHORED layout as a layout, not a sidecar', async () => {