@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/LICENSE +24 -0
- package/package.json +22 -0
- package/src/index.js +73 -0
- package/src/printer.js +1145 -0
- package/test/prettier-twig.test.js +1896 -0
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
|
+
}
|