@herb-tools/formatter 0.7.4 → 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.4/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.4/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.4/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.4/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)
@@ -3380,97 +3505,151 @@ class Visitor {
3380
3505
  visitChildNodes(node) {
3381
3506
  node.compactChildNodes().forEach(node => node.accept(this));
3382
3507
  }
3508
+ visitNode(_node) {
3509
+ // Default implementation does nothing
3510
+ }
3511
+ visitERBNode(_node) {
3512
+ // Default implementation does nothing
3513
+ }
3383
3514
  visitDocumentNode(node) {
3515
+ this.visitNode(node);
3384
3516
  this.visitChildNodes(node);
3385
3517
  }
3386
3518
  visitLiteralNode(node) {
3519
+ this.visitNode(node);
3387
3520
  this.visitChildNodes(node);
3388
3521
  }
3389
3522
  visitHTMLOpenTagNode(node) {
3523
+ this.visitNode(node);
3390
3524
  this.visitChildNodes(node);
3391
3525
  }
3392
3526
  visitHTMLCloseTagNode(node) {
3527
+ this.visitNode(node);
3393
3528
  this.visitChildNodes(node);
3394
3529
  }
3395
3530
  visitHTMLElementNode(node) {
3531
+ this.visitNode(node);
3396
3532
  this.visitChildNodes(node);
3397
3533
  }
3398
3534
  visitHTMLAttributeValueNode(node) {
3535
+ this.visitNode(node);
3399
3536
  this.visitChildNodes(node);
3400
3537
  }
3401
3538
  visitHTMLAttributeNameNode(node) {
3539
+ this.visitNode(node);
3402
3540
  this.visitChildNodes(node);
3403
3541
  }
3404
3542
  visitHTMLAttributeNode(node) {
3543
+ this.visitNode(node);
3405
3544
  this.visitChildNodes(node);
3406
3545
  }
3407
3546
  visitHTMLTextNode(node) {
3547
+ this.visitNode(node);
3408
3548
  this.visitChildNodes(node);
3409
3549
  }
3410
3550
  visitHTMLCommentNode(node) {
3551
+ this.visitNode(node);
3411
3552
  this.visitChildNodes(node);
3412
3553
  }
3413
3554
  visitHTMLDoctypeNode(node) {
3555
+ this.visitNode(node);
3414
3556
  this.visitChildNodes(node);
3415
3557
  }
3416
3558
  visitXMLDeclarationNode(node) {
3559
+ this.visitNode(node);
3417
3560
  this.visitChildNodes(node);
3418
3561
  }
3419
3562
  visitCDATANode(node) {
3563
+ this.visitNode(node);
3420
3564
  this.visitChildNodes(node);
3421
3565
  }
3422
3566
  visitWhitespaceNode(node) {
3567
+ this.visitNode(node);
3423
3568
  this.visitChildNodes(node);
3424
3569
  }
3425
3570
  visitERBContentNode(node) {
3571
+ this.visitNode(node);
3572
+ this.visitERBNode(node);
3426
3573
  this.visitChildNodes(node);
3427
3574
  }
3428
3575
  visitERBEndNode(node) {
3576
+ this.visitNode(node);
3577
+ this.visitERBNode(node);
3429
3578
  this.visitChildNodes(node);
3430
3579
  }
3431
3580
  visitERBElseNode(node) {
3581
+ this.visitNode(node);
3582
+ this.visitERBNode(node);
3432
3583
  this.visitChildNodes(node);
3433
3584
  }
3434
3585
  visitERBIfNode(node) {
3586
+ this.visitNode(node);
3587
+ this.visitERBNode(node);
3435
3588
  this.visitChildNodes(node);
3436
3589
  }
3437
3590
  visitERBBlockNode(node) {
3591
+ this.visitNode(node);
3592
+ this.visitERBNode(node);
3438
3593
  this.visitChildNodes(node);
3439
3594
  }
3440
3595
  visitERBWhenNode(node) {
3596
+ this.visitNode(node);
3597
+ this.visitERBNode(node);
3441
3598
  this.visitChildNodes(node);
3442
3599
  }
3443
3600
  visitERBCaseNode(node) {
3601
+ this.visitNode(node);
3602
+ this.visitERBNode(node);
3444
3603
  this.visitChildNodes(node);
3445
3604
  }
3446
3605
  visitERBCaseMatchNode(node) {
3606
+ this.visitNode(node);
3607
+ this.visitERBNode(node);
3447
3608
  this.visitChildNodes(node);
3448
3609
  }
3449
3610
  visitERBWhileNode(node) {
3611
+ this.visitNode(node);
3612
+ this.visitERBNode(node);
3450
3613
  this.visitChildNodes(node);
3451
3614
  }
3452
3615
  visitERBUntilNode(node) {
3616
+ this.visitNode(node);
3617
+ this.visitERBNode(node);
3453
3618
  this.visitChildNodes(node);
3454
3619
  }
3455
3620
  visitERBForNode(node) {
3621
+ this.visitNode(node);
3622
+ this.visitERBNode(node);
3456
3623
  this.visitChildNodes(node);
3457
3624
  }
3458
3625
  visitERBRescueNode(node) {
3626
+ this.visitNode(node);
3627
+ this.visitERBNode(node);
3459
3628
  this.visitChildNodes(node);
3460
3629
  }
3461
3630
  visitERBEnsureNode(node) {
3631
+ this.visitNode(node);
3632
+ this.visitERBNode(node);
3462
3633
  this.visitChildNodes(node);
3463
3634
  }
3464
3635
  visitERBBeginNode(node) {
3636
+ this.visitNode(node);
3637
+ this.visitERBNode(node);
3465
3638
  this.visitChildNodes(node);
3466
3639
  }
3467
3640
  visitERBUnlessNode(node) {
3641
+ this.visitNode(node);
3642
+ this.visitERBNode(node);
3468
3643
  this.visitChildNodes(node);
3469
3644
  }
3470
3645
  visitERBYieldNode(node) {
3646
+ this.visitNode(node);
3647
+ this.visitERBNode(node);
3471
3648
  this.visitChildNodes(node);
3472
3649
  }
3473
3650
  visitERBInNode(node) {
3651
+ this.visitNode(node);
3652
+ this.visitERBNode(node);
3474
3653
  this.visitChildNodes(node);
3475
3654
  }
3476
3655
  }
