mikser-io-render-liquid 3.0.0 → 4.1.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 +240 -1
- package/package.json +4 -2
- package/test/references.test.js +113 -0
- package/test/strict-filters.test.js +62 -0
package/index.js
CHANGED
|
@@ -30,6 +30,20 @@ export function load({ runtime, options, config }) {
|
|
|
30
30
|
root: options.layoutsFolder,
|
|
31
31
|
extname: '.liquid',
|
|
32
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 hrefUrlHelpers() 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.
|
|
33
47
|
...config,
|
|
34
48
|
})
|
|
35
49
|
|
|
@@ -85,7 +99,232 @@ export async function render({ entity, runtime, state, track }) {
|
|
|
85
99
|
}
|
|
86
100
|
}
|
|
87
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: [], assigns: [] }
|
|
126
|
+
}
|
|
127
|
+
const probe = new Liquid({})
|
|
128
|
+
let templates
|
|
129
|
+
try {
|
|
130
|
+
templates = probe.parse(source)
|
|
131
|
+
} catch (err) {
|
|
132
|
+
return { variables: [], partials: [], iterations: [], assigns: [], parseError: err.message }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const variables = new Set()
|
|
136
|
+
// Keyed by partial name and MERGED across call sites: `ui/btn` rendered
|
|
137
|
+
// eight times with different labels is one partial with the union of what
|
|
138
|
+
// it is ever passed, which is the question a contract answers.
|
|
139
|
+
const partials = new Map()
|
|
140
|
+
const iterations = []
|
|
141
|
+
const assigns = []
|
|
142
|
+
|
|
143
|
+
// The path a tag ARGUMENT refers to, or null if it is a literal.
|
|
144
|
+
//
|
|
145
|
+
// Type, not text: LIQUID_IDENT_PATH is unanchored, so run over the source
|
|
146
|
+
// of `variant: 'secondary'` it happily returns `secondary` — a variable
|
|
147
|
+
// that does not exist, reported as a dependency. A QuotedToken is a value
|
|
148
|
+
// the template supplied and depends on nothing.
|
|
149
|
+
function pathOfToken(token) {
|
|
150
|
+
const kind = token?.constructor?.name
|
|
151
|
+
if (kind === 'PropertyAccessToken') return token.getText?.() ?? null
|
|
152
|
+
// A filtered or otherwise compound argument arrives as a Value, whose
|
|
153
|
+
// identifier paths the branch walker already knows how to read.
|
|
154
|
+
if (token?.initial) {
|
|
155
|
+
const found = new Set()
|
|
156
|
+
collectValuePaths(token, found)
|
|
157
|
+
return found.size ? [...found][0] : null
|
|
158
|
+
}
|
|
159
|
+
return null
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function extractPath(expr) {
|
|
163
|
+
const m = LIQUID_IDENT_PATH.exec(String(expr ?? '').trim())
|
|
164
|
+
return m?.[1] ?? null
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Walk a LiquidJS Value (used in {% if %}, {% case %}, etc.) and
|
|
168
|
+
// collect identifier paths. Value has `.initial.postfix[]`; each
|
|
169
|
+
// postfix item with `props` represents one identifier path; props
|
|
170
|
+
// are PropertyAccessToken[] whose `.content` joins to the path.
|
|
171
|
+
function collectValuePaths(value, sink) {
|
|
172
|
+
const postfix = value?.initial?.postfix
|
|
173
|
+
if (!Array.isArray(postfix)) return
|
|
174
|
+
for (const item of postfix) {
|
|
175
|
+
if (!Array.isArray(item?.props) || !item.props.length) continue
|
|
176
|
+
const segments = item.props
|
|
177
|
+
.map(p => typeof p?.content === 'string' ? p.content : null)
|
|
178
|
+
.filter(Boolean)
|
|
179
|
+
if (segments.length) sink.add(segments.join('.'))
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function getText(maybeWrapped) {
|
|
184
|
+
if (maybeWrapped == null) return null
|
|
185
|
+
if (typeof maybeWrapped === 'string') return maybeWrapped
|
|
186
|
+
if (typeof maybeWrapped.getText === 'function') {
|
|
187
|
+
try { return maybeWrapped.getText() } catch { return null }
|
|
188
|
+
}
|
|
189
|
+
return null
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// An alias resolved through the scopes in view. Only the first segment can
|
|
193
|
+
// be one: `c.specs` where `c` is the item of `for c in r.cases` is
|
|
194
|
+
// `r.cases[].specs`, and the tail is property access on whatever that was.
|
|
195
|
+
function deref(path, scope) {
|
|
196
|
+
if (!path) return path
|
|
197
|
+
const [head, ...rest] = String(path).split('.')
|
|
198
|
+
const base = scope[head]
|
|
199
|
+
return base ? [base, ...rest].join('.') : path
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const record = (path, scope) => {
|
|
203
|
+
const resolved = deref(path, scope)
|
|
204
|
+
if (resolved) variables.add(resolved)
|
|
205
|
+
return resolved
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function walk(nodes, scope = {}) {
|
|
209
|
+
if (!Array.isArray(nodes)) return
|
|
210
|
+
for (const node of nodes) {
|
|
211
|
+
const kind = node?.constructor?.name
|
|
212
|
+
switch (kind) {
|
|
213
|
+
case 'Output': {
|
|
214
|
+
// Token content is everything between `{{` and `}}`,
|
|
215
|
+
// including filters. Leading identifier path is the
|
|
216
|
+
// variable ref.
|
|
217
|
+
const content = node.token?.content ?? ''
|
|
218
|
+
const path = extractPath(content)
|
|
219
|
+
if (path) record(path, scope)
|
|
220
|
+
break
|
|
221
|
+
}
|
|
222
|
+
case 'IncludeTag':
|
|
223
|
+
case 'RenderTag':
|
|
224
|
+
case 'LayoutTag': {
|
|
225
|
+
const file = getText(node.file)
|
|
226
|
+
if (file) {
|
|
227
|
+
const entry = partials.get(file) ?? { name: file, args: {}, aliases: [] }
|
|
228
|
+
// The arguments a partial is called WITH. Dropping
|
|
229
|
+
// these was the hole: `{% render 'ui/btn', label:
|
|
230
|
+
// r.more %}` makes this template depend on `r.more`,
|
|
231
|
+
// and nothing recorded that — so a contract built from
|
|
232
|
+
// one file could not see a key consumed one file down.
|
|
233
|
+
for (const [name, token] of Object.entries(node.hash?.hash ?? {})) {
|
|
234
|
+
const path = pathOfToken(token)
|
|
235
|
+
if (!path) continue
|
|
236
|
+
// Resolved HERE, in the scope the call sits in,
|
|
237
|
+
// before the partial ever runs — so a partial
|
|
238
|
+
// rendered inside a loop reports what the loop
|
|
239
|
+
// hands it, not the loop variable's local name.
|
|
240
|
+
entry.args[name] = record(path, scope)
|
|
241
|
+
}
|
|
242
|
+
// `{% render 'x' with item as t %}` — the same binding
|
|
243
|
+
// written positionally.
|
|
244
|
+
const withPath = node.with ? pathOfToken(node.with.value) : null
|
|
245
|
+
if (withPath) {
|
|
246
|
+
entry.aliases.push({ from: record(withPath, scope), to: node.with.alias ?? null })
|
|
247
|
+
}
|
|
248
|
+
partials.set(file, entry)
|
|
249
|
+
}
|
|
250
|
+
// Render/include accept a body in some dialects; walk it.
|
|
251
|
+
if (Array.isArray(node.templates)) walk(node.templates, scope)
|
|
252
|
+
break
|
|
253
|
+
}
|
|
254
|
+
case 'AssignTag': {
|
|
255
|
+
// `{% assign hero = data.meta.hero %}` renames a path. A
|
|
256
|
+
// contract that reports `hero.tags` names a variable local
|
|
257
|
+
// to one file; resolving the alias reports
|
|
258
|
+
// `data.meta.hero.tags`, which is what the AUTHOR writes.
|
|
259
|
+
const found = new Set()
|
|
260
|
+
collectValuePaths(node.value, found)
|
|
261
|
+
const raw = [...found][0] ?? null
|
|
262
|
+
const from = raw ? record(raw, scope) : null
|
|
263
|
+
if (node.key) {
|
|
264
|
+
assigns.push({ key: node.key, from })
|
|
265
|
+
// Bound for the REST of this template, which is what
|
|
266
|
+
// `assign` means — everything after it sees the alias.
|
|
267
|
+
if (from) scope[node.key] = from
|
|
268
|
+
}
|
|
269
|
+
break
|
|
270
|
+
}
|
|
271
|
+
case 'ForTag': {
|
|
272
|
+
const collection = getText(node.collection)
|
|
273
|
+
// `[]` marks an element rather than the collection itself.
|
|
274
|
+
// Without it `for c in r.cases` reports `r.cases.specs`,
|
|
275
|
+
// which is not a key anyone can write — the specs are on
|
|
276
|
+
// each case, not on the list.
|
|
277
|
+
const inner = { ...scope }
|
|
278
|
+
if (collection) {
|
|
279
|
+
const item = node.variable ?? '(for)'
|
|
280
|
+
iterations.push({ item, collection })
|
|
281
|
+
const path = extractPath(collection)
|
|
282
|
+
const resolved = path ? record(path, scope) : null
|
|
283
|
+
if (resolved && node.variable) inner[node.variable] = `${resolved}[]`
|
|
284
|
+
}
|
|
285
|
+
if (Array.isArray(node.templates)) walk(node.templates, inner)
|
|
286
|
+
if (Array.isArray(node.elseTemplates)) walk(node.elseTemplates, inner)
|
|
287
|
+
break
|
|
288
|
+
}
|
|
289
|
+
case 'IfTag':
|
|
290
|
+
case 'UnlessTag':
|
|
291
|
+
case 'CaseTag': {
|
|
292
|
+
// Branches live on .branches (each has .templates) and .elseTemplates.
|
|
293
|
+
// Branch condition is a LiquidJS Value whose `.initial.postfix[]`
|
|
294
|
+
// expresses identifier paths; walk them rather than relying on
|
|
295
|
+
// string forms that aren't reliably exposed.
|
|
296
|
+
if (Array.isArray(node.branches)) {
|
|
297
|
+
for (const branch of node.branches) {
|
|
298
|
+
const found = new Set()
|
|
299
|
+
collectValuePaths(branch.value, found)
|
|
300
|
+
for (const f of found) record(f, scope)
|
|
301
|
+
if (Array.isArray(branch.templates)) walk(branch.templates, scope)
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if (Array.isArray(node.elseTemplates)) walk(node.elseTemplates, scope)
|
|
305
|
+
break
|
|
306
|
+
}
|
|
307
|
+
default: {
|
|
308
|
+
// Generic walk — many tags expose nested .templates.
|
|
309
|
+
if (Array.isArray(node?.templates)) walk(node.templates, scope)
|
|
310
|
+
if (Array.isArray(node?.elseTemplates)) walk(node.elseTemplates, scope)
|
|
311
|
+
break
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
walk(templates)
|
|
318
|
+
|
|
319
|
+
return {
|
|
320
|
+
variables: Array.from(variables).sort(),
|
|
321
|
+
partials: Array.from(partials.values()).sort((a, b) => a.name.localeCompare(b.name)),
|
|
322
|
+
iterations,
|
|
323
|
+
assigns,
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
88
327
|
// v9 factory — ADR-0010.
|
|
89
328
|
export function renderLiquid(options = {}) {
|
|
90
|
-
return { name: options.name ?? 'liquid', options, load, render }
|
|
329
|
+
return { name: options.name ?? 'liquid', options, load, render, parseReferences }
|
|
91
330
|
}
|
package/package.json
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mikser-io-render-liquid",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.1.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"
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// What a liquid template DEPENDS ON — the raw material for the layout contract
|
|
2
|
+
// that mikser_layouts_inspect reports.
|
|
3
|
+
//
|
|
4
|
+
// Two things used to be dropped, and both broke the contract exactly where it
|
|
5
|
+
// becomes useful, at the boundary between one file and the next:
|
|
6
|
+
//
|
|
7
|
+
// - the ARGUMENTS a partial is called with. `{% render 'ui/tags', tags:
|
|
8
|
+
// hero.tags %}` makes this template depend on `hero.tags`; recording only
|
|
9
|
+
// the partial's NAME left a key consumed one file down invisible.
|
|
10
|
+
// - aliases. A section that opens `{% assign hero = data.meta.hero %}` and
|
|
11
|
+
// then says `hero.tags` names a key that appears in no document.
|
|
12
|
+
//
|
|
13
|
+
// Aliases are resolved HERE because this is the only place their scope is
|
|
14
|
+
// known: the closure walker downstream sees paths, not tags.
|
|
15
|
+
|
|
16
|
+
import { describe, it } from 'node:test'
|
|
17
|
+
import assert from 'node:assert/strict'
|
|
18
|
+
import { parseReferences } from '../index.js'
|
|
19
|
+
|
|
20
|
+
const argsOf = (r, name) => r.partials.find(p => p.name === name)?.args ?? null
|
|
21
|
+
|
|
22
|
+
describe('liquid parseReferences: partial arguments', () => {
|
|
23
|
+
it('records the arguments a partial is called with', () => {
|
|
24
|
+
const r = parseReferences("{% render 'ui/tags', tags: data.meta.hero.tags, rows: data.meta.hero.tagRows %}")
|
|
25
|
+
assert.deepEqual(argsOf(r, 'ui/tags'), {
|
|
26
|
+
tags: 'data.meta.hero.tags',
|
|
27
|
+
rows: 'data.meta.hero.tagRows',
|
|
28
|
+
})
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('counts them as references of the CALLING template', () => {
|
|
32
|
+
const r = parseReferences("{% render 'ui/btn', label: data.meta.cta %}")
|
|
33
|
+
assert.ok(r.variables.includes('data.meta.cta'))
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('ignores literal arguments, which depend on nothing', () => {
|
|
37
|
+
// The path regex is unanchored, so a naive reading of `variant:
|
|
38
|
+
// 'secondary'` yields `secondary` — a variable that does not exist,
|
|
39
|
+
// reported as a dependency.
|
|
40
|
+
const r = parseReferences("{% render 'ui/btn', variant: 'secondary', label: data.meta.cta %}")
|
|
41
|
+
assert.deepEqual(argsOf(r, 'ui/btn'), { label: 'data.meta.cta' })
|
|
42
|
+
assert.ok(!r.variables.includes('secondary'))
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('merges call sites, so one partial is one entry', () => {
|
|
46
|
+
const r = parseReferences("{% render 'ui/btn', label: a.one %}{% render 'ui/btn', href: a.two %}")
|
|
47
|
+
assert.equal(r.partials.length, 1)
|
|
48
|
+
assert.deepEqual(argsOf(r, 'ui/btn'), { label: 'a.one', href: 'a.two' })
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('records `with ... as` as an alias', () => {
|
|
52
|
+
const r = parseReferences("{% render 'ui/card' with data.meta.hero as card %}")
|
|
53
|
+
assert.deepEqual(r.partials.find(p => p.name === 'ui/card').aliases,
|
|
54
|
+
[{ from: 'data.meta.hero', to: 'card' }])
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('reports a plain include with no arguments', () => {
|
|
58
|
+
const r = parseReferences("{% include 'chrome/nav' %}")
|
|
59
|
+
assert.deepEqual(r.partials, [{ name: 'chrome/nav', args: {}, aliases: [] }])
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
describe('liquid parseReferences: aliases', () => {
|
|
64
|
+
it('resolves an assign back to the path it renames', () => {
|
|
65
|
+
const r = parseReferences('{% assign hero = data.meta.hero %}{{ hero.title }}')
|
|
66
|
+
assert.ok(r.variables.includes('data.meta.hero.title'),
|
|
67
|
+
`expected the resolved path, got: ${r.variables.join(', ')}`)
|
|
68
|
+
assert.ok(!r.variables.includes('hero.title'), 'the local name must not survive')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('reports the assign itself, so a caller can see the renaming', () => {
|
|
72
|
+
const r = parseReferences('{% assign hero = data.meta.hero %}')
|
|
73
|
+
assert.deepEqual(r.assigns, [{ key: 'hero', from: 'data.meta.hero' }])
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('resolves an assign written in terms of another assign', () => {
|
|
77
|
+
const r = parseReferences(
|
|
78
|
+
'{% assign hero = data.meta.hero %}{% assign o = hero.origin %}{{ o.label }}')
|
|
79
|
+
assert.ok(r.variables.includes('data.meta.hero.origin.label'))
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('marks a for-item as an ELEMENT, not as the collection', () => {
|
|
83
|
+
// `cases.specs` would be a key that exists on no document — the specs
|
|
84
|
+
// are on each case, not on the list of them.
|
|
85
|
+
const r = parseReferences('{% for c in data.meta.cases %}{{ c.specs }}{% endfor %}')
|
|
86
|
+
assert.ok(r.variables.includes('data.meta.cases[].specs'))
|
|
87
|
+
assert.ok(!r.variables.includes('data.meta.cases.specs'))
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('resolves arguments through the loop they are written in', () => {
|
|
91
|
+
const r = parseReferences(
|
|
92
|
+
"{% for c in data.meta.cases %}{% render 'ui/tag', label: c.title %}{% endfor %}")
|
|
93
|
+
assert.deepEqual(argsOf(r, 'ui/tag'), { label: 'data.meta.cases[].title' })
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('does NOT leak a loop item past the end of its loop', () => {
|
|
97
|
+
const r = parseReferences('{% for c in data.meta.cases %}{{ c.a }}{% endfor %}{{ c.b }}')
|
|
98
|
+
assert.ok(r.variables.includes('data.meta.cases[].a'))
|
|
99
|
+
assert.ok(r.variables.includes('c.b'), 'outside the loop it is a different, unresolved name')
|
|
100
|
+
assert.ok(!r.variables.includes('data.meta.cases[].b'))
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
describe('liquid parseReferences: shape', () => {
|
|
105
|
+
it('returns the same keys as every other engine, so no caller branches', () => {
|
|
106
|
+
for (const source of ['', '{{ a }}', '{% if %}']) {
|
|
107
|
+
const r = parseReferences(source)
|
|
108
|
+
for (const key of ['variables', 'partials', 'iterations', 'assigns']) {
|
|
109
|
+
assert.ok(key in r, `${JSON.stringify(source)} is missing ${key}`)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
})
|
|
@@ -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
|
+
})
|