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 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 renderHref() missing from the
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
- const partials = 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'])
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
- function walk(nodes) {
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) variables.add(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) partials.add(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
- if (path) variables.add(path)
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
- collectValuePaths(branch.value, variables)
214
- if (Array.isArray(branch.templates)) walk(branch.templates)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io-render-liquid",
3
- "version": "4.0.0",
3
+ "version": "4.2.0",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -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
+ })