mikser-io-render-liquid 3.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
@@ -30,6 +30,20 @@ export function load({ runtime, options, config }) {
30
30
  root: options.layoutsFolder,
31
31
  extname: '.liquid',
32
32
  cache: !options.watch,
33
+ // LiquidJS defaults strictFilters to false, which makes an
34
+ // unknown filter a no-op that returns its input unchanged.
35
+ // That is a bad default HERE specifically, because which
36
+ // filters exist depends on which render plugins are loaded:
37
+ // every function on `runtime` is registered as one below. So
38
+ // `{{ '/contacts' | href }}` with hrefUrlHelpers() missing from the
39
+ // plugin list renders the string back, and a missing plugin is
40
+ // indistinguishable from a working helper.
41
+ //
42
+ // strictVariables is deliberately NOT set. It would throw on
43
+ // any undefined variable, which templates legitimately rely on
44
+ // being empty — a far larger change than this issue is about.
45
+ strictFilters: true,
46
+ // Spread last, so a project can put either back.
33
47
  ...config,
34
48
  })
35
49
 
@@ -85,7 +99,232 @@ export async function render({ entity, runtime, state, track }) {
85
99
  }
86
100
  }
87
101
 
102
+ // Static reference scan for `mikser-io-layouts`'s inspect() primitive.
103
+ // Uses LiquidJS's own parser to walk the template AST. Returns the
104
+ // variables, partials, and iterations the source mentions — the
105
+ // authoring-time view of "what could this template reference."
106
+ // The runtime-precise answer ("what did each render actually touch")
107
+ // lives in mikser-io's manifest refClosure.
108
+ //
109
+ // AST nodes we care about:
110
+ // - Output → output expression, e.g. {{ post.title | filter }}
111
+ // - IncludeTag → {% include 'name' %} — partial reference
112
+ // - RenderTag → {% render 'name' %} — partial reference
113
+ // - LayoutTag → {% layout 'name' %} — partial reference (the
114
+ // parent the current template extends)
115
+ // - ForTag → {% for x in y %} — iteration
116
+ // - IfTag → recurse into branches for variable refs
117
+ // - UnlessTag → same
118
+ // - CaseTag → same
119
+ //
120
+ // Other tag types are walked-through (their nested templates are
121
+ // scanned) but don't produce a top-level entry.
122
+ const LIQUID_IDENT_PATH = /([a-zA-Z_$][\w$]*(?:\.[a-zA-Z_$][\w$]*)*)/
123
+ export function parseReferences(source) {
124
+ if (typeof source !== 'string' || !source) {
125
+ return { variables: [], partials: [], iterations: [], assigns: [] }
126
+ }
127
+ const probe = new Liquid({})
128
+ let templates
129
+ try {
130
+ templates = probe.parse(source)
131
+ } catch (err) {
132
+ return { variables: [], partials: [], iterations: [], assigns: [], parseError: err.message }
133
+ }
134
+
135
+ const variables = 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()
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
+ }
161
+
162
+ function extractPath(expr) {
163
+ const m = LIQUID_IDENT_PATH.exec(String(expr ?? '').trim())
164
+ return m?.[1] ?? null
165
+ }
166
+
167
+ // Walk a LiquidJS Value (used in {% if %}, {% case %}, etc.) and
168
+ // collect identifier paths. Value has `.initial.postfix[]`; each
169
+ // postfix item with `props` represents one identifier path; props
170
+ // are PropertyAccessToken[] whose `.content` joins to the path.
171
+ function collectValuePaths(value, sink) {
172
+ const postfix = value?.initial?.postfix
173
+ if (!Array.isArray(postfix)) return
174
+ for (const item of postfix) {
175
+ if (!Array.isArray(item?.props) || !item.props.length) continue
176
+ const segments = item.props
177
+ .map(p => typeof p?.content === 'string' ? p.content : null)
178
+ .filter(Boolean)
179
+ if (segments.length) sink.add(segments.join('.'))
180
+ }
181
+ }
182
+
183
+ function getText(maybeWrapped) {
184
+ if (maybeWrapped == null) return null
185
+ if (typeof maybeWrapped === 'string') return maybeWrapped
186
+ if (typeof maybeWrapped.getText === 'function') {
187
+ try { return maybeWrapped.getText() } catch { return null }
188
+ }
189
+ return null
190
+ }
191
+
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 = {}) {
209
+ if (!Array.isArray(nodes)) return
210
+ for (const node of nodes) {
211
+ const kind = node?.constructor?.name
212
+ switch (kind) {
213
+ case 'Output': {
214
+ // Token content is everything between `{{` and `}}`,
215
+ // including filters. Leading identifier path is the
216
+ // variable ref.
217
+ const content = node.token?.content ?? ''
218
+ const path = extractPath(content)
219
+ if (path) record(path, scope)
220
+ break
221
+ }
222
+ case 'IncludeTag':
223
+ case 'RenderTag':
224
+ case 'LayoutTag': {
225
+ const file = getText(node.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
+ }
250
+ // Render/include accept a body in some dialects; walk it.
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
+ }
269
+ break
270
+ }
271
+ case 'ForTag': {
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 }
278
+ if (collection) {
279
+ const item = node.variable ?? '(for)'
280
+ iterations.push({ item, collection })
281
+ const path = extractPath(collection)
282
+ const resolved = path ? record(path, scope) : null
283
+ if (resolved && node.variable) inner[node.variable] = `${resolved}[]`
284
+ }
285
+ if (Array.isArray(node.templates)) walk(node.templates, inner)
286
+ if (Array.isArray(node.elseTemplates)) walk(node.elseTemplates, inner)
287
+ break
288
+ }
289
+ case 'IfTag':
290
+ case 'UnlessTag':
291
+ case 'CaseTag': {
292
+ // Branches live on .branches (each has .templates) and .elseTemplates.
293
+ // Branch condition is a LiquidJS Value whose `.initial.postfix[]`
294
+ // expresses identifier paths; walk them rather than relying on
295
+ // string forms that aren't reliably exposed.
296
+ if (Array.isArray(node.branches)) {
297
+ for (const branch of node.branches) {
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)
302
+ }
303
+ }
304
+ if (Array.isArray(node.elseTemplates)) walk(node.elseTemplates, scope)
305
+ break
306
+ }
307
+ default: {
308
+ // Generic walk — many tags expose nested .templates.
309
+ if (Array.isArray(node?.templates)) walk(node.templates, scope)
310
+ if (Array.isArray(node?.elseTemplates)) walk(node.elseTemplates, scope)
311
+ break
312
+ }
313
+ }
314
+ }
315
+ }
316
+
317
+ walk(templates)
318
+
319
+ return {
320
+ variables: Array.from(variables).sort(),
321
+ partials: Array.from(partials.values()).sort((a, b) => a.name.localeCompare(b.name)),
322
+ iterations,
323
+ assigns,
324
+ }
325
+ }
326
+
88
327
  // v9 factory — ADR-0010.
