@falsefalse/prettier-plugin-handlebars 0.0.2 → 0.0.5

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/dist/plugin.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { locEnd, locStart, parse } from './parser';
1
+ import { locEnd, locStart } from './core/source';
2
+ import { parse } from './parser';
2
3
  export declare const languages: {
3
4
  name: string;
4
5
  type: string;
package/dist/plugin.js CHANGED
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.defaultOptions = exports.options = exports.printers = exports.parsers = exports.languages = void 0;
4
+ const source_1 = require("./core/source");
4
5
  const parser_1 = require("./parser");
5
6
  const printer_1 = require("./printer");
6
7
  exports.languages = [
@@ -17,8 +18,8 @@ exports.parsers = {
17
18
  'handlebars': {
18
19
  parse: parser_1.parse,
19
20
  astFormat: 'handlebars-ast',
20
- locStart: parser_1.locStart,
21
- locEnd: parser_1.locEnd,
21
+ locStart: source_1.locStart,
22
+ locEnd: source_1.locEnd,
22
23
  },
23
24
  };
24
25
  exports.printers = {
package/dist/printer.d.ts CHANGED
@@ -1,8 +1,3 @@
1
- import type { ParserOptions, Printer } from 'prettier';
1
+ import type { Printer } from 'prettier';
2
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
3
  export declare const printer: Printer<Node>;
package/dist/printer.js CHANGED
@@ -35,9 +35,9 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.printer = void 0;
37
37
  const doc_1 = require("prettier/doc");
38
- const template_format_core_1 = require("template-format-core");
38
+ const html_1 = require("./core/html");
39
39
  const tokens_1 = require("./dialects/handlebars/tokens");
40
- const whitespace = __importStar(require("./whitespace"));
40
+ const whitespace = __importStar(require("./core/whitespace"));
41
41
  const { dedent, fill, group, hardline, ifBreak, indent, join, line, literalline, softline } = doc_1.builders;
42
42
  const { removeLines } = doc_1.utils;
43
43
  /* A run of blank lines collapses to one, which is two hardlines. */
@@ -192,13 +192,15 @@ function contentSpan(pieces) {
192
192
  end -= 1;
193
193
  return [start, end];
194
194
  }
195
- function printExpression(expression, breakable) {
196
- return expression.type === 'SubExpression' ? printCall(expression, '(', ')', breakable) : expression.source;
195
+ function printExpression(expression, breakable, options) {
196
+ return expression.type === 'SubExpression'
197
+ ? printCall(expression, '(', ')', options, breakable)
198
+ : printSource(expression.source, options);
197
199
  }
198
- function printCallParts(call, breakable) {
199
- const parts = call.params.map((param) => printExpression(param, breakable));
200
+ function printCallParts(call, breakable, options) {
201
+ const parts = call.params.map((param) => printExpression(param, breakable, options));
200
202
  for (const pair of call.hash) {
201
- parts.push([pair.key, '=', printExpression(pair.value, breakable)]);
203
+ parts.push([pair.key, '=', printExpression(pair.value, breakable, options)]);
202
204
  }
203
205
  if (call.blockParams && call.blockParams.length > 0) {
204
206
  parts.push(['as |', call.blockParams.join(' '), '|']);
@@ -206,22 +208,22 @@ function printCallParts(call, breakable) {
206
208
  return parts;
207
209
  }
208
210
  /** 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
+ function printCall(call, open, close, options, breakable = true) {
212
+ const parts = printCallParts(call, breakable, options);
211
213
  if (parts.length === 0) {
212
- return [open, printExpression(call.path, breakable), close];
214
+ return [open, printExpression(call.path, breakable, options), close];
213
215
  }
214
216
  if (!breakable) {
215
- return [open, printExpression(call.path, false), ' ', join(' ', parts), close];
217
+ return [open, printExpression(call.path, false, options), ' ', join(' ', parts), close];
216
218
  }
217
219
  /* 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]);
220
+ return group([open, indent([printExpression(call.path, true, options), line, join(line, parts)]), softline, close]);
219
221
  }
220
222
  /* The three inline statements are one call in different delimiters: a mustache in `{{}}` (or
221
223
  * `{{{}}}` when unescaped), a partial in `{{> }}`, a decorator in `{{*}}`. */
222
- function printStatement(node, prefix) {
224
+ function printStatement(node, prefix, options) {
223
225
  const triple = node.type === 'MustacheStatement' && node.triple;
224
- return printCall(node, [triple ? '{{{' : '{{', trim(node.trimOpen), prefix], [trim(node.trimClose), triple ? '}}}' : '}}']);
226
+ return printCall(node, [triple ? '{{{' : '{{', trim(node.trimOpen), prefix], [trim(node.trimClose), triple ? '}}}' : '}}'], options);
225
227
  }
226
228
  function printComment(node) {
227
229
  /* `block` already covers a multiline body - the parser sets it for either - so re-testing
@@ -240,7 +242,7 @@ function printComment(node) {
240
242
  * the surrounding structure instead of staying frozen at the column it was written at.
241
243
  * Common indentation is stripped and re-applied, which keeps the body's *relative* shape. */
242
244
  if (/^\n/u.test(body)) {
243
- const lines = (0, template_format_core_1.stripCommonIndent)(body.replace(/^\n/u, '').replace(trailingWhitespace, '').split('\n'));
245
+ const lines = whitespace.stripCommonIndent(body.replace(/^\n/u, '').replace(trailingWhitespace, '').split('\n'));
244
246
  return lines.every((line) => line === '')
245
247
  ? [open, hardline, close]
246
248
  : [open, indent([hardline, join(hardline, lines)]), hardline, close];
@@ -261,24 +263,55 @@ function printComment(node) {
261
263
  const tail = whitespace.html.test(body[body.length - 1] ?? '') ? '' : ' ';
262
264
  return [open, lead, join(literalline, body.split('\n')), tail, close];
263
265
  }
266
+ /* Handlebars escapes only the quote that closes the literal: `\n` is a backslash and an `n`,
267
+ * and `\\` is two backslashes. So unescaping is one replacement and re-escaping is its inverse,
268
+ * and a value can always be written in either quote. */
269
+ const isQuoted = (source) => source.length >= 2 && (source[0] === '"' || source[0] === "'") && source[source.length - 1] === source[0];
270
+ const unquote = (source) => source.slice(1, -1).split(`\\${source[0]}`).join(source[0]);
271
+ /* House style: `"` around an attribute value, `'` around a string literal in a mustache. The two
272
+ * are complementary, so a literal inside an attribute already wears the quote the attribute did
273
+ * not, and neither has to give way - which is what makes a template read the same throughout.
274
+ * Both still yield to the quote they sit inside, and to whichever one needs no escaping. */
275
+ const ATTRIBUTE_QUOTE = '"';
276
+ const LITERAL_QUOTE = "'";
277
+ const otherQuote = (quote) => (quote === '"' ? "'" : '"');
264
278
  /**
265
- * The quote that needs no escaping; `singleQuote` decides only when either would do.
279
+ * The house quote, unless the text already holds it or it is the one enclosing this.
266
280
  *
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.
281
+ * A quote ends the run it opened, so the enclosing one is unusable at any price: `class="{{t
282
+ * "x"}}"` ends the attribute at the second `"` and the rest of the tag becomes something else.
283
+ * Text holding the other quote is only worth an escape, and an attribute has none to spend, so
284
+ * both callers want the quote their text does not hold.
269
285
  */
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;
286
+ function pickQuote(text, house, enclosing) {
287
+ const usable = [house, otherQuote(house)].filter((quote) => quote !== enclosing);
288
+ return usable.find((quote) => !text.includes(quote)) ?? usable[0] ?? house;
289
+ }
290
+ /**
291
+ * A literal or path as it will be printed. A quoted path is a name written as a string -
292
+ * `{{> 'card'}}` - because the expression reader hands the head back as a path however it was
293
+ * written, so both go through the same quoting.
294
+ *
295
+ * Returns a string rather than a `Doc`: a block's closer has to repeat this exactly, and `Doc`
296
+ * is not something it can repeat.
297
+ */
298
+ function printSource(source, options) {
299
+ if (!isQuoted(source) || options.opaqueQuotes) {
300
+ return source;
301
+ }
302
+ const value = unquote(source);
303
+ const quote = pickQuote(value, LITERAL_QUOTE, options.enclosingQuote);
304
+ return quote + value.split(quote).join(`\\${quote}`) + quote;
275
305
  }
276
306
  function printAttribute(attribute, options) {
277
307
  if (attribute.type === 'RawAttribute') {
278
308
  return attribute.raw;
279
309
  }
310
+ /* The block's body is text and mustaches, not attributes: `title="` is half of one TextNode,
311
+ * so nothing downstream can tell which quote - if any - a literal in there sits inside. The
312
+ * text keeps the author's quotes because it is content; a literal keeps them for company. */
280
313
  if (attribute.type === 'AttributeBlock') {
281
- return printAny(attribute.block, options);
314
+ return printAny(attribute.block, { ...options, opaqueQuotes: true });
282
315
  }
283
316
  if (!attribute.value) {
284
317
  return attribute.name;
@@ -288,8 +321,10 @@ function printAttribute(attribute, options) {
288
321
  * rendered value. The parser marks the value's text as whitespace-significant, which is what
289
322
  * keeps a block's body from being laid out at the printer's indent level instead of the
290
323
  * author's - and what lets prettier see where the value's own lines end. */
324
+ /* Against the value's raw text, not just its TextNode parts: a quote inside a mustache is
325
+ * printed too, so `class='{{t "x"}}'` cannot be re-quoted with `"` without ending early. */
291
326
  const { parts } = attribute.value;
292
- const quote = chooseQuote(attribute.value, options);
327
+ const quote = pickQuote(attribute.value.raw, ATTRIBUTE_QUOTE, options.enclosingQuote);
293
328
  const nested = { ...options, enclosingQuote: quote };
294
329
  return [attribute.name, '=', quote, ...parts.map((part) => printAny(part, nested)), quote];
295
330
  }
@@ -299,7 +334,7 @@ function printAttribute(attribute, options) {
299
334
  * a stray bracket rather than a break.
300
335
  */
301
336
  function printOpenTag(node, options) {
302
- const marker = node.selfClosing && !template_format_core_1.voidElements.has(node.tag.toLowerCase()) ? ' />' : '>';
337
+ const marker = node.selfClosing && !(0, html_1.isVoidElement)(node.tag) ? ' />' : '>';
303
338
  if (node.attributes.length === 0) {
304
339
  return ['<', node.tag, marker];
305
340
  }
@@ -382,11 +417,11 @@ function printBlock(node, options, tail = []) {
382
417
  const sections = [
383
418
  {
384
419
  program: node.program,
385
- open: (breakable) => printCall(node, ['{{', trim(node.trimOpen), prefix], [trim(node.trimClose), '}}'], breakable),
420
+ open: (breakable) => printCall(node, ['{{', trim(node.trimOpen), prefix], [trim(node.trimClose), '}}'], options, breakable),
386
421
  },
387
422
  ...(node.inverseChain ?? []).map((branch) => ({
388
423
  program: branch.program,
389
- open: (breakable) => printCall(branch, ['{{', trim(branch.trimOpen), `${elseKeyword} `], [trim(branch.trimClose), '}}'], breakable),
424
+ open: (breakable) => printCall(branch, ['{{', trim(branch.trimOpen), `${elseKeyword} `], [trim(branch.trimClose), '}}'], options, breakable),
390
425
  })),
391
426
  ];
392
427
  /* An empty `{{else}}` prints nothing - unless it carries `~`, which strips whitespace that
@@ -400,7 +435,7 @@ function printBlock(node, options, tail = []) {
400
435
  const close = [
401
436
  '{{',
402
437
  trim(node.closeTrimOpen),
403
- tokens_1.handlebarsDialect.getBlockClosePrefix(node.path.source),
438
+ tokens_1.handlebarsDialect.getBlockClosePrefix(printSource(node.path.source, options)),
404
439
  trim(node.closeTrimClose),
405
440
  '}}',
406
441
  tail,
@@ -484,11 +519,11 @@ function printAny(node, options) {
484
519
  case 'TextNode':
485
520
  return assemble(textPieces(node));
486
521
  case 'MustacheStatement':
487
- return printStatement(node, '');
522
+ return printStatement(node, '', options);
488
523
  case 'PartialStatement':
489
- return printStatement(node, '> ');
524
+ return printStatement(node, '> ', options);
490
525
  case 'DecoratorStatement':
491
- return printStatement(node, '*');
526
+ return printStatement(node, '*', options);
492
527
  case 'CommentStatement':
493
528
  return printComment(node);
494
529
  /* `childPieces` intercepts these, so this arm only keeps the switch exhaustive. It goes
@@ -514,10 +549,12 @@ const visitorKeys = {
514
549
  ElseBranch: ['program'],
515
550
  };
516
551
  exports.printer = {
517
- /* Only the root reaches this: everything below recurses through printAny. */
518
- print(path, options) {
552
+ /* Only the root reaches this: everything below recurses through printAny. Prettier's options
553
+ * are not among the arguments because nothing here reads them - the quoting is this
554
+ * formatter's own, and width and indentation belong to the doc printer. */
555
+ print(path) {
519
556
  const node = path.node;
520
- return node.type === 'Program' && path.parent === null ? printRoot(node.body, options) : printAny(node, options);
557
+ return node.type === 'Program' && path.parent === null ? printRoot(node.body, {}) : printAny(node, {});
521
558
  },
522
559
  getVisitorKeys(node, nonTraversableKeys) {
523
560
  const type = typeof node === 'object' && node !== null && 'type' in node ? String(node.type) : '';
package/dist/types.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import type { SourceRange } from 'template-format-core';
2
- export type { SourceRange } from 'template-format-core';
1
+ import type { SourceRange } from './core/source';
2
+ export type { SourceRange } from './core/source';
3
3
  export type Node = Program | ElementNode | TextNode | MustacheStatement | BlockStatement | PartialStatement | DecoratorStatement | CommentStatement | UnmatchedNode;
4
4
  export interface Program extends SourceRange {
5
5
  type: 'Program';
@@ -7,7 +7,6 @@ The plugin works through Prettier. Editors must be configured so Prettier is sel
7
7
  Install:
8
8
 
9
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
10
 
12
11
  Recommended workspace settings:
13
12
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@falsefalse/prettier-plugin-handlebars",
3
- "version": "0.0.2",
3
+ "version": "0.0.5",
4
4
  "description": "Whitespace-aware Handlebars formatter for Prettier",
5
5
  "contributors": [
6
6
  "Claude <noreply@anthropic.com>"
@@ -45,8 +45,8 @@
45
45
  ],
46
46
  "scripts": {
47
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",
48
+ "build": "npm run clean && tsc -p tsconfig.build.json",
49
+ "typecheck": "tsc",
50
50
  "test": "vitest run",
51
51
  "test:watch": "vitest",
52
52
  "check": "npm run build && npm run typecheck && npm test && npm run gates",
@@ -67,8 +67,5 @@
67
67
  },
68
68
  "peerDependencies": {
69
69
  "prettier": ">=3.0.0"
70
- },
71
- "dependencies": {
72
- "template-format-core": "^0.1.1"
73
70
  }
74
71
  }
File without changes
File without changes