@mrhenry/prettier-twig 0.1.0

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.
@@ -0,0 +1,1896 @@
1
+ // @ts-check
2
+ /**
3
+ * End-to-end tests for the `@mrhenry/prettier-twig` plugin.
4
+ *
5
+ * Covers the formatting rules (tabs, multi-line tags, conditional attributes,
6
+ * twig blocks), Prettier's report-only / range / cursor modes (which rely on
7
+ * node offsets being exact positions in the original source) and directed
8
+ * tests for edge cases (raw-text elements, void/self-closing tags, attribute
9
+ * forms, control flow).
10
+ *
11
+ * @module test
12
+ */
13
+ import { test } from 'node:test';
14
+ import assert from 'node:assert/strict';
15
+ import { format, check, formatWithCursor } from 'prettier';
16
+ import * as pluginModule from '@mrhenry/prettier-twig';
17
+
18
+ const plugin = /** @type {any} */ (/** @type {any} */ (pluginModule).default ?? pluginModule);
19
+
20
+ /**
21
+ * @param {string} source
22
+ * @param {any} [options]
23
+ * @returns {Promise<string>}
24
+ */
25
+ function fmt(source, options = {}) {
26
+ return format(source, { parser: 'twig-html', plugins: [plugin], ...options });
27
+ }
28
+
29
+ test('formats a simple element with tabs', async () => {
30
+ const out = await fmt(`<div>
31
+ <span>x</span>
32
+ </div>`);
33
+ assert.equal(
34
+ out,
35
+ `<div>
36
+ <span>x</span>
37
+ </div>
38
+ `,
39
+ );
40
+ });
41
+
42
+ test('expands elements with multiple attributes', async () => {
43
+ {
44
+ const out = await fmt(`<div class="a" data-x="1"><br></div>`);
45
+ assert.equal(
46
+ out,
47
+ `<div
48
+ class="a"
49
+ data-x="1"
50
+ >
51
+ <br>
52
+ </div>
53
+ `,
54
+ );
55
+ }
56
+
57
+ {
58
+ // no line breaks around the text: its exact whitespace is significant
59
+ const out = await fmt(`<div class="a" data-x="1">text</div>`);
60
+ assert.equal(
61
+ out,
62
+ `<div
63
+ class="a"
64
+ data-x="1"
65
+ >text</div>
66
+ `,
67
+ );
68
+ }
69
+ });
70
+
71
+ test('keeps a single-attribute element on one line', async () => {
72
+ const out = await fmt(`<div class="a">text</div>`);
73
+ assert.equal(
74
+ out,
75
+ `<div class="a">text</div>
76
+ `,
77
+ );
78
+ });
79
+
80
+ test('keeps html, link and meta tags on a single line', async () => {
81
+ {
82
+ const out = await fmt(`<html lang="en" dir="rtl">
83
+ <head></head>
84
+ </html>`);
85
+ assert.equal(
86
+ out,
87
+ `<html lang="en" dir="rtl">
88
+ <head></head>
89
+ </html>
90
+ `,
91
+ );
92
+ }
93
+
94
+ {
95
+ assert.equal(
96
+ await fmt(`<link rel="stylesheet" href="/style.css" media="screen">`),
97
+ `<link rel="stylesheet" href="/style.css" media="screen">\n`,
98
+ );
99
+ }
100
+
101
+ {
102
+ assert.equal(
103
+ await fmt(`<meta name="description" content="a b c">`),
104
+ `<meta name="description" content="a b c">\n`,
105
+ );
106
+ }
107
+
108
+ {
109
+ // even when the source breaks the start tag across lines
110
+ assert.equal(
111
+ await fmt(`<meta
112
+ name="a"
113
+ content="b"
114
+ >`),
115
+ `<meta name="a" content="b">\n`,
116
+ );
117
+ }
118
+
119
+ {
120
+ // a single space is kept before the closing slash of a self-closing tag
121
+ assert.equal(
122
+ await fmt(`<meta name="msapplication-TileColor" content="#ffffff" />`),
123
+ `<meta name="msapplication-TileColor" content="#ffffff" />\n`,
124
+ );
125
+ assert.equal(await fmt(`<link rel="a" href="/b"/>`), `<link rel="a" href="/b" />\n`);
126
+ }
127
+ });
128
+
129
+ test('does not close open-ended partials', async () => {
130
+ {
131
+ // a partial that opens the document, completed by another template
132
+ assert.equal(await fmt(`<html>\n <body>`), `<html>
133
+ <body>
134
+ `);
135
+ }
136
+
137
+ {
138
+ assert.equal(
139
+ await fmt(`<div class="a" data-b="1">`),
140
+ `<div
141
+ class="a"
142
+ data-b="1"
143
+ >
144
+ `,
145
+ );
146
+ }
147
+
148
+ {
149
+ assert.equal(await fmt(`<div class="a">`), `<div class="a">\n`);
150
+ }
151
+
152
+ {
153
+ // closed elements still get their end tag
154
+ assert.equal(await fmt(`<div class="a">x</div>`), `<div class="a">x</div>\n`);
155
+ }
156
+ });
157
+
158
+ test('preserves the indentation of dangling end tags', async () => {
159
+ {
160
+ // a partial that closes the document, opened by another template
161
+ assert.equal(
162
+ await fmt(`{{ function('wp_footer') }}
163
+ </body>
164
+ </html>`),
165
+ ` {{ function('wp_footer') }}
166
+ </body>
167
+ </html>
168
+ `,
169
+ );
170
+
171
+ // a subtle variation that does get formatted: extra spaces in the tag
172
+ assert.equal(
173
+ await fmt(`{{ function('wp_footer') }}
174
+ </body>
175
+ </html>`),
176
+ ` {{ function('wp_footer') }}
177
+ </body>
178
+ </html>
179
+ `,
180
+ );
181
+ }
182
+
183
+ {
184
+ const source = ` {% include 'footer.twig' %}
185
+ </body>
186
+ </html>
187
+ `;
188
+ assert.equal(await fmt(source), source);
189
+
190
+ // a subtle variation that does get formatted: extra spaces in the tag
191
+ assert.equal(
192
+ await fmt(` {% include 'footer.twig' %}
193
+ </body>
194
+ </html>
195
+ `),
196
+ source,
197
+ );
198
+ }
199
+ });
200
+
201
+ test('preserves significant whitespace around inline text', async () => {
202
+ {
203
+ // no line break: the exact whitespace is preserved
204
+ assert.equal(await fmt(`<span>foo</span>`), `<span>foo</span>\n`);
205
+ assert.equal(await fmt(`<span> foo</span>`), `<span> foo</span>\n`);
206
+
207
+ // a subtle variation that does get formatted: the start tag's space
208
+ assert.equal(await fmt(`<span >foo</span>`), `<span>foo</span>\n`);
209
+ }
210
+
211
+ {
212
+ // already spans lines: indentation may be normalized, close tag is kept
213
+ assert.equal(await fmt(`<span>\nfoo</span>`), `<span>\n\tfoo</span>\n`);
214
+ assert.equal(await fmt(`<span>\nfoo\n</span>`), `<span>\n\tfoo\n</span>\n`);
215
+ }
216
+ });
217
+
218
+ test('keeps inline text next to expressions in place', async () => {
219
+ {
220
+ // the `.` is not surrounded by newlines, so it stays on the same line
221
+ const source = `<h1 class="hero__title type-a">
222
+ {{ page.title }}.
223
+ </h1>
224
+ `;
225
+ assert.equal(await fmt(source), source);
226
+
227
+ // a subtle variation that does get formatted: spaces inside the tag
228
+ assert.equal(
229
+ await fmt(`<h1 class="hero__title type-a">
230
+ {{ page.title }}.
231
+ </h1>
232
+ `),
233
+ source,
234
+ );
235
+ }
236
+
237
+ {
238
+ // the content is surrounded by newlines, so indentation may be adjusted
239
+ assert.equal(
240
+ await fmt(`<p>
241
+ {{ a }}, and {{ b }}!
242
+ </p>`),
243
+ `<p>
244
+ {{ a }}, and {{ b }}!
245
+ </p>
246
+ `,
247
+ );
248
+ }
249
+ });
250
+
251
+ test('keeps text around inline elements on one line', async () => {
252
+ const source = `<p class="l-error__text type-d">
253
+ Please try again later. Go back to the <a href="/">homepage</a>.
254
+ </p>
255
+ `;
256
+ assert.equal(await fmt(source), source);
257
+
258
+ // a subtle variation that does get formatted: a run of spaces in the text
259
+ assert.equal(
260
+ await fmt(`<p class="l-error__text type-d">
261
+ Please try again later. Go back to the <a href="/">homepage</a>.
262
+ </p>
263
+ `),
264
+ source,
265
+ );
266
+ });
267
+
268
+ test('places the closing > at the opening indent in multi-line tags', async () => {
269
+ {
270
+ const out = await fmt(`<div
271
+ class="a"
272
+ data-x
273
+ ><br></div>`);
274
+ assert.equal(
275
+ out,
276
+ `<div
277
+ class="a"
278
+ data-x
279
+ >
280
+ <br>
281
+ </div>
282
+ `,
283
+ );
284
+ }
285
+
286
+ {
287
+ const out = await fmt(`<div
288
+ class="a"
289
+ data-x
290
+ >body</div>`);
291
+ assert.equal(
292
+ out,
293
+ `<div
294
+ class="a"
295
+ data-x
296
+ >body</div>
297
+ `,
298
+ );
299
+ }
300
+
301
+ {
302
+ const out = await fmt(`<div
303
+ class="a"
304
+ data-x><br></div>`);
305
+ assert.equal(
306
+ out,
307
+ `<div
308
+ class="a"
309
+ data-x
310
+ >
311
+ <br>
312
+ </div>
313
+ `,
314
+ );
315
+ }
316
+
317
+ {
318
+ const out = await fmt(`<div
319
+ class="a"
320
+ data-x>body</div>`);
321
+ assert.equal(
322
+ out,
323
+ `<div
324
+ class="a"
325
+ data-x
326
+ >body</div>
327
+ `,
328
+ );
329
+ }
330
+
331
+ {
332
+ // no content: the end tag closes on the same line as the opening `>`
333
+ const out = await fmt(`<div
334
+ class="a"
335
+ data-x></div>`);
336
+ assert.equal(
337
+ out,
338
+ `<div
339
+ class="a"
340
+ data-x
341
+ ></div>
342
+ `,
343
+ );
344
+ }
345
+
346
+ {
347
+ const out = await fmt(`<div
348
+ class="store-locator__map"
349
+ data-store-locator-map
350
+ ></div>`);
351
+ assert.equal(
352
+ out,
353
+ `<div
354
+ class="store-locator__map"
355
+ data-store-locator-map
356
+ ></div>
357
+ `,
358
+ );
359
+ }
360
+
361
+ {
362
+ const out = await fmt(`<span class="foo" aria-hidden="true">·&nbsp;&nbsp;</span>`);
363
+ assert.equal(
364
+ out,
365
+ `<span
366
+ class="foo"
367
+ aria-hidden="true"
368
+ >·&nbsp;&nbsp;</span>
369
+ `,
370
+ );
371
+ }
372
+ });
373
+
374
+ test('formats conditional attributes', async () => {
375
+ {
376
+ const source = `<a
377
+ href="{{ url }}"
378
+ {% if current %}aria-current="page"{% endif %}
379
+ ><br></a>`;
380
+ const out = await fmt(source);
381
+ assert.equal(
382
+ out,
383
+ `<a
384
+ href="{{ url }}"
385
+ {% if current %}
386
+ aria-current="page"
387
+ {% endif %}
388
+ >
389
+ <br>
390
+ </a>
391
+ `,
392
+ );
393
+ }
394
+
395
+ {
396
+ const source = `<a
397
+ href="{{ url }}"
398
+ {% if current %}aria-current="page"{% endif %}
399
+ >text</a>`;
400
+ const out = await fmt(source);
401
+ assert.equal(
402
+ out,
403
+ `<a
404
+ href="{{ url }}"
405
+ {% if current %}
406
+ aria-current="page"
407
+ {% endif %}
408
+ >text</a>
409
+ `,
410
+ );
411
+ }
412
+
413
+ {
414
+ const source = `<a href="{{ url }}" {% if current %}aria-current="page"{% endif %}><br></a>`;
415
+ const out = await fmt(source);
416
+ assert.equal(
417
+ out,
418
+ `<a
419
+ href="{{ url }}"
420
+ {% if current %}
421
+ aria-current="page"
422
+ {% endif %}
423
+ >
424
+ <br>
425
+ </a>
426
+ `,
427
+ );
428
+ }
429
+
430
+ {
431
+ const source = `<a href="{{ url }}" {% if current %}aria-current="page"{% endif %}>text</a>`;
432
+ const out = await fmt(source);
433
+ assert.equal(
434
+ out,
435
+ `<a
436
+ href="{{ url }}"
437
+ {% if current %}
438
+ aria-current="page"
439
+ {% endif %}
440
+ >text</a>
441
+ `,
442
+ );
443
+ }
444
+
445
+ {
446
+ const source = `<a
447
+ href="{{ url }}"
448
+ {% if current %}
449
+ aria-current="page"
450
+ {% endif %}
451
+ ><br></a>`;
452
+ const out = await fmt(source);
453
+ assert.equal(
454
+ out,
455
+ `<a
456
+ href="{{ url }}"
457
+ {% if current %}
458
+ aria-current="page"
459
+ {% endif %}
460
+ >
461
+ <br>
462
+ </a>
463
+ `,
464
+ );
465
+ }
466
+
467
+ {
468
+ const source = `<a
469
+ href="{{ url }}"
470
+ {% if current %}
471
+ aria-current="page"
472
+ {% endif %}
473
+ >text</a>`;
474
+ const out = await fmt(source);
475
+ assert.equal(
476
+ out,
477
+ `<a
478
+ href="{{ url }}"
479
+ {% if current %}
480
+ aria-current="page"
481
+ {% endif %}
482
+ >text</a>
483
+ `,
484
+ );
485
+ }
486
+ });
487
+
488
+ test('indents twig block bodies', async () => {
489
+ {
490
+ const out = await fmt(`{% if x %}
491
+ <div>a</div>
492
+ {% endif %}`);
493
+ assert.equal(
494
+ out,
495
+ `{% if x %}
496
+ <div>a</div>
497
+ {% endif %}
498
+ `,
499
+ );
500
+ }
501
+
502
+ {
503
+ const out = await fmt(`{% if x %}<div>a</div>{% endif %}`);
504
+ assert.equal(
505
+ out,
506
+ `{% if x %}
507
+ <div>a</div>
508
+ {% endif %}
509
+ `,
510
+ );
511
+ }
512
+
513
+ {
514
+ const out = await fmt(`{% if x %}
515
+ <div>a</div>
516
+ {% endif %}`);
517
+ assert.equal(
518
+ out,
519
+ `{% if x %}
520
+ <div>a</div>
521
+ {% endif %}
522
+ `,
523
+ );
524
+ }
525
+
526
+ {
527
+ const out = await fmt(`{% if
528
+ x
529
+ %}
530
+ <div>a</div>
531
+ {%
532
+ endif %}`);
533
+ assert.equal(
534
+ out,
535
+ `{% if x %}
536
+ <div>a</div>
537
+ {% endif %}
538
+ `,
539
+ );
540
+ }
541
+
542
+ {
543
+ const out = await fmt(`{% if x %}
544
+ <div>a</div>
545
+ {% endif
546
+ %}`);
547
+ assert.equal(
548
+ out,
549
+ `{% if x %}
550
+ <div>a</div>
551
+ {% endif %}
552
+ `,
553
+ );
554
+ }
555
+ });
556
+
557
+ test('formats an include with a multiline array', async () => {
558
+ {
559
+ const out = await fmt(`{% include 'x.twig' with { a: 1, b: 2 } %}`);
560
+ assert.equal(
561
+ out,
562
+ `{% include 'x.twig' with {
563
+ a: 1,
564
+ b: 2,
565
+ } %}
566
+ `,
567
+ );
568
+ }
569
+
570
+ {
571
+ const out = await fmt(`{% include 'x.twig' with {
572
+ a: 1,
573
+ b: 2,
574
+ } %}
575
+ `);
576
+ assert.equal(
577
+ out,
578
+ `{% include 'x.twig' with {
579
+ a: 1,
580
+ b: 2,
581
+ } %}
582
+ `,
583
+ );
584
+ }
585
+
586
+ {
587
+ const out = await fmt(`{% include 'x.twig'
588
+ with {
589
+ a: 1,
590
+ b: 2,
591
+ }
592
+ %}
593
+ `);
594
+ assert.equal(
595
+ out,
596
+ `{% include 'x.twig' with {
597
+ a: 1,
598
+ b: 2,
599
+ } %}
600
+ `,
601
+ );
602
+ }
603
+ });
604
+
605
+ test('formats a set with a multiline array', async () => {
606
+ {
607
+ const out = await fmt(`{% set image = ensure_timber_image(teaser.thumbnail) %}
608
+ {% if image %}
609
+ {% set url = image.imgix_src|add_query_arg({
610
+ ar: '4:5',
611
+ fit: 'crop',
612
+ crop: 'faces,edges',
613
+ auto: 'format',
614
+ q: 60,
615
+ }, false) %}
616
+ {% endif %}`);
617
+ assert.equal(
618
+ out,
619
+ `{% set image = ensure_timber_image(teaser.thumbnail) %}
620
+ {% if image %}
621
+ {% set url = image.imgix_src|add_query_arg({
622
+ ar: '4:5',
623
+ fit: 'crop',
624
+ crop: 'faces,edges',
625
+ auto: 'format',
626
+ q: 60,
627
+ }, false) %}
628
+ {% endif %}
629
+ `,
630
+ );
631
+ }
632
+
633
+ {
634
+ const out = await fmt(`{% set url = image.imgix_src|add_query_arg({ ar: '4:5', fit: 'crop', crop: 'faces,edges', auto: 'format', q: 60 }, false) %}`);
635
+ assert.equal(
636
+ out,
637
+ `{% set url = image.imgix_src|add_query_arg({
638
+ ar: '4:5',
639
+ fit: 'crop',
640
+ crop: 'faces,edges',
641
+ auto: 'format',
642
+ q: 60,
643
+ }, false) %}
644
+ `,
645
+ );
646
+ }
647
+
648
+ {
649
+ const out = await fmt(`{% set url =
650
+ image.imgix_src|add_query_arg(
651
+ {
652
+ ar: '4:5',
653
+ fit: 'crop',
654
+ crop: 'faces,edges',
655
+ auto: 'format',
656
+ q: 60,
657
+ },
658
+ false
659
+ )
660
+ %}
661
+ `);
662
+ assert.equal(
663
+ out,
664
+ `{% set url = image.imgix_src|add_query_arg({
665
+ ar: '4:5',
666
+ fit: 'crop',
667
+ crop: 'faces,edges',
668
+ auto: 'format',
669
+ q: 60,
670
+ }, false) %}
671
+ `,
672
+ );
673
+ }
674
+ });
675
+
676
+ test('keeps source multi-line objects expanded regardless of printWidth', async () => {
677
+ const source = `{% set url = image.imgix_src|add_query_arg({
678
+ ar: '1:1',
679
+ fit: 'fill',
680
+ fill: solid,
681
+ 'fill-color': 'fff',
682
+ auto: 'format',
683
+ q: 70,
684
+ }, false) %}
685
+ `;
686
+ assert.equal(await fmt(source, { printWidth: 200 }), source);
687
+
688
+ // a subtle variation that does get formatted: extra spaces around colons
689
+ assert.equal(
690
+ await fmt(
691
+ `{% set url = image.imgix_src|add_query_arg({
692
+ ar: '1:1',
693
+ fit: 'fill',
694
+ fill: solid,
695
+ 'fill-color': 'fff',
696
+ auto: 'format',
697
+ q: 70,
698
+ }, false) %}
699
+ `,
700
+ { printWidth: 200 },
701
+ ),
702
+ source,
703
+ );
704
+ });
705
+
706
+ test('formats a set with a multiline sequence', async () => {
707
+ const source = `{% set footer_menus = [
708
+ { menu_items: menus.footer_a_navigation_items },
709
+ { menu_items: menus.footer_b_navigation_items },
710
+ { menu_items: menus.footer_c_navigation_items },
711
+ ] %}
712
+ `;
713
+ assert.equal(await fmt(source, { printWidth: 200 }), source);
714
+
715
+ // a subtle variation that does get formatted: extra spaces after colons
716
+ assert.equal(
717
+ await fmt(
718
+ `{% set footer_menus = [
719
+ { menu_items: menus.footer_a_navigation_items },
720
+ { menu_items: menus.footer_b_navigation_items },
721
+ { menu_items: menus.footer_c_navigation_items },
722
+ ] %}
723
+ `,
724
+ { printWidth: 200 },
725
+ ),
726
+ source,
727
+ );
728
+ });
729
+
730
+ test('preserves only a single blank line between siblings', async () => {
731
+ {
732
+ const source = `<div>
733
+ <p>a</p>
734
+
735
+ <p>b</p>
736
+ </div>`;
737
+ const out = await fmt(source);
738
+ assert.equal(
739
+ out,
740
+ `<div>
741
+ <p>a</p>
742
+
743
+ <p>b</p>
744
+ </div>
745
+ `,
746
+ );
747
+ }
748
+
749
+ {
750
+ const source = `<div>
751
+ <p>a</p>
752
+
753
+
754
+ <p>b</p>
755
+ </div>`;
756
+ const out = await fmt(source);
757
+ assert.equal(
758
+ out,
759
+ `<div>
760
+ <p>a</p>
761
+
762
+ <p>b</p>
763
+ </div>
764
+ `,
765
+ );
766
+ }
767
+ });
768
+
769
+ test('drops blank lines at the start of the document', async () => {
770
+ assert.equal(await fmt(`\n\n\n<div>x</div>\n\n\n`), `<div>x</div>\n`);
771
+ assert.equal(
772
+ await fmt(`\n\n{% if a %}\n\t<p>x</p>\n{% endif %}`),
773
+ `{% if a %}
774
+ <p>x</p>
775
+ {% endif %}
776
+ `,
777
+ );
778
+ });
779
+
780
+ test('preserves blank lines between root-level siblings', async () => {
781
+ const source = `<p>a</p>
782
+
783
+ <p>b</p>
784
+
785
+ <p>c</p>
786
+ `;
787
+ assert.equal(await fmt(source), source);
788
+
789
+ // a subtle variation that does get formatted: extra blank lines collapse
790
+ assert.equal(
791
+ await fmt(`<p>a</p>
792
+
793
+
794
+ <p>b</p>
795
+
796
+ <p>c</p>
797
+ `),
798
+ source,
799
+ );
800
+ });
801
+
802
+ test('preserves blank lines around twig section heads and bodies', async () => {
803
+ const source = `{% if a %}
804
+
805
+ <p>x</p>
806
+
807
+ {% else %}
808
+
809
+ <p>y</p>
810
+ {% endif %}
811
+ `;
812
+ assert.equal(await fmt(source), source);
813
+
814
+ // a subtle variation that does get formatted: extra blank lines collapse
815
+ assert.equal(
816
+ await fmt(`{% if a %}
817
+
818
+
819
+ <p>x</p>
820
+
821
+ {% else %}
822
+
823
+ <p>y</p>
824
+ {% endif %}
825
+ `),
826
+ source,
827
+ );
828
+ });
829
+
830
+ test('keeps blank lines between statements bordered by whitespace trim markers', async () => {
831
+ const source = `{% if foo %}
832
+ {%- endif -%}
833
+
834
+ {%- if bar -%}
835
+ {% endif %}
836
+ `;
837
+ assert.equal(await fmt(source), source);
838
+
839
+ // a subtle variation that does get formatted: extra spaces in the tag
840
+ assert.equal(
841
+ await fmt(`{% if foo %}
842
+ {%- endif -%}
843
+
844
+ {%- if bar -%}
845
+ {% endif %}
846
+ `),
847
+ source,
848
+ );
849
+ });
850
+
851
+ test('keeps twig comments', async () => {
852
+ const out = await fmt(`{# note #}
853
+ <div>x</div>`);
854
+ assert.equal(
855
+ out,
856
+ `{# note #}
857
+ <div>x</div>
858
+ `,
859
+ );
860
+
861
+ // a subtle variation that does get formatted: extra spaces in the comment
862
+ assert.equal(
863
+ await fmt(`{# note #}
864
+ <div>x</div>`),
865
+ out,
866
+ );
867
+ });
868
+
869
+ test('format twig comments', async () => {
870
+ {
871
+ const out = await fmt(`{# note #}
872
+ <div>x</div>`);
873
+ assert.equal(
874
+ out,
875
+ `{# note #}
876
+ <div>x</div>
877
+ `,
878
+ );
879
+ }
880
+
881
+ {
882
+ const out = await fmt(`{# note #}
883
+ <div>x</div>`);
884
+ assert.equal(
885
+ out,
886
+ `{# note #}
887
+ <div>x</div>
888
+ `,
889
+ );
890
+ }
891
+
892
+ {
893
+ const out = await fmt(`{#
894
+ long note
895
+ seconde line
896
+ #}
897
+ <div>x</div>`);
898
+ assert.equal(
899
+ out,
900
+ `{#
901
+ long note
902
+ seconde line
903
+ #}
904
+ <div>x</div>
905
+ `,
906
+ );
907
+
908
+ // a subtle variation that does get formatted: a run of spaces inside
909
+ const normalized = await fmt(`{#
910
+ long note
911
+ seconde line
912
+ #}
913
+ <div>x</div>`);
914
+ assert.equal(
915
+ normalized,
916
+ `{#
917
+ long note
918
+ seconde line
919
+ #}
920
+ <div>x</div>
921
+ `,
922
+ );
923
+ }
924
+
925
+ {
926
+ const out = await fmt(`{# long note
927
+ seconde line
928
+ #}
929
+ <div>x</div>`);
930
+ assert.equal(
931
+ out,
932
+ `{#
933
+ long note
934
+ seconde line
935
+ #}
936
+ <div>x</div>
937
+ `,
938
+ );
939
+ }
940
+
941
+ {
942
+ const out = await fmt(`{#
943
+ long note
944
+ seconde line #}
945
+ <div>x</div>`);
946
+ assert.equal(
947
+ out,
948
+ `{#
949
+ long note
950
+ seconde line
951
+ #}
952
+ <div>x</div>
953
+ `,
954
+ );
955
+ }
956
+ });
957
+
958
+ test('keeps html comments and doctype', async () => {
959
+ const out = await fmt(`<!doctype html>
960
+ <!-- c -->
961
+ <p>x</p>`);
962
+ assert.equal(
963
+ out,
964
+ `<!doctype html>
965
+ <!-- c -->
966
+ <p>x</p>
967
+ `,
968
+ );
969
+
970
+ // a subtle variation that does get formatted: a space in the element tag
971
+ assert.equal(
972
+ await fmt(`<!doctype html>
973
+ <!-- c -->
974
+ <p >x</p>`),
975
+ out,
976
+ );
977
+ });
978
+
979
+ test('report-only mode: check returns false when formatting differs', async () => {
980
+ const unformatted = '<div class="a" data-b="1">x</div>';
981
+ const ok = await check(unformatted, { parser: 'twig-html', plugins: [plugin] });
982
+ assert.equal(ok, false);
983
+ });
984
+
985
+ test('report-only mode: check returns true when already formatted', async () => {
986
+ const formatted = await fmt('<div class="a" data-b="1">x</div>');
987
+ const ok = await check(formatted, { parser: 'twig-html', plugins: [plugin] });
988
+ assert.equal(ok, true);
989
+ });
990
+
991
+ test('range formatting does not corrupt output', async () => {
992
+ // Prettier's range machinery (`calculateRange` -> `isSourceElement`) only
993
+ // recognises built-in parser names, so a custom parser gets a no-op range.
994
+ // The guarantee we can make, and test, is that requesting a range returns
995
+ // the original source untouched (no offset drift or corruption).
996
+ const source = `<div class="a">keep</div>
997
+ <p class="x" data-y="1">z</p>`;
998
+ const rangeStart = source.indexOf('<p');
999
+ const rangeEnd = source.length;
1000
+ const out = await fmt(source, { rangeStart, rangeEnd });
1001
+ assert.equal(out, source);
1002
+
1003
+ // a subtle variation that does get formatted: without a range the same
1004
+ // source has its multi-attribute tag expanded
1005
+ assert.equal(
1006
+ await fmt(source),
1007
+ `<div class="a">keep</div>
1008
+ <p
1009
+ class="x"
1010
+ data-y="1"
1011
+ >z</p>
1012
+ `,
1013
+ );
1014
+ });
1015
+
1016
+ test('cursor offset is preserved', async () => {
1017
+ const source = `<div>
1018
+ <p>a</p>
1019
+ <span>b</span>
1020
+ </div>`;
1021
+ const cursorOffset = source.indexOf('<span');
1022
+ const res = await formatWithCursor(source, {
1023
+ parser: 'twig-html',
1024
+ plugins: [plugin],
1025
+ cursorOffset,
1026
+ });
1027
+ assert.equal(res.formatted, `${source}\n`);
1028
+ // the cursor still sits on the opening `<` of the `<span>` element
1029
+ assert.equal(res.formatted[res.cursorOffset], '<');
1030
+ assert.equal(res.formatted.slice(res.cursorOffset, res.cursorOffset + 5), '<span');
1031
+
1032
+ // a subtle variation that does get formatted: the mis-indented paragraph is
1033
+ // re-indented while the cursor still tracks the `<span>` element
1034
+ const unformatted = `<div>
1035
+ <p>a</p>
1036
+ <span>b</span>
1037
+ </div>`;
1038
+ const res2 = await formatWithCursor(unformatted, {
1039
+ parser: 'twig-html',
1040
+ plugins: [plugin],
1041
+ cursorOffset: unformatted.indexOf('<span'),
1042
+ });
1043
+ assert.equal(
1044
+ res2.formatted,
1045
+ `<div>
1046
+ <p>a</p>
1047
+ <span>b</span>
1048
+ </div>
1049
+ `,
1050
+ );
1051
+ assert.equal(res2.formatted.slice(res2.cursorOffset, res2.cursorOffset + 5), '<span');
1052
+ });
1053
+
1054
+ test('formats idempotently', async () => {
1055
+ const samples = [
1056
+ `<div class="a">x</div>`,
1057
+ `<div
1058
+ class="a"
1059
+ data-x="1"
1060
+ >x</div>`,
1061
+ `{% if x %}
1062
+ <p>a</p>
1063
+ {% else %}
1064
+ <p>b</p>
1065
+ {% endif %}`,
1066
+ `<ul>
1067
+ {% for i in items %}
1068
+ <li>{{ i }}</li>
1069
+ {% endfor %}
1070
+ </ul>`,
1071
+ ];
1072
+ for (const s of samples) {
1073
+ const once = await fmt(s);
1074
+ const twice = await fmt(once);
1075
+ assert.equal(once, twice, `not idempotent for ${JSON.stringify(s)}`);
1076
+ }
1077
+ });
1078
+
1079
+ test('preserves non-embedded raw-text element content verbatim', async () => {
1080
+ const source = `<textarea>
1081
+ keep spacing
1082
+ indented
1083
+ </textarea>
1084
+ `;
1085
+ assert.equal(await fmt(source), source);
1086
+
1087
+ // a subtle variation that does get formatted: a space in the start tag
1088
+ // (the raw content is still untouched)
1089
+ assert.equal(
1090
+ await fmt(`<textarea >
1091
+ keep spacing
1092
+ indented
1093
+ </textarea>
1094
+ `),
1095
+ source,
1096
+ );
1097
+ });
1098
+
1099
+ test('formats embedded JavaScript and keeps twig constructs', async () => {
1100
+ const source = `<script>
1101
+ var config = {{ config_json|json_encode|raw }};
1102
+ if (config) { console.log('{{ message }}'); }
1103
+ </script>
1104
+ `;
1105
+ assert.equal(
1106
+ await fmt(source),
1107
+ `<script>
1108
+ var config = {{ config_json|json_encode|raw }};
1109
+ if (config) {
1110
+ console.log("{{ message }}");
1111
+ }
1112
+ </script>
1113
+ `,
1114
+ );
1115
+ });
1116
+
1117
+ test('formats embedded CSS and keeps twig constructs', async () => {
1118
+ const source = `<style>
1119
+ .card { color: {{ color }}; background: url({{ background_url }}); }
1120
+ </style>
1121
+ `;
1122
+ assert.equal(
1123
+ await fmt(source),
1124
+ `<style>
1125
+ .card {
1126
+ color: {{ color }};
1127
+ background: url({{ background_url }});
1128
+ }
1129
+ </style>
1130
+ `,
1131
+ );
1132
+ });
1133
+
1134
+ test('formats scripts with inline twig control flow', async () => {
1135
+ const source = `<script>
1136
+ (function () {
1137
+ window.Mr = {
1138
+ gtagConfigured: {% if options.google_metrics.gtag_id %}true{% else %}false{% endif %}
1139
+ };
1140
+ })();
1141
+ </script>
1142
+ `;
1143
+ assert.equal(
1144
+ await fmt(source, { singleQuote: true }),
1145
+ `<script>
1146
+ (function () {
1147
+ window.Mr = {
1148
+ gtagConfigured: {% if options.google_metrics.gtag_id %}true{% else %}false{% endif %}
1149
+ };
1150
+ })();
1151
+ </script>
1152
+ `,
1153
+ );
1154
+ });
1155
+
1156
+ test('keeps multi-line twig blocks indented inside embedded JavaScript', async () => {
1157
+ const source = `<script>
1158
+ gtag("config", {{ options.google_metrics.gtag_id|json_encode }}, {
1159
+ {% if options.google_metrics.debug_mode %}
1160
+ 'debug_mode': true
1161
+ {% endif %}
1162
+ });
1163
+ </script>
1164
+ `;
1165
+ assert.equal(await fmt(source), source);
1166
+
1167
+ // a subtle variation that does get formatted: a space in the JavaScript,
1168
+ // while the multi-line twig block keeps its indentation
1169
+ assert.equal(
1170
+ await fmt(`<script>
1171
+ gtag( "config", {{ options.google_metrics.gtag_id|json_encode }}, {
1172
+ {% if options.google_metrics.debug_mode %}
1173
+ 'debug_mode': true
1174
+ {% endif %}
1175
+ });
1176
+ </script>
1177
+ `),
1178
+ source,
1179
+ );
1180
+ });
1181
+
1182
+ test('drops trailing commas in embedded JavaScript only', async () => {
1183
+ const source = `<script>
1184
+ var config = {
1185
+ a: 1,
1186
+ b: 2,
1187
+ };
1188
+ gtag("config", "x", {
1189
+ c: 3,
1190
+ });
1191
+ </script>
1192
+ <style>
1193
+ .a { color: red; }
1194
+ </style>
1195
+ {% include 'x.twig' with { a: 1, b: 2 } %}`;
1196
+ assert.equal(
1197
+ await fmt(source),
1198
+ `<script>
1199
+ var config = {
1200
+ a: 1,
1201
+ b: 2
1202
+ };
1203
+ gtag("config", "x", {
1204
+ c: 3
1205
+ });
1206
+ </script>
1207
+ <style>
1208
+ .a {
1209
+ color: red;
1210
+ }
1211
+ </style>
1212
+ {% include 'x.twig' with {
1213
+ a: 1,
1214
+ b: 2,
1215
+ } %}
1216
+ `,
1217
+ );
1218
+ });
1219
+
1220
+ test('honours the trailingComma option for twig constructs only', async () => {
1221
+ const source = `{% include 'partials/teaser-list-c.twig' with {
1222
+ teasers: format_teasers_data(posts)
1223
+ } %}
1224
+ <script>
1225
+ var config = {
1226
+ a: 1,
1227
+ };
1228
+ </script>
1229
+ <style>
1230
+ .a { color: red; }
1231
+ </style>`;
1232
+ assert.equal(
1233
+ await fmt(source),
1234
+ `{% include 'partials/teaser-list-c.twig' with {
1235
+ teasers: format_teasers_data(posts),
1236
+ } %}
1237
+ <script>
1238
+ var config = {
1239
+ a: 1
1240
+ };
1241
+ </script>
1242
+ <style>
1243
+ .a {
1244
+ color: red;
1245
+ }
1246
+ </style>
1247
+ `,
1248
+ );
1249
+ assert.equal(
1250
+ await fmt(source, { trailingComma: 'none' }),
1251
+ `{% include 'partials/teaser-list-c.twig' with {
1252
+ teasers: format_teasers_data(posts)
1253
+ } %}
1254
+ <script>
1255
+ var config = {
1256
+ a: 1
1257
+ };
1258
+ </script>
1259
+ <style>
1260
+ .a {
1261
+ color: red;
1262
+ }
1263
+ </style>
1264
+ `,
1265
+ );
1266
+ });
1267
+
1268
+ test('does not touch empty or external scripts and styles', async () => {
1269
+ assert.equal(await fmt('<script src="/x.js"></script>'), '<script src="/x.js"></script>\n');
1270
+ assert.equal(await fmt('<script></script>'), '<script></script>\n');
1271
+ assert.equal(await fmt('<style></style>'), '<style></style>\n');
1272
+ });
1273
+
1274
+ test('preserves significant whitespace inside <pre>', async () => {
1275
+ const source = `<pre>
1276
+ two spaces
1277
+ tab
1278
+ more
1279
+ </pre>
1280
+ `;
1281
+ assert.equal(await fmt(source), source);
1282
+
1283
+ // a subtle variation that does get formatted: a space in the start tag
1284
+ // (the whitespace-significant content is still untouched)
1285
+ assert.equal(
1286
+ await fmt(`<pre >
1287
+ two spaces
1288
+ tab
1289
+ more
1290
+ </pre>
1291
+ `),
1292
+ source,
1293
+ );
1294
+ });
1295
+
1296
+ test('keeps void elements without end tags', async () => {
1297
+ assert.equal(await fmt('<br>'), '<br>\n');
1298
+ assert.equal(await fmt('<hr>'), '<hr>\n');
1299
+ assert.equal(await fmt('<img src="a.png">'), '<img src="a.png">\n');
1300
+ });
1301
+
1302
+ test('expands multi-attribute void elements but never adds an end tag', async () => {
1303
+ assert.equal(
1304
+ await fmt('<img src="a.png" alt="a">'),
1305
+ `<img
1306
+ src="a.png"
1307
+ alt="a"
1308
+ >
1309
+ `,
1310
+ );
1311
+ });
1312
+
1313
+ test('keeps self-closing syntax for custom elements', async () => {
1314
+ assert.equal(await fmt('<custom-element />'), '<custom-element />\n');
1315
+ assert.equal(
1316
+ await fmt(`<custom-element />
1317
+ <p>x</p>`),
1318
+ `<custom-element />
1319
+ <p>x</p>
1320
+ `,
1321
+ );
1322
+
1323
+ // subtle variations that do get formatted: the space before the slash is
1324
+ // normalised to a single one
1325
+ assert.equal(await fmt('<custom-element />'), '<custom-element />\n');
1326
+ assert.equal(
1327
+ await fmt(`<custom-element/>
1328
+ <p>x</p>`),
1329
+ `<custom-element />
1330
+ <p>x</p>
1331
+ `,
1332
+ );
1333
+ });
1334
+
1335
+ test('does not invent an end tag for a self-closing single-attribute tag', async () => {
1336
+ // regression: the single-line start tag used to drop the `/`
1337
+ assert.equal(await fmt('<img src="a" />'), '<img src="a" />\n');
1338
+ });
1339
+
1340
+ test('ends the document with exactly one newline', async () => {
1341
+ assert.equal(await fmt('<div>x</div>'), '<div>x</div>\n');
1342
+ assert.equal(
1343
+ await fmt(`<div>x</div>
1344
+
1345
+
1346
+ `),
1347
+ '<div>x</div>\n',
1348
+ );
1349
+ assert.equal(await fmt(' ', { parser: 'twig-html', plugins: [plugin] }), '');
1350
+ });
1351
+
1352
+ test('normalizes unquoted, single-quoted and interpolated attribute forms', async () => {
1353
+ const out = await fmt(`<input type=text disabled value='a b' data-label="{{ label }}">`);
1354
+ assert.equal(
1355
+ out,
1356
+ `<input
1357
+ type=text
1358
+ disabled
1359
+ value='a b'
1360
+ data-label="{{ label }}"
1361
+ >
1362
+ `,
1363
+ );
1364
+ });
1365
+
1366
+ test('keeps event-handler attribute values verbatim', async () => {
1367
+ const source = `<form
1368
+ class="x"
1369
+ onsubmit="
1370
+ ;(function (e) {
1371
+ var element = document.getElementById('{{ id }}-select');
1372
+ if (!element) return;
1373
+
1374
+ window.location = href;
1375
+ }(event));
1376
+ "
1377
+ >
1378
+ </form>
1379
+ `;
1380
+ assert.equal(await fmt(source), source);
1381
+
1382
+ // a subtle variation that does get formatted: the ordinary attribute is
1383
+ // normalised while the event-handler value stays verbatim
1384
+ assert.equal(
1385
+ await fmt(`<form
1386
+ class = "x"
1387
+ onsubmit="
1388
+ ;(function (e) {
1389
+ var element = document.getElementById('{{ id }}-select');
1390
+ if (!element) return;
1391
+
1392
+ window.location = href;
1393
+ }(event));
1394
+ "
1395
+ >
1396
+ </form>
1397
+ `),
1398
+ source,
1399
+ );
1400
+ });
1401
+
1402
+ test('expands the start tag when an attribute value is multi-line', async () => {
1403
+ const source = `<div style="
1404
+ background-color: paleturquoise;
1405
+ box-sizing: border-box;
1406
+ font-family: monospace;
1407
+ min-height: 6.25rem;
1408
+ ">
1409
+ <p>
1410
+ wrapper
1411
+ </p>
1412
+ </div>
1413
+ `;
1414
+ assert.equal(
1415
+ await fmt(source),
1416
+ `<div
1417
+ style="
1418
+ background-color: paleturquoise;
1419
+ box-sizing: border-box;
1420
+ font-family: monospace;
1421
+ min-height: 6.25rem;
1422
+ "
1423
+ >
1424
+ <p>
1425
+ wrapper
1426
+ </p>
1427
+ </div>
1428
+ `,
1429
+ );
1430
+ });
1431
+
1432
+ test('aligns multi-line attribute values on one indent level', async () => {
1433
+ {
1434
+ const out = await fmt(`<img srcset="
1435
+ {{ url|add_query_arg({ w: 1600, q: 45 }) }} 2x,
1436
+ {{ url|add_query_arg({ w: 800 }) }}
1437
+ ">`);
1438
+ assert.equal(
1439
+ out,
1440
+ `<img
1441
+ srcset="
1442
+ {{ url|add_query_arg({ w: 1600, q: 45 }) }} 2x,
1443
+ {{ url|add_query_arg({ w: 800 }) }}
1444
+ "
1445
+ >
1446
+ `,
1447
+ );
1448
+ }
1449
+
1450
+ {
1451
+ const out = await fmt(`<div class="x" id="y"><img
1452
+ srcset="
1453
+ {{ a }} 1x,
1454
+ {{ b }} 2x
1455
+ "
1456
+ ></div>`);
1457
+ assert.equal(
1458
+ out,
1459
+ `<div
1460
+ class="x"
1461
+ id="y"
1462
+ >
1463
+ <img
1464
+ srcset="
1465
+ {{ a }} 1x,
1466
+ {{ b }} 2x
1467
+ "
1468
+ >
1469
+ </div>
1470
+ `,
1471
+ );
1472
+ }
1473
+ });
1474
+
1475
+ test('indents twig block bodies while section heads stay at block indent', async () => {
1476
+ const source = `{% if a %}
1477
+ {% for x in y %}
1478
+ <p>{{ x }}</p>
1479
+ {% endfor %}
1480
+ {% elseif b %}
1481
+ <p>b</p>
1482
+ {% else %}
1483
+ <p>c</p>
1484
+ {% endif %}
1485
+ `;
1486
+ const out = await fmt(source);
1487
+ assert.equal(out, source);
1488
+
1489
+ // a subtle variation that does get formatted: spaces inside the block tags
1490
+ assert.equal(
1491
+ await fmt(`{% if a %}
1492
+ {% for x in y %}
1493
+ <p>{{ x }}</p>
1494
+ {% endfor %}
1495
+ {% elseif b %}
1496
+ <p>b</p>
1497
+ {% else %}
1498
+ <p>c</p>
1499
+ {% endif %}
1500
+ `),
1501
+ source,
1502
+ );
1503
+ });
1504
+
1505
+ test('places else and elseif at the block indent in a flattened chain', async () => {
1506
+ {
1507
+ const out = await fmt(`{% if user %}<span>{{ user.name }}</span>{% endif %}`);
1508
+ assert.equal(
1509
+ out,
1510
+ `{% if user %}
1511
+ <span>{{ user.name }}</span>
1512
+ {% endif %}
1513
+ `,
1514
+ );
1515
+ }
1516
+
1517
+ {
1518
+ const out = await fmt(`{% if user %}
1519
+ <span>{{ user.name }}</span>
1520
+ {% endif %}`);
1521
+ assert.equal(
1522
+ out,
1523
+ `{% if user %}
1524
+ <span>{{ user.name }}</span>
1525
+ {% endif %}
1526
+ `,
1527
+ );
1528
+ }
1529
+
1530
+ {
1531
+ const out = await fmt(`{% if user %}<span>{{ user.name }}</span>{% else %}<span>N/A</span>{% endif %}`);
1532
+ assert.equal(
1533
+ out,
1534
+ `{% if user %}
1535
+ <span>{{ user.name }}</span>
1536
+ {% else %}
1537
+ <span>N/A</span>
1538
+ {% endif %}
1539
+ `,
1540
+ );
1541
+ }
1542
+
1543
+ {
1544
+ const out = await fmt(`{% if user %}
1545
+ <span>{{ user.name }}</span>
1546
+ {% else %}
1547
+ <span>N/A</span>
1548
+ {% endif %}`);
1549
+ assert.equal(
1550
+ out,
1551
+ `{% if user %}
1552
+ <span>{{ user.name }}</span>
1553
+ {% else %}
1554
+ <span>N/A</span>
1555
+ {% endif %}
1556
+ `,
1557
+ );
1558
+ }
1559
+ });
1560
+
1561
+ test('does not mess with whitespace in html elements', async () => {
1562
+ {
1563
+ const out = await fmt(`<span>{% if user %}{{ user.name }}{% else %}N/A{% endif %}</span>`);
1564
+ assert.equal(
1565
+ out,
1566
+ `<span>{% if user %}{{ user.name }}{% else %}N/A{% endif %}</span>
1567
+ `,
1568
+ );
1569
+ }
1570
+
1571
+ {
1572
+ const out = await fmt(`<span>
1573
+ {% if user %} {{ user.name }}{% else %} N/A{% endif %}
1574
+ </span>`);
1575
+ assert.equal(
1576
+ out,
1577
+ `<span>
1578
+ {% if user %} {{ user.name }}{% else %} N/A{% endif %}
1579
+ </span>
1580
+ `,
1581
+ );
1582
+ }
1583
+ });
1584
+
1585
+ test('drops redundant blank lines between siblings', async () => {
1586
+ const source = `<div>alpha</div>
1587
+
1588
+ <div>beta</div>
1589
+
1590
+
1591
+
1592
+
1593
+ <div>gamma</div>`;
1594
+ const out = await fmt(source);
1595
+ assert.equal(
1596
+ out,
1597
+ `<div>alpha</div>
1598
+
1599
+ <div>beta</div>
1600
+
1601
+ <div>gamma</div>
1602
+ `,
1603
+ );
1604
+ });
1605
+
1606
+ test('keeps twig and html comments, including inline and trimmed comments', async () => {
1607
+ const source = `<!doctype html>
1608
+ <!-- an html comment -->
1609
+ {# a twig comment #}
1610
+ {#- a trimmed twig comment -#}
1611
+ <div>{# inline #}text</div>
1612
+ `;
1613
+ const out = await fmt(source);
1614
+ assert.equal(out, source);
1615
+
1616
+ // a subtle variation that does get formatted: spaces in the standalone
1617
+ // comments are collapsed (the verbatim inline comment is left alone)
1618
+ assert.equal(
1619
+ await fmt(`<!doctype html>
1620
+ <!-- an html comment -->
1621
+ {# a twig comment #}
1622
+ {#- a trimmed twig comment -#}
1623
+ <div>{# inline #}text</div>
1624
+ `),
1625
+ source,
1626
+ );
1627
+ });
1628
+
1629
+ test('indents deeply nested elements', async () => {
1630
+ const out = await fmt(`<div><section><article><p>deep</p></article></section></div>`);
1631
+ assert.equal(
1632
+ out,
1633
+ `<div>
1634
+ <section>
1635
+ <article>
1636
+ <p>deep</p>
1637
+ </article>
1638
+ </section>
1639
+ </div>
1640
+ `,
1641
+ );
1642
+ });
1643
+
1644
+ test('indents nested loops inside elements', async () => {
1645
+ const source = `<table>
1646
+ <tbody>
1647
+ {% for row in rows %}
1648
+ <tr>
1649
+ {% for cell in row %}
1650
+ <td>{{ cell }}</td>
1651
+ {% endfor %}
1652
+ </tr>
1653
+ {% endfor %}
1654
+ </tbody>
1655
+ </table>
1656
+ `;
1657
+ assert.equal(await fmt(source), source);
1658
+
1659
+ // a subtle variation that does get formatted: spaces inside the loop tag
1660
+ assert.equal(
1661
+ await fmt(`<table>
1662
+ <tbody>
1663
+ {% for row in rows %}
1664
+ <tr>
1665
+ {% for cell in row %}
1666
+ <td>{{ cell }}</td>
1667
+ {% endfor %}
1668
+ </tr>
1669
+ {% endfor %}
1670
+ </tbody>
1671
+ </table>
1672
+ `),
1673
+ source,
1674
+ );
1675
+ });
1676
+
1677
+ test('nests control structures inside elements', async () => {
1678
+ 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
+ assert.equal(
1680
+ out,
1681
+ `{% for group in groups %}
1682
+ <div>
1683
+ <h2>{{ group.name }}</h2>
1684
+ <ul>
1685
+ {% for item in group.items %}
1686
+ <li>{{ item }}</li>
1687
+ {% endfor %}
1688
+ </ul>
1689
+ </div>
1690
+ {% endfor %}
1691
+ `,
1692
+ );
1693
+ });
1694
+
1695
+ test('indents nested if/else inside a loop with multi-attribute tags', async () => {
1696
+ const out = await fmt(`{% for x in items %}{% if x.active %}<li class="active" data-id="{{ x.id }}">{{ x.name }}</li>{% else %}<li>{{ x.name }}</li>{% endif %}{% else %}<li>empty</li>{% endfor %}`);
1697
+ assert.equal(
1698
+ out,
1699
+ `{% for x in items %}
1700
+ {% if x.active %}
1701
+ <li
1702
+ class="active"
1703
+ data-id="{{ x.id }}"
1704
+ >{{ x.name }}</li>
1705
+ {% else %}
1706
+ <li>{{ x.name }}</li>
1707
+ {% endif %}
1708
+ {% else %}
1709
+ <li>empty</li>
1710
+ {% endfor %}
1711
+ `,
1712
+ );
1713
+ });
1714
+
1715
+ test('keeps an if/elseif/else chain with inline bodies verbatim', async () => {
1716
+ assert.equal(
1717
+ await fmt(`{% if a %}A{% elseif b %}B{% elseif c %}C{% else %}D{% endif %}`),
1718
+ `{% if a %}A{% elseif b %}B{% elseif c %}C{% else %}D{% endif %}
1719
+ `,
1720
+ );
1721
+
1722
+ // a subtle variation that does get formatted: a line break inside a body
1723
+ // means the chain is no longer inline, so it is expanded
1724
+ assert.equal(
1725
+ await fmt(`{% if a %}
1726
+ A{% elseif b %}B{% elseif c %}C{% else %}D{% endif %}`),
1727
+ `{% if a %}
1728
+ A
1729
+ {% elseif b %}
1730
+ B
1731
+ {% elseif c %}
1732
+ C
1733
+ {% else %}
1734
+ D
1735
+ {% endif %}
1736
+ `,
1737
+ );
1738
+ });
1739
+
1740
+ test('indents nested twig blocks inside nested elements', async () => {
1741
+ const out = await fmt(`<div>
1742
+ <section>
1743
+ {% if a %}
1744
+ {% for x in y %}
1745
+ <p>{{ x }}</p>
1746
+ {% endfor %}
1747
+ {% else %}
1748
+ <p>none</p>
1749
+ {% endif %}
1750
+ </section>
1751
+ </div>`);
1752
+ assert.equal(
1753
+ out,
1754
+ `<div>
1755
+ <section>
1756
+ {% if a %}
1757
+ {% for x in y %}
1758
+ <p>{{ x }}</p>
1759
+ {% endfor %}
1760
+ {% else %}
1761
+ <p>none</p>
1762
+ {% endif %}
1763
+ </section>
1764
+ </div>
1765
+ `,
1766
+ );
1767
+ });
1768
+
1769
+ test('nested indent for include blocks', async () => {
1770
+ const source = `<div class="row">
1771
+ <div class="wrapper">
1772
+ <div class="frame-detail">
1773
+ {% if page_fields.colorways|length > 0 %}
1774
+ <ul class="frame-detail__colorways">
1775
+ {% for colorway in page_fields.colorways %}
1776
+ {% set image = ensure_timber_image(colorway.image) %}
1777
+ {% if image and colorway.name %}
1778
+ {% set anchor = single_use_anchor(fn('sanitize_title', colorway.name)) %}
1779
+ <li
1780
+ id="{{ anchor }}"
1781
+ class="frame-detail__colorways__item"
1782
+ >
1783
+ {% include 'snippets/colorway.twig' with {
1784
+ lazy_loading: loop.index0 > 1,
1785
+ } %}
1786
+ </li>
1787
+ {% endif %}
1788
+ {% endfor %}
1789
+ </ul>
1790
+ {% endif %}
1791
+ </div>
1792
+ </div>
1793
+ </div>
1794
+ `;
1795
+
1796
+ const out = await fmt(source);
1797
+
1798
+ assert.equal(out, source);
1799
+
1800
+ // a subtle variation that does get formatted: spaces inside a block tag
1801
+ assert.equal(
1802
+ await fmt(`<div class="row">
1803
+ <div class="wrapper">
1804
+ <div class="frame-detail">
1805
+ {% if page_fields.colorways|length > 0 %}
1806
+ <ul class="frame-detail__colorways">
1807
+ {% for colorway in page_fields.colorways %}
1808
+ {% set image = ensure_timber_image(colorway.image) %}
1809
+ {% if image and colorway.name %}
1810
+ {% set anchor = single_use_anchor(fn('sanitize_title', colorway.name)) %}
1811
+ <li
1812
+ id="{{ anchor }}"
1813
+ class="frame-detail__colorways__item"
1814
+ >
1815
+ {% include 'snippets/colorway.twig' with {
1816
+ lazy_loading: loop.index0 > 1,
1817
+ } %}
1818
+ </li>
1819
+ {% endif %}
1820
+ {% endfor %}
1821
+ </ul>
1822
+ {% endif %}
1823
+ </div>
1824
+ </div>
1825
+ </div>
1826
+ `),
1827
+ source,
1828
+ );
1829
+ });
1830
+
1831
+ test('nested blank link', async () => {
1832
+ const source = `<div class="row">
1833
+ <div class="wrapper">
1834
+ <div class="frame-detail">
1835
+ <div class="frame-detail__information">
1836
+ {% if page_fields.product_description %}
1837
+ <h2 class="frame-detail__information__title type-b">
1838
+ {{ __('Description', 'mr') }}
1839
+ </h2>
1840
+
1841
+ <div class="frame-detail__information__description type-d">
1842
+ {{ page_fields.product_description|wpautop }}
1843
+ </div>
1844
+ {% endif %}
1845
+ </div>
1846
+ </div>
1847
+ </div>
1848
+ </div>
1849
+ `;
1850
+
1851
+ const out = await fmt(source);
1852
+
1853
+ assert.equal(out, source);
1854
+
1855
+ // a subtle variation that does get formatted: an extra blank line collapses
1856
+ assert.equal(
1857
+ await fmt(`<div class="row">
1858
+ <div class="wrapper">
1859
+ <div class="frame-detail">
1860
+ <div class="frame-detail__information">
1861
+ {% if page_fields.product_description %}
1862
+ <h2 class="frame-detail__information__title type-b">
1863
+ {{ __('Description', 'mr') }}
1864
+ </h2>
1865
+
1866
+
1867
+ <div class="frame-detail__information__description type-d">
1868
+ {{ page_fields.product_description|wpautop }}
1869
+ </div>
1870
+ {% endif %}
1871
+ </div>
1872
+ </div>
1873
+ </div>
1874
+ </div>
1875
+ `),
1876
+ source,
1877
+ );
1878
+ });
1879
+
1880
+ test('multiple twig structures on a single line', async () => {
1881
+ const source = `{{ teaser.number_of_colorways }} {{ __('colours', 'mr') -}}
1882
+ `;
1883
+
1884
+ const out = await fmt(source);
1885
+
1886
+ assert.equal(out, source);
1887
+
1888
+ // a subtle variation that does get formatted: extra spaces inside and
1889
+ // between the expressions are normalised
1890
+ assert.equal(
1891
+ await fmt(`{{ teaser.number_of_colorways }} {{ __('colours', 'mr') -}}
1892
+ `),
1893
+ source,
1894
+ );
1895
+ });
1896
+