@herb-tools/formatter 0.4.3 → 0.5.0

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.
@@ -13,6 +13,7 @@ export declare class Printer extends Visitor {
13
13
  private indentLevel;
14
14
  private inlineMode;
15
15
  private isInComplexNesting;
16
+ private currentTagName;
16
17
  private static readonly INLINE_ELEMENTS;
17
18
  constructor(source: string, options: Required<FormatOptions>);
18
19
  print(object: Node | Token, indentLevel?: number): string;
@@ -48,6 +49,12 @@ export declare class Printer extends Visitor {
48
49
  * Determine if a tag should be rendered inline based on attribute count and other factors
49
50
  */
50
51
  private shouldRenderInline;
52
+ private hasMultilineAttributes;
53
+ private formatClassAttribute;
54
+ private isFormattableAttribute;
55
+ private formatMultilineAttribute;
56
+ private formatMultilineAttributeValue;
57
+ private breakTokensIntoLines;
51
58
  /**
52
59
  * Render multiline attributes for a tag
53
60
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@herb-tools/formatter",
3
- "version": "0.4.3",
3
+ "version": "0.5.0",
4
4
  "description": "Auto-formatter for HTML+ERB templates with intelligent indentation, line wrapping, and ERB-aware pretty-printing.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://herb-tools.dev",
@@ -35,7 +35,7 @@
35
35
  }
36
36
  },
37
37
  "dependencies": {
38
- "@herb-tools/core": "0.4.3",
38
+ "@herb-tools/core": "0.5.0",
39
39
  "glob": "^11.0.3"
40
40
  },
41
41
  "files": [
package/src/cli.ts CHANGED
@@ -27,11 +27,11 @@ export class CLI {
27
27
 
28
28
  Examples:
29
29
  herb-format # Format all **/*.html.erb files in current directory
30
- herb-format --check # Check if all **/*.html.erb files are formatted
30
+ herb-format templates/ # Format and **/*.html.erb within the given directory
31
31
  herb-format templates/index.html.erb # Format and write single file
32
+ herb-format --check # Check if all **/*.html.erb files are formatted
32
33
  herb-format --check templates/ # Check if all **/*.html.erb files in templates/ are formatted
33
34
  cat template.html.erb | herb-format # Format from stdin to stdout
34
- herb-format - < template.html.erb # Format from stdin to stdout
35
35
  `
36
36
 
37
37
  async run() {
package/src/printer.ts CHANGED
@@ -57,6 +57,12 @@ type ERBNode =
57
57
 
58
58
  import type { FormatOptions } from "./options.js"
59
59
 
60
+ // TODO: we can probably expand this list with more tags/attributes
61
+ const FORMATTABLE_ATTRIBUTES: Record<string, string[]> = {
62
+ '*': ['class'],
63
+ 'img': ['srcset', 'sizes']
64
+ }
65
+
60
66
  /**
61
67
  * Printer traverses the Herb AST using the Visitor pattern
62
68
  * and emits a formatted string with proper indentation, line breaks, and attribute wrapping.
@@ -69,6 +75,7 @@ export class Printer extends Visitor {
69
75
  private indentLevel: number = 0
70
76
  private inlineMode: boolean = false
71
77
  private isInComplexNesting: boolean = false
78
+ private currentTagName: string = ""
72
79
 
73
80
  private static readonly INLINE_ELEMENTS = new Set([
74
81
  'a', 'abbr', 'acronym', 'b', 'bdo', 'big', 'br', 'cite', 'code',
@@ -196,22 +203,142 @@ export class Printer extends Visitor {
196
203
  indentLength: number,
197
204
  maxLineLength: number = this.maxLineLength,
198
205
  hasComplexERB: boolean = false,
199
- nestingDepth: number = 0,
200
- inlineNodesLength: number = 0
206
+ _nestingDepth: number = 0,
207
+ _inlineNodesLength: number = 0,
208
+ hasMultilineAttributes: boolean = false
201
209
  ): boolean {
202
- if (hasComplexERB) return false
210
+ if (hasComplexERB || hasMultilineAttributes) return false
203
211
 
204
- // Special case: no attributes at all, always inline if it fits
205
212
  if (totalAttributeCount === 0) {
206
213
  return inlineLength + indentLength <= maxLineLength
207
214
  }
208
215
 
209
- const basicInlineCondition = totalAttributeCount <= 3 &&
210
- inlineLength + indentLength <= maxLineLength
216
+ if (totalAttributeCount > 3 || inlineLength + indentLength > maxLineLength) {
217
+ return false
218
+ }
219
+
220
+ return true
221
+ }
222
+
223
+ private hasMultilineAttributes(attributes: HTMLAttributeNode[]): boolean {
224
+ return attributes.some(attribute => {
225
+ if (attribute.value && (attribute.value instanceof HTMLAttributeValueNode || (attribute.value as any)?.type === 'AST_HTML_ATTRIBUTE_VALUE_NODE')) {
226
+ const attributeValue = attribute.value as HTMLAttributeValueNode
227
+
228
+ const content = attributeValue.children.map((child: Node) => {
229
+ if (child instanceof HTMLTextNode || (child as any).type === 'AST_HTML_TEXT_NODE' || child instanceof LiteralNode || (child as any).type === 'AST_LITERAL_NODE') {
230
+ return (child as HTMLTextNode | LiteralNode).content
231
+ } else if (child instanceof ERBContentNode || (child as any).type === 'AST_ERB_CONTENT_NODE') {
232
+ const erbAttribute = child as ERBContentNode
233
+
234
+ return erbAttribute.tag_opening!.value + erbAttribute.content!.value + erbAttribute.tag_closing!.value
235
+ }
236
+
237
+ return ""
238
+ }).join("")
239
+
240
+ if (/\r?\n/.test(content)) {
241
+ const name = (attribute.name as HTMLAttributeNameNode)!.name!.value ?? ""
242
+
243
+ if (name === 'class') {
244
+ const normalizedContent = content.replace(/\s+/g, ' ').trim()
245
+
246
+ return normalizedContent.length > 80
247
+ }
248
+
249
+ const lines = content.split(/\r?\n/)
250
+
251
+ if (lines.length > 1) {
252
+ return lines.slice(1).some(line => /^\s+/.test(line))
253
+ }
254
+ }
255
+ }
256
+
257
+ return false
258
+ })
259
+ }
260
+
261
+ private formatClassAttribute(content: string, name: string, equals: string, open_quote: string, close_quote: string): string {
262
+ const normalizedContent = content.replace(/\s+/g, ' ').trim()
263
+ const hasActualNewlines = /\r?\n/.test(content)
264
+
265
+ if (hasActualNewlines && normalizedContent.length > 80) {
266
+ const lines = content.split(/\r?\n/).map(line => line.trim()).filter(line => line)
267
+
268
+ if (lines.length > 1) {
269
+ return open_quote + this.formatMultilineAttributeValue(lines) + close_quote
270
+ }
271
+ }
272
+
273
+ const currentIndent = this.indentLevel * this.indentWidth
274
+ const attributeLine = `${name}${equals}${open_quote}${normalizedContent}${close_quote}`
275
+
276
+ if (currentIndent + attributeLine.length > this.maxLineLength && normalizedContent.length > 60) {
277
+ const classes = normalizedContent.split(' ')
278
+ const lines = this.breakTokensIntoLines(classes, currentIndent)
279
+
280
+ if (lines.length > 1) {
281
+ return open_quote + this.formatMultilineAttributeValue(lines) + close_quote
282
+ }
283
+ }
284
+
285
+ return open_quote + normalizedContent + close_quote
286
+ }
287
+
288
+ private isFormattableAttribute(attributeName: string, tagName: string): boolean {
289
+ const globalFormattable = FORMATTABLE_ATTRIBUTES['*'] || []
290
+ const tagSpecificFormattable = FORMATTABLE_ATTRIBUTES[tagName.toLowerCase()] || []
291
+
292
+ return globalFormattable.includes(attributeName) || tagSpecificFormattable.includes(attributeName)
293
+ }
294
+
295
+ private formatMultilineAttribute(content: string, name: string, equals: string, open_quote: string, close_quote: string): string {
296
+ if (name === 'srcset' || name === 'sizes') {
297
+ const normalizedContent = content.replace(/\s+/g, ' ').trim()
298
+
299
+ return open_quote + normalizedContent + close_quote
300
+ }
301
+
302
+ const lines = content.split('\n')
303
+
304
+ if (lines.length <= 1) {
305
+ return open_quote + content + close_quote
306
+ }
307
+
308
+ const formattedContent = this.formatMultilineAttributeValue(lines)
309
+
310
+ return open_quote + formattedContent + close_quote
311
+ }
312
+
313
+ private formatMultilineAttributeValue(lines: string[]): string {
314
+ const indent = " ".repeat((this.indentLevel + 1) * this.indentWidth)
315
+ const closeIndent = " ".repeat(this.indentLevel * this.indentWidth)
316
+
317
+ return "\n" + lines.map(line => indent + line).join("\n") + "\n" + closeIndent
318
+ }
319
+
320
+ private breakTokensIntoLines(tokens: string[], currentIndent: number, separator: string = ' '): string[] {
321
+ const lines: string[] = []
322
+ let currentLine = ''
323
+
324
+ for (const token of tokens) {
325
+ const testLine = currentLine ? currentLine + separator + token : token
326
+
327
+ if (testLine.length > (this.maxLineLength - currentIndent - 6)) {
328
+ if (currentLine) {
329
+ lines.push(currentLine)
330
+ currentLine = token
331
+ } else {
332
+ lines.push(token)
333
+ }
334
+ } else {
335
+ currentLine = testLine
336
+ }
337
+ }
211
338
 
212
- const erbInlineCondition = inlineNodesLength > 0 && totalAttributeCount <= 3
339
+ if (currentLine) lines.push(currentLine)
213
340
 
214
- return basicInlineCondition || erbInlineCondition
341
+ return lines
215
342
  }
216
343
 
217
344
  /**
@@ -219,8 +346,8 @@ export class Printer extends Visitor {
219
346
  */
220
347
  private renderMultilineAttributes(
221
348
  tagName: string,
222
- attributes: HTMLAttributeNode[],
223
- inlineNodes: Node[] = [],
349
+ _attributes: HTMLAttributeNode[],
350
+ _inlineNodes: Node[] = [],
224
351
  allChildren: Node[] = [],
225
352
  isSelfClosing: boolean = false,
226
353
  isVoid: boolean = false,
@@ -230,7 +357,6 @@ export class Printer extends Visitor {
230
357
  this.push(indent + `<${tagName}`)
231
358
 
232
359
  this.withIndent(() => {
233
- // Render children in order, handling both attributes and ERB nodes
234
360
  allChildren.forEach(child => {
235
361
  if (child instanceof HTMLAttributeNode || (child as any).type === 'AST_HTML_ATTRIBUTE_NODE') {
236
362
  this.push(this.indent() + this.renderAttribute(child as HTMLAttributeNode))
@@ -310,6 +436,7 @@ export class Printer extends Visitor {
310
436
  const tagName = open.tag_name?.value ?? ""
311
437
  const indent = this.indent()
312
438
 
439
+ this.currentTagName = tagName
313
440
 
314
441
  const attributes = this.extractAttributes(open.children)
315
442
  const inlineNodes = this.extractInlineNodes(open.children)
@@ -498,11 +625,10 @@ export class Printer extends Visitor {
498
625
  this.maxLineLength,
499
626
  hasComplexERB,
500
627
  nestingDepth,
501
- inlineNodes.length
628
+ inlineNodes.length,
629
+ this.hasMultilineAttributes(attributes)
502
630
  )
503
631
 
504
-
505
-
506
632
  if (shouldKeepInline) {
507
633
  if (children.length === 0) {
508
634
  if (isSelfClosing) {
@@ -632,17 +758,32 @@ export class Printer extends Visitor {
632
758
  }
633
759
 
634
760
  if (isInlineElement && children.length === 0) {
635
- let result = `<${tagName}`
636
- result += this.renderAttributesString(attributes)
637
- if (isSelfClosing) {
638
- result += " />"
639
- } else if (node.is_void) {
640
- result += ">"
641
- } else {
642
- result += `></${tagName}>`
761
+ const inline = this.renderInlineOpen(tagName, attributes, isSelfClosing, inlineNodes, open.children)
762
+ const totalAttributeCount = this.getTotalAttributeCount(attributes, inlineNodes)
763
+ const shouldKeepInline = this.shouldRenderInline(
764
+ totalAttributeCount,
765
+ inline.length,
766
+ indent.length,
767
+ this.maxLineLength,
768
+ false,
769
+ 0,
770
+ inlineNodes.length,
771
+ this.hasMultilineAttributes(attributes)
772
+ )
773
+
774
+ if (shouldKeepInline) {
775
+ let result = `<${tagName}`
776
+ result += this.renderAttributesString(attributes)
777
+ if (isSelfClosing) {
778
+ result += " />"
779
+ } else if (node.is_void) {
780
+ result += ">"
781
+ } else {
782
+ result += `></${tagName}>`
783
+ }
784
+ this.push(indent + result)
785
+ return
643
786
  }
644
- this.push(indent + result)
645
- return
646
787
  }
647
788
 
648
789
  this.renderMultilineAttributes(tagName, attributes, inlineNodes, open.children, isSelfClosing, node.is_void, children.length > 0)
@@ -683,7 +824,8 @@ export class Printer extends Visitor {
683
824
  this.maxLineLength,
684
825
  false,
685
826
  0,
686
- inlineNodes.length
827
+ inlineNodes.length,
828
+ this.hasMultilineAttributes(attributes)
687
829
  )
688
830
 
689
831
  if (shouldKeepInline) {
@@ -711,7 +853,8 @@ export class Printer extends Visitor {
711
853
  this.maxLineLength,
712
854
  false,
713
855
  0,
714
- inlineNodes.length
856
+ inlineNodes.length,
857
+ this.hasMultilineAttributes(attributes)
715
858
  )
716
859
 
717
860
  if (shouldKeepInline) {
@@ -805,20 +948,25 @@ export class Printer extends Visitor {
805
948
  const indent = this.indent()
806
949
  const open = node.comment_start?.value ?? ""
807
950
  const close = node.comment_end?.value ?? ""
951
+
808
952
  let inner: string
809
953
 
810
- if (node.comment_start && node.comment_end) {
811
- // TODO: use .value
812
- const [_, startIndex] = node.comment_start.range.toArray()
813
- const [endIndex] = node.comment_end.range.toArray()
814
- const rawInner = this.source.slice(startIndex, endIndex)
815
- inner = ` ${rawInner.trim()} `
816
- } else {
954
+ if (node.children && node.children.length > 0) {
817
955
  inner = node.children.map(child => {
818
- const prevLines = this.lines.length
819
- this.visit(child)
820
- return this.lines.slice(prevLines).join("")
956
+ if (child instanceof HTMLTextNode || (child as any).type === 'AST_HTML_TEXT_NODE') {
957
+ return (child as HTMLTextNode).content
958
+ } else if (child instanceof LiteralNode || (child as any).type === 'AST_LITERAL_NODE') {
959
+ return (child as LiteralNode).content
960
+ } else {
961
+ const prevLines = this.lines.length
962
+ this.visit(child)
963
+ return this.lines.slice(prevLines).join("")
964
+ }
821
965
  }).join("")
966
+
967
+ inner = ` ${inner.trim()} `
968
+ } else {
969
+ inner = ""
822
970
  }
823
971
 
824
972
  this.push(indent + open + inner + close)
@@ -830,26 +978,28 @@ export class Printer extends Visitor {
830
978
  const close = node.tag_closing?.value ?? ""
831
979
  let inner: string
832
980
 
833
- if (node.tag_opening && node.tag_closing) {
834
- const [, openingEnd] = node.tag_opening.range.toArray()
835
- const [closingStart] = node.tag_closing.range.toArray()
836
- const rawInner = this.source.slice(openingEnd, closingStart)
981
+ if (node.content && node.content.value) {
982
+ const rawInner = node.content.value
837
983
  const lines = rawInner.split("\n")
984
+
838
985
  if (lines.length > 2) {
839
986
  const childIndent = indent + " ".repeat(this.indentWidth)
840
987
  const innerLines = lines.slice(1, -1).map(line => childIndent + line.trim())
988
+
841
989
  inner = "\n" + innerLines.join("\n") + "\n"
842
990
  } else {
843
991
  inner = ` ${rawInner.trim()} `
844
992
  }
993
+ } else if ((node as any).children) {
994
+ inner = (node as any).children.map((child: any) => {
995
+ const prevLines = this.lines.length
996
+
997
+ this.visit(child)
998
+
999
+ return this.lines.slice(prevLines).join("")
1000
+ }).join("")
845
1001
  } else {
846
- inner = (node as any).children
847
- .map((child: any) => {
848
- const prevLines = this.lines.length
849
- this.visit(child)
850
- return this.lines.slice(prevLines).join("")
851
- })
852
- .join("")
1002
+ inner = ""
853
1003
  }
854
1004
 
855
1005
  this.push(indent + open + inner + close)
@@ -858,22 +1008,30 @@ export class Printer extends Visitor {
858
1008
  visitHTMLDoctypeNode(node: HTMLDoctypeNode): void {
859
1009
  const indent = this.indent()
860
1010
  const open = node.tag_opening?.value ?? ""
861
- let innerDoctype: string
862
1011
 
863
- if (node.tag_opening && node.tag_closing) {
864
- // TODO: use .value
865
- const [, openingEnd] = node.tag_opening.range.toArray()
866
- const [closingStart] = node.tag_closing.range.toArray()
867
- innerDoctype = this.source.slice(openingEnd, closingStart)
868
- } else {
869
- innerDoctype = node.children
870
- .map(child =>
871
- child instanceof HTMLTextNode ? child.content : (() => { const prevLines = this.lines.length; this.visit(child); return this.lines.slice(prevLines).join("") })(),
872
- )
873
- .join("")
874
- }
1012
+ let innerDoctype = node.children.map(child => {
1013
+ if (child instanceof HTMLTextNode || (child as any).type === 'AST_HTML_TEXT_NODE') {
1014
+ return (child as HTMLTextNode).content
1015
+ } else if (child instanceof LiteralNode || (child as any).type === 'AST_LITERAL_NODE') {
1016
+ return (child as LiteralNode).content
1017
+ } else if (child instanceof ERBContentNode || (child as any).type === 'AST_ERB_CONTENT_NODE') {
1018
+ const erbNode = child as ERBContentNode
1019
+ const erbOpen = erbNode.tag_opening?.value ?? ""
1020
+ const erbContent = erbNode.content?.value ?? ""
1021
+ const erbClose = erbNode.tag_closing?.value ?? ""
1022
+
1023
+ return erbOpen + (erbContent ? ` ${erbContent.trim()} ` : "") + erbClose
1024
+ } else {
1025
+ const prevLines = this.lines.length
1026
+
1027
+ this.visit(child)
1028
+
1029
+ return this.lines.slice(prevLines).join("")
1030
+ }
1031
+ }).join("")
875
1032
 
876
1033
  const close = node.tag_closing?.value ?? ""
1034
+
877
1035
  this.push(indent + open + innerDoctype + close)
878
1036
  }
879
1037
 
@@ -1175,6 +1333,8 @@ export class Printer extends Visitor {
1175
1333
  currentLineContent += erbContent
1176
1334
 
1177
1335
  if ((indent.length + currentLineContent.length) > Math.max(this.maxLineLength, 120)) {
1336
+ this.lines = oldLines
1337
+ this.inlineMode = oldInlineMode
1178
1338
  this.visitTextFlowChildrenMultiline(children)
1179
1339
 
1180
1340
  return
@@ -1359,7 +1519,15 @@ export class Printer extends Visitor {
1359
1519
  close_quote = '"'
1360
1520
  }
1361
1521
 
1362
- value = open_quote + content + close_quote
1522
+ if (this.isFormattableAttribute(name, this.currentTagName)) {
1523
+ if (name === 'class') {
1524
+ value = this.formatClassAttribute(content, name, equals, open_quote, close_quote)
1525
+ } else {
1526
+ value = this.formatMultilineAttribute(content, name, equals, open_quote, close_quote)
1527
+ }
1528
+ } else {
1529
+ value = open_quote + content + close_quote
1530
+ }
1363
1531
  }
1364
1532
 
1365
1533
  return name + equals + value
@@ -1368,7 +1536,7 @@ export class Printer extends Visitor {
1368
1536
  /**
1369
1537
  * Try to render a complete element inline including opening tag, children, and closing tag
1370
1538
  */
1371
- private tryRenderInlineFull(node: HTMLElementNode, tagName: string, attributes: HTMLAttributeNode[], children: Node[]): string | null {
1539
+ private tryRenderInlineFull(_node: HTMLElementNode, tagName: string, attributes: HTMLAttributeNode[], children: Node[]): string | null {
1372
1540
  let result = `<${tagName}`
1373
1541
 
1374
1542
  result += this.renderAttributesString(attributes)