mikser-io-layouts 2.11.1 → 2.12.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 +39 -1
- package/lib/mcp.js +26 -3
- package/package.json +1 -1
- package/test/check-entity.test.js +21 -0
- package/test/unit.test.js +40 -5
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
|
}
|
|
@@ -292,6 +295,23 @@ export function layouts(userOptions = {}) {
|
|
|
292
295
|
const { layouts } = runtime.state.layouts
|
|
293
296
|
const logger = useLogger()
|
|
294
297
|
const paths = await globby('**/*', { cwd: runtime.options.layoutsFolder, ignore: ['**/*.js'] })
|
|
298
|
+
// Sidecars are catalogued too — as entities of their own, not as
|
|
299
|
+
// layouts. They are where a site's data layer lives (`data.children`
|
|
300
|
+
// on this project is an entire catalog listing), and until now they
|
|
301
|
+
// were the one source file no tool could read, search or resolve:
|
|
302
|
+
// an agent could see what a template does but not what feeds it.
|
|
303
|
+
//
|
|
304
|
+
// They are scanned in the SAME pass so the checksum gate and the
|
|
305
|
+
// delete sweep cover them; a separate pass would sweep them away.
|
|
306
|
+
//
|
|
307
|
+
// The original objection to giving them entities was the id
|
|
308
|
+
// stripping — `page.js` becoming a phantom `/layouts/page` beside
|
|
309
|
+
// the real `/layouts/page.liquid`. Keeping the `.js` removes that,
|
|
310
|
+
// and they stay out of the layouts NAME map so nothing resolves a
|
|
311
|
+
// template to one. Rendering is not a risk either: layout matching
|
|
312
|
+
// skips this collection outright.
|
|
313
|
+
const sidecarPaths = (await globby('**/*.js', { cwd: runtime.options.layoutsFolder }))
|
|
314
|
+
.filter(isSidecarScript)
|
|
295
315
|
const inputs = await sidecarInputs()
|
|
296
316
|
const scanned = new Set()
|
|
297
317
|
const stats = { emitted: 0, skipped: 0, deleted: 0 }
|
|
@@ -309,11 +329,29 @@ export function layouts(userOptions = {}) {
|
|
|
309
329
|
// before the loop; the gate hits a Map.get instead of a per-
|
|
310
330
|
// file SQL lookup.
|
|
311
331
|
const priorChecksums = checksumsByCollection(collection)
|
|
312
|
-
for (let relativePath of paths) {
|
|
332
|
+
for (let relativePath of [...paths, ...sidecarPaths]) {
|
|
313
333
|
const uri = path.join(runtime.options.layoutsFolder, relativePath)
|
|
314
334
|
const id = path.join('/layouts', relativePath)
|
|
315
335
|
scanned.add(id)
|
|
316
336
|
|
|
337
|
+
// A sidecar keeps its extension in both id and name, so it can
|
|
338
|
+
// never be mistaken for the layout it sits beside, and it is
|
|
339
|
+
// NOT put in the layouts map — that map answers "which template
|
|
340
|
+
// renders this", and a sidecar renders nothing.
|
|
341
|
+
if (sidecarPaths.includes(relativePath)) {
|
|
342
|
+
const chksum = await gateChecksum(uri, id, { priorChecksums })
|
|
343
|
+
if (chksum === null) { stats.skipped++; continue }
|
|
344
|
+
await createEntity({
|
|
345
|
+
id, uri, collection,
|
|
346
|
+
type: 'sidecar',
|
|
347
|
+
name: relativePath,
|
|
348
|
+
content: await readLayoutContent(uri),
|
|
349
|
+
checksum: chksum,
|
|
350
|
+
})
|
|
351
|
+
stats.emitted++
|
|
352
|
+
continue
|
|
353
|
+
}
|
|
354
|
+
|
|
317
355
|
const name = relativePath.replace(path.extname(relativePath), '')
|
|
318
356
|
const chksum = await gateChecksum(uri, id, {
|
|
319
357
|
priorChecksums,
|
package/lib/mcp.js
CHANGED
|
@@ -566,13 +566,36 @@ export function registerCheckTool({ runtime, findEntity, findEntities, useDataba
|
|
|
566
566
|
// declared key no layout reads.
|
|
567
567
|
const knows = (k) => req.has(k) || opt.has(k)
|
|
568
568
|
|| [...req, ...opt].some(d => d.startsWith(`${k}.`) || d.startsWith(`${k}[`))
|
|
569
|
-
|
|
570
|
-
|
|
569
|
+
|
|
570
|
+
// `declaredButNotRead` asks whether a declared key is read by
|
|
571
|
+
// ANY layout, so it is answered against the UNSCOPED contract —
|
|
572
|
+
// the opposite of `missing`, which asks what THIS page needs.
|
|
573
|
+
//
|
|
574
|
+
// Scoped, a shorter edition of a page looked broken: every
|
|
575
|
+
// section it does not use made the schema's keys for that
|
|
576
|
+
// section look dead, and one such page reported 118 of them
|
|
577
|
+
// while its longer sibling reported none. The document's only
|
|
578
|
+
// difference was using fewer sections.
|
|
579
|
+
const wide = await runtime.options.layouts.inspect(layoutId, { samples: 0 })
|
|
580
|
+
const anyLayout = new Set([
|
|
581
|
+
...(wide.references?.contract?.meta ?? []),
|
|
582
|
+
...observed,
|
|
583
|
+
])
|
|
584
|
+
const readAnywhere = (k) => anyLayout.has(k)
|
|
585
|
+
|| [...anyLayout].some(c => c.startsWith(`${k}.`) || c.startsWith(`${k}[`))
|
|
571
586
|
drift = {
|
|
572
587
|
schema: declaredSchema.name,
|
|
573
588
|
readButNotDeclared: [...consumed].filter(k => k && !knows(k)).sort(),
|
|
574
|
-
declaredButNotRead: [...req].filter(k => !
|
|
589
|
+
declaredButNotRead: [...req].filter(k => !readAnywhere(k)).sort(),
|
|
575
590
|
}
|
|
591
|
+
|
|
592
|
+
// And the same noise in the softer list: a key this page's
|
|
593
|
+
// layout cannot reach is not an omission, it is not applicable.
|
|
594
|
+
// Reporting 139 of them is the noise the scoping fix removed
|
|
595
|
+
// from `missing`, arriving by another route.
|
|
596
|
+
const reachable = (k) => consumed.has(k)
|
|
597
|
+
|| [...consumed].some(c => c.startsWith(`${k}.`) || c.startsWith(`${k}[`))
|
|
598
|
+
missingOptional = missingOptional.filter(reachable)
|
|
576
599
|
}
|
|
577
600
|
|
|
578
601
|
// A declared section that resolved to no template under this
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mikser-io-layouts",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.12.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",
|
|
@@ -373,6 +373,27 @@ describe('mikser_check_entity: with a declared schema', () => {
|
|
|
373
373
|
`missingOptional: ${r.missingOptional.join(', ')}`)
|
|
374
374
|
})
|
|
375
375
|
|
|
376
|
+
it('answers declaredButNotRead against ANY layout, not just this page', async () => {
|
|
377
|
+
// The mirror of the scoping fix. `missing` asks what THIS page needs,
|
|
378
|
+
// so it is scoped; "is this key read anywhere" is the opposite question
|
|
379
|
+
// and must not be. Scoped, a shorter edition of a page reported every
|
|
380
|
+
// key of every section it does not use as dead — 118 of them on a page
|
|
381
|
+
// whose only difference was using fewer sections than its sibling.
|
|
382
|
+
const r = await checkWithSchema('/documents/schemed.md')
|
|
383
|
+
assert.ok(!r.drift.declaredButNotRead.some(k => k.startsWith('hero.')),
|
|
384
|
+
`hero keys are read by a layout, just not this render: ${r.drift.declaredButNotRead.join(', ')}`)
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
it('does not list an optional key this page could not use anyway', async () => {
|
|
388
|
+
// Same noise by another route: a key outside what this page renders is
|
|
389
|
+
// not an omission, it is not applicable.
|
|
390
|
+
const r = await checkWithSchema('/documents/schemed.md')
|
|
391
|
+
for (const k of r.missingOptional) {
|
|
392
|
+
assert.ok(!/^(specs|series|results)\./.test(k),
|
|
393
|
+
`${k} belongs to a section this page does not render`)
|
|
394
|
+
}
|
|
395
|
+
})
|
|
396
|
+
|
|
376
397
|
it('reports drift in both directions', async () => {
|
|
377
398
|
const r = await checkWithSchema('/documents/schemed.md')
|
|
378
399
|
assert.equal(r.drift.schema, 'article')
|
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('
|
|
1046
|
-
//
|
|
1047
|
-
//
|
|
1048
|
-
//
|
|
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 () => {
|