Skip to content
Merged
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
73 changes: 39 additions & 34 deletions src/printer/print/liquid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
shouldPreserveContent,
FORCE_FLAT_GROUP_ID,
last,
transformStringQuotes,
} from '~/printer/utils';

import { printChildren } from '~/printer/print/children';
Expand All @@ -47,7 +48,7 @@ const { replaceEndOfLine } = doc.utils as any;

export function printLiquidDrop(
path: LiquidAstPath,
_options: LiquidParserOptions,
options: LiquidParserOptions,
print: LiquidPrinter,
{ leadingSpaceGroupId, trailingSpaceGroupId }: LiquidPrinterArgs,
) {
Expand Down Expand Up @@ -75,8 +76,11 @@ export function printLiquidDrop(
]);
}

// Transform quotes in base case markup based on liquidSingleQuote option
const markup = transformStringQuotes(node.markup, options.liquidSingleQuote);

// This should probably be better than this but it'll do for now.
const lines = markupLines(node.markup);
const lines = markupLines(markup);
if (lines.length > 1) {
return group([
'{{',
Expand All @@ -88,15 +92,7 @@ export function printLiquidDrop(
]);
}

return group([
'{{',
whitespaceStart,
' ',
node.markup,
' ',
whitespaceEnd,
'}}',
]);
return group(['{{', whitespaceStart, ' ', markup, ' ', whitespaceEnd, '}}']);
}

function printNamedLiquidBlockStart(
Expand Down Expand Up @@ -267,18 +263,22 @@ function printNamedLiquidBlockStart(

function printLiquidStatement(
path: AstPath<Extract<LiquidTag, { name: string; markup: string }>>,
_options: LiquidParserOptions,
options: LiquidParserOptions,
_print: LiquidPrinter,
_args: LiquidPrinterArgs,
): Doc {
const node = path.getValue();
const transformedMarkup = transformStringQuotes(
node.markup,
options.liquidSingleQuote,
);
const shouldSkipLeadingSpace =
node.markup.trim() === '' ||
(node.name === '#' && node.markup.startsWith('#'));
transformedMarkup.trim() === '' ||
(node.name === '#' && transformedMarkup.startsWith('#'));
return doc.utils.removeLines([
node.name,
shouldSkipLeadingSpace ? '' : ' ',
node.markup,
transformedMarkup,
]);
}

Expand Down Expand Up @@ -324,22 +324,9 @@ export function printLiquidBlockStart(
);
}

const lines = markupLines(node.markup);

if (node.name === 'liquid') {
return group([
'{%',
whitespaceStart,
' ',
node.name,
indent([hardline, join(hardline, reindent(lines, true))]),
hardline,
whitespaceEnd,
'%}',
]);
}

// For Twig comments ({# ... #}), don't transform quotes - comments should be preserved as-is
if (node.name === 'twig') {
const lines = markupLines(node.markup);
if (lines.length > 1) {
return group([
'{#',
Expand All @@ -351,17 +338,36 @@ export function printLiquidBlockStart(
]);
}

const markup = node.markup;
return group([
'{#',
whitespaceStart,
markup ? ` ${markup}` : '',
node.markup ? ` ${node.markup}` : '',
' ',
whitespaceEnd,
'#}',
]);
}

// Transform quotes in base case markup based on liquidSingleQuote option
const transformedMarkup = transformStringQuotes(
node.markup,
options.liquidSingleQuote,
);
const lines = markupLines(transformedMarkup);

if (node.name === 'liquid') {
return group([
'{%',
whitespaceStart,
' ',
node.name,
indent([hardline, join(hardline, reindent(lines, true))]),
hardline,
whitespaceEnd,
'%}',
]);
}

if (lines.length > 1) {
return group([
'{%',
Expand All @@ -373,13 +379,12 @@ export function printLiquidBlockStart(
]);
}

const markup = node.markup;
return group([
'{%',
whitespaceStart,
' ',
node.name,
markup ? ` ${markup}` : '',
transformedMarkup ? ` ${transformedMarkup}` : '',
' ',
whitespaceEnd,
'%}',
Expand Down
37 changes: 37 additions & 0 deletions src/printer/utils/string.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,40 @@ export function hasMoreThanOneNewLineBetweenNodes(
const count = between.match(/\n/g)?.length || 0;
return count > 1;
}

/**
* Transforms quotes in base case markup strings based on the liquidSingleQuote option.
* This handles cases where the parser falls back to storing markup as a raw string
* (e.g., Twig function calls like `stimulus_controller('controller-name')`).
*
* The function replaces quotes while being careful to:
* - Not replace quotes that are escaped
* - Not replace quotes inside strings that contain the target quote character
* - Handle nested quotes properly
*/
export function transformStringQuotes(
markup: string,
liquidSingleQuote: boolean,
): string {
const preferredQuote = liquidSingleQuote ? "'" : '"';

// Match strings with the non-preferred quote style
// This regex matches quoted strings, being careful about escapes
const stringRegex = liquidSingleQuote
? /"([^"\\]|\\.)*"/g // Match double-quoted strings
: /'([^'\\]|\\.)*'/g; // Match single-quoted strings

return markup.replace(stringRegex, (match) => {
// Get the content without the outer quotes
const content = match.slice(1, -1);

// If the content contains the preferred quote (unescaped), keep original quotes
// to avoid breaking the string
if (content.includes(preferredQuote)) {
return match;
}

// Replace the quotes
return preferredQuote + content + preferredQuote;
});
}
23 changes: 23 additions & 0 deletions test/twig-function-quotes/fixed.liquid
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
It should convert double quotes to single quotes in Twig function calls (base case)
{{ stimulus_controller('controller-name') }}
<div class="class name"></div>

It should keep double quotes if the string contains single quotes
{{ stimulus_controller("it's a name") }}

It should convert double quotes to single quotes in tag base case
{% set foo = 'bar' %}

It should handle multiple arguments with quotes
{{ some_function('arg1', 'arg2') }}

It should convert to double quotes when liquidSingleQuote is false
liquidSingleQuote: false
{{ stimulus_controller("controller-name") }}

It should keep single quotes if string contains double quotes when liquidSingleQuote is false
liquidSingleQuote: false
{{ stimulus_controller('say "hello"') }}

It should not modify HTML attributes within Twig comments
{# <div class="should-not-change"></div> #}
23 changes: 23 additions & 0 deletions test/twig-function-quotes/index.liquid
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
It should convert double quotes to single quotes in Twig function calls (base case)
{{ stimulus_controller("controller-name") }}
<div class="class name"></div>

It should keep double quotes if the string contains single quotes
{{ stimulus_controller("it's a name") }}

It should convert double quotes to single quotes in tag base case
{% set foo = "bar" %}

It should handle multiple arguments with quotes
{{ some_function("arg1", "arg2") }}

It should convert to double quotes when liquidSingleQuote is false
liquidSingleQuote: false
{{ stimulus_controller('controller-name') }}

It should keep single quotes if string contains double quotes when liquidSingleQuote is false
liquidSingleQuote: false
{{ stimulus_controller('say "hello"') }}

It should not modify HTML attributes within Twig comments
{# <div class="should-not-change"></div> #}
6 changes: 6 additions & 0 deletions test/twig-function-quotes/index.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { assertFormattedEqualsFixed } from '../test-helpers';
import * as path from 'path';

describe(`Unit: ${path.basename(__dirname)}`, () => {
assertFormattedEqualsFixed(__dirname);
});