@herb-tools/formatter 0.7.5 → 0.8.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.
package/dist/index.esm.js CHANGED
@@ -10,6 +10,7 @@ function createDedent(options) {
10
10
  function dedent(strings, ...values) {
11
11
  const raw = typeof strings === "string" ? [strings] : strings.raw;
12
12
  const {
13
+ alignValues = false,
13
14
  escapeSpecialCharacters = Array.isArray(strings),
14
15
  trimWhitespace = true
15
16
  } = options;
@@ -24,8 +25,10 @@ function createDedent(options) {
24
25
  }
25
26
  result += next;
26
27
  if (i < values.length) {
28
+ const value = alignValues ? alignValue(values[i], result) : values[i];
29
+
27
30
  // eslint-disable-next-line @typescript-eslint/restrict-plus-operands
28
- result += values[i];
31
+ result += value;
29
32
  }
30
33
  }
31
34
 
@@ -65,11 +68,35 @@ function createDedent(options) {
65
68
  }
66
69
  }
67
70
 
71
+ /**
72
+ * Adjusts the indentation of a multi-line interpolated value to match the current line.
73
+ */
74
+ function alignValue(value, precedingText) {
75
+ if (typeof value !== "string" || !value.includes("\n")) {
76
+ return value;
77
+ }
78
+ const currentLine = precedingText.slice(precedingText.lastIndexOf("\n") + 1);
79
+ const indentMatch = currentLine.match(/^(\s+)/);
80
+ if (indentMatch) {
81
+ const indent = indentMatch[1];
82
+ return value.replace(/\n/g, `\n${indent}`);
83
+ }
84
+ return value;
85
+ }
86
+
68
87
  class Position {
69
88
  line;
70
89
  column;
71
- static from(position) {
72
- return new Position(position.line, position.column);
90
+ static from(positionOrLine, column) {
91
+ if (typeof positionOrLine === "number") {
92
+ return new Position(positionOrLine, column);
93
+ }
94
+ else {
95
+ return new Position(positionOrLine.line, positionOrLine.column);
96
+ }
97
+ }
98
+ static get zero() {
99
+ return new Position(0, 0);
73
100
  }
74
101
  constructor(line, column) {
75
102
  this.line = line;
@@ -95,10 +122,20 @@ class Position {
95
122
  class Location {
96
123
  start;
97
124
  end;
98
- static from(location) {
99
- const start = Position.from(location.start);
100
- const end = Position.from(location.end);
101
- return new Location(start, end);
125
+ static from(locationOrLine, column, endLine, endColumn) {
126
+ if (typeof locationOrLine === "number") {
127
+ const start = Position.from(locationOrLine, column);
128
+ const end = Position.from(endLine, endColumn);
129
+ return new Location(start, end);
130
+ }
131
+ else {
132
+ const start = Position.from(locationOrLine.start);
133
+ const end = Position.from(locationOrLine.end);
134
+ return new Location(start, end);
135
+ }
136
+ }
137
+ static get zero() {
138
+ return new Location(Position.zero, Position.zero);
102
139
  }
103
140
  constructor(start, end) {
104
141
  this.start = start;
@@ -130,8 +167,16 @@ class Location {
130
167
  class Range {
131
168
  start;
132
169
  end;
133
- static from(range) {
134
- return new Range(range[0], range[1]);
170
+ static from(rangeOrStart, end) {
171
+ if (typeof rangeOrStart === "number") {
172
+ return new Range(rangeOrStart, end);
173
+ }
174
+ else {
175
+ return new Range(rangeOrStart[0], rangeOrStart[1]);
176
+ }
177
+ }
178
+ static get zero() {
179
+ return new Range(0, 0);
135
180
  }
136
181
  constructor(start, end) {
137
182
  this.start = start;
@@ -196,7 +241,7 @@ class Token {
196
241
  }
197
242
 
198
243
  // NOTE: This file is generated by the templates/template.rb script and should not
199
- // be modified manually. See /Users/marcoroth/Development/herb-release-0.7.5/templates/javascript/packages/core/src/errors.ts.erb
244
+ // be modified manually. See /Users/marcoroth/Development/herb-release-0.8.1/templates/javascript/packages/core/src/errors.ts.erb
200
245
  class HerbError {
201
246
  type;
202
247
  message;
@@ -621,6 +666,84 @@ class RubyParseError extends HerbError {
621
666
  return output;
622
667
  }
623
668
  }
669
+ class ERBControlFlowScopeError extends HerbError {
670
+ keyword;
671
+ static from(data) {
672
+ return new ERBControlFlowScopeError({
673
+ type: data.type,
674
+ message: data.message,
675
+ location: Location.from(data.location),
676
+ keyword: data.keyword,
677
+ });
678
+ }
679
+ constructor(props) {
680
+ super(props.type, props.message, props.location);
681
+ this.keyword = props.keyword;
682
+ }
683
+ toJSON() {
684
+ return {
685
+ ...super.toJSON(),
686
+ type: "ERB_CONTROL_FLOW_SCOPE_ERROR",
687
+ keyword: this.keyword,
688
+ };
689
+ }
690
+ toMonacoDiagnostic() {
691
+ return {
692
+ line: this.location.start.line,
693
+ column: this.location.start.column,
694
+ endLine: this.location.end.line,
695
+ endColumn: this.location.end.column,
696
+ message: this.message,
697
+ severity: 'error'
698
+ };
699
+ }
700
+ treeInspect() {
701
+ let output = "";
702
+ output += `@ ERBControlFlowScopeError ${this.location.treeInspectWithLabel()}\n`;
703
+ output += `├── message: "${this.message}"\n`;
704
+ output += `└── keyword: ${JSON.stringify(this.keyword)}\n`;
705
+ return output;
706
+ }
707
+ }
708
+ class MissingERBEndTagError extends HerbError {
709
+ keyword;
710
+ static from(data) {
711
+ return new MissingERBEndTagError({
712
+ type: data.type,
713
+ message: data.message,
714
+ location: Location.from(data.location),
715
+ keyword: data.keyword,
716
+ });
717
+ }
718
+ constructor(props) {
719
+ super(props.type, props.message, props.location);
720
+ this.keyword = props.keyword;
721
+ }
722
+ toJSON() {
723
+ return {
724
+ ...super.toJSON(),
725
+ type: "MISSINGERB_END_TAG_ERROR",
726
+ keyword: this.keyword,
727
+ };
728
+ }
729
+ toMonacoDiagnostic() {
730
+ return {
731
+ line: this.location.start.line,
732
+ column: this.location.start.column,
733
+ endLine: this.location.end.line,
734
+ endColumn: this.location.end.column,
735
+ message: this.message,
736
+ severity: 'error'
737
+ };
738
+ }
739
+ treeInspect() {
740
+ let output = "";
741
+ output += `@ MissingERBEndTagError ${this.location.treeInspectWithLabel()}\n`;
742
+ output += `├── message: "${this.message}"\n`;
743
+ output += `└── keyword: ${JSON.stringify(this.keyword)}\n`;
744
+ return output;
745
+ }
746
+ }
624
747
  function fromSerializedError(error) {
625
748
  switch (error.type) {
626
749
  case "UNEXPECTED_ERROR": return UnexpectedError.from(error);
@@ -632,6 +755,8 @@ function fromSerializedError(error) {
632
755
  case "VOID_ELEMENT_CLOSING_TAG_ERROR": return VoidElementClosingTagError.from(error);
633
756
  case "UNCLOSED_ELEMENT_ERROR": return UnclosedElementError.from(error);
634
757
  case "RUBY_PARSE_ERROR": return RubyParseError.from(error);
758
+ case "ERB_CONTROL_FLOW_SCOPE_ERROR": return ERBControlFlowScopeError.from(error);
759
+ case "MISSINGERB_END_TAG_ERROR": return MissingERBEndTagError.from(error);
635
760
  default:
636
761
  throw new Error(`Unknown node type: ${error.type}`);
637
762
  }
@@ -646,7 +771,7 @@ function convertToUTF8(string) {
646
771
  }
647
772
 
648
773
  // NOTE: This file is generated by the templates/template.rb script and should not
649
- // be modified manually. See /Users/marcoroth/Development/herb-release-0.7.5/templates/javascript/packages/core/src/nodes.ts.erb
774
+ // be modified manually. See /Users/marcoroth/Development/herb-release-0.8.1/templates/javascript/packages/core/src/nodes.ts.erb
650
775
  class Node {
651
776
  type;
652
777
  location;
@@ -2880,7 +3005,7 @@ class ParseResult extends Result {
2880
3005
  }
2881
3006
 
2882
3007
  // NOTE: This file is generated by the templates/template.rb script and should not
2883
- // be modified manually. See /Users/marcoroth/Development/herb-release-0.7.5/templates/javascript/packages/core/src/node-type-guards.ts.erb
3008
+ // be modified manually. See /Users/marcoroth/Development/herb-release-0.8.1/templates/javascript/packages/core/src/node-type-guards.ts.erb
2884
3009
  /**
2885
3010
  * Type guard functions for AST nodes.
2886
3011
  * These functions provide type checking by combining both instanceof
@@ -3265,7 +3390,7 @@ function isParseResult(object) {
3265
3390
  * Checks if a node is an ERB output node (generates content: <%= %> or <%== %>)
3266
3391
  */
3267
3392
  function isERBOutputNode(node) {
3268
- if (!isNode(node, ERBContentNode))
3393
+ if (!isERBNode(node))
3269
3394
  return false;
3270
3395
  if (!node.tag_opening?.value)
3271
3396
  return false;
@@ -3367,7 +3492,7 @@ function getNodesAfterPosition(nodes, position, inclusive = true) {
3367
3492
  }
3368
3493
 
3369
3494
  // NOTE: This file is generated by the templates/template.rb script and should not
3370
- // be modified manually. See /Users/marcoroth/Development/herb-release-0.7.5/templates/javascript/packages/core/src/visitor.ts.erb
3495
+ // be modified manually. See /Users/marcoroth/Development/herb-release-0.8.1/templates/javascript/packages/core/src/visitor.ts.erb
3371
3496
  class Visitor {
3372
3497
  visit(node) {
3373
3498
  if (!node)
@@ -3683,6 +3808,11 @@ class Printer extends Visitor {
3683
3808
  * - Verifying AST round-trip fidelity
3684
3809
  */
3685
3810
  class IdentityPrinter extends Printer {
3811
+ static printERBNode(node) {
3812
+ const printer = new IdentityPrinter();
3813
+ printer.printERBNode(node);
3814
+ return printer.context.getOutput();
3815
+ }
3686
3816
  visitLiteralNode(node) {
3687
3817
  this.write(node.content);
3688
3818
  }
@@ -3970,11 +4100,397 @@ class IdentityPrinter extends Printer {
3970
4100
  ({
3971
4101
  ...DEFAULT_PRINT_OPTIONS});
3972
4102
 
4103
+ // --- Constants ---
3973
4104
  // TODO: we can probably expand this list with more tags/attributes
3974
4105
  const FORMATTABLE_ATTRIBUTES = {
3975
4106
  '*': ['class'],
3976
4107
  'img': ['srcset', 'sizes']
3977
4108
  };
4109
+ const INLINE_ELEMENTS = new Set([
4110
+ 'a', 'abbr', 'acronym', 'b', 'bdo', 'big', 'br', 'cite', 'code',
4111
+ 'dfn', 'em', 'hr', 'i', 'img', 'kbd', 'label', 'map', 'object', 'q',
4112
+ 'samp', 'small', 'span', 'strong', 'sub', 'sup',
4113
+ 'tt', 'var', 'del', 'ins', 'mark', 's', 'u', 'time', 'wbr'
4114
+ ]);
4115
+ const CONTENT_PRESERVING_ELEMENTS = new Set([
4116
+ 'script', 'style', 'pre', 'textarea'
4117
+ ]);
4118
+ const SPACEABLE_CONTAINERS = new Set([
4119
+ 'div', 'section', 'article', 'main', 'header', 'footer', 'aside',
4120
+ 'figure', 'details', 'summary', 'dialog', 'fieldset'
4121
+ ]);
4122
+ const TIGHT_GROUP_PARENTS = new Set([
4123
+ 'ul', 'ol', 'nav', 'select', 'datalist', 'optgroup', 'tr', 'thead',
4124
+ 'tbody', 'tfoot'
4125
+ ]);
4126
+ const TIGHT_GROUP_CHILDREN = new Set([
4127
+ 'li', 'option', 'td', 'th', 'dt', 'dd'
4128
+ ]);
4129
+ const SPACING_THRESHOLD = 3;
4130
+ /**
4131
+ * Token list attributes that contain space-separated values and benefit from
4132
+ * spacing around ERB content for readability
4133
+ */
4134
+ const TOKEN_LIST_ATTRIBUTES = new Set([
4135
+ 'class', 'data-controller', 'data-action'
4136
+ ]);
4137
+ // --- Node Utility Functions ---
4138
+ /**
4139
+ * Check if a node is pure whitespace (empty text node with only whitespace)
4140
+ */
4141
+ function isPureWhitespaceNode(node) {
4142
+ return isNode(node, HTMLTextNode) && node.content.trim() === "";
4143
+ }
4144
+ /**
4145
+ * Check if a node is non-whitespace (has meaningful content)
4146
+ */
4147
+ function isNonWhitespaceNode(node) {
4148
+ if (isNode(node, WhitespaceNode))
4149
+ return false;
4150
+ if (isNode(node, HTMLTextNode))
4151
+ return node.content.trim() !== "";
4152
+ return true;
4153
+ }
4154
+ /**
4155
+ * Find the previous meaningful (non-whitespace) sibling
4156
+ * Returns -1 if no meaningful sibling is found
4157
+ */
4158
+ function findPreviousMeaningfulSibling(siblings, currentIndex) {
4159
+ for (let i = currentIndex - 1; i >= 0; i--) {
4160
+ if (isNonWhitespaceNode(siblings[i])) {
4161
+ return i;
4162
+ }
4163
+ }
4164
+ return -1;
4165
+ }
4166
+ /**
4167
+ * Check if there's whitespace between two indices in children array
4168
+ */
4169
+ function hasWhitespaceBetween(children, startIndex, endIndex) {
4170
+ for (let j = startIndex + 1; j < endIndex; j++) {
4171
+ if (isNode(children[j], WhitespaceNode) || isPureWhitespaceNode(children[j])) {
4172
+ return true;
4173
+ }
4174
+ }
4175
+ return false;
4176
+ }
4177
+ /**
4178
+ * Filter children to remove insignificant whitespace
4179
+ */
4180
+ function filterSignificantChildren(body) {
4181
+ return body.filter(child => {
4182
+ if (isNode(child, WhitespaceNode))
4183
+ return false;
4184
+ if (isNode(child, HTMLTextNode)) {
4185
+ if (child.content === " ")
4186
+ return true;
4187
+ return child.content.trim() !== "";
4188
+ }
4189
+ return true;
4190
+ });
4191
+ }
4192
+ /**
4193
+ * Smart filter that preserves exactly ONE whitespace before herb:disable comments
4194
+ */
4195
+ function filterEmptyNodesForHerbDisable(nodes) {
4196
+ const result = [];
4197
+ let pendingWhitespace = null;
4198
+ for (const node of nodes) {
4199
+ const isWhitespace = isNode(node, WhitespaceNode) || (isNode(node, HTMLTextNode) && node.content.trim() === "");
4200
+ const isHerbDisable = isNode(node, ERBContentNode) && isHerbDisableComment(node);
4201
+ if (isWhitespace) {
4202
+ if (!pendingWhitespace) {
4203
+ pendingWhitespace = node;
4204
+ }
4205
+ }
4206
+ else {
4207
+ if (isHerbDisable && pendingWhitespace) {
4208
+ result.push(pendingWhitespace);
4209
+ }
4210
+ pendingWhitespace = null;
4211
+ result.push(node);
4212
+ }
4213
+ }
4214
+ return result;
4215
+ }
4216
+ // --- Punctuation and Word Spacing Functions ---
4217
+ /**
4218
+ * Check if a word is standalone closing punctuation
4219
+ */
4220
+ function isClosingPunctuation(word) {
4221
+ return /^[.,;:!?)\]]+$/.test(word);
4222
+ }
4223
+ /**
4224
+ * Check if a line ends with opening punctuation
4225
+ */
4226
+ function lineEndsWithOpeningPunctuation(line) {
4227
+ return /[(\[]$/.test(line);
4228
+ }
4229
+ /**
4230
+ * Check if a string ends with an ERB tag
4231
+ */
4232
+ function endsWithERBTag(text) {
4233
+ const trimmed = text.trim();
4234
+ return /%>$/.test(trimmed) || /%>\S+$/.test(trimmed);
4235
+ }
4236
+ /**
4237
+ * Check if a string starts with an ERB tag
4238
+ */
4239
+ function startsWithERBTag(text) {
4240
+ return /^<%/.test(text.trim());
4241
+ }
4242
+ /**
4243
+ * Determine if space is needed between the current line and the next word
4244
+ */
4245
+ function needsSpaceBetween(currentLine, word) {
4246
+ if (isClosingPunctuation(word))
4247
+ return false;
4248
+ if (lineEndsWithOpeningPunctuation(currentLine))
4249
+ return false;
4250
+ if (currentLine.endsWith(' '))
4251
+ return false;
4252
+ if (word.startsWith(' '))
4253
+ return false;
4254
+ if (endsWithERBTag(currentLine) && startsWithERBTag(word))
4255
+ return false;
4256
+ return true;
4257
+ }
4258
+ /**
4259
+ * Build a line by adding a word with appropriate spacing
4260
+ */
4261
+ function buildLineWithWord(currentLine, word) {
4262
+ if (!currentLine)
4263
+ return word;
4264
+ if (word === ' ') {
4265
+ return currentLine.endsWith(' ') ? currentLine : `${currentLine} `;
4266
+ }
4267
+ if (isClosingPunctuation(word)) {
4268
+ currentLine = currentLine.trimEnd();
4269
+ return `${currentLine}${word}`;
4270
+ }
4271
+ return needsSpaceBetween(currentLine, word) ? `${currentLine} ${word}` : `${currentLine}${word}`;
4272
+ }
4273
+ /**
4274
+ * Check if a node is an inline element or ERB node
4275
+ */
4276
+ function isInlineOrERBNode(node) {
4277
+ return isERBNode(node) || (isNode(node, HTMLElementNode) && isInlineElement(getTagName(node)));
4278
+ }
4279
+ /**
4280
+ * Check if an element should be treated as inline based on its tag name
4281
+ */
4282
+ function isInlineElement(tagName) {
4283
+ return INLINE_ELEMENTS.has(tagName.toLowerCase());
4284
+ }
4285
+ /**
4286
+ * Check if the current inline element is adjacent to a previous inline element (no whitespace between)
4287
+ */
4288
+ function isAdjacentToPreviousInline(siblings, index) {
4289
+ const previousNode = siblings[index - 1];
4290
+ if (isInlineOrERBNode(previousNode)) {
4291
+ return true;
4292
+ }
4293
+ if (index > 1 && isNode(previousNode, HTMLTextNode) && !/^\s/.test(previousNode.content)) {
4294
+ const twoBack = siblings[index - 2];
4295
+ return isInlineOrERBNode(twoBack);
4296
+ }
4297
+ return false;
4298
+ }
4299
+ /**
4300
+ * Check if a node should be appended to the last line (for adjacent inline elements and punctuation)
4301
+ */
4302
+ function shouldAppendToLastLine(child, siblings, index) {
4303
+ if (index === 0)
4304
+ return false;
4305
+ if (isNode(child, HTMLTextNode) && !/^\s/.test(child.content)) {
4306
+ const previousNode = siblings[index - 1];
4307
+ return isInlineOrERBNode(previousNode);
4308
+ }
4309
+ if (isNode(child, HTMLElementNode) && isInlineElement(getTagName(child))) {
4310
+ return isAdjacentToPreviousInline(siblings, index);
4311
+ }
4312
+ if (isNode(child, ERBContentNode)) {
4313
+ for (let i = index - 1; i >= 0; i--) {
4314
+ const previousSibling = siblings[i];
4315
+ if (isPureWhitespaceNode(previousSibling) || isNode(previousSibling, WhitespaceNode)) {
4316
+ continue;
4317
+ }
4318
+ if (previousSibling.location && child.location) {
4319
+ return previousSibling.location.end.line === child.location.start.line;
4320
+ }
4321
+ break;
4322
+ }
4323
+ }
4324
+ return false;
4325
+ }
4326
+ /**
4327
+ * Check if user-intentional spacing should be preserved (double newlines between elements)
4328
+ */
4329
+ function shouldPreserveUserSpacing(child, siblings, index) {
4330
+ if (!isPureWhitespaceNode(child))
4331
+ return false;
4332
+ const hasPreviousNonWhitespace = index > 0 && isNonWhitespaceNode(siblings[index - 1]);
4333
+ const hasNextNonWhitespace = index < siblings.length - 1 && isNonWhitespaceNode(siblings[index + 1]);
4334
+ const hasMultipleNewlines = isNode(child, HTMLTextNode) && child.content.includes('\n\n');
4335
+ return hasPreviousNonWhitespace && hasNextNonWhitespace && hasMultipleNewlines;
4336
+ }
4337
+ /**
4338
+ * Check if children contain any text content with newlines
4339
+ */
4340
+ function hasMultilineTextContent(children) {
4341
+ for (const child of children) {
4342
+ if (isNode(child, HTMLTextNode)) {
4343
+ return child.content.includes('\n');
4344
+ }
4345
+ if (isNode(child, HTMLElementNode) && hasMultilineTextContent(child.body)) {
4346
+ return true;
4347
+ }
4348
+ }
4349
+ return false;
4350
+ }
4351
+ /**
4352
+ * Check if all nested elements in the children are inline elements
4353
+ */
4354
+ function areAllNestedElementsInline(children) {
4355
+ for (const child of children) {
4356
+ if (isNode(child, HTMLElementNode)) {
4357
+ if (!isInlineElement(getTagName(child))) {
4358
+ return false;
4359
+ }
4360
+ if (!areAllNestedElementsInline(child.body)) {
4361
+ return false;
4362
+ }
4363
+ }
4364
+ else if (isAnyOf(child, HTMLDoctypeNode, HTMLCommentNode, isERBControlFlowNode)) {
4365
+ return false;
4366
+ }
4367
+ }
4368
+ return true;
4369
+ }
4370
+ /**
4371
+ * Check if element has complex ERB control flow
4372
+ */
4373
+ function hasComplexERBControlFlow(inlineNodes) {
4374
+ return inlineNodes.some(node => {
4375
+ if (isNode(node, ERBIfNode)) {
4376
+ if (node.statements.length > 0 && node.location) {
4377
+ const startLine = node.location.start.line;
4378
+ const endLine = node.location.end.line;
4379
+ return startLine !== endLine;
4380
+ }
4381
+ return false;
4382
+ }
4383
+ return false;
4384
+ });
4385
+ }
4386
+ /**
4387
+ * Check if children contain mixed text and inline elements (like "text<em>inline</em>text")
4388
+ * or mixed ERB output and text (like "<%= value %> text")
4389
+ * This indicates content that should be formatted inline even with structural newlines
4390
+ */
4391
+ function hasMixedTextAndInlineContent(children) {
4392
+ let hasText = false;
4393
+ let hasInlineElements = false;
4394
+ for (const child of children) {
4395
+ if (isNode(child, HTMLTextNode)) {
4396
+ if (child.content.trim() !== "") {
4397
+ hasText = true;
4398
+ }
4399
+ }
4400
+ else if (isNode(child, HTMLElementNode)) {
4401
+ if (isInlineElement(getTagName(child))) {
4402
+ hasInlineElements = true;
4403
+ }
4404
+ }
4405
+ }
4406
+ return (hasText && hasInlineElements) || (hasERBOutput(children) && hasText);
4407
+ }
4408
+ function isContentPreserving(element) {
4409
+ const tagName = getTagName(element);
4410
+ return CONTENT_PRESERVING_ELEMENTS.has(tagName);
4411
+ }
4412
+ /**
4413
+ * Count consecutive inline elements/ERB at the start of children (with no whitespace between)
4414
+ */
4415
+ function countAdjacentInlineElements(children) {
4416
+ let count = 0;
4417
+ let lastSignificantIndex = -1;
4418
+ for (let i = 0; i < children.length; i++) {
4419
+ const child = children[i];
4420
+ if (isPureWhitespaceNode(child) || isNode(child, WhitespaceNode)) {
4421
+ continue;
4422
+ }
4423
+ const isInlineOrERB = (isNode(child, HTMLElementNode) && isInlineElement(getTagName(child))) || isNode(child, ERBContentNode);
4424
+ if (!isInlineOrERB) {
4425
+ break;
4426
+ }
4427
+ if (lastSignificantIndex >= 0 && hasWhitespaceBetween(children, lastSignificantIndex, i)) {
4428
+ break;
4429
+ }
4430
+ count++;
4431
+ lastSignificantIndex = i;
4432
+ }
4433
+ return count;
4434
+ }
4435
+ /**
4436
+ * Check if a node represents a block-level element
4437
+ */
4438
+ function isBlockLevelNode(node) {
4439
+ if (!isNode(node, HTMLElementNode)) {
4440
+ return false;
4441
+ }
4442
+ const tagName = getTagName(node);
4443
+ if (INLINE_ELEMENTS.has(tagName)) {
4444
+ return false;
4445
+ }
4446
+ return true;
4447
+ }
4448
+ /**
4449
+ * Check if an element is a line-breaking element (br or hr)
4450
+ */
4451
+ function isLineBreakingElement(node) {
4452
+ if (!isNode(node, HTMLElementNode)) {
4453
+ return false;
4454
+ }
4455
+ const tagName = getTagName(node);
4456
+ return tagName === 'br' || tagName === 'hr';
4457
+ }
4458
+ /**
4459
+ * Normalize text by replacing multiple spaces with single space and trim
4460
+ * Then split into words
4461
+ */
4462
+ function normalizeAndSplitWords(text) {
4463
+ const normalized = text.replace(/\s+/g, ' ');
4464
+ return normalized.trim().split(' ');
4465
+ }
4466
+ /**
4467
+ * Check if text ends with whitespace
4468
+ */
4469
+ function endsWithWhitespace(text) {
4470
+ return /\s$/.test(text);
4471
+ }
4472
+ /**
4473
+ * Check if an ERB content node is a herb:disable comment
4474
+ */
4475
+ function isHerbDisableComment(node) {
4476
+ if (!isNode(node, ERBContentNode))
4477
+ return false;
4478
+ if (node.tag_opening?.value !== "<%#")
4479
+ return false;
4480
+ const content = node?.content?.value || "";
4481
+ const trimmed = content.trim();
4482
+ return trimmed.startsWith("herb:disable");
4483
+ }
4484
+ /**
4485
+ * Check if a text node is YAML frontmatter (starts and ends with ---)
4486
+ */
4487
+ function isFrontmatter(node) {
4488
+ if (!isNode(node, HTMLTextNode))
4489
+ return false;
4490
+ const content = node.content.trim();
4491
+ return content.startsWith("---") && /---\s*$/.test(content);
4492
+ }
4493
+
3978
4494
  /**
3979
4495
  * Printer traverses the Herb AST using the Visitor pattern
3980
4496
  * and emits a formatted string with proper indentation, line breaks, and attribute wrapping.
@@ -3998,28 +4514,6 @@ class FormatPrinter extends Printer {
3998
4514
  elementStack = [];
3999
4515
  elementFormattingAnalysis = new Map();
4000
4516
  source;
4001
- // TODO: extract
4002
- static INLINE_ELEMENTS = new Set([
4003
- 'a', 'abbr', 'acronym', 'b', 'bdo', 'big', 'br', 'cite', 'code',
4004
- 'dfn', 'em', 'i', 'img', 'kbd', 'label', 'map', 'object', 'q',
4005
- 'samp', 'small', 'span', 'strong', 'sub', 'sup',
4006
- 'tt', 'var', 'del', 'ins', 'mark', 's', 'u', 'time', 'wbr'
4007
- ]);
4008
- static CONTENT_PRESERVING_ELEMENTS = new Set([
4009
- 'script', 'style', 'pre', 'textarea'
4010
- ]);
4011
- static SPACEABLE_CONTAINERS = new Set([
4012
- 'div', 'section', 'article', 'main', 'header', 'footer', 'aside',
4013
- 'figure', 'details', 'summary', 'dialog', 'fieldset'
4014
- ]);
4015
- static TIGHT_GROUP_PARENTS = new Set([
4016
- 'ul', 'ol', 'nav', 'select', 'datalist', 'optgroup', 'tr', 'thead',
4017
- 'tbody', 'tfoot'
4018
- ]);
4019
- static TIGHT_GROUP_CHILDREN = new Set([
4020
- 'li', 'option', 'td', 'th', 'dt', 'dd'
4021
- ]);
4022
- static SPACING_THRESHOLD = 3;
4023
4517
  constructor(source, options) {
4024
4518
  super();
4025
4519
  this.source = source;
@@ -4178,20 +4672,20 @@ class FormatPrinter extends Printer {
4178
4672
  if (hasMixedContent) {
4179
4673
  return false;
4180
4674
  }
4181
- const meaningfulSiblings = siblings.filter(child => this.isNonWhitespaceNode(child));
4182
- if (meaningfulSiblings.length < FormatPrinter.SPACING_THRESHOLD) {
4675
+ const meaningfulSiblings = siblings.filter(child => isNonWhitespaceNode(child));
4676
+ if (meaningfulSiblings.length < SPACING_THRESHOLD) {
4183
4677
  return false;
4184
4678
  }
4185
4679
  const parentTagName = parentElement ? getTagName(parentElement) : null;
4186
- if (parentTagName && FormatPrinter.TIGHT_GROUP_PARENTS.has(parentTagName)) {
4680
+ if (parentTagName && TIGHT_GROUP_PARENTS.has(parentTagName)) {
4187
4681
  return false;
4188
4682
  }
4189
- const isSpaceableContainer = !parentTagName || (parentTagName && FormatPrinter.SPACEABLE_CONTAINERS.has(parentTagName));
4683
+ const isSpaceableContainer = !parentTagName || (parentTagName && SPACEABLE_CONTAINERS.has(parentTagName));
4190
4684
  if (!isSpaceableContainer && meaningfulSiblings.length < 5) {
4191
4685
  return false;
4192
4686
  }
4193
4687
  const currentNode = siblings[currentIndex];
4194
- const previousMeaningfulIndex = this.findPreviousMeaningfulSibling(siblings, currentIndex);
4688
+ const previousMeaningfulIndex = findPreviousMeaningfulSibling(siblings, currentIndex);
4195
4689
  const isCurrentComment = isCommentNode(currentNode);
4196
4690
  if (previousMeaningfulIndex !== -1) {
4197
4691
  const previousNode = siblings[previousMeaningfulIndex];
@@ -4205,69 +4699,37 @@ class FormatPrinter extends Printer {
4205
4699
  }
4206
4700
  if (isNode(currentNode, HTMLElementNode)) {
4207
4701
  const currentTagName = getTagName(currentNode);
4208
- if (FormatPrinter.INLINE_ELEMENTS.has(currentTagName)) {
4702
+ if (INLINE_ELEMENTS.has(currentTagName)) {
4209
4703
  return false;
4210
4704
  }
4211
- if (FormatPrinter.TIGHT_GROUP_CHILDREN.has(currentTagName)) {
4705
+ if (TIGHT_GROUP_CHILDREN.has(currentTagName)) {
4212
4706
  return false;
4213
4707
  }
4214
4708
  if (currentTagName === 'a' && parentTagName === 'nav') {
4215
4709
  return false;
4216
4710
  }
4217
4711
  }
4218
- const isBlockElement = this.isBlockLevelNode(currentNode);
4712
+ const isBlockElement = isBlockLevelNode(currentNode);
4219
4713
  const isERBBlock = isERBNode(currentNode) && isERBControlFlowNode(currentNode);
4220
4714
  const isComment = isCommentNode(currentNode);
4221
4715
  return isBlockElement || isERBBlock || isComment;
4222
4716
  }
4223
- /**
4224
- * Token list attributes that contain space-separated values and benefit from
4225
- * spacing around ERB content for readability
4226
- */
4227
- static TOKEN_LIST_ATTRIBUTES = new Set([
4228
- 'class', 'data-controller', 'data-action'
4229
- ]);
4230
4717
  /**
4231
4718
  * Check if we're currently processing a token list attribute that needs spacing
4232
4719
  */
4233
- isInTokenListAttribute() {
4234
- return this.currentAttributeName !== null &&
4235
- FormatPrinter.TOKEN_LIST_ATTRIBUTES.has(this.currentAttributeName);
4720
+ get isInTokenListAttribute() {
4721
+ return this.currentAttributeName !== null && TOKEN_LIST_ATTRIBUTES.has(this.currentAttributeName);
4236
4722
  }
4237
4723
  /**
4238
- * Find the previous meaningful (non-whitespace) sibling
4724
+ * Render attributes as a space-separated string
4239
4725
  */
4240
- findPreviousMeaningfulSibling(siblings, currentIndex) {
4241
- for (let i = currentIndex - 1; i >= 0; i--) {
4242
- if (this.isNonWhitespaceNode(siblings[i])) {
4243
- return i;
4244
- }
4245
- }
4246
- return -1;
4726
+ renderAttributesString(attributes) {
4727
+ if (attributes.length === 0)
4728
+ return "";
4729
+ return ` ${attributes.map(attribute => this.renderAttribute(attribute)).join(" ")}`;
4247
4730
  }
4248
4731
  /**
4249
- * Check if a node represents a block-level element
4250
- */
4251
- isBlockLevelNode(node) {
4252
- if (!isNode(node, HTMLElementNode)) {
4253
- return false;
4254
- }
4255
- const tagName = getTagName(node);
4256
- if (FormatPrinter.INLINE_ELEMENTS.has(tagName)) {
4257
- return false;
4258
- }
4259
- return true;
4260
- }
4261
- /**
4262
- * Render attributes as a space-separated string
4263
- */
4264
- renderAttributesString(attributes) {
4265
- if (attributes.length === 0)
4266
- return "";
4267
- return ` ${attributes.map(attribute => this.renderAttribute(attribute)).join(" ")}`;
4268
- }
4269
- /**
4270
- * Determine if a tag should be rendered inline based on attribute count and other factors
4732
+ * Determine if a tag should be rendered inline based on attribute count and other factors
4271
4733
  */
4272
4734
  shouldRenderInline(totalAttributeCount, inlineLength, indentLength, maxLineLength = this.maxLineLength, hasComplexERB = false, hasMultilineAttributes = false, attributes = []) {
4273
4735
  if (hasComplexERB || hasMultilineAttributes)
@@ -4294,9 +4756,6 @@ class FormatPrinter extends Printer {
4294
4756
  }
4295
4757
  return true;
4296
4758
  }
4297
- getAttributeName(attribute) {
4298
- return attribute.name ? getCombinedAttributeName(attribute.name) : "";
4299
- }
4300
4759
  wouldClassAttributeBeMultiline(content, indentLength) {
4301
4760
  const normalizedContent = content.replace(/\s+/g, ' ').trim();
4302
4761
  const hasActualNewlines = /\r?\n/.test(content);
@@ -4318,6 +4777,11 @@ class FormatPrinter extends Printer {
4318
4777
  }
4319
4778
  return false;
4320
4779
  }
4780
+ // TOOD: extract to core or reuse function from core
4781
+ getAttributeName(attribute) {
4782
+ return attribute.name ? getCombinedAttributeName(attribute.name) : "";
4783
+ }
4784
+ // TOOD: extract to core or reuse function from core
4321
4785
  getAttributeValue(attribute) {
4322
4786
  if (isNode(attribute.value, HTMLAttributeValueNode)) {
4323
4787
  return attribute.value.children.map(child => isNode(child, HTMLTextNode) ? child.content : IdentityPrinter.print(child)).join('');
@@ -4453,28 +4917,38 @@ class FormatPrinter extends Printer {
4453
4917
  }
4454
4918
  // --- Visitor methods ---
4455
4919
  visitDocumentNode(node) {
4920
+ const children = this.formatFrontmatter(node);
4921
+ const hasTextFlow = this.isInTextFlowContext(null, children);
4922
+ if (hasTextFlow) {
4923
+ const wasInlineMode = this.inlineMode;
4924
+ this.inlineMode = true;
4925
+ this.visitTextFlowChildren(children);
4926
+ this.inlineMode = wasInlineMode;
4927
+ return;
4928
+ }
4456
4929
  let lastWasMeaningful = false;
4457
4930
  let hasHandledSpacing = false;
4458
- for (let i = 0; i < node.children.length; i++) {
4459
- const child = node.children[i];
4460
- if (isNode(child, HTMLTextNode)) {
4461
- const isWhitespaceOnly = child.content.trim() === "";
4462
- if (isWhitespaceOnly) {
4463
- const hasPreviousNonWhitespace = i > 0 && this.isNonWhitespaceNode(node.children[i - 1]);
4464
- const hasNextNonWhitespace = i < node.children.length - 1 && this.isNonWhitespaceNode(node.children[i + 1]);
4465
- const hasMultipleNewlines = child.content.includes('\n\n');
4466
- if (hasPreviousNonWhitespace && hasNextNonWhitespace && hasMultipleNewlines) {
4467
- this.push("");
4468
- hasHandledSpacing = true;
4469
- }
4470
- continue;
4471
- }
4931
+ for (let i = 0; i < children.length; i++) {
4932
+ const child = children[i];
4933
+ if (shouldPreserveUserSpacing(child, children, i)) {
4934
+ this.push("");
4935
+ hasHandledSpacing = true;
4936
+ continue;
4937
+ }
4938
+ if (isPureWhitespaceNode(child)) {
4939
+ continue;
4472
4940
  }
4473
- if (this.isNonWhitespaceNode(child) && lastWasMeaningful && !hasHandledSpacing) {
4941
+ if (shouldAppendToLastLine(child, children, i)) {
4942
+ this.appendChildToLastLine(child, children, i);
4943
+ lastWasMeaningful = true;
4944
+ hasHandledSpacing = false;
4945
+ continue;
4946
+ }
4947
+ if (isNonWhitespaceNode(child) && lastWasMeaningful && !hasHandledSpacing) {
4474
4948
  this.push("");
4475
4949
  }
4476
4950
  this.visit(child);
4477
- if (this.isNonWhitespaceNode(child)) {
4951
+ if (isNonWhitespaceNode(child)) {
4478
4952
  lastWasMeaningful = true;
4479
4953
  hasHandledSpacing = false;
4480
4954
  }
@@ -4483,6 +4957,12 @@ class FormatPrinter extends Printer {
4483
4957
  visitHTMLElementNode(node) {
4484
4958
  this.elementStack.push(node);
4485
4959
  this.elementFormattingAnalysis.set(node, this.analyzeElementFormatting(node));
4960
+ if (this.inlineMode && node.is_void && this.indentLevel === 0) {
4961
+ const openTag = this.capture(() => this.visit(node.open_tag)).join('');
4962
+ this.pushToLastLine(openTag);
4963
+ this.elementStack.pop();
4964
+ return;
4965
+ }
4486
4966
  this.visit(node.open_tag);
4487
4967
  if (node.body.length > 0) {
4488
4968
  this.visitHTMLElementBody(node.body, node);
@@ -4493,7 +4973,8 @@ class FormatPrinter extends Printer {
4493
4973
  this.elementStack.pop();
4494
4974
  }
4495
4975
  visitHTMLElementBody(body, element) {
4496
- if (this.isContentPreserving(element)) {
4976
+ const tagName = getTagName(element);
4977
+ if (isContentPreserving(element)) {
4497
4978
  element.body.map(child => {
4498
4979
  if (isNode(child, HTMLElementNode)) {
4499
4980
  const wasInlineMode = this.inlineMode;
@@ -4510,12 +4991,14 @@ class FormatPrinter extends Printer {
4510
4991
  }
4511
4992
  const analysis = this.elementFormattingAnalysis.get(element);
4512
4993
  const hasTextFlow = this.isInTextFlowContext(null, body);
4513
- const children = this.filterSignificantChildren(body, hasTextFlow);
4994
+ const children = filterSignificantChildren(body);
4514
4995
  if (analysis?.elementContentInline) {
4515
4996
  if (children.length === 0)
4516
4997
  return;
4517
4998
  const oldInlineMode = this.inlineMode;
4518
4999
  const nodesToRender = hasTextFlow ? body : children;
5000
+ const hasOnlyTextContent = nodesToRender.every(child => isNode(child, HTMLTextNode) || isNode(child, WhitespaceNode));
5001
+ const shouldPreserveSpaces = hasOnlyTextContent && isInlineElement(tagName);
4519
5002
  this.inlineMode = true;
4520
5003
  const lines = this.capture(() => {
4521
5004
  nodesToRender.forEach(child => {
@@ -4530,10 +5013,19 @@ class FormatPrinter extends Printer {
4530
5013
  }
4531
5014
  }
4532
5015
  else {
4533
- const normalizedContent = child.content.replace(/\s+/g, ' ').trim();
4534
- if (normalizedContent) {
5016
+ const normalizedContent = child.content.replace(/\s+/g, ' ');
5017
+ if (shouldPreserveSpaces && normalizedContent) {
4535
5018
  this.push(normalizedContent);
4536
5019
  }
5020
+ else {
5021
+ const trimmedContent = normalizedContent.trim();
5022
+ if (trimmedContent) {
5023
+ this.push(trimmedContent);
5024
+ }
5025
+ else if (normalizedContent === ' ') {
5026
+ this.push(' ');
5027
+ }
5028
+ }
4537
5029
  }
4538
5030
  }
4539
5031
  else if (isNode(child, WhitespaceNode)) {
@@ -4545,7 +5037,9 @@ class FormatPrinter extends Printer {
4545
5037
  });
4546
5038
  });
4547
5039
  const content = lines.join('');
4548
- const inlineContent = hasTextFlow ? content.replace(/\s+/g, ' ').trim() : content.trim();
5040
+ const inlineContent = shouldPreserveSpaces
5041
+ ? (hasTextFlow ? content.replace(/\s+/g, ' ') : content)
5042
+ : (hasTextFlow ? content.replace(/\s+/g, ' ').trim() : content.trim());
4549
5043
  if (inlineContent) {
4550
5044
  this.pushToLastLine(inlineContent);
4551
5045
  }
@@ -4554,12 +5048,69 @@ class FormatPrinter extends Printer {
4554
5048
  }
4555
5049
  if (children.length === 0)
4556
5050
  return;
5051
+ let leadingHerbDisableComment = null;
5052
+ let leadingHerbDisableIndex = -1;
5053
+ let firstWhitespaceIndex = -1;
5054
+ let remainingChildren = children;
5055
+ let remainingBodyUnfiltered = body;
5056
+ for (let i = 0; i < children.length; i++) {
5057
+ const child = children[i];
5058
+ if (isNode(child, WhitespaceNode) || isPureWhitespaceNode(child)) {
5059
+ if (firstWhitespaceIndex < 0) {
5060
+ firstWhitespaceIndex = i;
5061
+ }
5062
+ continue;
5063
+ }
5064
+ if (isNode(child, ERBContentNode) && isHerbDisableComment(child)) {
5065
+ leadingHerbDisableComment = child;
5066
+ leadingHerbDisableIndex = i;
5067
+ }
5068
+ break;
5069
+ }
5070
+ if (leadingHerbDisableComment && leadingHerbDisableIndex >= 0) {
5071
+ remainingChildren = children.filter((_, index) => {
5072
+ if (index === leadingHerbDisableIndex)
5073
+ return false;
5074
+ if (firstWhitespaceIndex >= 0 && index === leadingHerbDisableIndex - 1) {
5075
+ const child = children[index];
5076
+ if (isNode(child, WhitespaceNode) || isPureWhitespaceNode(child)) {
5077
+ return false;
5078
+ }
5079
+ }
5080
+ return true;
5081
+ });
5082
+ remainingBodyUnfiltered = body.filter((_, index) => {
5083
+ if (index === leadingHerbDisableIndex)
5084
+ return false;
5085
+ if (firstWhitespaceIndex >= 0 && index === leadingHerbDisableIndex - 1) {
5086
+ const child = body[index];
5087
+ if (isNode(child, WhitespaceNode) || isPureWhitespaceNode(child)) {
5088
+ return false;
5089
+ }
5090
+ }
5091
+ return true;
5092
+ });
5093
+ }
5094
+ if (leadingHerbDisableComment) {
5095
+ const herbDisableString = this.capture(() => {
5096
+ const savedIndentLevel = this.indentLevel;
5097
+ this.indentLevel = 0;
5098
+ this.inlineMode = true;
5099
+ this.visit(leadingHerbDisableComment);
5100
+ this.inlineMode = false;
5101
+ this.indentLevel = savedIndentLevel;
5102
+ }).join("");
5103
+ const hasLeadingWhitespace = firstWhitespaceIndex >= 0 && firstWhitespaceIndex < leadingHerbDisableIndex;
5104
+ this.pushToLastLine((hasLeadingWhitespace ? ' ' : '') + herbDisableString);
5105
+ }
5106
+ if (remainingChildren.length === 0)
5107
+ return;
4557
5108
  this.withIndent(() => {
4558
5109
  if (hasTextFlow) {
4559
- this.visitTextFlowChildren(children);
5110
+ this.visitTextFlowChildren(remainingBodyUnfiltered);
4560
5111
  }
4561
5112
  else {
4562
- this.visitElementChildren(body, element);
5113
+ this.visitElementChildren(leadingHerbDisableComment ? remainingChildren : body, element);
4563
5114
  }
4564
5115
  });
4565
5116
  }
@@ -4574,8 +5125,8 @@ class FormatPrinter extends Printer {
4574
5125
  if (isNode(child, HTMLTextNode)) {
4575
5126
  const isWhitespaceOnly = child.content.trim() === "";
4576
5127
  if (isWhitespaceOnly) {
4577
- const hasPreviousNonWhitespace = i > 0 && this.isNonWhitespaceNode(body[i - 1]);
4578
- const hasNextNonWhitespace = i < body.length - 1 && this.isNonWhitespaceNode(body[i + 1]);
5128
+ const hasPreviousNonWhitespace = i > 0 && isNonWhitespaceNode(body[i - 1]);
5129
+ const hasNextNonWhitespace = i < body.length - 1 && isNonWhitespaceNode(body[i + 1]);
4579
5130
  const hasMultipleNewlines = child.content.includes('\n\n');
4580
5131
  if (hasPreviousNonWhitespace && hasNextNonWhitespace && hasMultipleNewlines) {
4581
5132
  this.push("");
@@ -4584,7 +5135,7 @@ class FormatPrinter extends Printer {
4584
5135
  continue;
4585
5136
  }
4586
5137
  }
4587
- if (this.isNonWhitespaceNode(child) && lastWasMeaningful && !hasHandledSpacing) {
5138
+ if (isNonWhitespaceNode(child) && lastWasMeaningful && !hasHandledSpacing) {
4588
5139
  const element = body[i - 1];
4589
5140
  const hasExistingSpacing = i > 0 && isNode(element, HTMLTextNode) && element.content.trim() === "" && (element.content.includes('\n\n') || element.content.split('\n').length > 2);
4590
5141
  const shouldAddSpacing = this.shouldAddSpacingBetweenSiblings(parentElement, body, i, hasExistingSpacing);
@@ -4592,8 +5143,35 @@ class FormatPrinter extends Printer {
4592
5143
  this.push("");
4593
5144
  }
4594
5145
  }
4595
- this.visit(child);
4596
- if (this.isNonWhitespaceNode(child)) {
5146
+ let hasTrailingHerbDisable = false;
5147
+ if (isNode(child, HTMLElementNode) && child.close_tag) {
5148
+ for (let j = i + 1; j < body.length; j++) {
5149
+ const nextChild = body[j];
5150
+ if (isNode(nextChild, WhitespaceNode) || isPureWhitespaceNode(nextChild)) {
5151
+ continue;
5152
+ }
5153
+ if (isNode(nextChild, ERBContentNode) && isHerbDisableComment(nextChild)) {
5154
+ hasTrailingHerbDisable = true;
5155
+ this.visit(child);
5156
+ const herbDisableString = this.capture(() => {
5157
+ const savedIndentLevel = this.indentLevel;
5158
+ this.indentLevel = 0;
5159
+ this.inlineMode = true;
5160
+ this.visit(nextChild);
5161
+ this.inlineMode = false;
5162
+ this.indentLevel = savedIndentLevel;
5163
+ }).join("");
5164
+ this.pushToLastLine(' ' + herbDisableString);
5165
+ i = j;
5166
+ break;
5167
+ }
5168
+ break;
5169
+ }
5170
+ }
5171
+ if (!hasTrailingHerbDisable) {
5172
+ this.visit(child);
5173
+ }
5174
+ if (isNonWhitespaceNode(child)) {
4597
5175
  lastWasMeaningful = true;
4598
5176
  hasHandledSpacing = false;
4599
5177
  }
@@ -4683,8 +5261,8 @@ class FormatPrinter extends Printer {
4683
5261
  if (isNode(child, HTMLTextNode) || isNode(child, LiteralNode)) {
4684
5262
  return child.content;
4685
5263
  }
4686
- else if (isERBNode(child) || isNode(child, ERBContentNode)) {
4687
- return this.reconstructERBNode(child, false);
5264
+ else if (isERBNode(child)) {
5265
+ return IdentityPrinter.print(child);
4688
5266
  }
4689
5267
  else {
4690
5268
  return "";
@@ -4737,11 +5315,21 @@ class FormatPrinter extends Printer {
4737
5315
  if (contentLines.length === 1 && contentTrimmedLines.length === 1) {
4738
5316
  const startsWithSpace = content[0] === " ";
4739
5317
  const before = startsWithSpace ? "" : " ";
4740
- this.pushWithIndent(open + before + content.trimEnd() + ' ' + close);
5318
+ if (this.inlineMode) {
5319
+ this.push(open + before + content.trimEnd() + ' ' + close);
5320
+ }
5321
+ else {
5322
+ this.pushWithIndent(open + before + content.trimEnd() + ' ' + close);
5323
+ }
4741
5324
  return;
4742
5325
  }
4743
5326
  if (contentTrimmedLines.length === 1) {
4744
- this.pushWithIndent(open + ' ' + content.trim() + ' ' + close);
5327
+ if (this.inlineMode) {
5328
+ this.push(open + ' ' + content.trim() + ' ' + close);
5329
+ }
5330
+ else {
5331
+ this.pushWithIndent(open + ' ' + content.trim() + ' ' + close);
5332
+ }
4745
5333
  return;
4746
5334
  }
4747
5335
  const firstLineEmpty = contentLines[0].trim() === "";
@@ -4782,6 +5370,7 @@ class FormatPrinter extends Printer {
4782
5370
  }
4783
5371
  visitERBCaseMatchNode(node) {
4784
5372
  this.printERBNode(node);
5373
+ this.withIndent(() => this.visitAll(node.children));
4785
5374
  this.visitAll(node.conditions);
4786
5375
  if (node.else_clause)
4787
5376
  this.visit(node.else_clause);
@@ -4790,7 +5379,15 @@ class FormatPrinter extends Printer {
4790
5379
  }
4791
5380
  visitERBBlockNode(node) {
4792
5381
  this.printERBNode(node);
4793
- this.withIndent(() => this.visitElementChildren(node.body, null));
5382
+ this.withIndent(() => {
5383
+ const hasTextFlow = this.isInTextFlowContext(null, node.body);
5384
+ if (hasTextFlow) {
5385
+ this.visitTextFlowChildren(node.body);
5386
+ }
5387
+ else {
5388
+ this.visitElementChildren(node.body, null);
5389
+ }
5390
+ });
4794
5391
  if (node.end_node)
4795
5392
  this.visit(node.end_node);
4796
5393
  }
@@ -4803,7 +5400,7 @@ class FormatPrinter extends Printer {
4803
5400
  this.lines.push(this.renderAttribute(child));
4804
5401
  }
4805
5402
  else {
4806
- const shouldAddSpaces = this.isInTokenListAttribute();
5403
+ const shouldAddSpaces = this.isInTokenListAttribute;
4807
5404
  if (shouldAddSpaces) {
4808
5405
  this.lines.push(" ");
4809
5406
  }
@@ -4814,12 +5411,12 @@ class FormatPrinter extends Printer {
4814
5411
  }
4815
5412
  });
4816
5413
  const hasHTMLAttributes = node.statements.some(child => isNode(child, HTMLAttributeNode));
4817
- const isTokenList = this.isInTokenListAttribute();
5414
+ const isTokenList = this.isInTokenListAttribute;
4818
5415
  if ((hasHTMLAttributes || isTokenList) && node.end_node) {
4819
5416
  this.lines.push(" ");
4820
5417
  }
4821
5418
  if (node.subsequent)
4822
- this.visit(node.end_node);
5419
+ this.visit(node.subsequent);
4823
5420
  if (node.end_node)
4824
5421
  this.visit(node.end_node);
4825
5422
  }
@@ -4835,8 +5432,14 @@ class FormatPrinter extends Printer {
4835
5432
  }
4836
5433
  }
4837
5434
  visitERBElseNode(node) {
4838
- this.printERBNode(node);
4839
- this.withIndent(() => node.statements.forEach(statement => this.visit(statement)));
5435
+ if (this.inlineMode) {
5436
+ this.printERBNode(node);
5437
+ node.statements.forEach(statement => this.visit(statement));
5438
+ }
5439
+ else {
5440
+ this.printERBNode(node);
5441
+ this.withIndent(() => node.statements.forEach(statement => this.visit(statement)));
5442
+ }
4840
5443
  }
4841
5444
  visitERBWhenNode(node) {
4842
5445
  this.printERBNode(node);
@@ -4844,6 +5447,7 @@ class FormatPrinter extends Printer {
4844
5447
  }
4845
5448
  visitERBCaseNode(node) {
4846
5449
  this.printERBNode(node);
5450
+ this.withIndent(() => this.visitAll(node.children));
4847
5451
  this.visitAll(node.conditions);
4848
5452
  if (node.else_clause)
4849
5453
  this.visit(node.else_clause);
@@ -4918,7 +5522,7 @@ class FormatPrinter extends Printer {
4918
5522
  const attributes = filterNodes(children, HTMLAttributeNode);
4919
5523
  const inlineNodes = this.extractInlineNodes(children);
4920
5524
  const hasERBControlFlow = inlineNodes.some(node => isERBControlFlowNode(node)) || children.some(node => isERBControlFlowNode(node));
4921
- const hasComplexERB = hasERBControlFlow && this.hasComplexERBControlFlow(inlineNodes);
5525
+ const hasComplexERB = hasERBControlFlow && hasComplexERBControlFlow(inlineNodes);
4922
5526
  if (hasComplexERB)
4923
5527
  return false;
4924
5528
  const totalAttributeCount = this.getTotalAttributeCount(attributes, inlineNodes);
@@ -4933,26 +5537,38 @@ class FormatPrinter extends Printer {
4933
5537
  */
4934
5538
  shouldRenderElementContentInline(node) {
4935
5539
  const tagName = getTagName(node);
4936
- const children = this.filterSignificantChildren(node.body, this.isInTextFlowContext(null, node.body));
4937
- const isInlineElement = this.isInlineElement(tagName);
5540
+ const children = filterSignificantChildren(node.body);
4938
5541
  const openTagInline = this.shouldRenderOpenTagInline(node);
4939
5542
  if (!openTagInline)
4940
5543
  return false;
4941
5544
  if (children.length === 0)
4942
5545
  return true;
4943
- if (isInlineElement) {
4944
- const fullInlineResult = this.tryRenderInlineFull(node, tagName, filterNodes(node.open_tag?.children, HTMLAttributeNode), children);
5546
+ let hasLeadingHerbDisable = false;
5547
+ for (const child of node.body) {
5548
+ if (isNode(child, WhitespaceNode) || isPureWhitespaceNode(child)) {
5549
+ continue;
5550
+ }
5551
+ if (isNode(child, ERBContentNode) && isHerbDisableComment(child)) {
5552
+ hasLeadingHerbDisable = true;
5553
+ }
5554
+ break;
5555
+ }
5556
+ if (hasLeadingHerbDisable && !isInlineElement(tagName)) {
5557
+ return false;
5558
+ }
5559
+ if (isInlineElement(tagName)) {
5560
+ const fullInlineResult = this.tryRenderInlineFull(node, tagName, filterNodes(node.open_tag?.children, HTMLAttributeNode), node.body);
4945
5561
  if (fullInlineResult) {
4946
5562
  const totalLength = this.indent.length + fullInlineResult.length;
4947
5563
  return totalLength <= this.maxLineLength || totalLength <= 120;
4948
5564
  }
4949
5565
  return false;
4950
5566
  }
4951
- const allNestedAreInline = this.areAllNestedElementsInline(children);
4952
- const hasMultilineText = this.hasMultilineTextContent(children);
4953
- const hasMixedContent = this.hasMixedTextAndInlineContent(children);
5567
+ const allNestedAreInline = areAllNestedElementsInline(children);
5568
+ const hasMultilineText = hasMultilineTextContent(children);
5569
+ const hasMixedContent = hasMixedTextAndInlineContent(children);
4954
5570
  if (allNestedAreInline && (!hasMultilineText || hasMixedContent)) {
4955
- const fullInlineResult = this.tryRenderInlineFull(node, tagName, filterNodes(node.open_tag?.children, HTMLAttributeNode), children);
5571
+ const fullInlineResult = this.tryRenderInlineFull(node, tagName, filterNodes(node.open_tag?.children, HTMLAttributeNode), node.body);
4956
5572
  if (fullInlineResult) {
4957
5573
  const totalLength = this.indent.length + fullInlineResult.length;
4958
5574
  if (totalLength <= this.maxLineLength) {
@@ -4979,117 +5595,555 @@ class FormatPrinter extends Printer {
4979
5595
  return true;
4980
5596
  if (node.open_tag?.tag_closing?.value === "/>")
4981
5597
  return true;
4982
- if (this.isContentPreserving(node))
5598
+ if (isContentPreserving(node))
4983
5599
  return true;
4984
- const children = this.filterSignificantChildren(node.body, this.isInTextFlowContext(null, node.body));
5600
+ const children = filterSignificantChildren(node.body);
4985
5601
  if (children.length === 0)
4986
5602
  return true;
4987
5603
  return elementContentInline;
4988
5604
  }
4989
5605
  // --- Utility methods ---
4990
- isNonWhitespaceNode(node) {
4991
- if (isNode(node, WhitespaceNode))
4992
- return false;
4993
- if (isNode(node, HTMLTextNode))
4994
- return node.content.trim() !== "";
4995
- return true;
5606
+ formatFrontmatter(node) {
5607
+ const firstChild = node.children[0];
5608
+ const hasFrontmatter = firstChild && isFrontmatter(firstChild);
5609
+ if (!hasFrontmatter)
5610
+ return node.children;
5611
+ this.push(firstChild.content.trimEnd());
5612
+ const remaining = node.children.slice(1);
5613
+ if (remaining.length > 0)
5614
+ this.push("");
5615
+ return remaining;
4996
5616
  }
4997
5617
  /**
4998
- * Check if an element should be treated as inline based on its tag name
5618
+ * Append a child node to the last output line
4999
5619
  */
5000
- isInlineElement(tagName) {
5001
- return FormatPrinter.INLINE_ELEMENTS.has(tagName.toLowerCase());
5620
+ appendChildToLastLine(child, siblings, index) {
5621
+ if (isNode(child, HTMLTextNode)) {
5622
+ this.pushToLastLine(child.content.trim());
5623
+ }
5624
+ else {
5625
+ let hasSpaceBefore = false;
5626
+ if (siblings && index !== undefined && index > 0) {
5627
+ const prevSibling = siblings[index - 1];
5628
+ if (isPureWhitespaceNode(prevSibling) || isNode(prevSibling, WhitespaceNode)) {
5629
+ hasSpaceBefore = true;
5630
+ }
5631
+ }
5632
+ const oldInlineMode = this.inlineMode;
5633
+ this.inlineMode = true;
5634
+ const inlineContent = this.capture(() => this.visit(child)).join("");
5635
+ this.inlineMode = oldInlineMode;
5636
+ this.pushToLastLine((hasSpaceBefore ? " " : "") + inlineContent);
5637
+ }
5002
5638
  }
5003
5639
  /**
5004
- * Check if we're in a text flow context (parent contains mixed text and inline elements)
5640
+ * Visit children in a text flow context (mixed text and inline elements)
5641
+ * Handles word wrapping and keeps adjacent inline elements together
5005
5642
  */
5006
5643
  visitTextFlowChildren(children) {
5007
- let currentLineContent = "";
5008
- for (const child of children) {
5009
- if (isNode(child, HTMLTextNode)) {
5010
- const content = child.content;
5011
- let processedContent = content.replace(/\s+/g, ' ').trim();
5012
- if (processedContent) {
5013
- const hasLeadingSpace = /^\s/.test(content);
5014
- if (currentLineContent && hasLeadingSpace && !currentLineContent.endsWith(' ')) {
5015
- currentLineContent += ' ';
5016
- }
5017
- currentLineContent += processedContent;
5018
- const hasTrailingSpace = /\s$/.test(content);
5019
- if (hasTrailingSpace && !currentLineContent.endsWith(' ')) {
5020
- currentLineContent += ' ';
5021
- }
5022
- if ((this.indent.length + currentLineContent.length) > Math.max(this.maxLineLength, 120)) {
5023
- children.forEach(child => this.visit(child));
5024
- return;
5644
+ const adjacentInlineCount = countAdjacentInlineElements(children);
5645
+ if (adjacentInlineCount >= 2) {
5646
+ const { processedIndices } = this.renderAdjacentInlineElements(children, adjacentInlineCount);
5647
+ this.visitRemainingChildren(children, processedIndices);
5648
+ return;
5649
+ }
5650
+ this.buildAndWrapTextFlow(children);
5651
+ }
5652
+ /**
5653
+ * Wrap remaining words that don't fit on the current line
5654
+ * Returns the wrapped lines with proper indentation
5655
+ */
5656
+ wrapRemainingWords(words, wrapWidth) {
5657
+ const lines = [];
5658
+ let line = "";
5659
+ for (const word of words) {
5660
+ const testLine = line + (line ? " " : "") + word;
5661
+ if (testLine.length > wrapWidth && line) {
5662
+ lines.push(this.indent + line);
5663
+ line = word;
5664
+ }
5665
+ else {
5666
+ line = testLine;
5667
+ }
5668
+ }
5669
+ if (line) {
5670
+ lines.push(this.indent + line);
5671
+ }
5672
+ return lines;
5673
+ }
5674
+ /**
5675
+ * Try to merge text starting with punctuation to inline content
5676
+ * Returns object with merged content and whether processing should stop
5677
+ */
5678
+ tryMergePunctuationText(inlineContent, trimmedText, wrapWidth) {
5679
+ const combined = inlineContent + trimmedText;
5680
+ if (combined.length <= wrapWidth) {
5681
+ return {
5682
+ mergedContent: inlineContent + trimmedText,
5683
+ shouldStop: false,
5684
+ wrappedLines: []
5685
+ };
5686
+ }
5687
+ const match = trimmedText.match(/^[.!?:;]+/);
5688
+ if (!match) {
5689
+ return {
5690
+ mergedContent: inlineContent,
5691
+ shouldStop: false,
5692
+ wrappedLines: []
5693
+ };
5694
+ }
5695
+ const punctuation = match[0];
5696
+ const restText = trimmedText.substring(punctuation.length).trim();
5697
+ if (!restText) {
5698
+ return {
5699
+ mergedContent: inlineContent + punctuation,
5700
+ shouldStop: false,
5701
+ wrappedLines: []
5702
+ };
5703
+ }
5704
+ const words = restText.split(/\s+/);
5705
+ let toMerge = punctuation;
5706
+ let mergedWordCount = 0;
5707
+ for (const word of words) {
5708
+ const testMerge = toMerge + ' ' + word;
5709
+ if ((inlineContent + testMerge).length <= wrapWidth) {
5710
+ toMerge = testMerge;
5711
+ mergedWordCount++;
5712
+ }
5713
+ else {
5714
+ break;
5715
+ }
5716
+ }
5717
+ const mergedContent = inlineContent + toMerge;
5718
+ if (mergedWordCount >= words.length) {
5719
+ return {
5720
+ mergedContent,
5721
+ shouldStop: false,
5722
+ wrappedLines: []
5723
+ };
5724
+ }
5725
+ const remainingWords = words.slice(mergedWordCount);
5726
+ const wrappedLines = this.wrapRemainingWords(remainingWords, wrapWidth);
5727
+ return {
5728
+ mergedContent,
5729
+ shouldStop: true,
5730
+ wrappedLines
5731
+ };
5732
+ }
5733
+ /**
5734
+ * Render adjacent inline elements together on one line
5735
+ */
5736
+ renderAdjacentInlineElements(children, count) {
5737
+ let inlineContent = "";
5738
+ let processedCount = 0;
5739
+ let lastProcessedIndex = -1;
5740
+ const processedIndices = new Set();
5741
+ for (let index = 0; index < children.length && processedCount < count; index++) {
5742
+ const child = children[index];
5743
+ if (isPureWhitespaceNode(child) || isNode(child, WhitespaceNode)) {
5744
+ continue;
5745
+ }
5746
+ if (isNode(child, HTMLElementNode) && isInlineElement(getTagName(child))) {
5747
+ inlineContent += this.renderInlineElementAsString(child);
5748
+ processedCount++;
5749
+ lastProcessedIndex = index;
5750
+ processedIndices.add(index);
5751
+ if (inlineContent && isLineBreakingElement(child)) {
5752
+ this.pushWithIndent(inlineContent);
5753
+ inlineContent = "";
5754
+ }
5755
+ }
5756
+ else if (isNode(child, ERBContentNode)) {
5757
+ inlineContent += this.renderERBAsString(child);
5758
+ processedCount++;
5759
+ lastProcessedIndex = index;
5760
+ processedIndices.add(index);
5761
+ }
5762
+ }
5763
+ if (lastProcessedIndex >= 0) {
5764
+ for (let index = lastProcessedIndex + 1; index < children.length; index++) {
5765
+ const child = children[index];
5766
+ if (isPureWhitespaceNode(child) || isNode(child, WhitespaceNode)) {
5767
+ continue;
5768
+ }
5769
+ if (isNode(child, ERBContentNode)) {
5770
+ inlineContent += this.renderERBAsString(child);
5771
+ processedIndices.add(index);
5772
+ continue;
5773
+ }
5774
+ if (isNode(child, HTMLTextNode)) {
5775
+ const trimmed = child.content.trim();
5776
+ if (trimmed && /^[.!?:;]/.test(trimmed)) {
5777
+ const wrapWidth = this.maxLineLength - this.indent.length;
5778
+ const result = this.tryMergePunctuationText(inlineContent, trimmed, wrapWidth);
5779
+ inlineContent = result.mergedContent;
5780
+ processedIndices.add(index);
5781
+ if (result.shouldStop) {
5782
+ if (inlineContent) {
5783
+ this.pushWithIndent(inlineContent);
5784
+ }
5785
+ result.wrappedLines.forEach(line => this.push(line));
5786
+ return { processedIndices };
5787
+ }
5025
5788
  }
5026
5789
  }
5790
+ break;
5027
5791
  }
5028
- else if (isNode(child, HTMLElementNode)) {
5029
- const childTagName = getTagName(child);
5030
- if (this.isInlineElement(childTagName)) {
5031
- const childInline = this.tryRenderInlineFull(child, childTagName, filterNodes(child.open_tag?.children, HTMLAttributeNode), this.filterEmptyNodes(child.body));
5032
- if (childInline) {
5033
- currentLineContent += childInline;
5034
- if ((this.indent.length + currentLineContent.length) > this.maxLineLength) {
5035
- children.forEach(child => this.visit(child));
5036
- return;
5792
+ }
5793
+ if (inlineContent) {
5794
+ this.pushWithIndent(inlineContent);
5795
+ }
5796
+ return { processedIndices };
5797
+ }
5798
+ /**
5799
+ * Render an inline element as a string
5800
+ */
5801
+ renderInlineElementAsString(element) {
5802
+ const tagName = getTagName(element);
5803
+ if (element.is_void || element.open_tag?.tag_closing?.value === "/>") {
5804
+ const attributes = filterNodes(element.open_tag?.children, HTMLAttributeNode);
5805
+ const attributesString = this.renderAttributesString(attributes);
5806
+ const isSelfClosing = element.open_tag?.tag_closing?.value === "/>";
5807
+ return `<${tagName}${attributesString}${isSelfClosing ? " />" : ">"}`;
5808
+ }
5809
+ const childrenToRender = this.getFilteredChildren(element.body);
5810
+ const childInline = this.tryRenderInlineFull(element, tagName, filterNodes(element.open_tag?.children, HTMLAttributeNode), childrenToRender);
5811
+ return childInline !== null ? childInline : "";
5812
+ }
5813
+ /**
5814
+ * Render an ERB node as a string
5815
+ */
5816
+ renderERBAsString(node) {
5817
+ return this.capture(() => {
5818
+ this.inlineMode = true;
5819
+ this.visit(node);
5820
+ }).join("");
5821
+ }
5822
+ /**
5823
+ * Visit remaining children after processing adjacent inline elements
5824
+ */
5825
+ visitRemainingChildren(children, processedIndices) {
5826
+ for (let index = 0; index < children.length; index++) {
5827
+ const child = children[index];
5828
+ if (isPureWhitespaceNode(child) || isNode(child, WhitespaceNode)) {
5829
+ continue;
5830
+ }
5831
+ if (processedIndices.has(index)) {
5832
+ continue;
5833
+ }
5834
+ this.visit(child);
5835
+ }
5836
+ }
5837
+ /**
5838
+ * Build words array from text/inline/ERB and wrap them
5839
+ */
5840
+ buildAndWrapTextFlow(children) {
5841
+ const unitsWithNodes = this.buildContentUnitsWithNodes(children);
5842
+ const words = [];
5843
+ for (const { unit, node } of unitsWithNodes) {
5844
+ if (unit.breaksFlow) {
5845
+ this.flushWords(words);
5846
+ if (node) {
5847
+ this.visit(node);
5848
+ }
5849
+ }
5850
+ else if (unit.isAtomic) {
5851
+ words.push({ word: unit.content, isHerbDisable: unit.isHerbDisable || false });
5852
+ }
5853
+ else {
5854
+ const text = unit.content.replace(/\s+/g, ' ');
5855
+ const hasLeadingSpace = text.startsWith(' ');
5856
+ const hasTrailingSpace = text.endsWith(' ');
5857
+ const trimmedText = text.trim();
5858
+ if (trimmedText) {
5859
+ if (hasLeadingSpace && words.length > 0) {
5860
+ const lastWord = words[words.length - 1];
5861
+ if (!lastWord.word.endsWith(' ')) {
5862
+ lastWord.word += ' ';
5037
5863
  }
5038
5864
  }
5039
- else {
5040
- if (currentLineContent.trim()) {
5041
- this.pushWithIndent(currentLineContent.trim());
5042
- currentLineContent = "";
5865
+ const textWords = trimmedText.split(' ').map(w => ({ word: w, isHerbDisable: false }));
5866
+ words.push(...textWords);
5867
+ if (hasTrailingSpace && words.length > 0) {
5868
+ const lastWord = words[words.length - 1];
5869
+ if (!isClosingPunctuation(lastWord.word)) {
5870
+ lastWord.word += ' ';
5043
5871
  }
5044
- this.visit(child);
5045
5872
  }
5046
5873
  }
5047
- else {
5048
- if (currentLineContent.trim()) {
5049
- this.pushWithIndent(currentLineContent.trim());
5050
- currentLineContent = "";
5874
+ else if (text === ' ' && words.length > 0) {
5875
+ const lastWord = words[words.length - 1];
5876
+ if (!lastWord.word.endsWith(' ')) {
5877
+ lastWord.word += ' ';
5051
5878
  }
5052
- this.visit(child);
5053
5879
  }
5054
5880
  }
5055
- else if (isNode(child, ERBContentNode)) {
5056
- const oldLines = this.lines;
5057
- const oldInlineMode = this.inlineMode;
5058
- // TODO: use this.capture
5059
- try {
5060
- this.lines = [];
5061
- this.inlineMode = true;
5062
- this.visit(child);
5063
- const erbContent = this.lines.join("");
5064
- currentLineContent += erbContent;
5065
- if ((this.indent.length + currentLineContent.length) > Math.max(this.maxLineLength, 120)) {
5066
- this.lines = oldLines;
5067
- this.inlineMode = oldInlineMode;
5068
- children.forEach(child => this.visit(child));
5069
- return;
5881
+ }
5882
+ this.flushWords(words);
5883
+ }
5884
+ /**
5885
+ * Try to merge text that follows an atomic unit (ERB/inline) with no whitespace
5886
+ * Returns true if merge was performed
5887
+ */
5888
+ tryMergeTextAfterAtomic(result, textNode) {
5889
+ if (result.length === 0)
5890
+ return false;
5891
+ const lastUnit = result[result.length - 1];
5892
+ if (!lastUnit.unit.isAtomic || (lastUnit.unit.type !== 'erb' && lastUnit.unit.type !== 'inline')) {
5893
+ return false;
5894
+ }
5895
+ const words = normalizeAndSplitWords(textNode.content);
5896
+ if (words.length === 0 || !words[0])
5897
+ return false;
5898
+ const firstWord = words[0];
5899
+ const firstChar = firstWord[0];
5900
+ if (/\s/.test(firstChar)) {
5901
+ return false;
5902
+ }
5903
+ lastUnit.unit.content += firstWord;
5904
+ if (words.length > 1) {
5905
+ let remainingText = words.slice(1).join(' ');
5906
+ if (endsWithWhitespace(textNode.content)) {
5907
+ remainingText += ' ';
5908
+ }
5909
+ result.push({
5910
+ unit: { content: remainingText, type: 'text', isAtomic: false, breaksFlow: false },
5911
+ node: textNode
5912
+ });
5913
+ }
5914
+ else if (endsWithWhitespace(textNode.content)) {
5915
+ result.push({
5916
+ unit: { content: ' ', type: 'text', isAtomic: false, breaksFlow: false },
5917
+ node: textNode
5918
+ });
5919
+ }
5920
+ return true;
5921
+ }
5922
+ /**
5923
+ * Try to merge an atomic unit (ERB/inline) with preceding text that has no whitespace
5924
+ * Returns true if merge was performed
5925
+ */
5926
+ tryMergeAtomicAfterText(result, children, lastProcessedIndex, atomicContent, atomicType, atomicNode) {
5927
+ if (result.length === 0)
5928
+ return false;
5929
+ const lastUnit = result[result.length - 1];
5930
+ if (lastUnit.unit.type !== 'text' || lastUnit.unit.isAtomic)
5931
+ return false;
5932
+ const words = normalizeAndSplitWords(lastUnit.unit.content);
5933
+ const lastWord = words[words.length - 1];
5934
+ if (!lastWord)
5935
+ return false;
5936
+ result.pop();
5937
+ if (words.length > 1) {
5938
+ const remainingText = words.slice(0, -1).join(' ');
5939
+ result.push({
5940
+ unit: { content: remainingText, type: 'text', isAtomic: false, breaksFlow: false },
5941
+ node: children[lastProcessedIndex]
5942
+ });
5943
+ }
5944
+ result.push({
5945
+ unit: { content: lastWord + atomicContent, type: atomicType, isAtomic: true, breaksFlow: false },
5946
+ node: atomicNode
5947
+ });
5948
+ return true;
5949
+ }
5950
+ /**
5951
+ * Check if there's whitespace between current node and last processed node
5952
+ */
5953
+ hasWhitespaceBeforeNode(children, lastProcessedIndex, currentIndex, currentNode) {
5954
+ if (hasWhitespaceBetween(children, lastProcessedIndex, currentIndex)) {
5955
+ return true;
5956
+ }
5957
+ if (isNode(currentNode, HTMLTextNode) && /^\s/.test(currentNode.content)) {
5958
+ return true;
5959
+ }
5960
+ return false;
5961
+ }
5962
+ /**
5963
+ * Check if last unit in result ends with whitespace
5964
+ */
5965
+ lastUnitEndsWithWhitespace(result) {
5966
+ if (result.length === 0)
5967
+ return false;
5968
+ const lastUnit = result[result.length - 1];
5969
+ return lastUnit.unit.type === 'text' && endsWithWhitespace(lastUnit.unit.content);
5970
+ }
5971
+ /**
5972
+ * Process a text node and add it to results (with potential merging)
5973
+ */
5974
+ processTextNode(result, children, child, index, lastProcessedIndex) {
5975
+ const isAtomic = child.content === ' ';
5976
+ if (!isAtomic && lastProcessedIndex >= 0 && result.length > 0) {
5977
+ const hasWhitespace = this.hasWhitespaceBeforeNode(children, lastProcessedIndex, index, child);
5978
+ const lastUnit = result[result.length - 1];
5979
+ const lastIsAtomic = lastUnit.unit.isAtomic && (lastUnit.unit.type === 'erb' || lastUnit.unit.type === 'inline');
5980
+ if (lastIsAtomic && !hasWhitespace && this.tryMergeTextAfterAtomic(result, child)) {
5981
+ return;
5982
+ }
5983
+ }
5984
+ result.push({
5985
+ unit: { content: child.content, type: 'text', isAtomic, breaksFlow: false },
5986
+ node: child
5987
+ });
5988
+ }
5989
+ /**
5990
+ * Process an inline element and add it to results (with potential merging)
5991
+ */
5992
+ processInlineElement(result, children, child, index, lastProcessedIndex) {
5993
+ const tagName = getTagName(child);
5994
+ const childrenToRender = this.getFilteredChildren(child.body);
5995
+ const inlineContent = this.tryRenderInlineFull(child, tagName, filterNodes(child.open_tag?.children, HTMLAttributeNode), childrenToRender);
5996
+ if (inlineContent === null) {
5997
+ result.push({
5998
+ unit: { content: '', type: 'block', isAtomic: false, breaksFlow: true },
5999
+ node: child
6000
+ });
6001
+ return false;
6002
+ }
6003
+ if (lastProcessedIndex >= 0) {
6004
+ const hasWhitespace = hasWhitespaceBetween(children, lastProcessedIndex, index) || this.lastUnitEndsWithWhitespace(result);
6005
+ if (!hasWhitespace && this.tryMergeAtomicAfterText(result, children, lastProcessedIndex, inlineContent, 'inline', child)) {
6006
+ return true;
6007
+ }
6008
+ }
6009
+ result.push({
6010
+ unit: { content: inlineContent, type: 'inline', isAtomic: true, breaksFlow: false },
6011
+ node: child
6012
+ });
6013
+ return false;
6014
+ }
6015
+ /**
6016
+ * Process an ERB content node and add it to results (with potential merging)
6017
+ */
6018
+ processERBContentNode(result, children, child, index, lastProcessedIndex) {
6019
+ const erbContent = this.renderERBAsString(child);
6020
+ const isHerbDisable = isHerbDisableComment(child);
6021
+ if (lastProcessedIndex >= 0) {
6022
+ const hasWhitespace = hasWhitespaceBetween(children, lastProcessedIndex, index) || this.lastUnitEndsWithWhitespace(result);
6023
+ if (!hasWhitespace && this.tryMergeAtomicAfterText(result, children, lastProcessedIndex, erbContent, 'erb', child)) {
6024
+ return true;
6025
+ }
6026
+ if (hasWhitespace && result.length > 0) {
6027
+ const lastUnit = result[result.length - 1];
6028
+ const lastIsAtomic = lastUnit.unit.isAtomic && (lastUnit.unit.type === 'inline' || lastUnit.unit.type === 'erb');
6029
+ if (lastIsAtomic && !this.lastUnitEndsWithWhitespace(result)) {
6030
+ result.push({
6031
+ unit: { content: ' ', type: 'text', isAtomic: true, breaksFlow: false },
6032
+ node: null
6033
+ });
6034
+ }
6035
+ }
6036
+ }
6037
+ result.push({
6038
+ unit: { content: erbContent, type: 'erb', isAtomic: true, breaksFlow: false, isHerbDisable },
6039
+ node: child
6040
+ });
6041
+ return false;
6042
+ }
6043
+ /**
6044
+ * Convert AST nodes to content units with node references
6045
+ */
6046
+ buildContentUnitsWithNodes(children) {
6047
+ const result = [];
6048
+ let lastProcessedIndex = -1;
6049
+ for (let i = 0; i < children.length; i++) {
6050
+ const child = children[i];
6051
+ if (isNode(child, WhitespaceNode))
6052
+ continue;
6053
+ if (isPureWhitespaceNode(child) && !(isNode(child, HTMLTextNode) && child.content === ' ')) {
6054
+ if (lastProcessedIndex >= 0) {
6055
+ const hasNonWhitespaceAfter = children.slice(i + 1).some(node => !isNode(node, WhitespaceNode) && !isPureWhitespaceNode(node));
6056
+ if (hasNonWhitespaceAfter) {
6057
+ const previousNode = children[lastProcessedIndex];
6058
+ if (!isLineBreakingElement(previousNode)) {
6059
+ result.push({
6060
+ unit: { content: ' ', type: 'text', isAtomic: true, breaksFlow: false },
6061
+ node: child
6062
+ });
6063
+ }
6064
+ }
6065
+ }
6066
+ continue;
6067
+ }
6068
+ if (isNode(child, HTMLTextNode)) {
6069
+ this.processTextNode(result, children, child, i, lastProcessedIndex);
6070
+ lastProcessedIndex = i;
6071
+ }
6072
+ else if (isNode(child, HTMLElementNode)) {
6073
+ const tagName = getTagName(child);
6074
+ if (isInlineElement(tagName)) {
6075
+ const merged = this.processInlineElement(result, children, child, i, lastProcessedIndex);
6076
+ if (merged) {
6077
+ lastProcessedIndex = i;
6078
+ continue;
5070
6079
  }
5071
6080
  }
5072
- finally {
5073
- this.lines = oldLines;
5074
- this.inlineMode = oldInlineMode;
6081
+ else {
6082
+ result.push({
6083
+ unit: { content: '', type: 'block', isAtomic: false, breaksFlow: true },
6084
+ node: child
6085
+ });
5075
6086
  }
6087
+ lastProcessedIndex = i;
5076
6088
  }
5077
- else {
5078
- if (currentLineContent.trim()) {
5079
- this.pushWithIndent(currentLineContent.trim());
5080
- currentLineContent = "";
6089
+ else if (isNode(child, ERBContentNode)) {
6090
+ const merged = this.processERBContentNode(result, children, child, i, lastProcessedIndex);
6091
+ if (merged) {
6092
+ lastProcessedIndex = i;
6093
+ continue;
5081
6094
  }
5082
- this.visit(child);
6095
+ lastProcessedIndex = i;
6096
+ }
6097
+ else {
6098
+ result.push({
6099
+ unit: { content: '', type: 'block', isAtomic: false, breaksFlow: true },
6100
+ node: child
6101
+ });
6102
+ lastProcessedIndex = i;
5083
6103
  }
5084
6104
  }
5085
- if (currentLineContent.trim()) {
5086
- const finalLine = this.indent + currentLineContent.trim();
5087
- if (finalLine.length > Math.max(this.maxLineLength, 120)) {
5088
- this.visitAll(children);
5089
- return;
6105
+ return result;
6106
+ }
6107
+ /**
6108
+ * Flush accumulated words to output with wrapping
6109
+ */
6110
+ flushWords(words) {
6111
+ if (words.length > 0) {
6112
+ this.wrapAndPushWords(words);
6113
+ words.length = 0;
6114
+ }
6115
+ }
6116
+ /**
6117
+ * Wrap words to fit within line length and push to output
6118
+ * Handles punctuation spacing intelligently
6119
+ * Excludes herb:disable comments from line length calculations
6120
+ */
6121
+ wrapAndPushWords(words) {
6122
+ const wrapWidth = this.maxLineLength - this.indent.length;
6123
+ const lines = [];
6124
+ let currentLine = "";
6125
+ let effectiveLength = 0;
6126
+ for (const { word, isHerbDisable } of words) {
6127
+ const nextLine = buildLineWithWord(currentLine, word);
6128
+ let nextEffectiveLength = effectiveLength;
6129
+ if (!isHerbDisable) {
6130
+ const spaceBefore = currentLine && needsSpaceBetween(currentLine, word) ? 1 : 0;
6131
+ nextEffectiveLength = effectiveLength + spaceBefore + word.length;
6132
+ }
6133
+ if (currentLine && !isClosingPunctuation(word) && nextEffectiveLength >= wrapWidth) {
6134
+ lines.push(this.indent + currentLine.trimEnd());
6135
+ currentLine = word;
6136
+ effectiveLength = isHerbDisable ? 0 : word.length;
6137
+ }
6138
+ else {
6139
+ currentLine = nextLine;
6140
+ effectiveLength = nextEffectiveLength;
5090
6141
  }
5091
- this.push(finalLine);
5092
6142
  }
6143
+ if (currentLine) {
6144
+ lines.push(this.indent + currentLine.trimEnd());
6145
+ }
6146
+ lines.forEach(line => this.push(line));
5093
6147
  }
5094
6148
  isInTextFlowContext(_parent, children) {
5095
6149
  const hasTextContent = children.some(child => isNode(child, HTMLTextNode) && child.content.trim() !== "");
@@ -5102,7 +6156,7 @@ class FormatPrinter extends Printer {
5102
6156
  if (isNode(child, ERBContentNode))
5103
6157
  return true;
5104
6158
  if (isNode(child, HTMLElementNode)) {
5105
- return this.isInlineElement(getTagName(child));
6159
+ return isInlineElement(getTagName(child));
5106
6160
  }
5107
6161
  return false;
5108
6162
  });
@@ -5172,7 +6226,7 @@ class FormatPrinter extends Printer {
5172
6226
  }
5173
6227
  else {
5174
6228
  const printed = IdentityPrinter.print(child);
5175
- if (this.currentAttributeName && FormatPrinter.TOKEN_LIST_ATTRIBUTES.has(this.currentAttributeName)) {
6229
+ if (this.isInTokenListAttribute) {
5176
6230
  return printed.replace(/%>([^<\s])/g, '%> $1').replace(/([^>\s])<%/g, '$1 <%');
5177
6231
  }
5178
6232
  return printed;
@@ -5208,18 +6262,35 @@ class FormatPrinter extends Printer {
5208
6262
  let result = `<${tagName}`;
5209
6263
  result += this.renderAttributesString(attributes);
5210
6264
  result += ">";
5211
- const childrenContent = this.tryRenderChildrenInline(children);
6265
+ const childrenContent = this.tryRenderChildrenInline(children, tagName);
5212
6266
  if (!childrenContent)
5213
6267
  return null;
5214
6268
  result += childrenContent;
5215
6269
  result += `</${tagName}>`;
5216
6270
  return result;
5217
6271
  }
6272
+ /**
6273
+ * Check if children contain a leading herb:disable comment (after optional whitespace)
6274
+ */
6275
+ hasLeadingHerbDisable(children) {
6276
+ for (const child of children) {
6277
+ if (isNode(child, WhitespaceNode) || (isNode(child, HTMLTextNode) && child.content.trim() === "")) {
6278
+ continue;
6279
+ }
6280
+ return isNode(child, ERBContentNode) && isHerbDisableComment(child);
6281
+ }
6282
+ return false;
6283
+ }
5218
6284
  /**
5219
6285
  * Try to render just the children inline (without tags)
5220
6286
  */
5221
- tryRenderChildrenInline(children) {
6287
+ tryRenderChildrenInline(children, tagName) {
5222
6288
  let result = "";
6289
+ let hasInternalWhitespace = false;
6290
+ let addedLeadingSpace = false;
6291
+ const hasHerbDisable = this.hasLeadingHerbDisable(children);
6292
+ const hasOnlyTextContent = children.every(child => isNode(child, HTMLTextNode) || isNode(child, WhitespaceNode));
6293
+ const shouldPreserveSpaces = hasOnlyTextContent && tagName && isInlineElement(tagName);
5223
6294
  for (const child of children) {
5224
6295
  if (isNode(child, HTMLTextNode)) {
5225
6296
  const normalizedContent = child.content.replace(/\s+/g, ' ');
@@ -5227,36 +6298,53 @@ class FormatPrinter extends Printer {
5227
6298
  const hasTrailingSpace = /\s$/.test(child.content);
5228
6299
  const trimmedContent = normalizedContent.trim();
5229
6300
  if (trimmedContent) {
5230
- let finalContent = trimmedContent;
5231
- if (hasLeadingSpace && result && !result.endsWith(' ')) {
5232
- finalContent = ' ' + finalContent;
6301
+ if (hasLeadingSpace && (result || shouldPreserveSpaces) && !result.endsWith(' ')) {
6302
+ result += ' ';
5233
6303
  }
6304
+ result += trimmedContent;
5234
6305
  if (hasTrailingSpace) {
5235
- finalContent = finalContent + ' ';
5236
- }
5237
- result += finalContent;
5238
- }
5239
- else if (hasLeadingSpace || hasTrailingSpace) {
5240
- if (result && !result.endsWith(' ')) {
5241
6306
  result += ' ';
5242
6307
  }
6308
+ continue;
6309
+ }
6310
+ }
6311
+ const isWhitespace = isNode(child, WhitespaceNode) || (isNode(child, HTMLTextNode) && child.content.trim() === "");
6312
+ if (isWhitespace && !result.endsWith(' ')) {
6313
+ if (!result && hasHerbDisable && !addedLeadingSpace) {
6314
+ result += ' ';
6315
+ addedLeadingSpace = true;
6316
+ }
6317
+ else if (result) {
6318
+ result += ' ';
6319
+ hasInternalWhitespace = true;
5243
6320
  }
5244
6321
  }
5245
6322
  else if (isNode(child, HTMLElementNode)) {
5246
6323
  const tagName = getTagName(child);
5247
- if (!this.isInlineElement(tagName)) {
6324
+ if (!isInlineElement(tagName)) {
5248
6325
  return null;
5249
6326
  }
5250
- const childInline = this.tryRenderInlineFull(child, tagName, filterNodes(child.open_tag?.children, HTMLAttributeNode), this.filterEmptyNodes(child.body));
6327
+ const childrenToRender = this.getFilteredChildren(child.body);
6328
+ const childInline = this.tryRenderInlineFull(child, tagName, filterNodes(child.open_tag?.children, HTMLAttributeNode), childrenToRender);
5251
6329
  if (!childInline) {
5252
6330
  return null;
5253
6331
  }
5254
6332
  result += childInline;
5255
6333
  }
5256
- else {
5257
- result += this.capture(() => this.visit(child)).join("");
6334
+ else if (!isNode(child, HTMLTextNode) && !isWhitespace) {
6335
+ const wasInlineMode = this.inlineMode;
6336
+ this.inlineMode = true;
6337
+ const captured = this.capture(() => this.visit(child)).join("");
6338
+ this.inlineMode = wasInlineMode;
6339
+ result += captured;
5258
6340
  }
5259
6341
  }
6342
+ if (shouldPreserveSpaces) {
6343
+ return result;
6344
+ }
6345
+ if (hasHerbDisable && result.startsWith(' ') || hasInternalWhitespace) {
6346
+ return result.trimEnd();
6347
+ }
5260
6348
  return result.trim();
5261
6349
  }
5262
6350
  /**
@@ -5271,8 +6359,7 @@ class FormatPrinter extends Printer {
5271
6359
  }
5272
6360
  }
5273
6361
  else if (isNode(child, HTMLElementNode)) {
5274
- const isInlineElement = this.isInlineElement(getTagName(child));
5275
- if (!isInlineElement) {
6362
+ if (!isInlineElement(getTagName(child))) {
5276
6363
  return null;
5277
6364
  }
5278
6365
  }
@@ -5288,103 +6375,14 @@ class FormatPrinter extends Printer {
5288
6375
  return `<${tagName}>${content}</${tagName}>`;
5289
6376
  }
5290
6377
  /**
5291
- * Check if children contain mixed text and inline elements (like "text<em>inline</em>text")
5292
- * or mixed ERB output and text (like "<%= value %> text")
5293
- * This indicates content that should be formatted inline even with structural newlines
5294
- */
5295
- hasMixedTextAndInlineContent(children) {
5296
- let hasText = false;
5297
- let hasInlineElements = false;
5298
- for (const child of children) {
5299
- if (isNode(child, HTMLTextNode)) {
5300
- if (child.content.trim() !== "") {
5301
- hasText = true;
5302
- }
5303
- }
5304
- else if (isNode(child, HTMLElementNode)) {
5305
- if (this.isInlineElement(getTagName(child))) {
5306
- hasInlineElements = true;
5307
- }
5308
- }
5309
- }
5310
- return (hasText && hasInlineElements) || (hasERBOutput(children) && hasText);
5311
- }
5312
- /**
5313
- * Check if children contain any text content with newlines
5314
- */
5315
- hasMultilineTextContent(children) {
5316
- for (const child of children) {
5317
- if (isNode(child, HTMLTextNode)) {
5318
- return child.content.includes('\n');
5319
- }
5320
- if (isNode(child, HTMLElementNode)) {
5321
- const nestedChildren = this.filterEmptyNodes(child.body);
5322
- if (this.hasMultilineTextContent(nestedChildren)) {
5323
- return true;
5324
- }
5325
- }
5326
- }
5327
- return false;
5328
- }
5329
- /**
5330
- * Check if all nested elements in the children are inline elements
5331
- */
5332
- areAllNestedElementsInline(children) {
5333
- for (const child of children) {
5334
- if (isNode(child, HTMLElementNode)) {
5335
- if (!this.isInlineElement(getTagName(child))) {
5336
- return false;
5337
- }
5338
- const nestedChildren = this.filterEmptyNodes(child.body);
5339
- if (!this.areAllNestedElementsInline(nestedChildren)) {
5340
- return false;
5341
- }
5342
- }
5343
- else if (isAnyOf(child, HTMLDoctypeNode, HTMLCommentNode, isERBControlFlowNode)) {
5344
- return false;
5345
- }
5346
- }
5347
- return true;
5348
- }
5349
- /**
5350
- * Check if element has complex ERB control flow
6378
+ * Get filtered children, using smart herb:disable filtering if needed
5351
6379
  */
5352
- hasComplexERBControlFlow(inlineNodes) {
5353
- return inlineNodes.some(node => {
5354
- if (isNode(node, ERBIfNode)) {
5355
- if (node.statements.length > 0 && node.location) {
5356
- const startLine = node.location.start.line;
5357
- const endLine = node.location.end.line;
5358
- return startLine !== endLine;
5359
- }
5360
- return false;
5361
- }
5362
- return false;
5363
- });
5364
- }
5365
- /**
5366
- * Filter children to remove insignificant whitespace
5367
- */
5368
- filterSignificantChildren(body, hasTextFlow) {
5369
- return body.filter(child => {
5370
- if (isNode(child, WhitespaceNode))
5371
- return false;
5372
- if (isNode(child, HTMLTextNode)) {
5373
- if (hasTextFlow && child.content === " ")
5374
- return true;
5375
- return child.content.trim() !== "";
5376
- }
5377
- return true;
5378
- });
5379
- }
5380
- /**
5381
- * Filter out empty text nodes and whitespace nodes
5382
- */
5383
- filterEmptyNodes(nodes) {
5384
- return nodes.filter(child => !isNode(child, WhitespaceNode) && !(isNode(child, HTMLTextNode) && child.content.trim() === ""));
6380
+ getFilteredChildren(body) {
6381
+ const hasHerbDisable = body.some(child => isNode(child, ERBContentNode) && isHerbDisableComment(child));
6382
+ return hasHerbDisable ? filterEmptyNodesForHerbDisable(body) : body;
5385
6383
  }
5386
6384
  renderElementInline(element) {
5387
- const children = this.filterEmptyNodes(element.body);
6385
+ const children = this.getFilteredChildren(element.body);
5388
6386
  return this.renderChildrenInline(children);
5389
6387
  }
5390
6388
  renderChildrenInline(children) {
@@ -5406,9 +6404,29 @@ class FormatPrinter extends Printer {
5406
6404
  }
5407
6405
  return content.replace(/\s+/g, ' ').trim();
5408
6406
  }
5409
- isContentPreserving(element) {
5410
- const tagName = getTagName(element);
5411
- return FormatPrinter.CONTENT_PRESERVING_ELEMENTS.has(tagName);
6407
+ }
6408
+
6409
+ const isScaffoldTemplate = (result) => {
6410
+ const detector = new ScaffoldTemplateDetector();
6411
+ detector.visit(result.value);
6412
+ return detector.hasEscapedERB;
6413
+ };
6414
+ /**
6415
+ * Visitor that detects if the AST represents a Rails scaffold template.
6416
+ * Scaffold templates contain escaped ERB tags (<%%= or <%%)
6417
+ * and should not be formatted to preserve their exact structure.
6418
+ */
6419
+ class ScaffoldTemplateDetector extends Visitor {
6420
+ hasEscapedERB = false;
6421
+ visitERBContentNode(node) {
6422
+ const opening = node.tag_opening?.value;
6423
+ if (opening && opening.startsWith("<%%")) {
6424
+ this.hasEscapedERB = true;
6425
+ return;
6426
+ }
6427
+ if (this.hasEscapedERB)
6428
+ return;
6429
+ this.visitChildNodes(node);
5412
6430
  }
5413
6431
  }
5414
6432
 
@@ -5418,6 +6436,8 @@ class FormatPrinter extends Printer {
5418
6436
  const defaultFormatOptions = {
5419
6437
  indentWidth: 2,
5420
6438
  maxLineLength: 80,
6439
+ preRewriters: [],
6440
+ postRewriters: [],
5421
6441
  };
5422
6442
  /**
5423
6443
  * Merge provided options with defaults for any missing values.
@@ -5428,6 +6448,8 @@ function resolveFormatOptions(options = {}) {
5428
6448
  return {
5429
6449
  indentWidth: options.indentWidth ?? defaultFormatOptions.indentWidth,
5430
6450
  maxLineLength: options.maxLineLength ?? defaultFormatOptions.maxLineLength,
6451
+ preRewriters: options.preRewriters ?? defaultFormatOptions.preRewriters,
6452
+ postRewriters: options.postRewriters ?? defaultFormatOptions.postRewriters,
5431
6453
  };
5432
6454
  }
5433
6455
 
@@ -5438,6 +6460,30 @@ function resolveFormatOptions(options = {}) {
5438
6460
  class Formatter {
5439
6461
  herb;
5440
6462
  options;
6463
+ /**
6464
+ * Creates a Formatter instance from a Config object (recommended).
6465
+ *
6466
+ * @param herb - The Herb backend instance for parsing
6467
+ * @param config - Optional Config instance for formatter options
6468
+ * @param options - Additional options to override config
6469
+ * @returns A configured Formatter instance
6470
+ */
6471
+ static from(herb, config, options = {}) {
6472
+ const formatterConfig = config?.formatter || {};
6473
+ const mergedOptions = {
6474
+ indentWidth: options.indentWidth ?? formatterConfig.indentWidth,
6475
+ maxLineLength: options.maxLineLength ?? formatterConfig.maxLineLength,
6476
+ preRewriters: options.preRewriters,
6477
+ postRewriters: options.postRewriters,
6478
+ };
6479
+ return new Formatter(herb, mergedOptions);
6480
+ }
6481
+ /**
6482
+ * Creates a new Formatter instance.
6483
+ *
6484
+ * @param herb - The Herb backend instance for parsing
6485
+ * @param options - Format options (including rewriters)
6486
+ */
5441
6487
  constructor(herb, options = {}) {
5442
6488
  this.herb = herb;
5443
6489
  this.options = resolveFormatOptions(options);
@@ -5445,12 +6491,44 @@ class Formatter {
5445
6491
  /**
5446
6492
  * Format a source string, optionally overriding format options per call.
5447
6493
  */
5448
- format(source, options = {}) {
5449
- const result = this.parse(source);
6494
+ format(source, options = {}, filePath) {
6495
+ let result = this.parse(source);
5450
6496
  if (result.failed)
5451
6497
  return source;
6498
+ if (isScaffoldTemplate(result))
6499
+ return source;
5452
6500
  const resolvedOptions = resolveFormatOptions({ ...this.options, ...options });
5453
- return new FormatPrinter(source, resolvedOptions).print(result.value);
6501
+ let node = result.value;
6502
+ if (resolvedOptions.preRewriters.length > 0) {
6503
+ const context = {
6504
+ filePath,
6505
+ baseDir: process.cwd() // TODO: format() shouldn't depend on node internals
6506
+ };
6507
+ for (const rewriter of resolvedOptions.preRewriters) {
6508
+ try {
6509
+ node = rewriter.rewrite(node, context);
6510
+ }
6511
+ catch (error) {
6512
+ console.error(`Pre-format rewriter "${rewriter.name}" failed:`, error);
6513
+ }
6514
+ }
6515
+ }
6516
+ let formatted = new FormatPrinter(source, resolvedOptions).print(node);
6517
+ if (resolvedOptions.postRewriters.length > 0) {
6518
+ const context = {
6519
+ filePath,
6520
+ baseDir: process.cwd() // TODO: format() shouldn't depend on node internals
6521
+ };
6522
+ for (const rewriter of resolvedOptions.postRewriters) {
6523
+ try {
6524
+ formatted = rewriter.rewrite(formatted, context);
6525
+ }
6526
+ catch (error) {
6527
+ console.error(`Post-format rewriter "${rewriter.name}" failed:`, error);
6528
+ }
6529
+ }
6530
+ }
6531
+ return formatted;
5454
6532
  }
5455
6533
  parse(source) {
5456
6534
  this.herb.ensureBackend();