89
328
  export function renderLiquid(options = {}) {
90
- return { name: options.name ?? 'liquid', options, load, render }
329
+ return { name: options.name ?? 'liquid', options, load, render, parseReferences }
91
330
  }
package/package.json CHANGED
@@ -1,10 +1,12 @@
1
1
  {
2
2
  "name": "mikser-io-render-liquid",
3
- "version": "3.0.0",
3
+ "version": "4.1.0",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "type": "module",
7
- "scripts": {},
7
+ "scripts": {
8
+ "test": "node --no-warnings --test --test-reporter=spec test/**/*.test.js"
9
+ },
8
10
  "repository": {
9
11
  "type": "git",
10
12
  "url": "git+https://github.com/almero-digital-marketing/mikser-io-render-liquid.git"
@@ -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
+ })
@@ -0,0 +1,62 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { readFile } from 'node:fs/promises'
4
+ import { Liquid } from 'liquidjs'
5
+
6
+ // Why this file exists: every function on `runtime` is registered as a Liquid
7
+ // filter, so WHICH filters exist depends on which render plugins are loaded.
8
+ // With LiquidJS's default strictFilters:false, an unknown filter is a no-op
9
+ // that returns its input — making a missing plugin indistinguishable from a
10
+ // working helper. `{{ '/contacts' | href }}` renders "/contacts" either way.
11
+ describe('strictFilters', () => {
12
+ // The engine options this plugin builds, mirrored so the behavioural
13
+ // claims below are about LiquidJS itself, not about a mock.
14
+ const engine = (over = {}) => new Liquid({ extname: '.liquid', strictFilters: true, ...over })
15
+
16
+ it('an unknown filter throws instead of passing the input through', async () => {
17
+ await assert.rejects(
18
+ () => engine().parseAndRender("{{ '/contacts' | href }}"),
19
+ /undefined filter/i,
20
+ )
21
+ })
22
+
23
+ it('a registered filter still works', async () => {
24
+ const e = engine()
25
+ e.registerFilter('href', (input) => `../${String(input).replace(/^\//, '')}`)
26
+ assert.equal(await e.parseAndRender("{{ '/contacts' | href }}"), '../contacts')
27
+ })
28
+
29
+ it('shows what the old default did — the reason for the change', async () => {
30
+ // Silent pass-through: the string comes back unchanged and nothing
31
+ // anywhere reports that `href` does not exist.
32
+ const loose = new Liquid({ strictFilters: false })
33
+ assert.equal(await loose.parseAndRender("{{ '/contacts' | href }}"), '/contacts')
34
+ })
35
+
36
+ it('strictVariables is NOT enabled — undefined variables stay empty', async () => {
37
+ // Deliberate: templates legitimately rely on this, and turning it on
38
+ // is a far larger change than the filter issue.
39
+ assert.equal(await engine().parseAndRender('[{{ nope }}]'), '[]')
40
+ })
41
+
42
+ it('a project can put the old behaviour back', async () => {
43
+ // `...config` is spread after the defaults in load(), so userland wins.
44
+ const e = engine({ strictFilters: false })
45
+ assert.equal(await e.parseAndRender("{{ '/x' | href }}"), '/x')
46
+ })
47
+ })
48
+
49
+ describe('the plugin sets it', () => {
50
+ it('index.js enables strictFilters, and spreads config afterwards', async () => {
51
+ const src = await readFile(new URL('../index.js', import.meta.url), 'utf8')
52
+ assert.match(src, /strictFilters: true/)
53
+ // Order is load-bearing: config must come last to remain an override.
54
+ assert.ok(src.indexOf('strictFilters: true') < src.indexOf('...config'),
55
+ 'config must be spread AFTER the defaults')
56
+ })
57
+
58
+ it('does not enable strictVariables', async () => {
59
+ const src = await readFile(new URL('../index.js', import.meta.url), 'utf8')
60
+ assert.ok(!/strictVariables:\s*true/.test(src))
61
+ })
62
+ })