mikser-io-render-liquid 3.0.0 → 4.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
@@ -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 renderHref() 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,144 @@ 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: [] }
126
+ }
127
+ const probe = new Liquid({})
128
+ let templates
129
+ try {
130
+ templates = probe.parse(source)
131
+ } catch (err) {
132
+ return { variables: [], partials: [], iterations: [], parseError: err.message }
133
+ }
134
+
135
+ const variables = new Set()
136
+ const partials = new Set()
137
+ const iterations = []
138
+
139
+ function extractPath(expr) {
140
+ const m = LIQUID_IDENT_PATH.exec(String(expr ?? '').trim())
141
+ return m?.[1] ?? null
142
+ }
143
+
144
+ // Walk a LiquidJS Value (used in {% if %}, {% case %}, etc.) and
145
+ // collect identifier paths. Value has `.initial.postfix[]`; each
146
+ // postfix item with `props` represents one identifier path; props
147
+ // are PropertyAccessToken[] whose `.content` joins to the path.
148
+ function collectValuePaths(value, sink) {
149
+ const postfix = value?.initial?.postfix
150
+ if (!Array.isArray(postfix)) return
151
+ for (const item of postfix) {
152
+ if (!Array.isArray(item?.props) || !item.props.length) continue
153
+ const segments = item.props
154
+ .map(p => typeof p?.content === 'string' ? p.content : null)
155
+ .filter(Boolean)
156
+ if (segments.length) sink.add(segments.join('.'))
157
+ }
158
+ }
159
+
160
+ function getText(maybeWrapped) {
161
+ if (maybeWrapped == null) return null
162
+ if (typeof maybeWrapped === 'string') return maybeWrapped
163
+ if (typeof maybeWrapped.getText === 'function') {
164
+ try { return maybeWrapped.getText() } catch { return null }
165
+ }
166
+ return null
167
+ }
168
+
169
+ function walk(nodes) {
170
+ if (!Array.isArray(nodes)) return
171
+ for (const node of nodes) {
172
+ const kind = node?.constructor?.name
173
+ switch (kind) {
174
+ case 'Output': {
175
+ // Token content is everything between `{{` and `}}`,
176
+ // including filters. Leading identifier path is the
177
+ // variable ref.
178
+ const content = node.token?.content ?? ''
179
+ const path = extractPath(content)
180
+ if (path) variables.add(path)
181
+ break
182
+ }
183
+ case 'IncludeTag':
184
+ case 'RenderTag':
185
+ case 'LayoutTag': {
186
+ const file = getText(node.file)
187
+ if (file) partials.add(file)
188
+ // Render/include accept a body in some dialects; walk it.
189
+ if (Array.isArray(node.templates)) walk(node.templates)
190
+ break
191
+ }
192
+ case 'ForTag': {
193
+ const collection = getText(node.collection)
194
+ if (collection) {
195
+ const item = node.variable ?? '(for)'
196
+ iterations.push({ item, collection })
197
+ const path = extractPath(collection)
198
+ if (path) variables.add(path)
199
+ }
200
+ if (Array.isArray(node.templates)) walk(node.templates)
201
+ if (Array.isArray(node.elseTemplates)) walk(node.elseTemplates)
202
+ break
203
+ }
204
+ case 'IfTag':
205
+ case 'UnlessTag':
206
+ case 'CaseTag': {
207
+ // Branches live on .branches (each has .templates) and .elseTemplates.
208
+ // Branch condition is a LiquidJS Value whose `.initial.postfix[]`
209
+ // expresses identifier paths; walk them rather than relying on
210
+ // string forms that aren't reliably exposed.
211
+ if (Array.isArray(node.branches)) {
212
+ for (const branch of node.branches) {
213
+ collectValuePaths(branch.value, variables)
214
+ if (Array.isArray(branch.templates)) walk(branch.templates)
215
+ }
216
+ }
217
+ if (Array.isArray(node.elseTemplates)) walk(node.elseTemplates)
218
+ break
219
+ }
220
+ default: {
221
+ // 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)
224
+ break
225
+ }
226
+ }
227
+ }
228
+ }
229
+
230
+ walk(templates)
231
+
232
+ return {
233
+ variables: Array.from(variables).sort(),
234
+ partials: Array.from(partials).sort(),
235
+ iterations,
236
+ }
237
+ }
238
+
88
239
  // v9 factory — ADR-0010.
89
240
  export function renderLiquid(options = {}) {
90
- return { name: options.name ?? 'liquid', options, load, render }
241
+ return { name: options.name ?? 'liquid', options, load, render, parseReferences }
91
242
  }
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.0.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,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
+ })