Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ factory.isTruthy = Syntax.isTruthy
factory.isFalsy = Syntax.isFalsy
factory.evalExp = Syntax.evalExp
factory.evalValue = Syntax.evalValue
factory.splitAssignmentsByVariable = require('./src/split-assignments.js').splitAssignmentsByVariable
factory.Types = {
ParseError: Errors.ParseError,
TokenizationEroor: Errors.TokenizationError,
Expand Down
347 changes: 347 additions & 0 deletions src/split-assignments.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,347 @@
/**
* Split Liquid template into per-variable assignment slices.
* Uses the existing Liquid parser AST to isolate assignments per variable.
*
* @param {string} liquidText - The Liquid template source
* @param {Object} options - Configuration options
* @param {string[]} options.assignTags - Tag names that perform assignment (default: ['assign','assignVar','parseAssign'])
* @param {boolean} options.includeOtherAssigns - Include assigns to other variables (default: false)
* @param {boolean} options.pruneEmptyBlocks - Remove empty branches (default: true)
* @param {boolean} options.includeComments - Include comments (default: false, not reconstructible from AST)
* @param {boolean} options.keepNonAssign - Keep html, value, and non-control tags (default: false)
* @param {string} options.outputOrder - 'first-appearance' (default)
* @returns {string[]} Array of Liquid strings, one per variable, ordered by first appearance
*/
function splitAssignmentsByVariable (liquidText, options) {
const Liquid = require('../index.js')
const engine = Liquid()
const opts = Object.assign({
assignTags: ['assign', 'assignVar', 'parseAssign'],
includeOtherAssigns: false,
pruneEmptyBlocks: true,
includeComments: false,
keepNonAssign: false,
outputOrder: 'first-appearance'
}, options || {})

const templates = engine.parse(liquidText)
const varFirstAppearance = identifyAssignedVariables(templates, opts.assignTags)
const orderedVars = Object.keys(varFirstAppearance).sort(
(a, b) => varFirstAppearance[a] - varFirstAppearance[b]
)

return orderedVars.map(targetVar => {
const filtered = filterTemplates(templates, targetVar, opts)
return renderTemplatesToLiquid(filtered)
})
}

/**
* Traverse AST and collect assigned variables with first appearance index.
*/
function identifyAssignedVariables (templates, assignTags, indexRef) {
indexRef = indexRef || { value: 0 }
const result = {}

function traverse (tpls) {
if (!Array.isArray(tpls)) return
for (const t of tpls) {
if (t.type === 'tag' && assignTags.indexOf(t.name) >= 0 && t.tagImpl && t.tagImpl.key) {
const key = t.tagImpl.key
if (!(key in result)) {
result[key] = indexRef.value
}
}
indexRef.value++

const childTpls = getChildTemplates(t)
if (childTpls) traverse(childTpls)
}
}

traverse(templates)
return result
}

function getChildTemplates (template) {
if (template.type !== 'tag') return null
const impl = template.tagImpl
const name = template.name
if (name === 'if' && impl && impl.branches) {
const all = []
for (const b of impl.branches) all.push(...(b.templates || []))
if (impl.elseTemplates) all.push(...impl.elseTemplates)
return all
}
if (name === 'unless' && impl && (impl.templates || impl.elseTemplates)) {
return [...(impl.templates || []), ...(impl.elseTemplates || [])]
}
if ((name === 'for' || name === 'tablerow') && impl && (impl.templates || impl.elseTemplates)) {
return [...(impl.templates || []), ...(impl.elseTemplates || [])]
}
if (name === 'case' && impl && (impl.cases || impl.elseTemplates)) {
const all = []
for (const c of (impl.cases || [])) all.push(...(c.templates || []))
if (impl.elseTemplates) all.push(...impl.elseTemplates)
return all
}
if (name === 'capture' && impl && impl.templates) return impl.templates
return null
}

/**
* Filter templates to keep only control-flow and assigns to targetVar.
*/
function filterTemplates (templates, targetVar, options) {
if (!Array.isArray(templates)) return []
const result = []
const assignTags = options.assignTags || []
const includeOtherAssigns = options.includeOtherAssigns
const pruneEmptyBlocks = options.pruneEmptyBlocks
const keepNonAssign = options.keepNonAssign

for (const t of templates) {
if (t.type === 'html') {
if (keepNonAssign) result.push(t)
continue
}
if (t.type === 'value') {
if (keepNonAssign) result.push(t)
continue
}
if (t.type === 'tag') {
if (assignTags.indexOf(t.name) >= 0) {
const key = t.tagImpl && t.tagImpl.key
if (key === targetVar) {
result.push(t)
} else if (includeOtherAssigns) {
result.push(t)
}
continue
}
if (t.name === 'comment' && !options.includeComments) continue
if (t.name === 'raw') {
if (keepNonAssign) result.push(t)
continue
}

const filtered = filterControlFlow(t, targetVar, options)
if (filtered) {
if (pruneEmptyBlocks && isEmptyBlock(filtered)) continue
result.push(filtered)
}
}
Comment on lines +122 to +133
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

renderTemplateToLiquid doesn't handle the comment tag, so comments preserved by filterTemplates when includeComments: true (line 122) fall through to the final return ''. As a result includeComments never renders comment tags and the output Liquid drops them. Can we add an if (name === 'comment') return (token && token.raw) || '' branch (similar to assigns)?

Finding type: Logical Bugs | Severity: 🟠 Medium


Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents:

In src/split-assignments.js around lines 122 to 344, the renderTemplateToLiquid function
fails to handle the 'comment' tag even though filterTemplates can keep comments when
includeComments is true; as a result comments are dropped from the output. Modify
renderTemplateToLiquid to add a branch for when name === 'comment' that returns (token
&& token.raw) || '' (similar to the existing assign-handling branch) so comment tags are
preserved in the rendered Liquid.

}
return result
}

function filterControlFlow (template, targetVar, options) {
const impl = template.tagImpl
const name = template.name
if (name === 'if' && impl && impl.branches) {
const elseFiltered = filterTemplates(impl.elseTemplates || [], targetVar, options)
const branchFiltered = impl.branches.map(b => filterTemplates(b.templates || [], targetVar, options))
const hasContentLater = (idx) =>
branchFiltered.some((tpls, i) => i > idx && tpls.length > 0) || elseFiltered.length > 0

const newBranches = []
for (let i = 0; i < impl.branches.length; i++) {
const b = impl.branches[i]
const filtered = branchFiltered[i]
const keep = filtered.length > 0 || !options.pruneEmptyBlocks ||
(i === 0 && hasContentLater(0)) || (i > 0 && hasContentLater(i))
if (keep) {
newBranches.push({ cond: b.cond, templates: filtered })
}
}
if (newBranches.length === 0 && elseFiltered.length === 0 && options.pruneEmptyBlocks) {
return null
}
if (newBranches.length === 0 && elseFiltered.length > 0 && impl.branches.length > 0) {
newBranches.push({ cond: impl.branches[0].cond, templates: [] })
}
const out = Object.create(template)
out.tagImpl = Object.create(impl)
out.tagImpl.branches = newBranches
out.tagImpl.elseTemplates = elseFiltered
return out
}
if (name === 'unless' && impl) {
const templatesFiltered = filterTemplates(impl.templates || [], targetVar, options)
const elseFiltered = filterTemplates(impl.elseTemplates || [], targetVar, options)
if (templatesFiltered.length === 0 && elseFiltered.length === 0 && options.pruneEmptyBlocks) {
return null
}
const out = Object.create(template)
out.tagImpl = Object.create(impl)
out.tagImpl.templates = templatesFiltered
out.tagImpl.elseTemplates = elseFiltered
return out
}
if ((name === 'for' || name === 'tablerow') && impl) {
const templatesFiltered = filterTemplates(impl.templates || [], targetVar, options)
const elseFiltered = filterTemplates(impl.elseTemplates || [], targetVar, options)
if (templatesFiltered.length === 0 && elseFiltered.length === 0 && options.pruneEmptyBlocks) {
return null
}
const out = Object.create(template)
out.tagImpl = Object.create(impl)
out.tagImpl.templates = templatesFiltered
out.tagImpl.elseTemplates = elseFiltered
return out
}
if (name === 'case' && impl) {
const newCases = []
for (const c of (impl.cases || [])) {
const filtered = filterTemplates(c.templates || [], targetVar, options)
if (filtered.length > 0 || !options.pruneEmptyBlocks) {
newCases.push({ val: c.val, templates: filtered })
}
}
const elseFiltered = filterTemplates(impl.elseTemplates || [], targetVar, options)
if (newCases.length === 0 && elseFiltered.length === 0 && options.pruneEmptyBlocks) {
return null
}
const out = Object.create(template)
out.tagImpl = Object.create(impl)
out.tagImpl.cases = newCases
out.tagImpl.elseTemplates = elseFiltered
return out
}
if (name === 'capture' && impl) {
const filtered = filterTemplates(impl.templates || [], targetVar, options)
if (filtered.length === 0 && options.pruneEmptyBlocks) return null
const out = Object.create(template)
out.tagImpl = Object.create(impl)
out.tagImpl.templates = filtered
return out
}
return null
}

function isEmptyBlock (template) {
if (template.type !== 'tag') return false
const impl = template.tagImpl
if (template.name === 'if' && impl && impl.branches) {
return impl.branches.every(b => (b.templates || []).length === 0) &&
(impl.elseTemplates || []).length === 0
}
if (template.name === 'unless' && impl) {
return (impl.templates || []).length === 0 && (impl.elseTemplates || []).length === 0
}
if ((template.name === 'for' || template.name === 'tablerow') && impl) {
return (impl.templates || []).length === 0 && (impl.elseTemplates || []).length === 0
}
if (template.name === 'case' && impl) {
return (impl.cases || []).every(c => (c.templates || []).length === 0) &&
(impl.elseTemplates || []).length === 0
}
if (template.name === 'capture' && impl) {
return (impl.templates || []).length === 0
}
return false
}

/**
* Render pruned AST back to Liquid string.
*/
function renderTemplatesToLiquid (templates) {
if (!Array.isArray(templates)) return ''
return templates.map(t => renderTemplateToLiquid(t)).join('')
}

function renderTemplateToLiquid (template) {
if (!template) return ''
if (template.type === 'html') return template.raw || template.value || ''
if (template.type === 'value') return (template.token && template.token.raw) || ''
if (template.type !== 'tag') return ''

const name = template.name
const token = template.token

if (['assign', 'assignVar', 'parseAssign'].indexOf(name) >= 0) {
return (token && token.raw) || ''
}
Comment on lines +253 to +264
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

renderTemplateToLiquid only emits raw output for tag names ['assign','assignVar','parseAssign'], but filterTemplates can retain any tag listed in options.assignTags. A custom assignment tag (e.g. set) survives filtering but hits renderTemplateToLiquid's default case and becomes an empty string, so the per-variable slice silently loses the assignment; can we make rendering use options.assignTags (or a shared list) so filtered custom assignment tags are re-serialized instead of dropped?

Finding type: Logical Bugs | Severity: 🔴 High


Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents:

In src/split-assignments.js around lines 253 to 264, the renderTemplateToLiquid function
hardcodes ['assign','assignVar','parseAssign'] and so drops custom assignment tags that
passed filterTemplates. Make rendering aware of the configured assignTags by refactoring
renderTemplatesToLiquid and renderTemplateToLiquid to accept an options (or assignTags)
parameter and use options.assignTags instead of the hardcoded array. Propagate this new
parameter from the top-level splitAssignmentsByVariable where
renderTemplatesToLiquid(filtered) is called (change to renderTemplatesToLiquid(filtered,
opts)), and update all internal calls to pass the options down so custom assignment tags
are serialized rather than omitted.


const impl = template.tagImpl
if (name === 'if' && impl && impl.branches && impl.branches.length > 0) {
let out = (token && token.raw) || ''
out += renderTemplatesToLiquid(impl.branches[0].templates || [])
for (let i = 1; i < impl.branches.length; i++) {
const b = impl.branches[i]
out += `{% elsif ${b.cond} %}`
out += renderTemplatesToLiquid(b.templates || [])
}
if ((impl.elseTemplates || []).length > 0) {
out += '{% else %}'
Comment on lines +266 to +276
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

renderTemplateToLiquid hardcodes out += {% elsif ${b.cond} %}, so original {%- elsif … -%} or {% elsif … -%} whitespace-control modifiers are lost. This drops the trim directive and can change rendered HTML/newlines, violating keepNonAssign's intent to preserve original control-flow syntax. Can we store each branch's raw token when parsing and reuse it here instead of emitting a generic {% elsif … %}?

Finding type: Logical Bugs | Severity: 🔴 High


Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents:

In src/split-assignments.js around lines 266 to 276, the renderTemplateToLiquid function
emits a hardcoded `{% elsif ${b.cond} %}`, which loses any original whitespace-control
(e.g. {%- elsif … -%}) and breaks preservation of original control-flow formatting.
Modify the code so it uses the branch's original raw token when available: (a) in
filterControlFlow where newBranches are created (around lines ~147-166), copy the
original branch token (e.g. b.token or b.token.raw) onto each new branch object you
push; (b) in renderTemplateToLiquid (lines 266-276), replace the hardcoded `{% elsif ...
%}` emission with using b.token.raw (falling back to the current synthetic `{% elsif
${b.cond} %}` if b.token or b.token.raw is missing). This preserves any trim/whitespace
modifiers from the source.

out += renderTemplatesToLiquid(impl.elseTemplates)
}
out += '{% endif %}'
return out
}

if (name === 'unless' && impl) {
let out = (token && token.raw) || ''
out += renderTemplatesToLiquid(impl.templates || [])
if ((impl.elseTemplates || []).length > 0) {
out += '{% else %}'
out += renderTemplatesToLiquid(impl.elseTemplates)
}
out += '{% endunless %}'
return out
}

if (name === 'for' && impl) {
let out = (token && token.raw) || ''
out += renderTemplatesToLiquid(impl.templates || [])
if ((impl.elseTemplates || []).length > 0) {
out += '{% else %}'
out += renderTemplatesToLiquid(impl.elseTemplates)
}
out += '{% endfor %}'
return out
}

if (name === 'tablerow' && impl) {
let out = (token && token.raw) || ''
out += renderTemplatesToLiquid(impl.templates || [])
if ((impl.elseTemplates || []).length > 0) {
out += '{% else %}'
out += renderTemplatesToLiquid(impl.elseTemplates)
}
out += '{% endtablerow %}'
return out
}

if (name === 'case' && impl) {
let out = (token && token.raw) || ''
for (const c of (impl.cases || [])) {
out += `{% when ${c.val} %}`
out += renderTemplatesToLiquid(c.templates || [])
}
if ((impl.elseTemplates || []).length > 0) {
out += '{% else %}'
out += renderTemplatesToLiquid(impl.elseTemplates)
}
out += '{% endcase %}'
return out
}

if (name === 'capture' && impl) {
let out = (token && token.raw) || ''
out += renderTemplatesToLiquid(impl.templates || [])
out += '{% endcapture %}'
return out
}

if (name === 'raw' && impl && impl.tokens) {
let out = (token && token.raw) || ''
out += impl.tokens.map(t => t.raw).join('')
out += '{% endraw %}'
return out
}

return ''
}

module.exports = { splitAssignmentsByVariable }
10 changes: 10 additions & 0 deletions test/fixtures/sample-liquid-input.liquid
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{% assign a = 1 %}
{% if x %}
{% assign b = 2 %}
{% else %}
{% assign c = 3 %}
{% endif %}
{% for item in items %}
{% assign d = item %}
{% endfor %}
{% assign e = 5 %}
9 changes: 9 additions & 0 deletions test/fixtures/sample-liquid-output.liquid
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{% assign a = 1 %}
---
{% if x %}{% assign b = 2 %}{% endif %}
---
{% if x %}{% else %}{% assign c = 3 %}{% endif %}
---
{% for item in items %}{% assign d = item %}{% endfor %}
---
{% assign e = 5 %}
Loading