@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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Poliklot
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# @falsefalse/prettier-plugin-handlebars
|
|
2
|
+
|
|
3
|
+
A Prettier plugin for classic Handlebars templates with mixed HTML markup.
|
|
4
|
+
|
|
5
|
+
This started as a fork of another plugin — see [Prior work](#prior-work). It is **opinionated**:
|
|
6
|
+
it exposes no options of its own, only Prettier's core `printWidth`, `tabWidth`, `useTabs` and
|
|
7
|
+
`singleQuote`. Everything else is a decision the formatter has already made.
|
|
8
|
+
|
|
9
|
+
## The rule everything follows
|
|
10
|
+
|
|
11
|
+
> Whitespace that renders belongs to the author. Whitespace that does not belongs to the
|
|
12
|
+
> formatter.
|
|
13
|
+
|
|
14
|
+
Between two siblings, whitespace reaches the page, so it is reproduced exactly — a space stays a
|
|
15
|
+
space, a newline stays a newline, a run of blank lines collapses to one. The formatter never
|
|
16
|
+
invents a gap the author did not write, and never drops one they did.
|
|
17
|
+
|
|
18
|
+
Inside a tag or a mustache, whitespace never reaches the page, so it is the formatter's: it is
|
|
19
|
+
normalised, and driven by width, all-or-nothing.
|
|
20
|
+
|
|
21
|
+
Two consequences worth stating plainly:
|
|
22
|
+
|
|
23
|
+
- **A one-liner stays a one-liner.** `{{#if a}}x{{else}}y{{/if}}` is one line because the author
|
|
24
|
+
wrote it as one line, not because it happens to fit.
|
|
25
|
+
- **Anything the author broke stays broken.** Reflowing it would move rendered whitespace.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npm install --save-dev prettier github:falsefalse/prettier-plugin-handlebars
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
```js
|
|
34
|
+
/** @type {import("prettier").Config} */
|
|
35
|
+
module.exports = {
|
|
36
|
+
plugins: ['@falsefalse/prettier-plugin-handlebars'],
|
|
37
|
+
overrides: [{ files: ['*.hbs', '*.handlebars'], options: { parser: 'handlebars' } }],
|
|
38
|
+
};
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The `overrides` entry is what makes Prettier pick this parser; without it `.hbs` files are either
|
|
42
|
+
skipped or handed to the HTML parser. See [docs/EDITOR_SETUP.md](./docs/EDITOR_SETUP.md).
|
|
43
|
+
|
|
44
|
+
## What it formats
|
|
45
|
+
|
|
46
|
+
- HTML elements, void elements, custom elements, comments, `pre` / `textarea` / `script` / `style`
|
|
47
|
+
- `{{mustache}}`, `{{{triple}}}`, `{{! comments }}`, `{{!-- block comments --}}`
|
|
48
|
+
- block helpers, `{{else}}`, `{{else if …}}`, inverted `{{^…}}`
|
|
49
|
+
- partials `{{> name}}`, dynamic partials `{{> (lookup . "n")}}`, block partials `{{#> layout}}`
|
|
50
|
+
- inline partials `{{#*inline "name"}}`, decorators `{{*log}}`
|
|
51
|
+
- Mustache inheritance — `{{< layout}}`, `{{$block}}`
|
|
52
|
+
- raw blocks `{{{{raw}}}}…{{{{/raw}}}}`
|
|
53
|
+
- whitespace control markers, `{{~v~}}`, `{{~#if a~}}`
|
|
54
|
+
- Handlebars inside attribute values, and blocks in attribute position
|
|
55
|
+
- hash params written `k=v`, `k= v` or `k = v`, printed consistently
|
|
56
|
+
- subexpressions to any depth, broken by width all the way down
|
|
57
|
+
- `prettier-ignore`, `prettier-ignore-start` / `-end`
|
|
58
|
+
|
|
59
|
+
## Unclosed input is rejected
|
|
60
|
+
|
|
61
|
+
Everything that opens must close. There is no recovery: a formatter that guesses at a missing
|
|
62
|
+
`}}` prints markup the author did not write, and one that passes a mismatched tag through leaves
|
|
63
|
+
the rest of the file unformatted with nothing to show for it.
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
$ prettier --write page.hbs
|
|
67
|
+
[error] page.hbs: SyntaxError: unclosed tag: expected </span> (3:3)
|
|
68
|
+
[error] 1 | <div>
|
|
69
|
+
[error] 2 | <p>x</p>
|
|
70
|
+
[error] > 3 | <span>y
|
|
71
|
+
[error] | ^^^^^^
|
|
72
|
+
[error] 4 | </div>
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The error carries a source range, so editors can put the cursor on it.
|
|
76
|
+
|
|
77
|
+
This includes the HTML spec's optional end tags: `<ul><li>a<li>b</ul>` is rejected. One rule with
|
|
78
|
+
no list of exceptions beats a list of exceptions that has to be kept in step with the spec.
|
|
79
|
+
|
|
80
|
+
What is checked is structure: delimiters balance, tags nest, a block matches its own closer —
|
|
81
|
+
not Handlebars' expression grammar. `{{}}`, `{{{x}}}}` and `{{foo xa"y}}` are all
|
|
82
|
+
delimiter-balanced, so they pass through unchanged for Handlebars itself to reject at compile
|
|
83
|
+
time. The formatter does not make them worse, and it is not a second implementation of the
|
|
84
|
+
language.
|
|
85
|
+
|
|
86
|
+
### When the markup only balances at render time
|
|
87
|
+
|
|
88
|
+
Two escape hatches, in order of preference.
|
|
89
|
+
|
|
90
|
+
Hide the markup behind a call, so the parser sees balanced source and the browser still gets what
|
|
91
|
+
you meant:
|
|
92
|
+
|
|
93
|
+
```hbs
|
|
94
|
+
{{#if twoColumns}}{{{concat '<div class="row">'}}}{{/if}}
|
|
95
|
+
…
|
|
96
|
+
{{#if twoColumns}}{{{concat '</div>'}}}{{/if}}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Or fence the region off entirely. Nothing inside is parsed, so nothing inside can be rejected:
|
|
100
|
+
|
|
101
|
+
```hbs
|
|
102
|
+
{{! prettier-ignore-start }}
|
|
103
|
+
<div>deliberately unbalanced
|
|
104
|
+
{{! prettier-ignore-end }}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Options
|
|
108
|
+
|
|
109
|
+
None. `printWidth`, `tabWidth`, `useTabs` and `singleQuote` are read from Prettier's core config;
|
|
110
|
+
this plugin adds nothing.
|
|
111
|
+
|
|
112
|
+
## Development
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
npm ci
|
|
116
|
+
npm run check # build + tests + both fuzz gates
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
`npm run check` runs the test suite plus two fuzz gates, each two-sided: every generated case
|
|
120
|
+
must format idempotently, without losing source, and **without changing what the template
|
|
121
|
+
renders** — compiled with the real Handlebars runtime, not approximated. Every malformed case
|
|
122
|
+
must be refused with a location.
|
|
123
|
+
|
|
124
|
+
`scripts/run-property-gate.mjs` checks the same properties over a real corpus:
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
npm run corpus:gate -- --git ../your-repo --width 95 path/to/templates
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
The last gate is a person reading the diff the formatter would produce. The property gates prove
|
|
131
|
+
correctness; they cannot see bad taste, which is the failure mode that actually matters here.
|
|
132
|
+
|
|
133
|
+
[docs/REWRITE-PLAN.md](./docs/REWRITE-PLAN.md) is the design record — why the printer looks like
|
|
134
|
+
this, and what the previous one got wrong.
|
|
135
|
+
|
|
136
|
+
## Prior work
|
|
137
|
+
|
|
138
|
+
This plugin began as a fork of
|
|
139
|
+
[Poliklot/prettier-plugin-handlebars](https://github.com/Poliklot/prettier-plugin-handlebars),
|
|
140
|
+
published as `@poliklot/prettier-plugin-handlebars` and MIT-licensed, © Poliklot. The parser and
|
|
141
|
+
printer have since been rewritten — [docs/REWRITE-PLAN.md](./docs/REWRITE-PLAN.md) is that record
|
|
142
|
+
— and the package now ships under its own name, but the shape of the project, a Handlebars-aware
|
|
143
|
+
`.hbs` formatter with no options of its own, starts there.
|
|
144
|
+
|
|
145
|
+
## Docs
|
|
146
|
+
|
|
147
|
+
- [Editor setup](./docs/EDITOR_SETUP.md)
|
|
148
|
+
- [Troubleshooting](./docs/TROUBLESHOOTING.md)
|
|
149
|
+
- [Printer rewrite plan](./docs/REWRITE-PLAN.md) — the design record
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { TemplateBlockPrefix, TemplateToken } from 'template-format-core';
|
|
2
|
+
export interface HandlebarsToken extends TemplateToken {
|
|
3
|
+
/**
|
|
4
|
+
* Whether the tokenizer found a closing delimiter, rather than running to the end of the
|
|
5
|
+
* input. Recorded by the one place that knows: re-deriving it by string-matching the token's
|
|
6
|
+
* tail reports true for any unterminated token running to a text end that already ends in
|
|
7
|
+
* `}}`, letting `{{foo "bar}}` past the malformed guard to print as `{{foo "bar}}}}`.
|
|
8
|
+
*/
|
|
9
|
+
terminated: boolean;
|
|
10
|
+
}
|
|
11
|
+
export declare const handlebarsDialect: {
|
|
12
|
+
openDelimiter: string;
|
|
13
|
+
parseToken: typeof parseHandlebarsToken;
|
|
14
|
+
findNextOpen: typeof findNextHandlebarsOpen;
|
|
15
|
+
isEscapedOpen: typeof isEscapedHandlebarsOpen;
|
|
16
|
+
isDynamicElementStart: typeof isDynamicHandlebarsElementStart;
|
|
17
|
+
consumeRawBlock: typeof consumeHandlebarsRawBlock;
|
|
18
|
+
getBlockExpression: typeof getHandlebarsBlockExpression;
|
|
19
|
+
getBlockPrefix: typeof getHandlebarsBlockPrefix;
|
|
20
|
+
getPrintedBlockPrefix: typeof getPrintedHandlebarsBlockPrefix;
|
|
21
|
+
getElseKeyword: typeof getHandlebarsElseKeyword;
|
|
22
|
+
getBlockClosePrefix: typeof getHandlebarsBlockClosePrefix;
|
|
23
|
+
shouldPreserveTokenVerbatim: typeof shouldPreserveHandlebarsTokenVerbatim;
|
|
24
|
+
};
|
|
25
|
+
declare function parseHandlebarsToken(text: string, position: number): HandlebarsToken;
|
|
26
|
+
/**
|
|
27
|
+
* Whether a comment at `position` is written in block form. `{{~!-- x --~}}` is one as much as
|
|
28
|
+
* `{{!-- x --}}`, so the `~` is skipped: anchoring on a literal `{{!--` reads the
|
|
29
|
+
* whitespace-control form as a line comment and demotes it to `{{! !-- x -- }}`.
|
|
30
|
+
*/
|
|
31
|
+
export declare function isHandlebarsBlockComment(text: string, position: number): boolean;
|
|
32
|
+
declare function isEscapedHandlebarsOpen(text: string, position: number): boolean;
|
|
33
|
+
declare function findNextHandlebarsOpen(text: string, position: number): number;
|
|
34
|
+
declare function isDynamicHandlebarsElementStart(text: string, position: number): boolean;
|
|
35
|
+
/**
|
|
36
|
+
* The name a raw block opens with, or `''` if `position` is not one. Tildes are tolerated on the
|
|
37
|
+
* open because the lexer takes them there; the closer below is stricter for the same reason.
|
|
38
|
+
*/
|
|
39
|
+
export declare function handlebarsRawBlockName(text: string, position: number, openEnd: number): string;
|
|
40
|
+
/**
|
|
41
|
+
* The only closer Handlebars accepts: no whitespace inside it and no tilde on either side -
|
|
42
|
+
* `{{{{~/raw}}}}` and `{{{{ / raw }}}}` are both lexical errors. One literal, so there is nothing
|
|
43
|
+
* to escape and nothing to drift - the two hand-written patterns this replaces disagreed about
|
|
44
|
+
* exactly that whitespace.
|
|
45
|
+
*/
|
|
46
|
+
export declare function handlebarsRawBlockCloser(name: string): string;
|
|
47
|
+
declare function consumeHandlebarsRawBlock(text: string, position: number): number | null;
|
|
48
|
+
declare function getHandlebarsBlockExpression(token: TemplateToken): string;
|
|
49
|
+
declare function getHandlebarsBlockPrefix(token: TemplateToken): TemplateBlockPrefix;
|
|
50
|
+
declare function getPrintedHandlebarsBlockPrefix(prefix: TemplateBlockPrefix): string;
|
|
51
|
+
declare function getHandlebarsElseKeyword(): string;
|
|
52
|
+
declare function getHandlebarsBlockClosePrefix(path: string): string;
|
|
53
|
+
declare function shouldPreserveHandlebarsTokenVerbatim(token: TemplateToken): boolean;
|
|
54
|
+
export {};
|
|
@@ -0,0 +1,296 @@
|
|
|
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.handlebarsDialect = void 0;
|
|
37
|
+
exports.isHandlebarsBlockComment = isHandlebarsBlockComment;
|
|
38
|
+
exports.handlebarsRawBlockName = handlebarsRawBlockName;
|
|
39
|
+
exports.handlebarsRawBlockCloser = handlebarsRawBlockCloser;
|
|
40
|
+
const template_format_core_1 = require("template-format-core");
|
|
41
|
+
const scan_1 = require("../../scan");
|
|
42
|
+
const whitespace = __importStar(require("../../whitespace"));
|
|
43
|
+
/* Deliberately not typed `: TemplateDialect`. That interface demands nine more members than the
|
|
44
|
+
* parser and printer ever ask for, each a second copy of Handlebars syntax to keep in step by
|
|
45
|
+
* hand - `getLineCommentTag` and `printComment` disagree about the same thing. */
|
|
46
|
+
exports.handlebarsDialect = {
|
|
47
|
+
openDelimiter: '{{',
|
|
48
|
+
parseToken: parseHandlebarsToken,
|
|
49
|
+
findNextOpen: findNextHandlebarsOpen,
|
|
50
|
+
isEscapedOpen: isEscapedHandlebarsOpen,
|
|
51
|
+
isDynamicElementStart: isDynamicHandlebarsElementStart,
|
|
52
|
+
consumeRawBlock: consumeHandlebarsRawBlock,
|
|
53
|
+
getBlockExpression: getHandlebarsBlockExpression,
|
|
54
|
+
getBlockPrefix: getHandlebarsBlockPrefix,
|
|
55
|
+
getPrintedBlockPrefix: getPrintedHandlebarsBlockPrefix,
|
|
56
|
+
getElseKeyword: getHandlebarsElseKeyword,
|
|
57
|
+
getBlockClosePrefix: getHandlebarsBlockClosePrefix,
|
|
58
|
+
shouldPreserveTokenVerbatim: shouldPreserveHandlebarsTokenVerbatim,
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* The path a block opens on, which is where its name ends. A plain whitespace split cut
|
|
62
|
+
* `{{#[my block]}}` down to `[my`, and that never matched the `[my block]` the block's own
|
|
63
|
+
* `{{/[my block]}}` reports - a Handlebars path segment may hold spaces inside `[...]`.
|
|
64
|
+
*/
|
|
65
|
+
function readPathName(inner) {
|
|
66
|
+
const text = inner.trim();
|
|
67
|
+
let brackets = false;
|
|
68
|
+
for (let pos = 0; pos < text.length; pos += 1) {
|
|
69
|
+
const char = text[pos];
|
|
70
|
+
if (char === '[') {
|
|
71
|
+
brackets = true;
|
|
72
|
+
}
|
|
73
|
+
else if (char === ']') {
|
|
74
|
+
brackets = false;
|
|
75
|
+
}
|
|
76
|
+
else if (!brackets && whitespace.handlebars.test(char)) {
|
|
77
|
+
return text.slice(0, pos);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return text;
|
|
81
|
+
}
|
|
82
|
+
function parseHandlebarsToken(text, position) {
|
|
83
|
+
const triple = text.startsWith('{{{', position);
|
|
84
|
+
const openLength = triple ? 3 : 2;
|
|
85
|
+
const close = triple ? '}}}' : '}}';
|
|
86
|
+
const isBlockComment = isHandlebarsBlockComment(text, position);
|
|
87
|
+
const blockClose = isBlockComment ? findHandlebarsBlockCommentClose(text, position + openLength, close) : null;
|
|
88
|
+
/* A comment body is text, not an expression: Handlebars ends a line comment at the first close
|
|
89
|
+
* delimiter, full stop. The quote-aware scanner lets an unbalanced quote run the token past
|
|
90
|
+
* its real `}}`, so `{{! "q }}\n{{#if a}}y{{/if}}` swallows the block and renders nothing -
|
|
91
|
+
* stable across passes, and invisible to every gate. */
|
|
92
|
+
const isLineComment = !isBlockComment && /^~?!/u.test(text.slice(position + openLength, position + openLength + 2));
|
|
93
|
+
const closeIdx = isBlockComment
|
|
94
|
+
? blockClose?.index ?? -1
|
|
95
|
+
: isLineComment
|
|
96
|
+
? text.indexOf(close, position + openLength)
|
|
97
|
+
: findHandlebarsClose(text, position + openLength, close);
|
|
98
|
+
const end = isBlockComment
|
|
99
|
+
? blockClose?.end ?? text.length
|
|
100
|
+
: closeIdx >= 0
|
|
101
|
+
? closeIdx + close.length
|
|
102
|
+
: text.length;
|
|
103
|
+
const rawContent = text.slice(position + openLength, closeIdx >= 0 ? closeIdx : undefined);
|
|
104
|
+
const rawInner = rawContent.trim();
|
|
105
|
+
const trimOpen = rawInner.startsWith('~');
|
|
106
|
+
/* A block comment's closing `~` sits after the `--`, so it is outside `rawContent`. */
|
|
107
|
+
const trimClose = blockClose ? blockClose.trimClose : rawInner.endsWith('~');
|
|
108
|
+
const inner = rawInner.replace(/^~/, '').replace(/~$/, '').trim();
|
|
109
|
+
const baseToken = {
|
|
110
|
+
rawContent,
|
|
111
|
+
rawInner,
|
|
112
|
+
start: position,
|
|
113
|
+
end,
|
|
114
|
+
triple,
|
|
115
|
+
trimOpen,
|
|
116
|
+
trimClose,
|
|
117
|
+
terminated: isBlockComment ? blockClose !== null : closeIdx >= 0,
|
|
118
|
+
};
|
|
119
|
+
if (inner.startsWith('!')) {
|
|
120
|
+
return { kind: 'comment', content: inner, name: undefined, ...baseToken };
|
|
121
|
+
}
|
|
122
|
+
if (inner.startsWith('>')) {
|
|
123
|
+
return { kind: 'partial', content: inner.slice(1).trim(), name: undefined, ...baseToken };
|
|
124
|
+
}
|
|
125
|
+
if (inner.startsWith('<')) {
|
|
126
|
+
const name = readPathName(inner.slice(1));
|
|
127
|
+
return { kind: 'blockStart', content: inner, name, specialForm: 'parent', ...baseToken };
|
|
128
|
+
}
|
|
129
|
+
if (inner.startsWith('#>')) {
|
|
130
|
+
const name = readPathName(inner.slice(2));
|
|
131
|
+
return { kind: 'blockStart', content: inner, name, specialForm: 'blockPartial', ...baseToken };
|
|
132
|
+
}
|
|
133
|
+
if (inner.startsWith('#*')) {
|
|
134
|
+
const name = readPathName(inner.slice(2));
|
|
135
|
+
return { kind: 'blockStart', content: inner, name, specialForm: 'decoratorBlock', ...baseToken };
|
|
136
|
+
}
|
|
137
|
+
if (inner.startsWith('*')) {
|
|
138
|
+
return { kind: 'mustache', content: inner, name: undefined, specialForm: 'decorator', ...baseToken };
|
|
139
|
+
}
|
|
140
|
+
if (inner.startsWith('#')) {
|
|
141
|
+
const name = readPathName(inner.slice(1));
|
|
142
|
+
return { kind: 'blockStart', content: inner, name, ...baseToken };
|
|
143
|
+
}
|
|
144
|
+
if (inner.startsWith('^')) {
|
|
145
|
+
const name = readPathName(inner.slice(1));
|
|
146
|
+
/* Bare `{{^}}` is the shorthand for `{{else}}`; only `{{^name}}` opens an inverted block. */
|
|
147
|
+
if (!name) {
|
|
148
|
+
return { kind: 'else', content: inner, name: 'else', ...baseToken };
|
|
149
|
+
}
|
|
150
|
+
return { kind: 'blockStart', content: inner, name, specialForm: 'inverseBlock', ...baseToken };
|
|
151
|
+
}
|
|
152
|
+
if (inner.startsWith('$')) {
|
|
153
|
+
const name = readPathName(inner.slice(1));
|
|
154
|
+
return { kind: 'blockStart', content: inner, name, specialForm: 'mustacheBlock', ...baseToken };
|
|
155
|
+
}
|
|
156
|
+
if (inner.startsWith('/')) {
|
|
157
|
+
const name = inner.slice(1).trim();
|
|
158
|
+
return { kind: 'blockEnd', content: inner, name, ...baseToken };
|
|
159
|
+
}
|
|
160
|
+
if (inner === 'else' || inner.startsWith('else ')) {
|
|
161
|
+
return {
|
|
162
|
+
kind: 'else',
|
|
163
|
+
content: inner,
|
|
164
|
+
name: 'else',
|
|
165
|
+
specialForm: inner === 'else' ? undefined : 'elseIf',
|
|
166
|
+
...baseToken,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
return { kind: 'mustache', content: inner, name: undefined, ...baseToken };
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Whether a comment at `position` is written in block form. `{{~!-- x --~}}` is one as much as
|
|
173
|
+
* `{{!-- x --}}`, so the `~` is skipped: anchoring on a literal `{{!--` reads the
|
|
174
|
+
* whitespace-control form as a line comment and demotes it to `{{! !-- x -- }}`.
|
|
175
|
+
*/
|
|
176
|
+
function isHandlebarsBlockComment(text, position) {
|
|
177
|
+
const openLength = text.startsWith('{{{', position) ? 3 : 2;
|
|
178
|
+
return /^~?!--/u.test(text.slice(position + openLength, position + openLength + 4));
|
|
179
|
+
}
|
|
180
|
+
/** The first `--}}` or `--~}}`, whichever comes first. */
|
|
181
|
+
function findHandlebarsBlockCommentClose(text, position, close) {
|
|
182
|
+
const plain = text.indexOf(`--${close}`, position);
|
|
183
|
+
const trimmed = text.indexOf(`--~${close}`, position);
|
|
184
|
+
if (trimmed >= 0 && (plain < 0 || trimmed < plain)) {
|
|
185
|
+
return { index: trimmed, end: trimmed + close.length + 3, trimClose: true };
|
|
186
|
+
}
|
|
187
|
+
return plain < 0 ? null : { index: plain, end: plain + close.length + 2, trimClose: false };
|
|
188
|
+
}
|
|
189
|
+
function findHandlebarsClose(text, position, closeDelimiter) {
|
|
190
|
+
return (0, scan_1.scanPastQuotes)(text, position, {
|
|
191
|
+
stopsAt: (index) => text.startsWith(closeDelimiter, index),
|
|
192
|
+
opensQuote: (index) => (0, template_format_core_1.isTemplateExpressionQuoteStart)(text, index, position),
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
function isEscapedHandlebarsOpen(text, position) {
|
|
196
|
+
if (!text.startsWith('{{', position)) {
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
let slashCount = 0;
|
|
200
|
+
for (let index = position - 1; index >= 0 && text[index] === '\\'; index -= 1) {
|
|
201
|
+
slashCount += 1;
|
|
202
|
+
}
|
|
203
|
+
return slashCount % 2 === 1;
|
|
204
|
+
}
|
|
205
|
+
function findNextHandlebarsOpen(text, position) {
|
|
206
|
+
let searchPos = position;
|
|
207
|
+
while (searchPos < text.length) {
|
|
208
|
+
const candidate = text.indexOf('{{', searchPos);
|
|
209
|
+
if (candidate === -1) {
|
|
210
|
+
return -1;
|
|
211
|
+
}
|
|
212
|
+
if (!isEscapedHandlebarsOpen(text, candidate)) {
|
|
213
|
+
return candidate;
|
|
214
|
+
}
|
|
215
|
+
searchPos = candidate + 2;
|
|
216
|
+
}
|
|
217
|
+
return -1;
|
|
218
|
+
}
|
|
219
|
+
function isDynamicHandlebarsElementStart(text, position) {
|
|
220
|
+
return text.startsWith('<{{', position) || text.startsWith('</{{', position);
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* The name a raw block opens with, or `''` if `position` is not one. Tildes are tolerated on the
|
|
224
|
+
* open because the lexer takes them there; the closer below is stricter for the same reason.
|
|
225
|
+
*/
|
|
226
|
+
function handlebarsRawBlockName(text, position, openEnd) {
|
|
227
|
+
const inner = text.slice(position + 4, openEnd).trim().replace(/^~/u, '').replace(/~$/u, '').trim();
|
|
228
|
+
return inner.startsWith('/') ? '' : (inner.split(whitespace.handlebarsRun)[0] ?? '');
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* The only closer Handlebars accepts: no whitespace inside it and no tilde on either side -
|
|
232
|
+
* `{{{{~/raw}}}}` and `{{{{ / raw }}}}` are both lexical errors. One literal, so there is nothing
|
|
233
|
+
* to escape and nothing to drift - the two hand-written patterns this replaces disagreed about
|
|
234
|
+
* exactly that whitespace.
|
|
235
|
+
*/
|
|
236
|
+
function handlebarsRawBlockCloser(name) {
|
|
237
|
+
return `{{{{/${name}}}}}`;
|
|
238
|
+
}
|
|
239
|
+
function consumeHandlebarsRawBlock(text, position) {
|
|
240
|
+
if (!text.startsWith('{{{{', position)) {
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
const openIdx = text.indexOf('}}}}', position + 4);
|
|
244
|
+
if (openIdx === -1) {
|
|
245
|
+
return text.length;
|
|
246
|
+
}
|
|
247
|
+
const name = handlebarsRawBlockName(text, position, openIdx);
|
|
248
|
+
if (!name) {
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
const closer = handlebarsRawBlockCloser(name);
|
|
252
|
+
const closeIdx = text.indexOf(closer, openIdx + 4);
|
|
253
|
+
if (closeIdx === -1) {
|
|
254
|
+
return text.length;
|
|
255
|
+
}
|
|
256
|
+
return closeIdx + closer.length;
|
|
257
|
+
}
|
|
258
|
+
function getHandlebarsBlockExpression(token) {
|
|
259
|
+
if (token.specialForm === 'blockPartial' || token.specialForm === 'decoratorBlock') {
|
|
260
|
+
return token.content.slice(2).trim();
|
|
261
|
+
}
|
|
262
|
+
return token.content.slice(1).trim();
|
|
263
|
+
}
|
|
264
|
+
function getHandlebarsBlockPrefix(token) {
|
|
265
|
+
if (token.specialForm === 'blockPartial') {
|
|
266
|
+
return '#>';
|
|
267
|
+
}
|
|
268
|
+
if (token.specialForm === 'decoratorBlock') {
|
|
269
|
+
return '#*';
|
|
270
|
+
}
|
|
271
|
+
if (token.specialForm === 'inverseBlock') {
|
|
272
|
+
return '^';
|
|
273
|
+
}
|
|
274
|
+
if (token.specialForm === 'parent') {
|
|
275
|
+
return '<';
|
|
276
|
+
}
|
|
277
|
+
if (token.specialForm === 'mustacheBlock') {
|
|
278
|
+
return '$';
|
|
279
|
+
}
|
|
280
|
+
return '#';
|
|
281
|
+
}
|
|
282
|
+
function getPrintedHandlebarsBlockPrefix(prefix) {
|
|
283
|
+
if (prefix === '#>' || prefix === '<') {
|
|
284
|
+
return `${prefix} `;
|
|
285
|
+
}
|
|
286
|
+
return prefix;
|
|
287
|
+
}
|
|
288
|
+
function getHandlebarsElseKeyword() {
|
|
289
|
+
return 'else';
|
|
290
|
+
}
|
|
291
|
+
function getHandlebarsBlockClosePrefix(path) {
|
|
292
|
+
return `/${path}`;
|
|
293
|
+
}
|
|
294
|
+
function shouldPreserveHandlebarsTokenVerbatim(token) {
|
|
295
|
+
return token.specialForm === 'elseIf';
|
|
296
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
interface Position {
|
|
2
|
+
line: number;
|
|
3
|
+
column: number;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* A construct the template opened and never closed, or closed with the wrong thing.
|
|
7
|
+
*
|
|
8
|
+
* The formatter refuses rather than guessing: guessing at a missing `}}` means printing markup
|
|
9
|
+
* the author did not write, and quietly passing a mismatched tag through means the rest of the
|
|
10
|
+
* file goes unformatted with nothing to show for it.
|
|
11
|
+
*/
|
|
12
|
+
export declare class TemplateSyntaxError extends SyntaxError {
|
|
13
|
+
readonly start: number;
|
|
14
|
+
readonly end: number;
|
|
15
|
+
/** Prettier renders a code frame from this, and editors put the cursor on it. */
|
|
16
|
+
loc?: {
|
|
17
|
+
start: Position;
|
|
18
|
+
end: Position;
|
|
19
|
+
};
|
|
20
|
+
constructor(message: string, start: number, end: number);
|
|
21
|
+
/** Offsets are all the parser knows; line and column need the whole text. */
|
|
22
|
+
locate(text: string): this;
|
|
23
|
+
}
|
|
24
|
+
export {};
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.TemplateSyntaxError = void 0;
|
|
4
|
+
function positionAt(text, offset) {
|
|
5
|
+
const upTo = text.slice(0, Math.max(0, Math.min(offset, text.length)));
|
|
6
|
+
return { line: upTo.split('\n').length, column: upTo.length - (upTo.lastIndexOf('\n') + 1) + 1 };
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* A construct the template opened and never closed, or closed with the wrong thing.
|
|
10
|
+
*
|
|
11
|
+
* The formatter refuses rather than guessing: guessing at a missing `}}` means printing markup
|
|
12
|
+
* the author did not write, and quietly passing a mismatched tag through means the rest of the
|
|
13
|
+
* file goes unformatted with nothing to show for it.
|
|
14
|
+
*/
|
|
15
|
+
class TemplateSyntaxError extends SyntaxError {
|
|
16
|
+
constructor(message, start, end) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.start = start;
|
|
19
|
+
this.end = end;
|
|
20
|
+
/* Editor integrations grep stderr for `: SyntaxError: <message> (line:col)` to place the
|
|
21
|
+
* cursor -- JsPrettier for Sublime Text does, and a subclass name misses that pattern. */
|
|
22
|
+
this.name = 'SyntaxError';
|
|
23
|
+
}
|
|
24
|
+
/** Offsets are all the parser knows; line and column need the whole text. */
|
|
25
|
+
locate(text) {
|
|
26
|
+
const start = positionAt(text, this.start);
|
|
27
|
+
this.loc = { start, end: positionAt(text, this.end) };
|
|
28
|
+
this.message = `${this.message} (${start.line}:${start.column})`;
|
|
29
|
+
return this;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
exports.TemplateSyntaxError = TemplateSyntaxError;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Call } from './types';
|
|
2
|
+
/**
|
|
3
|
+
* Parses one call's source into structured parts whose ranges are absolute in the template.
|
|
4
|
+
*
|
|
5
|
+
* `offset` is where `source` begins in the template, so a subexpression buried in a hash value
|
|
6
|
+
* can still be located exactly.
|
|
7
|
+
*/
|
|
8
|
+
export declare function parseCall(source: string, offset?: number): Call;
|