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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Almero Digital Marketing
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,171 @@
1
+ # mikser-io-layouts
2
+
3
+ The SSG-flavor task-production policy for [mikser-io](https://github.com/almero-digital-marketing/mikser-io). Manages HTML/template layouts: every layout whose pattern hits an entity contributes a render task — multi-match by default, no "best match wins" tiebreaker. One source entity can produce multiple outputs of different formats simply by being matched by multiple layouts.
4
+
5
+ This is the **canonical recipe** for the static-site case. A mikser project doesn't have to use it — the engine is renderer-agnostic — but every project that *does* produce rendered output from template-engine layouts uses this plugin.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install mikser-io-layouts
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```js
16
+ // mikser.config.js
17
+ import { documents, frontMatter, yaml, renderHbs } from 'mikser-io'
18
+ import { layouts } from 'mikser-io-layouts'
19
+
20
+ export default {
21
+ plugins: [
22
+ documents(),
23
+ frontMatter(),
24
+ yaml(),
25
+ layouts({ autoLayouts: true }),
26
+ renderHbs(),
27
+ ],
28
+ }
29
+ ```
30
+
31
+ ## Factory options
32
+
33
+ ```js
34
+ layouts({
35
+ layoutsFolder: 'layouts', // default: 'layouts'
36
+
37
+ // Each pattern that matches contributes a render task. Multi-match:
38
+ // an entity at /blog/welcome.md matches both '@/blog/*' (post) AND
39
+ // '@/**' (default) → two render tasks, one per layout.
40
+ match: {
41
+ '@/blog/*': 'post',
42
+ '@/pages/*': 'page',
43
+ '@/**': 'default',
44
+ },
45
+
46
+ autoLayouts: true, // Auto-match by name (single-match fallback; see below)
47
+ cleanUrls: true // /page.html → /page/index.html
48
+ })
49
+ ```
50
+
51
+ ## Per-entity selection — `meta.layout` / `meta.layouts`
52
+
53
+ An entity can override match-based assignment via frontmatter:
54
+
55
+ ```yaml
56
+ ---
57
+ layout: post # single — pick exactly one
58
+ ---
59
+ ```
60
+
61
+ ```yaml
62
+ ---
63
+ layouts: [post, post-email] # multiple — render the entity through each
64
+ ---
65
+ ```
66
+
67
+ `layout` and `layouts` are mutually exclusive — both set is an error. When neither is set, the plugin falls back to `options.match` (multi-match) then `autoLayouts` (single-match peel ladder).
68
+
69
+ ## Auto-layout matching (`autoLayouts: true`) — single-match fallback
70
+
71
+ Only runs when no `options.match` pattern hit the entity. The peel ladder is a most-specific-name search by design and stays first-wins (not multi-match):
72
+
73
+ | Entity (`entity.name`) | Candidates tried in order | Matches if layout exists at |
74
+ |---|---|---|
75
+ | `nginx.conf` | `nginx.conf`, `nginx` | `layouts/nginx.conf.*` or `layouts/nginx.*` |
76
+ | `styles/post.css` | `styles/post.css`, `styles/post` | `layouts/styles/post.css.*` or `layouts/styles/post.*` |
77
+ | `posts/article.md` | `posts/article.md`, `posts/article` | `layouts/posts/article.*` |
78
+
79
+ Cross-directory auto-matching is intentionally not supported — pair `posts/article.md` with a top-level `article.eta` via `meta.layout: 'article'` or a `layouts.match` rule.
80
+
81
+ ## Per-layout destination override
82
+
83
+ A layout's frontmatter can declare a `destination:` template. The template is Handlebars (path-shaped — substitutions only, no body rendering); it gets `{ entity }` as the context, is compiled once and cached, and the result is path-sanitized (`..` segments rejected). This fully overrides the default `entity.name + .format` derivation (and the cleanUrls folder transform).
84
+
85
+ ```yaml
86
+ # layouts/post-card.html.hbs
87
+ ---
88
+ match: '@/blog/*'
89
+ destination: '/cards/{{entity.name}}.html'
90
+ ---
91
+ ```
92
+
93
+ ```yaml
94
+ # Pull from meta
95
+ ---
96
+ destination: '/{{entity.meta.year}}/{{entity.name}}.summary.html'
97
+ ---
98
+ ```
99
+
100
+ ```yaml
101
+ # Use pagination context (when the sidecar paginates)
102
+ ---
103
+ destination: '/archive/page-{{entity.page}}.html'
104
+ ---
105
+ ```
106
+
107
+ ## Destination collisions — fail-fast
108
+
109
+ When two layouts match the same entity AND resolve to the same destination, the plugin logs a named-names error and drops every render task for that entity for the cycle. No winner — disambiguation is the author's call. The build continues for other entities.
110
+
111
+ ```
112
+ Layout collision for /documents/blog/welcome.md:
113
+ - post → /blog/welcome.html
114
+ - post-card → /blog/welcome.html
115
+ Set a `destination:` override on one of them, or change one's format.
116
+ Skipping this entity for the cycle.
117
+ ```
118
+
119
+ The common cases that hit this are:
120
+ 1. Two layouts produce the same format (`.html` from both `post.html.hbs` and `post-card.html.hbs`). Fix: set `destination:` on one.
121
+ 2. Two layouts with the same name in the same directory — file-system collision, not engine collision. Rename one.
122
+
123
+ ## Postprocess chains
124
+
125
+ A layout's filename encodes a postprocessor chain after the format segment: `<name>.<format>-<post1>[-<post2>...].<template>`. Each `post*` segment names a `mikser-io-post-<name>` plugin. Stages run in order, threading file paths (not buffers) between them; the final extension comes from the last stage's `output:`.
126
+
127
+ ```
128
+ layouts/welcome.html-mjml-email.hbs # renderer → MJML, post-mjml → HTML, post-email → EML
129
+ ```
130
+
131
+ Same shape as frontmatter on the source entity:
132
+
133
+ ```yaml
134
+ ---
135
+ postprocessors: [mjml, email]
136
+ ---
137
+ ```
138
+
139
+ See the [mikser-io rendering docs](https://github.com/almero-digital-marketing/mikser-io/blob/main/documentation/rendering.md#postprocess) for the per-stage contract and failure semantics.
140
+
141
+ ## `inspect()` primitive
142
+
143
+ `runtime.options.layouts.inspect(layoutId, { samples })` runs a layout against `samples` real entities and returns the resolved template source, render context, parsed refs, and any errors. Used by the `mikser-io-mcp` plugin's `mikser_layouts_inspect` MCP tool but available to any plugin that wants to surface layout introspection.
144
+
145
+ ## Entity properties
146
+
147
+ **Set on documents (by this plugin):**
148
+ - `layout`: The first matched layout (back-compat alias).
149
+ - `layouts`: Array of all matched layouts (canonical under multi-match).
150
+ - `destination`: Resolved output path — per-task; iterating `entity.layouts` lets you see each.
151
+ - `page` / `pages`: Pagination info (if the layout's sidecar provides pages data).
152
+
153
+ **Set on layouts (by this plugin):**
154
+ - `id`, `uri`, `source`: Path info
155
+ - `collection`: `'layouts'`
156
+ - `type`: `'layout'`
157
+ - `format`: Template format (`'hbs'`, `'html'`, etc.)
158
+ - `name`: Layout name without extension
159
+ - `template`: Same as `format`
160
+
161
+ ## Watch support
162
+
163
+ Yes — layout file changes trigger re-rendering of dependent documents via the refs system.
164
+
165
+ ## Href lookups
166
+
167
+ The href resolution path goes through `runtime.lookupHref(href)` — a sync function that hits the `meta_href` index on `mikser_entities`. Render workers open their own read-only sqlite handle on first task and call the same primitive; templates stay sync. The `href` render plugin uses this; layout-side code can call it directly.
168
+
169
+ ## License
170
+
171
+ MIT
package/index.js ADDED
@@ -0,0 +1,346 @@
1
+ // mikser-io-layouts — SSG-flavor task-production policy for mikser-io.
2
+ //
3
+ // Five concerns, three split into lib/ for navigation:
4
+ // - bootstrap + sync + output (this file: onSync / onLoaded / onImport /
5
+ // onComplete bodies, plus the factory + hook wiring)
6
+ // - destination resolution helpers (lib/destination.js)
7
+ // - inspect() primitive (lib/inspect.js)
8
+ // - layout matching (lib/matching.js — onProcessed body)
9
+ // - render-task assembly (lib/assembly.js — onBeforeRender body)
10
+ //
11
+ // The split is for cognitive load, not for plugin boundaries — the
12
+ // pieces still close over the same factory-destructured core API and
13
+ // always ship together.
14
+
15
+ import path from 'node:path'
16
+ import { mkdir, writeFile, unlink, rmdir, readFile } from 'node:fs/promises'
17
+ import { globby } from 'globby'
18
+ import _ from 'lodash'
19
+ import {
20
+ gateChecksum, sweepDeleted, scanSummary,
21
+ checksumsByCollection,
22
+ useDatabase,
23
+ } from 'mikser-io'
24
+
25
+ import { createInspect } from './lib/inspect.js'
26
+ import { createOnProcessed } from './lib/matching.js'
27
+ import { createOnBeforeRender } from './lib/assembly.js'
28
+ import { registerMcpTools } from './lib/mcp.js'
29
+
30
+ export function layouts(options = {}) {
31
+ return ({
32
+ runtime,
33
+ onLoaded,
34
+ useLogger,
35
+ onImport,
36
+ createEntity,
37
+ updateEntity,
38
+ deleteEntity,
39
+ watch,
40
+ onProcessed,
41
+ onBeforeRender,
42
+ useJournal,
43
+ renderEntities,
44
+ onComplete,
45
+ onSync,
46
+ matchEntity,
47
+ changeExtension,
48
+ getFormatInfo,
49
+ findById,
50
+ findEntity,
51
+ findEntities,
52
+ constants: { ACTION, OPERATION, TASKS },
53
+ }) => {
54
+ const collection = 'layouts'
55
+ const type = 'layout'
56
+
57
+ // Read a layout file's bytes into entity.content so the frontmatter
58
+ // plugin can extract YAML metadata at onProcess. Defensive — sync
59
+ // events can arrive ahead of file state in edge cases (rename races,
60
+ // synthetic test sync calls). A missing file logs at debug and the
61
+ // entity goes in with empty content; downstream renderers will
62
+ // surface the real failure mode with a clearer error.
63
+ async function readLayoutContent(uri) {
64
+ try {
65
+ return await readFile(uri, 'utf8')
66
+ } catch (err) {
67
+ useLogger().debug('Layout content unreadable at %s: %s', uri, err.message)
68
+ return ''
69
+ }
70
+ }
71
+
72
+ // Sitemap lookups live in the catalog — `meta_href` is indexed,
73
+ // so any "find entity by href" query goes through findEntity or
74
+ // (in workers) the read-only sqlite handle they open at first task.
75
+ // The old `runtime.state.layouts.sitemap` in-memory map + `uriIndex`
76
+ // are gone: every entity's "sitemap presence" IS its catalog row.
77
+
78
+ // Expose the layouts inspection surface for other plugins (the
79
+ // mikser-io-mcp plugin wraps inspect() as the mikser_layouts_inspect
80
+ // tool). Done at factory-eval time — before any onLoaded fires — so
81
+ // a later plugin's onLoaded can see it. Matches the preview plugin
82
+ // pattern (`runtime.options.preview = { store, get, stats, config }`).
83
+ runtime.options.layouts = {
84
+ inspect: createInspect({ runtime, findEntity, findEntities, useDatabase, collection }),
85
+ }
86
+
87
+ // MCP tool registration. Gated on runtime.options.mcp — when the
88
+ // mcp plugin isn't loaded, this is a no-op (vector / schemas /
89
+ // preview use the same pattern). The mcp plugin must be FIRST
90
+ // in the plugins array for this to fire; that constraint is
91
+ // documented in mikser-io's CLAUDE.md.
92
+ onLoaded(async () => {
93
+ registerMcpTools({ runtime, useLogger })
94
+ })
95
+
96
+ onSync(collection, async ({ action, context }) => {
97
+ if (!context.relativePath) return false
98
+ const { relativePath } = context
99
+ let id = path.join(`/${collection}`, relativePath)
100
+ if (_.endsWith(id, '.js')) id = id.replace(new RegExp('.js$'), '')
101
+
102
+ const uri = path.join(runtime.options.layoutsFolder, relativePath)
103
+ const { layouts } = runtime.state.layouts
104
+ switch (action) {
105
+ case ACTION.CREATE:
106
+ var layout = {
107
+ id,
108
+ uri,
109
+ collection,
110
+ type,
111
+ name: relativePath.replace(path.extname(relativePath), ''),
112
+ content: await readLayoutContent(uri),
113
+ ...getFormatInfo(relativePath)
114
+ }
115
+ layouts[layout.name] = layout
116
+ await createEntity(layout)
117
+ break
118
+ case ACTION.UPDATE:
119
+ var layout = {
120
+ id,
121
+ uri,
122
+ collection,
123
+ type,
124
+ name: relativePath.replace(path.extname(relativePath), ''),
125
+ content: await readLayoutContent(uri),
126
+ ...getFormatInfo(relativePath)
127
+ }
128
+ layouts[layout.name] = layout
129
+ await updateEntity(layout)
130
+ break
131
+ case ACTION.DELETE:
132
+ var layout = {
133
+ id,
134
+ collection,
135
+ type,
136
+ format: path.extname(relativePath).substring(1).toLowerCase(),
137
+ }
138
+ for (let name in layouts) {
139
+ if (layouts[name].id == layout.id) {
140
+ delete layouts[name]
141
+ }
142
+ }
143
+ await deleteEntity(layout)
144
+ break
145
+ }
146
+ })
147
+
148
+ onLoaded(async () => {
149
+ const logger = useLogger()
150
+
151
+ // Only the layouts collection map lives in memory now. The
152
+ // sitemap was 14k+ entries at scale and got serialized to every
153
+ // worker via Piscina; it now lives in the catalog and workers
154
+ // query it directly through their read-only sqlite handle (see
155
+ // src/render.js's ensureWorkerDb / lookupHrefViaDb).
156
+ //
157
+ // `layouts.layouts` (name → layout entity) stays because it's
158
+ // small (typically 5-20 entries), referenced by template-engine
159
+ // partial registration at render-plugin load time, and cheap to
160
+ // both serialize and rebuild.
161
+ runtime.state.layouts = {
162
+ layouts: {},
163
+ }
164
+
165
+ // Folder name resolved here (config override or default to the
166
+ // collection name) and used immediately to build the absolute
167
+ // path. No need to keep the bare folder-name string on
168
+ // runtime.options — runtime.options.layoutsFolder is the only
169
+ // useful form downstream.
170
+ const layoutsFolderName = options.layoutsFolder || collection
171
+ runtime.options.layoutsFolder = path.join(runtime.options.workingFolder, layoutsFolderName)
172
+ runtime.options.layoutsStateFolder = path.join(runtime.options.outputFolder, 'state')
173
+
174
+ logger.debug('Layouts folder: %s', runtime.options.layoutsFolder)
175
+ await mkdir(runtime.options.layoutsFolder, { recursive: true })
176
+
177
+ watch(collection, runtime.options.layoutsFolder)
178
+
179
+ // Rebuild the in-memory layouts map from the catalog. Indexed
180
+ // on `collection`, returns the small layouts set — typically a
181
+ // few entries, not the full corpus. Subsequent in-cycle
182
+ // mutations to layouts flow through createEntity in onProcess
183
+ // below.
184
+ const { layouts } = runtime.state.layouts
185
+ for (const e of await findEntities({ collection })) {
186
+ layouts[e.name] = e
187
+ }
188
+ })
189
+
190
+ onImport(async () => {
191
+ const { layouts } = runtime.state.layouts
192
+ const logger = useLogger()
193
+ const paths = await globby('**/*', { cwd: runtime.options.layoutsFolder, ignore: ['**/*.js'] })
194
+ const scanned = new Set()
195
+ const stats = { emitted: 0, skipped: 0, deleted: 0 }
196
+
197
+ // Same checksum gate + delete sweep mechanics as useSource
198
+ // (source.js) — extracted into shared helpers so adding a
199
+ // future scanning plugin doesn't repeat them again. Layouts
200
+ // can't simply USE useSource because it owns the in-memory
201
+ // `runtime.state.layouts.layouts` map (consumed by hbs's
202
+ // partial registration, by resolveLayout below, and by
203
+ // layouts.inspect), and the load step layers in
204
+ // getFormatInfo. The gate + sweep + summary line shape are
205
+ // shared regardless.
206
+ // Bulk-prefetch this collection's (id → checksum) map once
207
+ // before the loop; the gate hits a Map.get instead of a per-
208
+ // file SQL lookup.
209
+ const priorChecksums = checksumsByCollection(collection)
210
+ for (let relativePath of paths) {
211
+ const uri = path.join(runtime.options.layoutsFolder, relativePath)
212
+ const id = path.join('/layouts', relativePath)
213
+ scanned.add(id)
214
+
215
+ const chksum = await gateChecksum(uri, id, { priorChecksums })
216
+ if (chksum === null) {
217
+ stats.skipped++
218
+ continue
219
+ }
220
+
221
+ const layout = {
222
+ id, uri,
223
+ name: relativePath.replace(path.extname(relativePath), ''),
224
+ collection,
225
+ type,
226
+ content: await readLayoutContent(uri),
227
+ checksum: chksum,
228
+ }
229
+ Object.assign(layout, await getFormatInfo(relativePath))
230
+ layouts[layout.name] = layout
231
+ await createEntity(layout)
232
+ stats.emitted++
233
+ }
234
+
235
+ stats.deleted = await sweepDeleted(collection, scanned, async (e) => {
236
+ // Drop the layout from the in-memory map alongside the
237
+ // journal DELETE so partial-resolution and consumer
238
+ // renders this cycle don't reach for a layout whose file
239
+ // is gone.
240
+ for (let name in layouts) {
241
+ if (layouts[name].id === e.id) delete layouts[name]
242
+ }
243
+ await deleteEntity({ id: e.id, type, collection })
244
+ logger.debug('Layouts removed (file gone): %s', e.name)
245
+ }, runtime.options.layoutsFolder)
246
+ // ownerPrefix: the layouts folder. Sweep stays scoped to
247
+ // entities whose uri is rooted there; foreign entries in
248
+ // the `layouts` collection (none in practice today, but
249
+ // future MCP-served / API-injected layouts get the same
250
+ // protection as CSV-emitted documents).
251
+
252
+ logger.info(scanSummary({ cap: 'Layouts', loaded: paths.length, ...stats }))
253
+ })
254
+
255
+ // Matching + assembly are extracted to lib/ for cognitive load
256
+ // (~185 + ~360 lines respectively). They still close over the
257
+ // same factory-destructured core API — the createX(ctx) pattern
258
+ // just threads the destructured args explicitly across module
259
+ // boundaries.
260
+ onProcessed(createOnProcessed({
261
+ runtime, useLogger, useJournal,
262
+ findEntity, matchEntity, collection,
263
+ OPERATION,
264
+ options,
265
+ }))
266
+
267
+ onBeforeRender(createOnBeforeRender({
268
+ runtime, useLogger, useJournal,
269
+ findById, findEntity, findEntities,
270
+ renderEntities,
271
+ changeExtension,
272
+ OPERATION, TASKS,
273
+ options,
274
+ }))
275
+
276
+ onComplete(async ({ entity, options, output }) => {
277
+ const logger = useLogger()
278
+ if (entity.layout && !options?.ignore && output.result != null) {
279
+ // `entity.options.save === false` (set by useRenderer when
280
+ // called with { save: false }) opts out of writing the
281
+ // FINAL output to disk. The bytes still come back to the
282
+ // caller via output.result. Strict equality — only the
283
+ // literal `false` opts out, matching the catalog-flag pattern.
284
+ //
285
+ // The intermediate file (when a postprocessor will run next)
286
+ // must still exist somewhere on disk so the postprocessor
287
+ // can consume it. For save:true, that's outputFolder; for
288
+ // save:false, that's runtime.options.previewFolder — an
289
+ // engine-owned scratch path under runtimeFolder, never
290
+ // exposed in user-visible outputFolder. The postprocess
291
+ // task's outputFolder is rewritten in engine.js so post
292
+ // plugins resolve `entity.origin` against the same base.
293
+ const isFinal = !entity.layout.postprocessor || entity.origin != null
294
+ const previewMode = entity.options?.save === false
295
+ const skipWrite = previewMode && isFinal
296
+ const writeBase = (previewMode && !isFinal)
297
+ ? runtime.options.previewFolder
298
+ : runtime.options.outputFolder
299
+
300
+ if (!skipWrite) {
301
+ const destinationFile = path.join(writeBase, entity.destination)
302
+ await mkdir(path.dirname(destinationFile), { recursive: true })
303
+ try {
304
+ await unlink(destinationFile)
305
+ } catch { }
306
+ await writeFile(destinationFile, output.result)
307
+ logger.debug('Layout render finished: %s', entity.destination.replace(runtime.options.workingFolder, ''))
308
+ } else {
309
+ logger.debug('Layout render finished (save:false, bytes only): %s', entity.id)
310
+ }
311
+
312
+ if (entity.origin && entity.origin !== entity.destination) {
313
+ // Don't unlink the origin if it was the same path we just
314
+ // wrote to (post plugins that produce the same extension as
315
+ // the renderer's output — e.g. MJML→HTML on `*.html-mjml.*`
316
+ // layouts). Otherwise we'd delete our own final file.
317
+ //
318
+ // For preview flow (entity.options.save === false) the
319
+ // intermediate lived in previewFolder; for normal flow
320
+ // it lived in outputFolder. Pick the right base.
321
+ const originBase = previewMode
322
+ ? runtime.options.previewFolder
323
+ : runtime.options.outputFolder
324
+ const originFile = path.join(originBase, entity.origin)
325
+ try {
326
+ await unlink(originFile)
327
+ } catch { }
328
+ // With cleanUrls the origin was `<name>/index.html`; once
329
+ // the postprocessor wrote `<name>.<ext>` elsewhere, that
330
+ // folder is left empty. Remove it if so. rmdir only
331
+ // succeeds on an empty directory, so this is a no-op when
332
+ // the folder still holds other outputs (e.g. paginated
333
+ // pages).
334
+ try {
335
+ await rmdir(path.dirname(originFile))
336
+ } catch { }
337
+ }
338
+ }
339
+ })
340
+
341
+ return {
342
+ collection,
343
+ type,
344
+ }
345
+ }
346
+ }