@falsefalse/prettier-plugin-handlebars 0.0.2 → 0.0.3

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 CHANGED
@@ -1,6 +1,7 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Poliklot
3
+ Copyright (c) 2026 Illia Furman
4
+ Portions copyright (c) 2026 Poliklot
4
5
 
5
6
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
7
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -2,9 +2,9 @@
2
2
 
3
3
  A Prettier plugin for classic Handlebars templates with mixed HTML markup.
4
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.
5
+ It is **opinionated**: it exposes no options of its own, only Prettier's core `printWidth`,
6
+ `tabWidth` and `useTabs`. Everything else is a decision the formatter has already
7
+ made.
8
8
 
9
9
  ## The rule everything follows
10
10
 
@@ -27,7 +27,7 @@ Two consequences worth stating plainly:
27
27
  ## Install
28
28
 
29
29
  ```bash
30
- npm install --save-dev prettier github:falsefalse/prettier-plugin-handlebars
30
+ npm install --save-dev prettier @falsefalse/prettier-plugin-handlebars
31
31
  ```
32
32
 
33
33
  ```js
@@ -106,8 +106,21 @@ Or fence the region off entirely. Nothing inside is parsed, so nothing inside ca
106
106
 
107
107
  ## Options
108
108
 
109
- None. `printWidth`, `tabWidth`, `useTabs` and `singleQuote` are read from Prettier's core config;
110
- this plugin adds nothing.
109
+ None. `printWidth`, `tabWidth` and `useTabs` are read from Prettier's core config; this plugin
110
+ adds nothing. `singleQuote` is **ignored** - quoting is part of the opinion.
111
+
112
+ Quotes are `"` around an HTML attribute value and `'` around a string literal in a mustache:
113
+
114
+ ```hbs
115
+ <div class="card" title="{{t 'card.title'}}">{{t 'card.body' count=n}}</div>
116
+ ```
117
+
118
+ The two are complementary, so a literal inside an attribute already wears the quote the attribute
119
+ did not and neither has to give way. Both still yield to the quote they sit inside and to
120
+ whichever one needs no escaping, so `{{t "it's"}}` keeps its double quote. A literal inside a
121
+ block in attribute position, `<img {{#if m}}alt="{{t 'k'}}"{{/if}}>`, keeps the quote the author
122
+ gave it: there the enclosing quote is only half of a text node, so no reader downstream can tell
123
+ what it is.
111
124
 
112
125
  ## Development
113
126
 
@@ -133,15 +146,6 @@ correctness; they cannot see bad taste, which is the failure mode that actually
133
146
  [docs/REWRITE-PLAN.md](./docs/REWRITE-PLAN.md) is the design record — why the printer looks like
134
147
  this, and what the previous one got wrong.
135
148
 
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
149
  ## Docs
146
150
 
147
151
  - [Editor setup](./docs/EDITOR_SETUP.md)
@@ -21,4 +21,11 @@ export declare class TemplateSyntaxError extends SyntaxError {
21
21
  /** Offsets are all the parser knows; line and column need the whole text. */
22
22
  locate(text: string): this;
23
23
  }
24
+ /**
25
+ * Every malformed construct ends here. A formatter that guesses at a missing delimiter prints
26
+ * markup the author did not write; one that passes a mismatched tag through leaves the rest of
27
+ * the file unformatted with nothing to show for it. Refusing is the only honest option, and the
28
+ * offsets let an editor put the cursor on the offending place.
29
+ */
30
+ export declare function fail(message: string, start: number, end: number): never;
24
31
  export {};
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.TemplateSyntaxError = void 0;
4
+ exports.fail = fail;
4
5
  function positionAt(text, offset) {
5
6
  const upTo = text.slice(0, Math.max(0, Math.min(offset, text.length)));
6
7
  return { line: upTo.split('\n').length, column: upTo.length - (upTo.lastIndexOf('\n') + 1) + 1 };
@@ -30,3 +31,12 @@ class TemplateSyntaxError extends SyntaxError {
30
31
  }
31
32
  }
32
33
  exports.TemplateSyntaxError = TemplateSyntaxError;
