mikser-io-render-liquid 4.2.0 → 4.2.2

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
@@ -142,6 +142,12 @@ export function parseReferences(source) {
142
142
  // on the data: `{% if tags.size > 0 %}` asks how many, not for a key called
143
143
  // `size`. Recording them puts engine machinery into a document's contract.
144
144
  const LIQUID_PSEUDO = new Set(['size', 'first', 'last'])
145
+ // A filter that supplies a fallback is a guard, exactly as `{% if %}` is:
146
+ // `{{ hero.title | default: meta.title }}` renders correctly for a document
147
+ // that omits hero.title, so calling it required would report a working page
148
+ // as wrong.
149
+ const FALLBACK_FILTERS = new Set(['default'])
150
+ const hasFallback = (value) => (value?.filters ?? []).some(f => FALLBACK_FILTERS.has(f.name))
145
151
  // Keyed by partial name and MERGED across call sites: `ui/btn` rendered
146
152
  // eight times with different labels is one partial with the union of what
147
153
  // it is ever passed, which is the question a contract answers.
@@ -234,7 +240,17 @@ export function parseReferences(source) {
234
240
  // variable ref.
235
241
  const content = node.token?.content ?? ''
236
242
  const path = extractPath(content)
237
- if (path) record(path, scope, guarded)
243
+ if (path) record(path, scope, guarded || hasFallback(node.value))
244
+ // A filter's ARGUMENTS are read too. `{{ a | default: b }}`
245
+ // reads b, and unconditionally — it is the fallback, so it
246
+ // is what renders when a is absent. Leaving it unrecorded
247
+ // made it look like a key nothing consumes.
248
+ for (const filter of node.value?.filters ?? []) {
249
+ for (const arg of filter.args ?? []) {
250
+ const argPath = pathOfToken(arg)
251
+ if (argPath) record(argPath, scope, guarded)
252
+ }
253
+ }
238
254
  break
239
255
  }
240
256
  case 'IncludeTag':
@@ -262,6 +278,16 @@ export function parseReferences(source) {
262
278
  // r.more %}` makes this template depend on `r.more`,
263
279
  // and nothing recorded that — so a contract built from
264
280
  // 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)
265
291
  for (const [name, token] of Object.entries(node.hash?.hash ?? {})) {
266
292
  const path = pathOfToken(token)
267
293
  if (!path) continue
@@ -269,7 +295,7 @@ export function parseReferences(source) {
269
295
  // before the partial ever runs — so a partial
270
296
  // rendered inside a loop reports what the loop
271
297
  // hands it, not the loop variable's local name.
272
- entry.args[name] = record(path, scope, guarded)
298
+ entry.args[name] = record(path, scope, guarded || argHasFallback(name))
273
299
  }
274
300
  // `{% render 'x' with item as t %}` — the same binding
275
301
  // written positionally.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io-render-liquid",
3
- "version": "4.2.0",
3
+ "version": "4.2.2",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -114,3 +114,102 @@ describe('liquid parseReferences: shape', () => {
114
114
  }
115
115
  })
116
116
  })
