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