@@ -3629,6 +3808,11 @@ class Printer extends Visitor {
3629
3808
  * - Verifying AST round-trip fidelity
3630
3809
  */
3631
3810
  class IdentityPrinter extends Printer {
3811
+ static printERBNode(node) {
3812
+ const printer = new IdentityPrinter();
3813
+ printer.printERBNode(node);
3814
+ return printer.context.getOutput();
3815
+ }
3632
3816
  visitLiteralNode(node) {
3633
3817
  this.write(node.content);
3634
3818
  }
@@ -3916,11 +4100,396 @@ class IdentityPrinter extends Printer {
3916
4100
  ({
3917
4101
  ...DEFAULT_PRINT_OPTIONS});
3918
4102
 
4103
+ // --- Constants ---
3919
4104
  // TODO: we can probably expand this list with more tags/attributes
3920
4105
  const FORMATTABLE_ATTRIBUTES = {
3921
4106
  '*': ['class'],
3922
4107
  'img': ['srcset', 'sizes']
3923
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
+
3924
4493
  /**
3925
4494
  * Printer traverses the Herb AST using the Visitor pattern
3926
4495
  * and emits a formatted string with proper indentation, line breaks, and attribute wrapping.
@@ -3944,28 +4513,6 @@ class FormatPrinter extends Printer {
3944
4513
  elementStack = [];
3945
4514
  elementFormattingAnalysis = new Map();
3946
4515
  source;
3947
- // TODO: extract
3948
- static INLINE_ELEMENTS = new Set([
3949
- 'a', 'abbr', 'acronym', 'b', 'bdo', 'big', 'br', 'cite', 'code',
3950
- 'dfn', 'em', 'i', 'img', 'kbd', 'label', 'map', 'object', 'q',
3951
- 'samp', 'small', 'span', 'strong', 'sub', 'sup',
3952
- 'tt', 'var', 'del', 'ins', 'mark', 's', 'u', 'time', 'wbr'
3953
- ]);
3954
- static CONTENT_PRESERVING_ELEMENTS = new Set([
3955
- 'script', 'style', 'pre', 'textarea'
3956
- ]);
3957
- static SPACEABLE_CONTAINERS = new Set([
3958
- 'div', 'section', 'article', 'main', 'header', 'footer', 'aside',
3959
- 'figure', 'details', 'summary', 'dialog', 'fieldset'
3960
- ]);
3961
- static TIGHT_GROUP_PARENTS = new Set([
3962
- 'ul', 'ol', 'nav', 'select', 'datalist', 'optgroup', 'tr', 'thead',
3963
- 'tbody', 'tfoot'
3964
- ]);
3965
- static TIGHT_GROUP_CHILDREN = new Set([
3966
- 'li', 'option', 'td', 'th', 'dt', 'dd'
3967
- ]);
3968
- static SPACING_THRESHOLD = 3;
3969
4516
  constructor(source, options) {
3970
4517
  super();
3971
4518
  this.source = source;
@@ -4124,20 +4671,20 @@ class FormatPrinter extends Printer {
4124
4671
  if (hasMixedContent) {
4125
4672
  return false;
4126
4673
  }
4127
- const meaningfulSiblings = siblings.filter(child => this.isNonWhitespaceNode(child));
4128
- if (meaningfulSiblings.length < FormatPrinter.SPACING_THRESHOLD) {
4674
+ const meaningfulSiblings = siblings.filter(child => isNonWhitespaceNode(child));
4675
+ if (meaningfulSiblings.length < SPACING_THRESHOLD) {
4129
4676
  return false;
4130
4677
  }
4131
4678
  const parentTagName = parentElement ? getTagName(parentElement) : null;
4132
- if (parentTagName && FormatPrinter.TIGHT_GROUP_PARENTS.has(parentTagName)) {
4679
+ if (parentTagName && TIGHT_GROUP_PARENTS.has(parentTagName)) {
4133
4680
  return false;
4134
4681
  }
4135
- const isSpaceableContainer = !parentTagName || (parentTagName && FormatPrinter.SPACEABLE_CONTAINERS.has(parentTagName));
4682
+ const isSpaceableContainer = !parentTagName || (parentTagName && SPACEABLE_CONTAINERS.has(parentTagName));
4136
4683
  if (!isSpaceableContainer && meaningfulSiblings.length < 5) {
4137
4684
  return false;
4138
4685
  }
4139
4686
  const currentNode = siblings[currentIndex];
4140
- const previousMeaningfulIndex = this.findPreviousMeaningfulSibling(siblings, currentIndex);
4687
+ const previousMeaningfulIndex = findPreviousMeaningfulSibling(siblings, currentIndex);
4141
4688
  const isCurrentComment = isCommentNode(currentNode);
4142
4689
  if (previousMeaningfulIndex !== -1) {
4143
4690
  const previousNode = siblings[previousMeaningfulIndex];
@@ -4151,66 +4698,34 @@ class FormatPrinter extends Printer {
4151
4698
  }
4152
4699
  if (isNode(currentNode, HTMLElementNode)) {
4153
4700
  const currentTagName = getTagName(currentNode);
4154
- if (FormatPrinter.INLINE_ELEMENTS.has(currentTagName)) {
4701
+ if (INLINE_ELEMENTS.has(currentTagName)) {
4155
4702
  return false;
4156
4703
  }
4157
- if (FormatPrinter.TIGHT_GROUP_CHILDREN.has(currentTagName)) {
4704
+ if (TIGHT_GROUP_CHILDREN.has(currentTagName)) {
4158
4705
  return false;
4159
4706
  }
4160
4707
  if (currentTagName === 'a' && parentTagName === 'nav') {
4161
4708
  return false;
4162
4709
  }
4163
4710
  }
4164
- const isBlockElement = this.isBlockLevelNode(currentNode);
4711
+ const isBlockElement = isBlockLevelNode(currentNode);
4165
4712
  const isERBBlock = isERBNode(currentNode) && isERBControlFlowNode(currentNode);
4166
4713
  const isComment = isCommentNode(currentNode);
4167
4714
  return isBlockElement || isERBBlock || isComment;
4168
4715
  }
4169
- /**
4170
- * Token list attributes that contain space-separated values and benefit from
4171
- * spacing around ERB content for readability
4172
- */
4173
- static TOKEN_LIST_ATTRIBUTES = new Set([
4174
- 'class', 'data-controller', 'data-action'
4175
- ]);
4176
4716
  /**
4177
4717
  * Check if we're currently processing a token list attribute that needs spacing
4178
4718
  */
4179
- isInTokenListAttribute() {
4180
- return this.currentAttributeName !== null &&
4181
- FormatPrinter.TOKEN_LIST_ATTRIBUTES.has(this.currentAttributeName);
4719
+ get isInTokenListAttribute() {
4720
+ return this.currentAttributeName !== null && TOKEN_LIST_ATTRIBUTES.has(this.currentAttributeName);
4182
4721
  }
4183
4722
  /**
4184
- * Find the previous meaningful (non-whitespace) sibling
4723
+ * Render attributes as a space-separated string
4185
4724
  */
4186
- findPreviousMeaningfulSibling(siblings, currentIndex) {
4187
- for (let i = currentIndex - 1; i >= 0; i--) {
4188
- if (this.isNonWhitespaceNode(siblings[i])) {
4189
- return i;
4190
- }
4191
- }
4192
- return -1;
4193
- }
4194
- /**
4195
- * Check if a node represents a block-level element
4196
- */
4197
- isBlockLevelNode(node) {
4198
- if (!isNode(node, HTMLElementNode)) {
4199
- return false;
4200
- }
4201
- const tagName = getTagName(node);
4202
- if (FormatPrinter.INLINE_ELEMENTS.has(tagName)) {
4203
- return false;
4204
- }
4205
- return true;
4206
- }
4207
- /**
4208
- * Render attributes as a space-separated string
4209
- */
4210
- renderAttributesString(attributes) {
4211
- if (attributes.length === 0)
4212
- return "";
4213
- return ` ${attributes.map(attribute => this.renderAttribute(attribute)).join(" ")}`;
4725
+ renderAttributesString(attributes) {
4726
+ if (attributes.length === 0)
4727
+ return "";
4728
+ return ` ${attributes.map(attribute => this.renderAttribute(attribute)).join(" ")}`;
4214
4729
  }
4215
4730
  /**
4216
4731
  * Determine if a tag should be rendered inline based on attribute count and other factors
@@ -4240,9 +4755,6 @@ class FormatPrinter extends Printer {
4240
4755
  }
4241
4756
  return true;
4242
4757
  }
4243
- getAttributeName(attribute) {
4244
- return attribute.name ? getCombinedAttributeName(attribute.name) : "";
4245
- }
4246
4758
  wouldClassAttributeBeMultiline(content, indentLength) {
4247
4759
  const normalizedContent = content.replace(/\s+/g, ' ').trim();
4248
4760
  const hasActualNewlines = /\r?\n/.test(content);
@@ -4264,6 +4776,11 @@ class FormatPrinter extends Printer {
4264
4776
  }
4265
4777
  return false;
4266
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
4267
4784
  getAttributeValue(attribute) {
4268
4785
  if (isNode(attribute.value, HTMLAttributeValueNode)) {
4269
4786
  return attribute.value.children.map(child => isNode(child, HTMLTextNode) ? child.content : IdentityPrinter.print(child)).join('');
@@ -4399,28 +4916,38 @@ class FormatPrinter extends Printer {
4399
4916
  }
4400
4917
  // --- Visitor methods ---
4401
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
+ }
4402
4928
  let lastWasMeaningful = false;
4403
4929
  let hasHandledSpacing = false;
4404
- for (let i = 0; i < node.children.length; i++) {
4405
- const child = node.children[i];
4406
- if (isNode(child, HTMLTextNode)) {
4407
- const isWhitespaceOnly = child.content.trim() === "";
4408
- if (isWhitespaceOnly) {
4409
- const hasPreviousNonWhitespace = i > 0 && this.isNonWhitespaceNode(node.children[i - 1]);
4410
- const hasNextNonWhitespace = i < node.children.length - 1 && this.isNonWhitespaceNode(node.children[i + 1]);
4411
- const hasMultipleNewlines = child.content.includes('\n\n');
4412
- if (hasPreviousNonWhitespace && hasNextNonWhitespace && hasMultipleNewlines) {
4413
- this.push("");
4414
- hasHandledSpacing = true;
4415
- }
4416
- continue;
4417
- }
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;
4418
4939
  }
4419
- 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) {
4420
4947
  this.push("");
4421
4948
  }
4422
4949
  this.visit(child);
4423
- if (this.isNonWhitespaceNode(child)) {
4950
+ if (isNonWhitespaceNode(child)) {
4424
4951
  lastWasMeaningful = true;
4425
4952
  hasHandledSpacing = false;
4426
4953
  }
@@ -4429,6 +4956,12 @@ class FormatPrinter extends Printer {
4429
4956
  visitHTMLElementNode(node) {
4430
4957
  this.elementStack.push(node);
4431
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
+ }
4432
4965
  this.visit(node.open_tag);
4433
4966
  if (node.body.length > 0) {
4434
4967
  this.visitHTMLElementBody(node.body, node);
@@ -4439,7 +4972,8 @@ class FormatPrinter extends Printer {
4439
4972
  this.elementStack.pop();
4440
4973
  }
4441
4974
  visitHTMLElementBody(body, element) {
4442
- if (this.isContentPreserving(element)) {
4975
+ const tagName = getTagName(element);
4976
+ if (isContentPreserving(element)) {
4443
4977
  element.body.map(child => {
4444
4978
  if (isNode(child, HTMLElementNode)) {
4445
4979
  const wasInlineMode = this.inlineMode;
@@ -4456,12 +4990,14 @@ class FormatPrinter extends Printer {
4456
4990
  }
4457
4991
  const analysis = this.elementFormattingAnalysis.get(element);
4458
4992
  const hasTextFlow = this.isInTextFlowContext(null, body);
4459
- const children = this.filterSignificantChildren(body, hasTextFlow);
4993
+ const children = filterSignificantChildren(body);
4460
4994
  if (analysis?.elementContentInline) {
4461
4995
  if (children.length === 0)
4462
4996
  return;
4463
4997
  const oldInlineMode = this.inlineMode;
4464
4998
  const nodesToRender = hasTextFlow ? body : children;
4999
+ const hasOnlyTextContent = nodesToRender.every(child => isNode(child, HTMLTextNode) || isNode(child, WhitespaceNode));
5000
+ const shouldPreserveSpaces = hasOnlyTextContent && isInlineElement(tagName);
4465
5001
  this.inlineMode = true;
4466
5002
  const lines = this.capture(() => {
4467
5003
  nodesToRender.forEach(child => {
@@ -4476,10 +5012,19 @@ class FormatPrinter extends Printer {
4476
5012
  }
4477
5013
  }
4478
5014
  else {
4479
- const normalizedContent = child.content.replace(/\s+/g, ' ').trim();
4480
- if (normalizedContent) {
5015
+ const normalizedContent = child.content.replace(/\s+/g, ' ');
5016
+ if (shouldPreserveSpaces && normalizedContent) {
4481
5017
  this.push(normalizedContent);
4482
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
+ }
4483
5028
  }
4484
5029
  }
4485
5030
  else if (isNode(child, WhitespaceNode)) {
@@ -4491,7 +5036,9 @@ class FormatPrinter extends Printer {
4491
5036
  });
4492
5037
  });
4493
5038
  const content = lines.join('');
4494
- 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());
4495
5042
  if (inlineContent) {
4496
5043
  this.pushToLastLine(inlineContent);
4497
5044
  }
@@ -4500,12 +5047,69 @@ class FormatPrinter extends Printer {
4500
5047
  }
4501
5048
  if (children.length === 0)
4502
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;
4503
5107
  this.withIndent(() => {
4504
5108
  if (hasTextFlow) {
4505
- this.visitTextFlowChildren(children);
5109
+ this.visitTextFlowChildren(remainingBodyUnfiltered);
4506
5110
  }
4507
5111
  else {
4508
- this.visitElementChildren(body, element);
5112
+ this.visitElementChildren(leadingHerbDisableComment ? remainingChildren : body, element);
4509
5113
  }
4510
5114
  });
4511
5115
  }
@@ -4520,8 +5124,8 @@ class FormatPrinter extends Printer {
4520
5124
  if (isNode(child, HTMLTextNode)) {
4521
5125
  const isWhitespaceOnly = child.content.trim() === "";
4522
5126
  if (isWhitespaceOnly) {
4523
- const hasPreviousNonWhitespace = i > 0 && this.isNonWhitespaceNode(body[i - 1]);
4524
- 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]);
4525
5129
  const hasMultipleNewlines = child.content.includes('\n\n');
4526
5130
  if (hasPreviousNonWhitespace && hasNextNonWhitespace && hasMultipleNewlines) {
4527
5131
  this.push("");
@@ -4530,7 +5134,7 @@ class FormatPrinter extends Printer {
4530
5134
  continue;
4531
5135
  }
4532
5136
  }
4533
- if (this.isNonWhitespaceNode(child) && lastWasMeaningful && !hasHandledSpacing) {
5137
+ if (isNonWhitespaceNode(child) && lastWasMeaningful && !hasHandledSpacing) {
4534
5138
  const element = body[i - 1];
4535
5139
  const hasExistingSpacing = i > 0 && isNode(element, HTMLTextNode) && element.content.trim() === "" && (element.content.includes('\n\n') || element.content.split('\n').length > 2);
4536
5140
  const shouldAddSpacing = this.shouldAddSpacingBetweenSiblings(parentElement, body, i, hasExistingSpacing);
@@ -4538,8 +5142,35 @@ class FormatPrinter extends Printer {
4538
5142
  this.push("");
4539
5143
  }
4540
5144
  }
4541
- this.visit(child);
4542
- 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)) {
4543
5174
  lastWasMeaningful = true;
4544
5175
  hasHandledSpacing = false;
4545
5176
  }
@@ -4629,8 +5260,8 @@ class FormatPrinter extends Printer {
4629
5260
  if (isNode(child, HTMLTextNode) || isNode(child, LiteralNode)) {
4630
5261
  return child.content;
4631
5262
  }
4632
- else if (isERBNode(child) || isNode(child, ERBContentNode)) {
4633
- return this.reconstructERBNode(child, false);
5263
+ else if (isERBNode(child)) {
5264
+ return IdentityPrinter.print(child);
4634
5265
  }
4635
5266
  else {
4636
5267
  return "";
@@ -4683,11 +5314,21 @@ class FormatPrinter extends Printer {
4683
5314
  if (contentLines.length === 1 && contentTrimmedLines.length === 1) {
4684
5315
  const startsWithSpace = content[0] === " ";
4685
5316
  const before = startsWithSpace ? "" : " ";
4686
- 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
+ }
4687
5323
  return;
4688
5324
  }
4689
5325
  if (contentTrimmedLines.length === 1) {
4690
- 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
+ }
4691
5332
  return;
4692
5333
  }
4693
5334
  const firstLineEmpty = contentLines[0].trim() === "";
@@ -4728,6 +5369,7 @@ class FormatPrinter extends Printer {
4728
5369
  }
4729
5370
  visitERBCaseMatchNode(node) {
4730
5371
  this.printERBNode(node);
5372
+ this.withIndent(() => this.visitAll(node.children));
4731
5373
  this.visitAll(node.conditions);
4732
5374
  if (node.else_clause)
4733
5375
  this.visit(node.else_clause);
@@ -4736,7 +5378,15 @@ class FormatPrinter extends Printer {
4736
5378
  }
4737
5379
  visitERBBlockNode(node) {
4738
5380
  this.printERBNode(node);
4739
- 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
+ });
4740
5390
  if (node.end_node)
4741
5391
  this.visit(node.end_node);
4742
5392
  }
@@ -4749,7 +5399,7 @@ class FormatPrinter extends Printer {
4749
5399
  this.lines.push(this.renderAttribute(child));
4750
5400
  }
4751
5401
  else {
4752
- const shouldAddSpaces = this.isInTokenListAttribute();
5402
+ const shouldAddSpaces = this.isInTokenListAttribute;
4753
5403
  if (shouldAddSpaces) {
4754
5404
  this.lines.push(" ");
4755
5405
  }
@@ -4760,12 +5410,12 @@ class FormatPrinter extends Printer {
4760
5410
  }
4761
5411
  });
4762
5412
  const hasHTMLAttributes = node.statements.some(child => isNode(child, HTMLAttributeNode));
4763
- const isTokenList = this.isInTokenListAttribute();
5413
+ const isTokenList = this.isInTokenListAttribute;
4764
5414
  if ((hasHTMLAttributes || isTokenList) && node.end_node) {
4765
5415
  this.lines.push(" ");
4766
5416
  }
4767
5417
  if (node.subsequent)
4768
- this.visit(node.end_node);
5418
+ this.visit(node.subsequent);
4769
5419
  if (node.end_node)
4770
5420
  this.visit(node.end_node);
4771
5421
  }
@@ -4781,8 +5431,14 @@ class FormatPrinter extends Printer {
4781
5431
  }
4782
5432
  }
4783
5433
  visitERBElseNode(node) {
4784
- this.printERBNode(node);
4785
- 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
+ }
4786
5442
  }
4787
5443
  visitERBWhenNode(node) {
4788
5444
  this.printERBNode(node);
@@ -4790,6 +5446,7 @@ class FormatPrinter extends Printer {
4790
5446
  }
4791
5447
  visitERBCaseNode(node) {
4792
5448
  this.printERBNode(node);
5449
+ this.withIndent(() => this.visitAll(node.children));
4793
5450
  this.visitAll(node.conditions);
4794
5451
  if (node.else_clause)
4795
5452
  this.visit(node.else_clause);
@@ -4864,7 +5521,7 @@ class FormatPrinter extends Printer {
4864
5521
  const attributes = filterNodes(children, HTMLAttributeNode);
4865
5522
  const inlineNodes = this.extractInlineNodes(children);
4866
5523
  const hasERBControlFlow = inlineNodes.some(node => isERBControlFlowNode(node)) || children.some(node => isERBControlFlowNode(node));
4867
- const hasComplexERB = hasERBControlFlow && this.hasComplexERBControlFlow(inlineNodes);
5524
+ const hasComplexERB = hasERBControlFlow && hasComplexERBControlFlow(inlineNodes);
4868
5525
  if (hasComplexERB)
4869
5526
  return false;
4870
5527
  const totalAttributeCount = this.getTotalAttributeCount(attributes, inlineNodes);
@@ -4879,26 +5536,38 @@ class FormatPrinter extends Printer {
4879
5536
  */
4880
5537
  shouldRenderElementContentInline(node) {
4881
5538
  const tagName = getTagName(node);
4882
- const children = this.filterSignificantChildren(node.body, this.isInTextFlowContext(null, node.body));
4883
- const isInlineElement = this.isInlineElement(tagName);
5539
+ const children = filterSignificantChildren(node.body);
4884
5540
  const openTagInline = this.shouldRenderOpenTagInline(node);
4885
5541
  if (!openTagInline)
4886
5542
  return false;
4887
5543
  if (children.length === 0)
4888
5544
  return true;
4889
- if (isInlineElement) {
4890
- 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);
4891
5560
  if (fullInlineResult) {
4892
5561
  const totalLength = this.indent.length + fullInlineResult.length;
4893
5562
  return totalLength <= this.maxLineLength || totalLength <= 120;
4894
5563
  }
4895
5564
  return false;
4896
5565
  }
4897
- const allNestedAreInline = this.areAllNestedElementsInline(children);
4898
- const hasMultilineText = this.hasMultilineTextContent(children);
4899
- const hasMixedContent = this.hasMixedTextAndInlineContent(children);
5566
+ const allNestedAreInline = areAllNestedElementsInline(children);
5567
+ const hasMultilineText = hasMultilineTextContent(children);
5568
+ const hasMixedContent = hasMixedTextAndInlineContent(children);
4900
5569
  if (allNestedAreInline && (!hasMultilineText || hasMixedContent)) {
4901
- 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);
4902
5571
  if (fullInlineResult) {
4903
5572
  const totalLength = this.indent.length + fullInlineResult.length;
4904
5573
  if (totalLength <= this.maxLineLength) {
@@ -4925,117 +5594,551 @@ class FormatPrinter extends Printer {
4925
5594
  return true;
4926
5595
  if (node.open_tag?.tag_closing?.value === "/>")
4927
5596
  return true;
4928
- if (this.isContentPreserving(node))
5597
+ if (isContentPreserving(node))
4929
5598
  return true;
4930
- const children = this.filterSignificantChildren(node.body, this.isInTextFlowContext(null, node.body));
5599
+ const children = filterSignificantChildren(node.body);
4931
5600
  if (children.length === 0)
4932
5601
  return true;
4933
5602
  return elementContentInline;
4934
5603
  }
4935
5604
  // --- Utility methods ---
4936
- isNonWhitespaceNode(node) {
4937
- if (isNode(node, WhitespaceNode))
4938
- return false;
4939
- if (isNode(node, HTMLTextNode))
4940
- return node.content.trim() !== "";
4941
- 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;
4942
5615
  }
4943
5616
  /**
4944
- * Check if an element should be treated as inline based on its tag name
5617
+ * Append a child node to the last output line
4945
5618
  */
4946
- isInlineElement(tagName) {
4947
- 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
+ }
4948
5637
  }
4949
5638
  /**
4950
- * 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
4951
5641
  */
4952
5642
  visitTextFlowChildren(children) {
4953
- let currentLineContent = "";
4954
- for (const child of children) {
4955
- if (isNode(child, HTMLTextNode)) {
4956
- const content = child.content;
4957
- let processedContent = content.replace(/\s+/g, ' ').trim();
4958
- if (processedContent) {
4959
- const hasLeadingSpace = /^\s/.test(content);
4960
- if (currentLineContent && hasLeadingSpace && !currentLineContent.endsWith(' ')) {
4961
- currentLineContent += ' ';
4962
- }
4963
- currentLineContent += processedContent;
4964
- const hasTrailingSpace = /\s$/.test(content);
4965
- if (hasTrailingSpace && !currentLineContent.endsWith(' ')) {
4966
- currentLineContent += ' ';
4967
- }
4968
- if ((this.indent.length + currentLineContent.length) > Math.max(this.maxLineLength, 120)) {
4969
- children.forEach(child => this.visit(child));
4970
- 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
+ }
4971
5787
  }
4972
5788
  }
5789
+ break;
4973
5790
  }
4974
- else if (isNode(child, HTMLElementNode)) {
4975
- const childTagName = getTagName(child);
4976
- if (this.isInlineElement(childTagName)) {
4977
- const childInline = this.tryRenderInlineFull(child, childTagName, filterNodes(child.open_tag?.children, HTMLAttributeNode), this.filterEmptyNodes(child.body));
4978
- if (childInline) {
4979
- currentLineContent += childInline;
4980
- if ((this.indent.length + currentLineContent.length) > this.maxLineLength) {
4981
- children.forEach(child => this.visit(child));
4982
- 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 += ' ';
4983
5862
  }
4984
5863
  }
4985
- else {
4986
- if (currentLineContent.trim()) {
4987
- this.pushWithIndent(currentLineContent.trim());
4988
- 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 += ' ';
4989
5870
  }
4990
- this.visit(child);
4991
5871
  }
4992
5872
  }
4993
- else {
4994
- if (currentLineContent.trim()) {
4995
- this.pushWithIndent(currentLineContent.trim());
4996
- currentLineContent = "";
5873
+ else if (text === ' ' && words.length > 0) {
5874
+ const lastWord = words[words.length - 1];
5875
+ if (!lastWord.word.endsWith(' ')) {
5876
+ lastWord.word += ' ';
4997
5877
  }
4998
- this.visit(child);
4999
5878
  }
5000
5879
  }
5001
- else if (isNode(child, ERBContentNode)) {
5002
- const oldLines = this.lines;
5003
- const oldInlineMode = this.inlineMode;
5004
- // TODO: use this.capture
5005
- try {
5006
- this.lines = [];
5007
- this.inlineMode = true;
5008
- this.visit(child);
5009
- const erbContent = this.lines.join("");
5010
- currentLineContent += erbContent;
5011
- if ((this.indent.length + currentLineContent.length) > Math.max(this.maxLineLength, 120)) {
5012
- this.lines = oldLines;
5013
- this.inlineMode = oldInlineMode;
5014
- children.forEach(child => this.visit(child));
5015
- 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;
5016
6074
  }
5017
6075
  }
5018
- finally {
5019
- this.lines = oldLines;
5020
- this.inlineMode = oldInlineMode;
6076
+ else {
6077
+ result.push({
6078
+ unit: { content: '', type: 'block', isAtomic: false, breaksFlow: true },
6079
+ node: child
6080
+ });
5021
6081
  }
6082
+ lastProcessedIndex = i;
5022
6083
  }
5023
- else {
5024
- if (currentLineContent.trim()) {
5025
- this.pushWithIndent(currentLineContent.trim());
5026
- 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;
5027
6089
  }
5028
- 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;
5029
6098
  }
5030
6099
  }
5031
- if (currentLineContent.trim()) {
5032
- const finalLine = this.indent + currentLineContent.trim();
5033
- if (finalLine.length > Math.max(this.maxLineLength, 120)) {
5034
- this.visitAll(children);
5035
- 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;
5036
6136
  }
5037
- this.push(finalLine);
5038
6137
  }
6138
+ if (currentLine) {
6139
+ lines.push(this.indent + currentLine.trimEnd());
6140
+ }
6141
+ lines.forEach(line => this.push(line));
5039
6142
  }
5040
6143
  isInTextFlowContext(_parent, children) {
5041
6144
  const hasTextContent = children.some(child => isNode(child, HTMLTextNode) && child.content.trim() !== "");
@@ -5048,7 +6151,7 @@ class FormatPrinter extends Printer {
5048
6151
  if (isNode(child, ERBContentNode))
5049
6152
  return true;
5050
6153
  if (isNode(child, HTMLElementNode)) {
5051
- return this.isInlineElement(getTagName(child));
6154
+ return isInlineElement(getTagName(child));
5052
6155
  }
5053
6156
  return false;
5054
6157
  });
@@ -5118,7 +6221,7 @@ class FormatPrinter extends Printer {
5118
6221
  }
5119
6222
  else {
5120
6223
  const printed = IdentityPrinter.print(child);
5121
- if (this.currentAttributeName && FormatPrinter.TOKEN_LIST_ATTRIBUTES.has(this.currentAttributeName)) {
6224
+ if (this.isInTokenListAttribute) {
5122
6225
  return printed.replace(/%>([^<\s])/g, '%> $1').replace(/([^>\s])<%/g, '$1 <%');
5123
6226
  }
5124
6227
  return printed;
@@ -5154,18 +6257,35 @@ class FormatPrinter extends Printer {
5154
6257
  let result = `<${tagName}`;
5155
6258
  result += this.renderAttributesString(attributes);
5156
6259
  result += ">";
5157
- const childrenContent = this.tryRenderChildrenInline(children);
6260
+ const childrenContent = this.tryRenderChildrenInline(children, tagName);
5158
6261
  if (!childrenContent)
5159
6262
  return null;
5160
6263
  result += childrenContent;
5161
6264
  result += `</${tagName}>`;
5162
6265
  return result;
5163
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
+ }
5164
6279
  /**
5165
6280
  * Try to render just the children inline (without tags)
5166
6281
  */
5167
- tryRenderChildrenInline(children) {
6282
+ tryRenderChildrenInline(children, tagName) {
5168
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);
5169
6289
  for (const child of children) {
5170
6290
  if (isNode(child, HTMLTextNode)) {
5171
6291
  const normalizedContent = child.content.replace(/\s+/g, ' ');
@@ -5173,36 +6293,53 @@ class FormatPrinter extends Printer {
5173
6293
  const hasTrailingSpace = /\s$/.test(child.content);
5174
6294
  const trimmedContent = normalizedContent.trim();
5175
6295
  if (trimmedContent) {
5176
- let finalContent = trimmedContent;
5177
- if (hasLeadingSpace && result && !result.endsWith(' ')) {
5178
- finalContent = ' ' + finalContent;
6296
+ if (hasLeadingSpace && (result || shouldPreserveSpaces) && !result.endsWith(' ')) {
6297
+ result += ' ';
5179
6298
  }
6299
+ result += trimmedContent;
5180
6300
  if (hasTrailingSpace) {
5181
- finalContent = finalContent + ' ';
5182
- }
5183
- result += finalContent;
5184
- }
5185
- else if (hasLeadingSpace || hasTrailingSpace) {
5186
- if (result && !result.endsWith(' ')) {
5187
6301
  result += ' ';
5188
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;
5189
6315
  }
5190
6316
  }
5191
6317
  else if (isNode(child, HTMLElementNode)) {
5192
6318
  const tagName = getTagName(child);
5193
- if (!this.isInlineElement(tagName)) {
6319
+ if (!isInlineElement(tagName)) {
5194
6320
  return null;
5195
6321
  }
5196
- 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);
5197
6324
  if (!childInline) {
5198
6325
  return null;
5199
6326
  }
5200
6327
  result += childInline;
5201
6328
  }
5202
- else {
5203
- 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;
5204
6335
  }
5205
6336
  }
6337
+ if (shouldPreserveSpaces) {
6338
+ return result;
6339
+ }
6340
+ if (hasHerbDisable && result.startsWith(' ') || hasInternalWhitespace) {
6341
+ return result.trimEnd();
6342
+ }
5206
6343
  return result.trim();
5207
6344
  }
5208
6345
  /**
@@ -5217,8 +6354,7 @@ class FormatPrinter extends Printer {
5217
6354
  }
5218
6355
  }
5219
6356
  else if (isNode(child, HTMLElementNode)) {
5220
- const isInlineElement = this.isInlineElement(getTagName(child));
5221
- if (!isInlineElement) {
6357
+ if (!isInlineElement(getTagName(child))) {
5222
6358
  return null;
5223
6359
  }
5224
6360
  }
@@ -5234,103 +6370,14 @@ class FormatPrinter extends Printer {
5234
6370
  return `<${tagName}>${content}</${tagName}>`;
5235
6371
  }
5236
6372
  /**
5237
- * Check if children contain mixed text and inline elements (like "text<em>inline</em>text")
5238
- * or mixed ERB output and text (like "<%= value %> text")
5239
- * This indicates content that should be formatted inline even with structural newlines
5240
- */
5241
- hasMixedTextAndInlineContent(children) {
5242
- let hasText = false;
5243
- let hasInlineElements = false;
5244
- for (const child of children) {
5245
- if (isNode(child, HTMLTextNode)) {
5246
- if (child.content.trim() !== "") {
5247
- hasText = true;
5248
- }
5249
- }
5250
- else if (isNode(child, HTMLElementNode)) {
5251
- if (this.isInlineElement(getTagName(child))) {
5252
- hasInlineElements = true;
5253
- }
5254
- }
5255
- }
5256
- return (hasText && hasInlineElements) || (hasERBOutput(children) && hasText);
5257
- }
5258
- /**
5259
- * Check if children contain any text content with newlines
6373
+ * Get filtered children, using smart herb:disable filtering if needed
5260
6374
  */
5261
- hasMultilineTextContent(children) {
5262
- for (const child of children) {
5263
- if (isNode(child, HTMLTextNode)) {
5264
- return child.content.includes('\n');
5265
- }
5266
- if (isNode(child, HTMLElementNode)) {
5267
- const nestedChildren = this.filterEmptyNodes(child.body);
5268
- if (this.hasMultilineTextContent(nestedChildren)) {
5269
- return true;
5270
- }
5271
- }
5272
- }
5273
- return false;
5274
- }
5275
- /**
5276
- * Check if all nested elements in the children are inline elements
5277
- */
5278
- areAllNestedElementsInline(children) {
5279
- for (const child of children) {
5280
- if (isNode(child, HTMLElementNode)) {
5281
- if (!this.isInlineElement(getTagName(child))) {
5282
- return false;
5283
- }
5284
- const nestedChildren = this.filterEmptyNodes(child.body);
5285
- if (!this.areAllNestedElementsInline(nestedChildren)) {
5286
- return false;
5287
- }
5288
- }
5289
- else if (isAnyOf(child, HTMLDoctypeNode, HTMLCommentNode, isERBControlFlowNode)) {
5290
- return false;
5291
- }
5292
- }
5293
- return true;
5294
- }
5295
- /**
5296
- * Check if element has complex ERB control flow
5297
- */
5298
- hasComplexERBControlFlow(inlineNodes) {
5299
- return inlineNodes.some(node => {
5300
- if (isNode(node, ERBIfNode)) {
5301
- if (node.statements.length > 0 && node.location) {
5302
- const startLine = node.location.start.line;
5303
- const endLine = node.location.end.line;
5304
- return startLine !== endLine;
5305
- }
5306
- return false;
5307
- }
5308
- return false;
5309
- });
5310
- }
5311
- /**
5312
- * Filter children to remove insignificant whitespace
5313
- */
5314
- filterSignificantChildren(body, hasTextFlow) {
5315
- return body.filter(child => {
5316
- if (isNode(child, WhitespaceNode))
5317
- return false;
5318
- if (isNode(child, HTMLTextNode)) {
5319
- if (hasTextFlow && child.content === " ")
5320
- return true;
5321
- return child.content.trim() !== "";
5322
- }
5323
- return true;
5324
- });
5325
- }
5326
- /**
5327
- * Filter out empty text nodes and whitespace nodes
5328
- */
5329
- filterEmptyNodes(nodes) {
5330
- 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;
5331
6378
  }
5332
6379
  renderElementInline(element) {
5333
- const children = this.filterEmptyNodes(element.body);
6380
+ const children = this.getFilteredChildren(element.body);
5334
6381
  return this.renderChildrenInline(children);
5335
6382
  }
5336
6383
  renderChildrenInline(children) {
@@ -5352,9 +6399,29 @@ class FormatPrinter extends Printer {
5352
6399
  }
5353
6400
  return content.replace(/\s+/g, ' ').trim();
5354
6401
  }
5355
- isContentPreserving(element) {
5356
- const tagName = getTagName(element);
5357
- 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);
5358
6425
  }
5359
6426
  }
5360
6427
 
@@ -5364,6 +6431,8 @@ class FormatPrinter extends Printer {
5364
6431
  const defaultFormatOptions = {
5365
6432
  indentWidth: 2,
5366
6433
  maxLineLength: 80,
6434
+ preRewriters: [],
6435
+ postRewriters: [],
5367
6436
  };
5368
6437
  /**
5369
6438
  * Merge provided options with defaults for any missing values.
@@ -5374,6 +6443,8 @@ function resolveFormatOptions(options = {}) {
5374
6443
  return {
5375
6444
  indentWidth: options.indentWidth ?? defaultFormatOptions.indentWidth,
5376
6445
  maxLineLength: options.maxLineLength ?? defaultFormatOptions.maxLineLength,
6446
+ preRewriters: options.preRewriters ?? defaultFormatOptions.preRewriters,
6447
+ postRewriters: options.postRewriters ?? defaultFormatOptions.postRewriters,
5377
6448
  };
5378
6449
  }
5379
6450
 
@@ -5384,6 +6455,30 @@ function resolveFormatOptions(options = {}) {
5384
6455
  class Formatter {
5385
6456
  herb;
5386
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
+ */
5387
6482
  constructor(herb, options = {}) {
5388
6483
  this.herb = herb;
5389
6484
  this.options = resolveFormatOptions(options);
@@ -5391,12 +6486,44 @@ class Formatter {
5391
6486
  /**
5392
6487
  * Format a source string, optionally overriding format options per call.
5393
6488
  */
5394
- format(source, options = {}) {
5395
- const result = this.parse(source);
6489
+ format(source, options = {}, filePath) {
6490
+ let result = this.parse(source);
5396
6491
  if (result.failed)
5397
6492
  return source;
6493
+ if (isScaffoldTemplate(result))
6494
+ return source;
5398
6495
  const resolvedOptions = resolveFormatOptions({ ...this.options, ...options });
5399
- 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;
5400
6527
  }
5401
6528
  parse(source) {
5402
6529
  this.herb.ensureBackend();