@herb-tools/formatter 0.8.0 → 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.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.0/templates/javascript/packages/core/src/errors.ts.erb
244
+ // be modified manually. See /Users/marcoroth/Development/herb-release-0.8.2/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.0/templates/javascript/packages/core/src/nodes.ts.erb
774
+ // be modified manually. See /Users/marcoroth/Development/herb-release-0.8.2/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.0/templates/javascript/packages/core/src/node-type-guards.ts.erb
3008
+ // be modified manually. See /Users/marcoroth/Development/herb-release-0.8.2/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.0/templates/javascript/packages/core/src/visitor.ts.erb
3505
+ // be modified manually. See /Users/marcoroth/Development/herb-release-0.8.2/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
@@ -4230,7 +4232,8 @@ function lineEndsWithOpeningPunctuation(line) {
4230
4232
  * Check if a string ends with an ERB tag
4231
4233
  */
4232
4234
  function endsWithERBTag(text) {
4233
- return /%>$/.test(text.trim());
4235
+ const trimmed = text.trim();
4236
+ return /%>$/.test(trimmed) || /%>\S+$/.test(trimmed);
4234
4237
  }
4235
4238
  /**
4236
4239
  * Check if a string starts with an ERB tag
@@ -4512,6 +4515,10 @@ class FormatPrinter extends Printer {
4512
4515
  currentAttributeName = null;
4513
4516
  elementStack = [];
4514
4517
  elementFormattingAnalysis = new Map();
4518
+ nodeIsMultiline = new Map();
4519
+ stringLineCount = 0;
4520
+ tagGroupsCache = new Map();
4521
+ allSingleLineCache = new Map();
4515
4522
  source;
4516
4523
  constructor(source, options) {
4517
4524
  super();
@@ -4526,6 +4533,10 @@ class FormatPrinter extends Printer {
4526
4533
  // TODO: refactor to use @herb-tools/printer infrastructre (or rework printer use push and this.lines)
4527
4534
  this.lines = [];
4528
4535
  this.indentLevel = 0;
4536
+ this.stringLineCount = 0;
4537
+ this.nodeIsMultiline.clear();
4538
+ this.tagGroupsCache.clear();
4539
+ this.allSingleLineCache.clear();
4529
4540
  this.visit(node);
4530
4541
  return this.lines.join("\n");
4531
4542
  }
@@ -4559,7 +4570,9 @@ class FormatPrinter extends Printer {
4559
4570
  capture(callback) {
4560
4571
  const previousLines = this.lines;
4561
4572
  const previousInlineMode = this.inlineMode;
4573
+ const previousStringLineCount = this.stringLineCount;
4562
4574
  this.lines = [];
4575
+ this.stringLineCount = 0;
4563
4576
  try {
4564
4577
  callback();
4565
4578
  return this.lines;
@@ -4567,8 +4580,18 @@ class FormatPrinter extends Printer {
4567
4580
  finally {
4568
4581
  this.lines = previousLines;
4569
4582
  this.inlineMode = previousInlineMode;
4583
+ this.stringLineCount = previousStringLineCount;
4570
4584
  }
4571
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
+ }
4572
4595
  /**
4573
4596
  * Capture all nodes that would be visited during a callback
4574
4597
  * Returns a flat list of all nodes without generating any output
@@ -4604,6 +4627,7 @@ class FormatPrinter extends Printer {
4604
4627
  */
4605
4628
  push(line) {
4606
4629
  this.lines.push(line);
4630
+ this.stringLineCount++;
4607
4631
  }
4608
4632
  /**
4609
4633
  * @deprecated refactor to use @herb-tools/printer infrastructre (or rework printer use push and this.lines)
@@ -4648,6 +4672,79 @@ class FormatPrinter extends Printer {
4648
4672
  extractInlineNodes(nodes) {
4649
4673
  return nodes.filter(child => isNoneOf(child, HTMLAttributeNode, WhitespaceNode));
4650
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
+ }
4651
4748
  /**
4652
4749
  * Determine if spacing should be added between sibling elements
4653
4750
  *
@@ -4663,48 +4760,59 @@ class FormatPrinter extends Printer {
4663
4760
  * @param hasExistingSpacing - Whether user-added spacing already exists
4664
4761
  * @returns true if spacing should be added before the current element
4665
4762
  */
4666
- shouldAddSpacingBetweenSiblings(parentElement, siblings, currentIndex, hasExistingSpacing) {
4667
- 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))) {
4668
4768
  return true;
4669
4769
  }
