mikser-io-render-liquid 4.2.1 → 4.2.3
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 +43 -4
- package/package.json +1 -1
- package/test/references.test.js +96 -0
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.
|
|
@@ -293,10 +319,23 @@ export function parseReferences(source) {
|
|
|
293
319
|
const raw = [...found][0] ?? null
|
|
294
320
|
const from = raw ? record(raw, scope, guarded) : null
|
|
295
321
|
if (node.key) {
|
|
296
|
-
|
|
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
|
|
326
|
+
assigns.push({ key: node.key, from, ...(derived ? { derived } : {}) })
|
|
297
327
|
// Bound for the REST of this template, which is what
|
|
298
328
|
// `assign` means — everything after it sees the alias.
|
|
299
|
-
|
|
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
|
+
if (from && !derived) scope[node.key] = from
|
|
300
339
|
}
|
|
301
340
|
break
|
|
302
341
|
}
|
package/package.json
CHANGED
package/test/references.test.js
CHANGED
|
@@ -161,3 +161,99 @@ describe('liquid parseReferences: aliases through nesting', () => {
|
|
|
161
161
|
assert.ok(r.variables.includes('data.meta.hero.tagRows'))
|
|
162
162
|
})
|
|
163
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
|
+
})
|
|
216
|
+
|
|
217
|
+
// A filtered value is DERIVED, not an alias.
|
|
218
|
+
//
|
|
219
|
+
// `{% assign rows = c.specs | split: '|' %}` turns a string into a list, so a
|
|
220
|
+
// read of `rows[0]` says nothing about `specs` having members. Treating it as an
|
|
221
|
+
// alias reports `specs[]` as a required key of a document whose `specs` IS the
|
|
222
|
+
// string being split — a key no document can ever satisfy, on a page that
|
|
223
|
+
// renders correctly.
|
|
224
|
+
describe('liquid parseReferences: derived values are not aliases', () => {
|
|
225
|
+
it('records the source but does not extend paths under it', () => {
|
|
226
|
+
const r = parseReferences(
|
|
227
|
+
"{% assign rows = data.meta.specs | split: '|' %}"
|
|
228
|
+
+ '{% for row in rows %}{{ row.name }}{% endfor %}')
|
|
229
|
+
assert.ok(r.variables.includes('data.meta.specs'), 'the real dependency is still recorded')
|
|
230
|
+
assert.ok(!r.variables.some(v => v.startsWith('data.meta.specs[')),
|
|
231
|
+
`fabricated a member of a string: ${r.variables.join(', ')}`)
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
it('marks the assign derived so a closure walker knows too', () => {
|
|
235
|
+
// The walker applies these as bindings as well, and has no other way to
|
|
236
|
+
// know the value went through a filter.
|
|
237
|
+
const r = parseReferences("{% assign rows = data.meta.specs | split: '|' %}")
|
|
238
|
+
assert.equal(r.assigns.find(a => a.key === 'rows')?.derived, true)
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it('leaves an UNFILTERED alias working', () => {
|
|
242
|
+
const r = parseReferences('{% assign hero = data.meta.hero %}{{ hero.title }}')
|
|
243
|
+
assert.ok(r.variables.includes('data.meta.hero.title'))
|
|
244
|
+
assert.ok(!r.assigns.find(a => a.key === 'hero')?.derived)
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
it('handles the real shape: split, loop, split again', () => {
|
|
248
|
+
// Verbatim from the project that surfaced this.
|
|
249
|
+
const r = parseReferences(
|
|
250
|
+
"{% assign rows = data.meta.results.cases | split: '|' %}"
|
|
251
|
+
+ '{% for row in rows %}'
|
|
252
|
+
+ "{% assign kv = row | split: ':' %}"
|
|
253
|
+
+ '<dt>{{ kv[0] }}</dt>'
|
|
254
|
+
+ '{% endfor %}')
|
|
255
|
+
assert.ok(r.variables.includes('data.meta.results.cases'))
|
|
256
|
+
assert.ok(!r.variables.some(v => /results\.cases\[/.test(v)),
|
|
257
|
+
`fabricated: ${r.variables.filter(v => v.includes('cases')).join(', ')}`)
|
|
258
|
+
})
|
|
259
|
+
})
|