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 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
- const resolved = deref(path, scope)
204
- if (resolved) variables.add(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
- const entry = partials.get(file) ?? { name: file, args: {}, aliases: [] }
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
- for (const f of found) record(f, scope)
301
- if (Array.isArray(branch.templates)) walk(branch.templates, scope)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io-render-liquid",
3
- "version": "4.1.0",
3
+ "version": "4.2.0",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -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
- assert.deepEqual(r.partials, [{ name: 'chrome/nav', args: {}, aliases: [] }])
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