mikser-io-render-liquid 2.1.0 → 4.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/README.md +11 -6
- package/index.js +204 -4
- package/package.json +5 -3
- package/test/strict-filters.test.js +62 -0
package/README.md
CHANGED
|
@@ -14,16 +14,21 @@ npm install mikser-io-render-liquid
|
|
|
14
14
|
|
|
15
15
|
```js
|
|
16
16
|
// mikser.config.js
|
|
17
|
+
import { layouts } from 'mikser-io'
|
|
18
|
+
import { renderLiquid } from 'mikser-io-render-liquid'
|
|
19
|
+
|
|
17
20
|
export default {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
plugins: [
|
|
22
|
+
layouts(),
|
|
23
|
+
renderLiquid({
|
|
24
|
+
jsTruthy: true,
|
|
25
|
+
strictFilters: false
|
|
26
|
+
}),
|
|
27
|
+
]
|
|
23
28
|
}
|
|
24
29
|
```
|
|
25
30
|
|
|
26
|
-
The
|
|
31
|
+
The options object is passed through to the `Liquid` constructor — see [LiquidJS options](https://liquidjs.com/api/interfaces/LiquidOptions.html). Defaults applied by the plugin:
|
|
27
32
|
|
|
28
33
|
- `root: options.layoutsFolder`
|
|
29
34
|
- `extname: '.liquid'`
|
package/index.js
CHANGED
|
@@ -1,7 +1,23 @@
|
|
|
1
1
|
import { Liquid } from 'liquidjs'
|
|
2
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
2
3
|
|
|
3
4
|
let engine
|
|
4
5
|
|
|
6
|
+
// Per-render context propagated through LiquidJS's async chain via
|
|
7
|
+
// Node's AsyncLocalStorage. The render() function below sets the
|
|
8
|
+
// context; the wrapped _parsePartialFile / _parseLayoutFile (called
|
|
9
|
+
// by IncludeTag.render, RenderTag.render, LayoutTag.render for each
|
|
10
|
+
// include/render/layout invocation) read it back to report partial
|
|
11
|
+
// usage to the engine.
|
|
12
|
+
const renderContext = new AsyncLocalStorage()
|
|
13
|
+
|
|
14
|
+
function trackPartial(name) {
|
|
15
|
+
const ctx = renderContext.getStore()
|
|
16
|
+
if (!ctx?.track || !ctx.layouts || !name) return
|
|
17
|
+
const layout = ctx.layouts[name]
|
|
18
|
+
if (layout?.id) ctx.track.partial(layout.id)
|
|
19
|
+
}
|
|
20
|
+
|
|
5
21
|
export function load({ runtime, options, config }) {
|
|
6
22
|
if (!engine) {
|
|
7
23
|
// `root` still points at the layouts folder so LiquidJS's own
|
|
@@ -14,23 +30,65 @@ export function load({ runtime, options, config }) {
|
|
|
14
30
|
root: options.layoutsFolder,
|
|
15
31
|
extname: '.liquid',
|
|
16
32
|
cache: !options.watch,
|
|
33
|
+
// LiquidJS defaults strictFilters to false, which makes an
|
|
34
|
+
// unknown filter a no-op that returns its input unchanged.
|
|
35
|
+
// That is a bad default HERE specifically, because which
|
|
36
|
+
// filters exist depends on which render plugins are loaded:
|
|
37
|
+
// every function on `runtime` is registered as one below. So
|
|
38
|
+
// `{{ '/contacts' | href }}` with renderHref() missing from the
|
|
39
|
+
// plugin list renders the string back, and a missing plugin is
|
|
40
|
+
// indistinguishable from a working helper.
|
|
41
|
+
//
|
|
42
|
+
// strictVariables is deliberately NOT set. It would throw on
|
|
43
|
+
// any undefined variable, which templates legitimately rely on
|
|
44
|
+
// being empty — a far larger change than this issue is about.
|
|
45
|
+
strictFilters: true,
|
|
46
|
+
// Spread last, so a project can put either back.
|
|
17
47
|
...config,
|
|
18
48
|
})
|
|
49
|
+
|
|
50
|
+
// Wrap LiquidJS's per-invocation partial/layout loaders.
|
|
51
|
+
// IncludeTag.render and RenderTag.render call
|
|
52
|
+
// engine._parsePartialFile(filepath, …) for each `{% include %}` /
|
|
53
|
+
// `{% render %}`; LayoutTag.render calls
|
|
54
|
+
// engine._parseLayoutFile(filepath, …) for `{% layout %}`.
|
|
55
|
+
// These fire on every invocation — even when the partial's
|
|
56
|
+
// parse is cache-hit — so they're the right granularity for
|
|
57
|
+
// tracking per-render usage.
|
|
58
|
+
const _parsePartialFile = engine._parsePartialFile.bind(engine)
|
|
59
|
+
engine._parsePartialFile = function (file, sync, currentFile) {
|
|
60
|
+
trackPartial(file)
|
|
61
|
+
return _parsePartialFile(file, sync, currentFile)
|
|
62
|
+
}
|
|
63
|
+
const _parseLayoutFile = engine._parseLayoutFile.bind(engine)
|
|
64
|
+
engine._parseLayoutFile = function (file, sync, currentFile) {
|
|
65
|
+
trackPartial(file)
|
|
66
|
+
return _parseLayoutFile(file, sync, currentFile)
|
|
67
|
+
}
|
|
19
68
|
}
|
|
20
69
|
runtime.liquid = (source, data) => engine.parseAndRender(source, data)
|
|
21
70
|
}
|
|
22
71
|
|
|
23
|
-
export async function render({ entity, runtime }) {
|
|
24
|
-
// Expose every function on runtime as a Liquid filter,
|
|
25
|
-
//
|
|
72
|
+
export async function render({ entity, runtime, state, track }) {
|
|
73
|
+
// Expose every function on runtime as a Liquid filter, so render-
|
|
74
|
+
// helper plugins (markdown, href, ...) keep working without per-
|
|
75
|
+
// plugin glue.
|
|
26
76
|
for (let key in runtime) {
|
|
27
77
|
if (typeof runtime[key] === 'function') {
|
|
28
78
|
engine.registerFilter(key, (input, ...args) => runtime[key](input, ...args))
|
|
29
79
|
}
|
|
30
80
|
}
|
|
31
81
|
const source = entity.layout.content ?? ''
|
|
82
|
+
const layouts = state?.layouts?.layouts ?? {}
|
|
32
83
|
try {
|
|
33
|
-
|
|
84
|
+
// Establish the render context BEFORE parsing/rendering. Any
|
|
85
|
+
// internal partial or layout resolution inherits it through
|
|
86
|
+
// the async chain and reports the resolved name to the
|
|
87
|
+
// engine's track via the wrapped methods above.
|
|
88
|
+
return await renderContext.run(
|
|
89
|
+
{ track, layouts },
|
|
90
|
+
() => runtime.liquid(source, runtime),
|
|
91
|
+
)
|
|
34
92
|
} catch (err) {
|
|
35
93
|
// LiquidJS RenderError/ParseError carry a `token` with file/line/col.
|
|
36
94
|
const token = err?.token
|
|
@@ -40,3 +98,145 @@ export async function render({ entity, runtime }) {
|
|
|
40
98
|
throw err
|
|
41
99
|
}
|
|
42
100
|
}
|
|
101
|
+
|
|
102
|
+
// Static reference scan for `mikser-io-layouts`'s inspect() primitive.
|
|
103
|
+
// Uses LiquidJS's own parser to walk the template AST. Returns the
|
|
104
|
+
// variables, partials, and iterations the source mentions — the
|
|
105
|
+
// authoring-time view of "what could this template reference."
|
|
106
|
+
// The runtime-precise answer ("what did each render actually touch")
|
|
107
|
+
// lives in mikser-io's manifest refClosure.
|
|
108
|
+
//
|
|
109
|
+
// AST nodes we care about:
|
|
110
|
+
// - Output → output expression, e.g. {{ post.title | filter }}
|
|
111
|
+
// - IncludeTag → {% include 'name' %} — partial reference
|
|
112
|
+
// - RenderTag → {% render 'name' %} — partial reference
|
|
113
|
+
// - LayoutTag → {% layout 'name' %} — partial reference (the
|
|
114
|
+
// parent the current template extends)
|
|
115
|
+
// - ForTag → {% for x in y %} — iteration
|
|
116
|
+
// - IfTag → recurse into branches for variable refs
|
|
117
|
+
// - UnlessTag → same
|
|
118
|
+
// - CaseTag → same
|
|
119
|
+
//
|
|
120
|
+
// Other tag types are walked-through (their nested templates are
|
|
121
|
+
// scanned) but don't produce a top-level entry.
|
|
122
|
+
const LIQUID_IDENT_PATH = /([a-zA-Z_$][\w$]*(?:\.[a-zA-Z_$][\w$]*)*)/
|
|
123
|
+
export function parseReferences(source) {
|
|
124
|
+
if (typeof source !== 'string' || !source) {
|
|
125
|
+
return { variables: [], partials: [], iterations: [] }
|
|
126
|
+
}
|
|
127
|
+
const probe = new Liquid({})
|
|
128
|
+
let templates
|
|
129
|
+
try {
|
|
130
|
+
templates = probe.parse(source)
|
|
131
|
+
} catch (err) {
|
|
132
|
+
return { variables: [], partials: [], iterations: [], parseError: err.message }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const variables = new Set()
|
|
136
|
+
const partials = new Set()
|
|
137
|
+
const iterations = []
|
|
138
|
+
|
|
139
|
+
function extractPath(expr) {
|
|
140
|
+
const m = LIQUID_IDENT_PATH.exec(String(expr ?? '').trim())
|
|
141
|
+
return m?.[1] ?? null
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Walk a LiquidJS Value (used in {% if %}, {% case %}, etc.) and
|
|
145
|
+
// collect identifier paths. Value has `.initial.postfix[]`; each
|
|
146
|
+
// postfix item with `props` represents one identifier path; props
|
|
147
|
+
// are PropertyAccessToken[] whose `.content` joins to the path.
|
|
148
|
+
function collectValuePaths(value, sink) {
|
|
149
|
+
const postfix = value?.initial?.postfix
|
|
150
|
+
if (!Array.isArray(postfix)) return
|
|
151
|
+
for (const item of postfix) {
|
|
152
|
+
if (!Array.isArray(item?.props) || !item.props.length) continue
|
|
153
|
+
const segments = item.props
|
|
154
|
+
.map(p => typeof p?.content === 'string' ? p.content : null)
|
|
155
|
+
.filter(Boolean)
|
|
156
|
+
if (segments.length) sink.add(segments.join('.'))
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function getText(maybeWrapped) {
|
|
161
|
+
if (maybeWrapped == null) return null
|
|
162
|
+
if (typeof maybeWrapped === 'string') return maybeWrapped
|
|
163
|
+
if (typeof maybeWrapped.getText === 'function') {
|
|
164
|
+
try { return maybeWrapped.getText() } catch { return null }
|
|
165
|
+
}
|
|
166
|
+
return null
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function walk(nodes) {
|
|
170
|
+
if (!Array.isArray(nodes)) return
|
|
171
|
+
for (const node of nodes) {
|
|
172
|
+
const kind = node?.constructor?.name
|
|
173
|
+
switch (kind) {
|
|
174
|
+
case 'Output': {
|
|
175
|
+
// Token content is everything between `{{` and `}}`,
|
|
176
|
+
// including filters. Leading identifier path is the
|
|
177
|
+
// variable ref.
|
|
178
|
+
const content = node.token?.content ?? ''
|
|
179
|
+
const path = extractPath(content)
|
|
180
|
+
if (path) variables.add(path)
|
|
181
|
+
break
|
|
182
|
+
}
|
|
183
|
+
case 'IncludeTag':
|
|
184
|
+
case 'RenderTag':
|
|
185
|
+
case 'LayoutTag': {
|
|
186
|
+
const file = getText(node.file)
|
|
187
|
+
if (file) partials.add(file)
|
|
188
|
+
// Render/include accept a body in some dialects; walk it.
|
|
189
|
+
if (Array.isArray(node.templates)) walk(node.templates)
|
|
190
|
+
break
|
|
191
|
+
}
|
|
192
|
+
case 'ForTag': {
|
|
193
|
+
const collection = getText(node.collection)
|
|
194
|
+
if (collection) {
|
|
195
|
+
const item = node.variable ?? '(for)'
|
|
196
|
+
iterations.push({ item, collection })
|
|
197
|
+
const path = extractPath(collection)
|
|
198
|
+
if (path) variables.add(path)
|
|
199
|
+
}
|
|
200
|
+
if (Array.isArray(node.templates)) walk(node.templates)
|
|
201
|
+
if (Array.isArray(node.elseTemplates)) walk(node.elseTemplates)
|
|
202
|
+
break
|
|
203
|
+
}
|
|
204
|
+
case 'IfTag':
|
|
205
|
+
case 'UnlessTag':
|
|
206
|
+
case 'CaseTag': {
|
|
207
|
+
// Branches live on .branches (each has .templates) and .elseTemplates.
|
|
208
|
+
// Branch condition is a LiquidJS Value whose `.initial.postfix[]`
|
|
209
|
+
// expresses identifier paths; walk them rather than relying on
|
|
210
|
+
// string forms that aren't reliably exposed.
|
|
211
|
+
if (Array.isArray(node.branches)) {
|
|
212
|
+
for (const branch of node.branches) {
|
|
213
|
+
collectValuePaths(branch.value, variables)
|
|
214
|
+
if (Array.isArray(branch.templates)) walk(branch.templates)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (Array.isArray(node.elseTemplates)) walk(node.elseTemplates)
|
|
218
|
+
break
|
|
219
|
+
}
|
|
220
|
+
default: {
|
|
221
|
+
// Generic walk — many tags expose nested .templates.
|
|
222
|
+
if (Array.isArray(node?.templates)) walk(node.templates)
|
|
223
|
+
if (Array.isArray(node?.elseTemplates)) walk(node.elseTemplates)
|
|
224
|
+
break
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
walk(templates)
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
variables: Array.from(variables).sort(),
|
|
234
|
+
partials: Array.from(partials).sort(),
|
|
235
|
+
iterations,
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// v9 factory — ADR-0010.
|
|
240
|
+
export function renderLiquid(options = {}) {
|
|
241
|
+
return { name: options.name ?? 'liquid', options, load, render, parseReferences }
|
|
242
|
+
}
|
package/package.json
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mikser-io-render-liquid",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.0",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "module",
|
|
7
|
-
"scripts": {
|
|
7
|
+
"scripts": {
|
|
8
|
+
"test": "node --no-warnings --test --test-reporter=spec test/**/*.test.js"
|
|
9
|
+
},
|
|
8
10
|
"repository": {
|
|
9
11
|
"type": "git",
|
|
10
12
|
"url": "git+https://github.com/almero-digital-marketing/mikser-io-render-liquid.git"
|
|
@@ -16,7 +18,7 @@
|
|
|
16
18
|
},
|
|
17
19
|
"homepage": "https://github.com/almero-digital-marketing/mikser-io-render-liquid#readme",
|
|
18
20
|
"peerDependencies": {
|
|
19
|
-
"mikser-io": "^
|
|
21
|
+
"mikser-io": "^9.0.0"
|
|
20
22
|
},
|
|
21
23
|
"dependencies": {
|
|
22
24
|
"liquidjs": "^10.27.0"
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { readFile } from 'node:fs/promises'
|
|
4
|
+
import { Liquid } from 'liquidjs'
|
|
5
|
+
|
|
6
|
+
// Why this file exists: every function on `runtime` is registered as a Liquid
|
|
7
|
+
// filter, so WHICH filters exist depends on which render plugins are loaded.
|
|
8
|
+
// With LiquidJS's default strictFilters:false, an unknown filter is a no-op
|
|
9
|
+
// that returns its input — making a missing plugin indistinguishable from a
|
|
10
|
+
// working helper. `{{ '/contacts' | href }}` renders "/contacts" either way.
|
|
11
|
+
describe('strictFilters', () => {
|
|
12
|
+
// The engine options this plugin builds, mirrored so the behavioural
|
|
13
|
+
// claims below are about LiquidJS itself, not about a mock.
|
|
14
|
+
const engine = (over = {}) => new Liquid({ extname: '.liquid', strictFilters: true, ...over })
|
|
15
|
+
|
|
16
|
+
it('an unknown filter throws instead of passing the input through', async () => {
|
|
17
|
+
await assert.rejects(
|
|
18
|
+
() => engine().parseAndRender("{{ '/contacts' | href }}"),
|
|
19
|
+
/undefined filter/i,
|
|
20
|
+
)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('a registered filter still works', async () => {
|
|
24
|
+
const e = engine()
|
|
25
|
+
e.registerFilter('href', (input) => `../${String(input).replace(/^\//, '')}`)
|
|
26
|
+
assert.equal(await e.parseAndRender("{{ '/contacts' | href }}"), '../contacts')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('shows what the old default did — the reason for the change', async () => {
|
|
30
|
+
// Silent pass-through: the string comes back unchanged and nothing
|
|
31
|
+
// anywhere reports that `href` does not exist.
|
|
32
|
+
const loose = new Liquid({ strictFilters: false })
|
|
33
|
+
assert.equal(await loose.parseAndRender("{{ '/contacts' | href }}"), '/contacts')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('strictVariables is NOT enabled — undefined variables stay empty', async () => {
|
|
37
|
+
// Deliberate: templates legitimately rely on this, and turning it on
|
|
38
|
+
// is a far larger change than the filter issue.
|
|
39
|
+
assert.equal(await engine().parseAndRender('[{{ nope }}]'), '[]')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('a project can put the old behaviour back', async () => {
|
|
43
|
+
// `...config` is spread after the defaults in load(), so userland wins.
|
|
44
|
+
const e = engine({ strictFilters: false })
|
|
45
|
+
assert.equal(await e.parseAndRender("{{ '/x' | href }}"), '/x')
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
describe('the plugin sets it', () => {
|
|
50
|
+
it('index.js enables strictFilters, and spreads config afterwards', async () => {
|
|
51
|
+
const src = await readFile(new URL('../index.js', import.meta.url), 'utf8')
|
|
52
|
+
assert.match(src, /strictFilters: true/)
|
|
53
|
+
// Order is load-bearing: config must come last to remain an override.
|
|
54
|
+
assert.ok(src.indexOf('strictFilters: true') < src.indexOf('...config'),
|
|
55
|
+
'config must be spread AFTER the defaults')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('does not enable strictVariables', async () => {
|
|
59
|
+
const src = await readFile(new URL('../index.js', import.meta.url), 'utf8')
|
|
60
|
+
assert.ok(!/strictVariables:\s*true/.test(src))
|
|
61
|
+
})
|
|
62
|
+
})
|