34
+ /**
35
+ * Every malformed construct ends here. A formatter that guesses at a missing delimiter prints
36
+ * markup the author did not write; one that passes a mismatched tag through leaves the rest of
37
+ * the file unformatted with nothing to show for it. Refusing is the only honest option, and the
38
+ * offsets let an editor put the cursor on the offending place.
39
+ */
40
+ function fail(message, start, end) {
41
+ throw new TemplateSyntaxError(message, start, end);
42
+ }
@@ -0,0 +1,2 @@
1
+ export declare function isVoidElement(tag: string): boolean;
2
+ export declare function isRawTextElement(tag: string): boolean;
@@ -0,0 +1,32 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isVoidElement = isVoidElement;
4
+ exports.isRawTextElement = isRawTextElement;
5
+ /** Elements with no closing tag. Writing one is an error, not a shorthand. */
6
+ const voidElements = new Set([
7
+ 'area',
8
+ 'base',
9
+ 'br',
10
+ 'col',
11
+ 'embed',
12
+ 'hr',
13
+ 'img',
14
+ 'input',
15
+ 'keygen',
16
+ 'link',
17
+ 'meta',
18
+ 'param',
19
+ 'source',
20
+ 'track',
21
+ 'wbr',
22
+ ]);
23
+ /** Elements whose content is text, not markup: a `<` inside one opens nothing. */
24
+ const rawTextElements = new Set(['script', 'style', 'textarea', 'pre']);
25
+ /* Both sets are keyed lowercase and a tag name is not: `<BR>` is a `br`. Asking through these
26
+ * rather than reaching for the set is what keeps the fold from being forgotten at a call site. */
27
+ function isVoidElement(tag) {
28
+ return voidElements.has(tag.toLowerCase());
29
+ }
30
+ function isRawTextElement(tag) {
31
+ return rawTextElements.has(tag.toLowerCase());
32
+ }
@@ -0,0 +1,11 @@
1
+ /** Where a node came from. Optional: a node built rather than read has no source to point at. */
2
+ export interface SourceRange {
3
+ range?: [number, number];
4
+ }
5
+ /** A BOM and a CRLF are not content. Every offset the parser records counts the result. */
6
+ export declare function normalizeInput(text: string): string;
7
+ export declare function locStart(node: SourceRange): number;
8
+ export declare function locEnd(node: SourceRange): number;
9
+ export declare function withRange<T extends object>(node: T, start: number, end: number): T;
10
+ /** For a node whose span is only sometimes known - an attribute value outside a template. */
11
+ export declare function withOptionalRange<T extends object>(node: T, start?: number, end?: number): T;
@@ -0,0 +1,27 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.normalizeInput = normalizeInput;
4
+ exports.locStart = locStart;
5
+ exports.locEnd = locEnd;
6
+ exports.withRange = withRange;
7
+ exports.withOptionalRange = withOptionalRange;
8
+ /** A BOM and a CRLF are not content. Every offset the parser records counts the result. */
9
+ function normalizeInput(text) {
10
+ return text.replace(/^\uFEFF/u, '').replace(/\r\n?/gu, '\n');
11
+ }
12
+ function locStart(node) {
13
+ return node.range?.[0] ?? 0;
14
+ }
15
+ function locEnd(node) {
16
+ return node.range?.[1] ?? 0;
17
+ }
18
+ /* Non-enumerable, so a range never reaches a snapshot, a `JSON.stringify` or a structural
19
+ * comparison between an expected node and a parsed one. */
20
+ function withRange(node, start, end) {
21
+ Object.defineProperty(node, 'range', { value: [start, end], enumerable: false, configurable: true });
22
+ return node;
23
+ }
24
+ /** For a node whose span is only sometimes known - an attribute value outside a template. */
25
+ function withOptionalRange(node, start, end) {
26
+ return typeof start === 'number' && typeof end === 'number' ? withRange(node, start, end) : node;
27
+ }
@@ -26,3 +26,8 @@ export declare const htmlRunGlobal: RegExp;
26
26
  export declare const handlebars: RegExp;
27
27
  /** `/\s+/u` - splitting a mustache's inner text into words. */
28
28
  export declare const handlebarsRun: RegExp;
29
+ /**
30
+ * Every line shifted left by the smallest indent any non-blank line carries, trailing spaces
31
+ * and tabs dropped. Keeps a block's relative shape while letting the printer own its column.
32
+ */
33
+ export declare function stripCommonIndent(lines: string[]): string[];
@@ -8,6 +8,7 @@
8
8
  */
9
9
  Object.defineProperty(exports, "__esModule", { value: true });
10
10
  exports.handlebarsRun = exports.handlebars = exports.htmlRunGlobal = exports.htmlRun = exports.html = exports.htmlCharacters = void 0;
