mikser-io-layouts 11.2.0 → 11.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/index.js CHANGED
@@ -112,9 +112,11 @@ export function layouts(userOptions = {}) {
112
112
  // and a second copy of the stamping is a second chance for an
113
113
  // edited sidecar to keep answering from cache.
114
114
  //
115
- // Consumers: mikser-io-mcp-app, which invokes a sidecar's `call`,
116
- // `read` and `list` exports for an app's actions and data. The
117
- // render's own use of the same loader reads `load` and `plugins`.
115
+ // Offered, not required of anyone: a consumer asks for the
116
+ // `layouts` service and uses it if present. The render's own use
117
+ // of the same loader reads `load` and `plugins`; a surface that
118
+ // gives layouts behaviour beyond rendering can read exports of
119
+ // its own from the same file.
118
120
  sidecar: (layout) => loadSidecarModule(layout, {
119
121
  layoutsFolder: layoutsFolderNow(),
120
122
  logger: useLogger(),
package/lib/matching.js CHANGED
@@ -203,12 +203,28 @@ export function createOnProcessed({
203
203
  switch (operation) {
204
204
  case OPERATION.CREATE:
205
205
  case OPERATION.UPDATE:
206
+ let resolutionFailed = false
206
207
  try {
207
208
  entity.layouts = await resolveLayoutsForEntity(entity)
208
209
  } catch (err) {
209
210
  logger.error('Layout resolution for %s: %s', entity.id, err.message)
210
211
  entity.layouts = []
212
+ resolutionFailed = true
211
213
  }
214
+
215
+ // Whether the AUTHOR asked for a layout. Used further
216
+ // down to tell an abandoned page from a typo.
217
+ //
218
+ // Captured here, before the mirror below writes
219
+ // meta.layout for the pattern-matched and auto-layout
220
+ // paths, so the field still answers one question. Read
221
+ // after it, "the author named a layout" and "we recorded
222
+ // the one we chose" are the same value. That is a
223
+ // legibility choice, not a fix: the mirror only writes
224
+ // when a layout was found, so the `layouts.length === 0`
225
+ // term below already excludes every entity it touched.
226
+ const declaredALayout = Boolean(
227
+ entity.meta?.layout || entity.meta?.layouts?.length)
212
228
  // Back-compat alias. Most existing downstream code
213
229
  // reads `entity.layout`; keep it pointing at the
214
230
  // first matched layout so it stays useful. The
@@ -275,6 +291,41 @@ export function createOnProcessed({
275
291
  )
276
292
  }
277
293
 
294
+ // Tell the manifest this entity produces nothing, so the
295
+ // snapshots it used to hold stop claiming files nobody
296
+ // writes any more.
297
+ //
298
+ // Take `layout:` out of a document's front matter and the
299
+ // page stayed on disk with a snapshot still vouching for
300
+ // it — and because the file still matched the hash its own
301
+ // render recorded, --audit-output read OK while the site
302
+ // served a page the source no longer asked for. The
303
+ // manifest cannot see this by itself: with no render task
304
+ // there is nothing to compare against, and an asset whose
305
+ // preset threw looks exactly the same from there. This
306
+ // hook is where it IS knowable, so this is where it is
307
+ // said.
308
+ //
309
+ // Only for an entity that declared nothing and matched
310
+ // nothing. The two excluded cases are both errors, and
311
+ // mikser keeps the last good output through an error
312
+ // rather than taking the page down:
313
+ //
314
+ // declared but unresolvable — `layout: page` with no
315
+ // `page` layout, already warned about by
316
+ // resolveLayout. Renaming a layout file would
317
+ // otherwise delete every page that names it, on the
318
+ // strength of a typo.
319
+ // resolution threw — meta.layout and meta.layouts both
320
+ // set, say. Nothing was decided, so nothing follows.
321
+ //
322
+ // Optional-called: the peer floor is core ^11.1.0 and
323
+ // recordNoOutput is newer than that, so on an older
324
+ // engine this is simply the behaviour that came before.
325
+ if (entity.layouts.length === 0 && !declaredALayout && !resolutionFailed) {
326
+ runtime.manifest?.recordNoOutput?.(entity.id)
327
+ }
328
+
278
329
  // meta.postprocessor (string) / meta.postprocessors
279
330
  // (array) override is read at task-build time (in
280
331
  // onBeforeRender) because the layout entity is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io-layouts",
3
- "version": "11.2.0",
3
+ "version": "11.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",
package/test/unit.test.js CHANGED
@@ -728,6 +728,112 @@ describe('layouts plugin: multi-layouts (matching)', () => {
728
728
  })
729
729
  })
730
730
 
731
+ describe('layouts plugin: reporting that an entity renders nothing', () => {
732
+ // The manifest keeps a snapshot per (entity, destination) — its claim that
733
+ // the entity produced that file. An entity that STOPS producing output
734
+ // leaves the claim behind, and the page with it: still on disk, still
735
+ // matching the hash its own render recorded, so --audit-output reads OK
736
+ // while the site serves something the source no longer asks for.
737
+ //
738
+ // The manifest cannot see this alone. With no render task there is nothing
739
+ // to compare a claim against, and an asset whose preset threw looks
740
+ // identical from there — so guessing deletes the good derivative a failed
741
+ // preset exists to keep. This hook knows, so this hook says.
742
+ //
743
+ // What must NOT be reported is the whole difficulty. Both exclusions are
744
+ // errors, and mikser's rule through an error is that the last good output
745
+ // survives rather than the page coming down.
746
+ async function report(files, doc, options = {}) {
747
+ return withTempWorking(async (workingFolder) => {
748
+ const h = createHarness({
749
+ options: { workingFolder, outputFolder: path.join(workingFolder, 'out') },
750
+ })
751
+ const reported = []
752
+ h.runtime.manifest = { recordNoOutput: (id) => reported.push(id) }
753
+ layouts(options)(h.core)
754
+ await h.runHook('loaded')
755
+ for (const relativePath of files) {
756
+ await h.runSync('layouts', { action: 'create', context: { relativePath } })
757
+ }
758
+ h.journal.push({ id: 1, entity: doc, operation: 'create', context: {}, options: {}, output: null })
759
+ await h.runHook('processed', { aborted: false })
760
+ return { reported, doc, logs: h.logs }
761
+ })
762
+ }
763
+
764
+ const plainDoc = (meta) => ({
765
+ id: '/documents/blog/welcome.md',
766
+ collection: 'documents',
767
+ name: 'blog/welcome',
768
+ format: 'md',
769
+ meta,
770
+ })
771
+
772
+ it('reports an entity that declared nothing and matched nothing', async () => {
773
+ const { reported, doc } = await report(['post.hbs'], plainDoc({}))
774
+ assert.deepEqual(doc.layouts, [], 'precondition: nothing resolved')
775
+ assert.deepEqual(reported, ['/documents/blog/welcome.md'])
776
+ })
777
+
778
+ it('stays quiet when a declared layout merely cannot be found', async () => {
779
+ // A typo, or a layout file renamed. The entity still says which layout
780
+ // it wants, so this is an error to fix — not a page to withdraw.
781
+ // Reporting it would delete every page naming a layout the moment that
782
+ // layout's filename changed.
783
+ const { reported, doc, logs } = await report(['post.hbs'], plainDoc({ layout: 'pst' }))
784
+ assert.deepEqual(doc.layouts, [], 'precondition: nothing resolved')
785
+ assert.ok(logs.some(l => l.args.join(' ').includes('Layout not found')),
786
+ 'precondition: it really did fail to resolve')
787
+ assert.deepEqual(reported, [])
788
+ })
789
+
790
+ it('stays quiet when resolution threw', async () => {
791
+ // meta.layout and meta.layouts both set. Nothing was decided, so
792
+ // nothing follows from it.
793
+ const { reported, doc } = await report(
794
+ ['post.hbs'], plainDoc({ layout: 'post', layouts: ['post'] }))
795
+ assert.deepEqual(doc.layouts, [], 'precondition: resolution failed')
796
+ assert.deepEqual(reported, [])
797
+ })
798
+
799
+ it('stays quiet when a layout did match', async () => {
800
+ const { reported, doc } = await report(['post.hbs'], plainDoc({ layout: 'post' }))
801
+ assert.equal(doc.layouts.length, 1, 'precondition: it resolved')
802
+ assert.deepEqual(reported, [])
803
+ })
804
+
805
+ it('stays quiet when a CONFIG PATTERN matched and the author declared nothing', async () => {
806
+ // The case that pins down which question `declaredALayout` answers.
807
+ // The mirror block writes meta.layout for pattern-matched and
808
+ // auto-layout entities, so if it were read after that, "the author
809
+ // named a layout" and "we recorded the one we chose" would be the same
810
+ // field — and an entity that rendered perfectly well would be reported
811
+ // as producing nothing, taking its own fresh output off disk.
812
+ const { reported, doc } = await report(
813
+ ['post.hbs'], plainDoc({}), { match: { '@/blog/*': 'post' } })
814
+ assert.equal(doc.layouts.length, 1, 'precondition: the pattern matched')
815
+ assert.equal(doc.meta.layout, 'post', 'precondition: and the mirror recorded it')
816
+ assert.deepEqual(reported, [])
817
+ })
818
+
819
+ it('does not require the engine to offer recordNoOutput at all', async () => {
820
+ // The peer floor is core ^11.1.0 and recordNoOutput is newer, so an
821
+ // entity that renders nothing on an older engine has to be a quiet
822
+ // no-op rather than a crash mid-cycle.
823
+ await withTempWorking(async (workingFolder) => {
824
+ const h = createHarness({
825
+ options: { workingFolder, outputFolder: path.join(workingFolder, 'out') },
826
+ })
827
+ assert.equal(h.runtime.manifest, undefined, 'precondition: no manifest on the runtime')
828
+ layouts()(h.core)
829
+ await h.runHook('loaded')
830
+ await h.runSync('layouts', { action: 'create', context: { relativePath: 'post.hbs' } })
831
+ h.journal.push({ id: 1, entity: plainDoc({}), operation: 'create', context: {}, options: {}, output: null })
832
+ await h.runHook('processed', { aborted: false })
833
+ })
834
+ })
835
+ })
836
+
731
837
  describe('layouts plugin: multi-layouts (collision)', () => {
732
838
  it('drops both render tasks when two layouts produce the same destination', async () => {
733
839
  await withTempWorking(async (workingFolder) => {