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/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
|
+
}
|