mikser-io-render-liquid 4.1.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 +73 -20
- package/package.json +1 -1
- package/test/references.test.js +4 -1
package/index.js
CHANGED
|
@@ -122,17 +122,26 @@ 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: [], assigns: [] }
|
|
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: [], assigns: [], parseError: err.message }
|
|
132
|
+
return { variables: [], partials: [], iterations: [], assigns: [], optional: [], parseError: err.message }
|
|
133
133
|
}
|
|
134
134
|
|
|
135
135
|
const variables = new Set()
|
|
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'])
|
|
136
145
|
// Keyed by partial name and MERGED across call sites: `ui/btn` rendered
|
|
137
146
|
// eight times with different labels is one partial with the union of what
|
|
138
147
|
// it is ever passed, which is the question a contract answers.
|
|
@@ -199,13 +208,22 @@ export function parseReferences(source) {
|
|
|
199
208
|
return base ? [base, ...rest].join('.') : path
|
|
200
209
|
}
|
|
201
210
|
|
|
202
|
-
const record = (path, scope) => {
|
|
203
|
-
|
|
204
|
-
if (resolved)
|
|
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)
|
|
205
223
|
return resolved
|
|
206
224
|
}
|
|
207
225
|
|
|
208
|
-
function walk(nodes, scope = {}) {
|
|
226
|
+
function walk(nodes, scope = {}, guarded = false) {
|
|
209
227
|
if (!Array.isArray(nodes)) return
|
|
210
228
|
for (const node of nodes) {
|
|
211
229
|
const kind = node?.constructor?.name
|
|
@@ -216,7 +234,7 @@ export function parseReferences(source) {
|
|
|
216
234
|
// variable ref.
|
|
217
235
|
const content = node.token?.content ?? ''
|
|
218
236
|
const path = extractPath(content)
|
|
219
|
-
if (path) record(path, scope)
|
|
237
|
+
if (path) record(path, scope, guarded)
|
|
220
238
|
break
|
|
221
239
|
}
|
|
222
240
|
case 'IncludeTag':
|
|
@@ -224,7 +242,21 @@ export function parseReferences(source) {
|
|
|
224
242
|
case 'LayoutTag': {
|
|
225
243
|
const file = getText(node.file)
|
|
226
244
|
if (file) {
|
|
227
|
-
|
|
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)
|
|
228
260
|
// The arguments a partial is called WITH. Dropping
|
|
229
261
|
// these was the hole: `{% render 'ui/btn', label:
|
|
230
262
|
// r.more %}` makes this template depend on `r.more`,
|
|
@@ -237,18 +269,18 @@ export function parseReferences(source) {
|
|
|
237
269
|
// before the partial ever runs — so a partial
|
|
238
270
|
// rendered inside a loop reports what the loop
|
|
239
271
|
// hands it, not the loop variable's local name.
|
|
240
|
-
entry.args[name] = record(path, scope)
|
|
272
|
+
entry.args[name] = record(path, scope, guarded)
|
|
241
273
|
}
|
|
242
274
|
// `{% render 'x' with item as t %}` — the same binding
|
|
243
275
|
// written positionally.
|
|
244
276
|
const withPath = node.with ? pathOfToken(node.with.value) : null
|
|
245
277
|
if (withPath) {
|
|
246
|
-
entry.aliases.push({ from: record(withPath, scope), to: node.with.alias ?? null })
|
|
278
|
+
entry.aliases.push({ from: record(withPath, scope, guarded), to: node.with.alias ?? null })
|
|
247
279
|
}
|
|
248
280
|
partials.set(file, entry)
|
|
249
281
|
}
|
|
250
282
|
// Render/include accept a body in some dialects; walk it.
|
|
251
|
-
if (Array.isArray(node.templates)) walk(node.templates, scope)
|
|
283
|
+
if (Array.isArray(node.templates)) walk(node.templates, scope, guarded)
|
|
252
284
|
break
|
|
253
285
|
}
|
|
254
286
|
case 'AssignTag': {
|
|
@@ -259,7 +291,7 @@ export function parseReferences(source) {
|
|
|
259
291
|
const found = new Set()
|
|
260
292
|
collectValuePaths(node.value, found)
|
|
261
293
|
const raw = [...found][0] ?? null
|
|
262
|
-
const from = raw ? record(raw, scope) : null
|
|
294
|
+
const from = raw ? record(raw, scope, guarded) : null
|
|
263
295
|
if (node.key) {
|
|
264
296
|
assigns.push({ key: node.key, from })
|
|
265
297
|
// Bound for the REST of this template, which is what
|
|
@@ -279,11 +311,11 @@ export function parseReferences(source) {
|
|
|
279
311
|
const item = node.variable ?? '(for)'
|
|
280
312
|
iterations.push({ item, collection })
|
|
281
313
|
const path = extractPath(collection)
|
|
282
|
-
const resolved = path ? record(path, scope) : null
|
|
314
|
+
const resolved = path ? record(path, scope, guarded) : null
|
|
283
315
|
if (resolved && node.variable) inner[node.variable] = `${resolved}[]`
|
|
284
316
|
}
|
|
285
|
-
if (Array.isArray(node.templates)) walk(node.templates, inner)
|
|
286
|
-
if (Array.isArray(node.elseTemplates)) walk(node.elseTemplates, inner)
|
|
317
|
+
if (Array.isArray(node.templates)) walk(node.templates, inner, guarded)
|
|
318
|
+
if (Array.isArray(node.elseTemplates)) walk(node.elseTemplates, inner, guarded)
|
|
287
319
|
break
|
|
288
320
|
}
|
|
289
321
|
case 'IfTag':
|
|
@@ -293,21 +325,38 @@ export function parseReferences(source) {
|
|
|
293
325
|
// Branch condition is a LiquidJS Value whose `.initial.postfix[]`
|
|
294
326
|
// expresses identifier paths; walk them rather than relying on
|
|
295
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
|
+
}
|
|
296
342
|
if (Array.isArray(node.branches)) {
|
|
297
343
|
for (const branch of node.branches) {
|
|
298
344
|
const found = new Set()
|
|
299
345
|
collectValuePaths(branch.value, found)
|
|
300
|
-
|
|
301
|
-
|
|
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)
|
|
302
351
|
}
|
|
303
352
|
}
|
|
304
|
-
if (Array.isArray(node.elseTemplates)) walk(node.elseTemplates, scope)
|
|
353
|
+
if (Array.isArray(node.elseTemplates)) walk(node.elseTemplates, scope, guarded || guards)
|
|
305
354
|
break
|
|
306
355
|
}
|
|
307
356
|
default: {
|
|
308
357
|
// 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)
|
|
358
|
+
if (Array.isArray(node?.templates)) walk(node.templates, scope, guarded)
|
|
359
|
+
if (Array.isArray(node?.elseTemplates)) walk(node.elseTemplates, scope, guarded)
|
|
311
360
|
break
|
|
312
361
|
}
|
|
313
362
|
}
|
|
@@ -321,6 +370,10 @@ export function parseReferences(source) {
|
|
|
321
370
|
partials: Array.from(partials.values()).sort((a, b) => a.name.localeCompare(b.name)),
|
|
322
371
|
iterations,
|
|
323
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(),
|
|
324
377
|
}
|
|
325
378
|
}
|
|
326
379
|
|
package/package.json
CHANGED
package/test/references.test.js
CHANGED
|
@@ -56,7 +56,10 @@ describe('liquid parseReferences: partial arguments', () => {
|
|
|
56
56
|
|
|
57
57
|
it('reports a plain include with no arguments', () => {
|
|
58
58
|
const r = parseReferences("{% include 'chrome/nav' %}")
|
|
59
|
-
|
|
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: {} }])
|
|
60
63
|
})
|
|
61
64
|
})
|
|
62
65
|
|