11
+ exports.stripCommonIndent = stripCommonIndent;
11
12
  /**
12
13
  * HTML's set. Not `\s`, which also matches U+00A0 - a non-breaking space is content, so
13
14
  * `<div title=a b>` is one attribute holding one and `<div a b>` is one attribute named `a b`.
@@ -29,3 +30,20 @@ exports.htmlRunGlobal = new RegExp(`[${exports.htmlCharacters}]+`, 'gu');
29
30
  exports.handlebars = /\s/u;
30
31
  /** `/\s+/u` - splitting a mustache's inner text into words. */
31
32
  exports.handlebarsRun = /\s+/u;
33
+ /** How many spaces or tabs a line opens with. Not `trimStart`, which also eats a U+00A0. */
34
+ function indentOf(line) {
35
+ let at = 0;
36
+ while (line[at] === ' ' || line[at] === '\t')
37
+ at += 1;
38
+ return at;
39
+ }
40
+ /**
41
+ * Every line shifted left by the smallest indent any non-blank line carries, trailing spaces
42
+ * and tabs dropped. Keeps a block's relative shape while letting the printer own its column.
43
+ */
44
+ function stripCommonIndent(lines) {
45
+ const common = lines
46
+ .filter((line) => line.trim() !== '')
47
+ .reduce((least, line) => Math.min(least, indentOf(line)), Infinity);
48
+ return lines.map((line) => line.trim() === '' ? '' : line.slice(Math.min(common, indentOf(line))).replace(/[ \t]+$/u, ''));
49
+ }
@@ -1,5 +1,22 @@
1
- import type { TemplateBlockPrefix, TemplateToken } from 'template-format-core';
2
- export interface HandlebarsToken extends TemplateToken {
1
+ type TokenKind = 'blockStart' | 'blockEnd' | 'partial' | 'comment' | 'mustache' | 'else';
2
+ /** How a block opens, which is also how its closer and its `{{else}}` are spelled. */
3
+ type BlockPrefix = '#' | '#>' | '#*' | '^' | '<' | '$';
4
+ /** A form the printer reproduces but the parser treats as its plain counterpart. */
5
+ type SpecialForm = 'blockPartial' | 'decoratorBlock' | 'decorator' | 'elseIf' | 'inverseBlock' | 'parent' | 'mustacheBlock';
6
+ export interface HandlebarsToken {
7
+ kind: TokenKind;
8
+ /** The inner text, whitespace control and the leading sigil stripped. */
9
+ content: string;
10
+ /** The inner text exactly as written, delimiters aside. */
11
+ rawContent: string;
12
+ start: number;
13
+ end: number;
14
+ triple: boolean;
15
+ /** The path a block opens or closes on. Absent on everything that opens nothing. */
16
+ name?: string;
17
+ trimOpen: boolean;
18
+ trimClose: boolean;
19
+ specialForm?: SpecialForm;
3
20
  /**
4
21
  * Whether the tokenizer found a closing delimiter, rather than running to the end of the
5
22
  * input. Recorded by the one place that knows: re-deriving it by string-matching the token's
@@ -45,10 +62,10 @@ export declare function handlebarsRawBlockName(text: string, position: number, o
45
62
  */
46
63
  export declare function handlebarsRawBlockCloser(name: string): string;
47
64
  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;
65
+ declare function getHandlebarsBlockExpression(token: HandlebarsToken): string;
66
+ declare function getHandlebarsBlockPrefix(token: HandlebarsToken): BlockPrefix;
67
+ declare function getPrintedHandlebarsBlockPrefix(prefix: BlockPrefix): string;
51
68
  declare function getHandlebarsElseKeyword(): string;
52
69
  declare function getHandlebarsBlockClosePrefix(path: string): string;
53
- declare function shouldPreserveHandlebarsTokenVerbatim(token: TemplateToken): boolean;
70
+ declare function shouldPreserveHandlebarsTokenVerbatim(token: HandlebarsToken): boolean;
54
71
  export {};
@@ -37,12 +37,11 @@ exports.handlebarsDialect = void 0;
37
37
  exports.isHandlebarsBlockComment = isHandlebarsBlockComment;
38
38
  exports.handlebarsRawBlockName = handlebarsRawBlockName;
39
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. */
40
+ const scan_1 = require("../../core/scan");
41
+ const whitespace = __importStar(require("../../core/whitespace"));
42
+ /* Deliberately untyped. An interface here would fix a member for every piece of Handlebars
43
+ * syntax, each a second copy to keep in step with the printer by hand; the inferred shape is
44
+ * exactly what the parser and printer ask for. */
46
45
  exports.handlebarsDialect = {
47
46
  openDelimiter: '{{',
48
47
  parseToken: parseHandlebarsToken,
@@ -108,7 +107,6 @@ function parseHandlebarsToken(text, position) {
108
107
  const inner = rawInner.replace(/^~/, '').replace(/~$/, '').trim();
109
108
  const baseToken = {
110
109
  rawContent,
111
- rawInner,
112
110
  start: position,
113
111
  end,
114
112
  triple,
@@ -186,10 +184,19 @@ function findHandlebarsBlockCommentClose(text, position, close) {
186
184
  }
187
185
  return plain < 0 ? null : { index: plain, end: plain + close.length + 2, trimClose: false };
188
186
  }
187
+ /* A quote opens a string only where a value can start. Mid-token - `it's` in `{{t it's}}` -
188
+ * it is an apostrophe, and treating it as an opening quote runs the scan past the real `}}`. */
189
+ function opensQuote(text, index, expressionStart) {
190
+ if (index <= expressionStart) {
191
+ return true;
192
+ }
193
+ const previous = text[index - 1];
194
+ return !previous || whitespace.handlebars.test(previous) || /[([{=,:~|]/u.test(previous);
195
+ }
189
196
  function findHandlebarsClose(text, position, closeDelimiter) {
190
197
  return (0, scan_1.scanPastQuotes)(text, position, {
191
198
  stopsAt: (index) => text.startsWith(closeDelimiter, index),
192
- opensQuote: (index) => (0, template_format_core_1.isTemplateExpressionQuoteStart)(text, index, position),
199
+ opensQuote: (index) => opensQuote(text, index, position),
193
200
  });
194
201
  }
195
202
  function isEscapedHandlebarsOpen(text, position) {
@@ -34,9 +34,9 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.parseCall = parseCall;
37
- const template_format_core_1 = require("template-format-core");
38
- const errors_1 = require("./errors");
39
- const whitespace = __importStar(require("./whitespace"));
37
+ const source_1 = require("./core/source");
38
+ const errors_1 = require("./core/errors");
39
+ const whitespace = __importStar(require("./core/whitespace"));
40
40
  const quoteCharacters = new Set(['"', "'", '`']);
41
41
  const numberPattern = /^-?(?:\d+\.?\d*|\.\d+)$/u;
42
42
  function literalTypeOf(source) {
@@ -120,7 +120,7 @@ class CallReader {
120
120
  /** A leaf is its own source text and the span it came from; only the label differs. */
121
121
  leaf(type, start) {
122
122
  const node = { type, source: this.source.slice(start, this.index) };
123
- return (0, template_format_core_1.withRange)(node, ...this.span(start, this.index));
123
+ return (0, source_1.withRange)(node, ...this.span(start, this.index));
124
124
  }
125
125
  readSubExpression() {
126
126
  const start = this.index;
@@ -140,7 +140,7 @@ class CallReader {
140
140
  params: inner.params,
141
141
  hash: inner.hash,
142
142
  };
143
- return (0, template_format_core_1.withRange)(node, ...this.span(start, this.index));
143
+ return (0, source_1.withRange)(node, ...this.span(start, this.index));
144
144
  }
145
145
  readValue() {
146
146
  if (this.peek() === '(') {
@@ -165,7 +165,7 @@ class CallReader {
165
165
  return value;
166
166
  }
167
167
  const node = { type: 'PathExpression', source: value.source };
168
- return value.range ? (0, template_format_core_1.withRange)(node, ...value.range) : node;
168
+ return value.range ? (0, source_1.withRange)(node, ...value.range) : node;
169
169
  }
170
170
  readCall(nested = false) {
171
171
  this.skipWhitespace();
@@ -193,7 +193,7 @@ class CallReader {
193
193
  this.index += 1;
194
194
  this.skipWhitespace();
195
195
  const pairValue = this.readValue();
196
- hash.push((0, template_format_core_1.withRange)({ key: value.source, value: pairValue }, ...this.span(start, this.index)));
196
+ hash.push((0, source_1.withRange)({ key: value.source, value: pairValue }, ...this.span(start, this.index)));
197
197
  continue;
198
198
  }
199
199
  this.index = afterValue;
@@ -208,7 +208,7 @@ class CallReader {
208
208
  }
209
209
  emptyPath() {
210
210
  const node = { type: 'PathExpression', source: '' };
211
- return (0, template_format_core_1.withRange)(node, ...this.span(this.index, this.index));
211
+ return (0, source_1.withRange)(node, ...this.span(this.index, this.index));
212
212
  }
213
213
  }
214
214
  /**
@@ -0,0 +1,86 @@
1
+ import type { ElementAttribute } from '../types';
2
+ export declare const openDelimiter: string, isEscapedOpen: (text: string, position: number) => boolean, parseMustacheToken: (text: string, position: number) => import("../dialects/handlebars/tokens").HandlebarsToken;
3
+ export declare const leadingWhitespace: RegExp;
4
+ /** What an attribute name is made of: anything but whitespace and the characters that end one. */
5
+ export declare const attributeNameCharacter: RegExp;
6
+ /** What ends a tag name. HTML's tag-name state leaves on whitespace, `/` or `>`, and nothing else. */
7
+ export declare const tagNameTerminator: RegExp;
8
+ export declare function startsTemplateTag(text: string, position: number): boolean;
9
+ /**
10
+ * Where a tag ends, what it is called and whether it closed - without building a single node and
11
+ * without rejecting anything.
12
+ *
13
+ * Lookahead has to be total: callers scan regions they may go on to skip, including a
14
+ * `{{! prettier-ignore }}` body, so a `parseTag` here let the directive reject the very file it
15
+ * was written to protect. `terminated` is false when the tag ran to EOF, which is also how an
16
+ * unterminated attribute value shows up.
17
+ */
18
+ /**
19
+ * Past the mustache at `position`. A token reporting an end at or before where it started would
20
+ * leave the caller's loop standing still, so the opening delimiter is the smallest step taken.
21
+ */
22
+ export declare function skipTemplateTag(text: string, position: number): number;
23
+ export declare function scanTag(text: string, position: number): {
24
+ kind: 'open' | 'selfClosing' | 'close';
25
+ tag: string;
26
+ end: number;
27
+ terminated: boolean;
28
+ };
29
+ export type ParsedTag = {
30
+ kind: 'open';
31
+ tag: string;
32
+ attributes: ElementAttribute[];
33
+ attributesRange: [number, number];
34
+ end: number;
35
+ terminated: boolean;
36
+ } | {
37
+ kind: 'selfClosing';
38
+ tag: string;
39
+ attributes: ElementAttribute[];
40
+ attributesRange: [number, number];
41
+ end: number;
42
+ terminated: boolean;
43
+ } | {
44
+ kind: 'close';
45
+ tag: string;
46
+ source: string;
47
+ end: number;
48
+ terminated: boolean;
49
+ };
50
+ export declare function sameTag(one: string, other: string): boolean;
51
+ /**
52
+ * Whether a close tag for exactly `tag` starts here.
53
+ *
54
+ * The name has to end where `tag` does. On a prefix comparison `</bdi>` would close a `<b>`,
55
+ * deleting `di` from the source and pointing any error at the next, well-formed close tag.
56
+ */
57
+ export declare function startsCloseTag(text: string, position: number, tag: string): boolean;
58
+ export declare function readCloseTagSource(text: string, position: number, closeIdx: number): string;
59
+ export declare function trimTrailingWhitespace(text: string, from: number): number;
60
+ export declare function isTagStart(text: string, position: number): boolean;
61
+ /**
62
+ * An attribute value's extent and its text, whichever way it was written. The quotes are not
63
+ * part of the value, so `start` is inside them - getting that `+ 1` wrong shifts every range a
64
+ * mustache inside the value reports.
65
+ */
66
+ export declare function readAttributeValue(text: string, position: number): {
67
+ raw: string;
68
+ start: number;
69
+ end: number;
70
+ };
71
+ export declare function skipWhitespace(text: string, position: number): number;
72
+ /**
73
+ * HTML's attribute-name state ends at whitespace, `/`, `>` or `=`, and nowhere else.
74
+ *
75
+ * Matching a tag-name charset instead stepped over one character and carried on: `@click` came
76
+ * back as `click` and `(click)="go()"` as two boolean attributes, value gone, silently.
77
+ */
78
+ export declare function readAttributeName(text: string, position: number): {
79
+ value: string;
80
+ next: number;
81
+ };
82
+ export declare function readName(text: string, position: number): {
83
+ value: string;
84
+ next: number;
85
+ };
86
+ export declare function consumeTagLikeChunk(text: string, position: number): number;