@herb-tools/formatter 0.8.1 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -243,7 +243,7 @@ class Token {
243
243
  }
244
244
 
245
245
  // NOTE: This file is generated by the templates/template.rb script and should not
246
- // be modified manually. See /Users/marcoroth/Development/herb-release-0.8.1/templates/javascript/packages/core/src/errors.ts.erb
246
+ // be modified manually. See /Users/marcoroth/Development/herb-release-0.8.2/templates/javascript/packages/core/src/errors.ts.erb
247
247
  class HerbError {
248
248
  type;
249
249
  message;
@@ -773,7 +773,7 @@ function convertToUTF8(string) {
773
773
  }
774
774
 
775
775
  // NOTE: This file is generated by the templates/template.rb script and should not
776
- // be modified manually. See /Users/marcoroth/Development/herb-release-0.8.1/templates/javascript/packages/core/src/nodes.ts.erb
776
+ // be modified manually. See /Users/marcoroth/Development/herb-release-0.8.2/templates/javascript/packages/core/src/nodes.ts.erb
777
777
  class Node {
778
778
  type;
779
779
  location;
@@ -3007,7 +3007,7 @@ class ParseResult extends Result {
3007
3007
  }
3008
3008
 
3009
3009
  // NOTE: This file is generated by the templates/template.rb script and should not
3010
- // be modified manually. See /Users/marcoroth/Development/herb-release-0.8.1/templates/javascript/packages/core/src/node-type-guards.ts.erb
3010
+ // be modified manually. See /Users/marcoroth/Development/herb-release-0.8.2/templates/javascript/packages/core/src/node-type-guards.ts.erb
3011
3011
  /**
3012
3012
  * Type guard functions for AST nodes.
3013
3013
  * These functions provide type checking by combining both instanceof
@@ -3398,6 +3398,16 @@ function isERBOutputNode(node) {
3398
3398
  return false;
3399
3399
  return ["<%=", "<%=="].includes(node.tag_opening?.value);
3400
3400
  }
3401
+ /**
3402
+ * Checks if a node is a ERB comment node (control flow: <%# %>)
3403
+ */
3404
+ function isERBCommentNode(node) {
3405
+ if (!isERBNode(node))
3406
+ return false;
3407
+ if (!node.tag_opening?.value)
3408
+ return false;
3409
+ return node.tag_opening?.value === "<%#" || (node.tag_opening?.value !== "<%#" && (node.content?.value || "").trimStart().startsWith("#"));
3410
+ }
3401
3411
  /**
3402
3412
  * Checks if a node is a non-output ERB node (control flow: <% %>)
3403
3413
  */
@@ -3452,7 +3462,7 @@ function getTagName(node) {
3452
3462
  * Check if a node is a comment (HTML comment or ERB comment)
3453
3463
  */
3454
3464
  function isCommentNode(node) {
3455
- return isNode(node, HTMLCommentNode) || (isERBNode(node) && !isERBControlFlowNode(node));
3465
+ return isHTMLCommentNode(node) || isERBCommentNode(node);
3456
3466
  }
3457
3467
  /**
3458
3468
  * Compares two positions to determine if the first comes before the second
@@ -3494,7 +3504,7 @@ function getNodesAfterPosition(nodes, position, inclusive = true) {
3494
3504
  }
3495
3505
 
3496
3506
  // NOTE: This file is generated by the templates/template.rb script and should not
3497
- // be modified manually. See /Users/marcoroth/Development/herb-release-0.8.1/templates/javascript/packages/core/src/visitor.ts.erb
3507
+ // be modified manually. See /Users/marcoroth/Development/herb-release-0.8.2/templates/javascript/packages/core/src/visitor.ts.erb
3498
3508
  class Visitor {
3499
3509
  visit(node) {
3500
3510
  if (!node)
@@ -4121,14 +4131,6 @@ const SPACEABLE_CONTAINERS = new Set([
4121
4131
  'div', 'section', 'article', 'main', 'header', 'footer', 'aside',
4122
4132
  'figure', 'details', 'summary', 'dialog', 'fieldset'
4123
4133
  ]);
4124
- const TIGHT_GROUP_PARENTS = new Set([
4125
- 'ul', 'ol', 'nav', 'select', 'datalist', 'optgroup', 'tr', 'thead',
4126
- 'tbody', 'tfoot'
4127
- ]);
4128
- const TIGHT_GROUP_CHILDREN = new Set([
4129
- 'li', 'option', 'td', 'th', 'dt', 'dd'
4130
- ]);
4131
- const SPACING_THRESHOLD = 3;
4132
4134
  /**
4133
4135
  * Token list attributes that contain space-separated values and benefit from
4134
4136
  * spacing around ERB content for readability
@@ -4515,6 +4517,10 @@ class FormatPrinter extends Printer {
4515
4517
  currentAttributeName = null;
4516
4518
  elementStack = [];
4517
4519
  elementFormattingAnalysis = new Map();
4520
+ nodeIsMultiline = new Map();
4521
+ stringLineCount = 0;
4522
+ tagGroupsCache = new Map();
4523
+ allSingleLineCache = new Map();
4518
4524
  source;
4519
4525
  constructor(source, options) {
4520
4526
  super();
@@ -4529,6 +4535,10 @@ class FormatPrinter extends Printer {
4529
4535
  // TODO: refactor to use @herb-tools/printer infrastructre (or rework printer use push and this.lines)
4530
4536
  this.lines = [];
4531
4537
  this.indentLevel = 0;
4538
+ this.stringLineCount = 0;
4539
+ this.nodeIsMultiline.clear();
4540
+ this.tagGroupsCache.clear();
4541
+ this.allSingleLineCache.clear();
4532
4542
  this.visit(node);
4533
4543
  return this.lines.join("\n");
4534
4544
  }
@@ -4562,7 +4572,9 @@ class FormatPrinter extends Printer {
4562
4572
  capture(callback) {
4563
4573
  const previousLines = this.lines;
4564
4574
  const previousInlineMode = this.inlineMode;
4575
+ const previousStringLineCount = this.stringLineCount;
4565
4576
  this.lines = [];
4577
+ this.stringLineCount = 0;
4566
4578
  try {
4567
4579
  callback();
4568
4580
  return this.lines;
@@ -4570,8 +4582,18 @@ class FormatPrinter extends Printer {
4570
4582
  finally {
4571
4583
  this.lines = previousLines;
4572
4584
  this.inlineMode = previousInlineMode;
4585
+ this.stringLineCount = previousStringLineCount;
4573
4586
  }
4574
4587
  }
4588
+ /**
4589
+ * Track a boundary node's multiline status by comparing line count before/after rendering.
4590
+ */
4591
+ trackBoundary(node, callback) {
4592
+ const startLineCount = this.stringLineCount;
4593
+ callback();
4594
+ const endLineCount = this.stringLineCount;
4595
+ this.nodeIsMultiline.set(node, (endLineCount - startLineCount) > 1);
4596
+ }
4575
4597
  /**
4576
4598
  * Capture all nodes that would be visited during a callback
4577
4599
  * Returns a flat list of all nodes without generating any output
@@ -4607,6 +4629,7 @@ class FormatPrinter extends Printer {
4607
4629
  */
4608
4630
  push(line) {
4609
4631
  this.lines.push(line);
4632
+ this.stringLineCount++;
4610
4633
  }
4611
4634
  /**
4612
4635
  * @deprecated refactor to use @herb-tools/printer infrastructre (or rework printer use push and this.lines)
@@ -4651,6 +4674,79 @@ class FormatPrinter extends Printer {
4651
4674
  extractInlineNodes(nodes) {
4652
4675
  return nodes.filter(child => isNoneOf(child, HTMLAttributeNode, WhitespaceNode));
4653
4676
  }
4677
+ /**
4678
+ * Check if a node will render as multiple lines when formatted.
4679
+ */
4680
+ isMultilineElement(node) {
4681
+ if (isNode(node, ERBContentNode)) {
4682
+ return (node.content?.value || "").includes("\n");
4683
+ }
4684
+ if (isNode(node, HTMLElementNode) && isContentPreserving(node)) {
4685
+ return true;
4686
+ }
4687
+ const tracked = this.nodeIsMultiline.get(node);
4688
+ if (tracked !== undefined) {
4689
+ return tracked;
4690
+ }
4691
+ return false;
4692
+ }
4693
+ /**
4694
+ * Get a grouping key for a node (tag name for HTML, ERB type for ERB)
4695
+ */
4696
+ getGroupingKey(node) {
4697
+ if (isNode(node, HTMLElementNode)) {
4698
+ return getTagName(node);
4699
+ }
4700
+ if (isERBOutputNode(node))
4701
+ return "erb-output";
4702
+ if (isERBCommentNode(node))
4703
+ return "erb-comment";
4704
+ if (isERBNode(node))
4705
+ return "erb-code";
4706
+ return null;
4707
+ }
4708
+ /**
4709
+ * Detect groups of consecutive same-tag/same-type single-line elements
4710
+ * Returns a map of index -> group info for efficient lookup
4711
+ */
4712
+ detectTagGroups(siblings) {
4713
+ const cached = this.tagGroupsCache.get(siblings);
4714
+ if (cached)
4715
+ return cached;
4716
+ const groupMap = new Map();
4717
+ const meaningfulNodes = [];
4718
+ for (let i = 0; i < siblings.length; i++) {
4719
+ const node = siblings[i];
4720
+ if (!this.isMultilineElement(node)) {
4721
+ const groupKey = this.getGroupingKey(node);
4722
+ if (groupKey) {
4723
+ meaningfulNodes.push({ index: i, groupKey });
4724
+ }
4725
+ }
4726
+ }
4727
+ let groupStart = 0;
4728
+ while (groupStart < meaningfulNodes.length) {
4729
+ const startGroupKey = meaningfulNodes[groupStart].groupKey;
4730
+ let groupEnd = groupStart;
4731
+ while (groupEnd + 1 < meaningfulNodes.length && meaningfulNodes[groupEnd + 1].groupKey === startGroupKey) {
4732
+ groupEnd++;
4733
+ }
4734
+ if (groupEnd > groupStart) {
4735
+ const groupStartIndex = meaningfulNodes[groupStart].index;
4736
+ const groupEndIndex = meaningfulNodes[groupEnd].index;
4737
+ for (let i = groupStart; i <= groupEnd; i++) {
4738
+ groupMap.set(meaningfulNodes[i].index, {
4739
+ tagName: startGroupKey,
4740
+ groupStart: groupStartIndex,
4741
+ groupEnd: groupEndIndex
4742
+ });
4743
+ }
4744
+ }
4745
+ groupStart = groupEnd + 1;
4746
+ }
4747
+ this.tagGroupsCache.set(siblings, groupMap);
4748
+ return groupMap;
4749
+ }
4654
4750
  /**
4655
4751
  * Determine if spacing should be added between sibling elements
4656
4752
  *
@@ -4666,48 +4762,59 @@ class FormatPrinter extends Printer {
4666
4762
  * @param hasExistingSpacing - Whether user-added spacing already exists
4667
4763
  * @returns true if spacing should be added before the current element
4668
4764
  */
4669
- shouldAddSpacingBetweenSiblings(parentElement, siblings, currentIndex, hasExistingSpacing) {
4670
- if (hasExistingSpacing) {
4765
+ shouldAddSpacingBetweenSiblings(parentElement, siblings, currentIndex) {
4766
+ const currentNode = siblings[currentIndex];
4767
+ const previousMeaningfulIndex = findPreviousMeaningfulSibling(siblings, currentIndex);
4768
+ const previousNode = previousMeaningfulIndex !== -1 ? siblings[previousMeaningfulIndex] : null;
4769
+ if (previousNode && (isNode(previousNode, XMLDeclarationNode) || isNode(previousNode, HTMLDoctypeNode))) {
4671
4770
  return true;
4672
4771
  }
4673
4772
  const hasMixedContent = siblings.some(child => isNode(child, HTMLTextNode) && child.content.trim() !== "");
4674
- if (hasMixedContent) {
4773
+ if (hasMixedContent)
4675
4774
  return false;
4775
+ const isCurrentComment = isCommentNode(currentNode);
4776
+ const isPreviousComment = previousNode ? isCommentNode(previousNode) : false;
4777
+ const isCurrentMultiline = this.isMultilineElement(currentNode);
4778
+ const isPreviousMultiline = previousNode ? this.isMultilineElement(previousNode) : false;
4779
+ if (isPreviousComment && !isCurrentComment && (isNode(currentNode, HTMLElementNode) || isERBNode(currentNode))) {
4780
+ return isPreviousMultiline && isCurrentMultiline;
4676
4781
  }
4677
- const meaningfulSiblings = siblings.filter(child => isNonWhitespaceNode(child));
4678
- if (meaningfulSiblings.length < SPACING_THRESHOLD) {
4782
+ if (isPreviousComment && isCurrentComment) {
4679
4783
  return false;
4680
4784
  }
4785
+ if (isCurrentMultiline || isPreviousMultiline) {
4786
+ return true;
4787
+ }
4788
+ const meaningfulSiblings = siblings.filter(child => isNonWhitespaceNode(child));
4681
4789
  const parentTagName = parentElement ? getTagName(parentElement) : null;
4682
- if (parentTagName && TIGHT_GROUP_PARENTS.has(parentTagName)) {
4683
- return false;
4790
+ const isSpaceableContainer = !parentTagName || SPACEABLE_CONTAINERS.has(parentTagName);
4791
+ const tagGroups = this.detectTagGroups(siblings);
4792
+ const cached = this.allSingleLineCache.get(siblings);
4793
+ let allSingleLineHTMLElements;
4794
+ if (cached !== undefined) {
4795
+ allSingleLineHTMLElements = cached;
4796
+ }
4797
+ else {
4798
+ allSingleLineHTMLElements = meaningfulSiblings.every(node => isNode(node, HTMLElementNode) && !this.isMultilineElement(node));
4799
+ this.allSingleLineCache.set(siblings, allSingleLineHTMLElements);
4684
4800
  }
4685
- const isSpaceableContainer = !parentTagName || (parentTagName && SPACEABLE_CONTAINERS.has(parentTagName));
4686
4801
  if (!isSpaceableContainer && meaningfulSiblings.length < 5) {
4687
4802
  return false;
4688
4803
  }
4689
- const currentNode = siblings[currentIndex];
4690
- const previousMeaningfulIndex = findPreviousMeaningfulSibling(siblings, currentIndex);
4691
- const isCurrentComment = isCommentNode(currentNode);
4692
- if (previousMeaningfulIndex !== -1) {
4693
- const previousNode = siblings[previousMeaningfulIndex];
4694
- const isPreviousComment = isCommentNode(previousNode);
4695
- if (isPreviousComment && !isCurrentComment && (isNode(currentNode, HTMLElementNode) || isERBNode(currentNode))) {
4696
- return false;
4697
- }
4698
- if (isPreviousComment && isCurrentComment) {
4699
- return false;
4700
- }
4804
+ const currentGroup = tagGroups.get(currentIndex);
4805
+ const previousGroup = previousNode ? tagGroups.get(previousMeaningfulIndex) : undefined;
4806
+ if (currentGroup && previousGroup && currentGroup.groupStart === previousGroup.groupStart && currentGroup.groupEnd === previousGroup.groupEnd) {
4807
+ return false;
4808
+ }
4809
+ if (previousGroup && previousGroup.groupEnd === previousMeaningfulIndex) {
4810
+ return true;
4811
+ }
4812
+ if (allSingleLineHTMLElements && tagGroups.size === 0) {
4813
+ return false;
4701
4814
  }
4702
4815
  if (isNode(currentNode, HTMLElementNode)) {
4703
4816
  const currentTagName = getTagName(currentNode);
4704
- if (INLINE_ELEMENTS.has(currentTagName)) {
4705
- return false;
4706
- }
4707
- if (TIGHT_GROUP_CHILDREN.has(currentTagName)) {
4708
- return false;
4709
- }
4710
- if (currentTagName === 'a' && parentTagName === 'nav') {
4817
+ if (currentTagName && INLINE_ELEMENTS.has(currentTagName)) {
4711
4818
  return false;
4712
4819
  }
4713
4820
  }
@@ -4928,7 +5035,7 @@ class FormatPrinter extends Printer {
4928
5035
  this.inlineMode = wasInlineMode;
4929
5036
  return;
4930
5037
  }
4931
- let lastWasMeaningful = false;
5038
+ let lastMeaningfulNode = null;
4932
5039
  let hasHandledSpacing = false;
4933
5040
  for (let i = 0; i < children.length; i++) {
4934
5041
  const child = children[i];
@@ -4942,36 +5049,42 @@ class FormatPrinter extends Printer {
4942
5049
  }
4943
5050
  if (shouldAppendToLastLine(child, children, i)) {
4944
5051
  this.appendChildToLastLine(child, children, i);
4945
- lastWasMeaningful = true;
5052
+ lastMeaningfulNode = child;
4946
5053
  hasHandledSpacing = false;
4947
5054
  continue;
4948
5055
  }
4949
- if (isNonWhitespaceNode(child) && lastWasMeaningful && !hasHandledSpacing) {
4950
- this.push("");
4951
- }
5056
+ if (!isNonWhitespaceNode(child))
5057
+ continue;
5058
+ const childStartLine = this.stringLineCount;
4952
5059
  this.visit(child);
4953
- if (isNonWhitespaceNode(child)) {
4954
- lastWasMeaningful = true;
4955
- hasHandledSpacing = false;
5060
+ if (lastMeaningfulNode && !hasHandledSpacing) {
5061
+ const shouldAddSpacing = this.shouldAddSpacingBetweenSiblings(null, children, i);
5062
+ if (shouldAddSpacing) {
5063
+ this.lines.splice(childStartLine, 0, "");
5064
+ this.stringLineCount++;
5065
+ }
4956
5066
  }
5067
+ lastMeaningfulNode = child;
5068
+ hasHandledSpacing = false;
4957
5069
  }
4958
5070
  }
4959
5071
  visitHTMLElementNode(node) {
4960
5072
  this.elementStack.push(node);
4961
5073
  this.elementFormattingAnalysis.set(node, this.analyzeElementFormatting(node));
4962
- if (this.inlineMode && node.is_void && this.indentLevel === 0) {
4963
- const openTag = this.capture(() => this.visit(node.open_tag)).join('');
4964
- this.pushToLastLine(openTag);
4965
- this.elementStack.pop();
4966
- return;
4967
- }
4968
- this.visit(node.open_tag);
4969
- if (node.body.length > 0) {
4970
- this.visitHTMLElementBody(node.body, node);
4971
- }
4972
- if (node.close_tag) {
4973
- this.visit(node.close_tag);
4974
- }
5074
+ this.trackBoundary(node, () => {
5075
+ if (this.inlineMode && node.is_void && this.indentLevel === 0) {
5076
+ const openTag = this.capture(() => this.visit(node.open_tag)).join('');
5077
+ this.pushToLastLine(openTag);
5078
+ return;
5079
+ }
5080
+ this.visit(node.open_tag);
5081
+ if (node.body.length > 0) {
5082
+ this.visitHTMLElementBody(node.body, node);
5083
+ }
5084
+ if (node.close_tag) {
5085
+ this.visit(node.close_tag);
5086
+ }
5087
+ });
4975
5088
  this.elementStack.pop();
4976
5089
  }
4977
5090
  visitHTMLElementBody(body, element) {
@@ -5118,17 +5231,19 @@ class FormatPrinter extends Printer {
5118
5231
  }
5119
5232
  /**
5120
5233
  * Visit element children with intelligent spacing logic
5234
+ *
5235
+ * Tracks line positions and immediately splices blank lines after rendering each child.
5121
5236
  */
5122
5237
  visitElementChildren(body, parentElement) {
5123
- let lastWasMeaningful = false;
5238
+ let lastMeaningfulNode = null;
5124
5239
  let hasHandledSpacing = false;
5125
- for (let i = 0; i < body.length; i++) {
5126
- const child = body[i];
5240
+ for (let index = 0; index < body.length; index++) {
5241
+ const child = body[index];
5127
5242
  if (isNode(child, HTMLTextNode)) {
5128
5243
  const isWhitespaceOnly = child.content.trim() === "";
5129
5244
  if (isWhitespaceOnly) {
5130
- const hasPreviousNonWhitespace = i > 0 && isNonWhitespaceNode(body[i - 1]);
5131
- const hasNextNonWhitespace = i < body.length - 1 && isNonWhitespaceNode(body[i + 1]);
5245
+ const hasPreviousNonWhitespace = index > 0 && isNonWhitespaceNode(body[index - 1]);
5246
+ const hasNextNonWhitespace = index < body.length - 1 && isNonWhitespaceNode(body[index + 1]);
5132
5247
  const hasMultipleNewlines = child.content.includes('\n\n');
5133
5248
  if (hasPreviousNonWhitespace && hasNextNonWhitespace && hasMultipleNewlines) {
5134
5249
  this.push("");
@@ -5137,24 +5252,26 @@ class FormatPrinter extends Printer {
5137
5252
  continue;
5138
5253
  }
5139
5254
  }
5140
- if (isNonWhitespaceNode(child) && lastWasMeaningful && !hasHandledSpacing) {
5141
- const element = body[i - 1];
5142
- const hasExistingSpacing = i > 0 && isNode(element, HTMLTextNode) && element.content.trim() === "" && (element.content.includes('\n\n') || element.content.split('\n').length > 2);
5143
- const shouldAddSpacing = this.shouldAddSpacingBetweenSiblings(parentElement, body, i, hasExistingSpacing);
5144
- if (shouldAddSpacing) {
5145
- this.push("");
5146
- }
5147
- }
5255
+ if (!isNonWhitespaceNode(child))
5256
+ continue;
5148
5257
  let hasTrailingHerbDisable = false;
5149
5258
  if (isNode(child, HTMLElementNode) && child.close_tag) {
5150
- for (let j = i + 1; j < body.length; j++) {
5259
+ for (let j = index + 1; j < body.length; j++) {
5151
5260
  const nextChild = body[j];
5152
5261
  if (isNode(nextChild, WhitespaceNode) || isPureWhitespaceNode(nextChild)) {
5153
5262
  continue;
5154
5263
  }
5155
5264
  if (isNode(nextChild, ERBContentNode) && isHerbDisableComment(nextChild)) {
5156
5265
  hasTrailingHerbDisable = true;
5266
+ const childStartLine = this.stringLineCount;
5157
5267
  this.visit(child);
5268
+ if (lastMeaningfulNode && !hasHandledSpacing) {
5269
+ const shouldAddSpacing = this.shouldAddSpacingBetweenSiblings(parentElement, body, index);
5270
+ if (shouldAddSpacing) {
5271
+ this.lines.splice(childStartLine, 0, "");
5272
+ this.stringLineCount++;
5273
+ }
5274
+ }
5158
5275
  const herbDisableString = this.capture(() => {
5159
5276
  const savedIndentLevel = this.indentLevel;
5160
5277
  this.indentLevel = 0;
@@ -5164,17 +5281,25 @@ class FormatPrinter extends Printer {
5164
5281
  this.indentLevel = savedIndentLevel;
5165
5282
  }).join("");
5166
5283
  this.pushToLastLine(' ' + herbDisableString);
5167
- i = j;
5284
+ index = j;
5285
+ lastMeaningfulNode = child;
5286
+ hasHandledSpacing = false;
5168
5287
  break;
5169
5288
  }
5170
5289
  break;
5171
5290
  }
5172
5291
  }
5173
5292
  if (!hasTrailingHerbDisable) {
5293
+ const childStartLine = this.stringLineCount;
5174
5294
  this.visit(child);
5175
- }
5176
- if (isNonWhitespaceNode(child)) {
5177
- lastWasMeaningful = true;
5295
+ if (lastMeaningfulNode && !hasHandledSpacing) {
5296
+ const shouldAddSpacing = this.shouldAddSpacingBetweenSiblings(parentElement, body, index);
5297
+ if (shouldAddSpacing) {
5298
+ this.lines.splice(childStartLine, 0, "");
5299
+ this.stringLineCount++;
5300
+ }
5301
+ }
5302
+ lastMeaningfulNode = child;
5178
5303
  hasHandledSpacing = false;
5179
5304
  }
5180
5305
  }
@@ -5270,6 +5395,11 @@ class FormatPrinter extends Printer {
5270
5395
  return "";
5271
5396
  }
5272
5397
  }).join("");
5398
+ const trimmedInner = inner.trim();
5399
+ if (trimmedInner.startsWith('[if ') && trimmedInner.endsWith('<![endif]')) {
5400
+ this.pushWithIndent(open + inner + close);
5401
+ return;
5402
+ }
5273
5403
  const hasNewlines = inner.includes('\n');
5274
5404
  if (hasNewlines) {
5275
5405
  const lines = inner.split('\n');
@@ -5352,8 +5482,7 @@ class FormatPrinter extends Printer {
5352
5482
  this.pushWithIndent(IdentityPrinter.print(node));
5353
5483
  }
5354
5484
  visitERBContentNode(node) {
5355
- // TODO: this feels hacky
5356
- if (node.tag_opening?.value === "<%#") {
5485
+ if (isERBCommentNode(node)) {
5357
5486
  this.visitERBCommentNode(node);
5358
5487
  }
5359
5488
  else {
@@ -5364,83 +5493,92 @@ class FormatPrinter extends Printer {
5364
5493
  this.printERBNode(node);
5365
5494
  }
5366
5495
  visitERBYieldNode(node) {
5367
- this.printERBNode(node);
5496
+ this.trackBoundary(node, () => {
5497
+ this.printERBNode(node);
5498
+ });
5368
5499
  }
5369
5500
  visitERBInNode(node) {
5370
- this.printERBNode(node);
5371
- this.withIndent(() => this.visitAll(node.statements));
5501
+ this.trackBoundary(node, () => {
5502
+ this.printERBNode(node);
5503
+ this.withIndent(() => this.visitAll(node.statements));
5504
+ });
5372
5505
  }
5373
5506
  visitERBCaseMatchNode(node) {
5374
- this.printERBNode(node);
5375
- this.withIndent(() => this.visitAll(node.children));
5376
- this.visitAll(node.conditions);
5377
- if (node.else_clause)
5378
- this.visit(node.else_clause);
5379
- if (node.end_node)
5380
- this.visit(node.end_node);
5381
- }
5382
- visitERBBlockNode(node) {
5383
- this.printERBNode(node);
5384
- this.withIndent(() => {
5385
- const hasTextFlow = this.isInTextFlowContext(null, node.body);
5386
- if (hasTextFlow) {
5387
- this.visitTextFlowChildren(node.body);
5388
- }
5389
- else {
5390
- this.visitElementChildren(node.body, null);
5391
- }
5507
+ this.trackBoundary(node, () => {
5508
+ this.printERBNode(node);
5509
+ this.withIndent(() => this.visitAll(node.children));
5510
+ this.visitAll(node.conditions);
5511
+ if (node.else_clause)
5512
+ this.visit(node.else_clause);
5513
+ if (node.end_node)
5514
+ this.visit(node.end_node);
5392
5515
  });
5393
- if (node.end_node)
5394
- this.visit(node.end_node);
5395
5516
  }
5396
- visitERBIfNode(node) {
5397
- if (this.inlineMode) {
5517
+ visitERBBlockNode(node) {
5518
+ this.trackBoundary(node, () => {
5398
5519
  this.printERBNode(node);
5399
- node.statements.forEach(child => {
5400
- if (isNode(child, HTMLAttributeNode)) {
5401
- this.lines.push(" ");
5402
- this.lines.push(this.renderAttribute(child));
5520
+ this.withIndent(() => {
5521
+ const hasTextFlow = this.isInTextFlowContext(null, node.body);
5522
+ if (hasTextFlow) {
5523
+ this.visitTextFlowChildren(node.body);
5403
5524
  }
5404
5525
  else {
5405
- const shouldAddSpaces = this.isInTokenListAttribute;
5406
- if (shouldAddSpaces) {
5526
+ this.visitElementChildren(node.body, null);
5527
+ }
5528
+ });
5529
+ if (node.end_node)
5530
+ this.visit(node.end_node);
5531
+ });
5532
+ }
5533
+ visitERBIfNode(node) {
5534
+ this.trackBoundary(node, () => {
5535
+ if (this.inlineMode) {
5536
+ this.printERBNode(node);
5537
+ node.statements.forEach(child => {
5538
+ if (isNode(child, HTMLAttributeNode)) {
5407
5539
  this.lines.push(" ");
5540
+ this.lines.push(this.renderAttribute(child));
5408
5541
  }
5409
- this.visit(child);
5410
- if (shouldAddSpaces) {
5411
- this.lines.push(" ");
5542
+ else {
5543
+ const shouldAddSpaces = this.isInTokenListAttribute;
5544
+ if (shouldAddSpaces) {
5545
+ this.lines.push(" ");
5546
+ }
5547
+ this.visit(child);
5548
+ if (shouldAddSpaces) {
5549
+ this.lines.push(" ");
5550
+ }
5412
5551
  }
5552
+ });
5553
+ const hasHTMLAttributes = node.statements.some(child => isNode(child, HTMLAttributeNode));
5554
+ const isTokenList = this.isInTokenListAttribute;
5555
+ if ((hasHTMLAttributes || isTokenList) && node.end_node) {
5556
+ this.lines.push(" ");
5413
5557
  }
5414
- });
5415
- const hasHTMLAttributes = node.statements.some(child => isNode(child, HTMLAttributeNode));
5416
- const isTokenList = this.isInTokenListAttribute;
5417
- if ((hasHTMLAttributes || isTokenList) && node.end_node) {
5418
- this.lines.push(" ");
5558
+ if (node.subsequent)
5559
+ this.visit(node.subsequent);
5560
+ if (node.end_node)
5561
+ this.visit(node.end_node);
5419
5562
  }
5420
- if (node.subsequent)
5421
- this.visit(node.subsequent);
5422
- if (node.end_node)
5423
- this.visit(node.end_node);
5424
- }
5425
- else {
5426
- this.printERBNode(node);
5427
- this.withIndent(() => {
5428
- node.statements.forEach(child => this.visit(child));
5429
- });
5430
- if (node.subsequent)
5431
- this.visit(node.subsequent);
5432
- if (node.end_node)
5433
- this.visit(node.end_node);
5434
- }
5563
+ else {
5564
+ this.printERBNode(node);
5565
+ this.withIndent(() => {
5566
+ node.statements.forEach(child => this.visit(child));
5567
+ });
5568
+ if (node.subsequent)
5569
+ this.visit(node.subsequent);
5570
+ if (node.end_node)
5571
+ this.visit(node.end_node);
5572
+ }
5573
+ });
5435
5574
  }
5436
5575
  visitERBElseNode(node) {
5576
+ this.printERBNode(node);
5437
5577
  if (this.inlineMode) {
5438
- this.printERBNode(node);
5439
- node.statements.forEach(statement => this.visit(statement));
5578
+ this.visitAll(node.statements);
5440
5579
  }
5441
5580
  else {
5442
- this.printERBNode(node);
5443
- this.withIndent(() => node.statements.forEach(statement => this.visit(statement)));
5581
+ this.withIndent(() => this.visitAll(node.statements));
5444
5582
  }
5445
5583
  }
5446
5584
  visitERBWhenNode(node) {
@@ -5448,43 +5586,53 @@ class FormatPrinter extends Printer {
5448
5586
  this.withIndent(() => this.visitAll(node.statements));
5449
5587
  }
5450
5588
  visitERBCaseNode(node) {
5451
- this.printERBNode(node);
5452
- this.withIndent(() => this.visitAll(node.children));
5453
- this.visitAll(node.conditions);
5454
- if (node.else_clause)
5455
- this.visit(node.else_clause);
5456
- if (node.end_node)
5457
- this.visit(node.end_node);
5589
+ this.trackBoundary(node, () => {
5590
+ this.printERBNode(node);
5591
+ this.withIndent(() => this.visitAll(node.children));
5592
+ this.visitAll(node.conditions);
5593
+ if (node.else_clause)
5594
+ this.visit(node.else_clause);
5595
+ if (node.end_node)
5596
+ this.visit(node.end_node);
5597
+ });
5458
5598
  }
5459
5599
  visitERBBeginNode(node) {
5460
- this.printERBNode(node);
5461
- this.withIndent(() => this.visitAll(node.statements));
5462
- if (node.rescue_clause)
5463
- this.visit(node.rescue_clause);
5464
- if (node.else_clause)
5465
- this.visit(node.else_clause);
5466
- if (node.ensure_clause)
5467
- this.visit(node.ensure_clause);
5468
- if (node.end_node)
5469
- this.visit(node.end_node);
5600
+ this.trackBoundary(node, () => {
5601
+ this.printERBNode(node);
5602
+ this.withIndent(() => this.visitAll(node.statements));
5603
+ if (node.rescue_clause)
5604
+ this.visit(node.rescue_clause);
5605
+ if (node.else_clause)
5606
+ this.visit(node.else_clause);
5607
+ if (node.ensure_clause)
5608
+ this.visit(node.ensure_clause);
5609
+ if (node.end_node)
5610
+ this.visit(node.end_node);
5611
+ });
5470
5612
  }
5471
5613
  visitERBWhileNode(node) {
5472
- this.printERBNode(node);
5473
- this.withIndent(() => this.visitAll(node.statements));
5474
- if (node.end_node)
5475
- this.visit(node.end_node);
5614
+ this.trackBoundary(node, () => {
5615
+ this.printERBNode(node);
5616
+ this.withIndent(() => this.visitAll(node.statements));
5617
+ if (node.end_node)
5618
+ this.visit(node.end_node);
5619
+ });
5476
5620
  }
5477
5621
  visitERBUntilNode(node) {
5478
- this.printERBNode(node);
5479
- this.withIndent(() => this.visitAll(node.statements));
5480
- if (node.end_node)
5481
- this.visit(node.end_node);
5622
+ this.trackBoundary(node, () => {
5623
+ this.printERBNode(node);
5624
+ this.withIndent(() => this.visitAll(node.statements));
5625
+ if (node.end_node)
5626
+ this.visit(node.end_node);
5627
+ });
5482
5628
  }
5483
5629
  visitERBForNode(node) {
5484
- this.printERBNode(node);
5485
- this.withIndent(() => this.visitAll(node.statements));
5486
- if (node.end_node)
5487
- this.visit(node.end_node);
5630
+ this.trackBoundary(node, () => {
5631
+ this.printERBNode(node);
5632
+ this.withIndent(() => this.visitAll(node.statements));
5633
+ if (node.end_node)
5634
+ this.visit(node.end_node);
5635
+ });
5488
5636
  }
5489
5637
  visitERBRescueNode(node) {
5490
5638
  this.printERBNode(node);
@@ -5495,12 +5643,14 @@ class FormatPrinter extends Printer {
5495
5643
  this.withIndent(() => this.visitAll(node.statements));
5496
5644
  }
5497
5645
  visitERBUnlessNode(node) {
5498
- this.printERBNode(node);
5499
- this.withIndent(() => this.visitAll(node.statements));
5500
- if (node.else_clause)
5501
- this.visit(node.else_clause);
5502
- if (node.end_node)
5503
- this.visit(node.end_node);
5646
+ this.trackBoundary(node, () => {
5647
+ this.printERBNode(node);
5648
+ this.withIndent(() => this.visitAll(node.statements));
5649
+ if (node.else_clause)
5650
+ this.visit(node.else_clause);
5651
+ if (node.end_node)
5652
+ this.visit(node.end_node);
5653
+ });
5504
5654
  }
5505
5655
  // --- Element Formatting Analysis Helpers ---
5506
5656
  /**
@@ -5545,6 +5695,14 @@ class FormatPrinter extends Printer {
5545
5695
  return false;
5546
5696
  if (children.length === 0)
5547
5697
  return true;
5698
+ const hasNonInlineChildElements = children.some(child => {
5699
+ if (isNode(child, HTMLElementNode)) {
5700
+ return !this.shouldRenderElementContentInline(child);
5701
+ }
5702
+ return false;
5703
+ });
5704
+ if (hasNonInlineChildElements)
5705
+ return false;
5548
5706
  let hasLeadingHerbDisable = false;
5549
5707
  for (const child of node.body) {
5550
5708
  if (isNode(child, WhitespaceNode) || isPureWhitespaceNode(child)) {
@@ -5562,7 +5720,7 @@ class FormatPrinter extends Printer {
5562
5720
  const fullInlineResult = this.tryRenderInlineFull(node, tagName, filterNodes(node.open_tag?.children, HTMLAttributeNode), node.body);
5563
5721
  if (fullInlineResult) {
5564
5722
  const totalLength = this.indent.length + fullInlineResult.length;
5565
- return totalLength <= this.maxLineLength || totalLength <= 120;
5723
+ return totalLength <= this.maxLineLength;
5566
5724
  }
5567
5725
  return false;
5568
5726
  }
@@ -5583,7 +5741,8 @@ class FormatPrinter extends Printer {
5583
5741
  const openTagResult = this.renderInlineOpen(tagName, filterNodes(node.open_tag?.children, HTMLAttributeNode), false, [], node.open_tag?.children || []);
5584
5742
  const childrenContent = this.renderChildrenInline(children);
5585
5743
  const fullLine = openTagResult + childrenContent + `</${tagName}>`;
5586
- if ((this.indent.length + fullLine.length) <= this.maxLineLength) {
5744
+ const totalLength = this.indent.length + fullLine.length;
5745
+ if (totalLength <= this.maxLineLength) {
5587
5746
  return true;
5588
5747
  }
5589
5748
  }
@@ -6455,6 +6614,41 @@ function resolveFormatOptions(options = {}) {
6455
6614
  };
6456
6615
  }
6457
6616
 
6617
+ const HERB_FORMATTER_PREFIX = "herb:formatter";
6618
+ const HERB_FORMATTER_IGNORE_PREFIX = `${HERB_FORMATTER_PREFIX} ignore`;
6619
+ /**
6620
+ * Check if an ERB content node is a herb:formatter ignore comment
6621
+ */
6622
+ function isHerbFormatterIgnoreComment(node) {
6623
+ if (!isERBCommentNode(node))
6624
+ return false;
6625
+ const content = node?.content?.value || "";
6626
+ return content.trim() === HERB_FORMATTER_IGNORE_PREFIX;
6627
+ }
6628
+ /**
6629
+ * Check if the document contains a herb:formatter ignore directive anywhere.
6630
+ */
6631
+ function hasFormatterIgnoreDirective(node) {
6632
+ const detector = new FormatterIgnoreDetector();
6633
+ detector.visit(node);
6634
+ return detector.hasIgnoreDirective;
6635
+ }
6636
+ /**
6637
+ * Visitor that detects if the AST contains a herb:formatter ignore directive.
6638
+ */
6639
+ class FormatterIgnoreDetector extends Visitor {
6640
+ hasIgnoreDirective = false;
6641
+ visitERBContentNode(node) {
6642
+ if (isHerbFormatterIgnoreComment(node)) {
6643
+ this.hasIgnoreDirective = true;
6644
+ return;
6645
+ }
6646
+ if (this.hasIgnoreDirective)
6647
+ return;
6648
+ this.visitChildNodes(node);
6649
+ }
6650
+ }
6651
+
6458
6652
  /**
6459
6653
  * Formatter uses a Herb Backend to parse the source and then
6460
6654
  * formats the resulting AST into a well-indented, wrapped string.
@@ -6499,6 +6693,8 @@ class Formatter {
6499
6693
  return source;
6500
6694
  if (isScaffoldTemplate(result))
6501
6695
  return source;
6696
+ if (hasFormatterIgnoreDirective(result.value))
6697
+ return source;
6502
6698
  const resolvedOptions = resolveFormatOptions({ ...this.options, ...options });
6503
6699
  let node = result.value;
6504
6700
  if (resolvedOptions.preRewriters.length > 0) {