@mrhenry/prettier-twig 0.1.0 → 0.1.2

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.2",
4
4
  "type": "module",
5
5
  "main": "src/index.js",
6
6
  "license": "MIT",
package/src/printer.js CHANGED
@@ -70,6 +70,13 @@ function printChildren(path, printCallback, source, leadingHardline = true, opti
70
70
  docs.push(childDoc);
71
71
  } else if (blankBefore) {
72
72
  docs.push([hardline, hardline, childDoc]);
73
+ } else if (
74
+ first &&
75
+ !gap.includes('\n') &&
76
+ (child.type === 'comment' || child.type === 'twigComment')
77
+ ) {
78
+ // a comment written right after the opening tag stays there
79
+ docs.push(gap === '' ? childDoc : [' ', childDoc]);
73
80
  } else if (!gap.includes('\n') && previousWasInline && inline) {
74
81
  // consecutive inline expressions on one source line stay together
75
82
  docs.push([gap === '' ? '' : ' ', childDoc]);
@@ -109,9 +116,9 @@ function danglingEndIndent(raw) {
109
116
  }
110
117
 
111
118
  /**
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.
119
+ * Whether a node is inline content (a `{{ … }}` expression, text, a single-line
120
+ * comment or an inline element) that may share a line with adjacent content when
121
+ * the source has no line break between them.
115
122
  *
116
123
  * @param {any} node
117
124
  * @returns {boolean}
@@ -123,14 +130,34 @@ function isInlineChild(node) {
123
130
  if (node.type === 'twig') {
124
131
  return Boolean(node.atom.isPrint);
125
132
  }
133
+ if (node.type === 'twigComment') {
134
+ return !/[\r\n]/.test(node.atom.raw);
135
+ }
136
+ if (node.type === 'comment') {
137
+ return !/[\r\n]/.test(node.raw);
138
+ }
126
139
  return node.type === 'element' && INLINE_ELEMENTS.has(node.name);
127
140
  }
128
141
 
129
- /** Phrasing (inline) elements that may share a line with neighbouring text. */
142
+ /**
143
+ * Elements that are `display: inline` by default (and the inline-level ruby,
144
+ * replaced and form-control elements), so they may share a line with
145
+ * neighbouring text instead of being moved onto their own line.
146
+ */
130
147
  const INLINE_ELEMENTS = new Set([
131
- 'a', 'abbr', 'b', 'bdi', 'bdo', 'br', 'cite', 'code', 'data', 'dfn', 'em',
132
- 'i', 'img', 'kbd', 'mark', 'q', 'rp', 'rt', 'ruby', 's', 'samp', 'small',
133
- 'span', 'strong', 'sub', 'sup', 'time', 'u', 'var', 'wbr',
148
+ // text-level semantics
149
+ 'a', 'abbr', 'acronym', 'b', 'bdi', 'bdo', 'big', 'br', 'cite', 'code',
150
+ 'data', 'del', 'dfn', 'em', 'font', 'i', 'ins', 'kbd', 'mark', 'nobr',
151
+ 'q', 's', 'samp', 'small', 'span', 'strike', 'strong', 'sub', 'sup',
152
+ 'time', 'tt', 'u', 'var', 'wbr',
153
+ // ruby annotations
154
+ 'rp', 'rt', 'ruby',
155
+ // embedded / replaced inline-level content
156
+ 'audio', 'canvas', 'embed', 'iframe', 'img', 'map', 'math', 'object',
157
+ 'picture', 'svg', 'video',
158
+ // inline-level form controls
159
+ 'button', 'input', 'label', 'meter', 'output', 'progress', 'select',
160
+ 'textarea',
134
161
  ]);
135
162
 
136
163
  /**
@@ -280,6 +307,81 @@ function normalizeTwigTag(raw) {
280
307
  return normalized === '' ? `${open}${close}` : `${open} ${normalized} ${close}`;
281
308
  }
282
309
 
310
+ /**
311
+ * The net parenthesis depth a line opens (positive) or closes (negative),
312
+ * ignoring parentheses inside string literals.
313
+ *
314
+ * @param {string} text
315
+ * @returns {number}
316
+ */
317
+ function parenDelta(text) {
318
+ let depth = 0;
319
+ let quote = '';
320
+ for (let i = 0; i < text.length; i += 1) {
321
+ const ch = text[i];
322
+ if (quote !== '') {
323
+ if (ch === '\\') {
324
+ i += 1;
325
+ } else if (ch === quote) {
326
+ quote = '';
327
+ }
328
+ continue;
329
+ }
330
+ if (ch === '"' || ch === "'") {
331
+ quote = ch;
332
+ } else if (ch === '(') {
333
+ depth += 1;
334
+ } else if (ch === ')') {
335
+ depth -= 1;
336
+ }
337
+ }
338
+ return depth;
339
+ }
340
+
341
+ /**
342
+ * Prints a `{% if … %}` / `{% elseif … %}` head. A condition written over
343
+ * several lines and made complex by boolean operators or parentheses keeps its
344
+ * line breaks, one operand per line, re-indented one level per parenthesis it
345
+ * is nested in. Anything else is normalized to a single line.
346
+ *
347
+ * @param {string} raw
348
+ * @param {any} options
349
+ * @returns {any} A Prettier doc.
350
+ */
351
+ function formatTwigHead(raw, options) {
352
+ const { open, close, body } = twigDelimiters(raw);
353
+ const keywordMatch = body.match(/^\s*(if|elseif)\b/);
354
+ const condition = keywordMatch ? body.slice(keywordMatch[0].length) : '';
355
+ const complex = /\b(and|or)\b/.test(condition) || condition.includes('(');
356
+ if (!keywordMatch || !/[\r\n]/.test(body) || !complex) {
357
+ return normalizeTwigTag(raw);
358
+ }
359
+ const unit = options.useTabs === false ? ' '.repeat(options.tabWidth ?? 4) : '\t';
360
+ const lines = body
361
+ .split(/\r?\n/)
362
+ .map((/** @type {string} */ text) => collapseWhitespace(text).trim());
363
+ while (lines.length > 0 && lines[0] === '') {
364
+ lines.shift();
365
+ }
366
+ while (lines.length > 0 && lines[lines.length - 1] === '') {
367
+ lines.pop();
368
+ }
369
+ if (lines.length === 0) {
370
+ return `${open}${close}`;
371
+ }
372
+ const [first, ...rest] = lines;
373
+ let depth = parenDelta(first);
374
+ /** @type {any[]} */
375
+ const continuation = [];
376
+ for (const conditionLine of rest) {
377
+ const leadingClose = (conditionLine.match(/^\)+/) ?? [''])[0].length;
378
+ const level = Math.max(0, depth - leadingClose);
379
+ continuation.push([hardline, unit.repeat(level), conditionLine]);
380
+ depth += parenDelta(conditionLine);
381
+ }
382
+ return [open, ' ', first, indent(continuation), hardline, close];
383
+ }
384
+
283
385
  /**
284
386
  * Whether a mapping/sequence was written multi-line in the source (a newline
285
387
  * between the opening delimiter and the first entry). Such containers stay
@@ -440,14 +542,13 @@ function formatTwigTag(raw, forceBreakStructures = false, trailingComma = true)
440
542
  }
441
543
 
442
544
  /**
443
- * Prints an element's start tag.
545
+ * Whether an element's start tag prints on a single line: at most one real
546
+ * attribute, no conditional attributes and no multi-line attribute value.
444
547
  *
445
548
  * @param {any} node
446
- * @param {any[]} attrDocs
447
- * @param {string} source
448
- * @returns {any} A Prettier doc.
549
+ * @returns {boolean}
449
550
  */
