@mrhenry/prettier-twig 0.1.1 → 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 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.1",
3
+ "version": "0.1.3",
4
4
  "type": "module",
5
5
  "main": "src/index.js",
6
6
  "license": "MIT",
package/src/printer.js CHANGED
@@ -70,6 +70,13 @@ function printChildren(path, printCallback, source, leadingHardline = true, opti
70
70
  docs.push(childDoc);
71
71
  } else if (blankBefore) {
72
72
  docs.push([hardline, hardline, childDoc]);
73
+ } else if (
74
+ first &&
75
+ !gap.includes('\n') &&
76
+ (child.type === 'comment' || child.type === 'twigComment')
77
+ ) {
78
+ // a comment written right after the opening tag stays there
79
+ docs.push(gap === '' ? childDoc : [' ', childDoc]);
73
80
  } else if (!gap.includes('\n') && previousWasInline && inline) {
74
81
  // consecutive inline expressions on one source line stay together
75
82
  docs.push([gap === '' ? '' : ' ', childDoc]);
@@ -132,11 +139,25 @@ function isInlineChild(node) {
132
139
  return node.type === 'element' && INLINE_ELEMENTS.has(node.name);
133
140
  }
134
141
 
135
- /** Phrasing (inline) elements that may share a line with neighbouring text. */
142
+ /**
143
+ * Elements that are `display: inline` by default (and the inline-level ruby,
144
+ * replaced and form-control elements), so they may share a line with
145
+ * neighbouring text instead of being moved onto their own line.
146
+ */
136
147
  const INLINE_ELEMENTS = new Set([
137
- 'a', 'abbr', 'b', 'bdi', 'bdo', 'br', 'cite', 'code', 'data', 'dfn', 'em',
138
- 'i', 'img', 'kbd', 'mark', 'q', 'rp', 'rt', 'ruby', 's', 'samp', 'small',
139
- 'span', 'strong', 'sub', 'sup', 'time', 'u', 'var', 'wbr',
148
+ // text-level semantics
149
+ 'a', 'abbr', 'acronym', 'b', 'bdi', 'bdo', 'big', 'br', 'cite', 'code',
150
+ 'data', 'del', 'dfn', 'em', 'font', 'i', 'ins', 'kbd', 'mark', 'nobr',
151
+ 'q', 's', 'samp', 'small', 'span', 'strike', 'strong', 'sub', 'sup',
152
+ 'time', 'tt', 'u', 'var', 'wbr',
153
+ // ruby annotations
154
+ 'rp', 'rt', 'ruby',
155
+ // embedded / replaced inline-level content
156
+ 'audio', 'canvas', 'embed', 'iframe', 'img', 'map', 'math', 'object',
157
+ 'picture', 'svg', 'video',
158
+ // inline-level form controls
159
+ 'button', 'input', 'label', 'meter', 'output', 'progress', 'select',
160
+ 'textarea',
140
161
  ]);
141
162
 
142
163
  /**
@@ -193,15 +214,21 @@ function hasBlankLine(source, from, to) {
193
214
 
194
215
  /**
195
216
  * Collapses runs of whitespace in a twig construct's body to single spaces,
196
- * 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.
197
220
  *
198
221
  * @param {string} text
222
+ * @param {boolean} [removeFilterSpaces]
199
223
  * @returns {string}
200
224
  */
201
- function collapseWhitespace(text) {
225
+ function collapseWhitespace(text, removeFilterSpaces = false) {
202
226
  let out = '';
203
227
  let quote = '';
204
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 ? '([{|' : '([{';
205
232
  for (let i = 0; i < text.length; i += 1) {
206
233
  const ch = text[i];
207
234
  if (quote !== '') {
@@ -228,7 +255,7 @@ function collapseWhitespace(text) {
228
255
  continue;
229
256
  }
230
257
  const previous = out[out.length - 1] ?? '';
231
- if (pendingSpace && out !== '' && !'([{'.includes(previous) && !')]},'.includes(ch)) {
258
+ if (pendingSpace && out !== '' && !noSpaceAfter.includes(previous) && !noSpaceBefore.includes(ch)) {
232
259
  out += ' ';
233
260
  }
234
261
  pendingSpace = false;
@@ -282,7 +309,7 @@ function twigDelimiters(raw) {
282
309
  */
283
310
  function normalizeTwigTag(raw) {
284
311
  const { open, close, body } = twigDelimiters(raw);
285
- const normalized = collapseWhitespace(body).trim();
312
+ const normalized = collapseWhitespace(body, true).trim();
286
313
  return normalized === '' ? `${open}${close}` : `${open} ${normalized} ${close}`;
287
314
  }
288
315
 
@@ -338,7 +365,7 @@ function formatTwigHead(raw, options) {
338
365
  const unit = options.useTabs === false ? ' '.repeat(options.tabWidth ?? 4) : '\t';
339
366
  const lines = body
340
367
  .split(/\r?\n/)
341
- .map((/** @type {string} */ text) => collapseWhitespace(text).trim());
368
+ .map((/** @type {string} */ text) => collapseWhitespace(text, true).trim());
342
369
  while (lines.length > 0 && lines[0] === '') {
343
370
  lines.shift();
344
371
  }
@@ -477,6 +504,43 @@ function formatStructures(text, forceBreak, multiline = [], trailingComma = true
477
504
  return docs.length === 0 ? '' : docs;
478
505
  }
479
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
+
480
544
  /**
481
545
  * @param {string} text A single `{ … }` mapping or `[ … ]` sequence.
482
546
  * @param {boolean} forceBreak Whether the container must expand.
@@ -491,7 +555,9 @@ function formatContainer(text, forceBreak, multiline = [], trailingComma = true)
491
555
  if (entries.length === 0) {
492
556
  return `${open}${close}`;
493
557
  }
494
- 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
+ );
495
561
  const trailing = trailingComma ? ifBreak(',') : '';
496
562
  // mappings get inner spaces (`{ a: 1 }`), sequences do not (`[1, 2]`)
497
563
  const edge = open === '{' ? line : softline;
@@ -512,7 +578,7 @@ function formatContainer(text, forceBreak, multiline = [], trailingComma = true)
512
578
  */
513
579
  function formatTwigTag(raw, forceBreakStructures = false, trailingComma = true) {
514
580
  const { open, close, body } = twigDelimiters(raw);
515
- const normalized = collapseWhitespace(body).trim();
581
+ const normalized = collapseWhitespace(body, true).trim();
516
582
  if (normalized === '') {
517
583
  return `${open}${close}`;
518
584
  }
@@ -521,14 +587,13 @@ function formatTwigTag(raw, forceBreakStructures = false, trailingComma = true)
521
587
  }
522
588
 
523
589
  /**
524
- * Prints an element's start tag.
590
+ * Whether an element's start tag prints on a single line: at most one real
591
+ * attribute, no conditional attributes and no multi-line attribute value.
525
592
  *
526
593
  * @param {any} node
527
- * @param {any[]} attrDocs
528
- * @param {string} source
529
- * @returns {any} A Prettier doc.
594
+ * @returns {boolean}
530
595
  */
531
- function printOpenTag(node, attrDocs, source) {
596
+ function isSingleLineStartTag(node) {
532
597
  const attrs = node.attrs ?? [];
533
598
  const realAttrs = attrs.filter(
534
599
  (/** @type {any} */ a) => a.type !== 'twig' && a.type !== 'twigBlock' && a.type !== 'twigComment',
@@ -541,8 +606,20 @@ function printOpenTag(node, attrDocs, source) {
541
606
  : /[\r\n]/.test(c.text),
542
607
  ),
543
608
  );
609
+ return realAttrs.length <= 1 && !hasConditional && !hasComplexValue;
610
+ }
611
+
612
+ /**
613
+ * Prints an element's start tag.
614
+ *
615
+ * @param {any} node
616
+ * @param {any[]} attrDocs
617
+ * @param {string} source
618
+ * @returns {any} A Prettier doc.
619
+ */
620
+ function printOpenTag(node, attrDocs, source) {
544
621
  const forcedSingleLine = FORCE_SINGLE_LINE.has(node.name);
545
- const singleLineTag = forcedSingleLine || (realAttrs.length <= 1 && !hasConditional && !hasComplexValue);
622
+ const singleLineTag = forcedSingleLine || isSingleLineStartTag(node);
546
623
  return forcedSingleLine
547
624
  ? // `html`, `link` and `meta` are never broken across lines
548
625
  collapseWhitespace(source.slice(node.rawStart, node.startTagEnd)).replace(
@@ -648,7 +725,11 @@ function isTextOnly(node) {
648
725
 
649
726
  /**
650
727
  * Whether an element's non-empty content is only phrasing content (text,
651
- * prints, comments and inline twig blocks).
728
+ * prints, comments, inline twig blocks and inline elements).
729
+ *
730
+ * Inline element children only count as phrasing when the element's own start
731
+ * tag prints on a single line: once a multi-line start tag puts its `>` on a
732
+ * line of its own, the content belongs on the line below it.
652
733
  *
653
734
  * @param {any} node
654
735
  * @param {string} source
@@ -659,13 +740,15 @@ function isPhrasingContent(node, source) {
659
740
  if (children.length === 0) {
660
741
  return false;
661
742
  }
743
+ const inlineElementsAttach = isSingleLineStartTag(node);
662
744
  return children.every(
663
745
  (/** @type {any} */ c) =>
664
746
  c.type === 'text' ||
665
747
  c.type === 'twig' ||
666
748
  c.type === 'comment' ||
667
749
  c.type === 'twigComment' ||
668
- (c.type === 'twigBlock' && isInlineTwigBlock(c, source)),
750
+ (c.type === 'twigBlock' && isInlineTwigBlock(c, source)) ||
751
+ (c.type === 'element' && inlineElementsAttach && INLINE_ELEMENTS.has(c.name)),
669
752
  );
670
753
  }
671
754
 
@@ -715,9 +798,10 @@ const RAWTEXT_ELEMENTS = new Set(['script', 'style', 'textarea', 'title', 'pre']
715
798
  * Prints an attribute.
716
799
  *
717
800
  * @param {any} attr
801
+ * @param {any} options
718
802
  * @returns {any} A Prettier doc.
719
803
  */
720
- function printAttribute(attr) {
804
+ function printAttribute(attr, options) {
721
805
  if (attr.quote === null && attr.valueChunks.length === 0) {
722
806
  return attr.nameRaw;
723
807
  }
@@ -729,10 +813,24 @@ function printAttribute(attr) {
729
813
  const hasControlFlow = attr.valueChunks.some(
730
814
  (/** @type {any} */ c) => c.type === 'twig' && c.atom.tag,
731
815
  );
732
- const rawValue = attr.valueChunks
733
- .map((/** @type {any} */ c) => (c.type === 'twig' ? c.atom.raw : c.text))
734
- .join('');
735
- 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
+ }
736
834
  if (attr.quote === null) {
737
835
  return [attr.nameRaw, '=', value];
738
836
  }
@@ -1222,7 +1320,7 @@ function printNode(node, options) {
1222
1320
  case 'twigComment':
1223
1321
  return printTwigComment(node);
1224
1322
  case 'attr':
1225
- return printAttribute(node);
1323
+ return printAttribute(node, options);
1226
1324
  case 'text':
1227
1325
  return printText(node);
1228
1326
  case 'comment':
@@ -265,6 +265,56 @@ test('keeps text around inline elements on one line', async () => {
265
265
  );
266
266
  });
267
267
 
268
+ test('keeps inline elements from being pushed onto their own line', async () => {
269
+ {
270
+ const source = `<p><strong>If you are seeing this message you are viewing an incomplete page.<br>Everything you see here is WIP.</strong></p>
271
+ `;
272
+ assert.equal(await fmt(source), source);
273
+ }
274
+
275
+ {
276
+ const source = `<p class="type-d"><b>{{ __('There are no results.', 'mr') }}</b></p>
277
+ `;
278
+ assert.equal(await fmt(source), source);
279
+ }
280
+
281
+ {
282
+ // no text sibling: the whole inline run stays on one line
283
+ const source = `<p><del>old</del><ins>new</ins></p>
284
+ `;
285
+ assert.equal(await fmt(source), source);
286
+ }
287
+
288
+ {
289
+ // inline-level form controls and embedded content are covered too
290
+ const source = `<p><label for="x">Name</label><input id="x"><img src="a.png"></p>
291
+ `;
292
+ assert.equal(await fmt(source), source);
293
+ }
294
+
295
+ {
296
+ // ruby annotations and legacy inline elements
297
+ const source = `<p><ruby>漢<rt>kan</rt></ruby><tt>mono</tt><big>big</big></p>
298
+ `;
299
+ assert.equal(await fmt(source), source);
300
+ }
301
+
302
+ {
303
+ // a multi-line start tag puts its `>` on its own line, so the inline
304
+ // content belongs on the line below it (unchanged behaviour)
305
+ assert.equal(
306
+ await fmt(`<div class="a" data-x="1"><br></div>`),
307
+ `<div
308
+ class="a"
309
+ data-x="1"
310
+ >
311
+ <br>
312
+ </div>
313
+ `,
314
+ );
315
+ }
316
+ });
317
+
268
318
  test('places the closing > at the opening indent in multi-line tags', async () => {
269
319
  {
270
320
  const out = await fmt(`<div
@@ -1155,6 +1205,50 @@ test('keeps inline comments next to content without adding line breaks', async (
1155
1205
  }
1156
1206
  });
1157
1207
 
1208
+ test('keeps a comment written after the opening tag on that line', async () => {
1209
+ {
1210
+ const source = `<html lang="en" class="no-js"> {# TODO: set lang correctly #}
1211
+ <head></head>
1212
+ </html>
1213
+ `;
1214
+ assert.equal(await fmt(source), source);
1215
+ }
1216
+
1217
+ {
1218
+ // open-ended partial: the comment still follows the opening tag
1219
+ const source = `<html lang="en" class="no-js"> {# TODO: set lang correctly #}
1220
+ `;
1221
+ assert.equal(await fmt(source), source);
1222
+ }
1223
+
1224
+ {
1225
+ const source = `<div class="a"> {# c #}
1226
+ <p>x</p>
1227
+ </div>
1228
+ `;
1229
+ assert.equal(await fmt(source), source);
1230
+ }
1231
+
1232
+ {
1233
+ // html comments behave like twig comments
1234
+ const source = `<div class="a"> <!-- c -->
1235
+ <p>x</p>
1236
+ </div>
1237
+ `;
1238
+ assert.equal(await fmt(source), source);
1239
+ }
1240
+
1241
+ {
1242
+ // a newline after the opening tag still puts the comment on its own line
1243
+ const source = `<div class="a">
1244
+ {# c #}
1245
+ <p>x</p>
1246
+ </div>
1247
+ `;
1248
+ assert.equal(await fmt(source), source);
1249
+ }
1250
+ });
1251
+
1158
1252
  test('keeps html comments and doctype', async () => {
1159
1253
  const out = await fmt(`<!doctype html>
1160
1254
  <!-- c -->
@@ -1670,6 +1764,86 @@ test('aligns multi-line attribute values on one indent level', async () => {
1670
1764
  `,
1671
1765
  );
1672
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`);
1673
1847
  });
1674
1848
 
1675
1849
  test('indents twig block bodies while section heads stay at block indent', async () => {