mikser-io 6.3.1 → 6.9.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/index.js +2 -1
- package/package.json +5 -2
- package/src/api.js +165 -0
- package/src/plugins/layouts.js +1 -9
- package/src/plugins/rest.js +84 -52
- package/src/plugins/validator.js +1 -1
- package/src/render.js +1 -18
- package/src/utils.js +40 -3
package/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mikser-io",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.9.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": {
|
|
7
|
-
"
|
|
7
|
+
"debug": "node --no-warnings app.js --debug --working-folder test/fixture",
|
|
8
|
+
"test:unit": "node --test --test-reporter=spec 'test/unit/**/*.test.js'",
|
|
9
|
+
"test:smoke": "node --no-warnings app.js --working-folder test/fixture",
|
|
10
|
+
"test": "npm run test:unit && npm run test:smoke"
|
|
8
11
|
},
|
|
9
12
|
"bin": {
|
|
10
13
|
"mikser": "app.js"
|
package/src/api.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// Public, transport-agnostic primitives that the REST plugin's HTTP
|
|
2
|
+
// endpoints are thin wrappers over. Library users embedding mikser
|
|
3
|
+
// programmatically can import these directly.
|
|
4
|
+
|
|
5
|
+
import { randomUUID } from 'node:crypto'
|
|
6
|
+
import { writeFile, unlink, mkdir } from 'node:fs/promises'
|
|
7
|
+
import path from 'node:path'
|
|
8
|
+
import { updateEntity as defaultUpdateEntity } from './lifecycle.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Bind to the runtime and return an on-demand renderer that pipelines
|
|
12
|
+
* concurrent calls into the minimum number of `runtime.process()` cycles.
|
|
13
|
+
* The returned binding is stateful — each call to useRenderer() owns its
|
|
14
|
+
* own pending queue and `completed`-hook lifecycle. Mount once per
|
|
15
|
+
* consumer (the REST plugin mounts one; a library service mounts its own).
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* const { render } = useRenderer(runtime)
|
|
19
|
+
* const { output, entity } = await render(entityShape)
|
|
20
|
+
*
|
|
21
|
+
* @param {object} runtime - the mikser runtime singleton
|
|
22
|
+
* @param {object} [opts]
|
|
23
|
+
* @param {Function} [opts.updateEntity] - override lifecycle.updateEntity (mostly for testing)
|
|
24
|
+
* @param {number} [opts.defaultTimeout] - per-render timeout in ms (default 30_000)
|
|
25
|
+
* @returns {{ render: (entity, { timeout? }?) => Promise<{ output, entity }> }}
|
|
26
|
+
*/
|
|
27
|
+
export function useRenderer(runtime, {
|
|
28
|
+
updateEntity = defaultUpdateEntity,
|
|
29
|
+
defaultTimeout = 30_000,
|
|
30
|
+
} = {}) {
|
|
31
|
+
let pending = []
|
|
32
|
+
let cycleRunning = false
|
|
33
|
+
|
|
34
|
+
async function runBatch() {
|
|
35
|
+
if (cycleRunning || pending.length === 0) return
|
|
36
|
+
cycleRunning = true
|
|
37
|
+
|
|
38
|
+
const batch = pending
|
|
39
|
+
pending = []
|
|
40
|
+
|
|
41
|
+
const remaining = new Map(batch.map(b => [b.correlationId, b]))
|
|
42
|
+
const completedHooks = runtime.hooks.completed
|
|
43
|
+
const hook = async (entry) => {
|
|
44
|
+
const cid = entry.entity?._correlationId
|
|
45
|
+
if (!cid) return
|
|
46
|
+
const item = remaining.get(cid)
|
|
47
|
+
if (!item) return
|
|
48
|
+
remaining.delete(cid)
|
|
49
|
+
clearTimeout(item.timer)
|
|
50
|
+
item.resolve({ output: entry.output, entity: entry.entity })
|
|
51
|
+
}
|
|
52
|
+
completedHooks.push(hook)
|
|
53
|
+
|
|
54
|
+
for (const item of batch) {
|
|
55
|
+
item.timer = setTimeout(() => {
|
|
56
|
+
if (remaining.delete(item.correlationId)) {
|
|
57
|
+
item.reject(new Error(`Render timeout for ${item.entity.id}`))
|
|
58
|
+
}
|
|
59
|
+
}, item.timeout)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
for (const item of batch) {
|
|
64
|
+
await updateEntity(item.entity).catch(item.reject)
|
|
65
|
+
}
|
|
66
|
+
await runtime.process()
|
|
67
|
+
} catch (err) {
|
|
68
|
+
for (const item of remaining.values()) {
|
|
69
|
+
clearTimeout(item.timer)
|
|
70
|
+
item.reject(err)
|
|
71
|
+
}
|
|
72
|
+
remaining.clear()
|
|
73
|
+
} finally {
|
|
74
|
+
for (const item of remaining.values()) {
|
|
75
|
+
clearTimeout(item.timer)
|
|
76
|
+
item.reject(new Error(`Render did not complete for ${item.entity.id}`))
|
|
77
|
+
}
|
|
78
|
+
const idx = completedHooks.indexOf(hook)
|
|
79
|
+
if (idx >= 0) completedHooks.splice(idx, 1)
|
|
80
|
+
cycleRunning = false
|
|
81
|
+
if (pending.length) setImmediate(runBatch)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Submit an entity for rendering. Resolves with `{ output, entity }`
|
|
87
|
+
* where `output.result` is whatever the renderer/postprocessor returned
|
|
88
|
+
* (a string for HTML/text outputs, a Buffer for PDFs, etc.).
|
|
89
|
+
*
|
|
90
|
+
* Requests arriving concurrently are coalesced into the next available
|
|
91
|
+
* `runtime.process()` cycle — within that cycle, mikser's worker pool
|
|
92
|
+
* renders the batch in parallel.
|
|
93
|
+
*
|
|
94
|
+
* @param {object} entity - any entity-shaped object
|
|
95
|
+
* @param {object} [opts]
|
|
96
|
+
* @param {number} [opts.timeout] - override the default timeout
|
|
97
|
+
* @returns {Promise<{output, entity}>}
|
|
98
|
+
*/
|
|
99
|
+
function render(entity, { timeout = defaultTimeout } = {}) {
|
|
100
|
+
return new Promise((resolve, reject) => {
|
|
101
|
+
const correlationId = randomUUID()
|
|
102
|
+
pending.push({
|
|
103
|
+
entity: { ...entity, _correlationId: correlationId },
|
|
104
|
+
correlationId,
|
|
105
|
+
timeout,
|
|
106
|
+
resolve,
|
|
107
|
+
reject,
|
|
108
|
+
timer: null,
|
|
109
|
+
})
|
|
110
|
+
if (!cycleRunning) setImmediate(runBatch)
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return { render }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Bind to a single collection's source folder and return file-level
|
|
119
|
+
* `write` / `remove` operations against it. Each collection plugin sets
|
|
120
|
+
* `runtime.options.<name>Folder` during its onLoaded hook; this looks
|
|
121
|
+
* that up lazily, so it's safe to call useCollection() anywhere after
|
|
122
|
+
* `runtime.start()`.
|
|
123
|
+
*
|
|
124
|
+
* Distinct from `lifecycle.updateEntity` / `lifecycle.deleteEntity` —
|
|
125
|
+
* those write journal entries. These write actual files; in watch mode
|
|
126
|
+
* the resulting fs change is what kicks the next sync→process cycle.
|
|
127
|
+
*
|
|
128
|
+
* @example
|
|
129
|
+
* const documents = useCollection(runtime, 'documents')
|
|
130
|
+
* await documents.write('en/draft.md', '# Hi')
|
|
131
|
+
* await documents.remove('en/old.md')
|
|
132
|
+
*
|
|
133
|
+
* @param {object} runtime - the mikser runtime singleton
|
|
134
|
+
* @param {string} name - collection name (e.g. 'documents')
|
|
135
|
+
* @returns {{
|
|
136
|
+
* name: string,
|
|
137
|
+
* folder: string,
|
|
138
|
+
* write(relativePath: string, content?: string): Promise<string>,
|
|
139
|
+
* remove(relativePath: string): Promise<void>,
|
|
140
|
+
* }}
|
|
141
|
+
*/
|
|
142
|
+
export function useCollection(runtime, name) {
|
|
143
|
+
function resolveFolder() {
|
|
144
|
+
const folder = runtime?.options?.[`${name}Folder`]
|
|
145
|
+
if (!folder) throw new Error(`Unknown collection: ${name}`)
|
|
146
|
+
return folder
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
name,
|
|
151
|
+
get folder() { return resolveFolder() },
|
|
152
|
+
|
|
153
|
+
async write(relativePath, content = '') {
|
|
154
|
+
const uri = path.join(resolveFolder(), relativePath)
|
|
155
|
+
await mkdir(path.dirname(uri), { recursive: true })
|
|
156
|
+
await writeFile(uri, content, 'utf8')
|
|
157
|
+
return uri
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
async remove(relativePath) {
|
|
161
|
+
const uri = path.join(resolveFolder(), relativePath)
|
|
162
|
+
await unlink(uri)
|
|
163
|
+
},
|
|
164
|
+
}
|
|
165
|
+
}
|
package/src/plugins/layouts.js
CHANGED
|
@@ -21,6 +21,7 @@ export default ({
|
|
|
21
21
|
onSync,
|
|
22
22
|
matchEntity,
|
|
23
23
|
changeExtension,
|
|
24
|
+
getFormatInfo,
|
|
24
25
|
findEntity,
|
|
25
26
|
findEntities,
|
|
26
27
|
constants: { ACTION, OPERATION, TASKS },
|
|
@@ -28,15 +29,6 @@ export default ({
|
|
|
28
29
|
const collection = 'layouts'
|
|
29
30
|
const type = 'layout'
|
|
30
31
|
|
|
31
|
-
function getFormatInfo(relativePath) {
|
|
32
|
-
const template = path.extname(relativePath).substring(1).toLowerCase()
|
|
33
|
-
const withoutTemplate = relativePath.replace(path.extname(relativePath), '')
|
|
34
|
-
const formatExt = path.extname(withoutTemplate).substring(1).toLowerCase()
|
|
35
|
-
const [format, postprocessor] = formatExt.split('-')
|
|
36
|
-
const name = formatExt ? withoutTemplate.replace(path.extname(withoutTemplate), '') : withoutTemplate
|
|
37
|
-
return { name, format: format || 'html', template, postprocessor }
|
|
38
|
-
}
|
|
39
|
-
|
|
40
32
|
function addToSitemap(entity) {
|
|
41
33
|
const logger = useLogger()
|
|
42
34
|
const { sitemap } = runtime.state.layouts
|
package/src/plugins/rest.js
CHANGED
|
@@ -1,12 +1,75 @@
|
|
|
1
1
|
import path from 'node:path'
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
|
|
2
|
+
import { access } from 'node:fs/promises'
|
|
3
|
+
import { useRenderer, useCollection } from '../api.js'
|
|
4
|
+
|
|
5
|
+
// MIME type lookup used when streaming a postprocessor's output back over
|
|
6
|
+
// HTTP. The renderer's output extension lives on entity.destination
|
|
7
|
+
// (assigned by the layouts plugin), so we use it as the source of truth.
|
|
8
|
+
const MIME_BY_EXT = {
|
|
9
|
+
pdf: 'application/pdf',
|
|
10
|
+
html: 'text/html; charset=utf-8',
|
|
11
|
+
xml: 'application/xml; charset=utf-8',
|
|
12
|
+
xhtml: 'application/xhtml+xml; charset=utf-8',
|
|
13
|
+
rss: 'application/rss+xml; charset=utf-8',
|
|
14
|
+
atom: 'application/atom+xml; charset=utf-8',
|
|
15
|
+
json: 'application/json; charset=utf-8',
|
|
16
|
+
css: 'text/css; charset=utf-8',
|
|
17
|
+
js: 'application/javascript; charset=utf-8',
|
|
18
|
+
svg: 'image/svg+xml',
|
|
19
|
+
png: 'image/png',
|
|
20
|
+
jpg: 'image/jpeg',
|
|
21
|
+
jpeg: 'image/jpeg',
|
|
22
|
+
webp: 'image/webp',
|
|
23
|
+
gif: 'image/gif',
|
|
24
|
+
mp4: 'video/mp4',
|
|
25
|
+
webm: 'video/webm',
|
|
26
|
+
txt: 'text/plain; charset=utf-8',
|
|
27
|
+
md: 'text/markdown; charset=utf-8',
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function mimeForEntity(entity) {
|
|
31
|
+
if (!entity?.destination) return null
|
|
32
|
+
const ext = path.extname(entity.destination).toLowerCase().replace(/^\./, '')
|
|
33
|
+
return MIME_BY_EXT[ext] ?? null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Decide how to send the render output over HTTP. Exported (and pure-ish)
|
|
37
|
+
// so tests can exercise the branching without spinning up a real server.
|
|
38
|
+
export async function sendRenderOutput(res, output, entity) {
|
|
39
|
+
if (output == null || output.result == null) {
|
|
40
|
+
return res.status(204).send()
|
|
41
|
+
}
|
|
42
|
+
const result = output.result
|
|
43
|
+
const mime = mimeForEntity(entity)
|
|
44
|
+
|
|
45
|
+
if (Buffer.isBuffer(result)) {
|
|
46
|
+
if (mime) res.type(mime)
|
|
47
|
+
return res.send(result)
|
|
48
|
+
}
|
|
49
|
+
if (typeof result === 'string') {
|
|
50
|
+
// A few postprocessors might return an absolute path to a generated
|
|
51
|
+
// file rather than its contents. Only attempt this when the string
|
|
52
|
+
// looks plausibly path-shaped — short, starts with a slash, and the
|
|
53
|
+
// file actually exists. Otherwise treat it as content.
|
|
54
|
+
if (result.length < 4096 && (result.startsWith('/') || /^[A-Za-z]:[\\/]/.test(result))) {
|
|
55
|
+
try {
|
|
56
|
+
await access(result)
|
|
57
|
+
return res.sendFile(result)
|
|
58
|
+
} catch { /* not a path, fall through */ }
|
|
59
|
+
}
|
|
60
|
+
if (mime) res.type(mime)
|
|
61
|
+
return res.send(result)
|
|
62
|
+
}
|
|
63
|
+
// Anything else (plain object, etc.) is sent as JSON.
|
|
64
|
+
return res.json(result)
|
|
65
|
+
}
|
|
5
66
|
|
|
6
67
|
export default ({
|
|
7
68
|
runtime,
|
|
8
69
|
onLoaded,
|
|
9
70
|
useLogger,
|
|
71
|
+
updateEntity,
|
|
72
|
+
findEntities,
|
|
10
73
|
}) => {
|
|
11
74
|
onLoaded(async () => {
|
|
12
75
|
const logger = useLogger()
|
|
@@ -28,15 +91,19 @@ export default ({
|
|
|
28
91
|
res.status(401).json({ error: 'Unauthorized' })
|
|
29
92
|
}
|
|
30
93
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
94
|
+
// Reuse the transport-agnostic primitives from src/api.js so the
|
|
95
|
+
// library entry point and the REST endpoints share the exact same
|
|
96
|
+
// batching/timeouts/error semantics.
|
|
97
|
+
const { render } = useRenderer(runtime, {
|
|
98
|
+
updateEntity,
|
|
99
|
+
defaultTimeout: runtime.config.rest?.renderTimeout ?? 30_000,
|
|
100
|
+
})
|
|
34
101
|
|
|
35
102
|
router.get('/entities', async (req, res) => {
|
|
36
103
|
try {
|
|
37
104
|
const { page: rawPage, limit: rawLimit, ...filter } = req.query
|
|
38
105
|
const page = Math.max(1, parseInt(rawPage) || 1)
|
|
39
|
-
const limit = Math.min(100, Math.max(1, parseInt(rawLimit) || runtime.config.rest?.pageSize ?? 10))
|
|
106
|
+
const limit = Math.min(100, Math.max(1, parseInt(rawLimit) || (runtime.config.rest?.pageSize ?? 10)))
|
|
40
107
|
const query = Object.keys(filter).length ? filter : undefined
|
|
41
108
|
|
|
42
109
|
const all = await findEntities(query)
|
|
@@ -62,69 +129,34 @@ export default ({
|
|
|
62
129
|
router.put('/entities', auth, async (req, res) => {
|
|
63
130
|
try {
|
|
64
131
|
const { collection, relativePath, content = '' } = req.body
|
|
65
|
-
|
|
66
|
-
if (!folder) return res.status(400).json({ error: `Unknown collection: ${collection}` })
|
|
67
|
-
const uri = path.join(folder, relativePath)
|
|
68
|
-
await mkdir(path.dirname(uri), { recursive: true })
|
|
69
|
-
await writeFile(uri, content, 'utf8')
|
|
132
|
+
await useCollection(runtime, collection).write(relativePath, content)
|
|
70
133
|
res.status(202).json({ ok: true })
|
|
71
134
|
} catch (err) {
|
|
72
135
|
logger.error('REST update error: %s', err.message)
|
|
73
|
-
res.status(500).json({ error: err.message })
|
|
136
|
+
res.status(/Unknown collection/.test(err.message) ? 400 : 500).json({ error: err.message })
|
|
74
137
|
}
|
|
75
138
|
})
|
|
76
139
|
|
|
77
140
|
router.delete('/entities', auth, async (req, res) => {
|
|
78
141
|
try {
|
|
79
142
|
const { collection, relativePath } = req.body
|
|
80
|
-
|
|
81
|
-
if (!folder) return res.status(400).json({ error: `Unknown collection: ${collection}` })
|
|
82
|
-
const uri = path.join(folder, relativePath)
|
|
83
|
-
await unlink(uri)
|
|
143
|
+
await useCollection(runtime, collection).remove(relativePath)
|
|
84
144
|
res.status(202).json({ ok: true })
|
|
85
145
|
} catch (err) {
|
|
86
146
|
logger.error('REST delete error: %s', err.message)
|
|
87
|
-
res.status(500).json({ error: err.message })
|
|
147
|
+
res.status(/Unknown collection/.test(err.message) ? 400 : 500).json({ error: err.message })
|
|
88
148
|
}
|
|
89
149
|
})
|
|
90
150
|
|
|
91
|
-
router.post('/render', async (req, res) => {
|
|
151
|
+
router.post('/render', auth, async (req, res) => {
|
|
92
152
|
try {
|
|
93
|
-
const entity =
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
const output = await new Promise((resolve, reject) => {
|
|
97
|
-
const timer = setTimeout(() => {
|
|
98
|
-
runtime.removeHook('completed', hook)
|
|
99
|
-
reject(new Error(`Render timeout for ${entity.id}`))
|
|
100
|
-
}, timeout)
|
|
101
|
-
|
|
102
|
-
const hook = async (entry) => {
|
|
103
|
-
if (entry.entity._correlationId !== entity._correlationId) return
|
|
104
|
-
clearTimeout(timer)
|
|
105
|
-
runtime.removeHook('completed', hook)
|
|
106
|
-
resolve(entry.output)
|
|
107
|
-
}
|
|
108
|
-
runtime.addHook('completed', hook)
|
|
109
|
-
|
|
110
|
-
updateEntity(entity)
|
|
111
|
-
.then(() => runtime.process())
|
|
112
|
-
.catch(reject)
|
|
113
|
-
})
|
|
114
|
-
|
|
115
|
-
if (output?.result != null) {
|
|
116
|
-
const isFile = await access(output.result).then(() => true).catch(() => false)
|
|
117
|
-
if (isFile) {
|
|
118
|
-
res.sendFile(output.result)
|
|
119
|
-
} else {
|
|
120
|
-
res.send(output.result)
|
|
121
|
-
}
|
|
122
|
-
} else {
|
|
123
|
-
res.status(204).send()
|
|
124
|
-
}
|
|
153
|
+
const { output, entity } = await render(req.body)
|
|
154
|
+
await sendRenderOutput(res, output, entity)
|
|
125
155
|
} catch (err) {
|
|
126
156
|
logger.error('REST render error: %s', err.message)
|
|
127
|
-
res.
|
|
157
|
+
if (!res.headersSent) {
|
|
158
|
+
res.status(500).json({ error: err.message })
|
|
159
|
+
}
|
|
128
160
|
}
|
|
129
161
|
})
|
|
130
162
|
|
package/src/plugins/validator.js
CHANGED
|
@@ -10,7 +10,7 @@ export default ({
|
|
|
10
10
|
onLoad(() => {
|
|
11
11
|
for (let { match, validate, operations = [OPERATION.CREATE, OPERATION.UPDATE] } of runtime.config.validator?.validators || []) {
|
|
12
12
|
onValidate(operations, async entry => {
|
|
13
|
-
if (entity
|
|
13
|
+
if (entry.entity?.meta && matchEntity(entry.entity, match)) {
|
|
14
14
|
return await validate(entry.entity)
|
|
15
15
|
}
|
|
16
16
|
})
|
package/src/render.js
CHANGED
|
@@ -3,24 +3,7 @@ import { createRequire } from 'node:module'
|
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
import _ from 'lodash'
|
|
5
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
|
-
}
|
|
6
|
+
import { formatLogArgs } from './utils.js'
|
|
24
7
|
|
|
25
8
|
export default async ({ entity, options, config, context, state, logger, port }) => {
|
|
26
9
|
logger = logger || {
|
package/src/utils.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { hashFile,
|
|
1
|
+
import { hashFile, hash } from 'hasha'
|
|
2
2
|
import { stat } from 'node:fs/promises'
|
|
3
3
|
import TruncateStream from 'truncate-stream'
|
|
4
4
|
import { createReadStream } from 'node:fs'
|
|
@@ -23,7 +23,7 @@ export async function checksum(uri) {
|
|
|
23
23
|
const truncate = new TruncateStream({ maxBytes })
|
|
24
24
|
const fileStream = createReadStream(uri)
|
|
25
25
|
fileStream.pipe(truncate)
|
|
26
|
-
const checksum = size.toString() + ':' + await
|
|
26
|
+
const checksum = size.toString() + ':' + await hash(truncate, { algorithm: 'md5' })
|
|
27
27
|
return checksum
|
|
28
28
|
}
|
|
29
29
|
}
|
|
@@ -48,7 +48,7 @@ export function matchEntity(entity, match) {
|
|
|
48
48
|
if (!match) return false
|
|
49
49
|
if (typeof match == 'function') return match(entity)
|
|
50
50
|
else if (typeof match == 'string') {
|
|
51
|
-
if (match.substring(0,
|
|
51
|
+
if (match.substring(0, 2) == '@/') {
|
|
52
52
|
return minimatch(entity.name, match.substring(2))
|
|
53
53
|
} else {
|
|
54
54
|
return minimatch(entity.id, match)
|
|
@@ -64,6 +64,43 @@ export function changeExtension(file, format) {
|
|
|
64
64
|
return result
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
// Decode a layout filename into its parts. `template` is the outer
|
|
68
|
+
// extension (the renderer); `format` is the output format encoded as a
|
|
69
|
+
// second extension (defaults to 'html'); `postprocessor`, if present,
|
|
70
|
+
// is everything after the `-` in the format segment.
|
|
71
|
+
//
|
|
72
|
+
// Examples:
|
|
73
|
+
// foo.hbs -> { name:'foo', format:'html', template:'hbs', postprocessor:undefined }
|
|
74
|
+
// page.css.hbs -> { name:'page', format:'css', template:'hbs', postprocessor:undefined }
|
|
75
|
+
// report.html-pdf.hbs -> { name:'report', format:'html', template:'hbs', postprocessor:'pdf' }
|
|
76
|
+
// welcome.html-mjml.liquid-> { name:'welcome', format:'html', template:'liquid', postprocessor:'mjml' }
|
|
77
|
+
export function getFormatInfo(relativePath) {
|
|
78
|
+
const template = path.extname(relativePath).substring(1).toLowerCase()
|
|
79
|
+
const withoutTemplate = relativePath.replace(path.extname(relativePath), '')
|
|
80
|
+
const formatExt = path.extname(withoutTemplate).substring(1).toLowerCase()
|
|
81
|
+
const [format, postprocessor] = formatExt.split('-')
|
|
82
|
+
const name = formatExt ? withoutTemplate.replace(path.extname(withoutTemplate), '') : withoutTemplate
|
|
83
|
+
return { name, format: format || 'html', template, postprocessor }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Flatten template-helper args into a single human-readable message.
|
|
87
|
+
// Handlebars helpers receive a trailing options object (it has a `.hash`
|
|
88
|
+
// property) which we drop. Liquid filters and Eta calls don't.
|
|
89
|
+
export function formatLogArgs(args) {
|
|
90
|
+
if (args.length && typeof args[args.length - 1] === 'object' && args[args.length - 1] !== null && 'hash' in args[args.length - 1]) {
|
|
91
|
+
args = args.slice(0, -1)
|
|
92
|
+
}
|
|
93
|
+
return args
|
|
94
|
+
.map(arg => {
|
|
95
|
+
if (arg == null) return String(arg)
|
|
96
|
+
if (typeof arg === 'object') {
|
|
97
|
+
try { return JSON.stringify(arg) } catch { return String(arg) }
|
|
98
|
+
}
|
|
99
|
+
return String(arg)
|
|
100
|
+
})
|
|
101
|
+
.join(' ')
|
|
102
|
+
}
|
|
103
|
+
|
|
67
104
|
// Build a compact "[layouts/foo.hbs:12:4]" suffix from whatever the
|
|
68
105
|
// underlying template engine attached to its thrown error. Renderer
|
|
69
106
|
// plugins are expected to set `err.layoutUri` (and optionally `err.line` /
|