Skip to content

Commit 10212ef

Browse files
authored
Herb Formatter (#192)
This pull request introduces a new package, `@herb-tools/formatter`, which provides utilities for formatting HTML+ERB templates. It includes the implementation of the formatter logic, adds a CLI to test the formatting using `bin/herb-formatter [file]`, implements the Formatter into the language-server for format-on-save support, and adds a new tab to the playground to play with the formatter in the browser-based playground. Note: the current state of this is not 100% ready yet, as it formats a lot of documents in a sub-optimal way. ![CleanShot 2025-06-29 at 18 23 56@2x](https://github.com/user-attachments/assets/2c04f652-605c-46fd-91c2-df0e102a1b4d) https://github.com/user-attachments/assets/e1c22310-38dd-4fd8-8702-3a1ca06c7659 ![CleanShot 2025-06-29 at 18 19 52@2x](https://github.com/user-attachments/assets/651f6a4f-719d-4f37-a15a-95b59e263ba2) ![CleanShot 2025-06-29 at 18 20 52@2x](https://github.com/user-attachments/assets/65999cba-ff72-4e41-bbf2-f1bfcedaf195) --------- Signed-off-by: Marco Roth <marco.roth@intergga.ch>
1 parent ce16dd7 commit 10212ef

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

51 files changed

+2220
-14
lines changed

javascript/packages/core/src/parse-result.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,16 +61,16 @@ export class ParseResult extends Result {
6161
* @returns `true` if there are errors, otherwise `false`.
6262
*/
6363
get failed(): boolean {
64-
// TODO: this should probably be recursive as noted in the Ruby version
65-
return this.errors.length > 0 || this.value.errors.length > 0
64+
// Consider errors on this result and recursively in the document tree
65+
return this.recursiveErrors().length > 0
6666
}
6767

6868
/**
6969
* Determines if the parsing was successful.
7070
* @returns `true` if there are no errors, otherwise `false`.
7171
*/
7272
get successful(): boolean {
73-
return this.errors.length === 0
73+
return !this.failed
7474
}
7575

7676
/**

javascript/packages/formatter/README.md

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,26 @@ bun add @herb-tools/formatter
2929
```
3030
:::
3131

32-
<!-- ### Usage -->
32+
### Usage
3333

34-
<!-- TODO -->
34+
35+
#### Format a file
36+
37+
```bash
38+
# relative path
39+
herb-formatter templates/index.html.erb
40+
41+
# absolute path
42+
herb-formatter /full/path/to/template.html.erb
43+
```
44+
45+
#### Format from stdin
46+
47+
```bash
48+
cat template.html.erb | herb-formatter
49+
# or explicitly use "-" for stdin
50+
herb-formatter - < template.html.erb
51+
```
3552

3653
<!-- #### Configuration Options -->
3754

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
#!/usr/bin/env node
2+
3+
import("../dist/herb-formatter.js")
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { Result, HerbError, HerbWarning } from "@herb-tools/core"
2+
3+
export class FormatResult extends Result {
4+
readonly value: string
5+
6+
constructor(
7+
value: string,
8+
source: string,
9+
warnings: HerbWarning[] = [],
10+
errors: HerbError[] = [],
11+
) {
12+
super(source, warnings, errors)
13+
this.value = value
14+
}
15+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
{
2+
"name": "@herb-tools/formatter",
3+
"version": "0.3.1",
4+
"type": "module",
5+
"license": "MIT",
6+
"homepage": "https://herb-tools.dev",
7+
"bugs": "https://github.com/marcoroth/herb/issues/new?title=Package%20%60@herb-tools/formatter%60:%20",
8+
"repository": {
9+
"type": "git",
10+
"url": "https://github.com/marcoroth/herb.git",
11+
"directory": "javascript/packages/formatter"
12+
},
13+
"main": "./dist/index.cjs",
14+
"module": "./dist/index.esm.js",
15+
"require": "./dist/index.cjs",
16+
"types": "./dist/types/index.d.ts",
17+
"bin": {
18+
"herb-formatter": "bin/herb-formatter"
19+
},
20+
"scripts": {
21+
"build": "yarn clean && rollup -c rollup.config.mjs",
22+
"dev": "rollup -c rollup.config.mjs -w",
23+
"clean": "rimraf dist",
24+
"test": "vitest run",
25+
"test:watch": "vitest --watch",
26+
"prepublishOnly": "yarn clean && yarn build && yarn test"
27+
},
28+
"exports": {
29+
"./package.json": "./package.json",
30+
".": {
31+
"types": "./dist/types/index.d.ts",
32+
"import": "./dist/index.esm.js",
33+
"require": "./dist/index.cjs",
34+
"default": "./dist/index.esm.js"
35+
}
36+
},
37+
"dependencies": {
38+
"@herb-tools/core": "0.3.1"
39+
},
40+
"files": [
41+
"package.json",
42+
"README.md",
43+
"dist/",
44+
"src/",
45+
"bin/"
46+
]
47+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import typescript from "@rollup/plugin-typescript"
2+
import { nodeResolve } from "@rollup/plugin-node-resolve"
3+
import json from "@rollup/plugin-json"
4+
5+
const external = [
6+
// ...
7+
]
8+
9+
export default [
10+
// CLI build
11+
{
12+
input: "src/herb-formatter.ts",
13+
output: {
14+
file: "dist/herb-formatter.js",
15+
format: "esm",
16+
sourcemap: true,
17+
},
18+
external,
19+
plugins: [
20+
nodeResolve(),
21+
json(),
22+
typescript({
23+
tsconfig: "./tsconfig.json",
24+
rootDir: "src/",
25+
module: "esnext",
26+
}),
27+
],
28+
},
29+
{
30+
input: "src/index.ts",
31+
output: {
32+
file: "dist/index.esm.js",
33+
format: "esm",
34+
sourcemap: true,
35+
},
36+
external,
37+
plugins: [
38+
nodeResolve(),
39+
json(),
40+
typescript({
41+
tsconfig: "./tsconfig.json",
42+
declaration: true,
43+
declarationDir: "./dist/types",
44+
rootDir: "src/",
45+
}),
46+
],
47+
},
48+
{
49+
input: "src/index.ts",
50+
output: {
51+
file: "dist/index.cjs",
52+
format: "cjs",
53+
sourcemap: true,
54+
},
55+
external,
56+
plugins: [
57+
nodeResolve(),
58+
json(),
59+
typescript({
60+
tsconfig: "./tsconfig.json",
61+
rootDir: "src/",
62+
}),
63+
],
64+
},
65+
]
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { readFileSync } from "fs"
2+
import { Herb } from "@herb-tools/node-wasm"
3+
import { Formatter } from "./formatter.js"
4+
import { name, version } from "../package.json"
5+
6+
export class CLI {
7+
private usage = `
8+
Usage: herb-formatter [file] [options]
9+
10+
Arguments:
11+
file File to format (use '-' or omit for stdin)
12+
13+
Options:
14+
-h, --help show help
15+
-v, --version show version
16+
17+
Examples:
18+
herb-formatter templates/index.html.erb
19+
cat template.html.erb | herb-formatter
20+
herb-formatter - < template.html.erb
21+
`
22+
23+
async run() {
24+
const args = process.argv.slice(2)
25+
26+
if (args.includes("--help") || args.includes("-h")) {
27+
console.log(this.usage)
28+
process.exit(0)
29+
}
30+
31+
try {
32+
await Herb.load()
33+
34+
if (args.includes("--version") || args.includes("-v")) {
35+
console.log("Versions:")
36+
console.log(` ${name}@${version}, ${Herb.version}`.split(", ").join("\n "))
37+
process.exit(0)
38+
}
39+
40+
let source: string
41+
42+
// Find the first non-flag argument (the file)
43+
const file = args.find(arg => !arg.startsWith("-"))
44+
45+
// Read from file or stdin
46+
if (file && file !== "-") {
47+
source = readFileSync(file, "utf-8")
48+
} else {
49+
source = await this.readStdin()
50+
}
51+
52+
const formatter = new Formatter(Herb)
53+
const result = formatter.format(source)
54+
process.stdout.write(result)
55+
} catch (error) {
56+
console.error(error)
57+
process.exit(1)
58+
}
59+
}
60+
61+
private async readStdin(): Promise<string> {
62+
const chunks: Buffer[] = []
63+
64+
for await (const chunk of process.stdin) {
65+
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk)
66+
}
67+
68+
return Buffer.concat(chunks).toString("utf8")
69+
}
70+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { Printer } from "./printer.js"
2+
import { resolveFormatOptions } from "./options.js"
3+
4+
import type { FormatOptions } from "./options.js"
5+
import type { HerbBackend, ParseResult } from "@herb-tools/core"
6+
7+
/**
8+
* Formatter uses a Herb Backend to parse the source and then
9+
* formats the resulting AST into a well-indented, wrapped string.
10+
*/
11+
export class Formatter {
12+
private herb: HerbBackend
13+
private options: Required<FormatOptions>
14+
15+
constructor(herb: HerbBackend, options: FormatOptions = {}) {
16+
this.herb = herb
17+
this.options = resolveFormatOptions(options)
18+
}
19+
20+
/**
21+
* Format a source string, optionally overriding format options per call.
22+
*/
23+
format(source: string, options: FormatOptions = {}): string {
24+
const result = this.parse(source)
25+
if (result.failed) return source
26+
27+
const resolvedOptions = resolveFormatOptions({ ...this.options, ...options })
28+
29+
return new Printer(source, resolvedOptions).print(result.value)
30+
}
31+
32+
private parse(source: string): ParseResult {
33+
this.herb.ensureBackend()
34+
return this.herb.parse(source)
35+
}
36+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
#!/usr/bin/env node
2+
3+
import { CLI } from "./cli.js"
4+
5+
const cli = new CLI()
6+
cli.run()
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export { Formatter } from "./formatter.js"
2+
export { defaultFormatOptions, resolveFormatOptions } from "./options.js"
3+
// export { CLI } from "./cli.js"
4+
5+
export type { FormatOptions } from "./options.js"

0 commit comments

Comments
 (0)