@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,135 @@
1
+ import type { SourceRange } from 'template-format-core';
2
+ export type { SourceRange } from 'template-format-core';
3
+ export type Node = Program | ElementNode | TextNode | MustacheStatement | BlockStatement | PartialStatement | DecoratorStatement | CommentStatement | UnmatchedNode;
4
+ export interface Program extends SourceRange {
5
+ type: 'Program';
6
+ body: Node[];
7
+ }
8
+ export interface AttributeValue extends SourceRange {
9
+ type: 'AttributeValue';
10
+ parts: AttributeValuePart[];
11
+ /** The value between the quotes, verbatim. Quotes inside a mustache get printed too. */
12
+ raw: string;
13
+ }
14
+ /** Everything that can stand in attribute position, or inside a value alongside its text. */
15
+ export type AttributeBlockNode = MustacheStatement | BlockStatement | PartialStatement | DecoratorStatement | CommentStatement;
16
+ export type AttributeValuePart = TextNode | AttributeBlockNode;
17
+ /**
18
+ * `glued` marks an attribute the author wrote with no space before it. For a mustache or block in
19
+ * attribute position that space renders, so the printer may not invent one.
20
+ */
21
+ export type ElementAttribute = SourceRange & {
22
+ glued?: boolean;
23
+ } & ({
24
+ type: 'Attribute';
25
+ name: string;
26
+ value?: AttributeValue | null;
27
+ } | {
28
+ type: 'RawAttribute';
29
+ raw: string;
30
+ } | {
31
+ type: 'AttributeBlock';
32
+ block: AttributeBlockNode;
33
+ });
34
+ export interface ElementNode extends SourceRange {
35
+ type: 'ElementNode';
36
+ tag: string;
37
+ attributes: ElementAttribute[];
38
+ children: Node[];
39
+ selfClosing: boolean;
40
+ /** As on `TextNode`: inside a value the printer may not break the tag, every character renders. */
41
+ preserveWhitespace?: boolean;
42
+ /** Set only when the author spelled the closing tag differently; HTML tag names ignore case. */
43
+ closeTag?: string;
44
+ /** Span between the tag name and the closing `>`, so the attributes can be checked to cover it. */
45
+ attributesRange?: [number, number];
46
+ /** Span between the open tag's `>` and the close tag's `<`, so children can be checked to tile it. */
47
+ contentRange?: [number, number];
48
+ }
49
+ export interface TextNode extends SourceRange {
50
+ type: 'TextNode';
51
+ /** The source run, verbatim. Whitespace is content, never metadata. */
52
+ chars: string;
53
+ /** Content copied through untouched: raw-text elements, prettier-ignore regions. */
54
+ verbatim?: boolean;
55
+ /** Inside an attribute value, where every space renders and none of them are the printer's. */
56
+ preserveWhitespace?: boolean;
57
+ }
58
+ export type Expression = PathExpression | Literal | SubExpression;
59
+ export type LiteralType = 'StringLiteral' | 'NumberLiteral' | 'BooleanLiteral' | 'NullLiteral' | 'UndefinedLiteral';
60
+ export interface PathExpression extends SourceRange {
61
+ type: 'PathExpression';
62
+ /** `a.[b c].d`, `../../x`, `@index`, `this`, exactly as written. */
63
+ source: string;
64
+ }
65
+ export interface Literal extends SourceRange {
66
+ type: LiteralType;
67
+ /** Exactly as written, quotes included. */
68
+ source: string;
69
+ }
70
+ export interface SubExpression extends SourceRange {
71
+ type: 'SubExpression';
72
+ /** `(concat 'p' x)` including the parens, so every expression node prints from itself. */
73
+ source: string;
74
+ path: PathExpression | SubExpression;
75
+ params: Expression[];
76
+ hash: HashPair[];
77
+ }
78
+ export interface HashPair extends SourceRange {
79
+ /** Always a bare identifier: Handlebars rejects `a.b=1`. */
80
+ key: string;
81
+ value: Expression;
82
+ }
83
+ interface MustacheBase {
84
+ /** A SubExpression head is only reachable through a dynamic partial, `{{> (lookup . "n")}}`. */
85
+ path: PathExpression | SubExpression;
86
+ params: Expression[];
87
+ hash: HashPair[];
88
+ blockParams?: string[];
89
+ trimOpen?: boolean;
90
+ trimClose?: boolean;
91
+ }
92
+ /** The parts every call shares: a mustache, a block marker, a partial, a subexpression. */
93
+ export type Call = Pick<MustacheBase, 'path' | 'params' | 'hash' | 'blockParams'>;
94
+ export interface MustacheStatement extends MustacheBase, SourceRange {
95
+ type: 'MustacheStatement';
96
+ triple: boolean;
97
+ }
98
+ export interface ElseBranch extends MustacheBase, SourceRange {
99
+ type: 'ElseBranch';
100
+ program: Program;
101
+ }
102
+ export interface BlockStatement extends MustacheBase, SourceRange {
103
+ type: 'BlockStatement';
104
+ program: Program;
105
+ inverseChain?: ElseBranch[];
106
+ inverse: Program;
107
+ inverseTrimOpen?: boolean;
108
+ inverseTrimClose?: boolean;
109
+ blockPrefix?: '#' | '#>' | '#*' | '^' | '<' | '$';
110
+ closeTrimOpen?: boolean;
111
+ closeTrimClose?: boolean;
112
+ }
113
+ export interface PartialStatement extends MustacheBase, SourceRange {
114
+ type: 'PartialStatement';
115
+ }
116
+ export interface DecoratorStatement extends MustacheBase, SourceRange {
117
+ type: 'DecoratorStatement';
118
+ }
119
+ export interface CommentStatement extends SourceRange {
120
+ type: 'CommentStatement';
121
+ value: string;
122
+ multiline: boolean;
123
+ block: boolean;
124
+ /** express-hbs' `{{!< name}}` layout directive, which must not be padded into prose. */
125
+ layout?: boolean;
126
+ trimOpen?: boolean;
127
+ trimClose?: boolean;
128
+ }
129
+ export interface UnmatchedNode extends SourceRange {
130
+ type: 'UnmatchedNode';
131
+ raw: string;
132
+ /** As on `TextNode`: in a value the trailing gap is content, not somewhere to break. */
133
+ preserveWhitespace?: boolean;
134
+ }
135
+ export type ParseEndReason = 'blockEnd' | 'else' | 'tagClose' | null;
package/dist/types.js ADDED
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Two classes, because which one is right depends on which language owns the text. A loose `\s`
3
+ * in the parser or the expression reader is Handlebars' and points here.
4
+ *
5
+ * Import the module, not the names: `whitespace.html` against `whitespace.handlebars` at the
6
+ * call site is the point.
7
+ */
8
+ /**
9
+ * HTML's set. Not `\s`, which also matches U+00A0 - a non-breaking space is content, so
10
+ * `<div title=a b>` is one attribute holding one and `<div a b>` is one attribute named `a b`.
11
+ * Written out once; every class below is built from it, including the two in `parser.ts`.
12
+ */
13
+ export declare const htmlCharacters = "\\t\\n\\f\\r ";
14
+ /** `/[\t\n\f\r ]/u` */
15
+ export declare const html: RegExp;
16
+ /** `/[\t\n\f\r ]+/u` - collapsing a run to one space, or splitting text into its gaps. */
17
+ export declare const htmlRun: RegExp;
18
+ /** `/[\t\n\f\r ]+/gu` - for `replace`. Kept apart: `lastIndex` makes `g` unsafe to `test`. */
19
+ export declare const htmlRunGlobal: RegExp;
20
+ /**
21
+ * Handlebars' whitespace is `\s`, U+00A0 included: its lexer reads `{{foo bar}}` as path
22
+ * `foo` and param `bar`. Narrowing these to `html` would put the expression reader out of step
23
+ * with the runtime, and no corpus holds an NBSP inside a mustache to catch it. A `\s` spliced
24
+ * into a larger pattern - `/^else\s+/` - stays written out, and is this class too.
25
+ */
26
+ export declare const handlebars: RegExp;
27
+ /** `/\s+/u` - splitting a mustache's inner text into words. */
28
+ export declare const handlebarsRun: RegExp;
@@ -0,0 +1,31 @@
1
+ "use strict";
2
+ /**
3
+ * Two classes, because which one is right depends on which language owns the text. A loose `\s`
4
+ * in the parser or the expression reader is Handlebars' and points here.
5
+ *
6
+ * Import the module, not the names: `whitespace.html` against `whitespace.handlebars` at the
7
+ * call site is the point.
8
+ */
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.handlebarsRun = exports.handlebars = exports.htmlRunGlobal = exports.htmlRun = exports.html = exports.htmlCharacters = void 0;
11
+ /**
12
+ * HTML's set. Not `\s`, which also matches U+00A0 - a non-breaking space is content, so
13
+ * `<div title=a b>` is one attribute holding one and `<div a b>` is one attribute named `a b`.
14
+ * Written out once; every class below is built from it, including the two in `parser.ts`.
15
+ */
16
+ exports.htmlCharacters = '\\t\\n\\f\\r ';
17
+ /** `/[\t\n\f\r ]/u` */
18
+ exports.html = new RegExp(`[${exports.htmlCharacters}]`, 'u');
19
+ /** `/[\t\n\f\r ]+/u` - collapsing a run to one space, or splitting text into its gaps. */
20
+ exports.htmlRun = new RegExp(`[${exports.htmlCharacters}]+`, 'u');
21
+ /** `/[\t\n\f\r ]+/gu` - for `replace`. Kept apart: `lastIndex` makes `g` unsafe to `test`. */
22
+ exports.htmlRunGlobal = new RegExp(`[${exports.htmlCharacters}]+`, 'gu');
23
+ /**
24
+ * Handlebars' whitespace is `\s`, U+00A0 included: its lexer reads `{{foo bar}}` as path
25
+ * `foo` and param `bar`. Narrowing these to `html` would put the expression reader out of step
26
+ * with the runtime, and no corpus holds an NBSP inside a mustache to catch it. A `\s` spliced
27
+ * into a larger pattern - `/^else\s+/` - stays written out, and is this class too.
28
+ */
29
+ exports.handlebars = /\s/u;
30
+ /** `/\s+/u` - splitting a mustache's inner text into words. */
31
+ exports.handlebarsRun = /\s+/u;
@@ -0,0 +1,107 @@
1
+ # Editor Setup
2
+
3
+ The plugin works through Prettier. Editors must be configured so Prettier is selected as the formatter for `.hbs` / `.handlebars` files and can resolve the plugin from the project.
4
+
5
+ ## VS Code
6
+
7
+ Install:
8
+
9
+ - [Prettier - Code formatter](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode)
10
+ - Optional companion extension: [HBS Master](https://marketplace.visualstudio.com/items?itemName=poliklot.hbs-master)
11
+
12
+ Recommended workspace settings:
13
+
14
+ ```json
15
+ {
16
+ "editor.formatOnSave": true,
17
+ "prettier.documentSelectors": ["**/*.hbs", "**/*.handlebars"],
18
+ "[handlebars]": {
19
+ "editor.defaultFormatter": "esbenp.prettier-vscode"
20
+ }
21
+ }
22
+ ```
23
+
24
+ Recommended Prettier config:
25
+
26
+ ```js
27
+ /** @type {import("prettier").Config} */
28
+ module.exports = {
29
+ plugins: ['@falsefalse/prettier-plugin-handlebars'],
30
+ overrides: [
31
+ {
32
+ files: ["*.hbs", "*.handlebars"],
33
+ options: {
34
+ parser: "handlebars",
35
+ },
36
+ },
37
+ ],
38
+ };
39
+ ```
40
+
41
+ If VS Code still does not format files, open the Prettier output panel and check whether it is using the workspace Prettier package.
42
+
43
+ ## WebStorm / PhpStorm / other JetBrains IDEs
44
+
45
+ 1. Install `prettier` and `@falsefalse/prettier-plugin-handlebars` in the project.
46
+ 2. Open **Settings → Languages & Frameworks → JavaScript → Prettier**.
47
+ 3. Point **Prettier package** to the local `node_modules/prettier` package.
48
+ 4. Enable **Run on save** for `.hbs` / `.handlebars` if desired.
49
+ 5. Keep the explicit `overrides` rule in your Prettier config.
50
+
51
+ If the IDE formats the file as plain HTML, run the CLI command below to verify that project config is correct:
52
+
53
+ ```bash
54
+ npx prettier --check "**/*.{hbs,handlebars}"
55
+ ```
56
+
57
+ ## Neovim / Vim
58
+
59
+ Use the local project Prettier binary and pass the file path so Prettier can resolve config and parser overrides.
60
+
61
+ Example command for formatter plugins:
62
+
63
+ ```bash
64
+ ./node_modules/.bin/prettier --stdin-filepath template.hbs
65
+ ```
66
+
67
+ If your formatter does not pass `--stdin-filepath`, use explicit plugin and parser args:
68
+
69
+ ```bash
70
+ ./node_modules/.bin/prettier \
71
+ --plugin @falsefalse/prettier-plugin-handlebars \
72
+ --parser handlebars
73
+ ```
74
+
75
+ ## Sublime Text
76
+
77
+ [JsPrettier](https://packagecontrol.io/packages/JsPrettier) reads the position back out of
78
+ Prettier's stderr rather than out of the error object, and it matches on the plain name. That is
79
+ why syntax errors from this plugin call themselves `SyntaxError` rather than something more
80
+ specific:
81
+
82
+ ```
83
+ [error] page.hbs: SyntaxError: unclosed tag: expected </span> (3:3)
84
+ ```
85
+
86
+ Rename that and the cursor goes nowhere. `src/errors.ts` says so next to the assignment, and
87
+ `test/syntax-errors.test.ts` has a test that fails if it drifts.
88
+
89
+ ## Syntax errors in the editor
90
+
91
+ Malformed templates are rejected rather than reformatted, so an editor that formats on save will
92
+ report a failure instead of silently writing a degraded file. The error carries a source range
93
+ (`loc.start` and `loc.end`), which is enough for an editor to select the offending construct, not
94
+ just jump to the line.
95
+
96
+ If the editor reports the error but does not move the cursor, it is reading stderr rather than the
97
+ error object — see the Sublime Text note above.
98
+
99
+ ## CLI sanity check
100
+
101
+ When an editor behaves differently from the terminal, use this as the source of truth:
102
+
103
+ ```bash
104
+ npx prettier --write "**/*.{hbs,handlebars}"
105
+ ```
106
+
107
+ If that command works, the plugin is configured correctly and the remaining problem is editor resolution.
@@ -0,0 +1,89 @@
1
+ # Troubleshooting
2
+
3
+ ## Prettier does not format `.hbs` files
4
+
5
+ Almost always a resolution problem, not a formatting one. Prettier will not pick a plugin's parser
6
+ for an extension unless you say so:
7
+
8
+ ```js
9
+ /** @type {import("prettier").Config} */
10
+ module.exports = {
11
+ plugins: ['@falsefalse/prettier-plugin-handlebars'],
12
+ overrides: [{ files: ['*.hbs', '*.handlebars'], options: { parser: 'handlebars' } }],
13
+ };
14
+ ```
15
+
16
+ Check, in order:
17
+
18
+ 1. `npx prettier --check "**/*.{hbs,handlebars}"` — the terminal is the source of truth. If this
19
+ works and the editor does not, the problem is editor resolution; see
20
+ [EDITOR_SETUP.md](./EDITOR_SETUP.md).
21
+ 2. `.prettierignore` does not cover the files.
22
+ 3. The plugin resolves from the project, not a global install: `npm ls prettier`.
23
+
24
+ In a pnpm or monorepo layout the plugin may not be hoisted where Prettier looks. Give it an
25
+ explicit path:
26
+
27
+ ```js
28
+ plugins: [require.resolve('@falsefalse/prettier-plugin-handlebars')];
29
+ ```
30
+
31
+ ## Prettier picks the HTML parser instead
32
+
33
+ The `overrides` entry is missing, or a broader entry above it already claimed `*.hbs`. Prettier
34
+ applies the last matching override, so put the Handlebars one last.
35
+
36
+ ## `SyntaxError: unclosed tag: expected </div> (12:3)`
37
+
38
+ Not a plugin bug — the template is malformed and the plugin refuses to guess. See
39
+ [the README](../README.md#malformed-input-is-rejected) for the full list of what is rejected and
40
+ the two escape hatches.
41
+
42
+ The common surprises:
43
+
44
+ - **Optional end tags are not optional here.** `<li>`, `<td>`, `<p>` and friends must be closed,
45
+ even though the HTML spec lets you omit them.
46
+ - **`{{#*inline "name"}}` closes with `{{/inline}}`**, not `{{/name}}`.
47
+ - **A typo'd closer reports at the opener.** `{{#if a}}x{{/unless}}` says "unclosed block:
48
+ expected `{{/if}}`" and points at the `{{#if}}`, because that is where the parser knows
49
+ something is wrong. Crossed constructs report at the closer, which is the more useful place:
50
+ `{{#each xs}}{{#if a}}x{{/each}}{{/if}}` points at `{{/each}}`.
51
+ - **A missing quote swallows the tag.** `<div class="foo>` reports "unterminated tag: expected
52
+ `'>'`" — the quote is what actually went wrong.
53
+
54
+ ## A block of markup must stay byte-for-byte
55
+
56
+ ```hbs
57
+ {{! prettier-ignore }}
58
+ <div class="keep exactly"></div>
59
+ ```
60
+
61
+ Or a range, which is also the escape hatch for markup that is deliberately unbalanced:
62
+
63
+ ```hbs
64
+ {{! prettier-ignore-start }}
65
+ <script>
66
+ window.data = {{ rawJson }};
67
+ </script>
68
+ {{! prettier-ignore-end }}
69
+ ```
70
+
71
+ ## The formatter left my file alone and I expected it to change something
72
+
73
+ It probably did the right thing. The formatter reproduces whitespace between siblings exactly, so
74
+ a template that is already consistent with the author's own line breaks has nothing to change. It
75
+ only reflows inside tags and mustaches, and only when a line exceeds `printWidth`.
76
+
77
+ Conversely, if you want a block joined onto one line, join it yourself — the formatter will keep
78
+ it that way. It will not join lines you separated.
79
+
80
+ ## Debug checklist
81
+
82
+ 1. `npx prettier --check "**/*.{hbs,handlebars}"`
83
+ 2. `npm ls prettier @falsefalse/prettier-plugin-handlebars`
84
+ 3. `.prettierignore` does not cover the files
85
+ 4. The editor uses the workspace Prettier, not a bundled one
86
+
87
+ If none of that explains it,
88
+ [open an issue](https://github.com/falsefalse/prettier-plugin-handlebars/issues) with the smallest
89
+ template that reproduces the problem and the Prettier version you are on.
package/package.json ADDED
@@ -0,0 +1,74 @@
1
+ {
2
+ "name": "@falsefalse/prettier-plugin-handlebars",
3
+ "version": "0.0.1",
4
+ "description": "Whitespace-aware Handlebars formatter for Prettier",
5
+ "contributors": [
6
+ "Claude <noreply@anthropic.com>"
7
+ ],
8
+ "license": "MIT",
9
+ "type": "commonjs",
10
+ "main": "./dist/plugin.js",
11
+ "types": "./dist/plugin.d.ts",
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/plugin.d.ts",
15
+ "require": "./dist/plugin.js",
16
+ "default": "./dist/plugin.js"
17
+ }
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/falsefalse/prettier-plugin-handlebars.git"
22
+ },
23
+ "bugs": {
24
+ "url": "https://github.com/falsefalse/prettier-plugin-handlebars/issues"
25
+ },
26
+ "homepage": "https://github.com/falsefalse/prettier-plugin-handlebars#readme",
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "keywords": [
31
+ "prettier",
32
+ "prettier-plugin",
33
+ "handlebars",
34
+ "hbs",
35
+ "html",
36
+ "formatter"
37
+ ],
38
+ "engines": {
39
+ "node": ">=20"
40
+ },
41
+ "files": [
42
+ "dist",
43
+ "docs/EDITOR_SETUP.md",
44
+ "docs/TROUBLESHOOTING.md"
45
+ ],
46
+ "scripts": {
47
+ "clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
48
+ "build": "npm run clean && tsc -p tsconfig.json",
49
+ "typecheck": "tsc --noEmit -p ./ts.base.json",
50
+ "test": "vitest run",
51
+ "test:watch": "vitest",
52
+ "check": "npm run build && npm run typecheck && npm test && npm run gates",
53
+ "gates": "node scripts/run-parser-fuzz-check.mjs && node scripts/run-format-fuzz-check.mjs",
54
+ "prepack": "npm run build",
55
+ "prepublishOnly": "npm run check",
56
+ "fuzz:format": "npm run build && node scripts/run-format-fuzz-check.mjs",
57
+ "fuzz:parser": "npm run build && node scripts/run-parser-fuzz-check.mjs",
58
+ "corpus:diff": "npm run build && node scripts/corpus-diff.mjs",
59
+ "corpus:gate": "npm run build && node scripts/run-property-gate.mjs"
60
+ },
61
+ "devDependencies": {
62
+ "@types/node": "^26.5.0",
63
+ "handlebars": "^4.7.9",
64
+ "prettier": "^3.9.6",
65
+ "typescript": "^7.0.2",
66
+ "vitest": "^4.1.11"
67
+ },
68
+ "peerDependencies": {
69
+ "prettier": ">=3.0.0"
70
+ },
71
+ "dependencies": {
72
+ "template-format-core": "^0.1.1"
73
+ }
74
+ }