mikser-io-render-liquid 4.2.3 → 5.0.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
@@ -148,13 +148,31 @@ export function parseReferences(source) {
148
148
  // as wrong.
149
149
  const FALLBACK_FILTERS = new Set(['default'])
150
150
  const hasFallback = (value) => (value?.filters ?? []).some(f => FALLBACK_FILTERS.has(f.name))
151
- // Keyed by partial name and MERGED across call sites: `ui/btn` rendered
152
- // eight times with different labels is one partial with the union of what
153
- // it is ever passed, which is the question a contract answers.
151
+
152
+ // Any filter makes the value DERIVED, and a derived value is not an alias.
153
+ //
154
+ // `{% assign rows = specs | split: '|' %}` turns a string into a list, so a
155
+ // read of `rows[0].k` says nothing about `specs` having members. The source
156
+ // is still recorded — that is the real dependency — only the paths beneath
157
+ // it are dropped.
158
+ //
159
+ // Deliberately blunt. Classifying all 87 of liquid's filters by what they do
160
+ // to a value's shape was tried and removed: it is a table that has to track
161
+ // an engine's evolution, it is wrong in the direction that INVENTS required
162
+ // keys, and it exists per engine. What it bought was a few extra paths in a
163
+ // list that is now advisory, which is not worth the fragility. Being
164
+ // conservative here costs an occasional missing entry in a weak list;
165
+ // being clever cost a working page being reported as broken, twice.
166
+ const isDerived = (value) => !!(value?.filters ?? []).length
167
+
168
+ // Keyed by partial name and MERGED across call sites: one partial rendered
169
+ // eight times with different labels is one entry holding the union of what
170
+ // it is ever passed.
154
171
  const partials = new Map()
155
172
  const iterations = []
156
173
  const assigns = []
157
174
 
175
+
158
176
  // The path a tag ARGUMENT refers to, or null if it is a literal.
159
177
  //
160
178
  // Type, not text: LIQUID_IDENT_PATH is unanchored, so run over the source
@@ -278,16 +296,6 @@ export function parseReferences(source) {
278
296
  // r.more %}` makes this template depend on `r.more`,
279
297
  // and nothing recorded that — so a contract built from
280
298
  // one file could not see a key consumed one file down.
281
- // Whether a given argument carries a fallback filter,
282
- // read from the RAW tag text: liquidjs parses a hash
283
- // value down to a bare path token and drops the filter
284
- // from the structured form, so there is nothing else to
285
- // look at. Best-effort by necessity, and wrong only in
286
- // the direction of calling something optional.
287
- const rawArgs = String(node.token?.args ?? node.token?.content ?? '')
288
- const argHasFallback = (name) => new RegExp(
289
- `\\b${name}\\s*:\\s*[A-Za-z_$][\\w$.]*\\s*\\|\\s*(?:${[...FALLBACK_FILTERS].join('|')})\\b`,
290
- ).test(rawArgs)
291
299
  for (const [name, token] of Object.entries(node.hash?.hash ?? {})) {
292
300
  const path = pathOfToken(token)
293
301
  if (!path) continue
@@ -295,7 +303,7 @@ export function parseReferences(source) {
295
303
  // before the partial ever runs — so a partial
296
304
  // rendered inside a loop reports what the loop
297
305
  // hands it, not the loop variable's local name.
298
- entry.args[name] = record(path, scope, guarded || argHasFallback(name))
306
+ entry.args[name] = record(path, scope, guarded)
299
307
  }
300
308
  // `{% render 'x' with item as t %}` — the same binding
301
309
  // written positionally.
@@ -319,22 +327,16 @@ export function parseReferences(source) {
319
327
  const raw = [...found][0] ?? null
320
328
  const from = raw ? record(raw, scope, guarded) : null
321
329
  if (node.key) {
322
- // `derived` travels with it: the closure walker applies
323
- // these assigns as bindings too, and it has no other way
324
- // to know the value went through a filter.
325
- const derived = !!(node.value?.filters ?? []).length
330
+ // Bound according to what the filters did to the
331
+ // shape: unchanged keeps the source path, an
332
+ // element-yielding filter binds to `source[]`, and
333
+ // anything else is derived and binds nothing. The source
334
+ // is recorded above either way — that is the real
335
+ // dependency. `derived` travels with the assign because
336
+ // the closure walker applies these as bindings too and
337
+ // has no other way to know.
338
+ const derived = isDerived(node.value)
326
339
  assigns.push({ key: node.key, from, ...(derived ? { derived } : {}) })
327
- // Bound for the REST of this template, which is what
328
- // `assign` means — everything after it sees the alias.
329
- //
330
- // NOT bound when the value passed through a filter: the
331
- // result is DERIVED, so a read on it says nothing about
332
- // the source. `{% assign rows = c.specs | split: '|' %}`
333
- // turns a string into a list, and binding it would let a
334
- // loop over `rows` report `specs[]` — a key the document
335
- // does not have and cannot be given, since specs is the
336
- // string being split. The source itself is still
337
- // recorded above, which is the true dependency.
338
340
  if (from && !derived) scope[node.key] = from
339
341
  }
340
342
  break
@@ -351,7 +353,16 @@ export function parseReferences(source) {
351
353
  iterations.push({ item, collection })
352
354
  const path = extractPath(collection)
353
355
  const resolved = path ? record(path, scope, guarded) : null
354
- if (resolved && node.variable) inner[node.variable] = `${resolved}[]`
356
+ // A filtered collection is derived for the same reason.
357
+ // liquidjs parses it down to a bare path token, so the
358
+ // pipe is looked for in the raw tag text — with quoted
359
+ // segments removed first, since liquid's own
360
+ // `split: '|'` would otherwise read as one.
361
+ const raw = String(node.token?.args ?? node.token?.content ?? '')
362
+ .replace(/'[^']*'|"[^"]*"/g, "''")
363
+ if (resolved && node.variable && !raw.includes('|')) {
364
+ inner[node.variable] = `${resolved}[]`
365
+ }
355
366
  }
356
367
  if (Array.isArray(node.templates)) walk(node.templates, inner, guarded)
357
368
  if (Array.isArray(node.elseTemplates)) walk(node.elseTemplates, inner, guarded)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io-render-liquid",
3
- "version": "4.2.3",
3
+ "version": "5.0.0",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -195,23 +195,39 @@ describe('liquid parseReferences: default: as a guard', () => {
195
195
  assert.deepEqual(r.optional, [])
196
196
  })
197
197
 
198
- it('marks a defaulted PARTIAL ARGUMENT as optional', () => {
198
+ it('does NOT see a default: on a partial argument, and that is deliberate', () => {
199
199
  // liquidjs parses a hash value down to a bare path token and drops the
200
- // filter, so this is read from the raw tag text the only place it
201
- // survives.
200
+ // filter, so the only place it survives is the raw tag text. Reading it
201
+ // from there worked, and was removed: a regex over tag source is the
202
+ // kind of engine-shaped machinery that has to track the engine, and it
203
+ // bought optionality for one filter in one position.
204
+ //
205
+ // The cost is honest and small — such a key lands in `missing` instead
206
+ // of `missingOptional`, both of which are inferred and labelled as such.
202
207
  const r = parseReferences(
203
- "{% render 'ui/tag', label: data.meta.after | default: 'x', other: data.meta.plain %}")
204
- assert.ok(r.optional.includes('data.meta.after'), `optional: ${r.optional.join(', ')}`)
205
- assert.ok(!r.optional.includes('data.meta.plain'), 'an undefaulted argument stays required')
208
+ "{% render 'ui/tag', label: data.meta.after | default: 'x' %}")
209
+ assert.ok(r.variables.includes('data.meta.after'), 'still recorded as consumed')
210
+ assert.ok(!r.optional.includes('data.meta.after'))
206
211
  })
212
+ })
213
+
214
+ // Aliases nested more than one level deep.
215
+ //
216
+ // A read is only useful as provenance if it names the SOURCE path. Each of
217
+ // these rebinds a value to a new name, twice, and the recorded path has to
218
+ // survive both hops — a contract naming `field.label` describes nothing a
219
+ // document author can act on.
220
+ describe('liquid parseReferences: aliases through nesting', () => {
221
+ })
222
+
223
+ // A fallback filter is a guard.
224
+ //
225
+ // `{{ hero.title | default: meta.title }}` renders correctly for a document
226
+ // that omits hero.title — the layout was written to work without it, exactly as
227
+ // with `{% if %}`. Calling it required reports a working page as wrong, and
228
+ // `missing` is the one list that must only ever mean "probably wrong".
229
+ describe('liquid parseReferences: default: as a guard', () => {
207
230
 
208
- it('resolves the defaulted argument through scope, like any other', () => {
209
- const r = parseReferences(
210
- '{% assign r = data.meta.results %}'
211
- + "{% render 'ui/tag', label: r.afterLabel | default: 'x' %}")
212
- assert.ok(r.optional.includes('data.meta.results.afterLabel'),
213
- `optional: ${r.optional.join(', ')}`)
214
- })
215
231
  })
216
232
 
217
233
  // A filtered value is DERIVED, not an alias.
@@ -244,6 +260,35 @@ describe('liquid parseReferences: derived values are not aliases', () => {
244
260
  assert.ok(!r.assigns.find(a => a.key === 'hero')?.derived)
245
261
  })
246
262
 
263
+ it('drops paths under ANY filtered value, not just type-changing ones', () => {
264
+ // Blunt on purpose. Classifying liquid's 87 filters by what they do to a
265
+ // value's shape was tried and removed: the table has to track the
266
+ // engine, it is wrong in the direction that INVENTS required keys, and
267
+ // it exists per engine. `sort` really does preserve element type, and
268
+ // the key it would recover lands in an advisory list — not worth it.
269
+ const r = parseReferences(
270
+ '{% assign rows = data.meta.items | sort %}'
271
+ + '{% for row in rows %}{{ row.name }}{% endfor %}')
272
+ assert.ok(r.variables.includes('data.meta.items'), 'the dependency itself is still recorded')
273
+ assert.ok(!r.variables.some(v => v.startsWith('data.meta.items[')),
274
+ `extended a path through a filter: ${r.variables.join(', ')}`)
275
+ })
276
+
277
+ it('treats a filtered FOR collection the same way', () => {
278
+ // liquidjs parses the collection down to a bare path token, so the pipe
279
+ // is looked for in the raw text — with quotes stripped, since liquid's
280
+ // own `split: '|'` would otherwise read as one.
281
+ const r = parseReferences("{% for t in data.meta.tags | split: '|' %}{{ t.n }}{% endfor %}")
282
+ assert.ok(r.variables.includes('data.meta.tags'))
283
+ assert.ok(!r.variables.some(v => v.startsWith('data.meta.tags[')),
284
+ `fabricated a member of a string: ${r.variables.join(', ')}`)
285
+ })
286
+
287
+ it('leaves an unfiltered collection alone', () => {
288
+ const r = parseReferences('{% for t in data.meta.tags %}{{ t.n }}{% endfor %}')
289
+ assert.ok(r.variables.includes('data.meta.tags[].n'))
290
+ })
291
+
247
292
  it('handles the real shape: split, loop, split again', () => {
248
293
  // Verbatim from the project that surfaced this.
249
294
  const r = parseReferences(