@herb-tools/formatter 0.4.1 → 0.4.2

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.
@@ -12,6 +12,7 @@ export declare class Printer extends Visitor {
12
12
  private lines;
13
13
  private indentLevel;
14
14
  private inlineMode;
15
+ private isInComplexNesting;
15
16
  constructor(source: string, options: Required<FormatOptions>);
16
17
  print(object: Node | Token, indentLevel?: number): string;
17
18
  private push;
@@ -53,4 +54,17 @@ export declare class Printer extends Visitor {
53
54
  private visitERBGeneric;
54
55
  private renderInlineOpen;
55
56
  renderAttribute(attribute: HTMLAttributeNode): string;
57
+ /**
58
+ * Try to render children inline if they are simple enough.
59
+ * Returns the inline string if possible, null otherwise.
60
+ */
61
+ private tryRenderInline;
62
+ /**
63
+ * Calculate the maximum nesting depth in a subtree of nodes.
64
+ */
65
+ private getMaxNestingDepth;
66
+ /**
67
+ * Render an HTML element's content inline (without the wrapping tags).
68
+ */
69
+ private renderElementInline;
56
70
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@herb-tools/formatter",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "license": "MIT",
5
5
  "homepage": "https://herb-tools.dev",
6
6
  "bugs": "https://github.com/marcoroth/herb/issues/new?title=Package%20%60@herb-tools/formatter%60:%20",
@@ -34,7 +34,7 @@
34
34
  }
35
35
  },
36
36
  "dependencies": {
37
- "@herb-tools/core": "0.4.1",
37
+ "@herb-tools/core": "0.4.2",
38
38
  "glob": "^11.0.3"
39
39
  },
