@mrhenry/prettier-twig 0.1.2 → 0.1.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/CHANGELOG.md +12 -0
- package/package.json +1 -1
- package/src/printer.js +73 -13
- package/test/prettier-twig.test.js +80 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.3 (2026-09-24)
|
|
4
|
+
|
|
5
|
+
* Normalize twig structures inside attribute values (`{ w:1256 , q: 45 }` becomes `{ w: 1256, q: 45 }`)
|
|
6
|
+
* Normalize mapping key/value spacing (`{a:1}` becomes `{ a: 1 }`), leaving ternaries and slices untouched
|
|
7
|
+
* Drop whitespace around the `|` filter operator (`image.imgix_src | add_query_arg` becomes `image.imgix_src|add_query_arg`)
|
|
8
|
+
|
|
9
|
+
## 0.1.2 (2026-09-22)
|
|
10
|
+
|
|
11
|
+
* Fix inline elements (text-level, ruby, inline-level replaced content and inline-level form controls) being pushed onto their own line instead of staying with the surrounding text
|
|
12
|
+
* Fix a comment written right after an opening tag being moved onto its own line
|
package/package.json
CHANGED
package/src/printer.js
CHANGED
|
@@ -214,15 +214,21 @@ function hasBlankLine(source, from, to) {
|
|
|
214
214
|
|
|
215
215
|
/**
|
|
216
216
|
* Collapses runs of whitespace in a twig construct's body to single spaces,
|
|
217
|
-
* leaving the contents of string literals untouched.
|
|
217
|
+
* leaving the contents of string literals untouched. When `removeFilterSpaces`
|
|
218
|
+
* is set, whitespace around the `|` filter operator is dropped too (`a | b`
|
|
219
|
+
* becomes `a|b`); comment bodies keep their spaces by leaving it unset.
|
|
218
220
|
*
|
|
219
221
|
* @param {string} text
|
|
222
|
+
* @param {boolean} [removeFilterSpaces]
|
|
220
223
|
* @returns {string}
|
|
221
224
|
*/
|
|
222
|
-
function collapseWhitespace(text) {
|
|
225
|
+
function collapseWhitespace(text, removeFilterSpaces = false) {
|
|
223
226
|
let out = '';
|
|
224
227
|
let quote = '';
|
|
225
228
|
let pendingSpace = false;
|
|
229
|
+
// `|` is Twig's filter operator: no space goes before or after it
|
|
230
|
+
const noSpaceBefore = removeFilterSpaces ? ')]},|' : ')]},';
|
|
231
|
+
const noSpaceAfter = removeFilterSpaces ? '([{|' : '([{';
|
|
226
232
|
for (let i = 0; i < text.length; i += 1) {
|
|
227
233
|
const ch = text[i];
|
|
228
234
|
if (quote !== '') {
|
|
@@ -249,7 +255,7 @@ function collapseWhitespace(text) {
|
|
|
249
255
|
continue;
|
|
250
256
|
}
|
|
251
257
|
const previous = out[out.length - 1] ?? '';
|
|
252
|
-
if (pendingSpace && out !== '' && !
|
|
258
|
+
if (pendingSpace && out !== '' && !noSpaceAfter.includes(previous) && !noSpaceBefore.includes(ch)) {
|
|
253
259
|
out += ' ';
|
|
254
260
|
}
|
|
255
261
|
pendingSpace = false;
|
|
@@ -303,7 +309,7 @@ function twigDelimiters(raw) {
|
|
|
303
309
|
*/
|
|
304
310
|
function normalizeTwigTag(raw) {
|
|
305
311
|
const { open, close, body } = twigDelimiters(raw);
|
|
306
|
-
const normalized = collapseWhitespace(body).trim();
|
|
312
|
+
const normalized = collapseWhitespace(body, true).trim();
|
|
307
313
|
return normalized === '' ? `${open}${close}` : `${open} ${normalized} ${close}`;
|
|
308
314
|
}
|
|
309
315
|
|
|
@@ -359,7 +365,7 @@ function formatTwigHead(raw, options) {
|
|
|
359
365
|
const unit = options.useTabs === false ? ' '.repeat(options.tabWidth ?? 4) : '\t';
|
|
360
366
|
const lines = body
|
|
361
367
|
.split(/\r?\n/)
|
|
362
|
-
.map((/** @type {string} */ text) => collapseWhitespace(text).trim());
|
|
368
|
+
.map((/** @type {string} */ text) => collapseWhitespace(text, true).trim());
|
|
363
369
|
while (lines.length > 0 && lines[0] === '') {
|
|
364
370
|
lines.shift();
|
|
365
371
|
}
|
|
@@ -498,6 +504,43 @@ function formatStructures(text, forceBreak, multiline = [], trailingComma = true
|
|
|
498
504
|
return docs.length === 0 ? '' : docs;
|
|
499
505
|
}
|
|
500
506
|
|
|
507
|
+
/**
|
|
508
|
+
* Normalizes the key/value separator of a mapping entry to `key: value` (one
|
|
509
|
+
* space after, none before). Only the first top-level colon is touched, so a
|
|
510
|
+
* ternary or slice inside the value keeps its spacing. Entries without a colon
|
|
511
|
+
* (a shorthand key) are returned unchanged.
|
|
512
|
+
*
|
|
513
|
+
* @param {string} entry
|
|
514
|
+
* @returns {string}
|
|
515
|
+
*/
|
|
516
|
+
function normalizeMappingEntry(entry) {
|
|
517
|
+
let depth = 0;
|
|
518
|
+
let quote = '';
|
|
519
|
+
for (let i = 0; i < entry.length; i += 1) {
|
|
520
|
+
const ch = entry[i];
|
|
521
|
+
if (quote !== '') {
|
|
522
|
+
if (ch === '\\') {
|
|
523
|
+
i += 1;
|
|
524
|
+
} else if (ch === quote) {
|
|
525
|
+
quote = '';
|
|
526
|
+
}
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
if (ch === '"' || ch === "'") {
|
|
530
|
+
quote = ch;
|
|
531
|
+
} else if (ch === '(' || ch === '{' || ch === '[') {
|
|
532
|
+
depth += 1;
|
|
533
|
+
} else if (ch === ')' || ch === '}' || ch === ']') {
|
|
534
|
+
depth -= 1;
|
|
535
|
+
} else if (ch === ':' && depth === 0) {
|
|
536
|
+
const key = entry.slice(0, i).trim();
|
|
537
|
+
const value = entry.slice(i + 1).trim();
|
|
538
|
+
return `${key}: ${value}`;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
return entry;
|
|
542
|
+
}
|
|
543
|
+
|
|
501
544
|
/**
|
|
502
545
|
* @param {string} text A single `{ … }` mapping or `[ … ]` sequence.
|
|
503
546
|
* @param {boolean} forceBreak Whether the container must expand.
|
|
@@ -512,7 +555,9 @@ function formatContainer(text, forceBreak, multiline = [], trailingComma = true)
|
|
|
512
555
|
if (entries.length === 0) {
|
|
513
556
|
return `${open}${close}`;
|
|
514
557
|
}
|
|
515
|
-
const parts = entries.map((entry) =>
|
|
558
|
+
const parts = entries.map((entry) =>
|
|
559
|
+
formatStructures(open === '{' ? normalizeMappingEntry(entry) : entry, false, multiline, trailingComma),
|
|
560
|
+
);
|
|
516
561
|
const trailing = trailingComma ? ifBreak(',') : '';
|
|
517
562
|
// mappings get inner spaces (`{ a: 1 }`), sequences do not (`[1, 2]`)
|
|
518
563
|
const edge = open === '{' ? line : softline;
|
|
@@ -533,7 +578,7 @@ function formatContainer(text, forceBreak, multiline = [], trailingComma = true)
|
|
|
533
578
|
*/
|
|
534
579
|
function formatTwigTag(raw, forceBreakStructures = false, trailingComma = true) {
|
|
535
580
|
const { open, close, body } = twigDelimiters(raw);
|
|
536
|
-
const normalized = collapseWhitespace(body).trim();
|
|
581
|
+
const normalized = collapseWhitespace(body, true).trim();
|
|
537
582
|
if (normalized === '') {
|
|
538
583
|
return `${open}${close}`;
|
|
539
584
|
}
|
|
@@ -753,9 +798,10 @@ const RAWTEXT_ELEMENTS = new Set(['script', 'style', 'textarea', 'title', 'pre']
|
|
|
753
798
|
* Prints an attribute.
|
|
754
799
|
*
|
|
755
800
|
* @param {any} attr
|
|
801
|
+
* @param {any} options
|
|
756
802
|
* @returns {any} A Prettier doc.
|
|
757
803
|
*/
|
|
758
|
-
function printAttribute(attr) {
|
|
804
|
+
function printAttribute(attr, options) {
|
|
759
805
|
if (attr.quote === null && attr.valueChunks.length === 0) {
|
|
760
806
|
return attr.nameRaw;
|
|
761
807
|
}
|
|
@@ -767,10 +813,24 @@ function printAttribute(attr) {
|
|
|
767
813
|
const hasControlFlow = attr.valueChunks.some(
|
|
768
814
|
(/** @type {any} */ c) => c.type === 'twig' && c.atom.tag,
|
|
769
815
|
);
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
.
|
|
773
|
-
|
|
816
|
+
let value;
|
|
817
|
+
if (isEventHandler || hasControlFlow) {
|
|
818
|
+
value = attr.valueChunks
|
|
819
|
+
.map((/** @type {any} */ c) => (c.type === 'twig' ? c.atom.raw : c.text))
|
|
820
|
+
.join('');
|
|
821
|
+
} else {
|
|
822
|
+
// Plain values get their twig expressions normalized (`{ w:1256 , q: 45 }`
|
|
823
|
+
// becomes `{ w:1256, q: 45 }`, `a | b` becomes `a|b`); the surrounding
|
|
824
|
+
// literal text is kept and the result is re-indented.
|
|
825
|
+
const formattedValue = attr.valueChunks
|
|
826
|
+
.map((/** @type {any} */ c) =>
|
|
827
|
+
c.type === 'twig'
|
|
828
|
+
? docToString(formatTwigTag(c.atom.raw, false, options.trailingComma !== 'none'), options)
|
|
829
|
+
: c.text,
|
|
830
|
+
)
|
|
831
|
+
.join('');
|
|
832
|
+
value = printAttributeValue(formattedValue);
|
|
833
|
+
}
|
|
774
834
|
if (attr.quote === null) {
|
|
775
835
|
return [attr.nameRaw, '=', value];
|
|
776
836
|
}
|
|
@@ -1260,7 +1320,7 @@ function printNode(node, options) {
|
|
|
1260
1320
|
case 'twigComment':
|
|
1261
1321
|
return printTwigComment(node);
|
|
1262
1322
|
case 'attr':
|
|
1263
|
-
return printAttribute(node);
|
|
1323
|
+
return printAttribute(node, options);
|
|
1264
1324
|
case 'text':
|
|
1265
1325
|
return printText(node);
|
|
1266
1326
|
case 'comment':
|
|
@@ -1764,6 +1764,86 @@ test('aligns multi-line attribute values on one indent level', async () => {
|
|
|
1764
1764
|
`,
|
|
1765
1765
|
);
|
|
1766
1766
|
}
|
|
1767
|
+
|
|
1768
|
+
{
|
|
1769
|
+
// a value whose lines have no indentation is indented one level
|
|
1770
|
+
const out = await fmt(`<source
|
|
1771
|
+
srcset="
|
|
1772
|
+
{{ url|add_query_arg({ w: 120 }) }} 120w,
|
|
1773
|
+
{{ url|add_query_arg({ w: 250, q: 45 }) }} 250w,
|
|
1774
|
+
{{ url|add_query_arg({ w: 380, q: 45 }) }} 380w
|
|
1775
|
+
"
|
|
1776
|
+
sizes="calc(100vw - 96px - 48px)"
|
|
1777
|
+
media="(min-width: 35rem)"
|
|
1778
|
+
>`);
|
|
1779
|
+
assert.equal(
|
|
1780
|
+
out,
|
|
1781
|
+
`<source
|
|
1782
|
+
srcset="
|
|
1783
|
+
{{ url|add_query_arg({ w: 120 }) }} 120w,
|
|
1784
|
+
{{ url|add_query_arg({ w: 250, q: 45 }) }} 250w,
|
|
1785
|
+
{{ url|add_query_arg({ w: 380, q: 45 }) }} 380w
|
|
1786
|
+
"
|
|
1787
|
+
sizes="calc(100vw - 96px - 48px)"
|
|
1788
|
+
media="(min-width: 35rem)"
|
|
1789
|
+
>
|
|
1790
|
+
`,
|
|
1791
|
+
);
|
|
1792
|
+
}
|
|
1793
|
+
});
|
|
1794
|
+
|
|
1795
|
+
test('normalizes twig structures inside attribute values', async () => {
|
|
1796
|
+
const out = await fmt(`<source
|
|
1797
|
+
media="(min-width: 90rem)"
|
|
1798
|
+
srcset="
|
|
1799
|
+
{{ url|add_query_arg({ w:1256 , q: 45 }) }} 2x,
|
|
1800
|
+
{{ url|add_query_arg({ w: 628 }) }}
|
|
1801
|
+
"
|
|
1802
|
+
>`);
|
|
1803
|
+
assert.equal(
|
|
1804
|
+
out,
|
|
1805
|
+
`<source
|
|
1806
|
+
media="(min-width: 90rem)"
|
|
1807
|
+
srcset="
|
|
1808
|
+
{{ url|add_query_arg({ w: 1256, q: 45 }) }} 2x,
|
|
1809
|
+
{{ url|add_query_arg({ w: 628 }) }}
|
|
1810
|
+
"
|
|
1811
|
+
>
|
|
1812
|
+
`,
|
|
1813
|
+
);
|
|
1814
|
+
});
|
|
1815
|
+
|
|
1816
|
+
test('normalizes mapping key/value spacing without touching ternary or slices', async () => {
|
|
1817
|
+
assert.equal(await fmt(`{{ {a:1,b :2} }}`), `{{ { a: 1, b: 2 } }}\n`);
|
|
1818
|
+
assert.equal(await fmt(`{{ { x: a ? b : c, y: list[1:2] } }}`), `{{ { x: a ? b : c, y: list[1:2] } }}\n`);
|
|
1819
|
+
});
|
|
1820
|
+
|
|
1821
|
+
test('drops whitespace around the filter operator', async () => {
|
|
1822
|
+
const source = `{% set url = image.imgix_src|add_query_arg({
|
|
1823
|
+
auto: 'format',
|
|
1824
|
+
crop: 'faces,entropy',
|
|
1825
|
+
ar: aspect_ratio,
|
|
1826
|
+
fit: 'crop',
|
|
1827
|
+
q: 60,
|
|
1828
|
+
}, false) %}
|
|
1829
|
+
`;
|
|
1830
|
+
assert.equal(await fmt(source), source);
|
|
1831
|
+
|
|
1832
|
+
// a subtle variation that does get formatted: spaces around the filters
|
|
1833
|
+
assert.equal(
|
|
1834
|
+
await fmt(`{% set url = image.imgix_src | add_query_arg({
|
|
1835
|
+
auto: 'format',
|
|
1836
|
+
crop: 'faces,entropy',
|
|
1837
|
+
ar: aspect_ratio,
|
|
1838
|
+
fit: 'crop',
|
|
1839
|
+
q: 60,
|
|
1840
|
+
}, false) %}
|
|
1841
|
+
`),
|
|
1842
|
+
source,
|
|
1843
|
+
);
|
|
1844
|
+
|
|
1845
|
+
// whitespace inside a string literal is untouched
|
|
1846
|
+
assert.equal(await fmt(`{{ 'a | b' | upper }}`), `{{ 'a | b'|upper }}\n`);
|
|
1767
1847
|
});
|
|
1768
1848
|
|
|
1769
1849
|
test('indents twig block bodies while section heads stay at block indent', async () => {
|