@herb-tools/formatter 0.4.0 → 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.
package/src/printer.ts CHANGED
@@ -67,6 +67,8 @@ export class Printer extends Visitor {
67
67
  private source: string
68
68
  private lines: string[] = []
69
69
  private indentLevel: number = 0
70
+ private inlineMode: boolean = false
71
+ private isInComplexNesting: boolean = false
70
72
 
71
73
  constructor(source: string, options: Required<FormatOptions>) {
72
74
  super()
@@ -84,6 +86,7 @@ export class Printer extends Visitor {
84
86
 
85
87
  this.lines = []
86
88
  this.indentLevel = indentLevel
89
+ this.isInComplexNesting = false // Reset for each top-level element
87
90
 
88
91
  if (typeof (node as any).accept === 'function') {
89
92
  node.accept(this)
@@ -143,6 +146,12 @@ export class Printer extends Visitor {
143
146
  const attributes = open.children.filter((child): child is HTMLAttributeNode =>
144
147
  child instanceof HTMLAttributeNode || (child as any).type === 'AST_HTML_ATTRIBUTE_NODE'
145
148
  )
149
+
150
+ const inlineNodes = open.children.filter(child =>
151
+ !(child instanceof HTMLAttributeNode || (child as any).type === 'AST_HTML_ATTRIBUTE_NODE') &&
152
+ !(child instanceof WhitespaceNode || (child as any).type === 'AST_WHITESPACE_NODE')
153
+ )
154
+
146
155
  const children = node.body.filter(
147
156
  child =>
148
157
  !(child instanceof WhitespaceNode || (child as any).type === 'AST_WHITESPACE_NODE') &&
@@ -158,7 +167,7 @@ export class Printer extends Visitor {
158
167
  return
159
168
  }
160
169
 
161
- if (attributes.length === 0) {
170
+ if (attributes.length === 0 && inlineNodes.length === 0) {
162
171
  if (children.length === 0) {
163
172
  if (isSelfClosing) {
164
173
  this.push(indent + `<${tagName} />`)
@@ -167,9 +176,35 @@ export class Printer extends Visitor {
167
176
  } else {
168
177
  this.push(indent + `<${tagName}></${tagName}>`)
169
178
  }
179
+
170
180
  return
171
181
  }
172
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
+
173
208
  this.push(indent + `<${tagName}>`)
174
209
 
175
210
  this.withIndent(() => {
@@ -183,16 +218,49 @@ export class Printer extends Visitor {
183
218
  return
184
219
  }
185
220
 
186
- const inline = this.renderInlineOpen(tagName, attributes, isSelfClosing)
221
+ if (attributes.length === 0 && inlineNodes.length > 0) {
222
+ const inline = this.renderInlineOpen(tagName, [], isSelfClosing, inlineNodes, open.children)
223
+
224
+ if (children.length === 0) {
225
+ if (isSelfClosing || node.is_void) {
226
+ this.push(indent + inline)
227
+ } else {
228
+ this.push(indent + inline + `</${tagName}>`)
229
+ }
230
+ return
231
+ }
232
+
233
+ this.push(indent + inline)
234
+ this.withIndent(() => {
235
+ children.forEach(child => this.visit(child))
236
+ })
237
+
238
+ if (!node.is_void && !isSelfClosing) {
239
+ this.push(indent + `</${tagName}>`)
240
+ }
241
+
242
+ return
243
+ }
244
+
245
+ const inline = this.renderInlineOpen(tagName, attributes, isSelfClosing, inlineNodes, open.children)
187
246
  const singleAttribute = attributes[0]
188
247
  const hasEmptyValue =
189
248
  singleAttribute &&
190
249
  (singleAttribute.value instanceof HTMLAttributeValueNode || (singleAttribute.value as any)?.type === 'AST_HTML_ATTRIBUTE_VALUE_NODE') &&
191
250
  (singleAttribute.value as any)?.children.length === 0
192
251
 
193
- const shouldKeepInline = attributes.length <= 3 &&
194
- !hasEmptyValue &&
195
- inline.length + indent.length <= this.maxLineLength
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
+
261
+ const shouldKeepInline = (attributes.length <= 3 &&
262
+ inline.length + indent.length <= this.maxLineLength) ||
263
+ (inlineNodes.length > 0 && !hasERBControlFlow)
196
264
 
197
265
  if (shouldKeepInline) {
198
266
  if (children.length === 0) {
@@ -203,6 +271,7 @@ export class Printer extends Visitor {
203
271
  } else {
204
272
  this.push(indent + inline.replace('>', `></${tagName}>`))
205
273
  }
274
+
206
275
  return
207
276
  }
208
277
 
@@ -223,27 +292,63 @@ export class Printer extends Visitor {
223
292
  return
224
293
  }
225
294
 
226
- this.push(indent + `<${tagName}`)
227
- this.withIndent(() => {
228
- attributes.forEach(attribute => {
229
- this.push(this.indent() + this.renderAttribute(attribute))
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
+ })
230
305
  })
231
- })
232
306
 
233
- if (isSelfClosing) {
234
- this.push(indent + "/>")
235
- } else if (node.is_void) {
236
- this.push(indent + ">")
237
- } else if (children.length === 0) {
238
- this.push(indent + ">" + `</${tagName}>`)
239
- } else {
240
- this.push(indent + ">")
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) {
321
+ this.push(indent + this.renderInlineOpen(tagName, attributes, isSelfClosing, inlineNodes, open.children))
241
322
 
323
+ if (!isSelfClosing && !node.is_void && children.length > 0) {
324
+ this.withIndent(() => {
325
+ children.forEach(child => this.visit(child))
326
+ })
327
+ this.push(indent + `</${tagName}>`)
328
+ }
329
+ } else {
330
+ this.push(indent + `<${tagName}`)
242
331
  this.withIndent(() => {
243
- children.forEach(child => this.visit(child))
332
+ attributes.forEach(attribute => {
333
+ this.push(this.indent() + this.renderAttribute(attribute))
334
+ })
244
335
  })
245
336
 
246
- this.push(indent + `</${tagName}>`)
337
+ if (isSelfClosing) {
338
+ this.push(indent + "/>")
339
+ } else if (node.is_void) {
340
+ this.push(indent + ">")
341
+ } else if (children.length === 0) {
342
+ this.push(indent + ">" + `</${tagName}>`)
343
+ } else {
344
+ this.push(indent + ">")
345
+
346
+ this.withIndent(() => {
347
+ children.forEach(child => this.visit(child))
348
+ })
349
+
350
+ this.push(indent + `</${tagName}>`)
351
+ }
247
352
  }
248
353
  }
249
354
 
@@ -293,7 +398,6 @@ export class Printer extends Visitor {
293
398
  (singleAttribute.value as any)?.children.length === 0
294
399
 
295
400
  const shouldKeepInline = attributes.length <= 3 &&
296
- !hasEmptyValue &&
297
401
  inline.length + indent.length <= this.maxLineLength
298
402
 
299
403
  if (shouldKeepInline) {
@@ -492,18 +596,59 @@ export class Printer extends Visitor {
492
596
  }
493
597
 
494
598
  visitERBIfNode(node: ERBIfNode): void {
495
- this.printERBNode(node)
599
+ if (this.inlineMode) {
600
+ const open = node.tag_opening?.value ?? ""
601
+ const content = node.content?.value ?? ""
602
+ const close = node.tag_closing?.value ?? ""
496
603
 
497
- this.withIndent(() => {
498
- node.statements.forEach(child => this.visit(child))
499
- })
604
+ this.lines.push(open + content + close)
500
605
 
501
- if (node.subsequent) {
502
- this.visit(node.subsequent)
503
- }
606
+ if (node.statements.length > 0) {
607
+ this.lines.push(" ")
608
+ }
504
609
 
505
- if (node.end_node) {
506
- this.printERBNode(node.end_node as any)
610
+ node.statements.forEach((child, index) => {
611
+ if (child instanceof HTMLAttributeNode || (child as any).type === 'AST_HTML_ATTRIBUTE_NODE') {
612
+ this.lines.push(this.renderAttribute(child as HTMLAttributeNode))
613
+ } else {
614
+ this.visit(child)
615
+ }
616
+
617
+ if (index < node.statements.length - 1) {
618
+ this.lines.push(" ")
619
+ }
620
+ })
621
+
622
+ if (node.statements.length > 0) {
623
+ this.lines.push(" ")
624
+ }
625
+
626
+ if (node.subsequent) {
627
+ this.visit(node.subsequent)
628
+ }
629
+
630
+ if (node.end_node) {
631
+ const endNode = node.end_node as any
632
+ const endOpen = endNode.tag_opening?.value ?? ""
633
+ const endContent = endNode.content?.value ?? ""
634
+ const endClose = endNode.tag_closing?.value ?? ""
635
+
636
+ this.lines.push(endOpen + endContent + endClose)
637
+ }
638
+ } else {
639
+ this.printERBNode(node)
640
+
641
+ this.withIndent(() => {
642
+ node.statements.forEach(child => this.visit(child))
643
+ })
644
+
645
+ if (node.subsequent) {
646
+ this.visit(node.subsequent)
647
+ }
648
+
649
+ if (node.end_node) {
650
+ this.printERBNode(node.end_node as any)
651
+ }
507
652
  }
508
653
  }
509
654
 
@@ -601,15 +746,73 @@ export class Printer extends Visitor {
601
746
 
602
747
  // --- Utility methods ---
603
748
 
604
- private renderInlineOpen(name: string, attributes: HTMLAttributeNode[], selfClose: boolean): string {
749
+ private renderInlineOpen(name: string, attributes: HTMLAttributeNode[], selfClose: boolean, inlineNodes: Node[] = [], allChildren: Node[] = []): string {
605
750
  const parts = attributes.map(attribute => this.renderAttribute(attribute))
606
751
 
752
+ if (inlineNodes.length > 0) {
753
+ let result = `<${name}`
754
+
755
+ if (allChildren.length > 0) {
756
+ const currentIndentLevel = this.indentLevel
757
+ this.indentLevel = 0
758
+ const tempLines = this.lines
759
+ this.lines = []
760
+
761
+ allChildren.forEach(child => {
762
+ if (child instanceof HTMLAttributeNode || (child as any).type === 'AST_HTML_ATTRIBUTE_NODE') {
763
+ this.lines.push(" " + this.renderAttribute(child as HTMLAttributeNode))
764
+ } else if (!(child instanceof WhitespaceNode || (child as any).type === 'AST_WHITESPACE_NODE')) {
765
+ const wasInlineMode = this.inlineMode
766
+ this.inlineMode = true
767
+
768
+ this.lines.push(" ")
769
+
770
+ this.visit(child)
771
+ this.inlineMode = wasInlineMode
772
+ }
773
+ })
774
+
775
+ const inlineContent = this.lines.join("")
776
+ this.lines = tempLines
777
+ this.indentLevel = currentIndentLevel
778
+
779
+ result += inlineContent
780
+ } else {
781
+ if (parts.length > 0) {
782
+ result += ` ${parts.join(" ")}`
783
+ }
784
+
785
+ const currentIndentLevel = this.indentLevel
786
+ this.indentLevel = 0
787
+ const tempLines = this.lines
788
+ this.lines = []
789
+
790
+ inlineNodes.forEach(node => {
791
+ const wasInlineMode = this.inlineMode
792
+ this.inlineMode = true
793
+ this.visit(node)
794
+ this.inlineMode = wasInlineMode
795
+ })
796
+
797
+ const inlineContent = this.lines.join("")
798
+ this.lines = tempLines
799
+ this.indentLevel = currentIndentLevel
800
+
801
+ result += inlineContent
802
+ }
803
+
804
+ result += selfClose ? " />" : ">"
805
+
806
+ return result
807
+ }
808
+
607
809
  return `<${name}${parts.length ? " " + parts.join(" ") : ""}${selfClose ? " /" : ""}>`
608
810
  }
609
811
 
610
812
  renderAttribute(attribute: HTMLAttributeNode): string {
611
813
  const name = (attribute.name as HTMLAttributeNameNode)!.name!.value ?? ""
612
814
  const equals = attribute.equals?.value ?? ""
815
+
613
816
  let value = ""
614
817
 
615
818
  if (attribute.value && (attribute.value instanceof HTMLAttributeValueNode || (attribute.value as any)?.type === 'AST_HTML_ATTRIBUTE_VALUE_NODE')) {
@@ -617,13 +820,15 @@ export class Printer extends Visitor {
617
820
  const open_quote = (attrValue.open_quote?.value ?? "")
618
821
  const close_quote = (attrValue.close_quote?.value ?? "")
619
822
  const attribute_value = attrValue.children.map((attr: any) => {
620
- if (attr instanceof HTMLTextNode || (attr as any).type === 'AST_HTML_TEXT_NODE' ||
621
- attr instanceof LiteralNode || (attr as any).type === 'AST_LITERAL_NODE') {
823
+ if (attr instanceof HTMLTextNode || (attr as any).type === 'AST_HTML_TEXT_NODE' || attr instanceof LiteralNode || (attr as any).type === 'AST_LITERAL_NODE') {
824
+
622
825
  return (attr as HTMLTextNode | LiteralNode).content
623
826
  } else if (attr instanceof ERBContentNode || (attr as any).type === 'AST_ERB_CONTENT_NODE') {
624
827
  const erbAttr = attr as ERBContentNode
828
+
625
829
  return (erbAttr.tag_opening!.value + erbAttr.content!.value + erbAttr.tag_closing!.value)
626
830
  }
831
+
627
832
  return ""
628
833
  }).join("")
629
834
 
@@ -632,4 +837,159 @@ export class Printer extends Visitor {
632
837
 
633
838
  return name + equals + value
634
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
+ }
635
995
  }
@@ -1,3 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- import("../dist/herb-formatter.js")