@mrhenry/prettier-twig 0.1.0 → 0.1.1
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/package.json +1 -1
- package/src/printer.js +100 -11
- package/test/prettier-twig.test.js +217 -0
package/package.json
CHANGED
package/src/printer.js
CHANGED
|
@@ -109,9 +109,9 @@ function danglingEndIndent(raw) {
|
|
|
109
109
|
}
|
|
110
110
|
|
|
111
111
|
/**
|
|
112
|
-
* Whether a node is inline content (a `{{ … }}` expression
|
|
113
|
-
* share a line with adjacent content when
|
|
114
|
-
* them.
|
|
112
|
+
* Whether a node is inline content (a `{{ … }}` expression, text, a single-line
|
|
113
|
+
* comment or an inline element) that may share a line with adjacent content when
|
|
114
|
+
* the source has no line break between them.
|
|
115
115
|
*
|
|
116
116
|
* @param {any} node
|
|
117
117
|
* @returns {boolean}
|
|
@@ -123,6 +123,12 @@ function isInlineChild(node) {
|
|
|
123
123
|
if (node.type === 'twig') {
|
|
124
124
|
return Boolean(node.atom.isPrint);
|
|
125
125
|
}
|
|
126
|
+
if (node.type === 'twigComment') {
|
|
127
|
+
return !/[\r\n]/.test(node.atom.raw);
|
|
128
|
+
}
|
|
129
|
+
if (node.type === 'comment') {
|
|
130
|
+
return !/[\r\n]/.test(node.raw);
|
|
131
|
+
}
|
|
126
132
|
return node.type === 'element' && INLINE_ELEMENTS.has(node.name);
|
|
127
133
|
}
|
|
128
134
|
|
|
@@ -280,6 +286,81 @@ function normalizeTwigTag(raw) {
|
|
|
280
286
|
return normalized === '' ? `${open}${close}` : `${open} ${normalized} ${close}`;
|
|
281
287
|
}
|
|
282
288
|
|
|
289
|
+
/**
|
|
290
|
+
* The net parenthesis depth a line opens (positive) or closes (negative),
|
|
291
|
+
* ignoring parentheses inside string literals.
|
|
292
|
+
*
|
|
293
|
+
* @param {string} text
|
|
294
|
+
* @returns {number}
|
|
295
|
+
*/
|
|
296
|
+
function parenDelta(text) {
|
|
297
|
+
let depth = 0;
|
|
298
|
+
let quote = '';
|
|
299
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
300
|
+
const ch = text[i];
|
|
301
|
+
if (quote !== '') {
|
|
302
|
+
if (ch === '\\') {
|
|
303
|
+
i += 1;
|
|
304
|
+
} else if (ch === quote) {
|
|
305
|
+
quote = '';
|
|
306
|
+
}
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
if (ch === '"' || ch === "'") {
|
|
310
|
+
quote = ch;
|
|
311
|
+
} else if (ch === '(') {
|
|
312
|
+
depth += 1;
|
|
313
|
+
} else if (ch === ')') {
|
|
314
|
+
depth -= 1;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return depth;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Prints a `{% if … %}` / `{% elseif … %}` head. A condition written over
|
|
322
|
+
* several lines and made complex by boolean operators or parentheses keeps its
|
|
323
|
+
* line breaks, one operand per line, re-indented one level per parenthesis it
|
|
324
|
+
* is nested in. Anything else is normalized to a single line.
|
|
325
|
+
*
|
|
326
|
+
* @param {string} raw
|
|
327
|
+
* @param {any} options
|
|
328
|
+
* @returns {any} A Prettier doc.
|
|
329
|
+
*/
|
|
330
|
+
function formatTwigHead(raw, options) {
|
|
331
|
+
const { open, close, body } = twigDelimiters(raw);
|
|
332
|
+
const keywordMatch = body.match(/^\s*(if|elseif)\b/);
|
|
333
|
+
const condition = keywordMatch ? body.slice(keywordMatch[0].length) : '';
|
|
334
|
+
const complex = /\b(and|or)\b/.test(condition) || condition.includes('(');
|
|
335
|
+
if (!keywordMatch || !/[\r\n]/.test(body) || !complex) {
|
|
336
|
+
return normalizeTwigTag(raw);
|
|
337
|
+
}
|
|
338
|
+
const unit = options.useTabs === false ? ' '.repeat(options.tabWidth ?? 4) : '\t';
|
|
339
|
+
const lines = body
|
|
340
|
+
.split(/\r?\n/)
|
|
341
|
+
.map((/** @type {string} */ text) => collapseWhitespace(text).trim());
|
|
342
|
+
while (lines.length > 0 && lines[0] === '') {
|
|
343
|
+
lines.shift();
|
|
344
|
+
}
|
|
345
|
+
while (lines.length > 0 && lines[lines.length - 1] === '') {
|
|
346
|
+
lines.pop();
|
|
347
|
+
}
|
|
348
|
+
if (lines.length === 0) {
|
|
349
|
+
return `${open}${close}`;
|
|
350
|
+
}
|
|
351
|
+
const [first, ...rest] = lines;
|
|
352
|
+
let depth = parenDelta(first);
|
|
353
|
+
/** @type {any[]} */
|
|
354
|
+
const continuation = [];
|
|
355
|
+
for (const conditionLine of rest) {
|
|
356
|
+
const leadingClose = (conditionLine.match(/^\)+/) ?? [''])[0].length;
|
|
357
|
+
const level = Math.max(0, depth - leadingClose);
|
|
358
|
+
continuation.push([hardline, unit.repeat(level), conditionLine]);
|
|
359
|
+
depth += parenDelta(conditionLine);
|
|
360
|
+
}
|
|
361
|
+
return [open, ' ', first, indent(continuation), hardline, close];
|
|
362
|
+
}
|
|
363
|
+
|
|
283
364
|
/**
|
|
284
365
|
* Whether a mapping/sequence was written multi-line in the source (a newline
|
|
285
366
|
* between the opening delimiter and the first entry). Such containers stay
|
|
@@ -700,11 +781,12 @@ function printAttributeValue(raw) {
|
|
|
700
781
|
* @param {any} path
|
|
701
782
|
* @param {(selector: any, ...rest: any[]) => any} printCallback
|
|
702
783
|
* @param {string} source
|
|
784
|
+
* @param {any} options
|
|
703
785
|
* @returns {any} A Prettier doc.
|
|
704
786
|
*/
|
|
705
|
-
function printTwigBlock(path, printCallback, source) {
|
|
787
|
+
function printTwigBlock(path, printCallback, source, options) {
|
|
706
788
|
const node = path.node;
|
|
707
|
-
const open =
|
|
789
|
+
const open = formatTwigHead(node.open.atom.raw, options);
|
|
708
790
|
const close = normalizeTwigTag(node.close.atom.raw);
|
|
709
791
|
/** @type {any[]} */
|
|
710
792
|
const docs = [];
|
|
@@ -717,7 +799,7 @@ function printTwigBlock(path, printCallback, source) {
|
|
|
717
799
|
for (const section of node.sections ?? []) {
|
|
718
800
|
if (section.head) {
|
|
719
801
|
// mid tags (`{% else %}`, `{% elseif %}`) sit at the block's own indent
|
|
720
|
-
const head =
|
|
802
|
+
const head = formatTwigHead(section.head.atom.raw, options);
|
|
721
803
|
docs.push(hasBlankLine(source, previousEnd, section.head.atom.rawStart) ? [hardline, hardline, head] : [hardline, head]);
|
|
722
804
|
previousEnd = section.head.atom.rawEnd;
|
|
723
805
|
}
|
|
@@ -885,8 +967,11 @@ function printTwigComment(node) {
|
|
|
885
967
|
* @returns {boolean}
|
|
886
968
|
*/
|
|
887
969
|
function isInlineTwigBlock(node, source) {
|
|
888
|
-
|
|
889
|
-
|
|
970
|
+
// Trim-consumed whitespace before the `{%` is part of the node's raw span;
|
|
971
|
+
// the open/close atoms describe the block itself, so a block that only looks
|
|
972
|
+
// multi-line because of that leading trivia is still recognised as inline.
|
|
973
|
+
const { start, end } = contentBounds(node);
|
|
974
|
+
if (source.slice(start, end).includes('\n')) {
|
|
890
975
|
return false;
|
|
891
976
|
}
|
|
892
977
|
const children = (node.children ?? []).filter((/** @type {any} */ c) => !isWhitespaceOnly(c));
|
|
@@ -1111,9 +1196,13 @@ export function print(path, options, printCallback) {
|
|
|
1111
1196
|
case 'element':
|
|
1112
1197
|
return printElement(path, printCallback, source);
|
|
1113
1198
|
case 'twigBlock':
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1199
|
+
if (isInlineTwigBlock(node, source)) {
|
|
1200
|
+
// slice from the atoms so trim-consumed whitespace before the `{%`
|
|
1201
|
+
// is not duplicated (the containing printer owns that gap)
|
|
1202
|
+
const { start, end } = contentBounds(node);
|
|
1203
|
+
return source.slice(start, end);
|
|
1204
|
+
}
|
|
1205
|
+
return printTwigBlock(path, printCallback, source, options);
|
|
1117
1206
|
default:
|
|
1118
1207
|
return printNode(node, options);
|
|
1119
1208
|
}
|
|
@@ -485,6 +485,150 @@ test('formats conditional attributes', async () => {
|
|
|
485
485
|
}
|
|
486
486
|
});
|
|
487
487
|
|
|
488
|
+
test('keeps a complex multi-line condition across lines with paren-depth indent', async () => {
|
|
489
|
+
{
|
|
490
|
+
const out = await fmt(`<div
|
|
491
|
+
class="teaser-a"
|
|
492
|
+
{% if teaser.path %}
|
|
493
|
+
data-has-link
|
|
494
|
+
{% endif %}
|
|
495
|
+
{% if
|
|
496
|
+
show_region or
|
|
497
|
+
teaser.email or
|
|
498
|
+
teaser.telephone or
|
|
499
|
+
teaser.street or
|
|
500
|
+
teaser.postal or
|
|
501
|
+
teaser.city or
|
|
502
|
+
(teaser.type == 'Contact' and (
|
|
503
|
+
teaser.opening_hours.days|length > 0 or
|
|
504
|
+
teaser.opening_hours.text or
|
|
505
|
+
teaser.opening_hours_b.days|length > 0 or
|
|
506
|
+
teaser.opening_hours_b.text or
|
|
507
|
+
teaser.links|length > 0
|
|
508
|
+
))
|
|
509
|
+
%}
|
|
510
|
+
data-has-side
|
|
511
|
+
{% endif %}
|
|
512
|
+
>
|
|
513
|
+
`);
|
|
514
|
+
assert.equal(
|
|
515
|
+
out,
|
|
516
|
+
`<div
|
|
517
|
+
class="teaser-a"
|
|
518
|
+
{% if teaser.path %}
|
|
519
|
+
data-has-link
|
|
520
|
+
{% endif %}
|
|
521
|
+
{% if
|
|
522
|
+
show_region or
|
|
523
|
+
teaser.email or
|
|
524
|
+
teaser.telephone or
|
|
525
|
+
teaser.street or
|
|
526
|
+
teaser.postal or
|
|
527
|
+
teaser.city or
|
|
528
|
+
(teaser.type == 'Contact' and (
|
|
529
|
+
teaser.opening_hours.days|length > 0 or
|
|
530
|
+
teaser.opening_hours.text or
|
|
531
|
+
teaser.opening_hours_b.days|length > 0 or
|
|
532
|
+
teaser.opening_hours_b.text or
|
|
533
|
+
teaser.links|length > 0
|
|
534
|
+
))
|
|
535
|
+
%}
|
|
536
|
+
data-has-side
|
|
537
|
+
{% endif %}
|
|
538
|
+
>
|
|
539
|
+
`,
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
{
|
|
544
|
+
const out = await fmt(`<div
|
|
545
|
+
class="teaser-a"
|
|
546
|
+
{% if teaser.path %}
|
|
547
|
+
data-has-link
|
|
548
|
+
{% endif %}
|
|
549
|
+
{% if
|
|
550
|
+
show_region or
|
|
551
|
+
teaser.email or
|
|
552
|
+
teaser.telephone or
|
|
553
|
+
teaser.street or
|
|
554
|
+
teaser.postal or
|
|
555
|
+
teaser.city or
|
|
556
|
+
(
|
|
557
|
+
teaser.type == 'Contact' and (
|
|
558
|
+
teaser.opening_hours.days|length > 0 or
|
|
559
|
+
teaser.opening_hours.text or
|
|
560
|
+
teaser.opening_hours_b.days|length > 0 or
|
|
561
|
+
teaser.opening_hours_b.text or
|
|
562
|
+
teaser.links|length > 0
|
|
563
|
+
)
|
|
564
|
+
)
|
|
565
|
+
%}
|
|
566
|
+
data-has-side
|
|
567
|
+
{% endif %}
|
|
568
|
+
>
|
|
569
|
+
`);
|
|
570
|
+
assert.equal(
|
|
571
|
+
out,
|
|
572
|
+
`<div
|
|
573
|
+
class="teaser-a"
|
|
574
|
+
{% if teaser.path %}
|
|
575
|
+
data-has-link
|
|
576
|
+
{% endif %}
|
|
577
|
+
{% if
|
|
578
|
+
show_region or
|
|
579
|
+
teaser.email or
|
|
580
|
+
teaser.telephone or
|
|
581
|
+
teaser.street or
|
|
582
|
+
teaser.postal or
|
|
583
|
+
teaser.city or
|
|
584
|
+
(
|
|
585
|
+
teaser.type == 'Contact' and (
|
|
586
|
+
teaser.opening_hours.days|length > 0 or
|
|
587
|
+
teaser.opening_hours.text or
|
|
588
|
+
teaser.opening_hours_b.days|length > 0 or
|
|
589
|
+
teaser.opening_hours_b.text or
|
|
590
|
+
teaser.links|length > 0
|
|
591
|
+
)
|
|
592
|
+
)
|
|
593
|
+
%}
|
|
594
|
+
data-has-side
|
|
595
|
+
{% endif %}
|
|
596
|
+
>
|
|
597
|
+
`,
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
test('keeps multi-line elseif conditions across lines', async () => {
|
|
603
|
+
const source = `{% if
|
|
604
|
+
a or
|
|
605
|
+
b
|
|
606
|
+
%}
|
|
607
|
+
<p>x</p>
|
|
608
|
+
{% elseif
|
|
609
|
+
c and
|
|
610
|
+
d
|
|
611
|
+
%}
|
|
612
|
+
<p>y</p>
|
|
613
|
+
{% endif %}
|
|
614
|
+
`;
|
|
615
|
+
assert.equal(await fmt(source), source);
|
|
616
|
+
|
|
617
|
+
// a simple condition broken across lines is still collapsed
|
|
618
|
+
assert.equal(
|
|
619
|
+
await fmt(`{% if
|
|
620
|
+
x
|
|
621
|
+
%}
|
|
622
|
+
<p>a</p>
|
|
623
|
+
{% endif %}
|
|
624
|
+
`),
|
|
625
|
+
`{% if x %}
|
|
626
|
+
<p>a</p>
|
|
627
|
+
{% endif %}
|
|
628
|
+
`,
|
|
629
|
+
);
|
|
630
|
+
});
|
|
631
|
+
|
|
488
632
|
test('indents twig block bodies', async () => {
|
|
489
633
|
{
|
|
490
634
|
const out = await fmt(`{% if x %}
|
|
@@ -955,6 +1099,62 @@ test('format twig comments', async () => {
|
|
|
955
1099
|
}
|
|
956
1100
|
});
|
|
957
1101
|
|
|
1102
|
+
test('keeps inline comments next to content without adding line breaks', async () => {
|
|
1103
|
+
{
|
|
1104
|
+
// the comment sits after the text on the same line, so it stays there
|
|
1105
|
+
const source = `<p>
|
|
1106
|
+
Start of main content. {# TODO: Translate if necessary. #}
|
|
1107
|
+
</p>
|
|
1108
|
+
`;
|
|
1109
|
+
assert.equal(await fmt(source), source);
|
|
1110
|
+
|
|
1111
|
+
// a subtle variation that does get formatted: spaces in the comment
|
|
1112
|
+
assert.equal(
|
|
1113
|
+
await fmt(`<p>
|
|
1114
|
+
Start of main content. {# TODO: Translate if necessary. #}
|
|
1115
|
+
</p>
|
|
1116
|
+
`),
|
|
1117
|
+
source,
|
|
1118
|
+
);
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
{
|
|
1122
|
+
// in between text on one line
|
|
1123
|
+
const source = `<div>
|
|
1124
|
+
text {# c #} more
|
|
1125
|
+
</div>
|
|
1126
|
+
`;
|
|
1127
|
+
assert.equal(await fmt(source), source);
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
{
|
|
1131
|
+
// in between text and an inline element
|
|
1132
|
+
const source = `<p>
|
|
1133
|
+
Go back to the <a href="/">homepage</a>. {# c #}
|
|
1134
|
+
</p>
|
|
1135
|
+
`;
|
|
1136
|
+
assert.equal(await fmt(source), source);
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
{
|
|
1140
|
+
// html comments behave like twig comments
|
|
1141
|
+
const source = `<div>
|
|
1142
|
+
text <!-- c --> more
|
|
1143
|
+
</div>
|
|
1144
|
+
`;
|
|
1145
|
+
assert.equal(await fmt(source), source);
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
{
|
|
1149
|
+
// at the document root
|
|
1150
|
+
assert.equal(
|
|
1151
|
+
await fmt(`Start of main content. {# TODO: Translate if necessary. #}`),
|
|
1152
|
+
`Start of main content. {# TODO: Translate if necessary. #}
|
|
1153
|
+
`,
|
|
1154
|
+
);
|
|
1155
|
+
}
|
|
1156
|
+
});
|
|
1157
|
+
|
|
958
1158
|
test('keeps html comments and doctype', async () => {
|
|
959
1159
|
const out = await fmt(`<!doctype html>
|
|
960
1160
|
<!-- c -->
|
|
@@ -1674,6 +1874,23 @@ test('indents nested loops inside elements', async () => {
|
|
|
1674
1874
|
);
|
|
1675
1875
|
});
|
|
1676
1876
|
|
|
1877
|
+
test('keeps an inline twig block on one line after whitespace-trimmed siblings', async () => {
|
|
1878
|
+
const source = `{% if regions|length > 0 %}
|
|
1879
|
+
Regio
|
|
1880
|
+
|
|
1881
|
+
{% for region in regions -%}
|
|
1882
|
+
{{- region.name -}}
|
|
1883
|
+
{%- if not loop.last -%}, {% endif -%}
|
|
1884
|
+
{%- endfor -%}
|
|
1885
|
+
|
|
1886
|
+
{% if page_fields.job_meta_location or page_fields.job_meta_apply_until or tags|length > 0 %}
|
|
1887
|
+
<br>
|
|
1888
|
+
{% endif %}
|
|
1889
|
+
{% endif %}
|
|
1890
|
+
`;
|
|
1891
|
+
assert.equal(await fmt(source), source);
|
|
1892
|
+
});
|
|
1893
|
+
|
|
1677
1894
|
test('nests control structures inside elements', async () => {
|
|
1678
1895
|
const out = await fmt(`{% for group in groups %}<div><h2>{{ group.name }}</h2><ul>{% for item in group.items %}<li>{{ item }}</li>{% endfor %}</ul></div>{% endfor %}`);
|
|
1679
1896
|
assert.equal(
|