@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.
package/src/printer.js ADDED
@@ -0,0 +1,1145 @@
1
+ // @ts-check
2
+ /**
3
+ * The Prettier doc printer for the hybrid HTML+Twig AST.
4
+ *
5
+ * Style rules (deduced from the sample themes):
6
+ * - tabs for indentation, one level per HTML/twig nesting;
7
+ * - elements with a single attribute stay on one line, elements with several
8
+ * attributes (or conditional attributes) go multi-line with one attribute
9
+ * per line and the closing `>` on its own line at the opening `<` indent;
10
+ * - twig blocks indent their bodies;
11
+ * - twig spans are printed verbatim from their raw source;
12
+ * - text content layout (inline vs expanded) is preserved from the source;
13
+ * - blank lines between siblings are preserved.
14
+ *
15
+ * The printer descends through Prettier's `print` callback (via `path.call`
16
+ * and `print(selector)`), so Prettier can map original source offsets onto the
17
+ * printed output. That is what makes range formatting and `cursorOffset`
18
+ * tracking accurate; every AST node carries exact `rawStart`/`rawEnd` offsets.
19
+ *
20
+ * @module prettier-twig
21
+ */
22
+ import { doc } from 'prettier';
23
+
24
+ const { group, indent, align, line, softline, hardline, join, ifBreak } = doc.builders;
25
+
26
+ /**
27
+ * Prints a container's children: one per line at the container's indent,
28
+ * preserving blank lines between siblings.
29
+ *
30
+ * @param {any} path
31
+ * @param {(selector: any, ...rest: any[]) => any} printCallback
32
+ * @param {string} source
33
+ * @param {boolean} [leadingHardline]
34
+ * @param {any} [options]
35
+ * @returns {any[]} Prettier docs.
36
+ */
37
+ function printChildren(path, printCallback, source, leadingHardline = true, options = {}) {
38
+ const node = path.node;
39
+ const children = node.children ?? [];
40
+ /** @type {any[]} */
41
+ let docs = [];
42
+ // blank-line detection measures the source between the end of the previous
43
+ // non-whitespace sibling (or the container's opening) and the current one
44
+ let previousEnd = node.startTagEnd ?? node.rawStart;
45
+ let previousWasInline = false;
46
+ let first = true;
47
+ let danglingAt;
48
+ let danglingIndent = '';
49
+ for (let i = 0; i < children.length; i += 1) {
50
+ const child = children[i];
51
+ const isWs = isWhitespaceOnly(child);
52
+ const { start, end } = contentBounds(child);
53
+ const blankBefore = hasBlankLine(source, previousEnd, start);
54
+ const gap = source.slice(previousEnd, start);
55
+ if (isWs) {
56
+ continue;
57
+ }
58
+ previousEnd = end;
59
+ const childDoc = path.call(printCallback, 'children', i);
60
+ const inline = isInlineChild(child);
61
+ if (node.type === 'root' && danglingAt === undefined && child.type === 'text') {
62
+ const indent2 = danglingEndIndent(child.raw);
63
+ if (indent2 !== null) {
64
+ danglingAt = docs.length;
65
+ danglingIndent = indent2;
66
+ }
67
+ }
68
+ if (first && !leadingHardline) {
69
+ // the document must not start with a blank line
70
+ docs.push(childDoc);
71
+ } else if (blankBefore) {
72
+ docs.push([hardline, hardline, childDoc]);
73
+ } else if (!gap.includes('\n') && previousWasInline && inline) {
74
+ // consecutive inline expressions on one source line stay together
75
+ docs.push([gap === '' ? '' : ' ', childDoc]);
76
+ } else {
77
+ docs.push([hardline, childDoc]);
78
+ }
79
+ previousWasInline = inline;
80
+ first = false;
81
+ }
82
+ if (danglingAt !== undefined && danglingAt > 0) {
83
+ // Content that precedes a dangling end tag belongs inside the element
84
+ // that tag closes, so it sits one level below the tag's own indent.
85
+ const unit = options.useTabs === false ? ' '.repeat(options.tabWidth ?? 4) : '\t';
86
+ const prefix = danglingIndent + unit;
87
+ const head = docs.slice(0, danglingAt);
88
+ const tail = docs.slice(danglingAt);
89
+ docs = leadingHardline ? [align(prefix, head), ...tail] : [prefix, align(prefix, head), ...tail];
90
+ }
91
+ return docs;
92
+ }
93
+
94
+ /**
95
+ * The leading whitespace of the first dangling end tag in a text node, or
96
+ * `null` when the text holds no end tag.
97
+ *
98
+ * @param {string} raw
99
+ * @returns {string | null}
100
+ */
101
+ function danglingEndIndent(raw) {
102
+ for (const rawLine of raw.split(/\r?\n/)) {
103
+ const match = rawLine.match(/^([ \t]*)<\//);
104
+ if (match) {
105
+ return match[1];
106
+ }
107
+ }
108
+ return null;
109
+ }
110
+
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.
115
+ *
116
+ * @param {any} node
117
+ * @returns {boolean}
118
+ */
119
+ function isInlineChild(node) {
120
+ if (node.type === 'text') {
121
+ return node.raw.trim() !== '';
122
+ }
123
+ if (node.type === 'twig') {
124
+ return Boolean(node.atom.isPrint);
125
+ }
126
+ return node.type === 'element' && INLINE_ELEMENTS.has(node.name);
127
+ }
128
+
129
+ /** Phrasing (inline) elements that may share a line with neighbouring text. */
130
+ 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',
134
+ ]);
135
+
136
+ /**
137
+ * @param {any} node
138
+ * @returns {boolean} Whether the node is whitespace-only text.
139
+ */
140
+ function isWhitespaceOnly(node) {
141
+ return node.type === 'text' && node.raw.trim() === '';
142
+ }
143
+
144
+ /**
145
+ * The bounds of a node's own content. Comments carry their leading whitespace
146
+ * in `rawStart`/`rawEnd`, so the atom's offsets are used when present to avoid
147
+ * treating that trivia as part of the gap between siblings.
148
+ *
149
+ * @param {any} node
150
+ * @returns {{start: number, end: number}}
151
+ */
152
+ function contentBounds(node) {
153
+ // A twig block's raw span may start before its `{%` when the previous tag
154
+ // trimmed whitespace; use the open/close atoms so the trimmed gap stays
155
+ // visible to blank-line detection.
156
+ if (node.type === 'twigBlock' && node.open && node.close) {
157
+ return {
158
+ start: node.open.atom.rawStart ?? node.rawStart,
159
+ end: node.close.atom.rawEnd ?? node.rawEnd,
160
+ };
161
+ }
162
+ if (node.type === 'text') {
163
+ const raw = node.raw;
164
+ const leading = raw.length - raw.trimStart().length;
165
+ const trailing = raw.length - raw.trimEnd().length;
166
+ return { start: node.rawStart + leading, end: node.rawEnd - trailing };
167
+ }
168
+ const atom = node.atom;
169
+ return {
170
+ start: atom?.rawStart ?? node.rawStart,
171
+ end: atom?.rawEnd ?? node.rawEnd,
172
+ };
173
+ }
174
+
175
+ /**
176
+ * @param {string} source
177
+ * @param {number} from
178
+ * @param {number} to
179
+ * @returns {boolean} Whether the source between `from` and `to` contains a
180
+ * blank line (two or more newlines).
181
+ */
182
+ function hasBlankLine(source, from, to) {
183
+ const between = source.slice(Math.max(0, from), Math.max(0, to));
184
+ const count = (between.match(/\n/g) ?? []).length;
185
+ return count >= 2;
186
+ }
187
+
188
+ /**
189
+ * Collapses runs of whitespace in a twig construct's body to single spaces,
190
+ * leaving the contents of string literals untouched.
191
+ *
192
+ * @param {string} text
193
+ * @returns {string}
194
+ */
195
+ function collapseWhitespace(text) {
196
+ let out = '';
197
+ let quote = '';
198
+ let pendingSpace = false;
199
+ for (let i = 0; i < text.length; i += 1) {
200
+ const ch = text[i];
201
+ if (quote !== '') {
202
+ out += ch;
203
+ if (ch === '\\') {
204
+ out += text[i + 1] ?? '';
205
+ i += 1;
206
+ } else if (ch === quote) {
207
+ quote = '';
208
+ }
209
+ continue;
210
+ }
211
+ if (ch === '"' || ch === "'") {
212
+ if (pendingSpace && out !== '') {
213
+ out += ' ';
214
+ }
215
+ pendingSpace = false;
216
+ quote = ch;
217
+ out += ch;
218
+ continue;
219
+ }
220
+ if (/\s/.test(ch)) {
221
+ pendingSpace = true;
222
+ continue;
223
+ }
224
+ const previous = out[out.length - 1] ?? '';
225
+ if (pendingSpace && out !== '' && !'([{'.includes(previous) && !')]},'.includes(ch)) {
226
+ out += ' ';
227
+ }
228
+ pendingSpace = false;
229
+ out += ch;
230
+ }
231
+ return out;
232
+ }
233
+
234
+ /**
235
+ * Splits a `{% … %}` / `{{ … }}` construct into its delimiters (including any
236
+ * whitespace-trim markers) and its body.
237
+ *
238
+ * @param {string} raw
239
+ * @returns {{open: string, close: string, body: string}}
240
+ */
241
+ function twigDelimiters(raw) {
242
+ let open = '{%';
243
+ if (raw.startsWith('{%-')) {
244
+ open = '{%-';
245
+ } else if (raw.startsWith('{%~')) {
246
+ open = '{%~';
247
+ } else if (raw.startsWith('{{-')) {
248
+ open = '{{-';
249
+ } else if (raw.startsWith('{{~')) {
250
+ open = '{{~';
251
+ } else if (raw.startsWith('{{')) {
252
+ open = '{{';
253
+ }
254
+ let close = '%}';
255
+ if (raw.endsWith('-%}')) {
256
+ close = '-%}';
257
+ } else if (raw.endsWith('~%}')) {
258
+ close = '~%}';
259
+ } else if (raw.endsWith('-}}')) {
260
+ close = '-}}';
261
+ } else if (raw.endsWith('~}}')) {
262
+ close = '~}}';
263
+ } else if (raw.endsWith('}}')) {
264
+ close = '}}';
265
+ }
266
+ return { open, close, body: raw.slice(open.length, raw.length - close.length) };
267
+ }
268
+
269
+ /**
270
+ * Normalizes a `{% … %}` / `{{ … }}` construct to a single line: the opening
271
+ * and closing delimiters (including whitespace-trim markers) are preserved and
272
+ * the body's whitespace is collapsed.
273
+ *
274
+ * @param {string} raw
275
+ * @returns {string}
276
+ */
277
+ function normalizeTwigTag(raw) {
278
+ const { open, close, body } = twigDelimiters(raw);
279
+ const normalized = collapseWhitespace(body).trim();
280
+ return normalized === '' ? `${open}${close}` : `${open} ${normalized} ${close}`;
281
+ }
282
+
283
+ /**
284
+ * Whether a mapping/sequence was written multi-line in the source (a newline
285
+ * between the opening delimiter and the first entry). Such containers stay
286
+ * expanded even when they would fit on one line, mirroring Prettier.
287
+ *
288
+ * @param {string} text The container including its delimiters.
289
+ * @returns {boolean}
290
+ */
291
+ function isMultilineContainer(text) {
292
+ return new RegExp(`^\\${text[0]}[\\t ]*\\r?\\n`).test(text);
293
+ }
294
+
295
+ /**
296
+ * Collects, in pre-order, whether each `{ … }` / `[ … ]` container of `text`
297
+ * was multi-line in the source. They are consumed in the same order while
298
+ * formatting.
299
+ *
300
+ * @param {string} text
301
+ * @returns {boolean[]}
302
+ */
303
+ function collectMultilineStructures(text) {
304
+ /** @type {boolean[]} */
305
+ const result = [];
306
+ let quote = '';
307
+ let i = 0;
308
+ while (i < text.length) {
309
+ const ch = text[i];
310
+ if (quote !== '') {
311
+ if (ch === '\\') {
312
+ i += 2;
313
+ continue;
314
+ }
315
+ if (ch === quote) {
316
+ quote = '';
317
+ }
318
+ i += 1;
319
+ continue;
320
+ }
321
+ if (ch === '"' || ch === "'") {
322
+ quote = ch;
323
+ i += 1;
324
+ continue;
325
+ }
326
+ if (ch === '{' || ch === '[') {
327
+ const close = findMatchingBrace(text, i);
328
+ if (close !== -1) {
329
+ result.push(isMultilineContainer(text.slice(i, close + 1)));
330
+ result.push(...collectMultilineStructures(text.slice(i + 1, close)));
331
+ i = close + 1;
332
+ continue;
333
+ }
334
+ }
335
+ i += 1;
336
+ }
337
+ return result;
338
+ }
339
+
340
+ /**
341
+ * Formats the mapping/sequence literals in a twig construct body. Each `{ … }`
342
+ * / `[ … ]` becomes a Prettier group: it stays on one line when it fits,
343
+ * otherwise one entry per line with a trailing comma. Containers that were
344
+ * multi-line in the source are kept expanded.
345
+ *
346
+ * @param {string} text The already whitespace-normalized body.
347
+ * @param {boolean} forceBreak Whether every container must expand.
348
+ * @param {boolean[]} [multiline] Queue of source multi-line flags, in order.
349
+ * @param {boolean} [trailingComma] Whether expanded containers get a trailing comma.
350
+ * @returns {any} A Prettier doc.
351
+ */
352
+ function formatStructures(text, forceBreak, multiline = [], trailingComma = true) {
353
+ /** @type {any[]} */
354
+ const docs = [];
355
+ let quote = '';
356
+ let start = 0;
357
+ let i = 0;
358
+ while (i < text.length) {
359
+ const ch = text[i];
360
+ if (quote !== '') {
361
+ if (ch === '\\') {
362
+ i += 2;
363
+ continue;
364
+ }
365
+ if (ch === quote) {
366
+ quote = '';
367
+ }
368
+ i += 1;
369
+ continue;
370
+ }
371
+ if (ch === '"' || ch === "'") {
372
+ quote = ch;
373
+ i += 1;
374
+ continue;
375
+ }
376
+ if (ch === '{' || ch === '[') {
377
+ const close = findMatchingBrace(text, i);
378
+ if (close !== -1) {
379
+ if (i > start) {
380
+ docs.push(text.slice(start, i));
381
+ }
382
+ const sourceMultiline = multiline.shift() ?? false;
383
+ docs.push(
384
+ formatContainer(text.slice(i, close + 1), forceBreak || sourceMultiline, multiline, trailingComma),
385
+ );
386
+ i = close + 1;
387
+ start = i;
388
+ continue;
389
+ }
390
+ }
391
+ i += 1;
392
+ }
393
+ if (start < text.length) {
394
+ docs.push(text.slice(start));
395
+ }
396
+ return docs.length === 0 ? '' : docs;
397
+ }
398
+
399
+ /**
400
+ * @param {string} text A single `{ … }` mapping or `[ … ]` sequence.
401
+ * @param {boolean} forceBreak Whether the container must expand.
402
+ * @param {boolean[]} [multiline] Queue of source multi-line flags, in order.
403
+ * @param {boolean} [trailingComma] Whether an expanded container gets a trailing comma.
404
+ * @returns {any} A Prettier doc.
405
+ */
406
+ function formatContainer(text, forceBreak, multiline = [], trailingComma = true) {
407
+ const open = text[0];
408
+ const close = open === '{' ? '}' : ']';
409
+ const entries = splitTopLevel(text.slice(1, -1));
410
+ if (entries.length === 0) {
411
+ return `${open}${close}`;
412
+ }
413
+ const parts = entries.map((entry) => formatStructures(entry, false, multiline, trailingComma));
414
+ const trailing = trailingComma ? ifBreak(',') : '';
415
+ // mappings get inner spaces (`{ a: 1 }`), sequences do not (`[1, 2]`)
416
+ const edge = open === '{' ? line : softline;
417
+ return group(
418
+ [open, indent([edge, join([',', line], parts), trailing]), edge, close],
419
+ { shouldBreak: forceBreak },
420
+ );
421
+ }
422
+
423
+ /**
424
+ * Formats a `{% … %}` / `{{ … }}` construct: delimiters and whitespace are
425
+ * normalized and mappings/sequences are laid out by Prettier.
426
+ *
427
+ * @param {string} raw
428
+ * @param {boolean} [forceBreakStructures]
429
+ * @param {boolean} [trailingComma] Whether expanded containers get a trailing comma.
430
+ * @returns {any} A Prettier doc.
431
+ */
432
+ function formatTwigTag(raw, forceBreakStructures = false, trailingComma = true) {
433
+ const { open, close, body } = twigDelimiters(raw);
434
+ const normalized = collapseWhitespace(body).trim();
435
+ if (normalized === '') {
436
+ return `${open}${close}`;
437
+ }
438
+ const multiline = collectMultilineStructures(body);
439
+ return [open, ' ', formatStructures(normalized, forceBreakStructures, multiline, trailingComma), ' ', close];
440
+ }
441
+
442
+ /**
443
+ * Prints an element's start tag.
444
+ *
445
+ * @param {any} node
446
+ * @param {any[]} attrDocs
447
+ * @param {string} source
448
+ * @returns {any} A Prettier doc.
449
+ */
450
+ function printOpenTag(node, attrDocs, source) {
451
+ const attrs = node.attrs ?? [];
452
+ const realAttrs = attrs.filter(
453
+ (/** @type {any} */ a) => a.type !== 'twig' && a.type !== 'twigBlock' && a.type !== 'twigComment',
454
+ );
455
+ const hasConditional = attrs.some((/** @type {any} */ a) => a.type === 'twig' || a.type === 'twigBlock');
456
+ const hasComplexValue = realAttrs.some((/** @type {any} */ a) =>
457
+ a.valueChunks.some((/** @type {any} */ c) =>
458
+ c.type === 'twig'
459
+ ? Boolean(c.atom.tag) || /[\r\n]/.test(c.atom.raw)
460
+ : /[\r\n]/.test(c.text),
461
+ ),
462
+ );
463
+ const forcedSingleLine = FORCE_SINGLE_LINE.has(node.name);
464
+ const singleLineTag = forcedSingleLine || (realAttrs.length <= 1 && !hasConditional && !hasComplexValue);
465
+ return forcedSingleLine
466
+ ? // `html`, `link` and `meta` are never broken across lines
467
+ collapseWhitespace(source.slice(node.rawStart, node.startTagEnd)).replace(
468
+ /\s*\/?>$/,
469
+ node.selfClosing ? ' />' : '>',
470
+ )
471
+ : singleLineTag
472
+ ? ['<', node.nameRaw, ...attrDocs.map((/** @type {any} */ d) => [' ', d]), node.selfClosing ? ' />' : '>']
473
+ : ['<', node.nameRaw, indent([hardline, join(hardline, attrDocs)]), hardline, node.selfClosing ? '/>' : '>'];
474
+ }
475
+
476
+ /**
477
+ * Prints an element.
478
+ *
479
+ * @param {any} path
480
+ * @param {(selector: any, ...rest: any[]) => any} printCallback
481
+ * @param {string} source
482
+ * @returns {any} A Prettier doc.
483
+ */
484
+ function printElement(path, printCallback, source) {
485
+ const node = path.node;
486
+ const attrs = node.attrs ?? [];
487
+ const attrDocs = attrs.map((/** @type {any} */ _, /** @type {number} */ i) => path.call(printCallback, 'attrs', i));
488
+ const openDoc = printOpenTag(node, attrDocs, source);
489
+
490
+ if (node.selfClosing || VOID_ELEMENTS.has(node.name)) {
491
+ return group(openDoc);
492
+ }
493
+
494
+ // Twig partials may be open-ended (e.g. a template that starts with
495
+ // `<html><body>` and is closed by another template). When the source has no
496
+ // end tag, never add one: doing so would break template composition.
497
+ if (node.endTagStart === undefined) {
498
+ const children = (node.children ?? []).filter((/** @type {any} */ c) => !isWhitespaceOnly(c));
499
+ return children.length === 0
500
+ ? group(openDoc)
501
+ : group([openDoc, indent(printChildren(path, printCallback, source))]);
502
+ }
503
+
504
+ const closeDoc = ['</', node.nameRaw, '>'];
505
+ const inner = source.slice(node.startTagEnd, node.endTagStart ?? node.rawEnd);
506
+
507
+ // Raw-text / whitespace-significant elements (`<script>`, `<style>`,
508
+ // `<textarea>`, `<title>`, `<pre>`) keep their inner source verbatim: their
509
+ // content is not HTML to reflow and collapsing it would change meaning.
510
+ if (RAWTEXT_ELEMENTS.has(node.name)) {
511
+ return group([openDoc, inner, closeDoc]);
512
+ }
513
+
514
+ const hasLineBreak = /[\r\n]/.test(inner);
515
+
516
+ // Text content with no surrounding line break: its exact whitespace is
517
+ // significant, so the element is reproduced verbatim.
518
+ if (!hasLineBreak && hasTextContent(node)) {
519
+ return group([openDoc, inner, closeDoc]);
520
+ }
521
+
522
+ // Content made only of text, prints and comments is "phrasing" content.
523
+ // With no line break it is reproduced verbatim; when it already spans lines
524
+ // and is pure text, the indentation may be normalized while the closing tag
525
+ // stays put. Phrasing content that contains twig is printed structurally so
526
+ // multi-line tags (e.g. `include ... with { … }`) are laid out correctly.
527
+ if (isPhrasingContent(node, source)) {
528
+ if (!hasLineBreak) {
529
+ return group([openDoc, inner, closeDoc]);
530
+ }
531
+ if (isTextOnly(node)) {
532
+ return group([openDoc, printInlineText(inner), closeDoc]);
533
+ }
534
+ }
535
+
536
+ // An element without content closes on the same line as the opening tag's
537
+ // `>`, even when its attributes are laid out over several lines.
538
+ if ((node.children ?? []).length === 0) {
539
+ return group([openDoc, closeDoc]);
540
+ }
541
+
542
+ return group([openDoc, indent(printChildren(path, printCallback, source)), hardline, closeDoc]);
543
+ }
544
+
545
+ /**
546
+ * Whether an element has non-whitespace text content.
547
+ *
548
+ * @param {any} node
549
+ * @returns {boolean}
550
+ */
551
+ function hasTextContent(node) {
552
+ return (node.children ?? []).some(
553
+ (/** @type {any} */ c) => c.type === 'text' && c.raw.trim() !== '',
554
+ );
555
+ }
556
+
557
+ /**
558
+ * Whether an element's content is only text (including whitespace-only text).
559
+ *
560
+ * @param {any} node
561
+ * @returns {boolean}
562
+ */
563
+ function isTextOnly(node) {
564
+ const children = node.children ?? [];
565
+ return children.length > 0 && children.every((/** @type {any} */ c) => c.type === 'text');
566
+ }
567
+
568
+ /**
569
+ * Whether an element's non-empty content is only phrasing content (text,
570
+ * prints, comments and inline twig blocks).
571
+ *
572
+ * @param {any} node
573
+ * @param {string} source
574
+ * @returns {boolean}
575
+ */
576
+ function isPhrasingContent(node, source) {
577
+ const children = node.children ?? [];
578
+ if (children.length === 0) {
579
+ return false;
580
+ }
581
+ return children.every(
582
+ (/** @type {any} */ c) =>
583
+ c.type === 'text' ||
584
+ c.type === 'twig' ||
585
+ c.type === 'comment' ||
586
+ c.type === 'twigComment' ||
587
+ (c.type === 'twigBlock' && isInlineTwigBlock(c, source)),
588
+ );
589
+ }
590
+
591
+ /**
592
+ * Re-indents the inner text of a phrasing element that already spans lines.
593
+ * A leading / trailing line break is preserved so the closing tag stays where
594
+ * the source put it.
595
+ *
596
+ * @param {string} inner
597
+ * @returns {any} A Prettier doc.
598
+ */
599
+ function printInlineText(inner) {
600
+ const leading = /^[ \t]*\r?\n/.test(inner);
601
+ const trailing = /\r?\n[ \t]*$/.test(inner);
602
+ const lines = inner
603
+ .split(/\r?\n/)
604
+ .map((/** @type {string} */ text) => text.trim())
605
+ .filter((/** @type {string} */ text) => text !== '');
606
+ if (lines.length === 0) {
607
+ return hardline;
608
+ }
609
+ const rest = lines.slice(1);
610
+ let content;
611
+ if (leading) {
612
+ content = indent([hardline, join(hardline, lines)]);
613
+ } else if (rest.length > 0) {
614
+ content = [lines[0], indent([hardline, join(hardline, rest)])];
615
+ } else {
616
+ content = lines[0];
617
+ }
618
+ return trailing ? [content, hardline] : content;
619
+ }
620
+
621
+ /** Elements whose start tag must always stay on a single line. */
622
+ const FORCE_SINGLE_LINE = new Set(['html', 'link', 'meta']);
623
+
624
+ /** Void elements that never have an end tag. */
625
+ const VOID_ELEMENTS = new Set([
626
+ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link',
627
+ 'meta', 'param', 'source', 'track', 'wbr',
628
+ ]);
629
+
630
+ /** Elements whose inner content must be preserved verbatim. */
631
+ const RAWTEXT_ELEMENTS = new Set(['script', 'style', 'textarea', 'title', 'pre']);
632
+
633
+ /**
634
+ * Prints an attribute.
635
+ *
636
+ * @param {any} attr
637
+ * @returns {any} A Prettier doc.
638
+ */
639
+ function printAttribute(attr) {
640
+ if (attr.quote === null && attr.valueChunks.length === 0) {
641
+ return attr.nameRaw;
642
+ }
643
+ // Event-handler attributes hold opaque script and must not be reformatted.
644
+ // Control structures inside a value manage their own indentation, so they
645
+ // are printed verbatim; plain multi-line values are re-indented so every
646
+ // line sits at the same level.
647
+ const isEventHandler = /^on./i.test(attr.nameRaw);
648
+ const hasControlFlow = attr.valueChunks.some(
649
+ (/** @type {any} */ c) => c.type === 'twig' && c.atom.tag,
650
+ );
651
+ const rawValue = attr.valueChunks
652
+ .map((/** @type {any} */ c) => (c.type === 'twig' ? c.atom.raw : c.text))
653
+ .join('');
654
+ const value = isEventHandler || hasControlFlow ? rawValue : printAttributeValue(rawValue);
655
+ if (attr.quote === null) {
656
+ return [attr.nameRaw, '=', value];
657
+ }
658
+ return [attr.nameRaw, '=', attr.quote, value, attr.quote];
659
+ }
660
+
661
+ /**
662
+ * Re-indents a multi-line attribute value so all content lines share one indent
663
+ * level, with the closing quote back at the attribute's indent. A value with a
664
+ * leading/trailing line break keeps those breaks.
665
+ *
666
+ * @param {string} raw The value text (quotes excluded).
667
+ * @returns {any} A Prettier doc.
668
+ */
669
+ function printAttributeValue(raw) {
670
+ if (!/[\r\n]/.test(raw)) {
671
+ return raw;
672
+ }
673
+ const lines = raw.split(/\r?\n/).map((/** @type {string} */ text) => text.trim());
674
+ const leadingBreak = lines[0] === '';
675
+ const trailingBreak = lines[lines.length - 1] === '';
676
+ let content = lines;
677
+ if (leadingBreak) {
678
+ content = content.slice(1);
679
+ }
680
+ if (trailingBreak) {
681
+ content = content.slice(0, -1);
682
+ }
683
+ content = content.filter((/** @type {string} */ text) => text !== '');
684
+ if (content.length === 0) {
685
+ return '';
686
+ }
687
+ let body;
688
+ if (leadingBreak) {
689
+ body = indent([hardline, join(hardline, content)]);
690
+ } else {
691
+ const [first, ...rest] = content;
692
+ body = rest.length > 0 ? [first, indent([hardline, join(hardline, rest)])] : first;
693
+ }
694
+ return trailingBreak ? [body, hardline] : body;
695
+ }
696
+
697
+ /**
698
+ * Prints a twig block (text position or inside a start tag).
699
+ *
700
+ * @param {any} path
701
+ * @param {(selector: any, ...rest: any[]) => any} printCallback
702
+ * @param {string} source
703
+ * @returns {any} A Prettier doc.
704
+ */
705
+ function printTwigBlock(path, printCallback, source) {
706
+ const node = path.node;
707
+ const open = normalizeTwigTag(node.open.atom.raw);
708
+ const close = normalizeTwigTag(node.close.atom.raw);
709
+ /** @type {any[]} */
710
+ const docs = [];
711
+ // `node.children` is the flattened concatenation of every section body, so a
712
+ // running index maps body items back onto the path for position tracking.
713
+ let bodyIndex = 0;
714
+ // blank-line detection measures the source between the end of the previous
715
+ // non-whitespace item (or the opening / section head) and the current one
716
+ let previousEnd = node.open.atom.rawEnd;
717
+ for (const section of node.sections ?? []) {
718
+ if (section.head) {
719
+ // mid tags (`{% else %}`, `{% elseif %}`) sit at the block's own indent
720
+ const head = normalizeTwigTag(section.head.atom.raw);
721
+ docs.push(hasBlankLine(source, previousEnd, section.head.atom.rawStart) ? [hardline, hardline, head] : [hardline, head]);
722
+ previousEnd = section.head.atom.rawEnd;
723
+ }
724
+ /** @type {any[]} */
725
+ const bodyDocs = [];
726
+ for (const child of section.body) {
727
+ const i = bodyIndex;
728
+ bodyIndex += 1;
729
+ if (isWhitespaceOnly(child)) {
730
+ continue;
731
+ }
732
+ const childDoc = path.call(printCallback, 'children', i);
733
+ const { start, end } = contentBounds(child);
734
+ bodyDocs.push(hasBlankLine(source, previousEnd, start) ? [hardline, hardline, childDoc] : [hardline, childDoc]);
735
+ previousEnd = end;
736
+ }
737
+ if (bodyDocs.length > 0) {
738
+ docs.push(indent(bodyDocs));
739
+ }
740
+ }
741
+ return group([open, docs, hardline, close]);
742
+ }
743
+
744
+ /**
745
+ * Prints a twig tag. Object literals are laid out by Prettier; `include` is
746
+ * special-cased because its `with` mapping is always expanded, one entry per
747
+ * line, rather than kept as a single-line object notation.
748
+ *
749
+ * @param {any} node
750
+ * @param {any} options
751
+ * @returns {any} A Prettier doc.
752
+ */
753
+ function printTwig(node, options) {
754
+ const forceBreak = node.atom.tag === 'include';
755
+ return formatTwigTag(node.atom.raw, forceBreak, options.trailingComma !== 'none');
756
+ }
757
+
758
+ /**
759
+ * Finds the offset of the delimiter matching the `{` or `[` at `openIndex`.
760
+ *
761
+ * @param {string} text
762
+ * @param {number} openIndex
763
+ * @returns {number} The matching delimiter offset, or `-1`.
764
+ */
765
+ function findMatchingBrace(text, openIndex) {
766
+ const open = text[openIndex];
767
+ const close = open === '{' ? '}' : ']';
768
+ let depth = 0;
769
+ let quote = '';
770
+ for (let i = openIndex; i < text.length; i += 1) {
771
+ const ch = text[i];
772
+ if (quote !== '') {
773
+ if (ch === '\\') {
774
+ i += 1;
775
+ } else if (ch === quote) {
776
+ quote = '';
777
+ }
778
+ continue;
779
+ }
780
+ if (ch === '"' || ch === "'") {
781
+ quote = ch;
782
+ } else if (ch === open) {
783
+ depth += 1;
784
+ } else if (ch === close) {
785
+ depth -= 1;
786
+ if (depth === 0) {
787
+ return i;
788
+ }
789
+ }
790
+ }
791
+ return -1;
792
+ }
793
+
794
+ /**
795
+ * Splits a mapping body on top-level commas (ignoring commas nested in braces,
796
+ * brackets, parentheses and string literals).
797
+ *
798
+ * @param {string} text
799
+ * @returns {string[]} The trimmed entries.
800
+ */
801
+ function splitTopLevel(text) {
802
+ /** @type {string[]} */
803
+ const parts = [];
804
+ let depth = 0;
805
+ let quote = '';
806
+ let start = 0;
807
+ for (let i = 0; i < text.length; i += 1) {
808
+ const ch = text[i];
809
+ if (quote !== '') {
810
+ if (ch === '\\') {
811
+ i += 1;
812
+ } else if (ch === quote) {
813
+ quote = '';
814
+ }
815
+ continue;
816
+ }
817
+ if (ch === '"' || ch === "'") {
818
+ quote = ch;
819
+ } else if (ch === '{' || ch === '[' || ch === '(') {
820
+ depth += 1;
821
+ } else if (ch === '}' || ch === ']' || ch === ')') {
822
+ depth -= 1;
823
+ } else if (ch === ',' && depth === 0) {
824
+ parts.push(text.slice(start, i).trim());
825
+ start = i + 1;
826
+ }
827
+ }
828
+ const last = text.slice(start).trim();
829
+ if (last !== '') {
830
+ parts.push(last);
831
+ }
832
+ return parts;
833
+ }
834
+
835
+ /**
836
+ * Prints a twig comment, normalizing its whitespace. Single-line comments are
837
+ * collapsed; multi-line comments keep one trimmed content line per line with
838
+ * the `{#` / `#}` delimiters on their own lines.
839
+ *
840
+ * @param {any} node
841
+ * @returns {any} A Prettier doc.
842
+ */
843
+ function printTwigComment(node) {
844
+ const raw = node.atom.raw;
845
+ let open = '{#';
846
+ if (raw.startsWith('{##')) {
847
+ open = '{##';
848
+ } else if (raw.startsWith('{#-')) {
849
+ open = '{#-';
850
+ } else if (raw.startsWith('{#~')) {
851
+ open = '{#~';
852
+ }
853
+ let close = '#}';
854
+ if (raw.endsWith('##}')) {
855
+ close = '##}';
856
+ } else if (raw.endsWith('-#}')) {
857
+ close = '-#}';
858
+ } else if (raw.endsWith('~#}')) {
859
+ close = '~#}';
860
+ }
861
+ const body = raw.slice(open.length, raw.length - close.length);
862
+ if (!/[\r\n]/.test(body)) {
863
+ const content = collapseWhitespace(body).trim();
864
+ return content === '' ? `${open}${close}` : `${open} ${content} ${close}`;
865
+ }
866
+ const lines = body.split('\n').map((/** @type {string} */ text) => collapseWhitespace(text).trim());
867
+ while (lines.length > 0 && lines[0] === '') {
868
+ lines.shift();
869
+ }
870
+ while (lines.length > 0 && lines[lines.length - 1] === '') {
871
+ lines.pop();
872
+ }
873
+ if (lines.length === 0) {
874
+ return `${open}${close}`;
875
+ }
876
+ return [open, indent([hardline, join(hardline, lines)]), hardline, close];
877
+ }
878
+
879
+ /**
880
+ * Whether a twig block sits inline in its source and contains only inline
881
+ * content, so it can be printed verbatim without reflowing.
882
+ *
883
+ * @param {any} node
884
+ * @param {string} source
885
+ * @returns {boolean}
886
+ */
887
+ function isInlineTwigBlock(node, source) {
888
+ const raw = source.slice(node.rawStart, node.rawEnd);
889
+ if (raw.includes('\n')) {
890
+ return false;
891
+ }
892
+ const children = (node.children ?? []).filter((/** @type {any} */ c) => !isWhitespaceOnly(c));
893
+ return children.every(
894
+ (/** @type {any} */ c) =>
895
+ c.type === 'text' || c.type === 'twig' || c.type === 'comment' || c.type === 'twigComment',
896
+ );
897
+ }
898
+
899
+ /**
900
+ * Prints a text node.
901
+ *
902
+ * @param {any} node
903
+ * @returns {any} A Prettier doc.
904
+ */
905
+ function printText(node) {
906
+ const text = node.raw.trim();
907
+ if (text === '') {
908
+ return '';
909
+ }
910
+ if (/<\/[A-Za-z]/.test(text)) {
911
+ const lines = node.raw
912
+ .split(/\r?\n/)
913
+ .map((/** @type {string} */ rawLine) => rawLine.replace(/[ \t]+$/, ''));
914
+ while (lines.length > 0 && lines[0].trim() === '') {
915
+ lines.shift();
916
+ }
917
+ while (lines.length > 0 && lines[lines.length - 1].trim() === '') {
918
+ lines.pop();
919
+ }
920
+ return lines.join('\n');
921
+ }
922
+ return text.replace(/[ \t]+/g, ' ').replace(/ *\n */g, '\n');
923
+ }
924
+
925
+ /** Elements whose content is delegated to an embedded Prettier parser. */
926
+ const EMBEDDED_ELEMENTS = new Map([
927
+ ['script', 'babel'],
928
+ ['style', 'css'],
929
+ ]);
930
+
931
+ /** Placeholders that stand in for twig constructs during embedded formatting. */
932
+ const PLACEHOLDER_PATTERN = /_Tw(\d+)/g;
933
+
934
+ /**
935
+ * Extracts a script/style element's body as text, replacing every twig
936
+ * construct with a placeholder so the body parses as plain JavaScript or CSS.
937
+ * The printed twig docs are returned alongside, indexed by placeholder.
938
+ *
939
+ * @param {any} node
940
+ * @param {(selector: any, ...rest: any[]) => any} printChild
941
+ * @param {string} source
942
+ * @returns {{text: string, docs: any[]}}
943
+ */
944
+ function collectEmbeddedContent(node, printChild, source) {
945
+ const start = node.startTagEnd;
946
+ const end = node.endTagStart ?? node.rawEnd;
947
+ const children = node.children ?? [];
948
+ /** @type {any[]} */
949
+ const docs = [];
950
+ /** @type {Array<{start: number, end: number, placeholder: string}>} */
951
+ const edits = [];
952
+ for (let i = 0; i < children.length; i += 1) {
953
+ const child = children[i];
954
+ if (
955
+ child.type !== 'twig' &&
956
+ child.type !== 'twigBlock' &&
957
+ child.type !== 'twigComment' &&
958
+ child.type !== 'comment'
959
+ ) {
960
+ continue;
961
+ }
962
+ const { start: childStart, end: childEnd } = contentBounds(child);
963
+ const index = docs.length;
964
+ docs.push(printChild(['children', i]));
965
+ edits.push({
966
+ start: childStart - start,
967
+ end: childEnd - start,
968
+ placeholder: `_Tw${index}`,
969
+ });
970
+ }
971
+ let text = source.slice(start, end);
972
+ for (let i = edits.length - 1; i >= 0; i -= 1) {
973
+ const edit = edits[i];
974
+ text = text.slice(0, edit.start) + edit.placeholder + text.slice(edit.end);
975
+ }
976
+ return { text, docs };
977
+ }
978
+
979
+ /**
980
+ * Adds a line's indentation to the continuation lines of inserted text, so a
981
+ * multi-line twig construct stays aligned with the line it replaces.
982
+ *
983
+ * @param {string} text
984
+ * @param {string} lineIndent
985
+ * @returns {string}
986
+ */
987
+ function indentContinuation(text, lineIndent) {
988
+ if (lineIndent === '' || !text.includes('\n')) {
989
+ return text;
990
+ }
991
+ const [first, ...rest] = text.split('\n');
992
+ return [first, ...rest.map((/** @type {string} */ textLine) => (textLine === '' ? textLine : lineIndent + textLine))].join(
993
+ '\n',
994
+ );
995
+ }
996
+
997
+ /**
998
+ * Renders a doc to text, leaving strings untouched.
999
+ *
1000
+ * @param {any} value
1001
+ * @param {any} options
1002
+ * @returns {string}
1003
+ */
1004
+ function docToString(value, options) {
1005
+ if (typeof value === 'string') {
1006
+ return value;
1007
+ }
1008
+ return doc.printer.printDocToString(value, {
1009
+ printWidth: options.printWidth,
1010
+ useTabs: options.useTabs,
1011
+ tabWidth: options.tabWidth,
1012
+ }).formatted;
1013
+ }
1014
+
1015
+ /**
1016
+ * Formats a `<script>` / `<style>` element by delegating its body to the
1017
+ * embedded JavaScript or CSS parser.
1018
+ *
1019
+ * @param {any} path
1020
+ * @param {(text: string, options: any) => Promise<any>} textToDoc
1021
+ * @param {(selector: any, ...rest: any[]) => any} printChild
1022
+ * @param {any} options
1023
+ * @returns {Promise<any>} A Prettier doc, or `undefined` to fall back.
1024
+ */
1025
+ async function printEmbeddedElement(path, textToDoc, printChild, options) {
1026
+ const node = path.node;
1027
+ const source = options.originalText ?? '';
1028
+ const parser = EMBEDDED_ELEMENTS.get(node.name);
1029
+ const attrDocs = (node.attrs ?? []).map((/** @type {any} */ _, /** @type {number} */ i) =>
1030
+ printChild(['attrs', i]),
1031
+ );
1032
+ const openDoc = printOpenTag(node, attrDocs, source);
1033
+ if (node.selfClosing || node.endTagStart === undefined) {
1034
+ return group(openDoc);
1035
+ }
1036
+ const closeDoc = ['</', node.nameRaw, '>'];
1037
+ const children = (node.children ?? []).filter((/** @type {any} */ c) => !isWhitespaceOnly(c));
1038
+ if (children.length === 0) {
1039
+ return group([openDoc, closeDoc]);
1040
+ }
1041
+ const { text, docs } = collectEmbeddedContent(node, printChild, source);
1042
+ /** @type {any} */
1043
+ const embedOptions = { parser };
1044
+ if (parser === 'babel') {
1045
+ // trailing commas are dropped in embedded JavaScript; twig constructs and
1046
+ // embedded CSS are unaffected
1047
+ embedOptions.trailingComma = 'none';
1048
+ }
1049
+ let body;
1050
+ try {
1051
+ body = await textToDoc(text, embedOptions);
1052
+ } catch {
1053
+ return undefined;
1054
+ }
1055
+ // Layout is decided while twig constructs are still short placeholders, so a
1056
+ // long twig expression never forces its surrounding code to wrap. The body
1057
+ // is rendered to text, then the placeholders are substituted.
1058
+ const rendered = doc.printer.printDocToString(body, {
1059
+ printWidth: options.printWidth,
1060
+ useTabs: options.useTabs,
1061
+ tabWidth: options.tabWidth,
1062
+ }).formatted;
1063
+ const withTwig = rendered.replace(PLACEHOLDER_PATTERN, (match, index, offset) => {
1064
+ const twigDoc = docs[Number(index)];
1065
+ if (twigDoc === undefined) {
1066
+ return match;
1067
+ }
1068
+ const lineStart = rendered.lastIndexOf('\n', offset) + 1;
1069
+ const lineIndent = rendered.slice(lineStart).match(/^[ \t]*/)?.[0] ?? '';
1070
+ return indentContinuation(docToString(twigDoc, options), lineIndent);
1071
+ });
1072
+ return group([openDoc, indent([hardline, join(hardline, withTwig.split('\n'))]), hardline, closeDoc]);
1073
+ }
1074
+
1075
+ /**
1076
+ * The Prettier embed callback: hands `<script>` and `<style>` bodies to the
1077
+ * embedded language parsers.
1078
+ *
1079
+ * @param {any} path
1080
+ * @param {any} options
1081
+ * @returns {any} An embed function, or `undefined`.
1082
+ */
1083
+ export function embed(path, options) {
1084
+ const node = path.node;
1085
+ if (node?.type !== 'element' || !EMBEDDED_ELEMENTS.has(node.name)) {
1086
+ return undefined;
1087
+ }
1088
+ return (
1089
+ /** @type {any} */ textToDoc,
1090
+ /** @type {(selector: any, ...rest: any[]) => any} */ printChild,
1091
+ /** @type {any} */ innerPath,
1092
+ /** @type {any} */ innerOptions,
1093
+ ) => printEmbeddedElement(innerPath, textToDoc, printChild, innerOptions ?? options);
1094
+ }
1095
+
1096
+ /**
1097
+ * The Prettier print callback.
1098
+ *
1099
+ * @param {any} path
1100
+ * @param {any} options
1101
+ * @param {(selector: any, ...rest: any[]) => any} printCallback
1102
+ * @returns {any} A Prettier doc.
1103
+ */
1104
+ export function print(path, options, printCallback) {
1105
+ const node = path.node;
1106
+ const source = options.originalText ?? '';
1107
+ switch (node.type) {
1108
+ case 'root':
1109
+ // finish the document with exactly one newline (Prettier convention)
1110
+ return [printChildren(path, printCallback, source, false, options), hardline];
1111
+ case 'element':
1112
+ return printElement(path, printCallback, source);
1113
+ case 'twigBlock':
1114
+ return isInlineTwigBlock(node, source)
1115
+ ? source.slice(node.rawStart, node.rawEnd)
1116
+ : printTwigBlock(path, printCallback, source);
1117
+ default:
1118
+ return printNode(node, options);
1119
+ }
1120
+ }
1121
+
1122
+ /**
1123
+ * Prints a leaf node.
1124
+ *
1125
+ * @param {any} node
1126
+ * @param {any} options
1127
+ * @returns {any} A Prettier doc.
1128
+ */
1129
+ function printNode(node, options) {
1130
+ switch (node.type) {
1131
+ case 'twig':
1132
+ return printTwig(node, options);
1133
+ case 'twigComment':
1134
+ return printTwigComment(node);
1135
+ case 'attr':
1136
+ return printAttribute(node);
1137
+ case 'text':
1138
+ return printText(node);
1139
+ case 'comment':
1140
+ case 'doctype':
1141
+ return node.raw;
1142
+ default:
1143
+ return '';
1144
+ }
1145
+ }