@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.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.0/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.0/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.0/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.0/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,396 @@ 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
+ return /%>$/.test(text.trim());
4234
+ }
4235
+ /**
4236
+ * Check if a string starts with an ERB tag
4237
+ */
4238
+ function startsWithERBTag(text) {
4239
+ return /^<%/.test(text.trim());
4240
+ }
4241
+ /**
4242
+ * Determine if space is needed between the current line and the next word
4243
+ */
4244
+ function needsSpaceBetween(currentLine, word) {
4245
+ if (isClosingPunctuation(word))
4246
+ return false;
4247
+ if (lineEndsWithOpeningPunctuation(currentLine))
4248
+ return false;
4249
+ if (currentLine.endsWith(' '))
4250
+ return false;
4251
+ if (word.startsWith(' '))
4252
+ return false;
4253
+ if (endsWithERBTag(currentLine) && startsWithERBTag(word))
4254
+ return false;
4255
+ return true;
4256
+ }
4257
+ /**
4258
+ * Build a line by adding a word with appropriate spacing
4259
+ */
4260
+ function buildLineWithWord(currentLine, word) {
4261
+ if (!currentLine)
4262
+ return word;
4263
+ if (word === ' ') {
4264
+ return currentLine.endsWith(' ') ? currentLine : `${currentLine} `;
4265
+ }
4266
+ if (isClosingPunctuation(word)) {
4267
+ currentLine = currentLine.trimEnd();
4268
+ return `${currentLine}${word}`;
4269
+ }
4270
+ return needsSpaceBetween(currentLine, word) ? `${currentLine} ${word}` : `${currentLine}${word}`;
4271
+ }
4272
+ /**
4273
+ * Check if a node is an inline element or ERB node
4274
+ */
4275
+ function isInlineOrERBNode(node) {
4276
+ return isERBNode(node) || (isNode(node, HTMLElementNode) && isInlineElement(getTagName(node)));
4277
+ }
4278
+ /**
4279
+ * Check if an element should be treated as inline based on its tag name
4280
+ */
4281
+ function isInlineElement(tagName) {
4282
+ return INLINE_ELEMENTS.has(tagName.toLowerCase());
4283
+ }
4284
+ /**
4285
+ * Check if the current inline element is adjacent to a previous inline element (no whitespace between)
4286
+ */
4287
+ function isAdjacentToPreviousInline(siblings, index) {
4288
+ const previousNode = siblings[index - 1];
4289
+ if (isInlineOrERBNode(previousNode)) {
4290
+ return true;
4291
+ }
4292
+ if (index > 1 && isNode(previousNode, HTMLTextNode) && !/^\s/.test(previousNode.content)) {
4293
+ const twoBack = siblings[index - 2];
4294
+ return isInlineOrERBNode(twoBack);
4295
+ }
4296
+ return false;
4297
+ }
4298
+ /**
4299
+ * Check if a node should be appended to the last line (for adjacent inline elements and punctuation)
4300
+ */
4301
+ function shouldAppendToLastLine(child, siblings, index) {
4302
+ if (index === 0)
4303
+ return false;
4304
+ if (isNode(child, HTMLTextNode) && !/^\s/.test(child.content)) {
4305
+ const previousNode = siblings[index - 1];
4306
+ return isInlineOrERBNode(previousNode);
4307
+ }
4308
+ if (isNode(child, HTMLElementNode) && isInlineElement(getTagName(child))) {
4309
+ return isAdjacentToPreviousInline(siblings, index);
4310
+ }
4311
+ if (isNode(child, ERBContentNode)) {
4312
+ for (let i = index - 1; i >= 0; i--) {
4313
+ const previousSibling = siblings[i];
4314
+ if (isPureWhitespaceNode(previousSibling) || isNode(previousSibling, WhitespaceNode)) {
4315
+ continue;
4316
+ }
4317
+ if (previousSibling.location && child.location) {
4318
+ return previousSibling.location.end.line === child.location.start.line;
4319
+ }
4320
+ break;
4321
+ }
4322
+ }
4323
+ return false;
4324
+ }
4325
+ /**
4326
+ * Check if user-intentional spacing should be preserved (double newlines between elements)
4327
+ */
4328
+ function shouldPreserveUserSpacing(child, siblings, index) {
4329
+ if (!isPureWhitespaceNode(child))
4330
+ return false;
4331
+ const hasPreviousNonWhitespace = index > 0 && isNonWhitespaceNode(siblings[index - 1]);
4332
+ const hasNextNonWhitespace = index < siblings.length - 1 && isNonWhitespaceNode(siblings[index + 1]);
4333
+ const hasMultipleNewlines = isNode(child, HTMLTextNode) && child.content.includes('\n\n');
4334
+ return hasPreviousNonWhitespace && hasNextNonWhitespace && hasMultipleNewlines;
4335
+ }
4336
+ /**
4337
+ * Check if children contain any text content with newlines
4338
+ */
4339
+ function hasMultilineTextContent(children) {
4340
+ for (const child of children) {
4341
+ if (isNode(child, HTMLTextNode)) {
4342
+ return child.content.includes('\n');
4343
+ }
4344
+ if (isNode(child, HTMLElementNode) && hasMultilineTextContent(child.body)) {
4345
+ return true;
4346
+ }
4347
+ }
4348
+ return false;
4349
+ }
4350
+ /**
4351
+ * Check if all nested elements in the children are inline elements
4352
+ */
4353
+ function areAllNestedElementsInline(children) {
4354
+ for (const child of children) {
4355
+ if (isNode(child, HTMLElementNode)) {
4356
+ if (!isInlineElement(getTagName(child))) {
4357
+ return false;
4358
+ }
4359
+ if (!areAllNestedElementsInline(child.body)) {
4360
+ return false;
4361
+ }
4362
+ }
4363
+ else if (isAnyOf(child, HTMLDoctypeNode, HTMLCommentNode, isERBControlFlowNode)) {
4364
+ return false;
4365
+ }
4366
+ }
4367
+ return true;
4368
+ }
4369
+ /**
4370
+ * Check if element has complex ERB control flow
4371
+ */
4372
+ function hasComplexERBControlFlow(inlineNodes) {
4373
+ return inlineNodes.some(node => {
4374
+ if (isNode(node, ERBIfNode)) {
4375
+ if (node.statements.length > 0 && node.location) {
4376
+ const startLine = node.location.start.line;
4377
+ const endLine = node.location.end.line;
4378
+ return startLine !== endLine;
4379
+ }
4380
+ return false;
4381
+ }
4382
+ return false;
4383
+ });
4384
+ }
4385
+ /**
4386
+ * Check if children contain mixed text and inline elements (like "text<em>inline</em>text")
4387
+ * or mixed ERB output and text (like "<%= value %> text")
4388
+ * This indicates content that should be formatted inline even with structural newlines
4389
+ */
4390
+ function hasMixedTextAndInlineContent(children) {
4391
+ let hasText = false;
4392
+ let hasInlineElements = false;
4393
+ for (const child of children) {
4394
+ if (isNode(child, HTMLTextNode)) {
4395
+ if (child.content.trim() !== "") {
4396
+ hasText = true;
4397
+ }
4398
+ }
4399
+ else if (isNode(child, HTMLElementNode)) {
4400
+ if (isInlineElement(getTagName(child))) {
4401
+ hasInlineElements = true;
4402
+ }
4403
+ }
4404
+ }
4405
+ return (hasText && hasInlineElements) || (hasERBOutput(children) && hasText);
4406
+ }
4407
+ function isContentPreserving(element) {
4408
+ const tagName = getTagName(element);
4409
+ return CONTENT_PRESERVING_ELEMENTS.has(tagName);
4410
+ }
4411
+ /**
4412
+ * Count consecutive inline elements/ERB at the start of children (with no whitespace between)
4413
+ */
4414
+ function countAdjacentInlineElements(children) {
4415
+ let count = 0;
4416
+ let lastSignificantIndex = -1;
4417
+ for (let i = 0; i < children.length; i++) {
4418
+ const child = children[i];
4419
+ if (isPureWhitespaceNode(child) || isNode(child, WhitespaceNode)) {
4420
+ continue;
4421
+ }
4422
+ const isInlineOrERB = (isNode(child, HTMLElementNode) && isInlineElement(getTagName(child))) || isNode(child, ERBContentNode);
4423
+ if (!isInlineOrERB) {
4424
+ break;
4425
+ }
4426
+ if (lastSignificantIndex >= 0 && hasWhitespaceBetween(children, lastSignificantIndex, i)) {
4427
+ break;
4428
+ }
4429
+ count++;
4430
+ lastSignificantIndex = i;
4431
+ }
4432
+ return count;
4433
+ }
4434
+ /**
4435
+ * Check if a node represents a block-level element
4436
+ */
4437
+ function isBlockLevelNode(node) {
4438
+ if (!isNode(node, HTMLElementNode)) {
4439
+ return false;
4440
+ }
4441
+ const tagName = getTagName(node);
4442
+ if (INLINE_ELEMENTS.has(tagName)) {
4443
+ return false;
4444
+ }
4445
+ return true;
4446
+ }
4447
+ /**
4448
+ * Check if an element is a line-breaking element (br or hr)
4449
+ */
4450
+ function isLineBreakingElement(node) {
4451
+ if (!isNode(node, HTMLElementNode)) {
4452
+ return false;
4453
+ }
4454
+ const tagName = getTagName(node);
4455
+ return tagName === 'br' || tagName === 'hr';
4456
+ }
4457
+ /**
4458
+ * Normalize text by replacing multiple spaces with single space and trim
4459
+ * Then split into words
4460
+ */
4461
+ function normalizeAndSplitWords(text) {
4462
+ const normalized = text.replace(/\s+/g, ' ');
4463
+ return normalized.trim().split(' ');
4464
+ }
4465
+ /**
4466
+ * Check if text ends with whitespace
4467
+ */
4468
+ function endsWithWhitespace(text) {
4469
+ return /\s$/.test(text);
4470
+ }
4471
+ /**
4472
+ * Check if an ERB content node is a herb:disable comment
4473
+ */
4474
+ function isHerbDisableComment(node) {
4475
+ if (!isNode(node, ERBContentNode))
4476
+ return false;
4477
+ if (node.tag_opening?.value !== "<%#")
4478
+ return false;
4479
+ const content = node?.content?.value || "";
4480
+ const trimmed = content.trim();
4481
+ return trimmed.startsWith("herb:disable");
4482
+ }
4483
+ /**
4484
+ * Check if a text node is YAML frontmatter (starts and ends with ---)
4485
+ */
4486
+ function isFrontmatter(node) {
4487
+ if (!isNode(node, HTMLTextNode))
4488
+ return false;
4489
+ const content = node.content.trim();
4490
+ return content.startsWith("---") && /---\s*$/.test(content);
4491
+ }
4492
+
3978
4493
  /**
3979
4494
  * Printer traverses the Herb AST using the Visitor pattern
3980
4495
  * and emits a formatted string with proper indentation, line breaks, and attribute wrapping.
@@ -3998,28 +4513,6 @@ class FormatPrinter extends Printer {
3998
4513
  elementStack = [];
3999
4514
  elementFormattingAnalysis = new Map();
4000
4515
  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
4516
  constructor(source, options) {
4024
4517
  super();
4025
4518
  this.source = source;
@@ -4178,20 +4671,20 @@ class FormatPrinter extends Printer {
4178
4671
  if (hasMixedContent) {
4179
4672
  return false;
4180
4673
  }
4181
- const meaningfulSiblings = siblings.filter(child => this.isNonWhitespaceNode(child));
4182
- if (meaningfulSiblings.length < FormatPrinter.SPACING_THRESHOLD) {
4674
+ const meaningfulSiblings = siblings.filter(child => isNonWhitespaceNode(child));
4675
+ if (meaningfulSiblings.length < SPACING_THRESHOLD) {
4183
4676
  return false;
4184
4677
  }
4185
4678
  const parentTagName = parentElement ? getTagName(parentElement) : null;
4186
- if (parentTagName && FormatPrinter.TIGHT_GROUP_PARENTS.has(parentTagName)) {
4679
+ if (parentTagName && TIGHT_GROUP_PARENTS.has(parentTagName)) {
4187
4680
  return false;
4188
4681
  }
4189
- const isSpaceableContainer = !parentTagName || (parentTagName && FormatPrinter.SPACEABLE_CONTAINERS.has(parentTagName));
4682
+ const isSpaceableContainer = !parentTagName || (parentTagName && SPACEABLE_CONTAINERS.has(parentTagName));
4190
4683
  if (!isSpaceableContainer && meaningfulSiblings.length < 5) {
4191
4684
  return false;
4192
4685
  }
4193
4686
  const currentNode = siblings[currentIndex];
4194
- const previousMeaningfulIndex = this.findPreviousMeaningfulSibling(siblings, currentIndex);
4687
+ const previousMeaningfulIndex = findPreviousMeaningfulSibling(siblings, currentIndex);
4195
4688
  const isCurrentComment = isCommentNode(currentNode);
4196
4689
  if (previousMeaningfulIndex !== -1) {
4197
4690
  const previousNode = siblings[previousMeaningfulIndex];
@@ -4205,69 +4698,37 @@ class FormatPrinter extends Printer {
4205
4698
  }
4206
4699
  if (isNode(currentNode, HTMLElementNode)) {
4207
4700
  const currentTagName = getTagName(currentNode);
4208
- if (FormatPrinter.INLINE_ELEMENTS.has(currentTagName)) {
4701
+ if (INLINE_ELEMENTS.has(currentTagName)) {
4209
4702
  return false;
4210
4703
  }
4211
- if (FormatPrinter.TIGHT_GROUP_CHILDREN.has(currentTagName)) {
4704
+ if (TIGHT_GROUP_CHILDREN.has(currentTagName)) {
4212
4705
  return false;
4213
4706
  }
4214
4707
  if (currentTagName === 'a' && parentTagName === 'nav') {
4215
4708
  return false;
4216
4709
  }
4217
4710
  }
4218
- const isBlockElement = this.isBlockLevelNode(currentNode);
4711
+ const isBlockElement = isBlockLevelNode(currentNode);
4219
4712
  const isERBBlock = isERBNode(currentNode) && isERBControlFlowNode(currentNode);
4220
4713
  const isComment = isCommentNode(currentNode);
4221
4714
  return isBlockElement || isERBBlock || isComment;
4222
4715
  }
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
4716
  /**
4231
4717
  * Check if we're currently processing a token list attribute that needs spacing
4232
4718
  */
