mikser-io-layouts 11.0.1 → 11.2.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 +54 -4
- package/lib/assembly.js +12 -11
- package/lib/sidecar-modules.js +31 -0
- package/package.json +3 -3
- package/test/sidecar-modules.test.js +49 -1
package/index.js
CHANGED
|
@@ -19,7 +19,7 @@ import _ from 'lodash'
|
|
|
19
19
|
import {
|
|
20
20
|
gateChecksum, sweepDeleted, scanSummary,
|
|
21
21
|
checksumsByCollection,
|
|
22
|
-
checksum as fileChecksum, checksumOf,
|
|
22
|
+
checksum as fileChecksum, checksumOf, registerSourceChecksum,
|
|
23
23
|
useDatabase,
|
|
24
24
|
writeOutput, reportUnchanged,
|
|
25
25
|
provideService,
|
|
@@ -29,7 +29,7 @@ import {
|
|
|
29
29
|
import { createInspect } from './lib/inspect.js'
|
|
30
30
|
import { createOnProcessed } from './lib/matching.js'
|
|
31
31
|
import { createOnBeforeRender } from './lib/assembly.js'
|
|
32
|
-
import { installSidecarModuleHook } from './lib/sidecar-modules.js'
|
|
32
|
+
import { installSidecarModuleHook, loadSidecarModule } from './lib/sidecar-modules.js'
|
|
33
33
|
import { registerMcpTools } from './lib/mcp.js'
|
|
34
34
|
|
|
35
35
|
export function layouts(userOptions = {}) {
|
|
@@ -106,6 +106,19 @@ export function layouts(userOptions = {}) {
|
|
|
106
106
|
|
|
107
107
|
provideService('layouts', {
|
|
108
108
|
inspect: createInspect({ runtime, findEntity, findEntities, useDatabase, collection }),
|
|
109
|
+
// A layout's sidecar, loaded the way the render loads it —
|
|
110
|
+
// same path rule, same digest stamp. Offered as a service because
|
|
111
|
+
// the alternative is another package importing this one's lib/,
|
|
112
|
+
// and a second copy of the stamping is a second chance for an
|
|
113
|
+
// edited sidecar to keep answering from cache.
|
|
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`.
|
|
118
|
+
sidecar: (layout) => loadSidecarModule(layout, {
|
|
119
|
+
layoutsFolder: layoutsFolderNow(),
|
|
120
|
+
logger: useLogger(),
|
|
121
|
+
}),
|
|
109
122
|
}, { plugin: 'mikser-io-layouts' })
|
|
110
123
|
|
|
111
124
|
// Tool registration. Registers against core's tool registry, which
|
|
@@ -283,13 +296,27 @@ export function layouts(userOptions = {}) {
|
|
|
283
296
|
// non-entity, which is why it is a rule and not a guess.
|
|
284
297
|
const isSidecarScript = (rel) => path.extname(rel.replace(/\.js$/, '')) === ''
|
|
285
298
|
|
|
299
|
+
// The folder, resolved the same way the onLoaded below resolves it.
|
|
300
|
+
//
|
|
301
|
+
// That onLoaded is where `runtime.options.layoutsFolder` gets set, and
|
|
302
|
+
// a report-only command — mikser_explain, --audit-output — runs in an
|
|
303
|
+
// onLoaded too. Which of the two runs first is plugin order, so a
|
|
304
|
+
// recompute that read the option directly worked or threw depending on
|
|
305
|
+
// it. Derived here instead, so the answer does not depend on the
|
|
306
|
+
// question's timing.
|
|
307
|
+
function layoutsFolderNow() {
|
|
308
|
+
if (runtime.options.layoutsFolder) return runtime.options.layoutsFolder
|
|
309
|
+
const name = runtime.options.layouts ?? options.layoutsFolder ?? collection
|
|
310
|
+
return path.isAbsolute(name) ? name : path.join(runtime.options.workingFolder, name)
|
|
311
|
+
}
|
|
312
|
+
|
|
286
313
|
async function sidecarInputs() {
|
|
287
|
-
const scriptPaths = (await globby('**/*.js', { cwd:
|
|
314
|
+
const scriptPaths = (await globby('**/*.js', { cwd: layoutsFolderNow() }))
|
|
288
315
|
.filter(isSidecarScript)
|
|
289
316
|
const own = new Map()
|
|
290
317
|
const shared = []
|
|
291
318
|
for (const rel of scriptPaths.sort()) {
|
|
292
|
-
const sum = await fileChecksum(path.join(
|
|
319
|
+
const sum = await fileChecksum(path.join(layoutsFolderNow(), rel))
|
|
293
320
|
own.set(rel.replace(/\.js$/, ''), sum)
|
|
294
321
|
shared.push(`${rel}:${sum}`)
|
|
295
322
|
}
|
|
@@ -305,6 +332,29 @@ export function layouts(userOptions = {}) {
|
|
|
305
332
|
return Buffer.from(`${template}:${sidecar}:${inputs.sharedDigest}`, 'utf8')
|
|
306
333
|
}
|
|
307
334
|
|
|
335
|
+
// Tell core how to recompute what it stored.
|
|
336
|
+
//
|
|
337
|
+
// The catalog holds the composed value above, not a file hash, so
|
|
338
|
+
// anything comparing it against a fresh md5 of the template compares
|
|
339
|
+
// two recipes and always finds them different. mikser_explain did
|
|
340
|
+
// exactly that and reported `differs: true` for every layout on every
|
|
341
|
+
// site — a permanent false alarm about files nobody had edited.
|
|
342
|
+
//
|
|
343
|
+
// The recipe belongs here, so the recomputation does too. One
|
|
344
|
+
// function, the same one the gate uses, so the two cannot drift apart.
|
|
345
|
+
registerSourceChecksum(collection, async (entity) => {
|
|
346
|
+
// A sidecar is in this collection but is NOT composed: it is
|
|
347
|
+
// gated on its own bytes above, because it has no template and no
|
|
348
|
+
// sidecar of its own. Answering with a composed value here would
|
|
349
|
+
// report every sidecar as diverged — the same false alarm this
|
|
350
|
+
// registration exists to remove, pointed the other way.
|
|
351
|
+
//
|
|
352
|
+
// null means "nothing composed here, use the file hash", which is
|
|
353
|
+
// exactly right for it.
|
|
354
|
+
if (entity.type === 'sidecar') return null
|
|
355
|
+
return checksumOf(await layoutInputBytes(entity.uri, entity.name, await sidecarInputs()))
|
|
356
|
+
})
|
|
357
|
+
|
|
308
358
|
// Named so onSync can re-run it when a sidecar changes. A function
|
|
309
359
|
// declaration, so it is hoisted above the onSync registration above.
|
|
310
360
|
async function rescanLayouts() {
|
package/lib/assembly.js
CHANGED
|
@@ -30,7 +30,7 @@ import {
|
|
|
30
30
|
reportError,
|
|
31
31
|
} from 'mikser-io'
|
|
32
32
|
import { compileDestinationTemplate, sanitizeDestination, primaryDestination } from './destination.js'
|
|
33
|
-
import {
|
|
33
|
+
import { loadSidecarModule } from './sidecar-modules.js'
|
|
34
34
|
|
|
35
35
|
// What the sidecar actually returned, for a message a reader can act on.
|
|
36
36
|
// `[object Object]` and `` are the two least useful things to print here.
|
|
@@ -270,19 +270,20 @@ export function createOnBeforeRender({
|
|
|
270
270
|
const sidecarTrack = createTrack({ partial: false, meta: true, consumed: true })
|
|
271
271
|
if (existsSync(sidecarPath)) {
|
|
272
272
|
try {
|
|
273
|
-
//
|
|
274
|
-
//
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
const
|
|
280
|
-
|
|
281
|
-
|
|
273
|
+
// Path resolution and stamping live in
|
|
274
|
+
// sidecar-modules.js, shared with the apps surface
|
|
275
|
+
// that invokes a sidecar's action handlers — one
|
|
276
|
+
// implementation, so neither can drift from the
|
|
277
|
+
// digest rule that keeps an edited sidecar from
|
|
278
|
+
// answering out of cache.
|
|
279
|
+
const sidecarModule = await loadSidecarModule(entity.layout, {
|
|
280
|
+
layoutsFolder: runtime.options.layoutsFolder,
|
|
281
|
+
logger,
|
|
282
|
+
});
|
|
282
283
|
// Semicolon above is load-bearing: without it the
|
|
283
284
|
// parenthesised destructuring assignment below reads
|
|
284
285
|
// as a CALL of the preceding expression.
|
|
285
|
-
({ load, plugins = [] } =
|
|
286
|
+
({ load, plugins = [] } = sidecarModule ?? {})
|
|
286
287
|
} catch (err) {
|
|
287
288
|
logger.error('Layout sidecar %s failed to load: %s', sidecarPath.replace(runtime.options.workingFolder + '/', ''), err.message)
|
|
288
289
|
throw err
|
package/lib/sidecar-modules.js
CHANGED
|
@@ -31,6 +31,8 @@
|
|
|
31
31
|
// once rather than left to be discovered.
|
|
32
32
|
|
|
33
33
|
import module from 'node:module'
|
|
34
|
+
import path from 'node:path'
|
|
35
|
+
import { existsSync } from 'node:fs'
|
|
34
36
|
import { pathToFileURL } from 'node:url'
|
|
35
37
|
|
|
36
38
|
// Feature-detected rather than imported by name: `import { registerHooks }`
|
|
@@ -80,3 +82,32 @@ export function setSidecarStamp(next) {
|
|
|
80
82
|
export function sidecarHookInstalled() {
|
|
81
83
|
return installed
|
|
82
84
|
}
|
|
85
|
+
|
|
86
|
+
// Load a layout's sidecar module, or null when it has none.
|
|
87
|
+
//
|
|
88
|
+
// One implementation, two callers: the render (which then tracks what the
|
|
89
|
+
// sidecar reads) and the apps surface (which invokes its action handlers).
|
|
90
|
+
// The stamping is the whole reason this is shared — a second copy would drift
|
|
91
|
+
// from the digest rule above and reintroduce the failure that rule exists for:
|
|
92
|
+
// an edited sidecar that keeps answering from cache.
|
|
93
|
+
export async function loadSidecarModule(layout, { layoutsFolder, logger } = {}) {
|
|
94
|
+
if (!layout?.name || !layoutsFolder) return null
|
|
95
|
+
const sidecarPath = path.join(layoutsFolder, `${layout.name}.js`)
|
|
96
|
+
// Existence first, so a real ERR_MODULE_NOT_FOUND from INSIDE the sidecar
|
|
97
|
+
// — it imports a package that is not installed — is not swallowed as
|
|
98
|
+
// "this layout has no sidecar".
|
|
99
|
+
if (!existsSync(sidecarPath)) return null
|
|
100
|
+
|
|
101
|
+
const shared = layout.inputs?.shared || ''
|
|
102
|
+
setSidecarStamp(shared)
|
|
103
|
+
// With the resolve hook installed the digest reaches the sidecar's own
|
|
104
|
+
// imports too; without it only the entry reloads, so fall back to a
|
|
105
|
+
// per-call stamp to keep at least that much.
|
|
106
|
+
const entryStamp = sidecarHookInstalled() ? shared : Date.now()
|
|
107
|
+
try {
|
|
108
|
+
return await import(`${pathToFileURL(sidecarPath).href}?stamp=${entryStamp}`)
|
|
109
|
+
} catch (err) {
|
|
110
|
+
logger?.error('Layout sidecar %s failed to load: %s', sidecarPath, err.message)
|
|
111
|
+
throw err
|
|
112
|
+
}
|
|
113
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mikser-io-layouts",
|
|
3
|
-
"version": "11.0
|
|
3
|
+
"version": "11.2.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": "^11.0
|
|
21
|
+
"mikser-io": "^11.1.0",
|
|
22
22
|
"zod": "^4.0.0"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"lodash": "^4.17.0"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
|
-
"mikser-io": "^11.0
|
|
30
|
+
"mikser-io": "^11.1.0",
|
|
31
31
|
"mikser-io-render-liquid": "^11.0.1",
|
|
32
32
|
"zod": "^4.0.0"
|
|
33
33
|
}
|
|
@@ -23,7 +23,7 @@ import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises'
|
|
|
23
23
|
import { tmpdir } from 'node:os'
|
|
24
24
|
import path from 'node:path'
|
|
25
25
|
|
|
26
|
-
import { installSidecarModuleHook, setSidecarStamp, sidecarHookInstalled } from '../lib/sidecar-modules.js'
|
|
26
|
+
import { installSidecarModuleHook, setSidecarStamp, sidecarHookInstalled, loadSidecarModule } from '../lib/sidecar-modules.js'
|
|
27
27
|
|
|
28
28
|
describe('sidecar module identity', () => {
|
|
29
29
|
let root, layoutsFolder
|
|
@@ -98,3 +98,51 @@ describe('sidecar module identity', () => {
|
|
|
98
98
|
"export const icon = 'filter.svg'\n")
|
|
99
99
|
})
|
|
100
100
|
})
|
|
101
|
+
|
|
102
|
+
// loadSidecarModule — the shared loader. The render path and the apps surface
|
|
103
|
+
// both go through it; a second copy of the path rule or the stamping is a
|
|
104
|
+
// second chance for an edited sidecar to keep answering from cache, which is
|
|
105
|
+
// the failure the whole file above exists for.
|
|
106
|
+
describe('loadSidecarModule', () => {
|
|
107
|
+
let root, layoutsFolder
|
|
108
|
+
|
|
109
|
+
before(async () => {
|
|
110
|
+
root = await mkdtemp(path.join(tmpdir(), 'sidecar-loader-'))
|
|
111
|
+
layoutsFolder = path.join(root, 'layouts')
|
|
112
|
+
await mkdir(layoutsFolder, { recursive: true })
|
|
113
|
+
await writeFile(path.join(layoutsFolder, 'order.js'),
|
|
114
|
+
'export const load = () => ({ page: 1 })\n'
|
|
115
|
+
+ 'export const call = async ({ action }) => ({ handled: action })\n'
|
|
116
|
+
+ 'export const read = async ({ uri }) => ({ uri })\n'
|
|
117
|
+
+ 'export const list = async () => [{ uri: "app://order/rows" }]\n')
|
|
118
|
+
await writeFile(path.join(layoutsFolder, 'plain.js'), 'export const load = () => ({})\n')
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
after(async () => { await rm(root, { recursive: true, force: true }) })
|
|
122
|
+
|
|
123
|
+
it('returns the module with every named export a sidecar declares', async () => {
|
|
124
|
+
const sidecar = await loadSidecarModule({ name: 'order', inputs: { shared: 'd1' } }, { layoutsFolder })
|
|
125
|
+
assert.equal(typeof sidecar.load, 'function', 'the render reads load')
|
|
126
|
+
for (const name of ['call', 'read', 'list']) {
|
|
127
|
+
assert.equal(typeof sidecar[name], 'function', `the apps surface reads ${name}`)
|
|
128
|
+
}
|
|
129
|
+
assert.deepEqual(await sidecar.call({ action: 'approve' }), { handled: 'approve' })
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('returns null for a layout with no sidecar, rather than throwing', async () => {
|
|
133
|
+
assert.equal(await loadSidecarModule({ name: 'absent', inputs: {} }, { layoutsFolder }), null)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('returns null when it is asked without a layout or a folder', async () => {
|
|
137
|
+
assert.equal(await loadSidecarModule(null, { layoutsFolder }), null)
|
|
138
|
+
assert.equal(await loadSidecarModule({ name: 'order' }, {}), null)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('leaves a sidecar without the apps exports alone', async () => {
|
|
142
|
+
// Every existing sidecar is this shape. Reading `call` off one must be
|
|
143
|
+
// undefined, not an error — the apps surface decides what to do.
|
|
144
|
+
const sidecar = await loadSidecarModule({ name: 'plain', inputs: { shared: 'd1' } }, { layoutsFolder })
|
|
145
|
+
assert.equal(typeof sidecar.load, 'function')
|
|
146
|
+
assert.equal(sidecar.call, undefined)
|
|
147
|
+
})
|
|
148
|
+
})
|