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,399 @@
1
+ // Render-task assembly (the harder half of the plugin).
2
+ //
3
+ // onBeforeRender fires once per cycle, AFTER matching has stamped
4
+ // `entity.layouts` on each entity. Here we iterate the dispatch set,
5
+ // re-fetch each layout from the catalog (front-matter mutations can
6
+ // land between onProcessed and now), build the task envelope, resolve
7
+ // per-task destinations (default derivation or per-layout `destination:`
8
+ // Handlebars template), detect collisions, expand pagination, attach
9
+ // the postprocess chain, and stream tasks into the journal via
10
+ // renderEntities() in batches.
11
+ //
12
+ // Two dispatch sources, picked based on whether we have a refs graph:
13
+ // - --force / no refs: stream layout-bearing entities from SQL,
14
+ // hydrating one at a time. No full-catalog allocation.
15
+ // - incremental: build seed list from the journal, walk
16
+ // inverseClosureOf(seeds), union with opt-outs (meta.cache: 0)
17
+ // and query-affected snapshots. Bounded by closure size, not
18
+ // by catalog size.
19
+
20
+ import path from 'node:path'
21
+ import { existsSync } from 'node:fs'
22
+ import _ from 'lodash'
23
+ import {
24
+ inputHashOf,
25
+ createTrack,
26
+ queryContext,
27
+ useDatabase,
28
+ } from 'mikser-io'
29
+ import { compileDestinationTemplate, sanitizeDestination } from './destination.js'
30
+
31
+ export function createOnBeforeRender({
32
+ runtime, useLogger, useJournal,
33
+ findById, findEntity, findEntities,
34
+ renderEntities,
35
+ changeExtension,
36
+ OPERATION, TASKS,
37
+ options,
38
+ }) {
39
+ return async function onBeforeRenderHandler(signal) {
40
+ const logger = useLogger()
41
+ const tasks = []
42
+
43
+ // Batched journal flush. tasks accumulate across many entities
44
+ // (sidecar load, pagination expansion), so cap the in-memory
45
+ // queue at FLUSH_BATCH and write to the journal in chunks. At
46
+ // 1M --force the prior shape held the full tasks list in heap
47
+ // (~10GB); chunked flushing keeps peak constant regardless of
48
+ // corpus. Each renderEntities call is one journal transaction;
49
+ // multiple calls land sequentially before onRender fires.
50
+ const FLUSH_BATCH = 1000
51
+ async function maybeFlush() {
52
+ if (tasks.length >= FLUSH_BATCH) {
53
+ await renderEntities(tasks)
54
+ tasks.length = 0
55
+ }
56
+ }
57
+
58
+ // Async generator over the entities-to-dispatch. Two shapes
59
+ // depending on path:
60
+ // - --force / no refs: stream layout-bearing ids from SQL
61
+ // (ORDER BY time DESC; one-at-a-time findById hydration;
62
+ // no entity-array materialization regardless of corpus)
63
+ // - incremental: build seeds + closure + opt-outs in JS
64
+ // (bounded by graph reachability; typically tens to
65
+ // hundreds), sort the small set, yield in order.
66
+ async function* dispatchSource() {
67
+ if (runtime.options.force || !runtime.refs?.inverseClosureOf) {
68
+ // --force path. Project ids only — ~50B vs ~7KB per
69
+ // full entity body, ~140× smaller. SQL ORDER BY time
70
+ // DESC handles the sort we'd otherwise do JS-side.
71
+ // findById hydrates each in turn; entities without a
72
+ // resolved .layout (meta.layout was set but no layout
73
+ // file matched) drop here.
74
+ const db = useDatabase()
75
+ const ids = db.prepare(`
76
+ SELECT id FROM mikser_entities
77
+ WHERE meta_layout IS NOT NULL
78
+ ORDER BY time DESC
79
+ `).all().map(r => r.id)
80
+ if (runtime.options.force) {
81
+ logger.debug('Force rebuild — streaming %d candidate entities', ids.length)
82
+ }
83
+ for (const id of ids) {
84
+ if (signal.aborted) return
85
+ const entity = findById(id)
86
+ if (entity?.layout) yield entity
87
+ }
88
+ return
89
+ }
90
+
91
+ // Incremental path. Build seed list from journal mutations,
92
+ // walk refs.inverseClosureOf to get the dispatch ids, then
93
+ // findById each one. Crucially: we do NOT materialize the
94
+ // full layout-bearing slice of the catalog into heap. At
95
+ // 110k entities that allocation was 800MB; this path is
96
+ // bounded by closure size (typically 10s-100s on warm).
97
+ //
98
+ // Hash-aware seeding: drop CREATE/UPDATE entries whose
99
+ // post-processing inputHash matches the last manifest
100
+ // snapshot. Cold-start file discovery emits CREATE for
101
+ // every file even when content didn't change — without
102
+ // this filter, every restart would seed every entity and
103
+ // the closure walk would expand to the whole catalog.
104
+ // DELETE seeds always count.
105
+ const recordedHashes = runtime.manifest?.recordedHashes() ?? new Map()
106
+
107
+ const seenSeeds = new Set()
108
+ const seeds = []
109
+ for await (let { entity, operation } of useJournal(
110
+ 'Layouts dispatch',
111
+ [OPERATION.CREATE, OPERATION.UPDATE, OPERATION.DELETE],
112
+ signal,
113
+ )) {
114
+ if (!entity?.id || seenSeeds.has(entity.id)) continue
115
+ if (operation === OPERATION.DELETE) {
116
+ seenSeeds.add(entity.id)
117
+ seeds.push(entity)
118
+ continue
119
+ }
120
+ const current = await findEntity({ id: entity.id }) ?? entity
121
+ const priorHash = recordedHashes.get(current.id)
122
+ if (priorHash && inputHashOf(current) === priorHash) continue
123
+ seenSeeds.add(current.id)
124
+ seeds.push(current)
125
+ }
126
+
127
+ // Opt-outs: `meta.cache: false` entities render every
128
+ // cycle regardless of refs (escape hatch for external-data
129
+ // sidecars, ECT partials, anything mikser can't precisely
130
+ // track). Indexed query on the `meta_cache` column —
131
+ // typical site has 0-10 of these, no full scan.
132
+ const optOutEntities = await findEntities({ 'meta.cache': 0 })
133
+
134
+ // Query-dep affected snapshots: aggregate layouts that
135
+ // depend on findEntities(...) instead of static $-refs need
136
+ // a second-pass dispatch hint. manifest.queryAffected walks
137
+ // every snapshot whose refClosure contains a `query` entry
138
+ // and sift-matches the recorded filter against the cycle's
139
+ // mutated entities. Bounded by snapshots-with-query (small
140
+ // — index pages, sitemaps, RSS) × seeds.
141
+ const mutatedEntities = new Map(seeds.map(s => [s.id, s]))
142
+ const queryAffected = runtime.manifest?.queryAffected(mutatedEntities) ?? new Set()
143
+
144
+ if (seeds.length === 0 && optOutEntities.length === 0 && queryAffected.size === 0) return
145
+
146
+ const closure = seeds.length ? runtime.refs.inverseClosureOf(seeds) : new Set()
147
+ // Combine closure ids + opt-out ids + query-affected ids
148
+ // into one dispatch set.
149
+ const dispatchIds = new Set(closure)
150
+ for (const e of optOutEntities) dispatchIds.add(e.id)
151
+ for (const id of queryAffected) dispatchIds.add(id)
152
+
153
+ // Hydrate each id via findById. LRU cache absorbs
154
+ // duplicates (refs BFS revisits, partial dispatches
155
+ // hitting the same layout, etc.). Bounded by closure
156
+ // size — not by catalog size. We do materialize this
157
+ // small set because the sort is intrinsically order-
158
+ // sensitive — closure walk doesn't preserve time order.
159
+ const entities = []
160
+ for (const id of dispatchIds) {
161
+ const entity = findById(id)
162
+ if (entity?.layout) entities.push(entity)
163
+ }
164
+ entities.sort((a, b) => b.time - a.time)
165
+ logger.debug('Incremental dispatch: %d seeds + %d opt-outs → %d entities',
166
+ seeds.length, optOutEntities.length, entities.length)
167
+ for (const entity of entities) {
168
+ if (signal.aborted) return
169
+ yield entity
170
+ }
171
+ }
172
+
173
+ for await (const original of dispatchSource()) {
174
+ if (signal.aborted) return
175
+
176
+ delete original.page
177
+ delete original.pages
178
+ delete original.destination
179
+
180
+ // Multi-layouts: one render task per matched layout.
181
+ // entity.layouts is set in onProcessed; back-compat
182
+ // single-layout entities fall back to [entity.layout].
183
+ const layoutsForEntity = original.layouts?.length
184
+ ? original.layouts
185
+ : (original.layout ? [original.layout] : [])
186
+
187
+ // Per-entity destination set, to detect collisions across
188
+ // the layouts that match this same entity. On collision,
189
+ // log a named-names error and skip ALL tasks for this
190
+ // entity (no winner — fail-fast surfaces the design
191
+ // decision back to the author).
192
+ const tasksForEntity = []
193
+ const destinationsForEntity = new Map() // destination → layout.name
194
+ let collisionFound = false
195
+
196
+ for (const staleLayout of layoutsForEntity) {
197
+ if (collisionFound) break
198
+ if (signal.aborted) return
199
+
200
+ // Re-fetch the layout from the catalog. Front-matter
201
+ // mutations to layout entities (e.g. `meta.destination`)
202
+ // landed during onProcess but the snapshot stored on
203
+ // `entity.layouts` at onProcessed time can be stale; the
204
+ // catalog has the latest by onBeforeRender.
205
+ const refreshedLayout = (await findEntity({ id: staleLayout.id })) || staleLayout
206
+
207
+ // Apply entity-level postprocessor override per layout.
208
+ // meta.postprocessors (array) wins over meta.postprocessor
209
+ // (string); fall back to whatever the layout filename
210
+ // encoded.
211
+ const chainOverride = Array.isArray(original.meta?.postprocessors)
212
+ ? original.meta.postprocessors
213
+ : original.meta?.postprocessor
214
+ ? [original.meta.postprocessor]
215
+ : null
216
+ const layout = chainOverride
217
+ ? { ...refreshedLayout, postprocessors: chainOverride, postprocessor: chainOverride[0] }
218
+ : refreshedLayout
219
+
220
+ const entity = _.cloneDeep(original)
221
+ // Per-task: pin entity.layout to the layout being processed
222
+ // so downstream code (pagination, sidecar lookup, renderer
223
+ // dispatch) sees the single-layout shape it expects.
224
+ entity.layout = layout
225
+ entity.destination = '/' + entity.name
226
+ let data
227
+ let load
228
+ let plugins = []
229
+ const sidecarPath = `${path.join(runtime.options.layoutsFolder, entity.layout.name)}.js`
230
+ // Existence-check first so a real ERR_MODULE_NOT_FOUND inside the
231
+ // sidecar (e.g. it imports a missing package) doesn't get swallowed
232
+ // as "sidecar doesn't exist".
233
+ // Sidecar queries flow into the render's refClosure as
234
+ // `kind: 'query'` edges via the same track shape the engine
235
+ // uses. Without this, layouts whose sidecars build their
236
+ // data with findEntities/queryEntities would silently miss
237
+ // invalidations when a newly-added entity should make the
238
+ // listing change. partial slot disabled — sidecars don't
239
+ // load partials themselves.
240
+ const sidecarTrack = createTrack({ partial: false })
241
+ if (existsSync(sidecarPath)) {
242
+ try {
243
+ ({ load, plugins = [] } = await import(`${sidecarPath}?stamp=${Date.now()}`))
244
+ } catch (err) {
245
+ logger.error('Layout sidecar %s failed to load: %s', sidecarPath.replace(runtime.options.workingFolder + '/', ''), err.message)
246
+ throw err
247
+ }
248
+ if (load) {
249
+ try {
250
+ data = await queryContext.run(
251
+ { entityId: entity.id, track: sidecarTrack },
252
+ () => load({ entity, findEntity, findEntities, runtime, signal }),
253
+ )
254
+ } catch (err) {
255
+ logger.error('Layout sidecar %s load() threw: %s', sidecarPath.replace(runtime.options.workingFolder + '/', ''), err.message)
256
+ throw err
257
+ }
258
+ }
259
+ }
260
+
261
+ // Capture a candidate task without queueing yet. Collisions
262
+ // (two layouts producing the same destination for the same
263
+ // entity) are detected after all layouts have been processed,
264
+ // and the whole entity's task set is dropped on the floor —
265
+ // no winner. See the collision check after the per-layout
266
+ // loop closes.
267
+ const queueTask = (taskEntity, taskOptions, taskContext) => {
268
+ if (destinationsForEntity.has(taskEntity.destination)) {
269
+ const firstLayout = destinationsForEntity.get(taskEntity.destination)
270
+ logger.error(
271
+ 'Layout collision for %s:\n - %s → %s\n - %s → %s\nSet a `destination:` override on one of them, or change one\'s format. Skipping this entity for the cycle.',
272
+ original.id,
273
+ firstLayout, taskEntity.destination,
274
+ layout.name, taskEntity.destination,
275
+ )
276
+ collisionFound = true
277
+ return
278
+ }
279
+ destinationsForEntity.set(taskEntity.destination, layout.name)
280
+ tasksForEntity.push({ entity: taskEntity, options: taskOptions, context: taskContext })
281
+ }
282
+
283
+ // Layout-owned destination template (frontmatter
284
+ // `destination:` field). When set, it FULLY overrides the
285
+ // default `entity.name + .format` (+ cleanUrls) derivation.
286
+ // The template gets `{ entity }` as context — including
287
+ // pagination fields (`entity.page`, `entity.pages`) when
288
+ // populated below.
289
+ const destinationTemplate = layout.meta?.destination
290
+ ? compileDestinationTemplate(layout.meta.destination)
291
+ : null
292
+
293
+ if (data?.pages) {
294
+ if (!_.endsWith(entity.name, entity.format)) {
295
+ // Loop bound is `< data.pages` (not `data.pages - 1`).
296
+ // With 4 pages and the old bound, iteration only ran
297
+ // page=0,1,2 and the 4th page was silently dropped —
298
+ // the sitemap claimed "Page X of 4" but the destination
299
+ // for page 4 was never produced.
300
+ for (let page = 0; page < data.pages; page++) {
301
+ const pageEntity = _.cloneDeep(entity)
302
+ pageEntity.pages = data.pages
303
+ if (page) {
304
+ pageEntity.page = page + 1
305
+ pageEntity.id = changeExtension(entity.id, `${pageEntity.page}.${entity.layout.format}`)
306
+ // Remember the source entity id so the render manifest
307
+ // can reclaim paginated outputs when the parent is deleted.
308
+ pageEntity.parent = entity.id
309
+ if (entity.meta) {
310
+ if (entity.meta.href) {
311
+ pageEntity.meta.href = `${entity.meta.href}.${pageEntity.page}`
312
+ } else {
313
+ pageEntity.meta.href = `/${entity.name}.${pageEntity.page}`
314
+ }
315
+ }
316
+ } else {
317
+ pageEntity.page = 1
318
+ }
319
+
320
+ if (destinationTemplate) {
321
+ pageEntity.destination = sanitizeDestination(destinationTemplate({ entity: pageEntity }))
322
+ } else if (page) {
323
+ if (options.cleanUrls && entity.layout.format == 'html') {
324
+ pageEntity.destination = path.join(entity.destination.replace('index', ''), pageEntity.page.toString(), `index.${entity.layout.format}`)
325
+ } else {
326
+ pageEntity.destination += `.${pageEntity.page}.${entity.layout.format}`
327
+ }
328
+ } else {
329
+ if (options.cleanUrls && !_.endsWith(entity.name, 'index') && entity.layout.format == 'html') {
330
+ pageEntity.destination = path.join(entity.destination, `index.${entity.layout.format}`)
331
+ } else {
332
+ pageEntity.destination += `.${entity.layout.format}`
333
+ }
334
+ }
335
+
336
+ queueTask(pageEntity, {
337
+ renderer: entity.layout.template,
338
+ postprocessor: entity.layout.postprocessor,
339
+ postprocessors: entity.layout.postprocessors ?? (entity.layout.postprocessor ? [entity.layout.postprocessor] : []),
340
+ tasks: entity.meta?.task || TASKS.INLINE,
341
+ }, { data, plugins, sidecarQueries: sidecarTrack.queries })
342
+ }
343
+ }
344
+ } else {
345
+ if (destinationTemplate) {
346
+ entity.destination = sanitizeDestination(destinationTemplate({ entity }))
347
+ } else if (!_.endsWith(entity.name, entity.format)) {
348
+ // cleanUrls turns `foo` into `foo/index.html` so the
349
+ // served URL is `/foo/`. Skip the transform when the
350
+ // layout declares a postprocessor — the HTML render
351
+ // is a throwaway stepping stone (a PDF stepping
352
+ // stone has no served URL), and leaving it flat
353
+ // means engine.onPostprocess's plain extension swap
354
+ // produces `/foo.<ext>` naturally. Postprocess is
355
+ // decoupled from cleanUrls by where the decision
356
+ // gets made — not by the engine knowing about it.
357
+ if (options.cleanUrls
358
+ && !_.endsWith(entity.name, 'index')
359
+ && entity.layout.format == 'html'
360
+ && !entity.layout.postprocessor
361
+ ) {
362
+ entity.destination = path.join(entity.destination, `index.${entity.layout.format}`)
363
+ } else {
364
+ entity.destination += `.${entity.layout.format}`
365
+ }
366
+ }
367
+ if (entity.destination) {
368
+ queueTask(entity, {
369
+ renderer: entity.layout.template,
370
+ postprocessor: entity.layout.postprocessor,
371
+ postprocessors: entity.layout.postprocessors ?? (entity.layout.postprocessor ? [entity.layout.postprocessor] : []),
372
+ tasks: entity.meta?.task || TASKS.INLINE,
373
+ },
374
+ // sidecarQueries threads the sidecar load()'s
375
+ // findEntities calls into manifest.collectEdges
376
+ // as `{kind: 'query', filter}` refClosure entries.
377
+ // Without it, aggregate layouts that don't
378
+ // paginate (sitemap.xml, index pages, RSS feeds)
379
+ // lose query-dep tracking and never invalidate
380
+ // when matching entities are added/modified/
381
+ // deleted. The paginated branch above already
382
+ // does this.
383
+ { data, plugins, sidecarQueries: sidecarTrack.queries })
384
+ }
385
+ }
386
+ } // end per-layout for-loop
387
+
388
+ // Commit this entity's tasks (only if no collision was
389
+ // hit). A collision drops EVERYTHING for the entity — no
390
+ // winner, no half-built output.
391
+ if (!collisionFound) {
392
+ tasks.push(...tasksForEntity)
393
+ }
394
+ await maybeFlush()
395
+ }
396
+ // Final flush — drain anything below the FLUSH_BATCH watermark.
397
+ if (tasks.length) await renderEntities(tasks)
398
+ }
399
+ }
@@ -0,0 +1,33 @@
1
+ // Per-layout destination resolution helpers.
2
+ //
3
+ // `compileDestinationTemplate` caches Handlebars-compiled templates so
4
+ // multiple layouts sharing the same `destination:` string share the
5
+ // compiled function. `sanitizeDestination` rejects path traversal —
6
+ // authors can write whatever they like in frontmatter, but `..`
7
+ // segments don't escape the output folder.
8
+
9
+ import path from 'node:path'
10
+ import handlebars from 'handlebars'
11
+
12
+ const destinationTemplateCache = new Map()
13
+
14
+ export function compileDestinationTemplate(template) {
15
+ if (!destinationTemplateCache.has(template)) {
16
+ destinationTemplateCache.set(template, handlebars.compile(template))
17
+ }
18
+ return destinationTemplateCache.get(template)
19
+ }
20
+
21
+ // Path-traversal sanitization for destinations resolved from
22
+ // frontmatter templates. Rejects anything that would escape the output
23
+ // folder via `..` segments. Matches the forms-plugin sanitizer.
24
+ export function sanitizeDestination(p) {
25
+ if (p == null) return null
26
+ const s = String(p).replace(/\\/g, '/')
27
+ const leading = s.startsWith('/') ? '/' : ''
28
+ const normalized = path.posix.normalize(s.replace(/^\/+/, ''))
29
+ if (normalized === '..' || normalized.startsWith('../')) {
30
+ throw new Error(`layouts: rejected path-traversal in destination: ${p}`)
31
+ }
32
+ return leading + normalized
33
+ }
package/lib/inspect.js ADDED
@@ -0,0 +1,150 @@
1
+ // `inspect()` domain primitive — exposed at `runtime.options.layouts.inspect`
2
+ // and wrapped by `mikser-io-mcp`'s `mikser_layouts_inspect` tool.
3
+ //
4
+ // Returns a layout's metadata + template source + references it depends
5
+ // on + sample entities that target it. References come from two sources:
6
+ //
7
+ // - `runtime`: the manifest's recorded refClosure from recent renders
8
+ // of sample entities. PRECISE — captures what the renderer actually
9
+ // touched, including findEntities/queryEntities query edges. Null
10
+ // when the layout has never rendered.
11
+ //
12
+ // - `static`: dispatched to the renderer plugin via its optional
13
+ // `parseReferences(source)` method. Used for "what could this template
14
+ // reference" — covers conditional branches not exercised by past
15
+ // renders, and brand-new layouts that have no runtime data yet.
16
+ // `{ available: false, reason }` when the renderer doesn't expose
17
+ // a parser (markdown / metatext / custom renderers).
18
+ //
19
+ // Layouts is engine-agnostic — it dispatches by `entity.template` exactly
20
+ // like the render workers do. Template-syntax knowledge stays in the
21
+ // renderer plugin where it belongs.
22
+
23
+ import { readFile } from 'node:fs/promises'
24
+
25
+ export function createInspect({ runtime, findEntity, findEntities, useDatabase, collection }) {
26
+ return async function inspect(id, { samples = 3 } = {}) {
27
+ const layout = await findEntity({ id })
28
+ if (!layout || layout.collection !== collection) {
29
+ const err = new Error(`Layout not found: ${id}`)
30
+ err.code = 'LAYOUT_NOT_FOUND'
31
+ throw err
32
+ }
33
+
34
+ let templateSource = ''
35
+ try {
36
+ templateSource = await readFile(layout.uri, 'utf8')
37
+ } catch (err) {
38
+ const wrapped = new Error(`Layout entity exists but template file unreadable (${layout.uri}): ${err.message}`)
39
+ wrapped.code = 'LAYOUT_TEMPLATE_UNREADABLE'
40
+ throw wrapped
41
+ }
42
+
43
+ // Static refs: dispatch to the renderer's own parser. The
44
+ // renderer is registered under `runtime.renderers` keyed by the
45
+ // template name (the same dispatch path render.js uses).
46
+ const renderer = runtime.renderers?.get(layout.template)
47
+ const staticRefs = typeof renderer?.parseReferences === 'function'
48
+ ? safeParseReferences(renderer, templateSource, layout.template)
49
+ : { available: false, reason: renderer
50
+ ? `Renderer "${layout.template}" does not expose parseReferences()`
51
+ : `No renderer registered for template "${layout.template}"`
52
+ }
53
+
54
+ // Sample entities + their recent manifest snapshots. The
55
+ // refClosure on each snapshot is the *runtime* answer to
56
+ // "what does rendering this layout actually depend on."
57
+ const matching = samples > 0
58
+ ? await findEntities({ 'meta.layout': layout.name })
59
+ : []
60
+ const sampleEntities = matching
61
+ .slice(0, samples)
62
+ .map(e => ({ id: e.id, name: e.name, meta: e.meta }))
63
+
64
+ const runtimeRefs = await collectRuntimeRefs({
65
+ sampleEntities, useDatabase, layoutName: layout.name,
66
+ })
67
+
68
+ return {
69
+ layout: {
70
+ id: layout.id,
71
+ name: layout.name,
72
+ uri: layout.uri,
73
+ format: layout.format,
74
+ template: layout.template,
75
+ postprocessor: layout.postprocessor ?? null,
76
+ },
77
+ templateSource,
78
+ references: {
79
+ runtime: runtimeRefs,
80
+ static: staticRefs,
81
+ },
82
+ samples: sampleEntities,
83
+ }
84
+ }
85
+ }
86
+
87
+ // Each sample → most-recent mikser_snapshots row by renderedAt. Returns
88
+ // `null` when no sample has ever rendered (layout exists but isn't used).
89
+ // The refClosure column is JSON; parse it once per row so the caller sees
90
+ // a structured array.
91
+ async function collectRuntimeRefs({ sampleEntities, useDatabase, layoutName }) {
92
+ if (!sampleEntities.length) {
93
+ return { available: false, reason: 'No sample entities target this layout' }
94
+ }
95
+ const db = useDatabase?.()
96
+ if (!db?.handle) {
97
+ return { available: false, reason: 'Database handle unavailable' }
98
+ }
99
+
100
+ const stmt = db.handle.prepare(`
101
+ SELECT destination, refClosure, renderedAt, inputHash, outputHash
102
+ FROM mikser_snapshots
103
+ WHERE id = ?
104
+ ORDER BY renderedAt DESC
105
+ LIMIT 1
106
+ `)
107
+
108
+ const perSample = []
109
+ for (const sample of sampleEntities) {
110
+ const row = stmt.get(sample.id)
111
+ if (!row) {
112
+ perSample.push({ entityId: sample.id, rendered: false })
113
+ continue
114
+ }
115
+ let refClosure = []
116
+ try {
117
+ refClosure = row.refClosure ? JSON.parse(row.refClosure) : []
118
+ } catch { /* malformed JSON: surface empty rather than throw */ }
119
+ perSample.push({
120
+ entityId: sample.id,
121
+ destination: row.destination,
122
+ renderedAt: row.renderedAt,
123
+ inputHash: row.inputHash,
124
+ outputHash: row.outputHash,
125
+ refClosure,
126
+ })
127
+ }
128
+
129
+ const everRendered = perSample.some(s => s.rendered !== false && s.refClosure)
130
+ if (!everRendered) {
131
+ return { available: false, reason: `No sample entity using "${layoutName}" has rendered yet` }
132
+ }
133
+ return { available: true, samples: perSample }
134
+ }
135
+
136
+ // Wrap the renderer's parser so a thrown error in one engine doesn't
137
+ // blow up inspect() for everyone. Errors surface in the returned shape
138
+ // instead. Defensive — renderer authors may write naive parsers that
139
+ // throw on malformed source.
140
+ function safeParseReferences(renderer, source, templateName) {
141
+ try {
142
+ const out = renderer.parseReferences(source)
143
+ if (out == null || typeof out !== 'object') {
144
+ return { available: false, reason: `Renderer "${templateName}" parseReferences() returned non-object` }
145
+ }
146
+ return { available: true, ...out }
147
+ } catch (err) {
148
+ return { available: false, reason: `Renderer "${templateName}" parseReferences() threw: ${err.message}` }
149
+ }
150
+ }