@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 +6 -0
- package/package.json +2 -2
- package/src/index.js +25 -0
- package/src/printer.js +38 -8
- package/test/prettier-twig.test.js +45 -0
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.
|
|
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.
|
|
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
|
-
|
|
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
|
+
} );
|