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 +7 -0
- package/lib/assembly.js +14 -1
- package/lib/matching.js +9 -3
- package/lib/sidecar-modules.js +82 -0
- package/package.json +2 -2
- package/test/sidecar-modules.test.js +100 -0
- package/test/unit.test.js +45 -6
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
128
|
-
// no warn, no debug — so a typo'd or missing language segment
|
|
129
|
-
// whole section while the build
|
|
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
|
-
|
|
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')
|