4670
4770
  const hasMixedContent = siblings.some(child => isNode(child, HTMLTextNode) && child.content.trim() !== "");
4671
- if (hasMixedContent) {
4771
+ if (hasMixedContent)
4672
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;
4673
4779
  }
4674
- const meaningfulSiblings = siblings.filter(child => isNonWhitespaceNode(child));
4675
- if (meaningfulSiblings.length < SPACING_THRESHOLD) {
4780
+ if (isPreviousComment && isCurrentComment) {
4676
4781
  return false;
4677
4782
  }
4783
+ if (isCurrentMultiline || isPreviousMultiline) {
4784
+ return true;
4785
+ }
4786
+ const meaningfulSiblings = siblings.filter(child => isNonWhitespaceNode(child));
4678
4787
  const parentTagName = parentElement ? getTagName(parentElement) : null;
4679
- if (parentTagName && TIGHT_GROUP_PARENTS.has(parentTagName)) {
4680
- 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);
4681
4798
  }
4682
- const isSpaceableContainer = !parentTagName || (parentTagName && SPACEABLE_CONTAINERS.has(parentTagName));
4683
4799
  if (!isSpaceableContainer && meaningfulSiblings.length < 5) {
4684
4800
  return false;
4685
4801
  }
4686
- const currentNode = siblings[currentIndex];
4687
- const previousMeaningfulIndex = findPreviousMeaningfulSibling(siblings, currentIndex);
4688
- const isCurrentComment = isCommentNode(currentNode);
4689
- if (previousMeaningfulIndex !== -1) {
4690
- const previousNode = siblings[previousMeaningfulIndex];
4691
- const isPreviousComment = isCommentNode(previousNode);
4692
- if (isPreviousComment && !isCurrentComment && (isNode(currentNode, HTMLElementNode) || isERBNode(currentNode))) {
4693
- return false;
4694
- }
4695
- if (isPreviousComment && isCurrentComment) {
4696
- return false;
4697
- }
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;
4698
4812
  }
4699
4813
  if (isNode(currentNode, HTMLElementNode)) {
4700
4814
  const currentTagName = getTagName(currentNode);
4701
- if (INLINE_ELEMENTS.has(currentTagName)) {
4702
- return false;
4703
- }
4704
- if (TIGHT_GROUP_CHILDREN.has(currentTagName)) {
4705
- return false;
4706
- }
4707
- if (currentTagName === 'a' && parentTagName === 'nav') {
4815
+ if (currentTagName && INLINE_ELEMENTS.has(currentTagName)) {
4708
4816
  return false;
4709
4817
  }
4710
4818
  }
@@ -4925,7 +5033,7 @@ class FormatPrinter extends Printer {
4925
5033
  this.inlineMode = wasInlineMode;
4926
5034
  return;
4927
5035
  }
4928
- let lastWasMeaningful = false;
5036
+ let lastMeaningfulNode = null;
4929
5037
  let hasHandledSpacing = false;
4930
5038
  for (let i = 0; i < children.length; i++) {
4931
5039
  const child = children[i];
@@ -4939,36 +5047,42 @@ class FormatPrinter extends Printer {
4939
5047
  }
4940
5048
  if (shouldAppendToLastLine(child, children, i)) {
4941
5049
  this.appendChildToLastLine(child, children, i);
4942
- lastWasMeaningful = true;
5050
+ lastMeaningfulNode = child;
4943
5051
  hasHandledSpacing = false;
4944
5052
  continue;
4945
5053
  }
4946
- if (isNonWhitespaceNode(child) && lastWasMeaningful && !hasHandledSpacing) {
4947
- this.push("");
4948
- }
5054
+ if (!isNonWhitespaceNode(child))
5055
+ continue;
5056
+ const childStartLine = this.stringLineCount;
4949
5057
  this.visit(child);
4950
- if (isNonWhitespaceNode(child)) {
4951
- lastWasMeaningful = true;
4952
- 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
+ }
4953
5064
  }
5065
+ lastMeaningfulNode = child;
5066
+ hasHandledSpacing = false;
4954
5067
  }
