@mrhenry/prettier-twig 0.1.5 → 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,11 @@
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
+
3
9
  ## 0.1.5 (2026-09-25)
4
10
 
5
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrhenry/prettier-twig",
3
- "version": "0.1.5",
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]`)
@@ -916,12 +933,14 @@ function printTwigBlock(path, printCallback, source, options) {
916
933
  // blank-line detection measures the source between the end of the previous
917
934
  // non-whitespace item (or the opening / section head) and the current one
918
935
  let previousEnd = node.open.atom.rawEnd;
936
+ let previousWasInline = false;
919
937
  for (const section of node.sections ?? []) {
920
938
  if (section.head) {
921
939
  // mid tags (`{% else %}`, `{% elseif %}`) sit at the block's own indent
922
940
  const head = formatTwigHead(section.head.atom.raw, options);
923
941
  docs.push(hasBlankLine(source, previousEnd, section.head.atom.rawStart) ? [hardline, hardline, head] : [hardline, head]);
924
942
  previousEnd = section.head.atom.rawEnd;
943
+ previousWasInline = false;
925
944
  }
926
945
  /** @type {any[]} */
927
946
  const bodyDocs = [];
@@ -931,10 +950,21 @@ function printTwigBlock(path, printCallback, source, options) {
931
950
  if (isWhitespaceOnly(child)) {
932
951
  continue;
933
952
  }
934
- const childDoc = path.call(printCallback, 'children', i);
935
953
  const { start, end } = contentBounds(child);
936
- 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
+ }
937
966
  previousEnd = end;
967
+ previousWasInline = inline;
938
968
  }
939
969
  if (bodyDocs.length > 0) {
940
970
  docs.push(indent(bodyDocs));
@@ -1283,6 +1283,29 @@ test('keeps inline comments next to content without adding line breaks', async (
1283
1283
  }
1284
1284
  });
1285
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
+
1286
1309
  test('keeps a comment written after the opening tag on that line', async () => {
1287
1310
  {
1288
1311
  const source = `<html lang="en" class="no-js"> {# TODO: set lang correctly #}
@@ -2363,3 +2386,25 @@ test('multiple twig structures on a single line', async () => {
2363
2386
  );
2364
2387
  });
2365
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
+ } );