@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrhenry/prettier-twig",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "type": "module",
5
5
  "main": "src/index.js",
6
6
  "license": "MIT",
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 or text) that may
113
- * share a line with adjacent content when the source has no line break between
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 = normalizeTwigTag(node.open.atom.raw);
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 = normalizeTwigTag(section.head.atom.raw);
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
- const raw = source.slice(node.rawStart, node.rawEnd);
889
- if (raw.includes('\n')) {
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
- return isInlineTwigBlock(node, source)
1115
- ? source.slice(node.rawStart, node.rawEnd)
1116
- : printTwigBlock(path, printCallback, source);
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(