@herb-tools/printer 0.8.10 → 0.9.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.
Files changed (46) hide show
  1. package/dist/cli.js.map +1 -0
  2. package/dist/{src/erb-to-ruby-string-printer.js → erb-to-ruby-string-printer.js} +20 -22
  3. package/dist/erb-to-ruby-string-printer.js.map +1 -0
  4. package/dist/herb-print.js +55369 -15024
  5. package/dist/herb-print.js.map +1 -1
  6. package/dist/{src/identity-printer.js → identity-printer.js} +39 -0
  7. package/dist/identity-printer.js.map +1 -0
  8. package/dist/indent-printer.js +256 -0
  9. package/dist/indent-printer.js.map +1 -0
  10. package/dist/index.cjs +315 -22
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.js +316 -24
  13. package/dist/index.js.map +1 -1
  14. package/dist/print-context.js.map +1 -0
  15. package/dist/{src/printer.js → printer.js} +3 -1
  16. package/dist/printer.js.map +1 -0
  17. package/dist/types/identity-printer.d.ts +8 -0
  18. package/dist/types/indent-printer.d.ts +41 -0
  19. package/dist/types/index.d.ts +1 -0
  20. package/dist/types/printer.d.ts +1 -1
  21. package/package.json +2 -2
  22. package/src/erb-to-ruby-string-printer.ts +25 -27
  23. package/src/identity-printer.ts +52 -0
  24. package/src/indent-printer.ts +313 -0
  25. package/src/index.ts +1 -0
  26. package/src/printer.ts +6 -6
  27. package/dist/package.json +0 -50
  28. package/dist/src/cli.js.map +0 -1
  29. package/dist/src/erb-to-ruby-string-printer.js.map +0 -1
  30. package/dist/src/herb-print.js +0 -5
  31. package/dist/src/herb-print.js.map +0 -1
  32. package/dist/src/identity-printer.js.map +0 -1
  33. package/dist/src/index.js +0 -5
  34. package/dist/src/index.js.map +0 -1
  35. package/dist/src/print-context.js.map +0 -1
  36. package/dist/src/printer.js.map +0 -1
  37. package/dist/tsconfig.tsbuildinfo +0 -1
  38. package/dist/types/src/cli.d.ts +0 -6
  39. package/dist/types/src/erb-to-ruby-string-printer.d.ts +0 -42
  40. package/dist/types/src/herb-print.d.ts +0 -2
  41. package/dist/types/src/identity-printer.d.ts +0 -48
  42. package/dist/types/src/index.d.ts +0 -5
  43. package/dist/types/src/print-context.d.ts +0 -54
  44. package/dist/types/src/printer.d.ts +0 -39
  45. /package/dist/{src/cli.js → cli.js} +0 -0
  46. /package/dist/{src/print-context.js → print-context.js} +0 -0
