@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 +10 -0
- package/package.json +2 -2
- package/src/index.js +25 -0
- package/src/printer.js +63 -11
- package/test/prettier-twig.test.js +123 -0
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.
|
|
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]`)
|
|
@@ -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
|
-
? // `
|
|
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(['
|
|
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
|
-
|
|
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
|
+
} );
|