@mrhenry/prettier-twig 0.1.2 → 0.1.4

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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrhenry/prettier-twig",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "type": "module",
5
5
  "main": "src/index.js",
6
6
  "license": "MIT",
@@ -11,7 +11,7 @@
11
11
  "test": "node --test test/*.test.js"
12
12
  },
13
13
  "dependencies": {
14
- "@mrhenry/twig-html-parser": "0.1.0"
14
+ "@mrhenry/twig-html-parser": "^0.1.1"
15
15
  },
16
16
  "peerDependencies": {
17
17
  "prettier": "^3.0.0"
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 !== '' && !'([{'.includes(previous) && !')]},'.includes(ch)) {
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) => formatStructures(entry, false, multiline, trailingComma));
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
- const rawValue = attr.valueChunks
771
- .map((/** @type {any} */ c) => (c.type === 'twig' ? c.atom.raw : c.text))
772
- .join('');
773
- const value = isEventHandler || hasControlFlow ? rawValue : printAttributeValue(rawValue);
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 () => {