4955
5068
  }
4956
5069
  visitHTMLElementNode(node) {
4957
5070
  this.elementStack.push(node);
4958
5071
  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
- }
4965
- this.visit(node.open_tag);
4966
- if (node.body.length > 0) {
4967
- this.visitHTMLElementBody(node.body, node);
4968
- }
4969
- if (node.close_tag) {
4970
- this.visit(node.close_tag);
4971
- }
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
+ });
4972
5086
  this.elementStack.pop();
4973
5087
  }
4974
5088
  visitHTMLElementBody(body, element) {
@@ -5115,17 +5229,19 @@ class FormatPrinter extends Printer {
5115
5229
  }
5116
5230
  /**
5117
5231
  * Visit element children with intelligent spacing logic
5232
+ *
5233
+ * Tracks line positions and immediately splices blank lines after rendering each child.
5118
5234
  */
5119
5235
  visitElementChildren(body, parentElement) {
5120
- let lastWasMeaningful = false;
5236
+ let lastMeaningfulNode = null;
5121
5237
  let hasHandledSpacing = false;
5122
- for (let i = 0; i < body.length; i++) {
5123
- const child = body[i];
5238
+ for (let index = 0; index < body.length; index++) {
5239
+ const child = body[index];
5124
5240
  if (isNode(child, HTMLTextNode)) {
5125
5241
  const isWhitespaceOnly = child.content.trim() === "";
5126
5242
  if (isWhitespaceOnly) {
5127
- const hasPreviousNonWhitespace = i > 0 && isNonWhitespaceNode(body[i - 1]);
5128
- 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]);
5129
5245
  const hasMultipleNewlines = child.content.includes('\n\n');
5130
5246
  if (hasPreviousNonWhitespace && hasNextNonWhitespace && hasMultipleNewlines) {
5131
5247
  this.push("");
@@ -5134,24 +5250,26 @@ class FormatPrinter extends Printer {
5134
5250
  continue;
5135
5251
  }
5136
5252
  }
5137
- if (isNonWhitespaceNode(child) && lastWasMeaningful && !hasHandledSpacing) {
5138
- const element = body[i - 1];
5139
- const hasExistingSpacing = i > 0 && isNode(element, HTMLTextNode) && element.content.trim() === "" && (element.content.includes('\n\n') || element.content.split('\n').length > 2);
5140
- const shouldAddSpacing = this.shouldAddSpacingBetweenSiblings(parentElement, body, i, hasExistingSpacing);
5141
- if (shouldAddSpacing) {
5142
- this.push("");
5143
- }
5144
- }
5253
+ if (!isNonWhitespaceNode(child))
5254
+ continue;
5145
5255
  let hasTrailingHerbDisable = false;
5146
5256
  if (isNode(child, HTMLElementNode) && child.close_tag) {
5147
- for (let j = i + 1; j < body.length; j++) {
5257
+ for (let j = index + 1; j < body.length; j++) {
5148
5258
  const nextChild = body[j];
5149
5259
  if (isNode(nextChild, WhitespaceNode) || isPureWhitespaceNode(nextChild)) {
5150
5260
  continue;
5151
5261
  }
5152
5262
  if (isNode(nextChild, ERBContentNode) && isHerbDisableComment(nextChild)) {
5153
5263
  hasTrailingHerbDisable = true;
5264
+ const childStartLine = this.stringLineCount;
5154
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
+ }
5155
5273
  const herbDisableString = this.capture(() => {
5156
5274
  const savedIndentLevel = this.indentLevel;
5157
5275
  this.indentLevel = 0;
@@ -5161,17 +5279,25 @@ class FormatPrinter extends Printer {
5161
5279
  this.indentLevel = savedIndentLevel;
5162
5280
  }).join("");
5163
5281
  this.pushToLastLine(' ' + herbDisableString);
5164
- i = j;
5282
+ index = j;
5283
+ lastMeaningfulNode = child;
5284
+ hasHandledSpacing = false;
5165
5285
  break;
5166
5286
  }
5167
5287
  break;
5168
5288
  }
5169
5289
  }
5170
5290
  if (!hasTrailingHerbDisable) {
5291
+ const childStartLine = this.stringLineCount;
5171
5292
  this.visit(child);
5172
- }
5173
- if (isNonWhitespaceNode(child)) {
5174
- 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;
5175
5301
  hasHandledSpacing = false;
5176
5302
  }
5177
5303
  }
@@ -5267,6 +5393,11 @@ class FormatPrinter extends Printer {
5267
5393
  return "";
5268
5394
  }
5269
5395
  }).join("");
5396
+ const trimmedInner = inner.trim();
5397
+ if (trimmedInner.startsWith('[if ') && trimmedInner.endsWith('<![endif]')) {
5398
+ this.pushWithIndent(open + inner + close);
5399
+ return;
5400
+ }
5270
5401
  const hasNewlines = inner.includes('\n');
5271
5402
  if (hasNewlines) {
5272
5403
  const lines = inner.split('\n');
@@ -5349,8 +5480,7 @@ class FormatPrinter extends Printer {
5349
5480
  this.pushWithIndent(IdentityPrinter.print(node));
5350
5481
  }
5351
5482
  visitERBContentNode(node) {
5352
- // TODO: this feels hacky
5353
- if (node.tag_opening?.value === "<%#") {
5483
+ if (isERBCommentNode(node)) {
5354
5484
  this.visitERBCommentNode(node);
5355
5485
  }
5356
5486
  else {
@@ -5361,83 +5491,92 @@ class FormatPrinter extends Printer {
5361
5491
  this.printERBNode(node);
5362
5492
  }
5363
5493
  visitERBYieldNode(node) {
5364
- this.printERBNode(node);
5494
+ this.trackBoundary(node, () => {
5495
+ this.printERBNode(node);
5496
+ });
5365
5497
  }
5366
5498
  visitERBInNode(node) {
5367
- this.printERBNode(node);
5368
- this.withIndent(() => this.visitAll(node.statements));
5499
+ this.trackBoundary(node, () => {
5500
+ this.printERBNode(node);
5501
+ this.withIndent(() => this.visitAll(node.statements));
5502
+ });
5369
5503
  }
5370
5504
  visitERBCaseMatchNode(node) {
5371
- this.printERBNode(node);
5372
- this.withIndent(() => this.visitAll(node.children));
5373
- this.visitAll(node.conditions);
5374
- if (node.else_clause)
5375
- this.visit(node.else_clause);
5376
- if (node.end_node)
5377
- this.visit(node.end_node);
5378
- }
5379
- visitERBBlockNode(node) {
5380
- this.printERBNode(node);
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
- }
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);
5389
5513
  });
5390
- if (node.end_node)
5391
- this.visit(node.end_node);
5392
5514
  }
5393
- visitERBIfNode(node) {
5394
- if (this.inlineMode) {
5515
+ visitERBBlockNode(node) {
5516
+ this.trackBoundary(node, () => {
5395
5517
  this.printERBNode(node);
5396
- node.statements.forEach(child => {
5397
- if (isNode(child, HTMLAttributeNode)) {
5398
- this.lines.push(" ");
5399
- 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);
5400
5522
  }
5401
5523
  else {
5402
- const shouldAddSpaces = this.isInTokenListAttribute;
5403
- 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)) {
5404
5537
  this.lines.push(" ");
5538
+ this.lines.push(this.renderAttribute(child));
5405
5539
  }
5406
- this.visit(child);
5407
- if (shouldAddSpaces) {
5408
- 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
+ }
5409
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(" ");
5410
5555
  }
5411
- });
5412
- const hasHTMLAttributes = node.statements.some(child => isNode(child, HTMLAttributeNode));
5413
- const isTokenList = this.isInTokenListAttribute;
5414
- if ((hasHTMLAttributes || isTokenList) && node.end_node) {
5415
- this.lines.push(" ");
5556
+ if (node.subsequent)
5557
+ this.visit(node.subsequent);
5558
+ if (node.end_node)
5559
+ this.visit(node.end_node);
5416
5560
  }
5417
- if (node.subsequent)
5418
- this.visit(node.subsequent);
5419
- if (node.end_node)
5420
- this.visit(node.end_node);
5421
- }
5422
- else {
5423
- this.printERBNode(node);
5424
- this.withIndent(() => {
5425
- node.statements.forEach(child => this.visit(child));
5426
- });
5427
- if (node.subsequent)
5428
- this.visit(node.subsequent);
5429
- if (node.end_node)
5430
- this.visit(node.end_node);
5431
- }
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
+ });
5432
5572
  }
