@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.
- package/LICENSE +21 -0
- package/README.md +149 -0
- package/dist/dialects/handlebars/tokens.d.ts +54 -0
- package/dist/dialects/handlebars/tokens.js +296 -0
- package/dist/errors.d.ts +24 -0
- package/dist/errors.js +32 -0
- package/dist/expression.d.ts +8 -0
- package/dist/expression.js +222 -0
- package/dist/parser.d.ts +4 -0
- package/dist/parser.js +1275 -0
- package/dist/plugin.d.ts +22 -0
- package/dist/plugin.js +29 -0
- package/dist/printer.d.ts +8 -0
- package/dist/printer.js +526 -0
- package/dist/scan.d.ts +12 -0
- package/dist/scan.js +38 -0
- package/dist/types.d.ts +135 -0
- package/dist/types.js +2 -0
- package/dist/whitespace.d.ts +28 -0
- package/dist/whitespace.js +31 -0
- package/docs/EDITOR_SETUP.md +107 -0
- package/docs/TROUBLESHOOTING.md +89 -0
- package/package.json +74 -0
package/dist/plugin.d.ts
ADDED
|
@@ -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>;
|
package/dist/printer.js
ADDED
|
@@ -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
|
+
}
|