@herb-tools/formatter 0.5.0 → 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.
@@ -0,0 +1,243 @@
1
+ import { Printer } from "@herb-tools/printer";
2
+ import { ParseResult, Node, DocumentNode, HTMLOpenTagNode, HTMLCloseTagNode, HTMLElementNode, HTMLAttributeNode, HTMLAttributeValueNode, HTMLAttributeNameNode, HTMLTextNode, HTMLCommentNode, HTMLDoctypeNode, ERBContentNode, ERBBlockNode, ERBEndNode, ERBElseNode, ERBIfNode, ERBWhenNode, ERBCaseNode, ERBCaseMatchNode, ERBWhileNode, ERBUntilNode, ERBForNode, ERBRescueNode, ERBEnsureNode, ERBBeginNode, ERBUnlessNode, ERBYieldNode, ERBInNode, XMLDeclarationNode, CDATANode, Token } from "@herb-tools/core";
3
+ import type { ERBNode } from "@herb-tools/core";
4
+ import type { FormatOptions } from "./options.js";
5
+ /**
6
+ * Printer traverses the Herb AST using the Visitor pattern
7
+ * and emits a formatted string with proper indentation, line breaks, and attribute wrapping.
8
+ */
9
+ export declare class FormatPrinter extends Printer {
10
+ /**
11
+ * @deprecated integrate indentWidth into this.options and update FormatOptions to extend from @herb-tools/printer options
12
+ */
13
+ private indentWidth;
14
+ /**
15
+ * @deprecated integrate maxLineLength into this.options and update FormatOptions to extend from @herb-tools/printer options
16
+ */
17
+ private maxLineLength;
18
+ /**
19
+ * @deprecated refactor to use @herb-tools/printer infrastructre (or rework printer use push and this.lines)
20
+ */
21
+ private lines;
22
+ private indentLevel;
23
+ private inlineMode;
24
+ private currentAttributeName;
25
+ private elementStack;
26
+ private elementFormattingAnalysis;
27
+ source: string;
28
+ private static readonly INLINE_ELEMENTS;
29
+ private static readonly SPACEABLE_CONTAINERS;
30
+ private static readonly TIGHT_GROUP_PARENTS;
31
+ private static readonly TIGHT_GROUP_CHILDREN;
32
+ private static readonly SPACING_THRESHOLD;
33
+ constructor(source: string, options: Required<FormatOptions>);
34
+ print(input: Node | ParseResult | Token): string;
35
+ /**
36
+ * Get the current element (top of stack)
37
+ */
38
+ private get currentElement();
39
+ /**
40
+ * Get the current tag name from the current element context
41
+ */
42
+ private get currentTagName();
43
+ /**
44
+ * Append text to the last line instead of creating a new line
45
+ */
46
+ private pushToLastLine;
47
+ /**
48
+ * Capture output from a callback into a separate lines array
49
+ * Useful for testing what output would be generated without affecting the main output
50
+ */
51
+ private capture;
52
+ /**
53
+ * Capture all nodes that would be visited during a callback
54
+ * Returns a flat list of all nodes without generating any output
55
+ */
56
+ private captureNodes;
57
+ /**
58
+ * @deprecated refactor to use @herb-tools/printer infrastructre (or rework printer use push and this.lines)
59
+ */
60
+ private push;
61
+ private withIndent;
62
+ private get indent();
63
+ /**
64
+ * Format ERB content with proper spacing around the inner content.
65
+ * Returns empty string if content is empty, otherwise wraps content with single spaces.
66
+ */
67
+ private formatERBContent;
68
+ /**
69
+ * Count total attributes including those inside ERB conditionals
70
+ */
71
+ private getTotalAttributeCount;
72
+ /**
73
+ * Extract inline nodes (non-attribute, non-whitespace) from a list of nodes
74
+ */
75
+ private extractInlineNodes;
76
+ /**
77
+ * Determine if spacing should be added between sibling elements
78
+ *
79
+ * This implements the "rule of three" intelligent spacing system:
80
+ * - Adds spacing between 3 or more meaningful siblings
81
+ * - Respects semantic groupings (e.g., ul/li, nav/a stay tight)
82
+ * - Groups comments with following elements
83
+ * - Preserves user-added spacing
84
+ *
85
+ * @param parentElement - The parent element containing the siblings
86
+ * @param siblings - Array of all sibling nodes
87
+ * @param currentIndex - Index of the current node being evaluated
88
+ * @param hasExistingSpacing - Whether user-added spacing already exists
89
+ * @returns true if spacing should be added before the current element
90
+ */
91
+ private shouldAddSpacingBetweenSiblings;
92
+ /**
93
+ * Token list attributes that contain space-separated values and benefit from
94
+ * spacing around ERB content for readability
95
+ */
96
+ private static readonly TOKEN_LIST_ATTRIBUTES;
97
+ /**
98
+ * Check if we're currently processing a token list attribute that needs spacing
99
+ */
100
+ private isInTokenListAttribute;
101
+ /**
102
+ * Find the previous meaningful (non-whitespace) sibling
103
+ */
104
+ private findPreviousMeaningfulSibling;
105
+ /**
106
+ * Check if a node represents a block-level element
107
+ */
108
+ private isBlockLevelNode;
109
+ /**
110
+ * Render attributes as a space-separated string
111
+ */
112
+ private renderAttributesString;
113
+ /**
114
+ * Determine if a tag should be rendered inline based on attribute count and other factors
115
+ */
116
+ private shouldRenderInline;
117
+ private getAttributeName;
118
+ private wouldClassAttributeBeMultiline;
119
+ private getAttributeValue;
120
+ private hasMultilineAttributes;
121
+ private formatClassAttribute;
122
+ private isFormattableAttribute;
123
+ private formatMultilineAttribute;
124
+ private formatMultilineAttributeValue;
125
+ private breakTokensIntoLines;
126
+ /**
127
+ * Render multiline attributes for a tag
128
+ */
129
+ private renderMultilineAttributes;
130
+ /**
131
+ * Reconstruct the text representation of an ERB node
132
+ * @param withFormatting - if true, format the content; if false, preserve original
133
+ */
134
+ private reconstructERBNode;
135
+ /**
136
+ * Print an ERB tag (<% %> or <%= %>) with single spaces around inner content.
137
+ */
138
+ printERBNode(node: ERBNode): void;
139
+ visitDocumentNode(node: DocumentNode): void;
140
+ visitHTMLElementNode(node: HTMLElementNode): void;
141
+ visitHTMLElementBody(body: Node[], element: HTMLElementNode): void;
142
+ /**
143
+ * Visit element children with intelligent spacing logic
144
+ */
145
+ private visitElementChildren;
146
+ visitHTMLOpenTagNode(node: HTMLOpenTagNode): void;
147
+ visitHTMLCloseTagNode(node: HTMLCloseTagNode): void;
148
+ visitHTMLTextNode(node: HTMLTextNode): void;
149
+ visitHTMLAttributeNode(node: HTMLAttributeNode): void;
150
+ visitHTMLAttributeNameNode(node: HTMLAttributeNameNode): void;
151
+ visitHTMLAttributeValueNode(node: HTMLAttributeValueNode): void;
152
+ visitHTMLCommentNode(node: HTMLCommentNode): void;
153
+ visitERBCommentNode(node: ERBContentNode): void;
154
+ visitHTMLDoctypeNode(node: HTMLDoctypeNode): void;
155
+ visitXMLDeclarationNode(node: XMLDeclarationNode): void;
156
+ visitCDATANode(node: CDATANode): void;
157
+ visitERBContentNode(node: ERBContentNode): void;
158
+ visitERBEndNode(node: ERBEndNode): void;
159
+ visitERBYieldNode(node: ERBYieldNode): void;
160
+ visitERBInNode(node: ERBInNode): void;
161
+ visitERBCaseMatchNode(node: ERBCaseMatchNode): void;
162
+ visitERBBlockNode(node: ERBBlockNode): void;
163
+ visitERBIfNode(node: ERBIfNode): void;
164
+ visitERBElseNode(node: ERBElseNode): void;
165
+ visitERBWhenNode(node: ERBWhenNode): void;
166
+ visitERBCaseNode(node: ERBCaseNode): void;
167
+ visitERBBeginNode(node: ERBBeginNode): void;
168
+ visitERBWhileNode(node: ERBWhileNode): void;
169
+ visitERBUntilNode(node: ERBUntilNode): void;
170
+ visitERBForNode(node: ERBForNode): void;
171
+ visitERBRescueNode(node: ERBRescueNode): void;
172
+ visitERBEnsureNode(node: ERBEnsureNode): void;
173
+ visitERBUnlessNode(node: ERBUnlessNode): void;
174
+ /**
175
+ * Analyzes an HTMLElementNode and returns formatting decisions for all parts
176
+ */
177
+ private analyzeElementFormatting;
178
+ /**
179
+ * Determines if the open tag should be rendered inline
180
+ */
181
+ private shouldRenderOpenTagInline;
182
+ /**
183
+ * Determines if the element content should be rendered inline
184
+ */
185
+ private shouldRenderElementContentInline;
186
+ /**
187
+ * Determines if the close tag should be rendered inline (usually follows content decision)
188
+ */
189
+ private shouldRenderCloseTagInline;
190
+ private isNonWhitespaceNode;
191
+ /**
192
+ * Check if an element should be treated as inline based on its tag name
193
+ */
194
+ private isInlineElement;
195
+ /**
196
+ * Check if we're in a text flow context (parent contains mixed text and inline elements)
197
+ */
198
+ private visitTextFlowChildren;
199
+ private isInTextFlowContext;
200
+ private renderInlineOpen;
201
+ renderAttribute(attribute: HTMLAttributeNode): string;
202
+ /**
203
+ * Try to render a complete element inline including opening tag, children, and closing tag
204
+ */
205
+ private tryRenderInlineFull;
206
+ /**
207
+ * Try to render just the children inline (without tags)
208
+ */
209
+ private tryRenderChildrenInline;
210
+ /**
211
+ * Try to render children inline if they are simple enough.
212
+ * Returns the inline string if possible, null otherwise.
213
+ */
214
+ private tryRenderInline;
215
+ /**
216
+ * Check if children contain mixed text and inline elements (like "text<em>inline</em>text")
217
+ * or mixed ERB output and text (like "<%= value %> text")
218
+ * This indicates content that should be formatted inline even with structural newlines
219
+ */
220
+ private hasMixedTextAndInlineContent;
221
+ /**
222
+ * Check if children contain any text content with newlines
223
+ */
224
+ private hasMultilineTextContent;
225
+ /**
226
+ * Check if all nested elements in the children are inline elements
227
+ */
228
+ private areAllNestedElementsInline;
229
+ /**
230
+ * Check if element has complex ERB control flow
231
+ */
232
+ private hasComplexERBControlFlow;
233
+ /**
234
+ * Filter children to remove insignificant whitespace
235
+ */
236
+ private filterSignificantChildren;
237
+ /**
238
+ * Filter out empty text nodes and whitespace nodes
239
+ */
240
+ private filterEmptyNodes;
241
+ private renderElementInline;
242
+ private renderChildrenInline;
243
+ }
@@ -1,3 +1,4 @@
1
1
  export { Formatter } from "./formatter.js";
