mikser-io-layouts 1.0.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.
@@ -0,0 +1,212 @@
1
+ // Layout assignment (the matching half of the plugin).
2
+ //
3
+ // onProcessed fires once per cycle. For every CREATE/UPDATE entity in
4
+ // the journal that's NOT a layout itself, we figure out which layouts
5
+ // should render it and stash the result in `entity.layouts` (canonical
6
+ // multi-match) + `entity.layout` (back-compat alias).
7
+ //
8
+ // Three resolution paths, in priority:
9
+ // 1. Author declared (`meta.layout` string OR `meta.layouts` array
10
+ // — dual key, mutually exclusive).
11
+ // 2. options.match pattern hits → multi-match, every matching
12
+ // pattern contributes a layout.
13
+ // 3. options.autoLayouts → the peel-ladder fallback (first-wins,
14
+ // single match by design).
15
+ //
16
+ // onProcessed runs BEFORE onBeforeRender; the layout snapshot stored
17
+ // on entity.layouts here may go stale by render time (frontmatter
18
+ // mutations during onPersist or sidecar load). The assembly phase
19
+ // (lib/assembly.js) re-fetches each layout via findEntity to recover.
20
+
21
+ import path from 'node:path'
22
+
23
+ export function createOnProcessed({
24
+ runtime, useLogger, useJournal,
25
+ findEntity, matchEntity, collection,
26
+ OPERATION,
27
+ options,
28
+ }) {
29
+ return async function onProcessedHandler(signal) {
30
+ const logger = useLogger()
31
+ const { layouts } = runtime.state.layouts
32
+
33
+ // Resolve a layout name to the catalog entity (post-front-matter
34
+ // strip) rather than the state-map entry (raw file bytes). The
35
+ // state map is just an index — it's populated at sync time with
36
+ // whatever readLayoutContent returned, before front-matter has
37
+ // had a chance to lift YAML attributes into meta and strip them
38
+ // from content. Attaching the raw state-map entry as
39
+ // entity.layout makes the renderer emit YAML verbatim into the
40
+ // rendered output (visible bug surfaced via MCP-UI previews).
41
+ //
42
+ // Catalog is the single source of truth for content; state map
43
+ // is just the name → id lookup. Falls back to the state entry
44
+ // only when the catalog hasn't caught up (sync races, synthetic
45
+ // test setups where front-matter hasn't been wired).
46
+ async function resolveLayout(name) {
47
+ const stateEntry = layouts[name]
48
+ if (!stateEntry) return undefined
49
+ return (await findEntity({ id: stateEntry.id })) || stateEntry
50
+ }
51
+
52
+ // Resolve the set of layouts that should render `entity` this
53
+ // cycle. Returns an array — empty if none matched. Rules:
54
+ // 1. meta.layout (string) and meta.layouts (array) can't both
55
+ // be set; if both → throw, caught and logged below.
56
+ // 2. Author-declared selection (meta.layout / meta.layouts)
57
+ // wins: every named layout is resolved; unknown names get
58
+ // a warning but don't break the rest.
59
+ // 3. No author selection → multi-match across options.match
60
+ // patterns. Every matching pattern contributes a layout.
61
+ // 4. No pattern matched AND options.autoLayouts → fall back
62
+ // to the existing peel ladder; first found wins (the
63
+ // ladder is a search-by-priority by design, not a list of
64
+ // independent matches).
65
+ async function resolveLayoutsForEntity(entity) {
66
+ if (entity.meta?.layout && entity.meta?.layouts) {
67
+ throw new Error(
68
+ `Entity ${entity.id}: both 'meta.layout' and 'meta.layouts' are set — pick one.`
69
+ )
70
+ }
71
+ const declared = Array.isArray(entity.meta?.layouts)
72
+ ? entity.meta.layouts
73
+ : entity.meta?.layout
74
+ ? [entity.meta.layout]
75
+ : null
76
+
77
+ if (declared) {
78
+ const resolved = []
79
+ for (const name of declared) {
80
+ const layout = await resolveLayout(name)
81
+ if (layout) {
82
+ if (!resolved.find(l => l.name === layout.name)) resolved.push(layout)
83
+ } else {
84
+ logger.warn('Layout not found for %s: %s', entity.collection, entity.id, name)
85
+ }
86
+ }
87
+ return resolved
88
+ }
89
+
90
+ // Multi-match over config patterns.
91
+ const matched = []
92
+ for (const pattern in options.match || []) {
93
+ if (matchEntity(entity, pattern)) {
94
+ const name = options.match[pattern]
95
+ const layout = await resolveLayout(name)
96
+ if (layout && !matched.find(l => l.name === layout.name)) {
97
+ matched.push(layout)
98
+ }
99
+ }
100
+ }
101
+
102
+ // Auto-layout: only as a fallback when no pattern matched.
103
+ // The peel ladder is intentionally first-wins — it's a
104
+ // most-specific-name search, not a multi-match.
105
+ if (matched.length === 0 && options.autoLayouts && entity.id) {
106
+ const lookupBase = entity.id.replace(`/${entity.collection}/`, '')
107
+ const dir = path.dirname(lookupBase)
108
+ const base = path.basename(lookupBase)
109
+ const chunks = base.split('.')
110
+ const candidates = []
111
+ for (let i = chunks.length; i > 0; i--) {
112
+ const head = chunks.slice(0, i).join('.')
113
+ candidates.push(dir && dir !== '.' ? path.join(dir, head) : head)
114
+ }
115
+ const autoLayout = candidates.find(name => layouts[name])
116
+ if (autoLayout) {
117
+ const layout = await resolveLayout(autoLayout)
118
+ if (layout) matched.push(layout)
119
+ logger.debug('Auto layout matched %s -> %s for %s', entity.name, autoLayout, entity.id)
120
+ } else {
121
+ logger.trace('Auto layout no match for %s tried: %s', entity.id, candidates.join(', '))
122
+ }
123
+ }
124
+
125
+ return matched
126
+ }
127
+
128
+ for await (let { entity, operation } of useJournal('Layouts processing', [OPERATION.CREATE, OPERATION.UPDATE, OPERATION.DELETE], signal)) {
129
+ if (entity.collection == collection) continue
130
+ switch (operation) {
131
+ case OPERATION.CREATE:
132
+ case OPERATION.UPDATE:
133
+ try {
134
+ entity.layouts = await resolveLayoutsForEntity(entity)
135
+ } catch (err) {
136
+ logger.error('Layout resolution for %s: %s', entity.id, err.message)
137
+ entity.layouts = []
138
+ }
139
+ // Back-compat alias. Most existing downstream code
140
+ // reads `entity.layout`; keep it pointing at the
141
+ // first matched layout so it stays useful. The
142
+ // onBeforeRender task-build phase iterates
143
+ // entity.layouts and reassigns entity.layout per
144
+ // task to the layout being processed.
145
+ entity.layout = entity.layouts[0]
146
+
147
+ // Mirror the matched name(s) into meta so the
148
+ // catalog's `meta_layout` index can find this
149
+ // entity via "anything with a layout" queries.
150
+ // Author-declared cases already have meta.layout
151
+ // (or meta.layouts) set; this covers the
152
+ // pattern-match / auto-layout paths.
153
+ if (entity.layouts.length && !entity.meta?.layout && !entity.meta?.layouts) {
154
+ entity.meta = entity.meta || {}
155
+ if (entity.layouts.length === 1) {
156
+ entity.meta.layout = entity.layouts[0].name
157
+ } else {
158
+ entity.meta.layouts = entity.layouts.map(l => l.name)
159
+ }
160
+ }
161
+
162
+ // A render-requested entity (carries useRenderer's
163
+ // correlationId) that resolved to no layout will
164
+ // silently produce nothing — the caller just gets
165
+ // api.js's "did not complete". Surface the real
166
+ // reason here, where we authoritatively know no
167
+ // layout matched. Gated on correlationId so the
168
+ // thousands of normal layout-less content files
169
+ // stay quiet.
170
+ if (entity.layouts.length === 0 && entity.options?.correlationId) {
171
+ logger.warn(
172
+ 'Render requested for %s but no layout matched — set meta.layout / meta.layouts, add a layouts.match rule, or name it to match a layout (auto-layout). Entities without a layout are not rendered.',
173
+ entity.id,
174
+ )
175
+ }
176
+
177
+ // meta.postprocessor (string) / meta.postprocessors
178
+ // (array) override is read at task-build time (in
179
+ // onBeforeRender) because the layout entity is
180
+ // re-fetched from the catalog there — any mutation
181
+ // we did to it here would be reverted by the
182
+ // refresh. We just sanity-check the dual key here
183
+ // so authors see the error early.
184
+ if (entity.meta?.postprocessor && entity.meta?.postprocessors) {
185
+ logger.error(
186
+ 'Entity %s: both meta.postprocessor and meta.postprocessors set — pick one. The chain will fall back to the singular.',
187
+ entity.id,
188
+ )
189
+ }
190
+
191
+ if (entity.layouts.length) {
192
+ logger.debug('Layouts matched for %s (%d): %s',
193
+ entity.id, entity.layouts.length,
194
+ entity.layouts.map(l => l.name).join(', '))
195
+ } else if (entity.meta?.href) {
196
+ logger.trace('Layout missing for %s: %s', entity.collection, entity.id)
197
+ }
198
+ break
199
+ case OPERATION.DELETE:
200
+ // Catalog DELETE is the sole source of truth for "this
201
+ // entity is no longer in the sitemap" — sitemap lives
202
+ // in the catalog (queried via meta_href), so the
203
+ // DELETE alone removes it.
204
+ break
205
+ }
206
+ // Any layout/meta mutation above is auto-persisted by the
207
+ // useJournal generator when this for-body completes (the
208
+ // generator JSON.stringifies the entity post-yield and
209
+ // UPDATEs the row if it diverged from the original).
210
+ }
211
+ }
212
+ }
package/lib/mcp.js ADDED
@@ -0,0 +1,54 @@
1
+ // MCP tool surface for layouts. Registered against the mcp substrate
2
+ // at `runtime.options.mcp` when present — same gating pattern other
3
+ // siblings use (vector, schemas, preview). When mcp isn't loaded, the
4
+ // registration is a no-op.
5
+ //
6
+ // One tool: `mikser_layouts_inspect`. Surfaces the inspect() primitive
7
+ // already exposed at `runtime.options.layouts.inspect`. The schema and
8
+ // author-facing description live here because they're MCP-flavor; the
9
+ // underlying data shape is owned by lib/inspect.js.
10
+
11
+ import { z } from 'zod'
12
+
13
+ export function registerMcpTools({ runtime, useLogger }) {
14
+ const mcp = runtime.options.mcp
15
+ if (!mcp) return // mcp plugin not loaded — nothing to register
16
+ if (!runtime.options.layouts?.inspect) return // shouldn't happen if we run after our own inspect setup, but defensive
17
+
18
+ const logger = useLogger()
19
+
20
+ mcp.simpleTool(
21
+ 'mikser_layouts_inspect',
22
+ 'Inspect a layout: template source, references (runtime: precise refClosure from recent renders via the manifest; static: variables / partials / iterations / helpers parsed from source by the renderer plugin), and sample entities currently targeting it. Use this to answer "what does this layout need from an entity" before drafting a preview render — saves a guess-and-render-empty cycle.',
23
+ {
24
+ id: z.string().describe('Layout id, e.g. "/layouts/reports/royalty.html-pdf.liquid". Use mikser_query_entities with { collection: "layouts" } to discover ids.'),
25
+ samples: z.number().int().min(0).max(10).optional().describe('How many existing entities currently using this layout to include as data-shape examples. Default 3. Only entities with explicit meta.layout match; auto-matched layouts are not surfaced.'),
26
+ },
27
+ async ({ id, samples = 3 }) => {
28
+ try {
29
+ const result = await runtime.options.layouts.inspect(id, { samples })
30
+ return {
31
+ content: [{
32
+ type: 'text',
33
+ text: JSON.stringify({
34
+ ...result,
35
+ notes: [
36
+ 'references.runtime is the precise answer from manifest snapshots — what the renderer actually touched in recent renders. Empty if the layout has never rendered.',
37
+ 'references.static is the renderer-plugin AST walk of the template source — what the source mentions, including unexercised conditional branches. Empty when the renderer does not expose parseReferences (markdown / metatext / custom).',
38
+ 'samples only includes entities with explicit meta.layout. Auto-matched layouts are not listed; use mikser_query_entities with a filename-pattern filter for those.',
39
+ ],
40
+ }, null, 2),
41
+ }],
42
+ }
43
+ } catch (err) {
44
+ logger.error('MCP mikser_layouts_inspect error: %s', err.message)
45
+ return {
46
+ isError: true,
47
+ content: [{ type: 'text', text: err.message }],
48
+ }
49
+ }
50
+ },
51
+ )
52
+
53
+ logger.debug('MCP tool registered: mikser_layouts_inspect (layouts plugin)')
54
+ }
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "mikser-io-layouts",
3
+ "version": "1.0.0",
4
+ "description": "SSG-flavor task-production policy for mikser-io. Multi-match layout assignment, per-layout destinations, postprocess chain attachment, layout inspection.",
5
+ "main": "index.js",
6
+ "type": "module",
7
+ "scripts": {
8
+ "test": "node --no-warnings --test --test-reporter=spec 'test/**/*.test.js'"
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/almero-digital-marketing/mikser-io-layouts.git"
13
+ },
14
+ "author": "",
15
+ "license": "MIT",
16
+ "bugs": {
17
+ "url": "https://github.com/almero-digital-marketing/mikser-io-layouts/issues"
18
+ },
19
+ "homepage": "https://github.com/almero-digital-marketing/mikser-io-layouts#readme",
20
+ "peerDependencies": {
21
+ "mikser-io": "^9.0.0"
22
+ },
23
+ "dependencies": {
24
+ "globby": "^14.0.0",
25
+ "handlebars": "^4.7.0",
26
+ "lodash": "^4.17.0",
27
+ "zod": "^4.4.3"
28
+ }
29
+ }
@@ -0,0 +1,243 @@
1
+ // A minimal in-memory harness for testing core plugins.
2
+ //
3
+ // Real plugins call lifecycle registrations (onLoaded, onProcess, onSync, ...)
4
+ // during their factory invocation; the engine later drives those callbacks. In
5
+ // tests we substitute every API surface with a recorder, then drive the
6
+ // callbacks manually with controlled inputs.
7
+
8
+ import _ from 'lodash'
9
+ import {
10
+ runtime as realRuntime,
11
+ matchEntity, normalize, changeExtension, getFormatInfo, checksum, AbortError,
12
+ } from 'mikser-io'
13
+
14
+ const OPERATION = {
15
+ CREATE: 'create',
16
+ UPDATE: 'update',
17
+ DELETE: 'delete',
18
+ RENDER: 'render',
19
+ POSTPROCESS: 'postprocess',
20
+ }
21
+ const ACTION = {
22
+ CREATE: 'create',
23
+ UPDATE: 'update',
24
+ DELETE: 'delete',
25
+ TRIGGER: 'trigger',
26
+ }
27
+ const TASKS = { INLINE: 'inline', SERIAL: 'serial', WORKER: 'worker' }
28
+
29
+ export function createHarness({
30
+ config = {},
31
+ options = {},
32
+ state = {},
33
+ entities = [],
34
+ journal = [],
35
+ } = {}) {
36
+ const hookNames = [
37
+ 'load', 'loaded', 'import', 'imported',
38
+ 'process', 'processed', 'persist', 'persisted',
39
+ 'beforeRender', 'render', 'afterRender',
40
+ 'beforePostprocess', 'postprocess', 'afterPostprocess',
41
+ 'finalize', 'finalized', 'cancel', 'cancelled',
42
+ 'complete', 'validate',
43
+ ]
44
+ const hooks = Object.fromEntries(hookNames.map(n => [n, []]))
45
+ const sync = new Map()
46
+ const watchers = []
47
+ const renderTasks = []
48
+ const postprocessTasks = []
49
+ const progress = []
50
+ const logs = []
51
+ const mklog = (level) => (...args) => logs.push({ level, args })
52
+ const logger = {
53
+ info: mklog('info'),
54
+ warn: mklog('warn'),
55
+ error: mklog('error'),
56
+ debug: mklog('debug'),
57
+ trace: mklog('trace'),
58
+ notice: mklog('notice'),
59
+ }
60
+
61
+ let nextId = 1
62
+ const journalEntries = journal.map(e => ({ id: nextId++, output: null, context: {}, options: {}, ...e }))
63
+
64
+ function addEntry({ entity, operation, context = {}, options = {} }) {
65
+ journalEntries.push({ id: nextId++, entity, operation, context, options, output: null })
66
+ }
67
+
68
+ // Stub `runtime.catalog` so catalog.js's module-level findEntity /
69
+ // findEntities / findById / queryEntities / readEntity / expand
70
+ // walker read from the harness's in-memory entity set. Real
71
+ // catalog.js's onInitialized sets up an identical shape — a
72
+ // Map<id, entity> with a no-op save() — but persists to disk; the
73
+ // stub skips persistence. Pushes/mutations to the input `entities`
74
+ // array land in the same Map via the constructor below.
75
+ //
76
+ // Set on the runtime SINGLETON imported from src/runtime.js — that's
77
+ // what catalog.js sees. Setting it on the harness-local `runtime`
78
+ // object below wouldn't help because plugin code imports the
79
+ // singleton directly. Each createHarness() call overwrites the
80
+ // singleton's `.catalog`, which is fine because tests don't run in
81
+ // parallel within a file.
82
+ const catalogStub = {
83
+ byId: new Map(entities.filter(e => e?.id).map(e => [e.id, e])),
84
+ version: 'test',
85
+ cacheInvalidated: false,
86
+ // No-op save — the harness never wants disk side-effects.
87
+ // catalog.js's onFinalized hook calls runtime.catalog.save(),
88
+ // so any test that drives the full lifecycle would blow up
89
+ // without this.
90
+ save: async () => {},
91
+ }
92
+ realRuntime.catalog = catalogStub
93
+
94
+ const runtime = {
95
+ options: { workingFolder: '/tmp/test-mikser', plugins: [], ...options },
96
+ config,
97
+ state,
98
+ hooks,
99
+ engine: { logger },
100
+ catalog: catalogStub,
101
+ // Real lifecycle.js attaches these to the runtime via side-effect
102
+ // on import. The harness doesn't load that module, so reproduce
103
+ // them here against the in-memory journal.
104
+ create: async (entity) => addEntry({ operation: OPERATION.CREATE, entity }),
105
+ update: async (entity) => addEntry({ operation: OPERATION.UPDATE, entity }),
106
+ delete: async ({ id, collection, type }) =>
107
+ addEntry({ operation: OPERATION.DELETE, entity: { id, collection, type } }),
108
+ }
109
+ function updateEntry({ id, entity, output }) {
110
+ const entry = journalEntries.find(e => e.id === id)
111
+ if (!entry) return
112
+ if (entity !== undefined) entry.entity = entity
113
+ if (output !== undefined) entry.output = output
114
+ }
115
+ async function* useJournal(name, operations, signal) {
116
+ progress.push({ name, total: journalEntries.length })
117
+ for (const entry of journalEntries) {
118
+ if (operations?.length && !operations.includes(entry.operation)) continue
119
+ if (signal?.aborted) return
120
+ yield entry
121
+ }
122
+ }
123
+
124
+ const core = {
125
+ runtime,
126
+ useLogger: () => logger,
127
+
128
+ // Lifecycle registrations
129
+ onLoad: (cb) => hooks.load.push(cb),
130
+ onLoaded: (cb) => hooks.loaded.push(cb),
131
+ onImport: (cb) => hooks.import.push(cb),
132
+ onImported: (cb) => hooks.imported.push(cb),
133
+ onProcess: (cb) => hooks.process.push(cb),
134
+ onProcessed: (cb) => hooks.processed.push(cb),
135
+ onPersist: (cb) => hooks.persist.push(cb),
136
+ onPersisted: (cb) => hooks.persisted.push(cb),
137
+ onBeforeRender: (cb) => hooks.beforeRender.push(cb),
138
+ onRender: (cb) => hooks.render.push(cb),
139
+ onAfterRender: (cb) => hooks.afterRender.push(cb),
140
+ onBeforePostprocess: (cb) => hooks.beforePostprocess.push(cb),
141
+ onPostprocess: (cb) => hooks.postprocess.push(cb),
142
+ onAfterPostprocess: (cb) => hooks.afterPostprocess.push(cb),
143
+ onFinalize: (cb) => hooks.finalize.push(cb),
144
+ onFinalized: (cb) => hooks.finalized.push(cb),
145
+ onCancel: (cb) => hooks.cancel.push(cb),
146
+ onCancelled: (cb) => hooks.cancelled.push(cb),
147
+ onComplete: (cb) => hooks.complete.push(cb),
148
+ onSync: (name, cb) => sync.set(name, cb),
149
+ onValidate: (operations, cb) => hooks.validate.push({ operations, cb }),
150
+
151
+ // Entity helpers — write directly to the journal
152
+ createEntity: async (entity) => addEntry({ operation: OPERATION.CREATE, entity }),
153
+ updateEntity: async (entity) => addEntry({ operation: OPERATION.UPDATE, entity }),
154
+ deleteEntity: async ({ id, collection, type }) =>
155
+ addEntry({ operation: OPERATION.DELETE, entity: { id, collection, type } }),
156
+ addEntry,
157
+ addEntries: async (entries) => entries.forEach(addEntry),
158
+ updateEntry,
159
+ useJournal,
160
+ findEntity: async (query) => {
161
+ if (!query) return entities[0]
162
+ if (typeof query === 'function') return entities.find(query)
163
+ return entities.find(e => Object.entries(query).every(([k, v]) => e[k] === v))
164
+ },
165
+ // Synchronous PK lookup mirroring catalog.js's findById. Layouts'
166
+ // onBeforeRender hydrates dispatch ids through this — the harness
167
+ // serves from the same in-memory entities array.
168
+ findById: (id) => entities.find(e => e?.id === id) ?? catalogStub.byId.get(id),
169
+ findEntities: async (query) => {
170
+ if (!query) return [...entities]
171
+ if (typeof query === 'function') return entities.filter(query)
172
+ return entities.filter(e => Object.entries(query).every(([k, v]) => e[k] === v))
173
+ },
174
+ iterateEntities: async function* (query) {
175
+ // Stub — yields the same set findEntities would return,
176
+ // one entity at a time. Real impl in catalog.js chunks via
177
+ // sqlite; the harness doesn't need that fidelity because
178
+ // unit-test corpora are tiny.
179
+ const filtered = !query
180
+ ? [...entities]
181
+ : (typeof query === 'function'
182
+ ? entities.filter(query)
183
+ : entities.filter(e => Object.entries(query).every(([k, v]) => e[k] === v)))
184
+ for (const e of filtered) yield e
185
+ },
186
+
187
+ // Rendering & postprocessing — capture for assertions
188
+ renderEntities: async (tasks) => renderTasks.push(...tasks),
189
+ postprocessEntities: async (tasks) => postprocessTasks.push(...tasks),
190
+ renderEntity: async () => { },
191
+ postprocessEntity: async () => { },
192
+
193
+ // Misc
194
+ watch: (name, folder) => watchers.push({ name, folder }),
195
+ schedule: () => { },
196
+ trackProgress: () => { },
197
+ updateProgress: () => { },
198
+ stopProgress: () => { },
199
+
200
+ // Utilities (real implementations)
201
+ matchEntity,
202
+ normalize,
203
+ changeExtension,
204
+ getFormatInfo,
205
+ checksum,
206
+ AbortError,
207
+
208
+ constants: { OPERATION, ACTION, TASKS },
209
+ }
210
+
211
+ async function runHook(name, ...args) {
212
+ const callbacks = hooks[name] || []
213
+ const results = []
214
+ for (const cb of callbacks) {
215
+ results.push(await cb(...args))
216
+ }
217
+ return results
218
+ }
219
+
220
+ async function runSync(name, payload) {
221
+ const handler = sync.get(name)
222
+ if (!handler) return undefined
223
+ return handler(payload)
224
+ }
225
+
226
+ return {
227
+ core,
228
+ runtime,
229
+ logger,
230
+ logs,
231
+ sync,
232
+ watchers,
233
+ renderTasks,
234
+ postprocessTasks,
235
+ progress,
236
+ journal: journalEntries,
237
+ hooks,
238
+ runHook,
239
+ runSync,
240
+ addJournalEntry: addEntry,
241
+ constants: { OPERATION, ACTION, TASKS },
242
+ }
243
+ }