mikser-io-render-liquid 4.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 +105 -17
- package/package.json +1 -1
- package/test/references.test.js +113 -0
package/index.js
CHANGED
|
@@ -35,7 +35,7 @@ export function load({ runtime, options, config }) {
|
|
|
35
35
|
// That is a bad default HERE specifically, because which
|
|
36
36
|
// filters exist depends on which render plugins are loaded:
|
|
37
37
|
// every function on `runtime` is registered as one below. So
|
|
38
|
-
// `{{ '/contacts' | href }}` with
|
|
38
|
+
// `{{ '/contacts' | href }}` with hrefUrlHelpers() missing from the
|
|
39
39
|
// plugin list renders the string back, and a missing plugin is
|
|
40
40
|
// indistinguishable from a working helper.
|
|
41
41
|
//
|
|
@@ -122,19 +122,42 @@ export async function render({ entity, runtime, state, track }) {
|
|
|
122
122
|
const LIQUID_IDENT_PATH = /([a-zA-Z_$][\w$]*(?:\.[a-zA-Z_$][\w$]*)*)/
|
|
123
123
|
export function parseReferences(source) {
|
|
124
124
|
if (typeof source !== 'string' || !source) {
|
|
125
|
-
return { variables: [], partials: [], iterations: [] }
|
|
125
|
+
return { variables: [], partials: [], iterations: [], assigns: [] }
|
|
126
126
|
}
|
|
127
127
|
const probe = new Liquid({})
|
|
128
128
|
let templates
|
|
129
129
|
try {
|
|
130
130
|
templates = probe.parse(source)
|
|
131
131
|
} catch (err) {
|
|
132
|
-
return { variables: [], partials: [], iterations: [], parseError: err.message }
|
|
132
|
+
return { variables: [], partials: [], iterations: [], assigns: [], parseError: err.message }
|
|
133
133
|
}
|
|
134
134
|
|
|
135
135
|
const variables = new Set()
|
|
136
|
-
|
|
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()
|
|
137
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
|
+
}
|
|
138
161
|
|
|
139
162
|
function extractPath(expr) {
|
|
140
163
|
const m = LIQUID_IDENT_PATH.exec(String(expr ?? '').trim())
|
|
@@ -166,7 +189,23 @@ export function parseReferences(source) {
|
|
|
166
189
|
return null
|
|
167
190
|
}
|
|
168
191
|
|
|
169
|
-
|
|
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 = {}) {
|
|
170
209
|
if (!Array.isArray(nodes)) return
|
|
171
210
|
for (const node of nodes) {
|
|
172
211
|
const kind = node?.constructor?.name
|
|
@@ -177,28 +216,74 @@ export function parseReferences(source) {
|
|
|
177
216
|
// variable ref.
|
|
178
217
|
const content = node.token?.content ?? ''
|
|
179
218
|
const path = extractPath(content)
|
|
180
|
-
if (path)
|
|
219
|
+
if (path) record(path, scope)
|
|
181
220
|
break
|
|
182
221
|
}
|
|
183
222
|
case 'IncludeTag':
|
|
184
223
|
case 'RenderTag':
|
|
185
224
|
case 'LayoutTag': {
|
|
186
225
|
const file = getText(node.file)
|
|
187
|
-
if (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
|
+
}
|
|
188
250
|
// Render/include accept a body in some dialects; walk it.
|
|
189
|
-
if (Array.isArray(node.templates)) walk(node.templates)
|
|
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
|
+
}
|
|
190
269
|
break
|
|
191
270
|
}
|
|
192
271
|
case 'ForTag': {
|
|
193
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 }
|
|
194
278
|
if (collection) {
|
|
195
279
|
const item = node.variable ?? '(for)'
|
|
196
280
|
iterations.push({ item, collection })
|
|
197
281
|
const path = extractPath(collection)
|
|
198
|
-
|
|
282
|
+
const resolved = path ? record(path, scope) : null
|
|
283
|
+
if (resolved && node.variable) inner[node.variable] = `${resolved}[]`
|
|
199
284
|
}
|
|
200
|
-
if (Array.isArray(node.templates)) walk(node.templates)
|
|
201
|
-
if (Array.isArray(node.elseTemplates)) walk(node.elseTemplates)
|
|
285
|
+
if (Array.isArray(node.templates)) walk(node.templates, inner)
|
|
286
|
+
if (Array.isArray(node.elseTemplates)) walk(node.elseTemplates, inner)
|
|
202
287
|
break
|
|
203
288
|
}
|
|
204
289
|
case 'IfTag':
|
|
@@ -210,17 +295,19 @@ export function parseReferences(source) {
|
|
|
210
295
|
// string forms that aren't reliably exposed.
|
|
211
296
|
if (Array.isArray(node.branches)) {
|
|
212
297
|
for (const branch of node.branches) {
|
|
213
|
-
|
|
214
|
-
|
|
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)
|
|
215
302
|
}
|
|
216
303
|
}
|
|
217
|
-
if (Array.isArray(node.elseTemplates)) walk(node.elseTemplates)
|
|
304
|
+
if (Array.isArray(node.elseTemplates)) walk(node.elseTemplates, scope)
|
|
218
305
|
break
|
|
219
306
|
}
|
|
220
307
|
default: {
|
|
221
308
|
// 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)
|
|
309
|
+
if (Array.isArray(node?.templates)) walk(node.templates, scope)
|
|
310
|
+
if (Array.isArray(node?.elseTemplates)) walk(node.elseTemplates, scope)
|
|
224
311
|
break
|
|
225
312
|
}
|
|
226
313
|
}
|
|
@@ -231,8 +318,9 @@ export function parseReferences(source) {
|
|
|
231
318
|
|
|
232
319
|
return {
|
|
233
320
|
variables: Array.from(variables).sort(),
|
|
234
|
-
partials: Array.from(partials).sort(),
|
|
321
|
+
partials: Array.from(partials.values()).sort((a, b) => a.name.localeCompare(b.name)),
|
|
235
322
|
iterations,
|
|
323
|
+
assigns,
|
|
236
324
|
}
|
|
237
325
|
}
|
|
238
326
|
|
package/package.json
CHANGED
|
@@ -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
|
+
})
|