@falsefalse/prettier-plugin-handlebars 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,22 @@
1
+ import { locEnd, locStart, parse } from './parser';
2
+ export declare const languages: {
3
+ name: string;
4
+ type: string;
5
+ parsers: string[];
6
+ extensions: string[];
7
+ aliases: string[];
8
+ vscodeLanguageIds: string[];
9
+ }[];
10
+ export declare const parsers: {
11
+ handlebars: {
12
+ parse: typeof parse;
13
+ astFormat: string;
14
+ locStart: typeof locStart;
15
+ locEnd: typeof locEnd;
16
+ };
17
+ };
18
+ export declare const printers: {
19
+ 'handlebars-ast': import("prettier").Printer<import("./types").Node>;
20
+ };
21
+ export declare const options: {};
22
+ export declare const defaultOptions: {};
package/dist/plugin.js ADDED
@@ -0,0 +1,29 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.defaultOptions = exports.options = exports.printers = exports.parsers = exports.languages = void 0;
4
+ const parser_1 = require("./parser");
5
+ const printer_1 = require("./printer");
6
+ exports.languages = [
7
+ {
8
+ name: 'Handlebars',
9
+ type: 'markup',
10
+ parsers: ['handlebars'],
11
+ extensions: ['.hbs', '.handlebars'],
12
+ aliases: ['hbs', 'htmlbars', 'classic-handlebars'],
13
+ vscodeLanguageIds: ['handlebars'],
14
+ },
15
+ ];
16
+ exports.parsers = {
17
+ 'handlebars': {
18
+ parse: parser_1.parse,
19
+ astFormat: 'handlebars-ast',
20
+ locStart: parser_1.locStart,
21
+ locEnd: parser_1.locEnd,
22
+ },
23
+ };
24
+ exports.printers = {
25
+ 'handlebars-ast': printer_1.printer,
26
+ };
27
+ /* Opinionated formatter - prettier's core options only. */
28
+ exports.options = {};
29
+ exports.defaultOptions = {};
@@ -0,0 +1,8 @@
1
+ import type { ParserOptions, Printer } from 'prettier';
2
+ import type { Node } from './types';
3
+ /** Only prettier's core options reach the printer; this formatter is opinionated. */
4
+ export type PrintOptions = Pick<ParserOptions<Node>, 'singleQuote'> & {
5
+ /** Quote holding the value being printed into. Unusable by anything nested in it. */
6
+ enclosingQuote?: '"' | "'";
7
+ };
8
+ export declare const printer: Printer<Node>;
@@ -0,0 +1,526 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.printer = void 0;
37
+ const doc_1 = require("prettier/doc");
38
+ const template_format_core_1 = require("template-format-core");
39
+ const tokens_1 = require("./dialects/handlebars/tokens");
40
+ const whitespace = __importStar(require("./whitespace"));
41
+ const { dedent, fill, group, hardline, ifBreak, indent, join, line, literalline, softline } = doc_1.builders;
42
+ const { removeLines } = doc_1.utils;
43
+ /* A run of blank lines collapses to one, which is two hardlines. */
44
+ const MAX_HARDLINES = 2;
45
+ /** The `~` of `{{~foo~}}`, which strips the whitespace next to the delimiter it sits on. */
46
+ const trim = (marker) => (marker ? '~' : '');
47
+ const hardlines = (count) => Array.from({ length: count }, () => hardline);
48
+ /**
49
+ * A child that can take its container's closing marker inside it.
50
+ *
51
+ * Lazy rather than memoised: `withCloser` replaces the piece, so `doc` is never read on a child
52
+ * that got a tail. The getter buys not caching but never building the untailed doc at all,
53
+ * which on a deep chain of single children would double the work per level.
54
+ */
55
+ function tailable(build) {
56
+ return {
57
+ kind: 'doc',
58
+ get doc() {
59
+ return build([]);
60
+ },
61
+ withTail: build,
62
+ };
63
+ }
64
+ const isGap = (piece) => piece.kind !== 'doc';
65
+ /* Built from the shared class so the character list stays written in one place, and hoisted so
66
+ * `textPieces` is not compiling a pattern per text run. */
67
+ const whitespaceGap = new RegExp(`(${whitespace.htmlRun.source})`, 'u');
68
+ const trailingWhitespace = new RegExp(`${whitespace.htmlRun.source}$`, 'u');
69
+ /**
70
+ * The governing rule: whitespace between siblings renders, so it is reproduced, never invented.
71
+ * A run holding a newline stays a newline, which keeps apart what the author put on separate
72
+ * lines. A run of plain spaces becomes a `line`, free to collapse or wrap by width.
73
+ */
74
+ function whitespacePiece(text) {
75
+ const newlines = text.split('\n').length - 1;
76
+ return newlines === 0 ? { kind: 'space' } : { kind: 'break', count: Math.min(newlines, MAX_HARDLINES) };
77
+ }
78
+ /**
79
+ * A text run decomposes into the same pieces as a sibling list: words, and the gaps between
80
+ * them. Treating a run's interior differently from the gaps between nodes would make layout
81
+ * depend on where the parser happened to put a node boundary, collapsing a newline the author
82
+ * wrote inside a text run to a space.
83
+ */
84
+ function textPieces(node) {
85
+ /* Raw text and ignored regions are copied through; literalline keeps them off the indent. */
86
+ if (node.verbatim || node.preserveWhitespace) {
87
+ return [{ kind: 'doc', doc: join(literalline, node.chars.split('\n')) }];
88
+ }
89
+ /* ASCII whitespace only. A non-breaking space is content the author chose - it suppresses a
90
+ * line break on the page - so it travels inside a word rather than becoming a gap. */
91
+ /* Splitting on a capture group already alternates word, gap, word, so the odd slots are the
92
+ * gaps - no second pattern to keep in step with the first. */
93
+ return node.chars
94
+ .split(whitespaceGap)
95
+ .flatMap((part, index) => part === '' ? [] : [index % 2 === 1 ? whitespacePiece(part) : { kind: 'doc', doc: part }]);
96
+ }
97
+ /* Recovered text is copied through, but its trailing whitespace belongs to the surrounding
98
+ * program: left inside the raw it would be reprinted *and* re-added as a line ending, growing
99
+ * the file by a newline on every pass. */
100
+ function unmatchedPieces(node) {
101
+ /* In a value the trailing gap is content, not somewhere to break: split off as one it let the
102
+ * block around it break, and the printer indented the closing marker into a value the author
103
+ * owns - a space appeared on the page. */
104
+ if (node.preserveWhitespace) {
105
+ return [{ kind: 'doc', doc: join(literalline, node.raw.split('\n')) }];
106
+ }
107
+ /* ASCII only, as everywhere else: `\s` matches U+00A0, so on `\s` a non-breaking space ending
108
+ * an ignored region is rewritten as a plain one, and a run of them as a single space. */
109
+ const trailing = trailingWhitespace.exec(node.raw)?.[0] ?? '';
110
+ const body = trailing ? node.raw.slice(0, -trailing.length) : node.raw;
111
+ const pieces = [];
112
+ if (body) {
113
+ pieces.push({ kind: 'doc', doc: join(literalline, body.split('\n')) });
114
+ }
115
+ if (trailing) {
116
+ pieces.push(whitespacePiece(trailing));
117
+ }
118
+ return pieces;
119
+ }
120
+ /**
121
+ * Each run between hard breaks wraps on its own. `fill` rather than `group`, so a run that does
122
+ * not fit breaks only where it must - all-or-nothing is for attributes and call params, where
123
+ * the whitespace is the formatter's; content wraps like prose. Adjacent pieces with no gap are
124
+ * glued in the source and merge into one fill item.
125
+ */
126
+ function assemble(pieces) {
127
+ const docs = [];
128
+ const run = [];
129
+ let glued = [];
130
+ /* A group, not a bare array: `propagateBreaks` marks a group holding a hard line as broken,
131
+ * and `fits` then refuses it, so `fill` prints the item in break mode and the groups inside it
132
+ * get measured one by one. Left as an array it measured as "fits" - `fits` stops at the first
133
+ * hard line - and everything after that line printed flat, over width, until the next pass. */
134
+ const flushGlued = () => {
135
+ if (glued.length > 0) {
136
+ run.push(glued.length === 1 ? glued[0] : group([...glued]));
137
+ glued = [];
138
+ }
139
+ };
140
+ const flushRun = () => {
141
+ flushGlued();
142
+ if (run.length === 0) {
143
+ return;
144
+ }
145
+ /* `fill` only means anything with separators to wrap at. Wrapping a lone item in one hides
146
+ * its own groups from the width check, so a call that should break stays long. */
147
+ docs.push(run.length === 1 ? run[0] : fill([...run]));
148
+ run.length = 0;
149
+ };
150
+ for (const piece of pieces) {
151
+ if (piece.kind === 'break') {
152
+ flushRun();
153
+ docs.push(...hardlines(piece.count));
154
+ continue;
155
+ }
156
+ if (piece.kind === 'space') {
157
+ flushGlued();
158
+ /* fill reads even positions as content; keep separators on the odd ones. A space that must
159
+ * not become a line break is still a separator - printing it as one keeps its neighbours
160
+ * as separate items that `fill` can measure. Gluing it to them makes everything downstream
161
+ * of a standalone-sensitive statement one unbreakable blob, holding a long tag after a
162
+ * `prettier-ignore` region over width until a second pass moves it. */
163
+ if (run.length % 2 === 0)
164
+ run.push('');
165
+ run.push(spaceDoc(piece));
166
+ continue;
167
+ }
168
+ glued.push(piece.doc);
169
+ }
170
+ flushRun();
171
+ return docs;
172
+ }
173
+ /** A space the printer may wrap at, or one it may not. */
174
+ const spaceDoc = (piece) => (piece.hard ? ' ' : line);
175
+ /** Every space in the run becomes one the printer may not wrap at. */
176
+ const harden = (pieces) => pieces.map((piece) => (piece.kind === 'space' ? { kind: 'space', hard: true } : piece));
177
+ /** Gaps outside a content run have nothing to wrap, so they print as themselves. */
178
+ function gapDocs(gaps) {
179
+ return gaps.flatMap((gap) => {
180
+ if (gap.kind === 'break')
181
+ return hardlines(gap.count);
182
+ return gap.kind === 'space' ? [spaceDoc(gap)] : [];
183
+ });
184
+ }
185
+ /** The half-open span of `pieces` with the whitespace at either end excluded. */
186
+ function contentSpan(pieces) {
187
+ let start = 0;
188
+ let end = pieces.length;
189
+ while (start < end && isGap(pieces[start]))
190
+ start += 1;
191
+ while (end > start && isGap(pieces[end - 1]))
192
+ end -= 1;
193
+ return [start, end];
194
+ }
195
+ function printExpression(expression, breakable) {
196
+ return expression.type === 'SubExpression' ? printCall(expression, '(', ')', breakable) : expression.source;
197
+ }
198
+ function printCallParts(call, breakable) {
199
+ const parts = call.params.map((param) => printExpression(param, breakable));
200
+ for (const pair of call.hash) {
201
+ parts.push([pair.key, '=', printExpression(pair.value, breakable)]);
202
+ }
203
+ if (call.blockParams && call.blockParams.length > 0) {
204
+ parts.push(['as |', call.blockParams.join(' '), '|']);
205
+ }
206
+ return parts;
207
+ }
208
+ /** Whitespace inside a mustache does not render, so it is the formatter's: all-or-nothing. */
209
+ function printCall(call, open, close, breakable = true) {
210
+ const parts = printCallParts(call, breakable);
211
+ if (parts.length === 0) {
212
+ return [open, printExpression(call.path, breakable), close];
213
+ }
214
+ if (!breakable) {
215
+ return [open, printExpression(call.path, false), ' ', join(' ', parts), close];
216
+ }
217
+ /* One group, so a call that does not fit breaks every one of its parts. */
218
+ return group([open, indent([printExpression(call.path, true), line, join(line, parts)]), softline, close]);
219
+ }
220
+ /* The three inline statements are one call in different delimiters: a mustache in `{{}}` (or
221
+ * `{{{}}}` when unescaped), a partial in `{{> }}`, a decorator in `{{*}}`. */
222
+ function printStatement(node, prefix) {
223
+ const triple = node.type === 'MustacheStatement' && node.triple;
224
+ return printCall(node, [triple ? '{{{' : '{{', trim(node.trimOpen), prefix], [trim(node.trimClose), triple ? '}}}' : '}}']);
225
+ }
226
+ function printComment(node) {
227
+ /* `block` already covers a multiline body - the parser sets it for either - so re-testing
228
+ * `multiline` here only invited the two to be kept in step by hand. */
229
+ const [open, close] = node.block
230
+ ? [`{{${trim(node.trimOpen)}!--`, `--${trim(node.trimClose)}}}`]
231
+ : [`{{${trim(node.trimOpen)}!`, `${trim(node.trimClose)}}}`];
232
+ const body = node.value;
233
+ /* express-hbs' layout directive is not prose: padding `{{!< layout}}` to `{{! < layout }}`
234
+ * stops it being recognised and the layout silently stops being applied. The parser decides,
235
+ * from the source - the body alone cannot tell the directive from a comment about one. */
236
+ if (node.layout) {
237
+ return [open, body, close];
238
+ }
239
+ /* A body the author started on its own line is re-indented under the comment, so it follows
240
+ * the surrounding structure instead of staying frozen at the column it was written at.
241
+ * Common indentation is stripped and re-applied, which keeps the body's *relative* shape. */
242
+ if (/^\n/u.test(body)) {
243
+ const lines = (0, template_format_core_1.stripCommonIndent)(body.replace(/^\n/u, '').replace(trailingWhitespace, '').split('\n'));
244
+ return lines.every((line) => line === '')
245
+ ? [open, hardline, close]
246
+ : [open, indent([hardline, join(hardline, lines)]), hardline, close];
247
+ }
248
+ /* Otherwise pad only where the body is not already spaced away from the delimiter: padding
249
+ * regardless puts trailing whitespace on the opening line, which re-parses differently on the
250
+ * next pass. Continuation lines keep their own indentation, having nothing to hang from. */
251
+ /* An empty block comment still gets its spacing: `{{!----}}` reads as a typo, and it is what
252
+ * the formatter would otherwise write over every `{{!-- --}}` in a file. A line comment has no
253
+ * such problem - `{{!}}` is already what an empty one looks like. */
254
+ if (body === '') {
255
+ return node.block ? [open, ' ', close] : [open, close];
256
+ }
257
+ /* ASCII whitespace, not `\s`: a non-breaking space is content the author put there. Trimming
258
+ * on `\s` deleted one off the end of a block comment's body, and reading one as the pad it
259
+ * already had left a line comment unpadded. */
260
+ const lead = whitespace.html.test(body[0] ?? '') ? '' : ' ';
261
+ const tail = whitespace.html.test(body[body.length - 1] ?? '') ? '' : ' ';
262
+ return [open, lead, join(literalline, body.split('\n')), tail, close];
263
+ }
264
+ /**
265
+ * The quote that needs no escaping; `singleQuote` decides only when either would do.
266
+ *
267
+ * Against the value's raw text, not just its TextNode parts: a quote inside a mustache is printed
268
+ * too, so `class='{{t "x"}}'` cannot be re-quoted with `"` without ending the attribute early.
269
+ */
270
+ /* A quote ends the value holding it, so a nested element cannot reuse the outer one. */
271
+ function chooseQuote(value, options) {
272
+ const preferred = options.singleQuote === true ? "'" : '"';
273
+ const candidates = [preferred, preferred === '"' ? "'" : '"'];
274
+ return candidates.find((q) => q !== options.enclosingQuote && !value.raw.includes(q)) ?? preferred;
275
+ }
276
+ function printAttribute(attribute, options) {
277
+ if (attribute.type === 'RawAttribute') {
278
+ return attribute.raw;
279
+ }
280
+ if (attribute.type === 'AttributeBlock') {
281
+ return printAny(attribute.block, options);
282
+ }
283
+ if (!attribute.value) {
284
+ return attribute.name;
285
+ }
286
+ /* An attribute value is content: every space in it renders, so it is reproduced exactly. Only
287
+ * the calls inside it may be reflowed, since whitespace within a mustache never reaches the
288
+ * rendered value. The parser marks the value's text as whitespace-significant, which is what
289
+ * keeps a block's body from being laid out at the printer's indent level instead of the
290
+ * author's - and what lets prettier see where the value's own lines end. */
291
+ const { parts } = attribute.value;
292
+ const quote = chooseQuote(attribute.value, options);
293
+ const nested = { ...options, enclosingQuote: quote };
294
+ return [attribute.name, '=', quote, ...parts.map((part) => printAny(part, nested)), quote];
295
+ }
296
+ /**
297
+ * One group for the whole tag, with no inner group around the attributes. Grouping them
298
+ * separately lets the attributes fit while the `>` alone drops to the next line, which reads as
299
+ * a stray bracket rather than a break.
300
+ */
301
+ function printOpenTag(node, options) {
302
+ const marker = node.selfClosing && !template_format_core_1.voidElements.has(node.tag.toLowerCase()) ? ' />' : '>';
303
+ if (node.attributes.length === 0) {
304
+ return ['<', node.tag, marker];
305
+ }
306
+ /* A gap between attributes is normally the formatter's - it never reaches the page. But a
307
+ * mustache or block in attribute position emits content, so two the author glued together
308
+ * have to stay glued: `{{a}}{{b}}` is one attribute, `{{a}} {{b}}` is two. */
309
+ const attributes = node.attributes.flatMap((attribute, index) => {
310
+ const printed = printAttribute(attribute, options);
311
+ return index === 0 || attribute.glued ? [printed] : [line, printed];
312
+ });
313
+ /* Same rule against the tag name itself: a first attribute the author glued on stays glued,
314
+ * or `<h{{level}}>` prints as `<h {{level}}>` and stops being a heading. */
315
+ const head = node.attributes[0].glued ? attributes : [line, ...attributes];
316
+ return group(['<', node.tag, indent(head), ifBreak([softline, marker.trimStart()], marker)]);
317
+ }
318
+ /**
319
+ * The content between two markers - a tag's brackets, or a block's open and close - together
320
+ * with the marker that ends it.
321
+ *
322
+ * The trailing gap sits inside the indent, so the dedent lands the closer at the container's
323
+ * own level; dedenting outside overshoots, and the overshoot compounds with depth.
324
+ *
325
+ * With no trailing gap the closer is glued onto the last piece: `fill` measures its last item
326
+ * blind to what follows, so a `</p>` left outside would not count towards its line's width.
327
+ */
328
+ function printBody(pieces, closer) {
329
+ if (pieces.length === 0) {
330
+ return [closer];
331
+ }
332
+ const [start, end] = contentSpan(pieces);
333
+ /* Nothing but whitespace inside: emit it once rather than as both edges. */
334
+ if (start >= end) {
335
+ return [...gapDocs(pieces.slice(0, 1)), closer];
336
+ }
337
+ const leading = gapDocs(pieces.slice(0, start));
338
+ const trailing = gapDocs(pieces.slice(end)).map(dedent);
339
+ const content = pieces.slice(start, end);
340
+ if (trailing.length === 0) {
341
+ return [indent([...leading, ...assemble(withCloser(content, closer))])];
342
+ }
343
+ return [indent([...leading, ...assemble(content), ...trailing]), closer];
344
+ }
345
+ /**
346
+ * The closer goes *inside* the last child when that child has a body to put it in. `fill`
347
+ * measures its last item against an empty rest-stack, so a marker appended after the child is
348
+ * invisible to the width check one level down as well as at this one: the line came out over
349
+ * width, and the next pass - now seeing a real break there - printed it differently.
350
+ */
351
+ function withCloser(content, closer) {
352
+ const last = content[content.length - 1];
353
+ if (last?.kind === 'doc' && last.withTail) {
354
+ return [...content.slice(0, -1), { kind: 'doc', doc: last.withTail(closer) }];
355
+ }
356
+ return [...content, { kind: 'doc', doc: closer }];
357
+ }
358
+ /* Containers thread the marker into their body; everything else just carries it along. */
359
+ function printWithTail(node, options, tail) {
360
+ if (node.type === 'ElementNode') {
361
+ return printElement(node, options, tail);
362
+ }
363
+ if (node.type === 'BlockStatement') {
364
+ return printBlock(node, options, tail);
365
+ }
366
+ return [printAny(node, options), tail];
367
+ }
368
+ function printElement(node, options, tail = []) {
369
+ const openTag = printOpenTag(node, options);
370
+ const closer = ['</', node.closeTag ?? node.tag, '>', tail];
371
+ const doc = node.selfClosing
372
+ ? [openTag, tail]
373
+ : group([openTag, ...printBody(childPieces(node.children, options), closer)]);
374
+ /* Inside an attribute value every character renders, so the tag may not be broken across
375
+ * lines - that would put the printer's newlines and indent inside a value the author owns,
376
+ * changing the page. Only calls may still be reflowed: `{{ }}` never reaches the page. */
377
+ return node.preserveWhitespace ? removeLines(doc) : doc;
378
+ }
379
+ function printBlock(node, options, tail = []) {
380
+ const prefix = tokens_1.handlebarsDialect.getPrintedBlockPrefix(node.blockPrefix ?? '#');
381
+ const elseKeyword = tokens_1.handlebarsDialect.getElseKeyword();
382
+ const sections = [
383
+ {
384
+ program: node.program,
385
+ open: (breakable) => printCall(node, ['{{', trim(node.trimOpen), prefix], [trim(node.trimClose), '}}'], breakable),
386
+ },
387
+ ...(node.inverseChain ?? []).map((branch) => ({
388
+ program: branch.program,
389
+ open: (breakable) => printCall(branch, ['{{', trim(branch.trimOpen), `${elseKeyword} `], [trim(branch.trimClose), '}}'], breakable),
390
+ })),
391
+ ];
392
+ /* An empty `{{else}}` prints nothing - unless it carries `~`, which strips whitespace that
393
+ * would otherwise render. */
394
+ if (node.inverse.body.length > 0 || node.inverseTrimOpen || node.inverseTrimClose) {
395
+ sections.push({
396
+ program: node.inverse,
397
+ open: () => ['{{', trim(node.inverseTrimOpen), elseKeyword, trim(node.inverseTrimClose), '}}'],
398
+ });
399
+ }
400
+ const close = [
401
+ '{{',
402
+ trim(node.closeTrimOpen),
403
+ tokens_1.handlebarsDialect.getBlockClosePrefix(node.path.source),
404
+ trim(node.closeTrimClose),
405
+ '}}',
406
+ tail,
407
+ ];
408
+ const pieces = sections.map((section) => childPieces(section.program.body, options));
409
+ /* A section the author kept on one line is an atom: wrapping its body would leave a marker
410
+ * alone on its line, where Handlebars strips the whitespace around it and the page changes.
411
+ * Per section, not per block - read whole-block, a newline in one branch unwrapped the rest.
412
+ * Markers stay a whole-block decision; splitting `{{else if` from its condition never helps. */
413
+ const sectionBreaks = pieces.map((body) => body.some((piece) => piece.kind === 'break'));
414
+ const breakable = sectionBreaks.some(Boolean);
415
+ const opens = sections.map((section) => section.open(breakable));
416
+ /* Each body carries the marker that closes it, so `fill` can see it when measuring. */
417
+ return group([
418
+ opens[0],
419
+ ...pieces.flatMap((body, index) => printBody(sectionBreaks[index] ? body : harden(body), opens[index + 1] ?? close)),
420
+ ]);
421
+ }
422
+ /* Handlebars strips the whitespace around a partial, comment or block left alone on its line -
423
+ * a mustache is not - so a space next to one has to stay a space: wrapping there would start or
424
+ * stop that stripping. `UnmatchedNode` is in the set because its verbatim text may begin or end
425
+ * with any of them. */
426
+ const standaloneStatements = new Set([
427
+ 'PartialStatement',
428
+ 'CommentStatement',
429
+ 'BlockStatement',
430
+ 'DecoratorStatement',
431
+ 'UnmatchedNode',
432
+ ]);
433
+ /**
434
+ * Children need no separators: the whitespace between them is already in the tree, so the
435
+ * printer cannot add a gap the author did not write, nor drop one they did.
436
+ */
437
+ function childPieces(nodes, options) {
438
+ const pieces = [];
439
+ const sensitive = [];
440
+ for (const child of nodes) {
441
+ if (child.type === 'TextNode') {
442
+ pieces.push(...textPieces(child));
443
+ continue;
444
+ }
445
+ if (child.type === 'UnmatchedNode') {
446
+ const verbatim = unmatchedPieces(child);
447
+ /* Both edges: the text is opaque, so either end may be a standalone statement. */
448
+ sensitive.push(pieces.length, pieces.length + verbatim.length - 1);
449
+ pieces.push(...verbatim);
450
+ continue;
451
+ }
452
+ if (standaloneStatements.has(child.type)) {
453
+ sensitive.push(pieces.length);
454
+ }
455
+ pieces.push(tailable((tail) => printWithTail(child, options, tail)));
456
+ }
457
+ for (const at of sensitive) {
458
+ for (const neighbour of [at - 1, at + 1]) {
459
+ if (pieces[neighbour]?.kind === 'space') {
460
+ pieces[neighbour] = { kind: 'space', hard: true };
461
+ }
462
+ }
463
+ }
464
+ return pieces;
465
+ }
466
+ /**
467
+ * A template's own leading and trailing whitespace is not content: the file ends in exactly one
468
+ * newline whatever the author left behind.
469
+ */
470
+ function printRoot(nodes, options) {
471
+ const pieces = childPieces(nodes, options);
472
+ const [start, end] = contentSpan(pieces);
473
+ return start >= end ? '' : [...assemble(pieces.slice(start, end)), hardline];
474
+ }
475
+ /**
476
+ * The printer recurses over nodes directly rather than through prettier's AstPath. It makes no
477
+ * parent-dependent decisions - a node's shape is a function of the node alone - so the path
478
+ * buys nothing, and dropping it keeps every signature free of casts.
479
+ */
480
+ function printAny(node, options) {
481
+ switch (node.type) {
482
+ case 'Program':
483
+ return assemble(childPieces(node.body, options));
484
+ case 'TextNode':
485
+ return assemble(textPieces(node));
486
+ case 'MustacheStatement':
487
+ return printStatement(node, '');
488
+ case 'PartialStatement':
489
+ return printStatement(node, '> ');
490
+ case 'DecoratorStatement':
491
+ return printStatement(node, '*');
492
+ case 'CommentStatement':
493
+ return printComment(node);
494
+ /* `childPieces` intercepts these, so this arm only keeps the switch exhaustive. It goes
495
+ * through `unmatchedPieces` all the same, rather than repeating it: a second copy drifts,
496
+ * and one missing the trailing-whitespace split grows the file by a newline every pass. */
497
+ case 'UnmatchedNode':
498
+ return assemble(unmatchedPieces(node));
499
+ case 'ElementNode':
500
+ return printElement(node, options);
501
+ case 'BlockStatement':
502
+ return printBlock(node, options);
503
+ }
504
+ }
505
+ /* Prettier walks the tree itself to track a cursor offset; the printer's own recursion does not
506
+ * use these. */
507
+ const visitorKeys = {
508
+ Program: ['body'],
509
+ ElementNode: ['attributes', 'children'],
510
+ Attribute: ['value'],
511
+ AttributeValue: ['parts'],
512
+ AttributeBlock: ['block'],
513
+ BlockStatement: ['program', 'inverseChain', 'inverse'],
514
+ ElseBranch: ['program'],
515
+ };
516
+ exports.printer = {
517
+ /* Only the root reaches this: everything below recurses through printAny. */
518
+ print(path, options) {
519
+ const node = path.node;
520
+ return node.type === 'Program' && path.parent === null ? printRoot(node.body, options) : printAny(node, options);
521
+ },
522
+ getVisitorKeys(node, nonTraversableKeys) {
523
+ const type = typeof node === 'object' && node !== null && 'type' in node ? String(node.type) : '';
524
+ return (visitorKeys[type] ?? []).filter((key) => !nonTraversableKeys.has(key));
525
+ },
526
+ };
package/dist/scan.d.ts ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The first index at or after `from` where `stopsAt` holds, skipping quoted runs so a delimiter
3
+ * inside a string literal does not end the scan. Backslash escapes are honoured inside a quote
4
+ * and nowhere else. Returns -1 if the scan reaches the end without stopping.
5
+ *
6
+ * `opensQuote` is the one thing a caller must decide: inside a tag a quote only delimits a
7
+ * value after `=`, and treating every quote as one makes `title=a"b'c>` swallow the file.
8
+ */
9
+ export declare function scanPastQuotes(text: string, from: number, { stopsAt, opensQuote }: {
10
+ stopsAt: (index: number) => boolean;
11
+ opensQuote: (index: number) => boolean;
12
+ }): number;
package/dist/scan.js ADDED
@@ -0,0 +1,38 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.scanPastQuotes = scanPastQuotes;
4
+ /**
5
+ * The first index at or after `from` where `stopsAt` holds, skipping quoted runs so a delimiter
6
+ * inside a string literal does not end the scan. Backslash escapes are honoured inside a quote
7
+ * and nowhere else. Returns -1 if the scan reaches the end without stopping.
8
+ *
9
+ * `opensQuote` is the one thing a caller must decide: inside a tag a quote only delimits a
10
+ * value after `=`, and treating every quote as one makes `title=a"b'c>` swallow the file.
11
+ */
12
+ function scanPastQuotes(text, from, { stopsAt, opensQuote }) {
13
+ let quote = null;
14
+ let escaped = false;
15
+ for (let index = from; index < text.length; index += 1) {
16
+ const char = text[index];
17
+ if (quote) {
18
+ if (escaped) {
19
+ escaped = false;
20
+ }
21
+ else if (char === '\\') {
22
+ escaped = true;
23
+ }
24
+ else if (char === quote) {
25
+ quote = null;
26
+ }
27
+ continue;
28
+ }
29
+ if ((char === '"' || char === "'" || char === '`') && opensQuote(index)) {
30
+ quote = char;
31
+ continue;
32
+ }
33
+ if (stopsAt(index)) {
34
+ return index;
35
+ }
36
+ }
37
+ return -1;
38
+ }