450
- function printOpenTag(node, attrDocs, source) {
551
+ function isSingleLineStartTag(node) {
451
552
  const attrs = node.attrs ?? [];
452
553
  const realAttrs = attrs.filter(
453
554
  (/** @type {any} */ a) => a.type !== 'twig' && a.type !== 'twigBlock' && a.type !== 'twigComment',
@@ -460,8 +561,20 @@ function printOpenTag(node, attrDocs, source) {
460
561
  : /[\r\n]/.test(c.text),
461
562
  ),
462
563
  );
564
+ return realAttrs.length <= 1 && !hasConditional && !hasComplexValue;
565
+ }
566
+
567
+ /**
568
+ * Prints an element's start tag.
569
+ *
570
+ * @param {any} node
571
+ * @param {any[]} attrDocs
572
+ * @param {string} source
573
+ * @returns {any} A Prettier doc.
574
+ */
575
+ function printOpenTag(node, attrDocs, source) {
463
576
  const forcedSingleLine = FORCE_SINGLE_LINE.has(node.name);
464
- const singleLineTag = forcedSingleLine || (realAttrs.length <= 1 && !hasConditional && !hasComplexValue);
577
+ const singleLineTag = forcedSingleLine || isSingleLineStartTag(node);
465
578
  return forcedSingleLine
466
579
  ? // `html`, `link` and `meta` are never broken across lines
467
580
  collapseWhitespace(source.slice(node.rawStart, node.startTagEnd)).replace(
@@ -567,7 +680,11 @@ function isTextOnly(node) {
567
680
 
568
681
  /**
569
682
  * Whether an element's non-empty content is only phrasing content (text,
570
- * prints, comments and inline twig blocks).
683
+ * prints, comments, inline twig blocks and inline elements).
684
+ *
685
+ * Inline element children only count as phrasing when the element's own start
686
+ * tag prints on a single line: once a multi-line start tag puts its `>` on a
687
+ * line of its own, the content belongs on the line below it.
571
688
  *
572
689
  * @param {any} node
573
690
  * @param {string} source
@@ -578,13 +695,15 @@ function isPhrasingContent(node, source) {
578
695
  if (children.length === 0) {
579
696
  return false;
580
697
  }
698
+ const inlineElementsAttach = isSingleLineStartTag(node);
581
699
  return children.every(
582
700
  (/** @type {any} */ c) =>
583
701
  c.type === 'text' ||
584
702
  c.type === 'twig' ||
585
703
  c.type === 'comment' ||
586
704
  c.type === 'twigComment' ||
587
- (c.type === 'twigBlock' && isInlineTwigBlock(c, source)),
705
+ (c.type === 'twigBlock' && isInlineTwigBlock(c, source)) ||
706
+ (c.type === 'element' && inlineElementsAttach && INLINE_ELEMENTS.has(c.name)),
588
707
  );
589
708
  }
590
709
 
@@ -700,11 +819,12 @@ function printAttributeValue(raw) {
700
819
  * @param {any} path
701
820
  * @param {(selector: any, ...rest: any[]) => any} printCallback
702
821
  * @param {string} source
822
+ * @param {any} options
703
823
  * @returns {any} A Prettier doc.
704
824
  */
705
- function printTwigBlock(path, printCallback, source) {
825
+ function printTwigBlock(path, printCallback, source, options) {
706
826
  const node = path.node;
707
- const open = normalizeTwigTag(node.open.atom.raw);
827
+ const open = formatTwigHead(node.open.atom.raw, options);
708
828
  const close = normalizeTwigTag(node.close.atom.raw);
709
829
  /** @type {any[]} */
710
830
  const docs = [];
@@ -717,7 +837,7 @@ function printTwigBlock(path, printCallback, source) {
717
837
  for (const section of node.sections ?? []) {
718
838
  if (section.head) {
719
839
  // mid tags (`{% else %}`, `{% elseif %}`) sit at the block's own indent
720
- const head = normalizeTwigTag(section.head.atom.raw);
840
+ const head = formatTwigHead(section.head.atom.raw, options);
721
841
  docs.push(hasBlankLine(source, previousEnd, section.head.atom.rawStart) ? [hardline, hardline, head] : [hardline, head]);
722
842
  previousEnd = section.head.atom.rawEnd;
723
843
  }
@@ -885,8 +1005,11 @@ function printTwigComment(node) {
885
1005
  * @returns {boolean}
886
1006
  */
887
1007
  function isInlineTwigBlock(node, source) {
888
- const raw = source.slice(node.rawStart, node.rawEnd);
889
- if (raw.includes('\n')) {
1008
+ // Trim-consumed whitespace before the `{%` is part of the node's raw span;
1009
+ // the open/close atoms describe the block itself, so a block that only looks
1010
+ // multi-line because of that leading trivia is still recognised as inline.
1011
+ const { start, end } = contentBounds(node);
1012
+ if (source.slice(start, end).includes('\n')) {
890
1013
  return false;
891
1014
  }
892
1015
  const children = (node.children ?? []).filter((/** @type {any} */ c) => !isWhitespaceOnly(c));
@@ -1111,9 +1234,13 @@ export function print(path, options, printCallback) {
1111
1234
  case 'element':
1112
1235
  return printElement(path, printCallback, source);
1113
1236
  case 'twigBlock':
1114
- return isInlineTwigBlock(node, source)
1115
- ? source.slice(node.rawStart, node.rawEnd)
1116
- : printTwigBlock(path, printCallback, source);
1237
+ if (isInlineTwigBlock(node, source)) {
1238
+ // slice from the atoms so trim-consumed whitespace before the `{%`
1239
+ // is not duplicated (the containing printer owns that gap)
1240
+ const { start, end } = contentBounds(node);
1241
+ return source.slice(start, end);
1242
+ }
1243
+ return printTwigBlock(path, printCallback, source, options);
1117
1244
  default:
1118
1245
  return printNode(node, options);
1119
1246
  }
@@ -265,6 +265,56 @@ test('keeps text around inline elements on one line', async () => {
265
265
  );
266
266
  });
267
267
 
268
+ test('keeps inline elements from being pushed onto their own line', async () => {
269
+ {
270
+ const source = `<p><strong>If you are seeing this message you are viewing an incomplete page.<br>Everything you see here is WIP.</strong></p>
271
+ `;
272
+ assert.equal(await fmt(source), source);
273
+ }
274
+
275
+ {
276
+ const source = `<p class="type-d"><b>{{ __('There are no results.', 'mr') }}</b></p>
277
+ `;
278
+ assert.equal(await fmt(source), source);
279
+ }
280
+
281
+ {
282
+ // no text sibling: the whole inline run stays on one line
283
+ const source = `<p><del>old</del><ins>new</ins></p>
284
+ `;
285
+ assert.equal(await fmt(source), source);
286
+ }
287
+
288
+ {
289
+ // inline-level form controls and embedded content are covered too
290
+ const source = `<p><label for="x">Name</label><input id="x"><img src="a.png"></p>
291
+ `;
292
+ assert.equal(await fmt(source), source);
293
+ }
294
+
295
+ {
296
+ // ruby annotations and legacy inline elements
297
+ const source = `<p><ruby>漢<rt>kan</rt></ruby><tt>mono</tt><big>big</big></p>
298
+ `;
299
+ assert.equal(await fmt(source), source);
300
+ }
301
+
302
+ {
303
+ // a multi-line start tag puts its `>` on its own line, so the inline
304
+ // content belongs on the line below it (unchanged behaviour)
305
+ assert.equal(
306
+ await fmt(`<div class="a" data-x="1"><br></div>`),
307
+ `<div
308
+ class="a"
309
+ data-x="1"
310
+ >
311
+ <br>
312
+ </div>
313
+ `,
314
+ );
315
+ }
316
+ });
317
+
268
318
  test('places the closing > at the opening indent in multi-line tags', async () => {
269
319
  {
270
320
  const out = await fmt(`<div
@@ -485,6 +535,150 @@ test('formats conditional attributes', async () => {
485
535
  }
486
536
  });
487
537
 
538
+ test('keeps a complex multi-line condition across lines with paren-depth indent', async () => {
539
+ {
540
+ const out = await fmt(`<div
541
+ class="teaser-a"
542
+ {% if teaser.path %}
543
+ data-has-link
544
+ {% endif %}
545
+ {% if
546
+ show_region or
547
+ teaser.email or
548
+ teaser.telephone or
549
+ teaser.street or
550
+ teaser.postal or
551
+ teaser.city or
552
+ (teaser.type == 'Contact' and (
553
+ teaser.opening_hours.days|length > 0 or
554
+ teaser.opening_hours.text or
555
+ teaser.opening_hours_b.days|length > 0 or
556
+ teaser.opening_hours_b.text or
557
+ teaser.links|length > 0
558
+ ))
559
+ %}
560
+ data-has-side
561
+ {% endif %}
562
+ >
563
+ `);
564
+ assert.equal(
565
+ out,
566
+ `<div
567
+ class="teaser-a"
568
+ {% if teaser.path %}
569
+ data-has-link
570
+ {% endif %}
571
+ {% if
572
+ show_region or
573
+ teaser.email or
574
+ teaser.telephone or
575
+ teaser.street or
576
+ teaser.postal or
577
+ teaser.city or
578
+ (teaser.type == 'Contact' and (
579
+ teaser.opening_hours.days|length > 0 or
580
+ teaser.opening_hours.text or
581
+ teaser.opening_hours_b.days|length > 0 or
582
+ teaser.opening_hours_b.text or
583
+ teaser.links|length > 0
584
+ ))
585
+ %}
586
+ data-has-side
587
+ {% endif %}
588
+ >
589
+ `,
590
+ );
591
+ }
592
+
593
+ {
594
+ const out = await fmt(`<div
595
+ class="teaser-a"
596
+ {% if teaser.path %}
597
+ data-has-link
598
+ {% endif %}
599
+ {% if
600
+ show_region or
601
+ teaser.email or
602
+ teaser.telephone or
603
+ teaser.street or
604
+ teaser.postal or
605
+ teaser.city or
606
+ (
607
+ teaser.type == 'Contact' and (
608
+ teaser.opening_hours.days|length > 0 or
609
+ teaser.opening_hours.text or
610
+ teaser.opening_hours_b.days|length > 0 or
611
+ teaser.opening_hours_b.text or
612
+ teaser.links|length > 0
613
+ )
614
+ )
615
+ %}
616
+ data-has-side
617
+ {% endif %}
618
+ >
619
+ `);
620
+ assert.equal(
621
+ out,
622
+ `<div
623
+ class="teaser-a"
624
+ {% if teaser.path %}
625
+ data-has-link
626
+ {% endif %}
627
+ {% if
628
+ show_region or
629
+ teaser.email or
630
+ teaser.telephone or
631
+ teaser.street or
632
+ teaser.postal or
633
+ teaser.city or
634
+ (
635
+ teaser.type == 'Contact' and (
636
+ teaser.opening_hours.days|length > 0 or
637
+ teaser.opening_hours.text or
638
+ teaser.opening_hours_b.days|length > 0 or
639
+ teaser.opening_hours_b.text or
640
+ teaser.links|length > 0
641
+ )
642
+ )
643
+ %}
644
+ data-has-side
645
+ {% endif %}
646
+ >
647
+ `,
648
+ );
649
+ }
650
+ });
651
+
652
+ test('keeps multi-line elseif conditions across lines', async () => {
653
+ const source = `{% if
654
+ a or
655
+ b
656
+ %}
657
+ <p>x</p>
658
+ {% elseif
659
+ c and
660
+ d
661
+ %}
662
+ <p>y</p>
663
+ {% endif %}
664
+ `;
665
+ assert.equal(await fmt(source), source);
666
+
667
+ // a simple condition broken across lines is still collapsed
668
+ assert.equal(
669
+ await fmt(`{% if
670
+ x
671
+ %}
672
+ <p>a</p>
673
+ {% endif %}
674
+ `),
675
+ `{% if x %}
676
+ <p>a</p>
677
+ {% endif %}
678
+ `,
679
+ );
680
+ });
681
+
488
682
  test('indents twig block bodies', async () => {
489
683
  {
490
684
  const out = await fmt(`{% if x %}
@@ -955,6 +1149,106 @@ test('format twig comments', async () => {
955
1149
  }
956
1150
  });
957
1151
 
1152
+ test('keeps inline comments next to content without adding line breaks', async () => {
1153
+ {
1154
+ // the comment sits after the text on the same line, so it stays there
1155
+ const source = `<p>
1156
+ Start of main content. {# TODO: Translate if necessary. #}
1157
+ </p>
1158
+ `;
1159
+ assert.equal(await fmt(source), source);
1160
+
1161
+ // a subtle variation that does get formatted: spaces in the comment
1162
+ assert.equal(
1163
+ await fmt(`<p>
1164
+ Start of main content. {# TODO: Translate if necessary. #}
1165
+ </p>
1166
+ `),
1167
+ source,
1168
+ );
1169
+ }
1170
+
1171
+ {
1172
+ // in between text on one line
1173
+ const source = `<div>
1174
+ text {# c #} more
1175
+ </div>
1176
+ `;
1177
+ assert.equal(await fmt(source), source);
1178
+ }
1179
+
1180
+ {
1181
+ // in between text and an inline element
1182
+ const source = `<p>
1183
+ Go back to the <a href="/">homepage</a>. {# c #}
1184
+ </p>
1185
+ `;
1186
+ assert.equal(await fmt(source), source);
1187
+ }
1188
+
1189
+ {
1190
+ // html comments behave like twig comments
1191
+ const source = `<div>
1192
+ text <!-- c --> more
1193
+ </div>
1194
+ `;
1195
+ assert.equal(await fmt(source), source);
1196
+ }
1197
+
1198
+ {
1199
+ // at the document root
1200
+ assert.equal(
1201
+ await fmt(`Start of main content. {# TODO: Translate if necessary. #}`),
1202
+ `Start of main content. {# TODO: Translate if necessary. #}
1203
+ `,
1204
+ );
1205
+ }
1206
+ });
1207
+
1208
+ test('keeps a comment written after the opening tag on that line', async () => {
1209
+ {
1210
+ const source = `<html lang="en" class="no-js"> {# TODO: set lang correctly #}
1211
+ <head></head>
1212
+ </html>
1213
+ `;
1214
+ assert.equal(await fmt(source), source);
1215
+ }
1216
+
1217
+ {
1218
+ // open-ended partial: the comment still follows the opening tag
1219
+ const source = `<html lang="en" class="no-js"> {# TODO: set lang correctly #}
1220
+ `;
1221
+ assert.equal(await fmt(source), source);
1222
+ }
1223
+
1224
+ {
1225
+ const source = `<div class="a"> {# c #}
1226
+ <p>x</p>
1227
+ </div>
1228
+ `;
1229
+ assert.equal(await fmt(source), source);
1230
+ }
1231
+
1232
+ {
1233
+ // html comments behave like twig comments
1234
+ const source = `<div class="a"> <!-- c -->
1235
+ <p>x</p>
1236
+ </div>
1237
+ `;
1238
+ assert.equal(await fmt(source), source);
1239
+ }
1240
+
1241
+ {
1242
+ // a newline after the opening tag still puts the comment on its own line
1243
+ const source = `<div class="a">
1244
+ {# c #}
1245
+ <p>x</p>
1246
+ </div>
1247
+ `;
1248
+ assert.equal(await fmt(source), source);
1249
+ }
1250
+ });
1251
+
958
1252
  test('keeps html comments and doctype', async () => {
959
1253
  const out = await fmt(`<!doctype html>
960
1254
  <!-- c -->
@@ -1674,6 +1968,23 @@ test('indents nested loops inside elements', async () => {
1674
1968
  );
1675
1969
  });
1676
1970
 
1971
+ test('keeps an inline twig block on one line after whitespace-trimmed siblings', async () => {
1972
+ const source = `{% if regions|length > 0 %}
1973
+ Regio
1974
+
1975
+ {% for region in regions -%}
1976
+ {{- region.name -}}
1977
+ {%- if not loop.last -%}, {% endif -%}
1978
+ {%- endfor -%}
1979
+
1980
+ {% if page_fields.job_meta_location or page_fields.job_meta_apply_until or tags|length > 0 %}
1981
+ <br>
1982
+ {% endif %}
1983
+ {% endif %}
1984
+ `;
1985
+ assert.equal(await fmt(source), source);
1986
+ });
1987
+
1677
1988
  test('nests control structures inside elements', async () => {
1678
1989
  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
1990
  assert.equal(