@herb-tools/formatter 0.4.0 → 0.4.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.
@@ -11,6 +11,7 @@ export declare class Printer extends Visitor {
11
11
  private source;
12
12
  private lines;
13
13
  private indentLevel;
14
+ private inlineMode;
14
15
  constructor(source: string, options: Required<FormatOptions>);
15
16
  print(object: Node | Token, indentLevel?: number): string;
16
17
  private push;
package/package.json CHANGED
@@ -1,7 +1,6 @@
1
1
  {
2
2
  "name": "@herb-tools/formatter",
3
- "version": "0.4.0",
4
- "type": "module",
3
+ "version": "0.4.1",
5
4
  "license": "MIT",
6
5
  "homepage": "https://herb-tools.dev",
7
6
  "bugs": "https://github.com/marcoroth/herb/issues/new?title=Package%20%60@herb-tools/formatter%60:%20",
@@ -15,7 +14,7 @@
15
14
  "require": "./dist/index.cjs",
16
15
  "types": "./dist/types/index.d.ts",
17
16
  "bin": {
18
- "herb-formatter": "bin/herb-formatter"
17
+ "herb-format": "bin/herb-format"
19
18
  },
20
19
  "scripts": {
21
20
  "build": "yarn clean && rollup -c rollup.config.mjs",
@@ -35,7 +34,8 @@
35
34
  }
36
35
  },
37
36
  "dependencies": {
38
- "@herb-tools/core": "0.4.0"
37
+ "@herb-tools/core": "0.4.1",
38
+ "glob": "^11.0.3"
39
39
  },
40
40
  "files": [
41
41
  "package.json",
package/src/cli.ts CHANGED
@@ -1,30 +1,45 @@
1
- import { readFileSync } from "fs"
1
+ import dedent from "dedent"
2
+ import { readFileSync, writeFileSync, statSync } from "fs"
3
+ import { glob } from "glob"
4
+ import { join, resolve } from "path"
5
+
2
6
  import { Herb } from "@herb-tools/node-wasm"
3
7
  import { Formatter } from "./formatter.js"
8
+
4
9
  import { name, version } from "../package.json"
5
10
 
11
+ const pluralize = (count: number, singular: string, plural: string = singular + 's'): string => {
12
+ return count === 1 ? singular : plural
13
+ }
14
+
6
15
  export class CLI {
7
- private usage = `
8
- Usage: herb-formatter [file] [options]
16
+ private usage = dedent`
17
+ Usage: herb-format [file|directory] [options]
9
18
 
10
- Arguments:
11
- file File to format (use '-' or omit for stdin)
19
+ Arguments:
20
+ file|directory File to format, directory to format all **/*.html.erb files within,
21
+ or '-' for stdin (omit to format all **/*.html.erb files in current directory)
12
22
 
13
- Options:
14
- -h, --help show help
15
- -v, --version show version
23
+ Options:
24
+ -c, --check check if files are formatted without modifying them
25
+ -h, --help show help
26
+ -v, --version show version
16
27
 
17
- Examples:
18
- herb-formatter templates/index.html.erb
19
- cat template.html.erb | herb-formatter
20
- herb-formatter - < template.html.erb
21
- `
28
+ Examples:
29
+ herb-format # Format all **/*.html.erb files in current directory
30
+ herb-format --check # Check if all **/*.html.erb files are formatted
31
+ herb-format templates/index.html.erb # Format and write single file
32
+ herb-format --check templates/ # Check if all **/*.html.erb files in templates/ are formatted
33
+ cat template.html.erb | herb-format # Format from stdin to stdout
34
+ herb-format - < template.html.erb # Format from stdin to stdout
35
+ `
22
36
 
23
37
  async run() {
24
38
  const args = process.argv.slice(2)
25
39
 
26
40
  if (args.includes("--help") || args.includes("-h")) {
27
41
  console.log(this.usage)
42
+
28
43
  process.exit(0)
29
44
  }
30
45
 
@@ -34,26 +49,158 @@ export class CLI {
34
49
  if (args.includes("--version") || args.includes("-v")) {
35
50
  console.log("Versions:")
36
51
  console.log(` ${name}@${version}, ${Herb.version}`.split(", ").join("\n "))
52
+
37
53
  process.exit(0)
38
54
  }
39
55
 
40
- let source: string
56
+ console.log("⚠️ Experimental Preview: The formatter is in early development. Please report any unexpected behavior or bugs to https://github.com/marcoroth/herb/issues")
57
+ console.log()
58
+
59
+ const formatter = new Formatter(Herb)
60
+ const isCheckMode = args.includes("--check") || args.includes("-c")
41
61
 
42
- // Find the first non-flag argument (the file)
43
62
  const file = args.find(arg => !arg.startsWith("-"))
44
63
 
45
- // Read from file or stdin
46
- if (file && file !== "-") {
47
- source = readFileSync(file, "utf-8")
64
+ if (!file && !process.stdin.isTTY) {
65
+ if (isCheckMode) {
66
+ console.error("Error: --check mode is not supported with stdin")
67
+
68
+ process.exit(1)
69
+ }
70
+
71
+ const source = await this.readStdin()
72
+ const result = formatter.format(source)
73
+ const output = result.endsWith('\n') ? result : result + '\n'
74
+
75
+ process.stdout.write(output)
76
+ } else if (file === "-") {
77
+ if (isCheckMode) {
78
+ console.error("Error: --check mode is not supported with stdin")
79
+
80
+ process.exit(1)
81
+ }
82
+
83
+ const source = await this.readStdin()
84
+ const result = formatter.format(source)
85
+ const output = result.endsWith('\n') ? result : result + '\n'
86
+
87
+ process.stdout.write(output)
88
+ } else if (file) {
89
+ try {
90
+ const stats = statSync(file)
91
+
92
+ if (stats.isDirectory()) {
93
+ const pattern = join(file, "**/*.html.erb")
94
+ const files = await glob(pattern)
95
+
96
+ if (files.length === 0) {
97
+ console.log(`No files found matching pattern: ${resolve(pattern)}`)
98
+ process.exit(0)
99
+ }
100
+
101
+ let formattedCount = 0
102
+ let unformattedFiles: string[] = []
103
+
104
+ for (const filePath of files) {
105
+ try {
106
+ const source = readFileSync(filePath, "utf-8")
107
+ const result = formatter.format(source)
108
+ if (result !== source) {
109
+ if (isCheckMode) {
110
+ unformattedFiles.push(filePath)
111
+ } else {
112
+ writeFileSync(filePath, result, "utf-8")
113
+ console.log(`Formatted: ${filePath}`)
114
+ }
115
+ formattedCount++
116
+ }
117
+ } catch (error) {
118
+ console.error(`Error formatting ${filePath}:`, error)
119
+ }
120
+ }
121
+
122
+ if (isCheckMode) {
123
+ if (unformattedFiles.length > 0) {
124
+ console.log(`\nThe following ${pluralize(unformattedFiles.length, 'file is', 'files are')} not formatted:`)
125
+ unformattedFiles.forEach(file => console.log(` ${file}`))
126
+ console.log(`\nChecked ${files.length} ${pluralize(files.length, 'file')}, found ${unformattedFiles.length} unformatted ${pluralize(unformattedFiles.length, 'file')}`)
127
+ process.exit(1)
128
+ } else {
129
+ console.log(`\nChecked ${files.length} ${pluralize(files.length, 'file')}, all files are properly formatted`)
130
+ }
131
+ } else {
132
+ console.log(`\nChecked ${files.length} ${pluralize(files.length, 'file')}, formatted ${formattedCount} ${pluralize(formattedCount, 'file')}`)
133
+ }
134
+ } else {
135
+ const source = readFileSync(file, "utf-8")
136
+ const result = formatter.format(source)
137
+
138
+ if (result !== source) {
139
+ if (isCheckMode) {
140
+ console.log(`File is not formatted: ${file}`)
141
+ process.exit(1)
142
+ } else {
143
+ writeFileSync(file, result, "utf-8")
144
+ console.log(`Formatted: ${file}`)
145
+ }
146
+ } else if (isCheckMode) {
147
+ console.log(`File is properly formatted: ${file}`)
148
+ }
149
+ }
150
+
151
+ } catch (error) {
152
+ console.error(`Error: Cannot access '${file}':`, error)
153
+
154
+ process.exit(1)
155
+ }
48
156
  } else {
49
- source = await this.readStdin()
50
- }
157
+ const files = await glob("**/*.html.erb")
51
158
 
52
- const formatter = new Formatter(Herb)
53
- const result = formatter.format(source)
54
- process.stdout.write(result)
159
+ if (files.length === 0) {
160
+ console.log(`No files found matching pattern: ${resolve("**/*.html.erb")}`)
161
+
162
+ process.exit(0)
163
+ }
164
+
165
+ let formattedCount = 0
166
+ let unformattedFiles: string[] = []
167
+
168
+ for (const filePath of files) {
169
+ try {
170
+ const source = readFileSync(filePath, "utf-8")
171
+ const result = formatter.format(source)
172
+
173
+ if (result !== source) {
174
+ if (isCheckMode) {
175
+ unformattedFiles.push(filePath)
176
+ } else {
177
+ writeFileSync(filePath, result, "utf-8")
178
+ console.log(`Formatted: ${filePath}`)
179
+ }
180
+ formattedCount++
181
+ }
182
+ } catch (error) {
183
+ console.error(`Error formatting ${filePath}:`, error)
184
+ }
185
+ }
186
+
187
+ if (isCheckMode) {
188
+ if (unformattedFiles.length > 0) {
189
+ console.log(`\nThe following ${pluralize(unformattedFiles.length, 'file is', 'files are')} not formatted:`)
190
+ unformattedFiles.forEach(file => console.log(` ${file}`))
191
+ console.log(`\nChecked ${files.length} ${pluralize(files.length, 'file')}, found ${unformattedFiles.length} unformatted ${pluralize(unformattedFiles.length, 'file')}`)
192
+
193
+ process.exit(1)
194
+ } else {
195
+ console.log(`\nChecked ${files.length} ${pluralize(files.length, 'file')}, all files are properly formatted`)
196
+ }
197
+ } else {
198
+ console.log(`\nChecked ${files.length} ${pluralize(files.length, 'file')}, formatted ${formattedCount} ${pluralize(formattedCount, 'file')}`)
199
+ }
200
+ }
55
201
  } catch (error) {
56
202
  console.error(error)
203
+
57
204
  process.exit(1)
58
205
  }
59
206
  }
package/src/printer.ts CHANGED
@@ -67,6 +67,7 @@ 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
70
71
 
71
72
  constructor(source: string, options: Required<FormatOptions>) {
72
73
  super()
@@ -143,6 +144,11 @@ export class Printer extends Visitor {
143
144
  const attributes = open.children.filter((child): child is HTMLAttributeNode =>
144
145
  child instanceof HTMLAttributeNode || (child as any).type === 'AST_HTML_ATTRIBUTE_NODE'
145
146
  )
147
+ const inlineNodes = open.children.filter(child =>
148
+ !(child instanceof HTMLAttributeNode || (child as any).type === 'AST_HTML_ATTRIBUTE_NODE') &&
149
+ !(child instanceof WhitespaceNode || (child as any).type === 'AST_WHITESPACE_NODE')
150
+ )
151
+
146
152
  const children = node.body.filter(
147
153
  child =>
148
154
  !(child instanceof WhitespaceNode || (child as any).type === 'AST_WHITESPACE_NODE') &&
@@ -158,7 +164,7 @@ export class Printer extends Visitor {
158
164
  return
159
165
  }
160
166
 
161
- if (attributes.length === 0) {
167
+ if (attributes.length === 0 && inlineNodes.length === 0) {
162
168
  if (children.length === 0) {
163
169
  if (isSelfClosing) {
164
170
  this.push(indent + `<${tagName} />`)
@@ -167,6 +173,7 @@ export class Printer extends Visitor {
167
173
  } else {
168
174
  this.push(indent + `<${tagName}></${tagName}>`)
169
175
  }
176
+
170
177
  return
171
178
  }
172
179
 
@@ -183,16 +190,41 @@ export class Printer extends Visitor {
183
190
  return
184
191
  }
185
192
 
186
- const inline = this.renderInlineOpen(tagName, attributes, isSelfClosing)
193
+ if (attributes.length === 0 && inlineNodes.length > 0) {
194
+ const inline = this.renderInlineOpen(tagName, [], isSelfClosing, inlineNodes, open.children)
195
+
196
+ if (children.length === 0) {
197
+ if (isSelfClosing || node.is_void) {
198
+ this.push(indent + inline)
199
+ } else {
200
+ this.push(indent + inline + `</${tagName}>`)
201
+ }
202
+ return
203
+ }
204
+
205
+ this.push(indent + inline)
206
+ this.withIndent(() => {
207
+ children.forEach(child => this.visit(child))
208
+ })
209
+
210
+ if (!node.is_void && !isSelfClosing) {
211
+ this.push(indent + `</${tagName}>`)
212
+ }
213
+
214
+ return
215
+ }
216
+
217
+ const inline = this.renderInlineOpen(tagName, attributes, isSelfClosing, inlineNodes, open.children)
187
218
  const singleAttribute = attributes[0]
188
219
  const hasEmptyValue =
189
220
  singleAttribute &&
190
221
  (singleAttribute.value instanceof HTMLAttributeValueNode || (singleAttribute.value as any)?.type === 'AST_HTML_ATTRIBUTE_VALUE_NODE') &&
191
222
  (singleAttribute.value as any)?.children.length === 0
192
223
 
193
- const shouldKeepInline = attributes.length <= 3 &&
224
+ const shouldKeepInline = (attributes.length <= 3 &&
194
225
  !hasEmptyValue &&
195
- inline.length + indent.length <= this.maxLineLength
226
+ inline.length + indent.length <= this.maxLineLength) ||
227
+ inlineNodes.length > 0
196
228
 
197
229
  if (shouldKeepInline) {
198
230
  if (children.length === 0) {
@@ -223,27 +255,38 @@ export class Printer extends Visitor {
223
255
  return
224
256
  }
225
257
 
226
- this.push(indent + `<${tagName}`)
227
- this.withIndent(() => {
228
- attributes.forEach(attribute => {
229
- this.push(this.indent() + this.renderAttribute(attribute))
230
- })
231
- })
258
+ if (inlineNodes.length > 0) {
259
+ this.push(indent + this.renderInlineOpen(tagName, attributes, isSelfClosing, inlineNodes, open.children))
232
260
 
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}>`)
261
+ if (!isSelfClosing && !node.is_void && children.length > 0) {
262
+ this.withIndent(() => {
263
+ children.forEach(child => this.visit(child))
264
+ })
265
+ this.push(indent + `</${tagName}>`)
266
+ }
239
267
  } else {
240
- this.push(indent + ">")
241
-
268
+ this.push(indent + `<${tagName}`)
242
269
  this.withIndent(() => {
243
- children.forEach(child => this.visit(child))
270
+ attributes.forEach(attribute => {
271
+ this.push(this.indent() + this.renderAttribute(attribute))
272
+ })
244
273
  })
245
274
 
246
- this.push(indent + `</${tagName}>`)
275
+ if (isSelfClosing) {
276
+ this.push(indent + "/>")
277
+ } else if (node.is_void) {
278
+ this.push(indent + ">")
279
+ } else if (children.length === 0) {
280
+ this.push(indent + ">" + `</${tagName}>`)
281
+ } else {
282
+ this.push(indent + ">")
283
+
284
+ this.withIndent(() => {
285
+ children.forEach(child => this.visit(child))
286
+ })
287
+
288
+ this.push(indent + `</${tagName}>`)
289
+ }
247
290
  }
248
291
  }
249
292
 
@@ -492,18 +535,41 @@ export class Printer extends Visitor {
492
535
  }
493
536
 
494
537
  visitERBIfNode(node: ERBIfNode): void {
495
- this.printERBNode(node)
538
+ if (this.inlineMode) {
539
+ const open = node.tag_opening?.value ?? ""
540
+ const content = node.content?.value ?? ""
541
+ const close = node.tag_closing?.value ?? ""
542
+ this.lines.push(open + content + close)
543
+
544
+ node.statements.forEach(child => {
545
+ if (child instanceof HTMLAttributeNode || (child as any).type === 'AST_HTML_ATTRIBUTE_NODE') {
546
+ this.lines.push(" " + this.renderAttribute(child as HTMLAttributeNode) + " ")
547
+ } else {
548
+ this.visit(child)
549
+ }
550
+ })
496
551
 
497
- this.withIndent(() => {
498
- node.statements.forEach(child => this.visit(child))
499
- })
552
+ if (node.end_node) {
553
+ const endNode = node.end_node as any
554
+ const endOpen = endNode.tag_opening?.value ?? ""
555
+ const endContent = endNode.content?.value ?? ""
556
+ const endClose = endNode.tag_closing?.value ?? ""
557
+ this.lines.push(endOpen + endContent + endClose)
558
+ }
559
+ } else {
560
+ this.printERBNode(node)
500
561
 
501
- if (node.subsequent) {
502
- this.visit(node.subsequent)
503
- }
562
+ this.withIndent(() => {
563
+ node.statements.forEach(child => this.visit(child))
564
+ })
504
565
 
505
- if (node.end_node) {
506
- this.printERBNode(node.end_node as any)
566
+ if (node.subsequent) {
567
+ this.visit(node.subsequent)
568
+ }
569
+
570
+ if (node.end_node) {
571
+ this.printERBNode(node.end_node as any)
572
+ }
507
573
  }
508
574
  }
509
575
 
@@ -601,15 +667,73 @@ export class Printer extends Visitor {
601
667
 
602
668
  // --- Utility methods ---
603
669
 
604
- private renderInlineOpen(name: string, attributes: HTMLAttributeNode[], selfClose: boolean): string {
670
+ private renderInlineOpen(name: string, attributes: HTMLAttributeNode[], selfClose: boolean, inlineNodes: Node[] = [], allChildren: Node[] = []): string {
605
671
  const parts = attributes.map(attribute => this.renderAttribute(attribute))
606
672
 
673
+ if (inlineNodes.length > 0) {
674
+ let result = `<${name}`
675
+
676
+ if (allChildren.length > 0) {
677
+ const currentIndentLevel = this.indentLevel
678
+ this.indentLevel = 0
679
+ const tempLines = this.lines
680
+ this.lines = []
681
+
682
+ allChildren.forEach(child => {
683
+ if (child instanceof HTMLAttributeNode || (child as any).type === 'AST_HTML_ATTRIBUTE_NODE') {
684
+ this.lines.push(" " + this.renderAttribute(child as HTMLAttributeNode))
685
+ } else if (!(child instanceof WhitespaceNode || (child as any).type === 'AST_WHITESPACE_NODE')) {
686
+ const wasInlineMode = this.inlineMode
687
+ this.inlineMode = true
688
+
689
+ this.lines.push(" ")
690
+
691
+ this.visit(child)
692
+ this.inlineMode = wasInlineMode
693
+ }
694
+ })
695
+
696
+ const inlineContent = this.lines.join("")
697
+ this.lines = tempLines
698
+ this.indentLevel = currentIndentLevel
699
+
700
+ result += inlineContent
701
+ } else {
702
+ if (parts.length > 0) {
703
+ result += ` ${parts.join(" ")}`
704
+ }
705
+
706
+ const currentIndentLevel = this.indentLevel
707
+ this.indentLevel = 0
708
+ const tempLines = this.lines
709
+ this.lines = []
710
+
711
+ inlineNodes.forEach(node => {
712
+ const wasInlineMode = this.inlineMode
713
+ this.inlineMode = true
714
+ this.visit(node)
715
+ this.inlineMode = wasInlineMode
716
+ })
717
+
718
+ const inlineContent = this.lines.join("")
719
+ this.lines = tempLines
720
+ this.indentLevel = currentIndentLevel
721
+
722
+ result += inlineContent
723
+ }
724
+
725
+ result += selfClose ? " />" : ">"
726
+
727
+ return result
728
+ }
729
+
607
730
  return `<${name}${parts.length ? " " + parts.join(" ") : ""}${selfClose ? " /" : ""}>`
608
731
  }
609
732
 
610
733
  renderAttribute(attribute: HTMLAttributeNode): string {
611
734
  const name = (attribute.name as HTMLAttributeNameNode)!.name!.value ?? ""
612
735
  const equals = attribute.equals?.value ?? ""
736
+
613
737
  let value = ""
614
738
 
615
739
  if (attribute.value && (attribute.value instanceof HTMLAttributeValueNode || (attribute.value as any)?.type === 'AST_HTML_ATTRIBUTE_VALUE_NODE')) {
@@ -617,13 +741,15 @@ export class Printer extends Visitor {
617
741
  const open_quote = (attrValue.open_quote?.value ?? "")
618
742
  const close_quote = (attrValue.close_quote?.value ?? "")
619
743
  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') {
744
+ if (attr instanceof HTMLTextNode || (attr as any).type === 'AST_HTML_TEXT_NODE' || attr instanceof LiteralNode || (attr as any).type === 'AST_LITERAL_NODE') {
745
+
622
746
  return (attr as HTMLTextNode | LiteralNode).content
623
747
  } else if (attr instanceof ERBContentNode || (attr as any).type === 'AST_ERB_CONTENT_NODE') {
624
748
  const erbAttr = attr as ERBContentNode
749
+
625
750
  return (erbAttr.tag_opening!.value + erbAttr.content!.value + erbAttr.tag_closing!.value)
626
751
  }
752
+
627
753
  return ""
628
754
  }).join("")
629
755
 
@@ -1,3 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- import("../dist/herb-formatter.js")