@mrhenry/prettier-twig 0.1.4 → 0.1.6

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 CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.6 (2026-09-26)
4
+
5
+ * Fixed an exponential-time denial of service when formatting nested twig blocks. The printer now declares `getVisitorKeys`, so Prettier's traversal visits a `twigBlock` body once instead of twice per level (a 25-level template previously took over a minute to format).
6
+ * Depth-capped the mapping/sequence structure formatter, so a deeply nested expression (`[[[ … ]]]`) is emitted verbatim instead of overflowing the call stack.
7
+ * Keep trailing comments inside twig block bodies on the line they follow, along with the inline content on that line
8
+
9
+ ## 0.1.5 (2026-09-25)
10
+
11
+ * Preserve the `<html>` tag's line layout: keep it on a single line when the source did (even with several attributes), and format it multi-line when the source split it
12
+
3
13
  ## 0.1.3 (2026-09-24)
4
14
 
5
15
  * Normalize twig structures inside attribute values (`{ w:1256 , q: 45 }` becomes `{ w: 1256, q: 45 }`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrhenry/prettier-twig",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
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.1"
14
+ "@mrhenry/twig-html-parser": "^0.1.2"
15
15
  },
16
16
  "peerDependencies": {
17
17
  "prettier": "^3.0.0"
package/src/index.js CHANGED
@@ -34,6 +34,30 @@ function canAttachComment() {
34
34
  return false;
35
35
  }
36
36
 
37
+ /**
38
+ * The child-bearing keys of an AST node.
39
+ *
40
+ * Declaring this is a security requirement, not an optimisation: without it
41
+ * Prettier's default visitor walks *every* object-valued key, and a `twigBlock`
42
+ * exposes its body both as `children` and again as `sections[].body`. That
43
+ * double edge makes traversal work grow as `2^depth`, so a tiny nested
44
+ * template can hang the formatter. Only `children` and `attrs` are traversed
45
+ * (the printer reaches them through `path.call`); `sections` is internal.
46
+ *
47
+ * @param {any} node
48
+ * @returns {string[]} The keys whose values are child nodes.
49
+ */
50
+ function getVisitorKeys(node) {
51
+ const keys = [];
52
+ if (Array.isArray(node.children)) {
53
+ keys.push('children');
54
+ }
55
+ if (Array.isArray(node.attrs)) {
56
+ keys.push('attrs');
57
+ }
58
+ return keys;
59
+ }
60
+
37
61
  export const languages = [
38
62
  {
39
63
  name: 'twig',
@@ -62,6 +86,7 @@ export const printers = {
62
86
  print,
63
87
  embed,
64
88
  canAttachComment,
89
+ getVisitorKeys,
65
90
  },
66
91
  };
67
92
 
package/src/printer.js CHANGED
@@ -400,17 +400,29 @@ function isMultilineContainer(text) {
400
400
  return new RegExp(`^\\${text[0]}[\\t ]*\\r?\\n`).test(text);
401
401
  }
402
402
 
403
+ /**
404
+ * The deepest mapping/sequence nesting {@link formatStructures} descends into.
405
+ * Beyond this the original text is emitted verbatim, so an expression built
406
+ * from deeply nested `[[[ … ]]]` or `{a:{a:{…}}}` cannot drive the formatter's
407
+ * mutual recursion into a stack overflow.
408
+ */
409
+ const MAX_STRUCTURE_DEPTH = 200;
410
+
403
411
  /**
404
412
  * Collects, in pre-order, whether each `{ … }` / `[ … ]` container of `text`
405
413
  * was multi-line in the source. They are consumed in the same order while
406
414
  * formatting.
407
415
  *
408
416
  * @param {string} text
417
+ * @param {number} [depth]
409
418
  * @returns {boolean[]}
410
419
  */
411
- function collectMultilineStructures(text) {
420
+ function collectMultilineStructures(text, depth = 0) {
412
421
  /** @type {boolean[]} */
413
422
  const result = [];
423
+ if (depth > MAX_STRUCTURE_DEPTH) {
424
+ return result;
425
+ }
414
426
  let quote = '';
415
427
  let i = 0;
416
428
  while (i < text.length) {
@@ -435,7 +447,7 @@ function collectMultilineStructures(text) {
435
447
  const close = findMatchingBrace(text, i);
436
448
  if (close !== -1) {
437
449
  result.push(isMultilineContainer(text.slice(i, close + 1)));
438
- result.push(...collectMultilineStructures(text.slice(i + 1, close)));
450
+ result.push(...collectMultilineStructures(text.slice(i + 1, close), depth + 1));
439
451
  i = close + 1;
440
452
  continue;
441
453
  }
@@ -455,9 +467,13 @@ function collectMultilineStructures(text) {
455
467
  * @param {boolean} forceBreak Whether every container must expand.
456
468
  * @param {boolean[]} [multiline] Queue of source multi-line flags, in order.
457
469
  * @param {boolean} [trailingComma] Whether expanded containers get a trailing comma.
470
+ * @param {number} [depth] The current structure nesting depth.
458
471
  * @returns {any} A Prettier doc.
459
472
  */
460
- function formatStructures(text, forceBreak, multiline = [], trailingComma = true) {
473
+ function formatStructures(text, forceBreak, multiline = [], trailingComma = true, depth = 0) {
474
+ if (depth > MAX_STRUCTURE_DEPTH) {
475
+ return text;
476
+ }
461
477
  /** @type {any[]} */
462
478
  const docs = [];
463
479
  let quote = '';
@@ -489,7 +505,7 @@ function formatStructures(text, forceBreak, multiline = [], trailingComma = true
489
505
  }
490
506
  const sourceMultiline = multiline.shift() ?? false;
491
507
  docs.push(
492
- formatContainer(text.slice(i, close + 1), forceBreak || sourceMultiline, multiline, trailingComma),
508
+ formatContainer(text.slice(i, close + 1), forceBreak || sourceMultiline, multiline, trailingComma, depth + 1),
493
509
  );
494
510
  i = close + 1;
495
511
  start = i;
@@ -546,9 +562,10 @@ function normalizeMappingEntry(entry) {
546
562
  * @param {boolean} forceBreak Whether the container must expand.
547
563
  * @param {boolean[]} [multiline] Queue of source multi-line flags, in order.
548
564
  * @param {boolean} [trailingComma] Whether an expanded container gets a trailing comma.
565
+ * @param {number} [depth] The current structure nesting depth.
549
566
  * @returns {any} A Prettier doc.
550
567
  */
551
- function formatContainer(text, forceBreak, multiline = [], trailingComma = true) {
568
+ function formatContainer(text, forceBreak, multiline = [], trailingComma = true, depth = 0) {
552
569
  const open = text[0];
553
570
  const close = open === '{' ? '}' : ']';
554
571
  const entries = splitTopLevel(text.slice(1, -1));
@@ -556,7 +573,7 @@ function formatContainer(text, forceBreak, multiline = [], trailingComma = true)
556
573
  return `${open}${close}`;
557
574
  }
558
575
  const parts = entries.map((entry) =>
559
- formatStructures(open === '{' ? normalizeMappingEntry(entry) : entry, false, multiline, trailingComma),
576
+ formatStructures(open === '{' ? normalizeMappingEntry(entry) : entry, false, multiline, trailingComma, depth),
560
577
  );
561
578
  const trailing = trailingComma ? ifBreak(',') : '';
562
579
  // mappings get inner spaces (`{ a: 1 }`), sequences do not (`[1, 2]`)
@@ -609,6 +626,28 @@ function isSingleLineStartTag(node) {
609
626
  return realAttrs.length <= 1 && !hasConditional && !hasComplexValue;
610
627
  }
611
628
 
629
+ /**
630
+ * Whether an `<html>` start tag must stay on a single line because the source
631
+ * wrote it that way. Unlike other elements, `html` may carry several attributes
632
+ * on one line; a tag the author split across lines is formatted multi-line
633
+ * instead. Conditional attributes are excluded because their blocks always
634
+ * expand.
635
+ *
636
+ * @param {any} node
637
+ * @param {string} source
638
+ * @returns {boolean}
639
+ */
640
+ function keepsHtmlTagOnOneLine(node, source) {
641
+ if (node.name !== 'html') {
642
+ return false;
643
+ }
644
+ const attrs = node.attrs ?? [];
645
+ const hasConditional = attrs.some(
646
+ (/** @type {any} */ a) => a.type === 'twig' || a.type === 'twigBlock',
647
+ );
648
+ return !hasConditional && !/[\r\n]/.test(source.slice(node.rawStart, node.startTagEnd));
649
+ }
650
+
612
651
  /**
613
652
  * Prints an element's start tag.
614
653
  *
@@ -619,9 +658,9 @@ function isSingleLineStartTag(node) {
619
658
  */
620
659
  function printOpenTag(node, attrDocs, source) {
621
660
  const forcedSingleLine = FORCE_SINGLE_LINE.has(node.name);
622
- const singleLineTag = forcedSingleLine || isSingleLineStartTag(node);
661
+ const singleLineTag = forcedSingleLine || keepsHtmlTagOnOneLine(node, source) || isSingleLineStartTag(node);
623
662
  return forcedSingleLine
624
- ? // `html`, `link` and `meta` are never broken across lines
663
+ ? // `link` and `meta` are never broken across lines
625
664
  collapseWhitespace(source.slice(node.rawStart, node.startTagEnd)).replace(
626
665
  /\s*\/?>$/,
627
666
  node.selfClosing ? ' />' : '>',
@@ -783,7 +822,7 @@ function printInlineText(inner) {
783
822
  }
784
823
 
785
824
  /** Elements whose start tag must always stay on a single line. */
786
- const FORCE_SINGLE_LINE = new Set(['html', 'link', 'meta']);
825
+ const FORCE_SINGLE_LINE = new Set(['link', 'meta']);
787
826
 
788
827
  /** Void elements that never have an end tag. */
789
828
  const VOID_ELEMENTS = new Set([
@@ -894,12 +933,14 @@ function printTwigBlock(path, printCallback, source, options) {
894
933
  // blank-line detection measures the source between the end of the previous
895
934
  // non-whitespace item (or the opening / section head) and the current one
896
935
  let previousEnd = node.open.atom.rawEnd;
936
+ let previousWasInline = false;
897
937
  for (const section of node.sections ?? []) {
898
938
  if (section.head) {
899
939
  // mid tags (`{% else %}`, `{% elseif %}`) sit at the block's own indent
900
940
  const head = formatTwigHead(section.head.atom.raw, options);
901
941
  docs.push(hasBlankLine(source, previousEnd, section.head.atom.rawStart) ? [hardline, hardline, head] : [hardline, head]);
902
942
  previousEnd = section.head.atom.rawEnd;
943
+ previousWasInline = false;
903
944
  }
904
945
  /** @type {any[]} */
905
946
  const bodyDocs = [];
@@ -909,10 +950,21 @@ function printTwigBlock(path, printCallback, source, options) {
909
950
  if (isWhitespaceOnly(child)) {
910
951
  continue;
911
952
  }
912
- const childDoc = path.call(printCallback, 'children', i);
913
953
  const { start, end } = contentBounds(child);
914
- bodyDocs.push(hasBlankLine(source, previousEnd, start) ? [hardline, hardline, childDoc] : [hardline, childDoc]);
954
+ const gap = source.slice(previousEnd, start);
955
+ const childDoc = path.call(printCallback, 'children', i);
956
+ const inline = isInlineChild(child);
957
+ if (hasBlankLine(source, previousEnd, start)) {
958
+ bodyDocs.push([hardline, hardline, childDoc]);
959
+ } else if (!gap.includes('\n') && previousWasInline && inline) {
960
+ // consecutive inline content on one source line stays together,
961
+ // so a trailing comment remains after the line it comments
962
+ bodyDocs.push([gap === '' ? '' : ' ', childDoc]);
963
+ } else {
964
+ bodyDocs.push([hardline, childDoc]);
965
+ }
915
966
  previousEnd = end;
967
+ previousWasInline = inline;
916
968
  }
917
969
  if (bodyDocs.length > 0) {
918
970
  docs.push(indent(bodyDocs));
@@ -126,6 +126,84 @@ test('keeps html, link and meta tags on a single line', async () => {
126
126
  }
127
127
  });
128
128
 
129
+ test('preserves the html tag line layout', async () => {
130
+ {
131
+ // a simple twig value in a single attribute stays on one line
132
+ const out = await fmt(`<html lang="{{ currentSite.language }}">
133
+ <head></head>
134
+ </html>`);
135
+ assert.equal(
136
+ out,
137
+ `<html lang="{{ currentSite.language }}">
138
+ <head></head>
139
+ </html>
140
+ `,
141
+ );
142
+ }
143
+
144
+ {
145
+ // several attributes written on one line stay on one line
146
+ const out = await fmt(`<html lang="en" class="no-js" data-x="1">
147
+ <head></head>
148
+ </html>`);
149
+ assert.equal(
150
+ out,
151
+ `<html lang="en" class="no-js" data-x="1">
152
+ <head></head>
153
+ </html>
154
+ `,
155
+ );
156
+ }
157
+
158
+ {
159
+ // a tag the author split across lines is formatted multi-line
160
+ const out = await fmt(`<html
161
+ lang="en"
162
+ class="no-js"
163
+ data-x="1"
164
+ >
165
+ <head></head>
166
+ </html>`);
167
+ assert.equal(
168
+ out,
169
+ `<html
170
+ lang="en"
171
+ class="no-js"
172
+ data-x="1"
173
+ >
174
+ <head></head>
175
+ </html>
176
+ `,
177
+ );
178
+ }
179
+
180
+ {
181
+ // conditional attributes force the multi-line layout
182
+ const out = await fmt(`<html
183
+ lang="en"
184
+ class="no-js"
185
+ {% if current %}
186
+ data-current
187
+ {% endif %}
188
+ >
189
+ <head></head>
190
+ </html>`);
191
+ assert.equal(
192
+ out,
193
+ `<html
194
+ lang="en"
195
+ class="no-js"
196
+ {% if current %}
197
+ data-current
198
+ {% endif %}
199
+ >
200
+ <head></head>
201
+ </html>
202
+ `,
203
+ );
204
+ }
205
+ });
206
+
129
207
  test('does not close open-ended partials', async () => {
130
208
  {
131
209
  // a partial that opens the document, completed by another template
@@ -1205,6 +1283,29 @@ test('keeps inline comments next to content without adding line breaks', async (
1205
1283
  }
1206
1284
  });
1207
1285
 
1286
+ test('keeps trailing comments inside twig block bodies on their line', async () => {
1287
+ const source = `{% set animation_keyframes %}
1288
+ 0% { z-index: 1; } {# Start at bottom #}
1289
+ 1% { z-index: 999; } {# Come to front #}
1290
+ {{ relative_item_duration }}% { z-index: 999; } {# Stay at front #}
1291
+ {{ relative_item_duration + 1 }}% { z-index: 1; } {# Return to bottom #}
1292
+ {% endset %}
1293
+ `;
1294
+ assert.equal(await fmt(source), source);
1295
+
1296
+ // a subtle variation that does get formatted: spaces inside the expressions
1297
+ assert.equal(
1298
+ await fmt(`{% set animation_keyframes %}
1299
+ 0% { z-index: 1; } {# Start at bottom #}
1300
+ 1% { z-index: 999; } {# Come to front #}
1301
+ {{ relative_item_duration }}% { z-index: 999; } {# Stay at front #}
1302
+ {{ relative_item_duration + 1 }}% { z-index: 1; } {# Return to bottom #}
1303
+ {% endset %}
1304
+ `),
1305
+ source,
1306
+ );
1307
+ });
1308
+
1208
1309
  test('keeps a comment written after the opening tag on that line', async () => {
1209
1310
  {
1210
1311
  const source = `<html lang="en" class="no-js"> {# TODO: set lang correctly #}
@@ -2285,3 +2386,25 @@ test('multiple twig structures on a single line', async () => {
2285
2386
  );
2286
2387
  });
2287
2388
 
2389
+
2390
+ test( 'deeply nested twig blocks format without exponential traversal', async() => {
2391
+ // Without `getVisitorKeys` the printer's default traversal walks a
2392
+ // `twigBlock` body twice per level (via `children` and `sections[].body`),
2393
+ // so this 40-level input would take exponential time. Completing at all is
2394
+ // the assertion; a regression turns this into a multi-hour hang.
2395
+ const depth = 40;
2396
+ const source = `${ '{% if x %}'.repeat( depth ) }x${ '{% endif %}'.repeat( depth ) }`;
2397
+
2398
+ const out = await fmt( source );
2399
+
2400
+ assert.ok( out.includes( 'x' ) );
2401
+ } );
2402
+
2403
+ test( 'deeply nested structures inside an expression do not overflow the stack', async() => {
2404
+ const depth = 5000;
2405
+ const source = `{{ ${ '['.repeat( depth ) }1${ ']'.repeat( depth ) } }}`;
2406
+
2407
+ // The structure formatter stops recursing past its depth cap and emits the
2408
+ // original text verbatim rather than overflowing the call stack.
2409
+ await assert.doesNotReject( fmt( source ) );
2410
+ } );