mikser-io 6.0.6 → 6.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "6.0.6",
3
+ "version": "6.2.0",
4
4
  "description": "<p align=\"center\"> <img src=\"mikser-lockup-stacked.svg\" alt=\"mikser\" width=\"198\" /> </p>",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -53,7 +53,9 @@
53
53
  },
54
54
  "devDependencies": {
55
55
  "fluent-ffmpeg": "^2.1.3",
56
- "mikser-io-render-markdown": "^2.0.0",
56
+ "mikser-io-post-mjml": "file:../mikser-io-post-mjml",
57
+ "mikser-io-render-liquid": "file:../mikser-io-render-liquid",
58
+ "mikser-io-render-markdown": "file:../mikser-io-render-markdown",
57
59
  "sharp": "^0.34.5"
58
60
  },
59
61
  "directories": {
package/src/engine.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import pino from 'pino'
2
2
  import path from 'node:path'
3
3
  import { Command } from 'commander'
4
- import { rm, lstat, realpath, mkdir, writeFile } from 'fs/promises'
4
+ import { rm, lstat, realpath, mkdir, writeFile, readFile, unlink } from 'fs/promises'
5
5
  import { existsSync } from 'fs'
6
6
  import _ from 'lodash'
7
7
  import Piscina from 'piscina'
@@ -10,7 +10,7 @@ import { onInitialize, onInitialized, onRender, onCancel, onCancelled, onFinaliz
10
10
  import { useJournal, updateEntry } from './journal.js'
11
11
  import { globby } from 'globby'
12
12
  import { OPERATION, TASKS } from './constants.js'
13
- import { changeExtension } from './utils.js'
13
+ import { changeExtension, formatErrorContext } from './utils.js'
14
14
  import render from './render.js'
15
15
  import postprocess, { loadPlugin as loadPostPlugin } from './postprocess.js'
16
16
  import map from 'p-map'
@@ -89,6 +89,27 @@ export async function setup(options) {
89
89
  onLoaded(async () => {
90
90
  const logger = useLogger()
91
91
  logger.debug(runtime.options, 'Mikser options')
92
+
93
+ // Cumulative render manifest — survives across watch cycles, used to
94
+ // unlink stale output files when their source entity is deleted.
95
+ // Keyed by "<entity.id>:<entity.destination>" so paginated outputs
96
+ // for the same id stay distinct.
97
+ runtime.state.manifest = new Map()
98
+ const manifestPath = path.join(runtime.options.runtimeFolder, 'render-details.json')
99
+ if (existsSync(manifestPath)) {
100
+ try {
101
+ const arr = JSON.parse(await readFile(manifestPath, 'utf8'))
102
+ if (Array.isArray(arr)) {
103
+ for (const entity of arr) {
104
+ if (entity?.id && entity?.destination) {
105
+ runtime.state.manifest.set(`${entity.id}:${entity.destination}`, entity)
106
+ }
107
+ }
108
+ }
109
+ } catch (err) {
110
+ logger.warn('Could not load render-details.json: %s', err.message)
111
+ }
112
+ }
92
113
  })
93
114
 
94
115
  onRender(async (signal) => {
@@ -156,7 +177,7 @@ export async function setup(options) {
156
177
  } catch (err) {
157
178
  if (!signal.aborted) {
158
179
  await updateEntry({ id, output: { success: false } })
159
- logger.error('Render error: %s %s', entity.id, err.message)
180
+ logger.error('Render error: %s%s %s', entity.id, formatErrorContext(entity, err, runtime.options), err.message)
160
181
  }
161
182
  logger.debug('Render canceled')
162
183
  }
@@ -171,15 +192,37 @@ export async function setup(options) {
171
192
  })
172
193
 
173
194
  onAfterRender(async () => {
174
- const results = new Map()
195
+ const logger = useLogger()
196
+ const manifest = runtime.state.manifest
197
+
198
+ // Unlink stale output files for entities deleted in this cycle, and
199
+ // prune them from the manifest. Matches by `entity.id` for direct hits
200
+ // and by `entity.parent` so paginated children (whose id was rewritten
201
+ // via changeExtension) are reclaimed alongside their source.
202
+ for await (let { entity } of useJournal('Manifest cleanup', [OPERATION.DELETE])) {
203
+ for (const [key, value] of manifest) {
204
+ if (value.id === entity.id || value.parent === entity.id) {
205
+ const filePath = path.join(runtime.options.outputFolder, value.destination)
206
+ try {
207
+ await unlink(filePath)
208
+ logger.debug('Manifest unlinked stale output: %s', value.destination)
209
+ } catch { }
210
+ manifest.delete(key)
211
+ }
212
+ }
213
+ }
214
+
215
+ // Merge this cycle's successful renders. New ids appear; re-rendered
216
+ // ids overwrite (same key); ids whose destination changed leave the
217
+ // old key as a stale entry — handled by the cleanup pass on next DELETE.
175
218
  for await (let { output, entity } of useJournal('Output', [OPERATION.RENDER])) {
176
219
  if (output?.success) {
177
- const jobId = entity.id + ':' + entity.destination
178
- results.set(jobId, entity)
220
+ manifest.set(`${entity.id}:${entity.destination}`, entity)
179
221
  }
180
222
  }
181
- const renderOutput = path.join(runtime.options.runtimeFolder, `render-details.json`)
182
- await writeFile(renderOutput, JSON.stringify(Array.from(results.values())), 'utf8')
223
+
224
+ const manifestPath = path.join(runtime.options.runtimeFolder, 'render-details.json')
225
+ await writeFile(manifestPath, JSON.stringify(Array.from(manifest.values())), 'utf8')
183
226
  })
184
227
 
185
228
  onBeforePostprocess(async (signal) => {
@@ -259,7 +302,7 @@ export async function setup(options) {
259
302
  } catch (err) {
260
303
  if (!signal.aborted) {
261
304
  await updateEntry({ id, output: { success: false } })
262
- logger.error('Postprocess error: %s %s', entity.id, err.message)
305
+ logger.error('Postprocess error: %s%s %s', entity.id, formatErrorContext(entity, err, runtime.options), err.message)
263
306
  }
264
307
  logger.debug('Postprocess canceled')
265
308
  }
@@ -313,5 +356,5 @@ export async function setup(options) {
313
356
  }
314
357
 
315
358
  export function useLogger() {
316
- return runtime.engine.logger
359
+ return runtime.engine?.logger
317
360
  }
@@ -59,18 +59,18 @@ export default ({
59
59
 
60
60
  function removeFromSitemap(entity) {
61
61
  const { sitemap } = runtime.state.layouts
62
+ const matches = (current) =>
63
+ current.id === entity.id || current.parent === entity.id
62
64
  for (let href in sitemap) {
63
65
  let entry = sitemap[href]
64
66
  if (entry.id) {
65
- if (entry.id == entity.id) {
67
+ if (matches(entry)) {
66
68
  delete sitemap[href]
67
- return
68
69
  }
69
70
  } else {
70
71
  for (let lang in entry) {
71
- if (entry[lang].id == entity.id) {
72
+ if (matches(entry[lang])) {
72
73
  delete entry[lang]
73
- return
74
74
  }
75
75
  }
76
76
  }
@@ -251,6 +251,11 @@ export default ({
251
251
  }
252
252
  break
253
253
  case OPERATION.DELETE:
254
+ // DELETE journal entries are sparse (id/collection/type only),
255
+ // so the uri-based removePagesFromSitemap can't match. Walk
256
+ // the sitemap by id first; keep the uri-based sweep for any
257
+ // paginated children that match by uri.
258
+ removeFromSitemap(entity)
254
259
  removePagesFromSitemap(entity)
255
260
  break
256
261
  }
@@ -292,6 +297,9 @@ export default ({
292
297
  if (page) {
293
298
  pageEntity.page = page + 1
294
299
  pageEntity.id = changeExtension(entity.id, `${pageEntity.page}.${entity.layout.format}`)
300
+ // Remember the source entity id so the render manifest
301
+ // can reclaim paginated outputs when the parent is deleted.
302
+ pageEntity.parent = entity.id
295
303
  if (entity.meta) {
296
304
  if (entity.meta.href) {
297
305
  pageEntity.meta.href = `${entity.meta.href}.${pageEntity.page}`
@@ -53,5 +53,18 @@ export async function render({ entity, runtime }) {
53
53
  sandbox[helper] = runtime[helper]
54
54
  }
55
55
  }
56
- return runtime.hbs(source, sandbox)
56
+ try {
57
+ return runtime.hbs(source, sandbox)
58
+ } catch (err) {
59
+ // Handlebars compile errors expose `.lineNumber`/`.column`; runtime
60
+ // errors (missing helper, etc.) don't, but we still know the layout.
61
+ // Parse errors put the line in the message as "Parse error on line N".
62
+ if (err.lineNumber != null && err.line == null) err.line = err.lineNumber
63
+ if (err.line == null && typeof err.message === 'string') {
64
+ const m = err.message.match(/on line (\d+)/i)
65
+ if (m) err.line = Number(m[1])
66
+ }
67
+ err.layoutUri = entity.layout.uri
68
+ throw err
69
+ }
57
70
  }
@@ -1,12 +1,20 @@
1
1
  import path from 'node:path'
2
+ import { createRequire } from 'node:module'
2
3
  import _ from 'lodash'
3
4
 
4
5
  export async function loadPlugin(pluginName, workingFolder) {
6
+ const require = createRequire(path.join(workingFolder, 'package.json'))
7
+ let nodeModulesResolved
8
+ try {
9
+ nodeModulesResolved = require.resolve(`mikser-io-${pluginName}`)
10
+ } catch { }
11
+
5
12
  const resolveLocations = [
6
- path.join(workingFolder, 'node_modules', `mikser-core-${pluginName}/index.js`),
13
+ path.join(workingFolder, 'node_modules', `mikser-io-${pluginName}/index.js`),
14
+ nodeModulesResolved,
7
15
  path.join(workingFolder, 'plugins', `${pluginName}.js`),
8
16
  path.join(path.dirname(import.meta.url), 'plugins', 'post', `${pluginName.replace('post-', '')}.js`)
9
- ]
17
+ ].filter(Boolean)
10
18
  for (let resolveLocation of resolveLocations) {
11
19
  try {
12
20
  return await import(resolveLocation)
package/src/render.js CHANGED
@@ -2,6 +2,25 @@ import { readFileSync } from 'node:fs'
2
2
  import { createRequire } from 'node:module'
3
3
  import path from 'node:path'
4
4
  import _ from 'lodash'
5
+ import { useLogger } from './engine.js'
6
+
7
+ // Flatten template-helper args into a single human-readable message.
8
+ // Handlebars helpers receive a trailing options object (it has a `.hash`
9
+ // property) which we drop.
10
+ function formatLogArgs(args) {
11
+ if (args.length && typeof args[args.length - 1] === 'object' && args[args.length - 1] !== null && 'hash' in args[args.length - 1]) {
12
+ args = args.slice(0, -1)
13
+ }
14
+ return args
15
+ .map(arg => {
16
+ if (arg == null) return String(arg)
17
+ if (typeof arg === 'object') {
18
+ try { return JSON.stringify(arg) } catch { return String(arg) }
19
+ }
20
+ return String(arg)
21
+ })
22
+ .join(' ')
23
+ }
5
24
 
6
25
  export default async ({ entity, options, config, context, state, logger, port }) => {
7
26
  logger = logger || {
@@ -65,7 +84,21 @@ export default async ({ entity, options, config, context, state, logger, port })
65
84
  data: context.data,
66
85
  content() {
67
86
  return readFileSync(entity.source, { encoding: 'utf8' })
68
- }
87
+ },
88
+ // Logger functions are exposed directly so each renderer's
89
+ // auto-helper loop picks them up are picked up; falls back to the local `logger` in
90
+ // worker contexts where the engine singleton isn't initialised.
91
+ //
92
+ // Args are flattened into a single space-separated message so
93
+ // every value the template passed shows up — pino otherwise drops
94
+ // trailing positional args unless the first contains %s/%d format
95
+ // specifiers. Handlebars appends an internal options object as
96
+ // the last arg, which we strip before joining.
97
+ log: (...args) => (useLogger() ?? logger).info(formatLogArgs(args)),
98
+ warn: (...args) => (useLogger() ?? logger).warn(formatLogArgs(args)),
99
+ error: (...args) => (useLogger() ?? logger).error(formatLogArgs(args)),
100
+ debug: (...args) => (useLogger() ?? logger).debug(formatLogArgs(args)),
101
+ trace: (...args) => (useLogger() ?? logger).trace(formatLogArgs(args)),
69
102
  }
70
103
 
71
104
  for (let pluginName of pluginsToLoad) {
package/src/utils.js CHANGED
@@ -62,4 +62,22 @@ export function changeExtension(file, format) {
62
62
  let extension = path.extname(file)
63
63
  let result = file.substring(0, file.length - extension.length) + '.' + format
64
64
  return result
65
+ }
66
+
67
+ // Build a compact "[layouts/foo.hbs:12:4]" suffix from whatever the
68
+ // underlying template engine attached to its thrown error. Renderer
69
+ // plugins are expected to set `err.layoutUri` (and optionally `err.line` /
70
+ // `err.column`) before rethrowing.
71
+ export function formatErrorContext(entity, err, options) {
72
+ const layoutUri = err?.layoutUri || entity?.layout?.uri || entity?.layout?.id
73
+ if (!layoutUri) return ''
74
+ const workingFolder = options?.workingFolder
75
+ const rel = workingFolder && layoutUri.startsWith(workingFolder + '/')
76
+ ? layoutUri.slice(workingFolder.length + 1)
77
+ : layoutUri
78
+ const line = err?.line ?? err?.lineNumber
79
+ const column = err?.column ?? err?.col
80
+ let pos = ''
81
+ if (line) pos = `:${line}${column ? ':' + column : ''}`
82
+ return ` [${rel}${pos}]`
65
83
  }