@@ -0,0 +1,41 @@
1
+ import { IdentityPrinter } from "./identity-printer.js";
2
+ import type * as Nodes from "@herb-tools/core";
3
+ /**
4
+ * IndentPrinter - Re-indentation printer that preserves content but adjusts indentation
5
+ *
6
+ * Extends IdentityPrinter to preserve all content as-is while replacing
7
+ * leading whitespace on each line with the correct indentation based on
8
+ * the AST nesting depth.
9
+ */
10
+ export declare class IndentPrinter extends IdentityPrinter {
11
+ protected indentLevel: number;
12
+ protected indentWidth: number;
13
+ private pendingIndent;
14
+ constructor(indentWidth?: number);
15
+ protected get indent(): string;
16
+ protected write(content: string): void;
17
+ visitLiteralNode(node: Nodes.LiteralNode): void;
18
+ visitHTMLTextNode(node: Nodes.HTMLTextNode): void;
19
+ visitHTMLElementNode(node: Nodes.HTMLElementNode): void;
20
+ visitERBIfNode(node: Nodes.ERBIfNode): void;
21
+ visitERBElseNode(node: Nodes.ERBElseNode): void;
22
+ visitERBBlockNode(node: Nodes.ERBBlockNode): void;
23
+ visitERBCaseNode(node: Nodes.ERBCaseNode): void;
24
+ visitERBWhenNode(node: Nodes.ERBWhenNode): void;
25
+ visitERBWhileNode(node: Nodes.ERBWhileNode): void;
26
+ visitERBUntilNode(node: Nodes.ERBUntilNode): void;
27
+ visitERBForNode(node: Nodes.ERBForNode): void;
28
+ visitERBBeginNode(node: Nodes.ERBBeginNode): void;
29
+ visitERBRescueNode(node: Nodes.ERBRescueNode): void;
30
+ visitERBEnsureNode(node: Nodes.ERBEnsureNode): void;
31
+ visitERBUnlessNode(node: Nodes.ERBUnlessNode): void;
32
+ /**
33
+ * Write content, replacing leading whitespace on each line with the current indent.
34
+ *
35
+ * Uses a pendingIndent mechanism: when content ends with a newline followed by
36
+ * whitespace-only, sets pendingIndent=true instead of writing the indent immediately.
37
+ * The indent is then applied at the correct level when the next node writes content
38
+ * (via the overridden write() method).
39
+ */
40
+ protected writeWithIndent(content: string): void;
41
+ }
@@ -1,4 +1,5 @@
1
1
  export { IdentityPrinter } from "./identity-printer.js";
2
+ export { IndentPrinter } from "./indent-printer.js";
2
3
  export { ERBToRubyStringPrinter } from "./erb-to-ruby-string-printer.js";
3
4
  export { PrintContext } from "./print-context.js";
4
5
  export { Printer, DEFAULT_PRINT_OPTIONS } from "./printer.js";
@@ -35,5 +35,5 @@ export declare abstract class Printer extends Visitor {
35
35
  * @throws {Error} When node has parse errors and ignoreErrors is false
36
36
  */
37
37
  print(input: Token | Node | ParseResult | Node[] | undefined | null, options?: PrintOptions): string;
38
- protected write(content: string): void;
38
+ protected write(content: string | null | undefined): void;
39
39
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@herb-tools/printer",
3
- "version": "0.8.10",
3
+ "version": "0.9.1",
4
4
  "description": "AST printer infrastructure and lossless reconstruction tool for HTML+ERB templates",
5
5
  "license": "MIT",
6
6
  "homepage": "https://herb-tools.dev",
@@ -37,7 +37,7 @@
37
37
  "prepublishOnly": "yarn clean && yarn build && yarn test"
38
38
  },
39
39
  "dependencies": {
40
- "@herb-tools/core": "0.8.10",
40
+ "@herb-tools/core": "0.9.1",
41
41
  "tinyglobby": "^0.2.15"
42
42
  },
