@herb-tools/formatter 0.8.1 → 0.8.3

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