mikser-io-render-liquid 4.0.0 → 4.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/index.js +158 -17
- package/package.json +1 -1
- package/test/references.test.js +116 -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,51 @@ 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: [], optional: [] }
|
|
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: [], optional: [], parseError: err.message }
|
|
133
133
|
}
|
|
134
134
|
|
|
135
135
|
const variables = new Set()
|
|
136
|
-
|
|
136
|
+
// Paths a template only reads behind a guard. `{% if meta.backdrop %}` and
|
|
137
|
+
// everything inside that branch is OPTIONAL by construction — the layout
|
|
138
|
+
// was written to work without it — so reporting such a key as missing from
|
|
139
|
+
// a document says "probably wrong" about something that is fine.
|
|
140
|
+
const optional = new Set()
|
|
141
|
+
// Liquid resolves these on any array or string rather than looking them up
|
|
142
|
+
// on the data: `{% if tags.size > 0 %}` asks how many, not for a key called
|
|
143
|
+
// `size`. Recording them puts engine machinery into a document's contract.
|
|
144
|
+
const LIQUID_PSEUDO = new Set(['size', 'first', 'last'])
|
|
145
|
+
// Keyed by partial name and MERGED across call sites: `ui/btn` rendered
|
|
146
|
+
// eight times with different labels is one partial with the union of what
|
|
147
|
+
// it is ever passed, which is the question a contract answers.
|
|
148
|
+
const partials = new Map()
|
|
137
149
|
const iterations = []
|
|
150
|
+
const assigns = []
|
|
151
|
+
|
|
152
|
+
// The path a tag ARGUMENT refers to, or null if it is a literal.
|
|
153
|
+
//
|
|
154
|
+
// Type, not text: LIQUID_IDENT_PATH is unanchored, so run over the source
|
|
155
|
+
// of `variant: 'secondary'` it happily returns `secondary` — a variable
|
|
156
|
+
// that does not exist, reported as a dependency. A QuotedToken is a value
|
|
157
|
+
// the template supplied and depends on nothing.
|
|
158
|
+
function pathOfToken(token) {
|
|
159
|
+
const kind = token?.constructor?.name
|
|
160
|
+
if (kind === 'PropertyAccessToken') return token.getText?.() ?? null
|
|
161
|
+
// A filtered or otherwise compound argument arrives as a Value, whose
|
|
162
|
+
// identifier paths the branch walker already knows how to read.
|
|
163
|
+
if (token?.initial) {
|
|
164
|
+
const found = new Set()
|
|
165
|
+
collectValuePaths(token, found)
|
|
166
|
+
return found.size ? [...found][0] : null
|
|
167
|
+
}
|
|
168
|
+
return null
|
|
169
|
+
}
|
|
138
170
|
|
|
139
171
|
function extractPath(expr) {
|
|
140
172
|
const m = LIQUID_IDENT_PATH.exec(String(expr ?? '').trim())
|
|
@@ -166,7 +198,32 @@ export function parseReferences(source) {
|
|
|
166
198
|
return null
|
|
167
199
|
}
|
|
168
200
|
|
|
169
|
-
|
|
201
|
+
// An alias resolved through the scopes in view. Only the first segment can
|
|
202
|
+
// be one: `c.specs` where `c` is the item of `for c in r.cases` is
|
|
203
|
+
// `r.cases[].specs`, and the tail is property access on whatever that was.
|
|
204
|
+
function deref(path, scope) {
|
|
205
|
+
if (!path) return path
|
|
206
|
+
const [head, ...rest] = String(path).split('.')
|
|
207
|
+
const base = scope[head]
|
|
208
|
+
return base ? [base, ...rest].join('.') : path
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const record = (path, scope, guarded = false) => {
|
|
212
|
+
let resolved = deref(path, scope)
|
|
213
|
+
if (!resolved) return resolved
|
|
214
|
+
// Trim a trailing pseudo-property: `hero.tags.size` is a question about
|
|
215
|
+
// `hero.tags`, not a key of its own.
|
|
216
|
+
const parts = resolved.split('.')
|
|
217
|
+
if (parts.length > 1 && LIQUID_PSEUDO.has(parts[parts.length - 1])) {
|
|
218
|
+
parts.pop()
|
|
219
|
+
resolved = parts.join('.')
|
|
220
|
+
}
|
|
221
|
+
variables.add(resolved)
|
|
222
|
+
if (guarded) optional.add(resolved)
|
|
223
|
+
return resolved
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function walk(nodes, scope = {}, guarded = false) {
|
|
170
227
|
if (!Array.isArray(nodes)) return
|
|
171
228
|
for (const node of nodes) {
|
|
172
229
|
const kind = node?.constructor?.name
|
|
@@ -177,28 +234,88 @@ export function parseReferences(source) {
|
|
|
177
234
|
// variable ref.
|
|
178
235
|
const content = node.token?.content ?? ''
|
|
179
236
|
const path = extractPath(content)
|
|
180
|
-
if (path)
|
|
237
|
+
if (path) record(path, scope, guarded)
|
|
181
238
|
break
|
|
182
239
|
}
|
|
183
240
|
case 'IncludeTag':
|
|
184
241
|
case 'RenderTag':
|
|
185
242
|
case 'LayoutTag': {
|
|
186
243
|
const file = getText(node.file)
|
|
187
|
-
if (file)
|
|
244
|
+
if (file) {
|
|
245
|
+
// `include` shares the CALLER's scope; `render` does
|
|
246
|
+
// not. Liquid draws that line deliberately, and a
|
|
247
|
+
// contract that ignores it resolves nothing inside an
|
|
248
|
+
// included partial: the section registry reads
|
|
249
|
+
// `section`, which only means anything because the
|
|
250
|
+
// `for` loop that included it is still in view.
|
|
251
|
+
const inherits = kind !== 'RenderTag'
|
|
252
|
+
const entry = partials.get(file) ?? { name: file, args: {}, aliases: [], inherits, scope: {} }
|
|
253
|
+
// The scope an inherited partial was included IN, which
|
|
254
|
+
// only the parser can see. `{% include 'sections/_registry' %}`
|
|
255
|
+
// inside `{% for section in meta.sections %}` reads
|
|
256
|
+
// `section`, and that name means nothing without the
|
|
257
|
+
// loop it came from. Merged across call sites, because a
|
|
258
|
+
// partial included twice is one contract.
|
|
259
|
+
if (inherits) Object.assign(entry.scope, scope)
|
|
260
|
+
// The arguments a partial is called WITH. Dropping
|
|
261
|
+
// these was the hole: `{% render 'ui/btn', label:
|
|
262
|
+
// r.more %}` makes this template depend on `r.more`,
|
|
263
|
+
// and nothing recorded that — so a contract built from
|
|
264
|
+
// one file could not see a key consumed one file down.
|
|
265
|
+
for (const [name, token] of Object.entries(node.hash?.hash ?? {})) {
|
|
266
|
+
const path = pathOfToken(token)
|
|
267
|
+
if (!path) continue
|
|
268
|
+
// Resolved HERE, in the scope the call sits in,
|
|
269
|
+
// before the partial ever runs — so a partial
|
|
270
|
+
// rendered inside a loop reports what the loop
|
|
271
|
+
// hands it, not the loop variable's local name.
|
|
272
|
+
entry.args[name] = record(path, scope, guarded)
|
|
273
|
+
}
|
|
274
|
+
// `{% render 'x' with item as t %}` — the same binding
|
|
275
|
+
// written positionally.
|
|
276
|
+
const withPath = node.with ? pathOfToken(node.with.value) : null
|
|
277
|
+
if (withPath) {
|
|
278
|
+
entry.aliases.push({ from: record(withPath, scope, guarded), to: node.with.alias ?? null })
|
|
279
|
+
}
|
|
280
|
+
partials.set(file, entry)
|
|
281
|
+
}
|
|
188
282
|
// Render/include accept a body in some dialects; walk it.
|
|
189
|
-
if (Array.isArray(node.templates)) walk(node.templates)
|
|
283
|
+
if (Array.isArray(node.templates)) walk(node.templates, scope, guarded)
|
|
284
|
+
break
|
|
285
|
+
}
|
|
286
|
+
case 'AssignTag': {
|
|
287
|
+
// `{% assign hero = data.meta.hero %}` renames a path. A
|
|
288
|
+
// contract that reports `hero.tags` names a variable local
|
|
289
|
+
// to one file; resolving the alias reports
|
|
290
|
+
// `data.meta.hero.tags`, which is what the AUTHOR writes.
|
|
291
|
+
const found = new Set()
|
|
292
|
+
collectValuePaths(node.value, found)
|
|
293
|
+
const raw = [...found][0] ?? null
|
|
294
|
+
const from = raw ? record(raw, scope, guarded) : null
|
|
295
|
+
if (node.key) {
|
|
296
|
+
assigns.push({ key: node.key, from })
|
|
297
|
+
// Bound for the REST of this template, which is what
|
|
298
|
+
// `assign` means — everything after it sees the alias.
|
|
299
|
+
if (from) scope[node.key] = from
|
|
300
|
+
}
|
|
190
301
|
break
|
|
191
302
|
}
|
|
192
303
|
case 'ForTag': {
|
|
193
304
|
const collection = getText(node.collection)
|
|
305
|
+
// `[]` marks an element rather than the collection itself.
|
|
306
|
+
// Without it `for c in r.cases` reports `r.cases.specs`,
|
|
307
|
+
// which is not a key anyone can write — the specs are on
|
|
308
|
+
// each case, not on the list.
|
|
309
|
+
const inner = { ...scope }
|
|
194
310
|
if (collection) {
|
|
195
311
|
const item = node.variable ?? '(for)'
|
|
196
312
|
iterations.push({ item, collection })
|
|
197
313
|
const path = extractPath(collection)
|
|
198
|
-
|
|
314
|
+
const resolved = path ? record(path, scope, guarded) : null
|
|
315
|
+
if (resolved && node.variable) inner[node.variable] = `${resolved}[]`
|
|
199
316
|
}
|
|
200
|
-
if (Array.isArray(node.templates)) walk(node.templates)
|
|
201
|
-
if (Array.isArray(node.elseTemplates)) walk(node.elseTemplates)
|
|
317
|
+
if (Array.isArray(node.templates)) walk(node.templates, inner, guarded)
|
|
318
|
+
if (Array.isArray(node.elseTemplates)) walk(node.elseTemplates, inner, guarded)
|
|
202
319
|
break
|
|
203
320
|
}
|
|
204
321
|
case 'IfTag':
|
|
@@ -208,19 +325,38 @@ export function parseReferences(source) {
|
|
|
208
325
|
// Branch condition is a LiquidJS Value whose `.initial.postfix[]`
|
|
209
326
|
// expresses identifier paths; walk them rather than relying on
|
|
210
327
|
// string forms that aren't reliably exposed.
|
|
328
|
+
// A `case` dispatches on a value the document supplies and
|
|
329
|
+
// its branches are alternatives, not guards; `if`/`unless`
|
|
330
|
+
// are what make the content inside them optional.
|
|
331
|
+
const guards = kind !== 'CaseTag'
|
|
332
|
+
// A `case` reads its SUBJECT unconditionally — that is the
|
|
333
|
+
// value the document supplies to choose a branch. The
|
|
334
|
+
// branches hold the `when` literals, which depend on
|
|
335
|
+
// nothing, so reading only those recorded the dispatch as
|
|
336
|
+
// consuming no keys at all.
|
|
337
|
+
if (kind === 'CaseTag' && node.value) {
|
|
338
|
+
const subject = new Set()
|
|
339
|
+
collectValuePaths(node.value, subject)
|
|
340
|
+
for (const f of subject) record(f, scope)
|
|
341
|
+
}
|
|
211
342
|
if (Array.isArray(node.branches)) {
|
|
212
343
|
for (const branch of node.branches) {
|
|
213
|
-
|
|
214
|
-
|
|
344
|
+
const found = new Set()
|
|
345
|
+
collectValuePaths(branch.value, found)
|
|
346
|
+
// The condition itself is read unconditionally —
|
|
347
|
+
// the template always asks — but a document is not
|
|
348
|
+
// wrong for answering no.
|
|
349
|
+
for (const f of found) record(f, scope, guards)
|
|
350
|
+
if (Array.isArray(branch.templates)) walk(branch.templates, scope, guarded || guards)
|
|
215
351
|
}
|
|
216
352
|
}
|
|
217
|
-
if (Array.isArray(node.elseTemplates)) walk(node.elseTemplates)
|
|
353
|
+
if (Array.isArray(node.elseTemplates)) walk(node.elseTemplates, scope, guarded || guards)
|
|
218
354
|
break
|
|
219
355
|
}
|
|
220
356
|
default: {
|
|
221
357
|
// 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)
|
|
358
|
+
if (Array.isArray(node?.templates)) walk(node.templates, scope, guarded)
|
|
359
|
+
if (Array.isArray(node?.elseTemplates)) walk(node.elseTemplates, scope, guarded)
|
|
224
360
|
break
|
|
225
361
|
}
|
|
226
362
|
}
|
|
@@ -231,8 +367,13 @@ export function parseReferences(source) {
|
|
|
231
367
|
|
|
232
368
|
return {
|
|
233
369
|
variables: Array.from(variables).sort(),
|
|
234
|
-
partials: Array.from(partials).sort(),
|
|
370
|
+
partials: Array.from(partials.values()).sort((a, b) => a.name.localeCompare(b.name)),
|
|
235
371
|
iterations,
|
|
372
|
+
assigns,
|
|
373
|
+
// Read only behind a guard. Reported apart rather than dropped: a
|
|
374
|
+
// consumer deciding whether a document is WRONG needs these excluded,
|
|
375
|
+
// and a consumer asking what a layout can use needs them present.
|
|
376
|
+
optional: Array.from(optional).sort(),
|
|
236
377
|
}
|
|
237
378
|
}
|
|
238
379
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,116 @@
|
|
|
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
|
+
// `inherits` records that liquid's include shares the caller's scope,
|
|
60
|
+
// and `scope` carries what was in view at the call site.
|
|
61
|
+
assert.deepEqual(r.partials,
|
|
62
|
+
[{ name: 'chrome/nav', args: {}, aliases: [], inherits: true, scope: {} }])
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
describe('liquid parseReferences: aliases', () => {
|
|
67
|
+
it('resolves an assign back to the path it renames', () => {
|
|
68
|
+
const r = parseReferences('{% assign hero = data.meta.hero %}{{ hero.title }}')
|
|
69
|
+
assert.ok(r.variables.includes('data.meta.hero.title'),
|
|
70
|
+
`expected the resolved path, got: ${r.variables.join(', ')}`)
|
|
71
|
+
assert.ok(!r.variables.includes('hero.title'), 'the local name must not survive')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('reports the assign itself, so a caller can see the renaming', () => {
|
|
75
|
+
const r = parseReferences('{% assign hero = data.meta.hero %}')
|
|
76
|
+
assert.deepEqual(r.assigns, [{ key: 'hero', from: 'data.meta.hero' }])
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('resolves an assign written in terms of another assign', () => {
|
|
80
|
+
const r = parseReferences(
|
|
81
|
+
'{% assign hero = data.meta.hero %}{% assign o = hero.origin %}{{ o.label }}')
|
|
82
|
+
assert.ok(r.variables.includes('data.meta.hero.origin.label'))
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('marks a for-item as an ELEMENT, not as the collection', () => {
|
|
86
|
+
// `cases.specs` would be a key that exists on no document — the specs
|
|
87
|
+
// are on each case, not on the list of them.
|
|
88
|
+
const r = parseReferences('{% for c in data.meta.cases %}{{ c.specs }}{% endfor %}')
|
|
89
|
+
assert.ok(r.variables.includes('data.meta.cases[].specs'))
|
|
90
|
+
assert.ok(!r.variables.includes('data.meta.cases.specs'))
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('resolves arguments through the loop they are written in', () => {
|
|
94
|
+
const r = parseReferences(
|
|
95
|
+
"{% for c in data.meta.cases %}{% render 'ui/tag', label: c.title %}{% endfor %}")
|
|
96
|
+
assert.deepEqual(argsOf(r, 'ui/tag'), { label: 'data.meta.cases[].title' })
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('does NOT leak a loop item past the end of its loop', () => {
|
|
100
|
+
const r = parseReferences('{% for c in data.meta.cases %}{{ c.a }}{% endfor %}{{ c.b }}')
|
|
101
|
+
assert.ok(r.variables.includes('data.meta.cases[].a'))
|
|
102
|
+
assert.ok(r.variables.includes('c.b'), 'outside the loop it is a different, unresolved name')
|
|
103
|
+
assert.ok(!r.variables.includes('data.meta.cases[].b'))
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
describe('liquid parseReferences: shape', () => {
|
|
108
|
+
it('returns the same keys as every other engine, so no caller branches', () => {
|
|
109
|
+
for (const source of ['', '{{ a }}', '{% if %}']) {
|
|
110
|
+
const r = parseReferences(source)
|
|
111
|
+
for (const key of ['variables', 'partials', 'iterations', 'assigns']) {
|
|
112
|
+
assert.ok(key in r, `${JSON.stringify(source)} is missing ${key}`)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
})
|
|
116
|
+
})
|