43
43
  "files": [
@@ -1,8 +1,8 @@
1
1
  import { IdentityPrinter } from "./identity-printer.js"
2
2
  import { PrintOptions, DEFAULT_PRINT_OPTIONS } from "./printer.js"
3
- import { isERBOutputNode, filterNodes, ERBContentNode, } from "@herb-tools/core"
3
+ import { isERBOutputNode, filterNodes, ERBContentNode, isERBIfNode, isERBUnlessNode, isERBElseNode, isHTMLTextNode } from "@herb-tools/core"
4
4
 
5
- import { HTMLTextNode, ERBIfNode, ERBElseNode, ERBUnlessNode, Node, HTMLAttributeValueNode } from "@herb-tools/core"
5
+ import { HTMLTextNode, ERBIfNode, ERBUnlessNode, Node, HTMLAttributeValueNode } from "@herb-tools/core"
6
6
 
7
7
  export interface ERBToRubyStringOptions extends PrintOptions {
8
8
  /**
@@ -34,8 +34,6 @@ export const DEFAULT_ERB_TO_RUBY_STRING_OPTIONS: ERBToRubyStringOptions = {
34
34
  * - `<% if logged_in? %>Welcome<% else %>Login<% end %>!` => `"#{logged_in? ? "Welcome" : "Login"}!"`
35
35
  */
36
36
  export class ERBToRubyStringPrinter extends IdentityPrinter {
37
-
38
- // TODO: cleanup `.type === "AST_*" checks`
39
37
  static print(node: Node, options: Partial<ERBToRubyStringOptions> = DEFAULT_ERB_TO_RUBY_STRING_OPTIONS): string {
40
38
  const erbNodes = filterNodes([node], ERBContentNode)
41
39
 
@@ -51,22 +49,22 @@ export class ERBToRubyStringPrinter extends IdentityPrinter {
51
49
  return (childErbNodes[0].content?.value || "").trim()
52
50
  }
53
51
 
54
- if (node.children.length === 1 && node.children[0].type === "AST_ERB_IF_NODE" && !options.forceQuotes) {
55
- const ifNode = node.children[0] as ERBIfNode
52
+ const firstChild = node.children[0]
53
+
54
+ if (node.children.length === 1 && isERBIfNode(firstChild) && !options.forceQuotes) {
56
55
  const printer = new ERBToRubyStringPrinter()
57
56
 
58
- if (printer.canConvertToTernary(ifNode)) {
59
- printer.convertToTernaryWithoutWrapper(ifNode)
57
+ if (printer.canConvertToTernary(firstChild)) {
58
+ printer.convertToTernaryWithoutWrapper(firstChild)
60
59
  return printer.context.getOutput()
61
60
  }
62
61
  }
63
62
 
64
- if (node.children.length === 1 && node.children[0].type === "AST_ERB_UNLESS_NODE" && !options.forceQuotes) {
65
- const unlessNode = node.children[0] as ERBUnlessNode
63
+ if (node.children.length === 1 && isERBUnlessNode(firstChild) && !options.forceQuotes) {
66
64
  const printer = new ERBToRubyStringPrinter()
67
65
 
68
- if (printer.canConvertUnlessToTernary(unlessNode)) {
69
- printer.convertUnlessToTernaryWithoutWrapper(unlessNode)
66
+ if (printer.canConvertUnlessToTernary(firstChild)) {
67
+ printer.convertUnlessToTernaryWithoutWrapper(firstChild)
70
68
  return printer.context.getOutput()
71
69
  }
72
70
  }
@@ -119,16 +117,16 @@ export class ERBToRubyStringPrinter extends IdentityPrinter {
119
117
  }
120
118
 
121
119
  private canConvertToTernary(node: ERBIfNode): boolean {
122
- if (node.subsequent && node.subsequent.type !== "AST_ERB_ELSE_NODE") {
120
+ if (node.subsequent && !isERBElseNode(node.subsequent)) {
123
121
  return false
124
122
  }
125
123
 
126
- const ifOnlyText = node.statements ? node.statements.every(statement => statement.type === "AST_HTML_TEXT_NODE") : true
124
+ const ifOnlyText = node.statements ? node.statements.every(isHTMLTextNode) : true
127
125
  if (!ifOnlyText) return false
128
126
 
129
- if (node.subsequent && node.subsequent.type === "AST_ERB_ELSE_NODE") {
130
- return (node.subsequent as ERBElseNode).statements
131
- ? (node.subsequent as ERBElseNode).statements.every(statement => statement.type === "AST_HTML_TEXT_NODE")
127
+ if (isERBElseNode(node.subsequent)) {
128
+ return node.subsequent.statements
129
+ ? node.subsequent.statements.every(isHTMLTextNode)
132
130
  : true
133
131
  }
134
132
 
@@ -165,8 +163,8 @@ export class ERBToRubyStringPrinter extends IdentityPrinter {
165
163
  this.context.write(" : ")
166
164
  this.context.write('"')
167
165
 
168
- if (node.subsequent && node.subsequent.type === "AST_ERB_ELSE_NODE" && (node.subsequent as ERBElseNode).statements) {
169
- (node.subsequent as ERBElseNode).statements.forEach(statement => this.visit(statement))
166
+ if (isERBElseNode(node.subsequent) && node.subsequent.statements) {
167
+ node.subsequent.statements.forEach(statement => this.visit(statement))
170
168
  }
171
169
 
172
170
  this.context.write('"')
@@ -174,7 +172,7 @@ export class ERBToRubyStringPrinter extends IdentityPrinter {
174
172
  }
175
173
 
176
174
  private convertToTernaryWithoutWrapper(node: ERBIfNode) {
177
- if (node.subsequent && node.subsequent.type !== "AST_ERB_ELSE_NODE") {
175
+ if (node.subsequent && !isERBElseNode(node.subsequent)) {
178
176
  return false
179
177
  }
180
178
 
@@ -205,21 +203,21 @@ export class ERBToRubyStringPrinter extends IdentityPrinter {
205
203
  this.context.write(" : ")
206
204
  this.context.write('"')
207
205
 
208
- if (node.subsequent && node.subsequent.type === "AST_ERB_ELSE_NODE" && (node.subsequent as ERBElseNode).statements) {
209
- (node.subsequent as ERBElseNode).statements.forEach(statement => this.visit(statement))
206
+ if (isERBElseNode(node.subsequent) && node.subsequent.statements) {
207
+ node.subsequent.statements.forEach(statement => this.visit(statement))
210
208
  }
211
209
 
212
210
  this.context.write('"')
213
211
  }
214
212
 
215
213
  private canConvertUnlessToTernary(node: ERBUnlessNode): boolean {
216
- const unlessOnlyText = node.statements ? node.statements.every(statement => statement.type === "AST_HTML_TEXT_NODE") : true
214
+ const unlessOnlyText = node.statements ? node.statements.every(isHTMLTextNode) : true
217
215
 
218
216
  if (!unlessOnlyText) return false
219
217
 
220
- if (node.else_clause && node.else_clause.type === "AST_ERB_ELSE_NODE") {
218
+ if (isERBElseNode(node.else_clause)) {
221
219
  return node.else_clause.statements
222
- ? node.else_clause.statements.every(statement => statement.type === "AST_HTML_TEXT_NODE")
220
+ ? node.else_clause.statements.every(isHTMLTextNode)
223
221
  : true
224
222
  }
225
223
 
@@ -260,7 +258,7 @@ export class ERBToRubyStringPrinter extends IdentityPrinter {
260
258
  this.context.write(" : ")
261
259
  this.context.write('"')
262
260
 
263
- if (node.else_clause && node.else_clause.type === "AST_ERB_ELSE_NODE") {
261
+ if (isERBElseNode(node.else_clause)) {
264
262
  node.else_clause.statements.forEach(statement => this.visit(statement))
265
263
  }
266
264
 
@@ -300,7 +298,7 @@ export class ERBToRubyStringPrinter extends IdentityPrinter {
300
298
  this.context.write(" : ")
301
299
  this.context.write('"')
302
300
 
303
- if (node.else_clause && node.else_clause.type === "AST_ERB_ELSE_NODE") {
301
+ if (isERBElseNode(node.else_clause)) {
304
302
  node.else_clause.statements.forEach(statement => this.visit(statement))
305
303
  }
306
304
 
@@ -72,6 +72,14 @@ export class IdentityPrinter extends Printer {
72
72
  }
73
73
  }
74
74
 
75
+ visitHTMLVirtualCloseTagNode(_node: Nodes.HTMLVirtualCloseTagNode): void {
76
+ // Virtual closing tags don't print anything (they are synthetic)
77
+ }
78
+
79
+ visitHTMLOmittedCloseTagNode(_node: Nodes.HTMLOmittedCloseTagNode): void {
80
+ // Omitted closing tags don't print anything
81
+ }
82
+
75
83
  visitHTMLElementNode(node: Nodes.HTMLElementNode): void {
76
84
  const tagName = node.tag_name?.value
77
85
 
@@ -96,6 +104,30 @@ export class IdentityPrinter extends Printer {
96
104
  }
97
105
  }
98
106
 
107
+ visitHTMLConditionalElementNode(node: Nodes.HTMLConditionalElementNode): void {
108
+ const tagName = node.tag_name?.value
109
+
110
+ if (tagName) {
111
+ this.context.enterTag(tagName)
112
+ }
113
+
114
+ if (node.open_conditional) {
115
+ this.visit(node.open_conditional)
116
+ }
117
+
118
+ if (node.body) {
119
+ node.body.forEach(child => this.visit(child))
120
+ }
121
+
122
+ if (node.close_conditional) {
123
+ this.visit(node.close_conditional)
124
+ }
125
+
126
+ if (tagName) {
127
+ this.context.exitTag()
128
+ }
129
+ }
130
+
99
131
  visitHTMLAttributeNode(node: Nodes.HTMLAttributeNode): void {
100
132
  if (node.name) {
101
133
  this.visit(node.name)
@@ -126,6 +158,14 @@ export class IdentityPrinter extends Printer {
126
158
  }
127
159
  }
128
160
 
161
+ visitRubyLiteralNode(node: Nodes.RubyLiteralNode): void {
162
+ this.write(node.content)
163
+ }
164
+
165
+ visitRubyHTMLAttributesSplatNode(node: Nodes.RubyHTMLAttributesSplatNode): void {
166
+ this.write(node.content)
167
+ }
168
+
129
169
  visitHTMLCommentNode(node: Nodes.HTMLCommentNode): void {
130
170
  if (node.comment_start) {
131
171
  this.write(node.comment_start.value)
@@ -174,6 +214,10 @@ export class IdentityPrinter extends Printer {
174
214
  }
175
215
  }
176
216
 
217
+ visitERBOpenTagNode(node: Nodes.ERBOpenTagNode): void {
218
+ this.printERBNode(node)
219
+ }
220
+
177
221
  visitERBContentNode(node: Nodes.ERBContentNode): void {
178
222
  this.printERBNode(node)
179
223
  }
@@ -342,6 +386,14 @@ export class IdentityPrinter extends Printer {
342
386
  }
343
387
  }
344
388
 
389
+ visitERBRenderNode(node: Nodes.ERBRenderNode): void {
390
+ this.printERBNode(node)
391
+ }
392
+
393
+ visitRubyRenderLocalNode(_node: Nodes.RubyRenderLocalNode): void {
394
+ // extracted metadata, nothing to print
395
+ }
396
+
345
397
  visitERBYieldNode(node: Nodes.ERBYieldNode): void {
346
398
  this.printERBNode(node)
347
399
  }
@@ -0,0 +1,313 @@
1
+ import { IdentityPrinter } from "./identity-printer.js"
2
+
3
+ import type * as Nodes from "@herb-tools/core"
4
+
5
+ /**
6
+ * IndentPrinter - Re-indentation printer that preserves content but adjusts indentation
7
+ *
8
+ * Extends IdentityPrinter to preserve all content as-is while replacing
9
+ * leading whitespace on each line with the correct indentation based on
10
+ * the AST nesting depth.
11
+ */
12
+ export class IndentPrinter extends IdentityPrinter {
13
+ protected indentLevel: number = 0
14
+ protected indentWidth: number
15
+ private pendingIndent: boolean = false
16
+
17
+ constructor(indentWidth: number = 2) {
18
+ super()
19
+
20
+ this.indentWidth = indentWidth
21
+ }
22
+
23
+ protected get indent(): string {
24
+ return " ".repeat(this.indentLevel * this.indentWidth)
25
+ }
26
+
27
+ protected write(content: string): void {
28
+ if (this.pendingIndent && content.length > 0) {
29
+ this.pendingIndent = false
30
+ this.context.write(this.indent + content)
31
+ } else {
32
+ this.context.write(content)
33
+ }
34
+ }
35
+
36
+ visitLiteralNode(node: Nodes.LiteralNode): void {
37
+ this.writeWithIndent(node.content)
38
+ }
39
+
40
+ visitHTMLTextNode(node: Nodes.HTMLTextNode): void {
41
+ this.writeWithIndent(node.content)
42
+ }
43
+
44
+ visitHTMLElementNode(node: Nodes.HTMLElementNode): void {
45
+ const tagName = node.tag_name?.value
46
+
47
+ if (tagName) {
48
+ this.context.enterTag(tagName)
49
+ }
50
+
51
+ if (node.open_tag) {
52
+ this.visit(node.open_tag)
53
+ }
54
+
55
+ if (node.body) {
56
+ this.indentLevel++
57
+ node.body.forEach(child => this.visit(child))
58
+ this.indentLevel--
59
+ }
60
+
61
+ if (node.close_tag) {
62
+ this.visit(node.close_tag)
63
+ }
64
+
65
+ if (tagName) {
66
+ this.context.exitTag()
67
+ }
68
+ }
69
+
70
+ visitERBIfNode(node: Nodes.ERBIfNode): void {
71
+ this.printERBNode(node)
72
+
73
+ if (node.statements) {
74
+ this.indentLevel++
75
+ node.statements.forEach(statement => this.visit(statement))
76
+ this.indentLevel--
77
+ }
78
+
79
+ if (node.subsequent) {
80
+ this.visit(node.subsequent)
81
+ }
82
+
83
+ if (node.end_node) {
84
+ this.visit(node.end_node)
85
+ }
86
+ }
87
+
88
+ visitERBElseNode(node: Nodes.ERBElseNode): void {
89
+ this.printERBNode(node)
90
+
91
+ if (node.statements) {
92
+ this.indentLevel++
93
+ node.statements.forEach(statement => this.visit(statement))
94
+ this.indentLevel--
95
+ }
96
+ }
97
+
98
+ visitERBBlockNode(node: Nodes.ERBBlockNode): void {
99
+ this.printERBNode(node)
100
+
101
+ if (node.body) {
102
+ this.indentLevel++
103
+ node.body.forEach(child => this.visit(child))
104
+ this.indentLevel--
105
+ }
106
+
107
+ if (node.end_node) {
108
+ this.visit(node.end_node)
109
+ }
110
+ }
111
+
112
+ visitERBCaseNode(node: Nodes.ERBCaseNode): void {
113
+ this.printERBNode(node)
114
+
115
+ if (node.children) {
116
+ this.indentLevel++
117
+ node.children.forEach(child => this.visit(child))
118
+ this.indentLevel--
119
+ }
120
+
121
+ if (node.conditions) {
122
+ this.indentLevel++
123
+ node.conditions.forEach(condition => this.visit(condition))
124
+ this.indentLevel--
125
+ }
126
+
127
+ if (node.else_clause) {
128
+ this.indentLevel++
129
+ this.visit(node.else_clause)
130
+ this.indentLevel--
131
+ }
132
+
133
+ if (node.end_node) {
134
+ this.visit(node.end_node)
135
+ }
136
+ }
137
+
138
+ visitERBWhenNode(node: Nodes.ERBWhenNode): void {
139
+ this.printERBNode(node)
140
+
141
+ if (node.statements) {
142
+ this.indentLevel++
143
+ node.statements.forEach(statement => this.visit(statement))
144
+ this.indentLevel--
145
+ }
146
+ }
147
+
148
+ visitERBWhileNode(node: Nodes.ERBWhileNode): void {
149
+ this.printERBNode(node)
150
+
151
+ if (node.statements) {
152
+ this.indentLevel++
153
+ node.statements.forEach(statement => this.visit(statement))
154
+ this.indentLevel--
155
+ }
156
+
157
+ if (node.end_node) {
158
+ this.visit(node.end_node)
159
+ }
160
+ }
161
+
162
+ visitERBUntilNode(node: Nodes.ERBUntilNode): void {
163
+ this.printERBNode(node)
164
+
165
+ if (node.statements) {
166
+ this.indentLevel++
167
+ node.statements.forEach(statement => this.visit(statement))
168
+ this.indentLevel--
169
+ }
170
+
171
+ if (node.end_node) {
172
+ this.visit(node.end_node)
173
+ }
174
+ }
175
+
176
+ visitERBForNode(node: Nodes.ERBForNode): void {
177
+ this.printERBNode(node)
178
+
179
+ if (node.statements) {
180
+ this.indentLevel++
181
+ node.statements.forEach(statement => this.visit(statement))
182
+ this.indentLevel--
183
+ }
184
+
185
+ if (node.end_node) {
186
+ this.visit(node.end_node)
187
+ }
188
+ }
189
+
190
+ visitERBBeginNode(node: Nodes.ERBBeginNode): void {
191
+ this.printERBNode(node)
192
+
193
+ if (node.statements) {
194
+ this.indentLevel++
195
+ node.statements.forEach(statement => this.visit(statement))
196
+ this.indentLevel--
197
+ }
198
+
199
+ if (node.rescue_clause) {
200
+ this.visit(node.rescue_clause)
201
+ }
202
+
203
+ if (node.else_clause) {
204
+ this.visit(node.else_clause)
205
+ }
206
+
207
+ if (node.ensure_clause) {
208
+ this.visit(node.ensure_clause)
209
+ }
210
+
211
+ if (node.end_node) {
212
+ this.visit(node.end_node)
213
+ }
214
+ }
215
+
216
+ visitERBRescueNode(node: Nodes.ERBRescueNode): void {
217
+ this.printERBNode(node)
218
+
219
+ if (node.statements) {
220
+ this.indentLevel++
221
+ node.statements.forEach(statement => this.visit(statement))
222
+ this.indentLevel--
223
+ }
224
+
225
+ if (node.subsequent) {
226
+ this.visit(node.subsequent)
227
+ }
228
+ }
229
+
230
+ visitERBEnsureNode(node: Nodes.ERBEnsureNode): void {
231
+ this.printERBNode(node)
232
+
233
+ if (node.statements) {
234
+ this.indentLevel++
235
+ node.statements.forEach(statement => this.visit(statement))
236
+ this.indentLevel--
237
+ }
238
+ }
239
+
240
+ visitERBUnlessNode(node: Nodes.ERBUnlessNode): void {
241
+ this.printERBNode(node)
242
+
243
+ if (node.statements) {
244
+ this.indentLevel++
245
+ node.statements.forEach(statement => this.visit(statement))
246
+ this.indentLevel--
247
+ }
248
+
249
+ if (node.else_clause) {
250
+ this.visit(node.else_clause)
251
+ }
252
+
253
+ if (node.end_node) {
254
+ this.visit(node.end_node)
255
+ }
256
+ }
257
+
258
+ /**
259
+ * Write content, replacing leading whitespace on each line with the current indent.
260
+ *
261
+ * Uses a pendingIndent mechanism: when content ends with a newline followed by
262
+ * whitespace-only, sets pendingIndent=true instead of writing the indent immediately.
263
+ * The indent is then applied at the correct level when the next node writes content
264
+ * (via the overridden write() method).
265
+ */
266
+ protected writeWithIndent(content: string): void {
267
+ if (!content.includes("\n")) {
268
+ if (this.pendingIndent) {
269
+ this.pendingIndent = false
270
+
271
+ const trimmed = content.replace(/^[ \t]+/, "")
272
+
273
+ if (trimmed.length > 0) {
274
+ this.context.write(this.indent + trimmed)
275
+ }
276
+ } else {
277
+ this.context.write(content)
278
+ }
279
+
280
+ return
281
+ }
282
+
283
+ const lines = content.split("\n")
284
+ const lastIndex = lines.length - 1
285
+
286
+ for (let i = 0; i < lines.length; i++) {
287
+ if (i > 0) {
288
+ this.context.write("\n")
289
+ }
290
+
291
+ const line = lines[i]
292
+ const trimmed = line.replace(/^[ \t]+/, "")
293
+
294
+ if (i === 0) {
295
+ if (this.pendingIndent) {
296
+ this.pendingIndent = false
297
+
298
+ if (trimmed.length > 0) {
299
+ this.context.write(this.indent + trimmed)
300
+ }
301
+ } else {
302
+ this.context.write(line)
303
+ }
304
+ } else if (i === lastIndex && trimmed.length === 0) {
305
+ this.pendingIndent = true
306
+ } else if (trimmed.length === 0) {
307
+ // Middle whitespace-only line: write nothing (newline already written above)
308
+ } else {
309
+ this.context.write(this.indent + trimmed)
310
+ }
311
+ }
312
+ }
313
+ }
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export { IdentityPrinter } from "./identity-printer.js"
2
+ export { IndentPrinter } from "./indent-printer.js"
2
3
  export { ERBToRubyStringPrinter } from "./erb-to-ruby-string-printer.js"
3
4
  export { PrintContext } from "./print-context.js"
4
5
  export { Printer, DEFAULT_PRINT_OPTIONS } from "./printer.js"
package/src/printer.ts CHANGED
@@ -1,8 +1,6 @@
1
1
  import { Node, Visitor, Token, ParseResult, isToken, isParseResult } from "@herb-tools/core"
2
2
  import { PrintContext } from "./print-context.js"
3
3
 
4
- import type { ERBNode } from "@herb-tools/core"
5
-
6
4
  /**
7
5
  * Options for controlling the printing behavior
8
6
  */
@@ -33,7 +31,7 @@ export abstract class Printer extends Visitor {
33
31
  * @returns The printed string representation of the input
34
32
  * @throws {Error} When node has parse errors and ignoreErrors is false
35
33
  */
36
- static print(input: Token | Node | ParseResult | Node[] | undefined | null, options: PrintOptions = DEFAULT_PRINT_OPTIONS): string {
34
+ static print(input: Token | Node | ParseResult | Node[] | undefined | null, options: PrintOptions = DEFAULT_PRINT_OPTIONS): string {
37
35
  const printer = new (this as any)()
38
36
 
39
37
  return printer.print(input, options)
@@ -47,7 +45,7 @@ export abstract class Printer extends Visitor {
47
45
  * @returns The printed string representation of the input
48
46
  * @throws {Error} When node has parse errors and ignoreErrors is false
49
47
  */
50
- print(input: Token | Node | ParseResult | Node[] | undefined | null, options: PrintOptions = DEFAULT_PRINT_OPTIONS): string {
48
+ print(input: Token | Node | ParseResult | Node[] | undefined | null, options: PrintOptions = DEFAULT_PRINT_OPTIONS): string {
51
49
  if (!input) return ""
52
50
 
53
51
  if (isToken(input)) {
@@ -73,7 +71,9 @@ export abstract class Printer extends Visitor {
73
71
  return this.context.getOutput()
74
72
  }
75
73
 
76
- protected write(content: string): void {
77
- this.context.write(content)
74
+ protected write(content: string | null | undefined): void {
75
+ if (content !== null && content !== undefined) {
76
+ this.context.write(content)
77
+ }
78
78
  }
79
79
  }