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.
- package/LICENSE +21 -0
- package/README.md +171 -0
- package/index.js +346 -0
- package/lib/assembly.js +399 -0
- package/lib/destination.js +33 -0
- package/lib/inspect.js +150 -0
- package/lib/matching.js +212 -0
- package/lib/mcp.js +54 -0
- package/package.json +29 -0
- package/test/_harness.js +243 -0
- package/test/unit.test.js +716 -0
package/lib/assembly.js
ADDED
|
@@ -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
|
+
}
|