40
40
  "files": [
package/src/cli.ts CHANGED
@@ -105,13 +105,16 @@ export class CLI {
105
105
  try {
106
106
  const source = readFileSync(filePath, "utf-8")
107
107
  const result = formatter.format(source)
108
- if (result !== source) {
108
+ const output = result.endsWith('\n') ? result : result + '\n'
109
+
110
+ if (output !== source) {
109
111
  if (isCheckMode) {
110
112
  unformattedFiles.push(filePath)
111
113
  } else {
112
- writeFileSync(filePath, result, "utf-8")
114
+ writeFileSync(filePath, output, "utf-8")
113
115
  console.log(`Formatted: ${filePath}`)
114
116
  }
117
+
115
118
  formattedCount++
116
119
  }
117
120
  } catch (error) {
@@ -134,13 +137,14 @@ export class CLI {
134
137
  } else {
135
138
  const source = readFileSync(file, "utf-8")
136
139
  const result = formatter.format(source)
140
+ const output = result.endsWith('\n') ? result : result + '\n'
137
141
 
138
- if (result !== source) {
142
+ if (output !== source) {
139
143
  if (isCheckMode) {
140
144
  console.log(`File is not formatted: ${file}`)
141
145
  process.exit(1)
142
146
  } else {
143
- writeFileSync(file, result, "utf-8")
147
+ writeFileSync(file, output, "utf-8")
144
148
  console.log(`Formatted: ${file}`)
145
149
  }
146
150
  } else if (isCheckMode) {
@@ -169,12 +173,13 @@ export class CLI {
169
173
  try {
170
174
  const source = readFileSync(filePath, "utf-8")
171
175
  const result = formatter.format(source)
176
+ const output = result.endsWith('\n') ? result : result + '\n'
172
177
 
173
- if (result !== source) {
178
+ if (output !== source) {
174
179
  if (isCheckMode) {
175
180
  unformattedFiles.push(filePath)
176
181
  } else {
177
- writeFileSync(filePath, result, "utf-8")
182
+ writeFileSync(filePath, output, "utf-8")
178
183
  console.log(`Formatted: ${filePath}`)
179
184
  }
180
185
  formattedCount++
package/src/printer.ts CHANGED
@@ -68,6 +68,7 @@ export class Printer extends Visitor {
68
68
  private lines: string[] = []
69
69
  private indentLevel: number = 0
70
70
  private inlineMode: boolean = false
71
+ private isInComplexNesting: boolean = false
71
72
 
72
73
  constructor(source: string, options: Required<FormatOptions>) {
73
74
  super()
@@ -85,6 +86,7 @@ export class Printer extends Visitor {
85
86
 
86
87
  this.lines = []
87
88
  this.indentLevel = indentLevel
89
+ this.isInComplexNesting = false // Reset for each top-level element
88
90
 
89
91
  if (typeof (node as any).accept === 'function') {
90
92
  node.accept(this)
@@ -144,6 +146,7 @@ export class Printer extends Visitor {
144
146
  const attributes = open.children.filter((child): child is HTMLAttributeNode =>
145
147
  child instanceof HTMLAttributeNode || (child as any).type === 'AST_HTML_ATTRIBUTE_NODE'
146
148
  )
149
+
147
150
  const inlineNodes = open.children.filter(child =>
148
151
  !(child instanceof HTMLAttributeNode || (child as any).type === 'AST_HTML_ATTRIBUTE_NODE') &&
149
152
  !(child instanceof WhitespaceNode || (child as any).type === 'AST_WHITESPACE_NODE')
@@ -177,6 +180,31 @@ export class Printer extends Visitor {
177
180
  return
178
181
  }
179
182
 
183
+ if (children.length >= 1) {
184
+ if (this.isInComplexNesting) {
185
+ if (children.length === 1) {
186
+ const child = children[0]
187
+
188
+ if (child instanceof HTMLTextNode || (child as any).type === 'AST_HTML_TEXT_NODE') {
189
+ const textContent = (child as HTMLTextNode).content.trim()
190
+ const singleLine = `<${tagName}>${textContent}</${tagName}>`
191
+
192
+ if (!textContent.includes('\n') && (indent.length + singleLine.length) <= this.maxLineLength) {
193
+ this.push(indent + singleLine)
194
+ return
195
+ }
196
+ }
197
+ }
198
+ } else {
199
+ const inlineResult = this.tryRenderInline(children, tagName)
200
+
201
+ if (inlineResult && (indent.length + inlineResult.length) <= this.maxLineLength) {
202
+ this.push(indent + inlineResult)
203
+ return
204
+ }
205
+ }
206
+ }
207
+
180
208
  this.push(indent + `<${tagName}>`)
181
209
 
182
210
  this.withIndent(() => {
@@ -221,10 +249,18 @@ export class Printer extends Visitor {
221
249
  (singleAttribute.value instanceof HTMLAttributeValueNode || (singleAttribute.value as any)?.type === 'AST_HTML_ATTRIBUTE_VALUE_NODE') &&
222
250
  (singleAttribute.value as any)?.children.length === 0
223
251
 
252
+ const hasERBControlFlow = inlineNodes.some(node =>
253
+ node instanceof ERBIfNode || (node as any).type === 'AST_ERB_IF_NODE' ||
254
+ node instanceof ERBUnlessNode || (node as any).type === 'AST_ERB_UNLESS_NODE' ||
255
+ node instanceof ERBBlockNode || (node as any).type === 'AST_ERB_BLOCK_NODE' ||
256
+ node instanceof ERBCaseNode || (node as any).type === 'AST_ERB_CASE_NODE' ||
257
+ node instanceof ERBWhileNode || (node as any).type === 'AST_ERB_WHILE_NODE' ||
258
+ node instanceof ERBForNode || (node as any).type === 'AST_ERB_FOR_NODE'
259
+ )
260
+
224
261
  const shouldKeepInline = (attributes.length <= 3 &&
225
- !hasEmptyValue &&
226
262
  inline.length + indent.length <= this.maxLineLength) ||
227
- inlineNodes.length > 0
263
+ (inlineNodes.length > 0 && !hasERBControlFlow)
228
264
 
229
265
  if (shouldKeepInline) {
230
266
  if (children.length === 0) {
@@ -235,6 +271,7 @@ export class Printer extends Visitor {
235
271
  } else {
236
272
  this.push(indent + inline.replace('>', `></${tagName}>`))
237
273
  }
274
+
238
275
  return
239
276
  }
240
277
 
@@ -255,7 +292,32 @@ export class Printer extends Visitor {
255
292
  return
256
293
  }
257
294
 
258
- if (inlineNodes.length > 0) {
295
+ if (inlineNodes.length > 0 && hasERBControlFlow) {
296
+ this.push(indent + `<${tagName}`)
297
+ this.withIndent(() => {
298
+ open.children.forEach(child => {
299
+ if (child instanceof HTMLAttributeNode || (child as any).type === 'AST_HTML_ATTRIBUTE_NODE') {
300
+ this.push(this.indent() + this.renderAttribute(child as HTMLAttributeNode))
301
+ } else if (!(child instanceof WhitespaceNode || (child as any).type === 'AST_WHITESPACE_NODE')) {
302
+ this.visit(child)
303
+ }
304
+ })
305
+ })
306
+
307
+ if (isSelfClosing) {
308
+ this.push(indent + "/>")
309
+ } else if (node.is_void) {
310
+ this.push(indent + ">")
311
+ } else if (children.length === 0) {
312
+ this.push(indent + ">" + `</${tagName}>`)
313
+ } else {
314
+ this.push(indent + ">")
315
+ this.withIndent(() => {
316
+ children.forEach(child => this.visit(child))
317
+ })
318
+ this.push(indent + `</${tagName}>`)
319
+ }
320
+ } else if (inlineNodes.length > 0) {
259
321
  this.push(indent + this.renderInlineOpen(tagName, attributes, isSelfClosing, inlineNodes, open.children))
260
322
 
261
323
  if (!isSelfClosing && !node.is_void && children.length > 0) {
@@ -336,7 +398,6 @@ export class Printer extends Visitor {
336
398
  (singleAttribute.value as any)?.children.length === 0
337
399
 
338
400
  const shouldKeepInline = attributes.length <= 3 &&
339
- !hasEmptyValue &&
340
401
  inline.length + indent.length <= this.maxLineLength
341
402
 
342
403
  if (shouldKeepInline) {
@@ -539,21 +600,39 @@ export class Printer extends Visitor {
539
600
  const open = node.tag_opening?.value ?? ""
540
601
  const content = node.content?.value ?? ""
541
602
  const close = node.tag_closing?.value ?? ""
603
+
542
604
  this.lines.push(open + content + close)
543
605
 
544
- node.statements.forEach(child => {
606
+ if (node.statements.length > 0) {
607
+ this.lines.push(" ")
608
+ }
609
+
610
+ node.statements.forEach((child, index) => {
545
611
  if (child instanceof HTMLAttributeNode || (child as any).type === 'AST_HTML_ATTRIBUTE_NODE') {
546
- this.lines.push(" " + this.renderAttribute(child as HTMLAttributeNode) + " ")
612
+ this.lines.push(this.renderAttribute(child as HTMLAttributeNode))
547
613
  } else {
548
614
  this.visit(child)
549
615
  }
616
+
617
+ if (index < node.statements.length - 1) {
618
+ this.lines.push(" ")
619
+ }
550
620
  })
551
621
 
622
+ if (node.statements.length > 0) {
623
+ this.lines.push(" ")
624
+ }
625
+
626
+ if (node.subsequent) {
627
+ this.visit(node.subsequent)
628
+ }
629
+
552
630
  if (node.end_node) {
553
631
  const endNode = node.end_node as any
554
632
  const endOpen = endNode.tag_opening?.value ?? ""
555
633
  const endContent = endNode.content?.value ?? ""
556
634
  const endClose = endNode.tag_closing?.value ?? ""
635
+
557
636
  this.lines.push(endOpen + endContent + endClose)
558
637
  }
559
638
  } else {
@@ -758,4 +837,159 @@ export class Printer extends Visitor {
758
837
 
759
838
  return name + equals + value
760
839
  }
840
+
841
+ /**
842
+ * Try to render children inline if they are simple enough.
843
+ * Returns the inline string if possible, null otherwise.
844
+ */
845
+ private tryRenderInline(children: Node[], tagName: string, depth: number = 0): string | null {
846
+ if (children.length > 10) {
847
+ return null
848
+ }
849
+
850
+ const maxNestingDepth = this.getMaxNestingDepth(children, 0)
851
+
852
+ if (maxNestingDepth > 1) {
853
+ this.isInComplexNesting = true
854
+ return null
855
+ }
856
+
857
+ for (const child of children) {
858
+ if (child instanceof HTMLTextNode || (child as any).type === 'AST_HTML_TEXT_NODE') {
859
+ const textContent = (child as HTMLTextNode).content
860
+
861
+ if (textContent.includes('\n')) {
862
+ return null
863
+ }
864
+ } else if (child instanceof HTMLElementNode || (child as any).type === 'AST_HTML_ELEMENT_NODE') {
865
+ const element = child as HTMLElementNode
866
+ const openTag = element.open_tag as HTMLOpenTagNode
867
+
868
+ const attributes = openTag.children.filter((child): child is HTMLAttributeNode =>
869
+ child instanceof HTMLAttributeNode || (child as any).type === 'AST_HTML_ATTRIBUTE_NODE'
870
+ )
871
+
872
+ if (attributes.length > 0) {
873
+ return null
874
+ }
875
+
876
+ } else if (child instanceof ERBContentNode || (child as any).type === 'AST_ERB_CONTENT_NODE') {
877
+ // ERB content nodes are allowed in inline rendering
878
+ } else {
879
+ return null
880
+ }
881
+ }
882
+
883
+ const oldLines = this.lines
884
+ const oldInlineMode = this.inlineMode
885
+
886
+ try {
887
+ this.lines = []
888
+ this.inlineMode = true
889
+
890
+ let content = ''
891
+
892
+ for (const child of children) {
893
+ if (child instanceof HTMLTextNode || (child as any).type === 'AST_HTML_TEXT_NODE') {
894
+ content += (child as HTMLTextNode).content
895
+ } else if (child instanceof HTMLElementNode || (child as any).type === 'AST_HTML_ELEMENT_NODE') {
896
+ const element = child as HTMLElementNode
897
+ const openTag = element.open_tag as HTMLOpenTagNode
898
+ const childTagName = openTag?.tag_name?.value || ''
899
+
900
+ const attributes = openTag.children.filter((child): child is HTMLAttributeNode =>
901
+ child instanceof HTMLAttributeNode || (child as any).type === 'AST_HTML_ATTRIBUTE_NODE'
902
+ )
903
+
904
+ const attributesString = attributes.length > 0
905
+ ? ' ' + attributes.map(attr => this.renderAttribute(attr)).join(' ')
906
+ : ''
907
+
908
+ const elementContent = this.renderElementInline(element)
909
+
910
+ content += `<${childTagName}${attributesString}>${elementContent}</${childTagName}>`
911
+ } else if (child instanceof ERBContentNode || (child as any).type === 'AST_ERB_CONTENT_NODE') {
912
+ const erbNode = child as ERBContentNode
913
+ const open = erbNode.tag_opening?.value ?? ""
914
+ const erbContent = erbNode.content?.value ?? ""
915
+ const close = erbNode.tag_closing?.value ?? ""
916
+
917
+ content += `${open} ${erbContent.trim()} ${close}`
918
+ }
919
+ }
920
+
921
+ content = content.replace(/\s+/g, ' ').trim()
922
+
923
+ return `<${tagName}>${content}</${tagName}>`
924
+
925
+ } finally {
926
+ this.lines = oldLines
927
+ this.inlineMode = oldInlineMode
928
+ }
929
+ }
930
+
931
+ /**
932
+ * Calculate the maximum nesting depth in a subtree of nodes.
933
+ */
934
+ private getMaxNestingDepth(children: Node[], currentDepth: number): number {
935
+ let maxDepth = currentDepth
936
+
937
+ for (const child of children) {
938
+ if (child instanceof HTMLElementNode || (child as any).type === 'AST_HTML_ELEMENT_NODE') {
939
+ const element = child as HTMLElementNode
940
+ const elementChildren = element.body.filter(
941
+ child =>
942
+ !(child instanceof WhitespaceNode || (child as any).type === 'AST_WHITESPACE_NODE') &&
943
+ !((child instanceof HTMLTextNode || (child as any).type === 'AST_HTML_TEXT_NODE') && (child as any)?.content.trim() === ""),
944
+ )
945
+
946
+ const childDepth = this.getMaxNestingDepth(elementChildren, currentDepth + 1)
947
+ maxDepth = Math.max(maxDepth, childDepth)
948
+ }
949
+ }
950
+
951
+ return maxDepth
952
+ }
953
+
954
+ /**
955
+ * Render an HTML element's content inline (without the wrapping tags).
956
+ */
957
+ private renderElementInline(element: HTMLElementNode): string {
958
+ const children = element.body.filter(
959
+ child =>
960
+ !(child instanceof WhitespaceNode || (child as any).type === 'AST_WHITESPACE_NODE') &&
961
+ !((child instanceof HTMLTextNode || (child as any).type === 'AST_HTML_TEXT_NODE') && (child as any)?.content.trim() === ""),
962
+ )
963
+
964
+ let content = ''
965
+ for (const child of children) {
966
+ if (child instanceof HTMLTextNode || (child as any).type === 'AST_HTML_TEXT_NODE') {
967
+ content += (child as HTMLTextNode).content
968
+ } else if (child instanceof HTMLElementNode || (child as any).type === 'AST_HTML_ELEMENT_NODE') {
969
+ const childElement = child as HTMLElementNode
970
+ const openTag = childElement.open_tag as HTMLOpenTagNode
971
+ const childTagName = openTag?.tag_name?.value || ''
972
+
973
+ const attributes = openTag.children.filter((child): child is HTMLAttributeNode =>
974
+ child instanceof HTMLAttributeNode || (child as any).type === 'AST_HTML_ATTRIBUTE_NODE'
975
+ )
976
+
977
+ const attributesString = attributes.length > 0
978
+ ? ' ' + attributes.map(attr => this.renderAttribute(attr)).join(' ')
979
+ : ''
980
+
981
+ const childContent = this.renderElementInline(childElement)
982
+ content += `<${childTagName}${attributesString}>${childContent}</${childTagName}>`
983
+ } else if (child instanceof ERBContentNode || (child as any).type === 'AST_ERB_CONTENT_NODE') {
984
+ const erbNode = child as ERBContentNode
985
+ const open = erbNode.tag_opening?.value ?? ""
986
+ const erbContent = erbNode.content?.value ?? ""
987
+ const close = erbNode.tag_closing?.value ?? ""
988
+
989
+ content += `${open} ${erbContent.trim()} ${close}`
990
+ }
991
+ }
992
+
993
+ return content.replace(/\s+/g, ' ').trim()
994
+ }
761
995
  }