@herb-tools/printer 0.6.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.
- package/README.md +123 -0
- package/bin/herb-print +3 -0
- package/dist/cli.js +190 -0
- package/dist/cli.js.map +1 -0
- package/dist/herb-print.js +17200 -0
- package/dist/herb-print.js.map +1 -0
- package/dist/identity-printer.js +13 -0
- package/dist/identity-printer.js.map +1 -0
- package/dist/index.cjs +436 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.js +431 -0
- package/dist/index.js.map +1 -0
- package/dist/print-context.js +92 -0
- package/dist/print-context.js.map +1 -0
- package/dist/printer.js +325 -0
- package/dist/printer.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/dist/types/cli.d.ts +6 -0
- package/dist/types/herb-print.d.ts +2 -0
- package/dist/types/identity-printer.d.ts +12 -0
- package/dist/types/index.d.ts +4 -0
- package/dist/types/print-context.d.ts +54 -0
- package/dist/types/printer.d.ts +75 -0
- package/package.json +50 -0
- package/src/cli.ts +226 -0
- package/src/herb-print.ts +6 -0
- package/src/identity-printer.ts +14 -0
- package/src/index.ts +4 -0
- package/src/print-context.ts +104 -0
- package/src/printer.ts +438 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import dedent from "dedent"
|
|
4
|
+
|
|
5
|
+
import { readFileSync, writeFileSync } from "fs"
|
|
6
|
+
import { resolve } from "path"
|
|
7
|
+
import { glob } from "glob"
|
|
8
|
+
|
|
9
|
+
import { Herb } from "@herb-tools/node-wasm"
|
|
10
|
+
import { IdentityPrinter } from "./index.js"
|
|
11
|
+
|
|
12
|
+
interface CLIOptions {
|
|
13
|
+
input?: string
|
|
14
|
+
output?: string
|
|
15
|
+
verify?: boolean
|
|
16
|
+
stats?: boolean
|
|
17
|
+
help?: boolean
|
|
18
|
+
glob?: boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class CLI {
|
|
22
|
+
private parseArgs(args: string[]): CLIOptions {
|
|
23
|
+
const options: CLIOptions = {}
|
|
24
|
+
|
|
25
|
+
for (let i = 2; i < args.length; i++) {
|
|
26
|
+
const arg = args[i]
|
|
27
|
+
|
|
28
|
+
switch (arg) {
|
|
29
|
+
case '-i':
|
|
30
|
+
case '--input':
|
|
31
|
+
options.input = args[++i]
|
|
32
|
+
break
|
|
33
|
+
case '-o':
|
|
34
|
+
case '--output':
|
|
35
|
+
options.output = args[++i]
|
|
36
|
+
break
|
|
37
|
+
case '--verify':
|
|
38
|
+
options.verify = true
|
|
39
|
+
break
|
|
40
|
+
case '--stats':
|
|
41
|
+
options.stats = true
|
|
42
|
+
break
|
|
43
|
+
case '--glob':
|
|
44
|
+
options.glob = true
|
|
45
|
+
break
|
|
46
|
+
case '-h':
|
|
47
|
+
case '--help':
|
|
48
|
+
options.help = true
|
|
49
|
+
break
|
|
50
|
+
default:
|
|
51
|
+
if (!arg.startsWith('-') && !options.input) {
|
|
52
|
+
options.input = arg
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return options
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private showHelp() {
|
|
61
|
+
console.log(dedent`
|
|
62
|
+
herb-print - Print HTML+ERB AST back to source code
|
|
63
|
+
|
|
64
|
+
This tool parses HTML+ERB templates and prints them back, preserving the original
|
|
65
|
+
formatting as closely as possible. Useful for testing parser accuracy and as a
|
|
66
|
+
baseline for other transformations.
|
|
67
|
+
|
|
68
|
+
Usage:
|
|
69
|
+
herb-print [options] <input-file-or-pattern>
|
|
70
|
+
herb-print -i <input-file> -o <output-file>
|
|
71
|
+
|
|
72
|
+
Options:
|
|
73
|
+
-i, --input <file> Input file path
|
|
74
|
+
-o, --output <file> Output file path (defaults to stdout)
|
|
75
|
+
--verify Verify that output matches input exactly
|
|
76
|
+
--stats Show parsing and printing statistics
|
|
77
|
+
--glob Treat input as glob pattern (default: **/*.html.erb)
|
|
78
|
+
-h, --help Show this help message
|
|
79
|
+
|
|
80
|
+
Examples:
|
|
81
|
+
# Single file
|
|
82
|
+
herb-print input.html.erb > output.html.erb
|
|
83
|
+
herb-print -i input.html.erb -o output.html.erb --verify
|
|
84
|
+
herb-print input.html.erb --stats
|
|
85
|
+
|
|
86
|
+
# Glob patterns (batch verification)
|
|
87
|
+
herb-print --glob --verify # All .html.erb files
|
|
88
|
+
herb-print "app/views/**/*.html.erb" --glob --verify --stats
|
|
89
|
+
herb-print "*.erb" --glob --verify
|
|
90
|
+
herb-print "/path/to/templates" --glob --verify # Directory (auto-appends /**/*.html.erb)
|
|
91
|
+
herb-print "/path/to/templates/**/*.html.erb" --glob --verify
|
|
92
|
+
|
|
93
|
+
# The --verify flag is useful to test parser fidelity:
|
|
94
|
+
herb-print input.html.erb --verify
|
|
95
|
+
# Checks if parsing and printing results in identical content
|
|
96
|
+
`)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async run() {
|
|
100
|
+
const options = this.parseArgs(process.argv)
|
|
101
|
+
|
|
102
|
+
if (options.help || (!options.input && !options.glob)) {
|
|
103
|
+
this.showHelp()
|
|
104
|
+
process.exit(0)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
await Herb.load()
|
|
109
|
+
|
|
110
|
+
if (options.glob) {
|
|
111
|
+
const pattern = options.input || "**/*.html.erb"
|
|
112
|
+
const files = await glob(pattern)
|
|
113
|
+
|
|
114
|
+
if (files.length === 0) {
|
|
115
|
+
console.error(`No files found matching pattern: ${pattern}`)
|
|
116
|
+
process.exit(1)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let totalFiles = 0
|
|
120
|
+
let failedFiles = 0
|
|
121
|
+
let verificationFailures = 0
|
|
122
|
+
let totalBytes = 0
|
|
123
|
+
|
|
124
|
+
console.log(`Processing ${files.length} files...\n`)
|
|
125
|
+
|
|
126
|
+
for (const file of files) {
|
|
127
|
+
try {
|
|
128
|
+
const input = readFileSync(file, 'utf-8')
|
|
129
|
+
const parseResult = Herb.parse(input, { track_whitespace: true })
|
|
130
|
+
|
|
131
|
+
if (parseResult.errors.length > 0) {
|
|
132
|
+
console.error(`\x1b[31m✗\x1b[0m \x1b[1m${file}\x1b[0m: \x1b[1m\x1b[31mFailed\x1b[0m to parse`)
|
|
133
|
+
failedFiles++
|
|
134
|
+
continue
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const printer = new IdentityPrinter()
|
|
138
|
+
const output = printer.print(parseResult.value)
|
|
139
|
+
|
|
140
|
+
totalFiles++
|
|
141
|
+
totalBytes += input.length
|
|
142
|
+
|
|
143
|
+
if (options.verify) {
|
|
144
|
+
if (input === output) {
|
|
145
|
+
console.log(`\x1b[32m✓\x1b[0m \x1b[1m${file}\x1b[0m: \x1b[32mPerfect match\x1b[0m`)
|
|
146
|
+
} else {
|
|
147
|
+
console.error(`\x1b[31m✗\x1b[0m \x1b[1m${file}\x1b[0m: \x1b[1m\x1b[31mVerification failed\x1b[0m - differences detected`)
|
|
148
|
+
verificationFailures++
|
|
149
|
+
}
|
|
150
|
+
} else {
|
|
151
|
+
console.log(`\x1b[32m✓\x1b[0m \x1b[1m${file}\x1b[0m: \x1b[32mProcessed\x1b[0m`)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
} catch (error) {
|
|
155
|
+
console.error(`\x1b[31m✗\x1b[0m \x1b[1m${file}\x1b[0m: \x1b[1m\x1b[31mError\x1b[0m - ${error}`)
|
|
156
|
+
failedFiles++
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
console.log(`\nSummary:`)
|
|
161
|
+
console.log(` Files processed: ${totalFiles}`)
|
|
162
|
+
console.log(` Files failed: ${failedFiles}`)
|
|
163
|
+
|
|
164
|
+
if (options.verify) {
|
|
165
|
+
console.log(` Verifications: ${totalFiles - verificationFailures} passed, ${verificationFailures} failed`)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (options.stats) {
|
|
169
|
+
console.log(` Total bytes: ${totalBytes}`)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
process.exit(failedFiles > 0 || verificationFailures > 0 ? 1 : 0)
|
|
173
|
+
|
|
174
|
+
} else {
|
|
175
|
+
const inputPath = resolve(options.input!)
|
|
176
|
+
const input = readFileSync(inputPath, 'utf-8')
|
|
177
|
+
|
|
178
|
+
const parseResult = Herb.parse(input, { track_whitespace: true })
|
|
179
|
+
|
|
180
|
+
if (parseResult.errors.length > 0) {
|
|
181
|
+
console.error('Parse errors:', parseResult.errors.map(e => e.message).join(', '))
|
|
182
|
+
process.exit(1)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const printer = new IdentityPrinter()
|
|
186
|
+
const output = printer.print(parseResult.value)
|
|
187
|
+
|
|
188
|
+
if (options.output) {
|
|
189
|
+
const outputPath = resolve(options.output)
|
|
190
|
+
writeFileSync(outputPath, output, 'utf-8')
|
|
191
|
+
|
|
192
|
+
console.log(`Output written to: ${outputPath}`)
|
|
193
|
+
} else {
|
|
194
|
+
console.log(output)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (options.verify) {
|
|
198
|
+
if (input === output) {
|
|
199
|
+
console.error('\x1b[32m✓ Verification passed\x1b[0m - output matches input exactly')
|
|
200
|
+
} else {
|
|
201
|
+
console.error('\x1b[31m✗ Verification failed\x1b[0m - output differs from input')
|
|
202
|
+
process.exit(1)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (options.stats) {
|
|
207
|
+
const errors = parseResult.errors?.length || 0
|
|
208
|
+
const warnings = parseResult.warnings?.length || 0
|
|
209
|
+
|
|
210
|
+
console.error(dedent`
|
|
211
|
+
Printing Statistics:
|
|
212
|
+
Input size: ${input.length} bytes
|
|
213
|
+
Output size: ${output.length} bytes
|
|
214
|
+
Parse errors: ${errors}
|
|
215
|
+
Parse warnings: ${warnings}
|
|
216
|
+
Round-trip: ${input === output ? 'Perfect' : 'Differences detected'}
|
|
217
|
+
`)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
} catch (error) {
|
|
222
|
+
console.error('Error:', error instanceof Error ? error.message : error)
|
|
223
|
+
process.exit(1)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Printer } from "./printer.js"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* IdentityPrinter - Provides lossless reconstruction of the original source
|
|
5
|
+
*
|
|
6
|
+
* This printer aims to reconstruct the original input as faithfully as possible,
|
|
7
|
+
* preserving all whitespace, formatting, and structure. It's useful for:
|
|
8
|
+
* - Testing parser accuracy (input should equal output)
|
|
9
|
+
* - Baseline printing before applying transformations
|
|
10
|
+
* - Verifying AST round-trip fidelity
|
|
11
|
+
*/
|
|
12
|
+
export class IdentityPrinter extends Printer {
|
|
13
|
+
|
|
14
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
export class PrintContext {
|
|
2
|
+
private output: string = ""
|
|
3
|
+
private indentLevel: number = 0
|
|
4
|
+
private currentColumn: number = 0
|
|
5
|
+
private preserveStack: string[] = []
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Write text to the output
|
|
9
|
+
*/
|
|
10
|
+
write(text: string): void {
|
|
11
|
+
this.output += text
|
|
12
|
+
this.currentColumn += text.length
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Write text and update column tracking for newlines
|
|
17
|
+
*/
|
|
18
|
+
writeWithColumnTracking(text: string): void {
|
|
19
|
+
this.output += text
|
|
20
|
+
|
|
21
|
+
const lines = text.split('\n')
|
|
22
|
+
|
|
23
|
+
if (lines.length > 1) {
|
|
24
|
+
this.currentColumn = lines[lines.length - 1].length
|
|
25
|
+
} else {
|
|
26
|
+
this.currentColumn += text.length
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Increase indentation level
|
|
32
|
+
*/
|
|
33
|
+
indent(): void {
|
|
34
|
+
this.indentLevel++
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Decrease indentation level
|
|
39
|
+
*/
|
|
40
|
+
dedent(): void {
|
|
41
|
+
if (this.indentLevel > 0) {
|
|
42
|
+
this.indentLevel--
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Enter a tag that may preserve whitespace
|
|
48
|
+
*/
|
|
49
|
+
enterTag(tagName: string): void {
|
|
50
|
+
this.preserveStack.push(tagName.toLowerCase())
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Exit the current tag
|
|
55
|
+
*/
|
|
56
|
+
exitTag(): void {
|
|
57
|
+
this.preserveStack.pop()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Check if we're at the start of a line
|
|
62
|
+
*/
|
|
63
|
+
isAtStartOfLine(): boolean {
|
|
64
|
+
return this.currentColumn === 0
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Get current indentation level
|
|
69
|
+
*/
|
|
70
|
+
getCurrentIndentLevel(): number {
|
|
71
|
+
return this.indentLevel
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Get current column position
|
|
76
|
+
*/
|
|
77
|
+
getCurrentColumn(): number {
|
|
78
|
+
return this.currentColumn
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Get the current tag stack (for debugging)
|
|
83
|
+
*/
|
|
84
|
+
getTagStack(): string[] {
|
|
85
|
+
return [...this.preserveStack]
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Get the complete output string
|
|
90
|
+
*/
|
|
91
|
+
getOutput(): string {
|
|
92
|
+
return this.output
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Reset the context for reuse
|
|
97
|
+
*/
|
|
98
|
+
reset(): void {
|
|
99
|
+
this.output = ""
|
|
100
|
+
this.indentLevel = 0
|
|
101
|
+
this.currentColumn = 0
|
|
102
|
+
this.preserveStack = []
|
|
103
|
+
}
|
|
104
|
+
}
|