5433
5573
  visitERBElseNode(node) {
5574
+ this.printERBNode(node);
5434
5575
  if (this.inlineMode) {
5435
- this.printERBNode(node);
5436
- node.statements.forEach(statement => this.visit(statement));
5576
+ this.visitAll(node.statements);
5437
5577
  }
5438
5578
  else {
5439
- this.printERBNode(node);
5440
- this.withIndent(() => node.statements.forEach(statement => this.visit(statement)));
5579
+ this.withIndent(() => this.visitAll(node.statements));
5441
5580
  }
5442
5581
  }
5443
5582
  visitERBWhenNode(node) {
@@ -5445,43 +5584,53 @@ class FormatPrinter extends Printer {
5445
5584
  this.withIndent(() => this.visitAll(node.statements));
5446
5585
  }
5447
5586
  visitERBCaseNode(node) {
5448
- this.printERBNode(node);
5449
- this.withIndent(() => this.visitAll(node.children));
5450
- this.visitAll(node.conditions);
5451
- if (node.else_clause)
5452
- this.visit(node.else_clause);
5453
- if (node.end_node)
5454
- 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
+ });
5455
5596
  }
5456
5597
  visitERBBeginNode(node) {
5457
- this.printERBNode(node);
5458
- this.withIndent(() => this.visitAll(node.statements));
5459
- if (node.rescue_clause)
5460
- this.visit(node.rescue_clause);
5461
- if (node.else_clause)
5462
- this.visit(node.else_clause);
5463
- if (node.ensure_clause)
5464
- this.visit(node.ensure_clause);
5465
- if (node.end_node)
5466
- 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
+ });
5467
5610
  }
