mikser-io-layouts 2.4.1 → 2.5.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
@@ -27,6 +27,7 @@ import {
27
27
  import { createInspect } from './lib/inspect.js'
28
28
  import { createOnProcessed } from './lib/matching.js'
29
29
  import { createOnBeforeRender } from './lib/assembly.js'
30
+ import { installSidecarModuleHook } from './lib/sidecar-modules.js'
30
31
  import { registerMcpTools } from './lib/mcp.js'
31
32
 
32
33
  export function layouts(userOptions = {}) {
@@ -207,6 +208,12 @@ export function layouts(userOptions = {}) {
207
208
  logger.debug('Layouts folder: %s', runtime.options.layoutsFolder)
208
209
  await mkdir(runtime.options.layoutsFolder, { recursive: true })
209
210
 
211
+ // Keyed by the shared sidecar digest, so a module a sidecar
212
+ // imports is re-evaluated when any .js under this folder changes
213
+ // and served from cache when none has. Without it only the
214
+ // sidecar entry point reloads.
215
+ installSidecarModuleHook({ layoutsFolder: runtime.options.layoutsFolder, logger })
216
+
210
217
  watch(collection, runtime.options.layoutsFolder)
211
218
 
212
219
  // Rebuild the in-memory layouts map from the catalog. Indexed
package/lib/assembly.js CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  useDatabase,
28
28
  } from 'mikser-io'
29
29
  import { compileDestinationTemplate, sanitizeDestination, primaryDestination } from './destination.js'
30
+ import { setSidecarStamp, sidecarHookInstalled } from './sidecar-modules.js'
30
31
 
31
32
  export function createOnBeforeRender({
32
33
  runtime, useLogger, useJournal,
@@ -240,7 +241,19 @@ export function createOnBeforeRender({
240
241
  const sidecarTrack = createTrack({ partial: false })
241
242
  if (existsSync(sidecarPath)) {
242
243
  try {
243
- ({ load, plugins = [] } = await import(`${sidecarPath}?stamp=${Date.now()}`))
244
+ // Stamp = the shared sidecar digest, so the URL
245
+ // changes exactly when some .js under layoutsFolder
246
+ // does. With the resolve hook installed that stamp
247
+ // also reaches the sidecar's own imports; without it
248
+ // only the entry reloads, so fall back to a
249
+ // per-render stamp to keep at least that much.
250
+ const stamp = entity.layout.inputs?.shared || ''
251
+ setSidecarStamp(stamp)
252
+ const entryStamp = sidecarHookInstalled() ? stamp : Date.now();
253
+ // Semicolon above is load-bearing: without it the
254
+ // parenthesised destructuring assignment below reads
255
+ // as a CALL of the preceding expression.
256
+ ({ load, plugins = [] } = await import(`${sidecarPath}?stamp=${entryStamp}`))
244
257
  } catch (err) {
245
258
  logger.error('Layout sidecar %s failed to load: %s', sidecarPath.replace(runtime.options.workingFolder + '/', ''), err.message)
246
259
  throw err
package/lib/matching.js CHANGED
@@ -22,7 +22,7 @@ import path from 'node:path'
22
22
 
23
23
  import _ from 'lodash'
24
24
  import { primaryDestination } from './destination.js'
25
- import { reportWarning } from 'mikser-io'
25
+ import { reportWarning, isFullCycle } from 'mikser-io'
26
26
 
27
27
  export function createOnProcessed({
28
28
  runtime, useLogger, useJournal,
@@ -47,6 +47,13 @@ export function createOnProcessed({
47
47
  if (matchTally.reported) return
48
48
  const patterns = Object.keys(options.match || {})
49
49
  if (!patterns.length || !matchTally.evaluated) return
50
+ // Only when the cycle evaluated everything. An incremental run
51
+ // re-evaluates whatever changed, so a healthy pattern matches none of
52
+ // the two entities in a settled build — and a warning that fires on
53
+ // every build is one the reader filters, which takes the real
54
+ // instance with it. The message already pointed at --force to check
55
+ // the whole catalog; that is the condition, not a footnote.
56
+ if (!isFullCycle(runtime)) return
50
57
  matchTally.reported = true
51
58
 
52
59
  for (const pattern of patterns) {
@@ -68,8 +75,7 @@ export function createOnProcessed({
68
75
  })
69
76
  logger.warn(
70
77
  'Layout pattern %j (→ %j) matched none of the %d entities evaluated. ' +
71
- 'It is matched against entity.%s%s. On an incremental run unchanged ' +
72
- 'entities are not re-evaluated; use --force to check the whole catalog.',
78
+ 'It is matched against entity.%s%s.',
73
79
  pattern, options.match[pattern], matchTally.evaluated,
74
80
  onName ? 'name' : 'id',
75
81
  onName ? '' : " — prefix a pattern with '@/' to match entity.name instead, " +
@@ -0,0 +1,82 @@
1
+ // Module identity for layout sidecars.
2
+ //
3
+ // Node's ESM registry keys modules by resolved URL, so a `?stamp=` on the
4
+ // sidecar's own URL re-evaluates the ENTRY and nothing else: its
5
+ // `import './lib/context.js'` resolves to a URL with no query, which the
6
+ // registry answers from cache for the life of the process. Under --watch
7
+ // that leaves an edited helper stale after the first reload:
8
+ //
9
+ // import('/layouts/page.js?stamp=1') // page.js fresh, context.js read
10
+ // …edit lib/context.js…
11
+ // import('/layouts/page.js?stamp=2') // page.js fresh, context.js CACHED
12
+ //
13
+ // The stamp has to reach every module in the subgraph, which a resolve
14
+ // hook can do and an entry-point query cannot. `module.registerHooks`
15
+ // (Node 22.15+) runs synchronously in-thread, so the hook can append the
16
+ // current stamp to anything resolving under layoutsFolder.
17
+ //
18
+ // Consequences worth stating, because they are what make this safe to
19
+ // leave installed:
20
+ //
21
+ // - the stamp is the shared sidecar digest, so an unchanged tree keeps
22
+ // resolving to the same URLs and nothing is re-parsed. `Date.now()`
23
+ // re-parsed the entry on every render.
24
+ // - only paths under layoutsFolder are touched; the engine's own
25
+ // modules and node_modules resolve untouched.
26
+ // - a URL that already carries a query is left alone, so the entry
27
+ // import's explicit stamp is not doubled.
28
+ //
29
+ // Where the hook is unavailable, sidecars still reload and their imports
30
+ // still go stale — the pre-existing behaviour — and that is said out loud
31
+ // once rather than left to be discovered.
32
+
33
+ import module from 'node:module'
34
+ import { pathToFileURL } from 'node:url'
35
+
36
+ // Feature-detected rather than imported by name: `import { registerHooks }`
37
+ // throws at parse time on a Node that does not export it.
38
+ const canRegisterHooks = typeof module.registerHooks === 'function'
39
+
40
+ let installed = false
41
+ let rootUrl = null
42
+ let stamp = ''
43
+
44
+ // Register once per process. Idempotent, and a no-op on a Node without
45
+ // registerHooks — callers read the return value to decide what to use as
46
+ // the entry stamp.
47
+ export function installSidecarModuleHook({ layoutsFolder, logger }) {
48
+ if (installed) return true
49
+ if (!layoutsFolder) return false
50
+ if (!canRegisterHooks) {
51
+ logger?.debug(
52
+ 'Sidecar modules: node:module.registerHooks is unavailable (Node %s), so a module a '
53
+ + 'sidecar imports keeps its first-loaded copy for the life of the process. Editing one '
54
+ + 'under --watch needs a restart with --force. Node 22.15+ removes the limitation.',
55
+ process.version,
56
+ )
57
+ return false
58
+ }
59
+ rootUrl = pathToFileURL(layoutsFolder).href.replace(/\/?$/, '/')
60
+ module.registerHooks({
61
+ resolve(specifier, context, nextResolve) {
62
+ const resolved = nextResolve(specifier, context)
63
+ if (!resolved?.url?.startsWith(rootUrl)) return resolved
64
+ if (resolved.url.includes('?')) return resolved
65
+ return { ...resolved, url: `${resolved.url}?stamp=${stamp}` }
66
+ },
67
+ })
68
+ installed = true
69
+ return true
70
+ }
71
+
72
+ // The stamp every subsequent resolution under layoutsFolder is keyed by.
73
+ // Set from the layout's `inputs.shared` digest before importing its
74
+ // sidecar. Every layout in a cycle carries the same shared digest, so
75
+ // concurrent renders set the same value and there is nothing to race.
76
+ export function setSidecarStamp(next) {
77
+ stamp = next ?? ''
78
+ }
79
+
80
+ export function sidecarHookInstalled() {
81
+ return installed
82
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io-layouts",
3
- "version": "2.4.1",
3
+ "version": "2.5.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",
@@ -18,7 +18,7 @@
18
18
  },
19
19
  "homepage": "https://github.com/almero-digital-marketing/mikser-io-layouts#readme",
20
20
  "peerDependencies": {
21
- "mikser-io": "^9.18.0"
21
+ "mikser-io": "^9.20.0"
22
22
  },
23
23
  "dependencies": {
24
24
  "globby": "^14.0.0",
@@ -0,0 +1,100 @@
1
+ // Module identity for layout sidecars.
2
+ //
3
+ // Node's ESM registry keys modules by resolved URL. A `?stamp=` on the
4
+ // sidecar's own URL therefore re-evaluates the ENTRY and nothing else:
5
+ // its `import './lib/context.js'` resolves to a URL with no query and is
6
+ // answered from cache for the life of the process.
7
+ //
8
+ // Under --watch that shows up as a helper that propagates one edit and
9
+ // then freezes, and the damage outlives the process: the last render ran
10
+ // with the stale module, produced bytes that happened to match what was
11
+ // on disk, skipped the write, and recorded "these inputs produced those
12
+ // bytes". Revert the source and every check agrees with every other and
13
+ // all of them are wrong about the site — only --force recovers.
14
+ //
15
+ // These tests run in ONE process with a real edit between imports, which
16
+ // is the only place the bug exists. A scenario test spawns a fresh
17
+ // process per build, so its module cache is empty every time and the
18
+ // staleness cannot appear.
19
+
20
+ import { describe, it, before, after } from 'node:test'
21
+ import assert from 'node:assert/strict'
22
+ import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises'
23
+ import { tmpdir } from 'node:os'
24
+ import path from 'node:path'
25
+
26
+ import { installSidecarModuleHook, setSidecarStamp, sidecarHookInstalled } from '../lib/sidecar-modules.js'
27
+
28
+ describe('sidecar module identity', () => {
29
+ let root, layoutsFolder
30
+
31
+ before(async () => {
32
+ root = await mkdtemp(path.join(tmpdir(), 'mikser-sidecar-'))
33
+ layoutsFolder = path.join(root, 'layouts')
34
+ await mkdir(path.join(layoutsFolder, 'lib'), { recursive: true })
35
+ // page.js is the sidecar; lib/context.js is the module it imports.
36
+ await writeFile(path.join(layoutsFolder, 'lib', 'context.js'),
37
+ "export const icon = 'filter.svg'\n")
38
+ await writeFile(path.join(layoutsFolder, 'page.js'),
39
+ "import { icon } from './lib/context.js'\nexport const load = () => ({ icon })\n")
40
+ installSidecarModuleHook({ layoutsFolder, logger: { debug() {} } })
41
+ })
42
+
43
+ after(async () => { await rm(root, { recursive: true, force: true }) })
44
+
45
+ const importSidecar = async (stamp) => {
46
+ setSidecarStamp(stamp)
47
+ const mod = await import(`${path.join(layoutsFolder, 'page.js')}?stamp=${stamp}`)
48
+ return mod.load().icon
49
+ }
50
+
51
+ it('installs on a Node that supports registerHooks', () => {
52
+ // The fix depends on node:module.registerHooks (22.15+). If this
53
+ // fails the rest is testing the fallback, so it is worth stating.
54
+ assert.equal(sidecarHookInstalled(), true, `not installed on ${process.version}`)
55
+ })
56
+
57
+ it('reads the helper through the sidecar', async () => {
58
+ assert.equal(await importSidecar('digest-1'), 'filter.svg')
59
+ })
60
+
61
+ it('picks up an edit to the IMPORTED module when the stamp changes', async () => {
62
+ await writeFile(path.join(layoutsFolder, 'lib', 'context.js'),
63
+ "export const icon = 'x.svg'\n")
64
+ assert.equal(
65
+ await importSidecar('digest-2'), 'x.svg',
66
+ 'a transitive import must be re-evaluated when the stamp changes',
67
+ )
68
+ })
69
+
70
+ it('picks up a NOVEL value on the second edit', async () => {
71
+ // The case that proves it is not the write-skip masking a reload:
72
+ // a value never seen before cannot match anything already on disk.
73
+ await writeFile(path.join(layoutsFolder, 'lib', 'context.js'),
74
+ "export const icon = 'plus.svg'\n")
75
+ assert.equal(await importSidecar('digest-3'), 'plus.svg')
76
+ })
77
+
78
+ it('returns to the original value when the source is reverted', async () => {
79
+ await writeFile(path.join(layoutsFolder, 'lib', 'context.js'),
80
+ "export const icon = 'filter.svg'\n")
81
+ assert.equal(await importSidecar('digest-4'), 'filter.svg')
82
+ })
83
+
84
+ it('does NOT re-evaluate when the stamp is unchanged', async () => {
85
+ // The stamp is the shared digest, so an unchanged tree must resolve
86
+ // to the same URLs and re-parse nothing. Editing behind an unchanged
87
+ // stamp is not a real scenario — the digest would have moved — but it
88
+ // is how "cached" is observable.
89
+ const first = await importSidecar('digest-stable')
90
+ await writeFile(path.join(layoutsFolder, 'lib', 'context.js'),
91
+ "export const icon = 'never-seen.svg'\n")
92
+ assert.equal(
93
+ await importSidecar('digest-stable'), first,
94
+ 'an unchanged stamp must serve the cached module',
95
+ )
96
+ // Leave the tree as the other tests expect.
97
+ await writeFile(path.join(layoutsFolder, 'lib', 'context.js'),
98
+ "export const icon = 'filter.svg'\n")
99
+ })
100
+ })
package/test/unit.test.js CHANGED
@@ -124,12 +124,16 @@ describe('layouts plugin', () => {
124
124
  })
125
125
 
126
126
  it('warns once about a match pattern that selected nothing', async () => {
127
- // A pattern selecting zero entities used to be completely silent —
128
- // no warn, no debug — so a typo'd or missing language segment lost a
129
- // whole section while the build went green.
127
+ // A pattern selecting zero entities is otherwise completely silent —
128
+ // no warn, no debug — so a typo'd or missing language segment loses a
129
+ // whole section while the build goes green.
130
+ //
131
+ // `force` makes this a full cycle. The warning is gated on that,
132
+ // because on an incremental run the evaluated set is whatever changed
133
+ // and a healthy pattern legitimately matches none of it.
130
134
  await withTempWorking(async (workingFolder) => {
131
135
  const h = createHarness({
132
- options: { workingFolder, outputFolder: path.join(workingFolder, 'out') },
136
+ options: { workingFolder, outputFolder: path.join(workingFolder, 'out'), force: true },
133
137
  })
134
138
  layouts({
135
139
  autoLayouts: false,
@@ -161,7 +165,42 @@ describe('layouts plugin', () => {
161
165
  assert.ok(!text.includes("'post'"), 'must not name the pattern that DID match')
162
166
  assert.match(text, /matched against entity\.id/, 'names the field this pattern is matched against')
163
167
  assert.match(text, /'@\/'/, "points at the '@/' prefix for name matching")
164
- assert.match(text, /--force/, 'tells the reader how to check the whole catalog')
168
+ // No longer advises --force: the warning only fires on a full
169
+ // cycle now, so the advice would be telling the reader to do what
170
+ // they have already done.
171
+ assert.ok(!text.includes('--force'), 'does not advise what the reader just did')
172
+ })
173
+ })
174
+
175
+ it('stays silent about an unmatched pattern on an incremental cycle', async () => {
176
+ // The inverse of the test above, and the reason the gate exists. An
177
+ // incremental run re-evaluates only what changed, so a pattern that
178
+ // matches plenty of documents matches none of the two entities in a
179
+ // settled build. Warning there fires on every healthy build, and a
180
+ // warning that always fires is one the reader filters out — taking
181
+ // the real instance with it.
182
+ await withTempWorking(async (workingFolder) => {
183
+ const h = createHarness({
184
+ // No `force`, no `firstRun`, no cache invalidation.
185
+ options: { workingFolder, outputFolder: path.join(workingFolder, 'out') },
186
+ })
187
+ layouts({ autoLayouts: false, match: { 'nonesuch/*': 'section' } })(h.core)
188
+ await h.runHook('loaded')
189
+ await h.runSync('layouts', { action: 'create', context: { relativePath: 'section.hbs' } })
190
+
191
+ const doc = {
192
+ id: '/documents/post.md', name: 'post', collection: 'documents',
193
+ type: 'document', format: 'md', meta: {},
194
+ }
195
+ h.journal.push({ id: 1, entity: doc, operation: 'create', context: {}, options: {}, output: null })
196
+ await h.runHook('processed', { aborted: false })
197
+
198
+ const warnings = h.logs.filter(l =>
199
+ l.level === 'warn' && l.args.join(' ').includes('Layout pattern'))
200
+ assert.deepEqual(
201
+ warnings, [],
202
+ 'an incremental cycle cannot support the conclusion, so it must not draw it',
203
+ )
165
204
  })
166
205
  })
167
206
 
@@ -170,7 +209,7 @@ describe('layouts plugin', () => {
170
209
  // noise, so it is reported once per process.
171
210
  await withTempWorking(async (workingFolder) => {
172
211
  const h = createHarness({
173
- options: { workingFolder, outputFolder: path.join(workingFolder, 'out') },
212
+ options: { workingFolder, outputFolder: path.join(workingFolder, 'out'), force: true },
174
213
  })
175
214
  layouts({ autoLayouts: false, match: { 'nonesuch/*': 'section' } })(h.core)
176
215
  await h.runHook('loaded')