2
- export { defaultFormatOptions, resolveFormatOptions } from "./options.js";
2
+ export { FormatPrinter } from "./format-printer.js";
3
3
  export type { FormatOptions } from "./options.js";
4
+ export { defaultFormatOptions, resolveFormatOptions } from "./options.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@herb-tools/formatter",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Auto-formatter for HTML+ERB templates with intelligent indentation, line wrapping, and ERB-aware pretty-printing.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://herb-tools.dev",
@@ -35,7 +35,10 @@
35
35
  }
36
36
  },
37
37
  "dependencies": {
38
- "@herb-tools/core": "0.5.0",
38
+ "@herb-tools/core": "0.6.0",
39
+ "@herb-tools/printer": "0.6.0"
40
+ },
41
+ "devDependencies": {
39
42
  "glob": "^11.0.3"
40
43
  },
41
44
  "files": [
package/src/cli.ts CHANGED
@@ -14,11 +14,11 @@ const pluralize = (count: number, singular: string, plural: string = singular +
14
14
 
15
15
  export class CLI {
16
16
  private usage = dedent`
17
- Usage: herb-format [file|directory] [options]
17
+ Usage: herb-format [file|directory|glob-pattern] [options]
18
18
 
19
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)
20
+ file|directory|glob-pattern File to format, directory to format all **/*.html.erb files within,
21
+ glob pattern to match files, or '-' for stdin (omit to format all **/*.html.erb files in current directory)
22
22
 
23
23
  Options:
24
24
  -c, --check check if files are formatted without modifying them
@@ -27,8 +27,12 @@ export class CLI {
27
27
 
28
28
  Examples:
29
29
  herb-format # Format all **/*.html.erb files in current directory
30
- herb-format templates/ # Format and **/*.html.erb within the given directory
30
+ herb-format index.html.erb # Format and write single file
31
31
  herb-format templates/index.html.erb # Format and write single file
32
+ herb-format templates/ # Format and **/*.html.erb within the given directory
33
+ herb-format "templates/**/*.html.erb" # Format all .html.erb files in templates directory using glob pattern
34
+ herb-format "**/*.html.erb" # Format all .html.erb files using glob pattern
35
+ herb-format "**/*.xml.erb" # Format all .xml.erb files using glob pattern
32
36
  herb-format --check # Check if all **/*.html.erb files are formatted
33
37
  herb-format --check templates/ # Check if all **/*.html.erb files in templates/ are formatted
34
38
  cat template.html.erb | herb-format # Format from stdin to stdout
@@ -53,7 +57,7 @@ export class CLI {
53
57
  process.exit(0)
54
58
  }
55
59
 
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")
60
+ console.log("⚠️ Experimental Preview: The formatter is in early development. Please report any unexpected behavior or bugs to https://github.com/marcoroth/herb/issues/new?template=formatting-issue.md")
57
61
  console.log()
58
62
 
59
63
  const formatter = new Formatter(Herb)
@@ -86,17 +90,58 @@ export class CLI {
86
90
 
87
91
  process.stdout.write(output)
88
92
  } else if (file) {
93
+ let isDirectory = false
94
+ let isFile = false
95
+ let pattern = file
96
+
89
97
  try {
90
98
  const stats = statSync(file)
99
+ isDirectory = stats.isDirectory()
100
+ isFile = stats.isFile()
101
+ } catch {
102
+ // Not a file/directory, treat as glob pattern
103
+ }
91
104
 
92
- if (stats.isDirectory()) {
93
- const pattern = join(file, "**/*.html.erb")
94
- const files = await glob(pattern)
105
+ if (isDirectory) {
106
+ pattern = join(file, "**/*.html.erb")
107
+ } else if (isFile) {
108
+ const source = readFileSync(file, "utf-8")
109
+ const result = formatter.format(source)
110
+ const output = result.endsWith('\n') ? result : result + '\n'
95
111
 
96
- if (files.length === 0) {
97
- console.log(`No files found matching pattern: ${resolve(pattern)}`)
98
- process.exit(0)
112
+ if (output !== source) {
113
+ if (isCheckMode) {
114
+ console.log(`File is not formatted: ${file}`)
115
+ process.exit(1)
116
+ } else {
117
+ writeFileSync(file, output, "utf-8")
118
+ console.log(`Formatted: ${file}`)
99
119
  }
120
+ } else if (isCheckMode) {
121
+ console.log(`File is properly formatted: ${file}`)
122
+ }
123
+
124
+ process.exit(0)
125
+ }
126
+
127
+ try {
128
+ const files = await glob(pattern)
129
+
130
+ if (files.length === 0) {
131
+ try {
132
+ statSync(file)
133
+ } catch {
134
+ if (!file.includes('*') && !file.includes('?') && !file.includes('[') && !file.includes('{')) {
135
+ console.error(`Error: Cannot access '${file}': ENOENT: no such file or directory`)
136
+
137
+ process.exit(1)
138
+ }
139
+ }
140
+
141
+ console.log(`No files found matching pattern: ${resolve(pattern)}`)
142
+
143
+ process.exit(0)
144
+ }
100
145
 
101
146
  let formattedCount = 0
102
147
  let unformattedFiles: string[] = []
@@ -134,23 +179,6 @@ export class CLI {
134
179
  } else {
135
180
  console.log(`\nChecked ${files.length} ${pluralize(files.length, 'file')}, formatted ${formattedCount} ${pluralize(formattedCount, 'file')}`)
136
181
  }
137
- } else {
138
- const source = readFileSync(file, "utf-8")
139
- const result = formatter.format(source)
140
- const output = result.endsWith('\n') ? result : result + '\n'
141
-
142
- if (output !== source) {
143
- if (isCheckMode) {
144
- console.log(`File is not formatted: ${file}`)
145
- process.exit(1)
146
- } else {
147
- writeFileSync(file, output, "utf-8")
148
- console.log(`Formatted: ${file}`)
149
- }
150
- } else if (isCheckMode) {
151
- console.log(`File is properly formatted: ${file}`)
152
- }
153
- }
154
182
 
155
183
  } catch (error) {
156
184
  console.error(`Error: Cannot access '${file}':`, error)