5468
5611
  visitERBWhileNode(node) {
5469
- this.printERBNode(node);
5470
- this.withIndent(() => this.visitAll(node.statements));
5471
- if (node.end_node)
5472
- 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
+ });
5473
5618
  }
5474
5619
  visitERBUntilNode(node) {
5475
- this.printERBNode(node);
5476
- this.withIndent(() => this.visitAll(node.statements));
5477
- if (node.end_node)
5478
- 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
+ });
5479
5626
  }
5480
5627
  visitERBForNode(node) {
5481
- this.printERBNode(node);
5482
- this.withIndent(() => this.visitAll(node.statements));
5483
- if (node.end_node)
5484
- 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
+ });
5485
5634
  }
5486
5635
  visitERBRescueNode(node) {
5487
5636
  this.printERBNode(node);
@@ -5492,12 +5641,14 @@ class FormatPrinter extends Printer {
5492
5641
  this.withIndent(() => this.visitAll(node.statements));
5493
5642
  }
5494
5643
  visitERBUnlessNode(node) {
5495
- this.printERBNode(node);
5496
- this.withIndent(() => this.visitAll(node.statements));
5497
- if (node.else_clause)
5498
- this.visit(node.else_clause);
5499
- if (node.end_node)
5500
- 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
+ });
5501
5652
  }