4233
- isInTokenListAttribute() {
4234
- return this.currentAttributeName !== null &&
4235
- FormatPrinter.TOKEN_LIST_ATTRIBUTES.has(this.currentAttributeName);
4719
+ get isInTokenListAttribute() {
4720
+ return this.currentAttributeName !== null && TOKEN_LIST_ATTRIBUTES.has(this.currentAttributeName);
4236
4721
  }
4237
4722
  /**
4238
- * Find the previous meaningful (non-whitespace) sibling
4723
+ * Render attributes as a space-separated string
4239
4724
  */
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;
4725
+ renderAttributesString(attributes) {
4726
+ if (attributes.length === 0)
4727
+ return "";
4728
+ return ` ${attributes.map(attribute => this.renderAttribute(attribute)).join(" ")}`;
4247
4729
  }
4248
4730
  /**
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
4731
+ * Determine if a tag should be rendered inline based on attribute count and other factors
4271
4732
  */
4272
4733
  shouldRenderInline(totalAttributeCount, inlineLength, indentLength, maxLineLength = this.maxLineLength, hasComplexERB = false, hasMultilineAttributes = false, attributes = []) {
4273
4734
  if (hasComplexERB || hasMultilineAttributes)
@@ -4294,9 +4755,6 @@ class FormatPrinter extends Printer {
4294
4755
  }
4295
4756
  return true;
4296
4757
  }
4297
- getAttributeName(attribute) {
4298
- return attribute.name ? getCombinedAttributeName(attribute.name) : "";
4299
- }
4300
4758
  wouldClassAttributeBeMultiline(content, indentLength) {
4301
4759
  const normalizedContent = content.replace(/\s+/g, ' ').trim();
4302
4760
  const hasActualNewlines = /\r?\n/.test(content);
@@ -4318,6 +4776,11 @@ class FormatPrinter extends Printer {
4318
4776
  }
4319
4777
  return false;
4320
4778
  }
4779
+ // TOOD: extract to core or reuse function from core
4780
+ getAttributeName(attribute) {
4781
+ return attribute.name ? getCombinedAttributeName(attribute.name) : "";
4782
+ }
4783
+ // TOOD: extract to core or reuse function from core
4321
4784
  getAttributeValue(attribute) {
4322
4785
  if (isNode(attribute.value, HTMLAttributeValueNode)) {
4323
4786
  return attribute.value.children.map(child => isNode(child, HTMLTextNode) ? child.content : IdentityPrinter.print(child)).join('');
@@ -4453,28 +4916,38 @@ class FormatPrinter extends Printer {
4453
4916
  }
4454
4917
  // --- Visitor methods ---
4455
4918
  visitDocumentNode(node) {
4919
+ const children = this.formatFrontmatter(node);
4920
+ const hasTextFlow = this.isInTextFlowContext(null, children);
4921
+ if (hasTextFlow) {
4922
+ const wasInlineMode = this.inlineMode;
4923
+ this.inlineMode = true;
4924
+ this.visitTextFlowChildren(children);
4925
+ this.inlineMode = wasInlineMode;
4926
+ return;
4927
+ }
4456
4928
  let lastWasMeaningful = false;
4457
4929
  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
- }
4930
+ for (let i = 0; i < children.length; i++) {
4931
+ const child = children[i];
4932
+ if (shouldPreserveUserSpacing(child, children, i)) {
4933
+ this.push("");
4934
+ hasHandledSpacing = true;
4935
+ continue;
4936
+ }
4937
+ if (isPureWhitespaceNode(child)) {
4938
+ continue;
4472
4939
  }
4473
- if (this.isNonWhitespaceNode(child) && lastWasMeaningful && !hasHandledSpacing) {
4940
+ if (shouldAppendToLastLine(child, children, i)) {
4941
+ this.appendChildToLastLine(child, children, i);
4942
+ lastWasMeaningful = true;
4943
+ hasHandledSpacing = false;
4944
+ continue;
4945
+ }
4946
+ if (isNonWhitespaceNode(child) && lastWasMeaningful && !hasHandledSpacing) {
4474
4947
  this.push("");
4475
4948
  }
4476
4949
  this.visit(child);
4477
- if (this.isNonWhitespaceNode(child)) {
4950
+ if (isNonWhitespaceNode(child)) {
4478
4951
  lastWasMeaningful = true;
4479
4952
  hasHandledSpacing = false;
4480
4953
  }
@@ -4483,6 +4956,12 @@ class FormatPrinter extends Printer {
4483
4956
  visitHTMLElementNode(node) {
4484
4957
  this.elementStack.push(node);
4485
4958
  this.elementFormattingAnalysis.set(node, this.analyzeElementFormatting(node));
4959
+ if (this.inlineMode && node.is_void && this.indentLevel === 0) {
4960
+ const openTag = this.capture(() => this.visit(node.open_tag)).join('');
4961
+ this.pushToLastLine(openTag);
4962
+ this.elementStack.pop();
4963
+ return;
4964
+ }
4486
4965
  this.visit(node.open_tag);
4487
4966
  if (node.body.length > 0) {
4488
4967
  this.visitHTMLElementBody(node.body, node);
@@ -4493,7 +4972,8 @@ class FormatPrinter extends Printer {
4493
4972
  this.elementStack.pop();
4494
4973
  }
4495
4974
  visitHTMLElementBody(body, element) {
4496
- if (this.isContentPreserving(element)) {
4975
+ const tagName = getTagName(element);
4976
+ if (isContentPreserving(element)) {
4497
4977
  element.body.map(child => {
4498
4978
  if (isNode(child, HTMLElementNode)) {
4499
4979
  const wasInlineMode = this.inlineMode;
@@ -4510,12 +4990,14 @@ class FormatPrinter extends Printer {
4510
4990
  }
4511
4991
  const analysis = this.elementFormattingAnalysis.get(element);
4512
4992
  const hasTextFlow = this.isInTextFlowContext(null, body);
4513
- const children = this.filterSignificantChildren(body, hasTextFlow);
4993
+ const children = filterSignificantChildren(body);
4514
4994
  if (analysis?.elementContentInline) {
4515
4995
  if (children.length === 0)
4516
4996
  return;
4517
4997
  const oldInlineMode = this.inlineMode;
4518
4998
  const nodesToRender = hasTextFlow ? body : children;
4999
+ const hasOnlyTextContent = nodesToRender.every(child => isNode(child, HTMLTextNode) || isNode(child, WhitespaceNode));
5000
+ const shouldPreserveSpaces = hasOnlyTextContent && isInlineElement(tagName);
4519
5001
  this.inlineMode = true;
4520
5002
  const lines = this.capture(() => {
4521
5003
  nodesToRender.forEach(child => {
@@ -4530,10 +5012,19 @@ class FormatPrinter extends Printer {
4530
5012
  }
4531
5013
  }
4532
5014
  else {
4533
- const normalizedContent = child.content.replace(/\s+/g, ' ').trim();
4534
- if (normalizedContent) {
5015
+ const normalizedContent = child.content.replace(/\s+/g, ' ');
5016
+ if (shouldPreserveSpaces && normalizedContent) {
4535
5017
  this.push(normalizedContent);
4536
5018
  }
5019
+ else {
5020
+ const trimmedContent = normalizedContent.trim();
5021
+ if (trimmedContent) {
5022
+ this.push(trimmedContent);
5023
+ }
5024
+ else if (normalizedContent === ' ') {
5025
+ this.push(' ');
5026
+ }
5027
+ }
4537
5028
  }
4538
5029
  }
4539
5030
  else if (isNode(child, WhitespaceNode)) {
@@ -4545,7 +5036,9 @@ class FormatPrinter extends Printer {
4545
5036
  });
4546
5037
  });
4547
5038
  const content = lines.join('');
4548
- const inlineContent = hasTextFlow ? content.replace(/\s+/g, ' ').trim() : content.trim();
5039
+ const inlineContent = shouldPreserveSpaces
5040
+ ? (hasTextFlow ? content.replace(/\s+/g, ' ') : content)
5041
+ : (hasTextFlow ? content.replace(/\s+/g, ' ').trim() : content.trim());
4549
5042
  if (inlineContent) {
4550
5043
  this.pushToLastLine(inlineContent);
4551
5044
  }
@@ -4554,12 +5047,69 @@ class FormatPrinter extends Printer {
4554
5047
  }
4555
5048
  if (children.length === 0)
4556
5049
  return;
5050
+ let leadingHerbDisableComment = null;
5051
+ let leadingHerbDisableIndex = -1;
5052
+ let firstWhitespaceIndex = -1;
5053
+ let remainingChildren = children;
5054
+ let remainingBodyUnfiltered = body;
5055
+ for (let i = 0; i < children.length; i++) {
5056
+ const child = children[i];
5057
+ if (isNode(child, WhitespaceNode) || isPureWhitespaceNode(child)) {
5058
+ if (firstWhitespaceIndex < 0) {
5059
+ firstWhitespaceIndex = i;
5060
+ }
5061
+ continue;
5062
+ }
5063
+ if (isNode(child, ERBContentNode) && isHerbDisableComment(child)) {
5064
+ leadingHerbDisableComment = child;
5065
+ leadingHerbDisableIndex = i;
5066
+ }
5067
+ break;
5068
+ }
5069
+ if (leadingHerbDisableComment && leadingHerbDisableIndex >= 0) {
5070
+ remainingChildren = children.filter((_, index) => {
5071
+ if (index === leadingHerbDisableIndex)
5072
+ return false;
5073
+ if (firstWhitespaceIndex >= 0 && index === leadingHerbDisableIndex - 1) {
5074
+ const child = children[index];
5075
+ if (isNode(child, WhitespaceNode) || isPureWhitespaceNode(child)) {
5076
+ return false;
5077
+ }
5078
+ }
5079
+ return true;
5080
+ });
5081
+ remainingBodyUnfiltered = body.filter((_, index) => {
5082
+ if (index === leadingHerbDisableIndex)
5083
+ return false;
5084
+ if (firstWhitespaceIndex >= 0 && index === leadingHerbDisableIndex - 1) {
5085
+ const child = body[index];
5086
+ if (isNode(child, WhitespaceNode) || isPureWhitespaceNode(child)) {
5087
+ return false;
5088
+ }
5089
+ }
5090
+ return true;
5091
+ });
5092
+ }
5093
+ if (leadingHerbDisableComment) {
5094
+ const herbDisableString = this.capture(() => {
5095
+ const savedIndentLevel = this.indentLevel;
5096
+ this.indentLevel = 0;
5097
+ this.inlineMode = true;
5098
+ this.visit(leadingHerbDisableComment);
5099
+ this.inlineMode = false;
5100
+ this.indentLevel = savedIndentLevel;
5101
+ }).join("");
5102
+ const hasLeadingWhitespace = firstWhitespaceIndex >= 0 && firstWhitespaceIndex < leadingHerbDisableIndex;
5103
+ this.pushToLastLine((hasLeadingWhitespace ? ' ' : '') + herbDisableString);
5104
+ }
5105
+ if (remainingChildren.length === 0)
5106
+ return;
4557
5107
  this.withIndent(() => {
4558
5108
  if (hasTextFlow) {
4559
- this.visitTextFlowChildren(children);
5109
+ this.visitTextFlowChildren(remainingBodyUnfiltered);
4560
5110
  }
4561
5111
  else {
4562
- this.visitElementChildren(body, element);
5112
+ this.visitElementChildren(leadingHerbDisableComment ? remainingChildren : body, element);
4563
5113
  }
4564
5114
  });
4565
5115
  }
@@ -4574,8 +5124,8 @@ class FormatPrinter extends Printer {
4574
5124
  if (isNode(child, HTMLTextNode)) {
4575
5125
  const isWhitespaceOnly = child.content.trim() === "";
4576
5126
  if (isWhitespaceOnly) {
4577
- const hasPreviousNonWhitespace = i > 0 && this.isNonWhitespaceNode(body[i - 1]);
4578
- const hasNextNonWhitespace = i < body.length - 1 && this.isNonWhitespaceNode(body[i + 1]);
5127
+ const hasPreviousNonWhitespace = i > 0 && isNonWhitespaceNode(body[i - 1]);
5128
+ const hasNextNonWhitespace = i < body.length - 1 && isNonWhitespaceNode(body[i + 1]);
4579
5129
  const hasMultipleNewlines = child.content.includes('\n\n');
4580
5130
  if (hasPreviousNonWhitespace && hasNextNonWhitespace && hasMultipleNewlines) {
4581
5131
  this.push("");
@@ -4584,7 +5134,7 @@ class FormatPrinter extends Printer {
4584
5134
  continue;
4585
5135
  }
4586
5136
  }
4587
- if (this.isNonWhitespaceNode(child) && lastWasMeaningful && !hasHandledSpacing) {
5137
+ if (isNonWhitespaceNode(child) && lastWasMeaningful && !hasHandledSpacing) {
4588
5138
  const element = body[i - 1];
4589
5139
  const hasExistingSpacing = i > 0 && isNode(element, HTMLTextNode) && element.content.trim() === "" && (element.content.includes('\n\n') || element.content.split('\n').length > 2);
4590
5140
  const shouldAddSpacing = this.shouldAddSpacingBetweenSiblings(parentElement, body, i, hasExistingSpacing);
@@ -4592,8 +5142,35 @@ class FormatPrinter extends Printer {
4592
5142
  this.push("");
4593
5143
  }
4594
5144
  }
4595
- this.visit(child);
4596
- if (this.isNonWhitespaceNode(child)) {
5145
+ let hasTrailingHerbDisable = false;
5146
+ if (isNode(child, HTMLElementNode) && child.close_tag) {
5147
+ for (let j = i + 1; j < body.length; j++) {
5148
+ const nextChild = body[j];
5149
+ if (isNode(nextChild, WhitespaceNode) || isPureWhitespaceNode(nextChild)) {
5150
+ continue;
5151
+ }
5152
+ if (isNode(nextChild, ERBContentNode) && isHerbDisableComment(nextChild)) {
5153
+ hasTrailingHerbDisable = true;
5154
+ this.visit(child);
5155
+ const herbDisableString = this.capture(() => {
5156
+ const savedIndentLevel = this.indentLevel;
5157
+ this.indentLevel = 0;
5158
+ this.inlineMode = true;
5159
+ this.visit(nextChild);
5160
+ this.inlineMode = false;
5161
+ this.indentLevel = savedIndentLevel;
5162
+ }).join("");
5163
+ this.pushToLastLine(' ' + herbDisableString);
5164
+ i = j;
5165
+ break;
5166
+ }
5167
+ break;
5168
+ }
5169
+ }
5170
+ if (!hasTrailingHerbDisable) {
5171
+ this.visit(child);
5172
+ }
5173
+ if (isNonWhitespaceNode(child)) {
4597
5174
  lastWasMeaningful = true;
4598
5175
  hasHandledSpacing = false;
4599
5176
  }
@@ -4683,8 +5260,8 @@ class FormatPrinter extends Printer {
4683
5260
  if (isNode(child, HTMLTextNode) || isNode(child, LiteralNode)) {
4684
5261
  return child.content;
4685
5262
  }
4686
- else if (isERBNode(child) || isNode(child, ERBContentNode)) {
4687
- return this.reconstructERBNode(child, false);
5263
+ else if (isERBNode(child)) {
5264
+ return IdentityPrinter.print(child);
4688
5265
  }
4689
5266
  else {
4690
5267
  return "";
@@ -4737,11 +5314,21 @@ class FormatPrinter extends Printer {
4737
5314
  if (contentLines.length === 1 && contentTrimmedLines.length === 1) {
4738
5315
  const startsWithSpace = content[0] === " ";
4739
5316
  const before = startsWithSpace ? "" : " ";
4740
- this.pushWithIndent(open + before + content.trimEnd() + ' ' + close);
5317
+ if (this.inlineMode) {
5318
+ this.push(open + before + content.trimEnd() + ' ' + close);
5319
+ }
5320
+ else {
5321
+ this.pushWithIndent(open + before + content.trimEnd() + ' ' + close);
5322
+ }
4741
5323
  return;
4742
5324
  }
4743
5325
  if (contentTrimmedLines.length === 1) {
4744
- this.pushWithIndent(open + ' ' + content.trim() + ' ' + close);
5326
+ if (this.inlineMode) {
5327
+ this.push(open + ' ' + content.trim() + ' ' + close);
5328
+ }
5329
+ else {
5330
+ this.pushWithIndent(open + ' ' + content.trim() + ' ' + close);
5331
+ }
4745
5332
  return;
4746
5333
  }
4747
5334
  const firstLineEmpty = contentLines[0].trim() === "";
@@ -4782,6 +5369,7 @@ class FormatPrinter extends Printer {
4782
5369
  }
4783
5370
  visitERBCaseMatchNode(node) {
4784
5371
  this.printERBNode(node);
5372
+ this.withIndent(() => this.visitAll(node.children));
4785
5373
  this.visitAll(node.conditions);
4786
5374
  if (node.else_clause)
4787
5375
  this.visit(node.else_clause);
@@ -4790,7 +5378,15 @@ class FormatPrinter extends Printer {
4790
5378
  }
4791
5379
  visitERBBlockNode(node) {
4792
5380
  this.printERBNode(node);
4793
- this.withIndent(() => this.visitElementChildren(node.body, null));
5381
+ this.withIndent(() => {
5382
+ const hasTextFlow = this.isInTextFlowContext(null, node.body);
5383
+ if (hasTextFlow) {
5384
+ this.visitTextFlowChildren(node.body);
5385
+ }
5386
+ else {
5387
+ this.visitElementChildren(node.body, null);
5388
+ }
5389
+ });
4794
5390
  if (node.end_node)
4795
5391
  this.visit(node.end_node);
4796
5392
  }
@@ -4803,7 +5399,7 @@ class FormatPrinter extends Printer {
4803
5399
  this.lines.push(this.renderAttribute(child));
4804
5400
  }
4805
5401
  else {
4806
- const shouldAddSpaces = this.isInTokenListAttribute();
5402
+ const shouldAddSpaces = this.isInTokenListAttribute;
4807
5403
  if (shouldAddSpaces) {
4808
5404
  this.lines.push(" ");
4809
5405
  }
@@ -4814,12 +5410,12 @@ class FormatPrinter extends Printer {
4814
5410
  }
4815
5411
  });
4816
5412
  const hasHTMLAttributes = node.statements.some(child => isNode(child, HTMLAttributeNode));
4817
- const isTokenList = this.isInTokenListAttribute();
5413
+ const isTokenList = this.isInTokenListAttribute;
4818
5414
  if ((hasHTMLAttributes || isTokenList) && node.end_node) {
4819
5415
  this.lines.push(" ");
4820
5416
  }
4821
5417
  if (node.subsequent)
4822
- this.visit(node.end_node);
5418
+ this.visit(node.subsequent);
4823
5419
  if (node.end_node)
4824
5420
  this.visit(node.end_node);
4825
5421
  }
@@ -4835,8 +5431,14 @@ class FormatPrinter extends Printer {
4835
5431
  }
4836
5432
  }
4837
5433
  visitERBElseNode(node) {
4838
- this.printERBNode(node);
4839
- this.withIndent(() => node.statements.forEach(statement => this.visit(statement)));
5434
+ if (this.inlineMode) {
5435
+ this.printERBNode(node);
5436
+ node.statements.forEach(statement => this.visit(statement));
5437
+ }
5438
+ else {
5439
+ this.printERBNode(node);
5440
+ this.withIndent(() => node.statements.forEach(statement => this.visit(statement)));
5441
+ }
4840
5442
  }
4841
5443
  visitERBWhenNode(node) {
4842
5444
  this.printERBNode(node);
@@ -4844,6 +5446,7 @@ class FormatPrinter extends Printer {
4844
5446
  }
4845
5447
  visitERBCaseNode(node) {
4846
5448
  this.printERBNode(node);
5449
+ this.withIndent(() => this.visitAll(node.children));
4847
5450
  this.visitAll(node.conditions);
4848
5451
  if (node.else_clause)
4849
5452
  this.visit(node.else_clause);
@@ -4918,7 +5521,7 @@ class FormatPrinter extends Printer {
4918
5521
  const attributes = filterNodes(children, HTMLAttributeNode);
4919
5522
  const inlineNodes = this.extractInlineNodes(children);
4920
5523
  const hasERBControlFlow = inlineNodes.some(node => isERBControlFlowNode(node)) || children.some(node => isERBControlFlowNode(node));
4921
- const hasComplexERB = hasERBControlFlow && this.hasComplexERBControlFlow(inlineNodes);
5524
+ const hasComplexERB = hasERBControlFlow && hasComplexERBControlFlow(inlineNodes);
4922
5525
  if (hasComplexERB)
4923
5526
  return false;
4924
5527
  const totalAttributeCount = this.getTotalAttributeCount(attributes, inlineNodes);
@@ -4933,26 +5536,38 @@ class FormatPrinter extends Printer {
4933
5536
  */
4934
5537
  shouldRenderElementContentInline(node) {
4935
5538
  const tagName = getTagName(node);
4936
- const children = this.filterSignificantChildren(node.body, this.isInTextFlowContext(null, node.body));
4937
- const isInlineElement = this.isInlineElement(tagName);
5539
+ const children = filterSignificantChildren(node.body);
4938
5540
  const openTagInline = this.shouldRenderOpenTagInline(node);
4939
5541
  if (!openTagInline)
4940
5542
  return false;
4941
5543
  if (children.length === 0)
4942
5544
  return true;
4943
- if (isInlineElement) {
4944
- const fullInlineResult = this.tryRenderInlineFull(node, tagName, filterNodes(node.open_tag?.children, HTMLAttributeNode), children);
5545
+ let hasLeadingHerbDisable = false;
5546
+ for (const child of node.body) {
5547
+ if (isNode(child, WhitespaceNode) || isPureWhitespaceNode(child)) {
5548
+ continue;
5549
+ }
5550
+ if (isNode(child, ERBContentNode) && isHerbDisableComment(child)) {
5551
+ hasLeadingHerbDisable = true;
5552
+ }
5553
+ break;
5554
+ }
5555
+ if (hasLeadingHerbDisable && !isInlineElement(tagName)) {
5556
+ return false;
5557
+ }
5558
+ if (isInlineElement(tagName)) {
5559
+ const fullInlineResult = this.tryRenderInlineFull(node, tagName, filterNodes(node.open_tag?.children, HTMLAttributeNode), node.body);
4945
5560
  if (fullInlineResult) {
4946
5561
  const totalLength = this.indent.length + fullInlineResult.length;
4947
5562
  return totalLength <= this.maxLineLength || totalLength <= 120;
4948
5563
  }
4949
5564
  return false;
4950
5565
  }
4951
- const allNestedAreInline = this.areAllNestedElementsInline(children);
4952
- const hasMultilineText = this.hasMultilineTextContent(children);
4953
- const hasMixedContent = this.hasMixedTextAndInlineContent(children);
5566
+ const allNestedAreInline = areAllNestedElementsInline(children);
5567
+ const hasMultilineText = hasMultilineTextContent(children);
5568
+ const hasMixedContent = hasMixedTextAndInlineContent(children);
4954
5569
  if (allNestedAreInline && (!hasMultilineText || hasMixedContent)) {
4955
- const fullInlineResult = this.tryRenderInlineFull(node, tagName, filterNodes(node.open_tag?.children, HTMLAttributeNode), children);
5570
+ const fullInlineResult = this.tryRenderInlineFull(node, tagName, filterNodes(node.open_tag?.children, HTMLAttributeNode), node.body);
4956
5571
  if (fullInlineResult) {
4957
5572
  const totalLength = this.indent.length + fullInlineResult.length;
4958
5573
  if (totalLength <= this.maxLineLength) {
@@ -4979,117 +5594,551 @@ class FormatPrinter extends Printer {
4979
5594
  return true;
4980
5595
  if (node.open_tag?.tag_closing?.value === "/>")
4981
5596
  return true;
4982
- if (this.isContentPreserving(node))
5597
+ if (isContentPreserving(node))
4983
5598
  return true;
4984
- const children = this.filterSignificantChildren(node.body, this.isInTextFlowContext(null, node.body));
5599
+ const children = filterSignificantChildren(node.body);
4985
5600
  if (children.length === 0)
4986
5601
  return true;
4987
5602
  return elementContentInline;
4988
5603
  }
4989
5604
  // --- 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;
5605
+ formatFrontmatter(node) {
5606
+ const firstChild = node.children[0];
5607
+ const hasFrontmatter = firstChild && isFrontmatter(firstChild);
5608
+ if (!hasFrontmatter)
5609
+ return node.children;
5610
+ this.push(firstChild.content.trimEnd());
5611
+ const remaining = node.children.slice(1);
5612
+ if (remaining.length > 0)
5613
+ this.push("");
5614
+ return remaining;
4996
5615
  }
4997
5616
  /**
4998
- * Check if an element should be treated as inline based on its tag name
5617
+ * Append a child node to the last output line
4999
5618
  */
5000
- isInlineElement(tagName) {
5001
- return FormatPrinter.INLINE_ELEMENTS.has(tagName.toLowerCase());
5619
+ appendChildToLastLine(child, siblings, index) {
5620
+ if (isNode(child, HTMLTextNode)) {
5621
+ this.pushToLastLine(child.content.trim());
5622
+ }
5623
+ else {
5624
+ let hasSpaceBefore = false;
5625
+ if (siblings && index !== undefined && index > 0) {
5626
+ const prevSibling = siblings[index - 1];
5627
+ if (isPureWhitespaceNode(prevSibling) || isNode(prevSibling, WhitespaceNode)) {
5628
+ hasSpaceBefore = true;
5629
+ }
5630
+ }
5631
+ const oldInlineMode = this.inlineMode;
5632
+ this.inlineMode = true;
5633
+ const inlineContent = this.capture(() => this.visit(child)).join("");
5634
+ this.inlineMode = oldInlineMode;
5635
+ this.pushToLastLine((hasSpaceBefore ? " " : "") + inlineContent);
5636
+ }
5002
5637
  }
5003
5638
  /**
5004
- * Check if we're in a text flow context (parent contains mixed text and inline elements)
5639
+ * Visit children in a text flow context (mixed text and inline elements)
5640
+ * Handles word wrapping and keeps adjacent inline elements together
5005
5641
  */
5006
5642
  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;
5643
+ const adjacentInlineCount = countAdjacentInlineElements(children);
5644
+ if (adjacentInlineCount >= 2) {
5645
+ const { processedIndices } = this.renderAdjacentInlineElements(children, adjacentInlineCount);
5646
+ this.visitRemainingChildren(children, processedIndices);
5647
+ return;
5648
+ }
5649
+ this.buildAndWrapTextFlow(children);
5650
+ }
5651
+ /**
5652
+ * Wrap remaining words that don't fit on the current line
5653
+ * Returns the wrapped lines with proper indentation
5654
+ */
5655
+ wrapRemainingWords(words, wrapWidth) {
5656
+ const lines = [];
5657
+ let line = "";
5658
+ for (const word of words) {
5659
+ const testLine = line + (line ? " " : "") + word;
5660
+ if (testLine.length > wrapWidth && line) {
5661
+ lines.push(this.indent + line);
5662
+ line = word;
5663
+ }
5664
+ else {
5665
+ line = testLine;
5666
+ }
5667
+ }
5668
+ if (line) {
5669
+ lines.push(this.indent + line);
5670
+ }
5671
+ return lines;
5672
+ }
5673
+ /**
5674
+ * Try to merge text starting with punctuation to inline content
5675
+ * Returns object with merged content and whether processing should stop
5676
+ */
5677
+ tryMergePunctuationText(inlineContent, trimmedText, wrapWidth) {
5678
+ const combined = inlineContent + trimmedText;
5679
+ if (combined.length <= wrapWidth) {
5680
+ return {
5681
+ mergedContent: inlineContent + trimmedText,
5682
+ shouldStop: false,
5683
+ wrappedLines: []
5684
+ };
5685
+ }
5686
+ const match = trimmedText.match(/^[.!?:;]+/);
5687
+ if (!match) {
5688
+ return {
5689
+ mergedContent: inlineContent,
5690
+ shouldStop: false,
5691
+ wrappedLines: []
5692
+ };
5693
+ }
5694
+ const punctuation = match[0];
5695
+ const restText = trimmedText.substring(punctuation.length).trim();
5696
+ if (!restText) {
5697
+ return {
5698
+ mergedContent: inlineContent + punctuation,
5699
+ shouldStop: false,
5700
+ wrappedLines: []
5701
+ };
5702
+ }
5703
+ const words = restText.split(/\s+/);
5704
+ let toMerge = punctuation;
5705
+ let mergedWordCount = 0;
5706
+ for (const word of words) {
5707
+ const testMerge = toMerge + ' ' + word;
5708
+ if ((inlineContent + testMerge).length <= wrapWidth) {
5709
+ toMerge = testMerge;
5710
+ mergedWordCount++;
5711
+ }
5712
+ else {
5713
+ break;
5714
+ }
5715
+ }
5716
+ const mergedContent = inlineContent + toMerge;
5717
+ if (mergedWordCount >= words.length) {
5718
+ return {
5719
+ mergedContent,
5720
+ shouldStop: false,
5721
+ wrappedLines: []
5722
+ };
5723
+ }
5724
+ const remainingWords = words.slice(mergedWordCount);
5725
+ const wrappedLines = this.wrapRemainingWords(remainingWords, wrapWidth);
5726
+ return {
5727
+ mergedContent,
5728
+ shouldStop: true,
5729
+ wrappedLines
5730
+ };
5731
+ }
5732
+ /**
5733
+ * Render adjacent inline elements together on one line
5734
+ */
5735
+ renderAdjacentInlineElements(children, count) {
5736
+ let inlineContent = "";
5737
+ let processedCount = 0;
5738
+ let lastProcessedIndex = -1;
5739
+ const processedIndices = new Set();
5740
+ for (let index = 0; index < children.length && processedCount < count; index++) {
5741
+ const child = children[index];
5742
+ if (isPureWhitespaceNode(child) || isNode(child, WhitespaceNode)) {
5743
+ continue;
5744
+ }
5745
+ if (isNode(child, HTMLElementNode) && isInlineElement(getTagName(child))) {
5746
+ inlineContent += this.renderInlineElementAsString(child);
5747
+ processedCount++;
5748
+ lastProcessedIndex = index;
5749
+ processedIndices.add(index);
5750
+ if (inlineContent && isLineBreakingElement(child)) {
5751
+ this.pushWithIndent(inlineContent);
5752
+ inlineContent = "";
5753
+ }
5754
+ }
5755
+ else if (isNode(child, ERBContentNode)) {
5756
+ inlineContent += this.renderERBAsString(child);
5757
+ processedCount++;
5758
+ lastProcessedIndex = index;
5759
+ processedIndices.add(index);
5760
+ }
5761
+ }
5762
+ if (lastProcessedIndex >= 0) {
5763
+ for (let index = lastProcessedIndex + 1; index < children.length; index++) {
5764
+ const child = children[index];
5765
+ if (isPureWhitespaceNode(child) || isNode(child, WhitespaceNode)) {
5766
+ continue;
5767
+ }
5768
+ if (isNode(child, ERBContentNode)) {
5769
+ inlineContent += this.renderERBAsString(child);
5770
+ processedIndices.add(index);
5771
+ continue;
5772
+ }
5773
+ if (isNode(child, HTMLTextNode)) {
5774
+ const trimmed = child.content.trim();
5775
+ if (trimmed && /^[.!?:;]/.test(trimmed)) {
5776
+ const wrapWidth = this.maxLineLength - this.indent.length;
5777
+ const result = this.tryMergePunctuationText(inlineContent, trimmed, wrapWidth);
5778
+ inlineContent = result.mergedContent;
5779
+ processedIndices.add(index);
5780
+ if (result.shouldStop) {
5781
+ if (inlineContent) {
5782
+ this.pushWithIndent(inlineContent);
5783
+ }
5784
+ result.wrappedLines.forEach(line => this.push(line));
5785
+ return { processedIndices };
5786
+ }
5025
5787
  }
5026
5788
  }
5789
+ break;
5027
5790
  }
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;
5791
+ }
5792
+ if (inlineContent) {
5793
+ this.pushWithIndent(inlineContent);
5794
+ }
5795
+ return { processedIndices };
5796
+ }
5797
+ /**
5798
+ * Render an inline element as a string
5799
+ */
5800
+ renderInlineElementAsString(element) {
5801
+ const tagName = getTagName(element);
5802
+ if (element.is_void || element.open_tag?.tag_closing?.value === "/>") {
5803
+ const attributes = filterNodes(element.open_tag?.children, HTMLAttributeNode);
5804
+ const attributesString = this.renderAttributesString(attributes);
5805
+ const isSelfClosing = element.open_tag?.tag_closing?.value === "/>";
5806
+ return `<${tagName}${attributesString}${isSelfClosing ? " />" : ">"}`;
5807
+ }
5808
+ const childrenToRender = this.getFilteredChildren(element.body);
5809
+ const childInline = this.tryRenderInlineFull(element, tagName, filterNodes(element.open_tag?.children, HTMLAttributeNode), childrenToRender);
5810
+ return childInline !== null ? childInline : "";
5811
+ }
5812
+ /**
5813
+ * Render an ERB node as a string
5814
+ */
5815
+ renderERBAsString(node) {
5816
+ return this.capture(() => {
5817
+ this.inlineMode = true;
5818
+ this.visit(node);
5819
+ }).join("");
5820
+ }
5821
+ /**
5822
+ * Visit remaining children after processing adjacent inline elements
5823
+ */
5824
+ visitRemainingChildren(children, processedIndices) {
5825
+ for (let index = 0; index < children.length; index++) {
5826
+ const child = children[index];
5827
+ if (isPureWhitespaceNode(child) || isNode(child, WhitespaceNode)) {
5828
+ continue;
5829
+ }
5830
+ if (processedIndices.has(index)) {
5831
+ continue;
5832
+ }
5833
+ this.visit(child);
5834
+ }
5835
+ }
5836
+ /**
5837
+ * Build words array from text/inline/ERB and wrap them
5838
+ */
5839
+ buildAndWrapTextFlow(children) {
5840
+ const unitsWithNodes = this.buildContentUnitsWithNodes(children);
5841
+ const words = [];
5842
+ for (const { unit, node } of unitsWithNodes) {
5843
+ if (unit.breaksFlow) {
5844
+ this.flushWords(words);
5845
+ if (node) {
5846
+ this.visit(node);
5847
+ }
5848
+ }
5849
+ else if (unit.isAtomic) {
5850
+ words.push({ word: unit.content, isHerbDisable: unit.isHerbDisable || false });
5851
+ }
5852
+ else {
5853
+ const text = unit.content.replace(/\s+/g, ' ');
5854
+ const hasLeadingSpace = text.startsWith(' ');
5855
+ const hasTrailingSpace = text.endsWith(' ');
5856
+ const trimmedText = text.trim();
5857
+ if (trimmedText) {
5858
+ if (hasLeadingSpace && words.length > 0) {
5859
+ const lastWord = words[words.length - 1];
5860
+ if (!lastWord.word.endsWith(' ')) {
5861
+ lastWord.word += ' ';
5037
5862
  }
5038
5863
  }
5039
- else {
5040
- if (currentLineContent.trim()) {
5041
- this.pushWithIndent(currentLineContent.trim());
5042
- currentLineContent = "";
5864
+ const textWords = trimmedText.split(' ').map(w => ({ word: w, isHerbDisable: false }));
5865
+ words.push(...textWords);
5866
+ if (hasTrailingSpace && words.length > 0) {
5867
+ const lastWord = words[words.length - 1];
5868
+ if (!isClosingPunctuation(lastWord.word)) {
5869
+ lastWord.word += ' ';
5043
5870
  }
5044
- this.visit(child);
5045
5871
  }
5046
5872
  }
5047
- else {
5048
- if (currentLineContent.trim()) {
5049
- this.pushWithIndent(currentLineContent.trim());
5050
- currentLineContent = "";
5873
+ else if (text === ' ' && words.length > 0) {
5874
+ const lastWord = words[words.length - 1];
5875
+ if (!lastWord.word.endsWith(' ')) {
5876
+ lastWord.word += ' ';
5051
5877
  }
5052
- this.visit(child);
5053
5878
  }
5054
5879
  }
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;
5880
+ }
5881
+ this.flushWords(words);
5882
+ }
5883
+ /**
5884
+ * Try to merge text that follows an atomic unit (ERB/inline) with no whitespace
5885
+ * Returns true if merge was performed
5886
+ */
5887
+ tryMergeTextAfterAtomic(result, textNode) {
5888
+ if (result.length === 0)
5889
+ return false;
5890
+ const lastUnit = result[result.length - 1];
5891
+ if (!lastUnit.unit.isAtomic || (lastUnit.unit.type !== 'erb' && lastUnit.unit.type !== 'inline')) {
5892
+ return false;
5893
+ }
5894
+ const words = normalizeAndSplitWords(textNode.content);
5895
+ if (words.length === 0 || !words[0])
5896
+ return false;
5897
+ const firstWord = words[0];
5898
+ const firstChar = firstWord[0];
5899
+ if (!/[a-zA-Z0-9.!?:;]/.test(firstChar)) {
5900
+ return false;
5901
+ }
5902
+ lastUnit.unit.content += firstWord;
5903
+ if (words.length > 1) {
5904
+ let remainingText = words.slice(1).join(' ');
5905
+ if (endsWithWhitespace(textNode.content)) {
5906
+ remainingText += ' ';
5907
+ }
5908
+ result.push({
5909
+ unit: { content: remainingText, type: 'text', isAtomic: false, breaksFlow: false },
5910
+ node: textNode
5911
+ });
5912
+ }
5913
+ return true;
5914
+ }
5915
+ /**
5916
+ * Try to merge an atomic unit (ERB/inline) with preceding text that has no whitespace
5917
+ * Returns true if merge was performed
5918
+ */
5919
+ tryMergeAtomicAfterText(result, children, lastProcessedIndex, atomicContent, atomicType, atomicNode) {
5920
+ if (result.length === 0)
5921
+ return false;
5922
+ const lastUnit = result[result.length - 1];
5923
+ if (lastUnit.unit.type !== 'text' || lastUnit.unit.isAtomic)
5924
+ return false;
5925
+ const words = normalizeAndSplitWords(lastUnit.unit.content);
5926
+ const lastWord = words[words.length - 1];
5927
+ if (!lastWord)
5928
+ return false;
5929
+ result.pop();
5930
+ if (words.length > 1) {
5931
+ const remainingText = words.slice(0, -1).join(' ');
5932
+ result.push({
5933
+ unit: { content: remainingText, type: 'text', isAtomic: false, breaksFlow: false },
5934
+ node: children[lastProcessedIndex]
5935
+ });
5936
+ }
5937
+ result.push({
5938
+ unit: { content: lastWord + atomicContent, type: atomicType, isAtomic: true, breaksFlow: false },
5939
+ node: atomicNode
5940
+ });
5941
+ return true;
5942
+ }
5943
+ /**
5944
+ * Check if there's whitespace between current node and last processed node
5945
+ */
5946
+ hasWhitespaceBeforeNode(children, lastProcessedIndex, currentIndex, currentNode) {
5947
+ if (hasWhitespaceBetween(children, lastProcessedIndex, currentIndex)) {
5948
+ return true;
5949
+ }
5950
+ if (isNode(currentNode, HTMLTextNode) && /^\s/.test(currentNode.content)) {
5951
+ return true;
5952
+ }
5953
+ return false;
5954
+ }
5955
+ /**
5956
+ * Check if last unit in result ends with whitespace
5957
+ */
5958
+ lastUnitEndsWithWhitespace(result) {
5959
+ if (result.length === 0)
5960
+ return false;
5961
+ const lastUnit = result[result.length - 1];
5962
+ return lastUnit.unit.type === 'text' && endsWithWhitespace(lastUnit.unit.content);
5963
+ }
5964
+ /**
5965
+ * Process a text node and add it to results (with potential merging)
5966
+ */
5967
+ processTextNode(result, children, child, index, lastProcessedIndex) {
5968
+ const isAtomic = child.content === ' ';
5969
+ if (!isAtomic && lastProcessedIndex >= 0 && result.length > 0) {
5970
+ const hasWhitespace = this.hasWhitespaceBeforeNode(children, lastProcessedIndex, index, child);
5971
+ const lastUnit = result[result.length - 1];
5972
+ const lastIsAtomic = lastUnit.unit.isAtomic && (lastUnit.unit.type === 'erb' || lastUnit.unit.type === 'inline');
5973
+ const trimmed = child.content.trim();
5974
+ const startsWithClosingPunct = trimmed.length > 0 && /^[.!?:;]/.test(trimmed);
5975
+ if (lastIsAtomic && (!hasWhitespace || startsWithClosingPunct) && this.tryMergeTextAfterAtomic(result, child)) {
5976
+ return;
5977
+ }
5978
+ }
5979
+ result.push({
5980
+ unit: { content: child.content, type: 'text', isAtomic, breaksFlow: false },
5981
+ node: child
5982
+ });
5983
+ }
5984
+ /**
5985
+ * Process an inline element and add it to results (with potential merging)
5986
+ */
5987
+ processInlineElement(result, children, child, index, lastProcessedIndex) {
5988
+ const tagName = getTagName(child);
5989
+ const childrenToRender = this.getFilteredChildren(child.body);
5990
+ const inlineContent = this.tryRenderInlineFull(child, tagName, filterNodes(child.open_tag?.children, HTMLAttributeNode), childrenToRender);
5991
+ if (inlineContent === null) {
5992
+ result.push({
5993
+ unit: { content: '', type: 'block', isAtomic: false, breaksFlow: true },
5994
+ node: child
5995
+ });
5996
+ return false;
5997
+ }
5998
+ if (lastProcessedIndex >= 0) {
5999
+ const hasWhitespace = hasWhitespaceBetween(children, lastProcessedIndex, index) || this.lastUnitEndsWithWhitespace(result);
6000
+ if (!hasWhitespace && this.tryMergeAtomicAfterText(result, children, lastProcessedIndex, inlineContent, 'inline', child)) {
6001
+ return true;
6002
+ }
6003
+ }
6004
+ result.push({
6005
+ unit: { content: inlineContent, type: 'inline', isAtomic: true, breaksFlow: false },
6006
+ node: child
6007
+ });
6008
+ return false;
6009
+ }
6010
+ /**
6011
+ * Process an ERB content node and add it to results (with potential merging)
6012
+ */
6013
+ processERBContentNode(result, children, child, index, lastProcessedIndex) {
6014
+ const erbContent = this.renderERBAsString(child);
6015
+ const isHerbDisable = isHerbDisableComment(child);
6016
+ if (lastProcessedIndex >= 0) {
6017
+ const hasWhitespace = hasWhitespaceBetween(children, lastProcessedIndex, index) || this.lastUnitEndsWithWhitespace(result);
6018
+ if (!hasWhitespace && this.tryMergeAtomicAfterText(result, children, lastProcessedIndex, erbContent, 'erb', child)) {
6019
+ return true;
6020
+ }
6021
+ if (hasWhitespace && result.length > 0) {
6022
+ const lastUnit = result[result.length - 1];
6023
+ const lastIsAtomic = lastUnit.unit.isAtomic && (lastUnit.unit.type === 'inline' || lastUnit.unit.type === 'erb');
6024
+ if (lastIsAtomic && !this.lastUnitEndsWithWhitespace(result)) {
6025
+ result.push({
6026
+ unit: { content: ' ', type: 'text', isAtomic: true, breaksFlow: false },
6027
+ node: null
6028
+ });
6029
+ }
6030
+ }
6031
+ }
6032
+ result.push({
6033
+ unit: { content: erbContent, type: 'erb', isAtomic: true, breaksFlow: false, isHerbDisable },
6034
+ node: child
6035
+ });
6036
+ return false;
6037
+ }
6038
+ /**
6039
+ * Convert AST nodes to content units with node references
6040
+ */
6041
+ buildContentUnitsWithNodes(children) {
6042
+ const result = [];
6043
+ let lastProcessedIndex = -1;
6044
+ for (let i = 0; i < children.length; i++) {
6045
+ const child = children[i];
6046
+ if (isNode(child, WhitespaceNode))
6047
+ continue;
6048
+ if (isPureWhitespaceNode(child) && !(isNode(child, HTMLTextNode) && child.content === ' ')) {
6049
+ if (lastProcessedIndex >= 0) {
6050
+ const hasNonWhitespaceAfter = children.slice(i + 1).some(node => !isNode(node, WhitespaceNode) && !isPureWhitespaceNode(node));
6051
+ if (hasNonWhitespaceAfter) {
6052
+ const previousNode = children[lastProcessedIndex];
6053
+ if (!isLineBreakingElement(previousNode)) {
6054
+ result.push({
6055
+ unit: { content: ' ', type: 'text', isAtomic: true, breaksFlow: false },
6056
+ node: child
6057
+ });
6058
+ }
6059
+ }
6060
+ }
6061
+ continue;
6062
+ }
6063
+ if (isNode(child, HTMLTextNode)) {
6064
+ this.processTextNode(result, children, child, i, lastProcessedIndex);
6065
+ lastProcessedIndex = i;
6066
+ }
6067
+ else if (isNode(child, HTMLElementNode)) {
6068
+ const tagName = getTagName(child);
6069
+ if (isInlineElement(tagName)) {
6070
+ const merged = this.processInlineElement(result, children, child, i, lastProcessedIndex);
6071
+ if (merged) {
6072
+ lastProcessedIndex = i;
6073
+ continue;
5070
6074
  }
5071
6075
  }
5072
- finally {
5073
- this.lines = oldLines;
5074
- this.inlineMode = oldInlineMode;
6076
+ else {
6077
+ result.push({
6078
+ unit: { content: '', type: 'block', isAtomic: false, breaksFlow: true },
6079
+ node: child
6080
+ });
5075
6081
  }
6082
+ lastProcessedIndex = i;
5076
6083
  }
5077
- else {
5078
- if (currentLineContent.trim()) {
5079
- this.pushWithIndent(currentLineContent.trim());
5080
- currentLineContent = "";
6084
+ else if (isNode(child, ERBContentNode)) {
6085
+ const merged = this.processERBContentNode(result, children, child, i, lastProcessedIndex);
6086
+ if (merged) {
6087
+ lastProcessedIndex = i;
6088
+ continue;
5081
6089
  }
5082
- this.visit(child);
6090
+ lastProcessedIndex = i;
6091
+ }
6092
+ else {
6093
+ result.push({
6094
+ unit: { content: '', type: 'block', isAtomic: false, breaksFlow: true },
6095
+ node: child
6096
+ });
6097
+ lastProcessedIndex = i;
5083
6098
  }
5084
6099
  }
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;
6100
+ return result;
6101
+ }
6102
+ /**
6103
+ * Flush accumulated words to output with wrapping
6104
+ */
6105
+ flushWords(words) {
6106
+ if (words.length > 0) {
6107
+ this.wrapAndPushWords(words);
6108
+ words.length = 0;
6109
+ }
6110
+ }
6111
+ /**
6112
+ * Wrap words to fit within line length and push to output
6113
+ * Handles punctuation spacing intelligently
6114
+ * Excludes herb:disable comments from line length calculations
6115
+ */
6116
+ wrapAndPushWords(words) {
6117
+ const wrapWidth = this.maxLineLength - this.indent.length;
6118
+ const lines = [];
6119
+ let currentLine = "";
6120
+ let effectiveLength = 0;
6121
+ for (const { word, isHerbDisable } of words) {
6122
+ const nextLine = buildLineWithWord(currentLine, word);
6123
+ let nextEffectiveLength = effectiveLength;
6124
+ if (!isHerbDisable) {
6125
+ const spaceBefore = currentLine && needsSpaceBetween(currentLine, word) ? 1 : 0;
6126
+ nextEffectiveLength = effectiveLength + spaceBefore + word.length;
6127
+ }
6128
+ if (currentLine && !isClosingPunctuation(word) && nextEffectiveLength >= wrapWidth) {
6129
+ lines.push(this.indent + currentLine.trimEnd());
6130
+ currentLine = word;
6131
+ effectiveLength = isHerbDisable ? 0 : word.length;
6132
+ }
6133
+ else {
6134
+ currentLine = nextLine;
6135
+ effectiveLength = nextEffectiveLength;
5090
6136
  }
5091
- this.push(finalLine);
5092
6137
  }
6138
+ if (currentLine) {
6139
+ lines.push(this.indent + currentLine.trimEnd());
6140
+ }
6141
+ lines.forEach(line => this.push(line));
5093
6142
  }
5094
6143
  isInTextFlowContext(_parent, children) {
5095
6144
  const hasTextContent = children.some(child => isNode(child, HTMLTextNode) && child.content.trim() !== "");
@@ -5102,7 +6151,7 @@ class FormatPrinter extends Printer {
5102
6151
  if (isNode(child, ERBContentNode))
5103
6152
  return true;
5104
6153
  if (isNode(child, HTMLElementNode)) {
5105
- return this.isInlineElement(getTagName(child));
6154
+ return isInlineElement(getTagName(child));
5106
6155
  }
5107
6156
  return false;
5108
6157
  });
@@ -5172,7 +6221,7 @@ class FormatPrinter extends Printer {
5172
6221
  }
5173
6222
  else {
5174
6223
  const printed = IdentityPrinter.print(child);
5175
- if (this.currentAttributeName && FormatPrinter.TOKEN_LIST_ATTRIBUTES.has(this.currentAttributeName)) {
6224
+ if (this.isInTokenListAttribute) {
5176
6225
  return printed.replace(/%>([^<\s])/g, '%> $1').replace(/([^>\s])<%/g, '$1 <%');
5177
6226
  }
5178
6227
  return printed;
@@ -5208,18 +6257,35 @@ class FormatPrinter extends Printer {
5208
6257
  let result = `<${tagName}`;
5209
6258
  result += this.renderAttributesString(attributes);
5210
6259
  result += ">";
5211
- const childrenContent = this.tryRenderChildrenInline(children);
6260
+ const childrenContent = this.tryRenderChildrenInline(children, tagName);
5212
6261
  if (!childrenContent)
5213
6262
  return null;
5214
6263
  result += childrenContent;
5215
6264
  result += `</${tagName}>`;
5216
6265
  return result;
5217
6266
  }
6267
+ /**
6268
+ * Check if children contain a leading herb:disable comment (after optional whitespace)
6269
+ */
6270
+ hasLeadingHerbDisable(children) {
6271
+ for (const child of children) {
6272
+ if (isNode(child, WhitespaceNode) || (isNode(child, HTMLTextNode) && child.content.trim() === "")) {
6273
+ continue;
6274
+ }
6275
+ return isNode(child, ERBContentNode) && isHerbDisableComment(child);
6276
+ }
6277
+ return false;
6278
+ }
5218
6279
  /**
5219
6280
  * Try to render just the children inline (without tags)
5220
6281
  */
5221
- tryRenderChildrenInline(children) {
6282
+ tryRenderChildrenInline(children, tagName) {
5222
6283
  let result = "";
6284
+ let hasInternalWhitespace = false;
6285
+ let addedLeadingSpace = false;
6286
+ const hasHerbDisable = this.hasLeadingHerbDisable(children);
6287
+ const hasOnlyTextContent = children.every(child => isNode(child, HTMLTextNode) || isNode(child, WhitespaceNode));
6288
+ const shouldPreserveSpaces = hasOnlyTextContent && tagName && isInlineElement(tagName);
5223
6289
  for (const child of children) {
5224
6290
  if (isNode(child, HTMLTextNode)) {
5225
6291
  const normalizedContent = child.content.replace(/\s+/g, ' ');
@@ -5227,36 +6293,53 @@ class FormatPrinter extends Printer {
5227
6293
  const hasTrailingSpace = /\s$/.test(child.content);
5228
6294
  const trimmedContent = normalizedContent.trim();
5229
6295
  if (trimmedContent) {
5230
- let finalContent = trimmedContent;
5231
- if (hasLeadingSpace && result && !result.endsWith(' ')) {
5232
- finalContent = ' ' + finalContent;
6296
+ if (hasLeadingSpace && (result || shouldPreserveSpaces) && !result.endsWith(' ')) {
6297
+ result += ' ';
5233
6298
  }
6299
+ result += trimmedContent;
5234
6300
  if (hasTrailingSpace) {
5235
- finalContent = finalContent + ' ';
5236
- }
5237
- result += finalContent;
5238
- }
5239
- else if (hasLeadingSpace || hasTrailingSpace) {
5240
- if (result && !result.endsWith(' ')) {
5241
6301
  result += ' ';
5242
6302
  }
6303
+ continue;
6304
+ }
6305
+ }
6306
+ const isWhitespace = isNode(child, WhitespaceNode) || (isNode(child, HTMLTextNode) && child.content.trim() === "");
6307
+ if (isWhitespace && !result.endsWith(' ')) {
6308
+ if (!result && hasHerbDisable && !addedLeadingSpace) {
6309
+ result += ' ';
6310
+ addedLeadingSpace = true;
6311
+ }
6312
+ else if (result) {
6313
+ result += ' ';
6314
+ hasInternalWhitespace = true;
5243
6315
  }
5244
6316
  }
5245
6317
  else if (isNode(child, HTMLElementNode)) {
5246
6318
  const tagName = getTagName(child);
5247
- if (!this.isInlineElement(tagName)) {
6319
+ if (!isInlineElement(tagName)) {
5248
6320
  return null;
5249
6321
  }
5250
- const childInline = this.tryRenderInlineFull(child, tagName, filterNodes(child.open_tag?.children, HTMLAttributeNode), this.filterEmptyNodes(child.body));
6322
+ const childrenToRender = this.getFilteredChildren(child.body);
6323
+ const childInline = this.tryRenderInlineFull(child, tagName, filterNodes(child.open_tag?.children, HTMLAttributeNode), childrenToRender);
5251
6324
  if (!childInline) {
5252
6325
  return null;
5253
6326
  }
5254
6327
  result += childInline;
5255
6328
  }
5256
- else {
5257
- result += this.capture(() => this.visit(child)).join("");
6329
+ else if (!isNode(child, HTMLTextNode) && !isWhitespace) {
6330
+ const wasInlineMode = this.inlineMode;
6331
+ this.inlineMode = true;
6332
+ const captured = this.capture(() => this.visit(child)).join("");
6333
+ this.inlineMode = wasInlineMode;
6334
+ result += captured;
5258
6335
  }
5259
6336
  }
6337
+ if (shouldPreserveSpaces) {
6338
+ return result;
6339
+ }
6340
+ if (hasHerbDisable && result.startsWith(' ') || hasInternalWhitespace) {
6341
+ return result.trimEnd();
6342
+ }
5260
6343
  return result.trim();
5261
6344
  }
5262
6345
  /**
@@ -5271,8 +6354,7 @@ class FormatPrinter extends Printer {
5271
6354
  }
5272
6355
  }
5273
6356
  else if (isNode(child, HTMLElementNode)) {
5274
- const isInlineElement = this.isInlineElement(getTagName(child));
5275
- if (!isInlineElement) {
6357
+ if (!isInlineElement(getTagName(child))) {
5276
6358
  return null;
5277
6359
  }
5278
6360
  }
@@ -5288,103 +6370,14 @@ class FormatPrinter extends Printer {
5288
6370
  return `<${tagName}>${content}</${tagName}>`;
5289
6371
  }
5290
6372
  /**
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
6373
+ * Get filtered children, using smart herb:disable filtering if needed
5314
6374
  */
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
5351
- */
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() === ""));
6375
+ getFilteredChildren(body) {
6376
+ const hasHerbDisable = body.some(child => isNode(child, ERBContentNode) && isHerbDisableComment(child));
6377
+ return hasHerbDisable ? filterEmptyNodesForHerbDisable(body) : body;
5385
6378
  }
5386
6379
  renderElementInline(element) {
5387
- const children = this.filterEmptyNodes(element.body);
6380
+ const children = this.getFilteredChildren(element.body);
5388
6381
  return this.renderChildrenInline(children);
5389
6382
  }
5390
6383
  renderChildrenInline(children) {
@@ -5406,9 +6399,29 @@ class FormatPrinter extends Printer {
5406
6399
  }
5407
6400
  return content.replace(/\s+/g, ' ').trim();
5408
6401
  }
5409
- isContentPreserving(element) {
5410
- const tagName = getTagName(element);
5411
- return FormatPrinter.CONTENT_PRESERVING_ELEMENTS.has(tagName);
6402
+ }
6403
+
6404
+ const isScaffoldTemplate = (result) => {
6405
+ const detector = new ScaffoldTemplateDetector();
6406
+ detector.visit(result.value);
6407
+ return detector.hasEscapedERB;
6408
+ };
6409
+ /**
6410
+ * Visitor that detects if the AST represents a Rails scaffold template.
6411
+ * Scaffold templates contain escaped ERB tags (<%%= or <%%)
6412
+ * and should not be formatted to preserve their exact structure.
6413
+ */
6414
+ class ScaffoldTemplateDetector extends Visitor {
6415
+ hasEscapedERB = false;
6416
+ visitERBContentNode(node) {
6417
+ const opening = node.tag_opening?.value;
6418
+ if (opening && opening.startsWith("<%%")) {
6419
+ this.hasEscapedERB = true;
6420
+ return;
6421
+ }
6422
+ if (this.hasEscapedERB)
6423
+ return;
6424
+ this.visitChildNodes(node);
5412
6425
  }
5413
6426
  }
5414
6427
 
@@ -5418,6 +6431,8 @@ class FormatPrinter extends Printer {
5418
6431
  const defaultFormatOptions = {
5419
6432
  indentWidth: 2,
5420
6433
  maxLineLength: 80,
6434
+ preRewriters: [],
6435
+ postRewriters: [],
5421
6436
  };
5422
6437
  /**
5423
6438
  * Merge provided options with defaults for any missing values.
@@ -5428,6 +6443,8 @@ function resolveFormatOptions(options = {}) {
5428
6443
  return {
5429
6444
  indentWidth: options.indentWidth ?? defaultFormatOptions.indentWidth,
5430
6445
  maxLineLength: options.maxLineLength ?? defaultFormatOptions.maxLineLength,
6446
+ preRewriters: options.preRewriters ?? defaultFormatOptions.preRewriters,
6447
+ postRewriters: options.postRewriters ?? defaultFormatOptions.postRewriters,
5431
6448
  };
5432
6449
  }
5433
6450
 
@@ -5438,6 +6455,30 @@ function resolveFormatOptions(options = {}) {
5438
6455
  class Formatter {
5439
6456
  herb;
5440
6457
  options;
6458
+ /**
6459
+ * Creates a Formatter instance from a Config object (recommended).
6460
+ *
6461
+ * @param herb - The Herb backend instance for parsing
6462
+ * @param config - Optional Config instance for formatter options
6463
+ * @param options - Additional options to override config
6464
+ * @returns A configured Formatter instance
6465
+ */
6466
+ static from(herb, config, options = {}) {
6467
+ const formatterConfig = config?.formatter || {};
6468
+ const mergedOptions = {
6469
+ indentWidth: options.indentWidth ?? formatterConfig.indentWidth,
6470
+ maxLineLength: options.maxLineLength ?? formatterConfig.maxLineLength,
6471
+ preRewriters: options.preRewriters,
6472
+ postRewriters: options.postRewriters,
6473
+ };
6474
+ return new Formatter(herb, mergedOptions);
6475
+ }
6476
+ /**
6477
+ * Creates a new Formatter instance.
6478
+ *
6479
+ * @param herb - The Herb backend instance for parsing
6480
+ * @param options - Format options (including rewriters)
6481
+ */
5441
6482
  constructor(herb, options = {}) {
5442
6483
  this.herb = herb;
5443
6484
  this.options = resolveFormatOptions(options);
@@ -5445,12 +6486,44 @@ class Formatter {
5445
6486
  /**
5446
6487
  * Format a source string, optionally overriding format options per call.
5447
6488
  */
5448
- format(source, options = {}) {
5449
- const result = this.parse(source);
6489
+ format(source, options = {}, filePath) {
6490
+ let result = this.parse(source);
5450
6491
  if (result.failed)
5451
6492
  return source;
6493
+ if (isScaffoldTemplate(result))
6494
+ return source;
5452
6495
  const resolvedOptions = resolveFormatOptions({ ...this.options, ...options });
5453
- return new FormatPrinter(source, resolvedOptions).print(result.value);
6496
+ let node = result.value;
6497
+ if (resolvedOptions.preRewriters.length > 0) {
6498
+ const context = {
6499
+ filePath,
6500
+ baseDir: process.cwd() // TODO: format() shouldn't depend on node internals
6501
+ };
6502
+ for (const rewriter of resolvedOptions.preRewriters) {
6503
+ try {
6504
+ node = rewriter.rewrite(node, context);
6505
+ }
6506
+ catch (error) {
6507
+ console.error(`Pre-format rewriter "${rewriter.name}" failed:`, error);
6508
+ }
6509
+ }
6510
+ }
6511
+ let formatted = new FormatPrinter(source, resolvedOptions).print(node);
6512
+ if (resolvedOptions.postRewriters.length > 0) {
6513
+ const context = {
6514
+ filePath,
6515
+ baseDir: process.cwd() // TODO: format() shouldn't depend on node internals
6516
+ };
6517
+ for (const rewriter of resolvedOptions.postRewriters) {
6518
+ try {
6519
+ formatted = rewriter.rewrite(formatted, context);
6520
+ }
6521
+ catch (error) {
6522
+ console.error(`Post-format rewriter "${rewriter.name}" failed:`, error);
6523
+ }
6524
+ }
6525
+ }
6526
+ return formatted;
5454
6527
  }
5455
6528
  parse(source) {
5456
6529
  this.herb.ensureBackend();