117
+
118
+ // Aliases nested more than one level deep.
119
+ //
120
+ // A read is only useful as provenance if it names the SOURCE path. Each of
121
+ // these rebinds a value to a new name, twice, and the recorded path has to
122
+ // survive both hops — a contract naming `field.label` describes nothing a
123
+ // document author can act on.
124
+ describe('liquid parseReferences: aliases through nesting', () => {
125
+ it('follows a loop binding inside a loop', () => {
126
+ const r = parseReferences(
127
+ '{% for case in data.meta.results.cases %}'
128
+ + '{% for spec in case.specs %}{{ spec.name }}{% endfor %}'
129
+ + '{% endfor %}')
130
+ assert.ok(r.variables.includes('data.meta.results.cases[].specs[].name'),
131
+ `got: ${r.variables.join(', ')}`)
132
+ assert.ok(!r.variables.some(v => v.startsWith('spec.')), 'the local name must not survive')
133
+ })
134
+
135
+ it('follows a loop binding through an assign and back into a loop', () => {
136
+ const r = parseReferences(
137
+ '{% assign block = data.meta.enquiry %}'
138
+ + '{% for field in block.fields %}{{ field.label }}{{ field.type }}{% endfor %}')
139
+ assert.ok(r.variables.includes('data.meta.enquiry.fields[].label'),
140
+ `got: ${r.variables.join(', ')}`)
141
+ assert.ok(r.variables.includes('data.meta.enquiry.fields[].type'))
142
+ })
143
+
144
+ it('resolves a render argument written inside two loops', () => {
145
+ const r = parseReferences(
146
+ '{% for group in data.meta.groups %}'
147
+ + '{% for item in group.items %}'
148
+ + "{% render 'ui/tag', label: item.title %}"
149
+ + '{% endfor %}{% endfor %}')
150
+ const args = r.partials.find(p => p.name === 'ui/tag')?.args ?? {}
151
+ assert.deepEqual(args, { label: 'data.meta.groups[].items[].title' })
152
+ })
153
+
154
+ it('resolves an argument whose value is itself an alias', () => {
155
+ const r = parseReferences(
156
+ '{% assign hero = data.meta.hero %}'
157
+ + "{% render 'ui/tags', tags: hero.tags, rows: hero.tagRows %}")
158
+ const args = r.partials.find(p => p.name === 'ui/tags')?.args ?? {}
159
+ assert.deepEqual(args, { tags: 'data.meta.hero.tags', rows: 'data.meta.hero.tagRows' })
160
+ assert.ok(r.variables.includes('data.meta.hero.tags'))
161
+ assert.ok(r.variables.includes('data.meta.hero.tagRows'))
162
+ })
163
+ })
164
+
165
+ // A fallback filter is a guard.
166
+ //
167
+ // `{{ hero.title | default: meta.title }}` renders correctly for a document
168
+ // that omits hero.title — the layout was written to work without it, exactly as
169
+ // with `{% if %}`. Calling it required reports a working page as wrong, and
170
+ // `missing` is the one list that must only ever mean "probably wrong".
171
+ describe('liquid parseReferences: default: as a guard', () => {
172
+ it('marks a defaulted output as optional', () => {
173
+ const r = parseReferences('{{ data.meta.hero.title | default: data.meta.title }}')
174
+ assert.ok(r.optional.includes('data.meta.hero.title'),
175
+ `optional: ${r.optional.join(', ')}`)
176
+ })
177
+
178
+ it('still records it as consumed — optional is not unread', () => {
179
+ const r = parseReferences('{{ data.meta.hero.title | default: data.meta.title }}')
180
+ assert.ok(r.variables.includes('data.meta.hero.title'))
181
+ // The fallback's own source is read too, and unconditionally.
182
+ assert.ok(r.variables.includes('data.meta.title'))
183
+ assert.ok(!r.optional.includes('data.meta.title'))
184
+ })
185
+
186
+ it('leaves an undefaulted sibling required', () => {
187
+ const r = parseReferences(
188
+ '{{ data.meta.hero.title | default: data.meta.title }}{{ data.meta.hero.subtitle }}')
189
+ assert.ok(r.optional.includes('data.meta.hero.title'))
190
+ assert.ok(!r.optional.includes('data.meta.hero.subtitle'))
191
+ })
192
+
193
+ it('does not treat an ordinary filter as a guard', () => {
194
+ const r = parseReferences('{{ data.meta.hero.title | upcase }}')
195
+ assert.deepEqual(r.optional, [])
196
+ })
197
+
198
+ it('marks a defaulted PARTIAL ARGUMENT as optional', () => {
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.
202
+ 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')
206
+ })
207
+
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
+ })