5502
5653
  // --- Element Formatting Analysis Helpers ---
5503
5654
  /**
@@ -5542,6 +5693,14 @@ class FormatPrinter extends Printer {
5542
5693
  return false;
5543
5694
  if (children.length === 0)
5544
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;
5545
5704
  let hasLeadingHerbDisable = false;
5546
5705
  for (const child of node.body) {
5547
5706
  if (isNode(child, WhitespaceNode) || isPureWhitespaceNode(child)) {
@@ -5559,7 +5718,7 @@ class FormatPrinter extends Printer {
5559
5718
  const fullInlineResult = this.tryRenderInlineFull(node, tagName, filterNodes(node.open_tag?.children, HTMLAttributeNode), node.body);
5560
5719
  if (fullInlineResult) {
5561
5720
  const totalLength = this.indent.length + fullInlineResult.length;
5562
- return totalLength <= this.maxLineLength || totalLength <= 120;
5721
+ return totalLength <= this.maxLineLength;
5563
5722
  }
5564
5723
  return false;
5565
5724
  }
@@ -5580,7 +5739,8 @@ class FormatPrinter extends Printer {
5580
5739
  const openTagResult = this.renderInlineOpen(tagName, filterNodes(node.open_tag?.children, HTMLAttributeNode), false, [], node.open_tag?.children || []);
5581
5740
  const childrenContent = this.renderChildrenInline(children);
5582
5741
  const fullLine = openTagResult + childrenContent + `</${tagName}>`;
5583
- if ((this.indent.length + fullLine.length) <= this.maxLineLength) {
5742
+ const totalLength = this.indent.length + fullLine.length;
5743
+ if (totalLength <= this.maxLineLength) {
5584
5744
  return true;
5585
5745
  }
5586
5746
  }
@@ -5896,7 +6056,7 @@ class FormatPrinter extends Printer {
5896
6056
  return false;
5897
6057
  const firstWord = words[0];
5898
6058
  const firstChar = firstWord[0];
5899
- if (!/[a-zA-Z0-9.!?:;]/.test(firstChar)) {
6059
+ if (/\s/.test(firstChar)) {
5900
6060
  return false;
5901
6061
  }
5902
6062
  lastUnit.unit.content += firstWord;
@@ -5910,6 +6070,12 @@ class FormatPrinter extends Printer {
5910
6070
  node: textNode
5911
6071
  });
5912
6072
  }
6073
+ else if (endsWithWhitespace(textNode.content)) {
6074
+ result.push({
6075
+ unit: { content: ' ', type: 'text', isAtomic: false, breaksFlow: false },
6076
+ node: textNode
6077
+ });
6078
+ }
5913
6079
  return true;
5914
6080
  }
5915
6081
  /**
@@ -5970,9 +6136,7 @@ class FormatPrinter extends Printer {
5970
6136
  const hasWhitespace = this.hasWhitespaceBeforeNode(children, lastProcessedIndex, index, child);
5971
6137
  const lastUnit = result[result.length - 1];
5972
6138
  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)) {
6139
+ if (lastIsAtomic && !hasWhitespace && this.tryMergeTextAfterAtomic(result, child)) {
5976
6140
  return;
5977
6141
  }
5978
6142
  }
@@ -6448,6 +6612,41 @@ function resolveFormatOptions(options = {}) {
6448
6612
  };
6449
6613
  }
6450
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
+
6451
6650
  /**
6452
6651
  * Formatter uses a Herb Backend to parse the source and then
6453
6652
  * formats the resulting AST into a well-indented, wrapped string.
@@ -6492,6 +6691,8 @@ class Formatter {
6492
6691
  return source;
6493
6692
  if (isScaffoldTemplate(result))
6494
6693
  return source;
6694
+ if (hasFormatterIgnoreDirective(result.value))
6695
+ return source;
6495
6696
  const resolvedOptions = resolveFormatOptions({ ...this.options, ...options });
6496
6697
  let node = result.value;
6497
6698
  if (resolvedOptions.preRewriters.length > 0) {