@herb-tools/formatter 0.7.5 → 0.8.0

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