@herb-tools/formatter 0.5.0 → 0.6.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.
@@ -0,0 +1,249 @@
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 CONTENT_PRESERVING_ELEMENTS;
30
+ private static readonly SPACEABLE_CONTAINERS;
31
+ private static readonly TIGHT_GROUP_PARENTS;
32
+ private static readonly TIGHT_GROUP_CHILDREN;
33
+ private static readonly SPACING_THRESHOLD;
34
+ constructor(source: string, options: Required<FormatOptions>);
35
+ print(input: Node | ParseResult | Token): string;
36
+ /**
37
+ * Get the current element (top of stack)
38
+ */
39
+ private get currentElement();
40
+ /**
41
+ * Get the current tag name from the current element context
42
+ */
43
+ private get currentTagName();
44
+ /**
45
+ * Append text to the last line instead of creating a new line
46
+ */
47
+ private pushToLastLine;
48
+ /**
49
+ * Capture output from a callback into a separate lines array
50
+ * Useful for testing what output would be generated without affecting the main output
51
+ */
52
+ private capture;
53
+ /**
54
+ * Capture all nodes that would be visited during a callback
55
+ * Returns a flat list of all nodes without generating any output
56
+ */
57
+ private captureNodes;
58
+ /**
59
+ * @deprecated refactor to use @herb-tools/printer infrastructre (or rework printer use push and this.lines)
60
+ */
61
+ private push;
62
+ /**
63
+ * @deprecated refactor to use @herb-tools/printer infrastructre (or rework printer use push and this.lines)
64
+ */
65
+ private pushWithIndent;
66
+ private withIndent;
67
+ private get indent();
68
+ /**
69
+ * Format ERB content with proper spacing around the inner content.
70
+ * Returns empty string if content is empty, otherwise wraps content with single spaces.
71
+ */
72
+ private formatERBContent;
73
+ /**
74
+ * Count total attributes including those inside ERB conditionals
75
+ */
76
+ private getTotalAttributeCount;
77
+ /**
78
+ * Extract inline nodes (non-attribute, non-whitespace) from a list of nodes
79
+ */
80
+ private extractInlineNodes;
81
+ /**
82
+ * Determine if spacing should be added between sibling elements
83
+ *
84
+ * This implements the "rule of three" intelligent spacing system:
85
+ * - Adds spacing between 3 or more meaningful siblings
86
+ * - Respects semantic groupings (e.g., ul/li, nav/a stay tight)
87
+ * - Groups comments with following elements
88
+ * - Preserves user-added spacing
89
+ *
90
+ * @param parentElement - The parent element containing the siblings
91
+ * @param siblings - Array of all sibling nodes
92
+ * @param currentIndex - Index of the current node being evaluated
93
+ * @param hasExistingSpacing - Whether user-added spacing already exists
94
+ * @returns true if spacing should be added before the current element
95
+ */
96
+ private shouldAddSpacingBetweenSiblings;
97
+ /**
98
+ * Token list attributes that contain space-separated values and benefit from
99
+ * spacing around ERB content for readability
100
+ */
101
+ private static readonly TOKEN_LIST_ATTRIBUTES;
102
+ /**
103
+ * Check if we're currently processing a token list attribute that needs spacing
104
+ */
105
+ private isInTokenListAttribute;
106
+ /**
107
+ * Find the previous meaningful (non-whitespace) sibling
108
+ */
109
+ private findPreviousMeaningfulSibling;
110
+ /**
111
+ * Check if a node represents a block-level element
112
+ */
113
+ private isBlockLevelNode;
114
+ /**
115
+ * Render attributes as a space-separated string
116
+ */
117
+ private renderAttributesString;
118
+ /**
119
+ * Determine if a tag should be rendered inline based on attribute count and other factors
120
+ */
121
+ private shouldRenderInline;
122
+ private getAttributeName;
123
+ private wouldClassAttributeBeMultiline;
124
+ private getAttributeValue;
125
+ private hasMultilineAttributes;
126
+ private formatClassAttribute;
127
+ private isFormattableAttribute;
128
+ private formatMultilineAttribute;
129
+ private formatMultilineAttributeValue;
130
+ private breakTokensIntoLines;
131
+ /**
132
+ * Render multiline attributes for a tag
133
+ */
134
+ private renderMultilineAttributes;
135
+ /**
136
+ * Reconstruct the text representation of an ERB node
137
+ * @param withFormatting - if true, format the content; if false, preserve original
138
+ */
139
+ private reconstructERBNode;
140
+ /**
141
+ * Print an ERB tag (<% %> or <%= %>) with single spaces around inner content.
142
+ */
143
+ printERBNode(node: ERBNode): void;
144
+ visitDocumentNode(node: DocumentNode): void;
145
+ visitHTMLElementNode(node: HTMLElementNode): void;
146
+ visitHTMLElementBody(body: Node[], element: HTMLElementNode): void;
147
+ /**
148
+ * Visit element children with intelligent spacing logic
149
+ */
150
+ private visitElementChildren;
151
+ visitHTMLOpenTagNode(node: HTMLOpenTagNode): void;
152
+ visitHTMLCloseTagNode(node: HTMLCloseTagNode): void;
153
+ visitHTMLTextNode(node: HTMLTextNode): void;
154
+ visitHTMLAttributeNode(node: HTMLAttributeNode): void;
155
+ visitHTMLAttributeNameNode(node: HTMLAttributeNameNode): void;
156
+ visitHTMLAttributeValueNode(node: HTMLAttributeValueNode): void;
157
+ visitHTMLCommentNode(node: HTMLCommentNode): void;
158
+ visitERBCommentNode(node: ERBContentNode): void;
159
+ visitHTMLDoctypeNode(node: HTMLDoctypeNode): void;
160
+ visitXMLDeclarationNode(node: XMLDeclarationNode): void;
161
+ visitCDATANode(node: CDATANode): void;
162
+ visitERBContentNode(node: ERBContentNode): void;
163
+ visitERBEndNode(node: ERBEndNode): void;
164
+ visitERBYieldNode(node: ERBYieldNode): void;
165
+ visitERBInNode(node: ERBInNode): void;
166
+ visitERBCaseMatchNode(node: ERBCaseMatchNode): void;
167
+ visitERBBlockNode(node: ERBBlockNode): void;
168
+ visitERBIfNode(node: ERBIfNode): void;
169
+ visitERBElseNode(node: ERBElseNode): void;
170
+ visitERBWhenNode(node: ERBWhenNode): void;
171
+ visitERBCaseNode(node: ERBCaseNode): void;
172
+ visitERBBeginNode(node: ERBBeginNode): void;
173
+ visitERBWhileNode(node: ERBWhileNode): void;
174
+ visitERBUntilNode(node: ERBUntilNode): void;
175
+ visitERBForNode(node: ERBForNode): void;
176
+ visitERBRescueNode(node: ERBRescueNode): void;
177
+ visitERBEnsureNode(node: ERBEnsureNode): void;
178
+ visitERBUnlessNode(node: ERBUnlessNode): void;
179
+ /**
180
+ * Analyzes an HTMLElementNode and returns formatting decisions for all parts
181
+ */
182
+ private analyzeElementFormatting;
183
+ /**
184
+ * Determines if the open tag should be rendered inline
185
+ */
186
+ private shouldRenderOpenTagInline;
187
+ /**
188
+ * Determines if the element content should be rendered inline
189
+ */
190
+ private shouldRenderElementContentInline;
191
+ /**
192
+ * Determines if the close tag should be rendered inline (usually follows content decision)
193
+ */
194
+ private shouldRenderCloseTagInline;
195
+ private isNonWhitespaceNode;
196
+ /**
197
+ * Check if an element should be treated as inline based on its tag name
198
+ */
199
+ private isInlineElement;
200
+ /**
201
+ * Check if we're in a text flow context (parent contains mixed text and inline elements)
202
+ */
203
+ private visitTextFlowChildren;
204
+ private isInTextFlowContext;
205
+ private renderInlineOpen;
206
+ renderAttribute(attribute: HTMLAttributeNode): string;
207
+ /**
208
+ * Try to render a complete element inline including opening tag, children, and closing tag
209
+ */
210
+ private tryRenderInlineFull;
211
+ /**
212
+ * Try to render just the children inline (without tags)
213
+ */
214
+ private tryRenderChildrenInline;
215
+ /**
216
+ * Try to render children inline if they are simple enough.
217
+ * Returns the inline string if possible, null otherwise.
218
+ */
219
+ private tryRenderInline;
220
+ /**
221
+ * Check if children contain mixed text and inline elements (like "text<em>inline</em>text")
222
+ * or mixed ERB output and text (like "<%= value %> text")
223
+ * This indicates content that should be formatted inline even with structural newlines
224
+ */
225
+ private hasMixedTextAndInlineContent;
226
+ /**
227
+ * Check if children contain any text content with newlines
228
+ */
229
+ private hasMultilineTextContent;
230
+ /**
231
+ * Check if all nested elements in the children are inline elements
232
+ */
233
+ private areAllNestedElementsInline;
234
+ /**
235
+ * Check if element has complex ERB control flow
236
+ */
237
+ private hasComplexERBControlFlow;
238
+ /**
239
+ * Filter children to remove insignificant whitespace
240
+ */
241
+ private filterSignificantChildren;
242
+ /**
243
+ * Filter out empty text nodes and whitespace nodes
244
+ */
245
+ private filterEmptyNodes;
246
+ private renderElementInline;
247
+ private renderChildrenInline;
248
+ private isContentPreserving;
249
+ }
@@ -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.1",
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.1",
39
+ "@herb-tools/printer": "0.6.1"
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)