@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.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.0/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.0/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.0/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.0/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
@@ -4232,7 +4234,8 @@ function lineEndsWithOpeningPunctuation(line) {
4232
4234
  * Check if a string ends with an ERB tag
4233
4235
  */
4234
4236
  function endsWithERBTag(text) {
4235
- return /%>$/.test(text.trim());
4237
+ const trimmed = text.trim();
4238
+ return /%>$/.test(trimmed) || /%>\S+$/.test(trimmed);
4236
4239
  }
4237
4240
  /**
4238
4241
  * Check if a string starts with an ERB tag
@@ -4514,6 +4517,10 @@ class FormatPrinter extends Printer {
4514
4517
  currentAttributeName = null;
4515
4518
  elementStack = [];
4516
4519
  elementFormattingAnalysis = new Map();
4520
+ nodeIsMultiline = new Map();
4521
+ stringLineCount = 0;
4522
+ tagGroupsCache = new Map();
4523
+ allSingleLineCache = new Map();
4517
4524
  source;
4518
4525
  constructor(source, options) {
4519
4526
  super();
@@ -4528,6 +4535,10 @@ class FormatPrinter extends Printer {
4528
4535
  // TODO: refactor to use @herb-tools/printer infrastructre (or rework printer use push and this.lines)
4529
4536
  this.lines = [];
4530
4537
  this.indentLevel = 0;
4538
+ this.stringLineCount = 0;
4539
+ this.nodeIsMultiline.clear();
4540
+ this.tagGroupsCache.clear();
4541
+ this.allSingleLineCache.clear();
4531
4542
  this.visit(node);
4532
4543
  return this.lines.join("\n");
4533
4544
  }
@@ -4561,7 +4572,9 @@ class FormatPrinter extends Printer {
4561
4572
  capture(callback) {
4562
4573
  const previousLines = this.lines;
4563
4574
  const previousInlineMode = this.inlineMode;
4575
+ const previousStringLineCount = this.stringLineCount;
4564
4576
  this.lines = [];
4577
+ this.stringLineCount = 0;
4565
4578
  try {
4566
4579
  callback();
4567
4580
  return this.lines;
@@ -4569,8 +4582,18 @@ class FormatPrinter extends Printer {
4569
4582
  finally {
4570
4583
  this.lines = previousLines;
4571
4584
  this.inlineMode = previousInlineMode;
4585
+ this.stringLineCount = previousStringLineCount;
4572
4586
  }
4573
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
+ }
4574
4597
  /**
4575
4598
  * Capture all nodes that would be visited during a callback
4576
4599
  * Returns a flat list of all nodes without generating any output
@@ -4606,6 +4629,7 @@ class FormatPrinter extends Printer {
4606
4629
  */
4607
4630
  push(line) {
4608
4631
  this.lines.push(line);
4632
+ this.stringLineCount++;
4609
4633
  }
4610
4634
  /**
4611
4635
  * @deprecated refactor to use @herb-tools/printer infrastructre (or rework printer use push and this.lines)
@@ -4650,6 +4674,79 @@ class FormatPrinter extends Printer {
4650
4674
  extractInlineNodes(nodes) {
4651
4675
  return nodes.filter(child => isNoneOf(child, HTMLAttributeNode, WhitespaceNode));
4652
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
+ }
4653
4750
  /**
4654
4751
  * Determine if spacing should be added between sibling elements
4655
4752
  *
@@ -4665,48 +4762,59 @@ class FormatPrinter extends Printer {
4665
4762
  * @param hasExistingSpacing - Whether user-added spacing already exists
4666
4763
  * @returns true if spacing should be added before the current element
4667
4764
  */
4668
- shouldAddSpacingBetweenSiblings(parentElement, siblings, currentIndex, hasExistingSpacing) {
4669
- 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))) {
4670
4770
  return true;
4671
4771
  }
4672
4772
  const hasMixedContent = siblings.some(child => isNode(child, HTMLTextNode) && child.content.trim() !== "");
4673
- if (hasMixedContent) {
4773
+ if (hasMixedContent)
4674
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;
4675
4781
  }
4676
- const meaningfulSiblings = siblings.filter(child => isNonWhitespaceNode(child));
4677
- if (meaningfulSiblings.length < SPACING_THRESHOLD) {
4782
+ if (isPreviousComment && isCurrentComment) {
4678
4783
  return false;
4679
4784
  }
4785
+ if (isCurrentMultiline || isPreviousMultiline) {
4786
+ return true;
4787
+ }
4788
+ const meaningfulSiblings = siblings.filter(child => isNonWhitespaceNode(child));
4680
4789
  const parentTagName = parentElement ? getTagName(parentElement) : null;
4681
- if (parentTagName && TIGHT_GROUP_PARENTS.has(parentTagName)) {
4682
- 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);
4683
4800
  }
4684
- const isSpaceableContainer = !parentTagName || (parentTagName && SPACEABLE_CONTAINERS.has(parentTagName));
4685
4801
  if (!isSpaceableContainer && meaningfulSiblings.length < 5) {
4686
4802
  return false;
4687
4803
  }
4688
- const currentNode = siblings[currentIndex];
4689
- const previousMeaningfulIndex = findPreviousMeaningfulSibling(siblings, currentIndex);
4690
- const isCurrentComment = isCommentNode(currentNode);
4691
- if (previousMeaningfulIndex !== -1) {
4692
- const previousNode = siblings[previousMeaningfulIndex];
4693
- const isPreviousComment = isCommentNode(previousNode);
4694
- if (isPreviousComment && !isCurrentComment && (isNode(currentNode, HTMLElementNode) || isERBNode(currentNode))) {
4695
- return false;
4696
- }
4697
- if (isPreviousComment && isCurrentComment) {
4698
- return false;
4699
- }
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;
4700
4814
  }
4701
4815
  if (isNode(currentNode, HTMLElementNode)) {
4702
4816
  const currentTagName = getTagName(currentNode);
4703
- if (INLINE_ELEMENTS.has(currentTagName)) {
4704
- return false;
4705
- }
4706
- if (TIGHT_GROUP_CHILDREN.has(currentTagName)) {
4707
- return false;
4708
- }
4709
- if (currentTagName === 'a' && parentTagName === 'nav') {
4817
+ if (currentTagName && INLINE_ELEMENTS.has(currentTagName)) {
4710
4818
  return false;
4711
4819
  }
4712
4820
  }
@@ -4927,7 +5035,7 @@ class FormatPrinter extends Printer {
4927
5035
  this.inlineMode = wasInlineMode;
4928
5036
  return;
4929
5037
  }
4930
- let lastWasMeaningful = false;
5038
+ let lastMeaningfulNode = null;
4931
5039
  let hasHandledSpacing = false;
4932
5040
  for (let i = 0; i < children.length; i++) {
4933
5041
  const child = children[i];
@@ -4941,36 +5049,42 @@ class FormatPrinter extends Printer {
4941
5049
  }
4942
5050
  if (shouldAppendToLastLine(child, children, i)) {
4943
5051
  this.appendChildToLastLine(child, children, i);
4944
- lastWasMeaningful = true;
5052
+ lastMeaningfulNode = child;
4945
5053
  hasHandledSpacing = false;
4946
5054
  continue;
4947
5055
  }
4948
- if (isNonWhitespaceNode(child) && lastWasMeaningful && !hasHandledSpacing) {
4949
- this.push("");
4950
- }
5056
+ if (!isNonWhitespaceNode(child))
5057
+ continue;
5058
+ const childStartLine = this.stringLineCount;
4951
5059
  this.visit(child);
4952
- if (isNonWhitespaceNode(child)) {
4953
- lastWasMeaningful = true;
4954
- 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
+ }
4955
5066
  }
5067
+ lastMeaningfulNode = child;
5068
+ hasHandledSpacing = false;
4956
5069
  }
4957
5070
  }
4958
5071
  visitHTMLElementNode(node) {
4959
5072
  this.elementStack.push(node);
4960
5073
  this.elementFormattingAnalysis.set(node, this.analyzeElementFormatting(node));
4961
- if (this.inlineMode && node.is_void && this.indentLevel === 0) {
4962
- const openTag = this.capture(() => this.visit(node.open_tag)).join('');
4963
- this.pushToLastLine(openTag);
4964
- this.elementStack.pop();
4965
- return;
4966
- }
4967
- this.visit(node.open_tag);
4968
- if (node.body.length > 0) {
4969
- this.visitHTMLElementBody(node.body, node);
4970
- }
4971
- if (node.close_tag) {
4972
- this.visit(node.close_tag);
4973
- }
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
+ });
4974
5088
  this.elementStack.pop();
4975
5089
  }
4976
5090
  visitHTMLElementBody(body, element) {
@@ -5117,17 +5231,19 @@ class FormatPrinter extends Printer {
5117
5231
  }
5118
5232
  /**
5119
5233
  * Visit element children with intelligent spacing logic
5234
+ *
5235
+ * Tracks line positions and immediately splices blank lines after rendering each child.
5120
5236
  */
5121
5237
  visitElementChildren(body, parentElement) {
5122
- let lastWasMeaningful = false;
5238
+ let lastMeaningfulNode = null;
5123
5239
  let hasHandledSpacing = false;
5124
- for (let i = 0; i < body.length; i++) {
5125
- const child = body[i];
5240
+ for (let index = 0; index < body.length; index++) {
5241
+ const child = body[index];
5126
5242
  if (isNode(child, HTMLTextNode)) {
5127
5243
  const isWhitespaceOnly = child.content.trim() === "";
5128
5244
  if (isWhitespaceOnly) {
5129
- const hasPreviousNonWhitespace = i > 0 && isNonWhitespaceNode(body[i - 1]);
5130
- 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]);
5131
5247
  const hasMultipleNewlines = child.content.includes('\n\n');
5132
5248
  if (hasPreviousNonWhitespace && hasNextNonWhitespace && hasMultipleNewlines) {
5133
5249
  this.push("");
@@ -5136,24 +5252,26 @@ class FormatPrinter extends Printer {
5136
5252
  continue;
5137
5253
  }
5138
5254
  }
5139
- if (isNonWhitespaceNode(child) && lastWasMeaningful && !hasHandledSpacing) {
5140
- const element = body[i - 1];
5141
- const hasExistingSpacing = i > 0 && isNode(element, HTMLTextNode) && element.content.trim() === "" && (element.content.includes('\n\n') || element.content.split('\n').length > 2);
5142
- const shouldAddSpacing = this.shouldAddSpacingBetweenSiblings(parentElement, body, i, hasExistingSpacing);
5143
- if (shouldAddSpacing) {
5144
- this.push("");
5145
- }
5146
- }
5255
+ if (!isNonWhitespaceNode(child))
5256
+ continue;
5147
5257
  let hasTrailingHerbDisable = false;
5148
5258
  if (isNode(child, HTMLElementNode) && child.close_tag) {
5149
- for (let j = i + 1; j < body.length; j++) {
5259
+ for (let j = index + 1; j < body.length; j++) {
5150
5260
  const nextChild = body[j];
5151
5261
  if (isNode(nextChild, WhitespaceNode) || isPureWhitespaceNode(nextChild)) {
5152
5262
  continue;
5153
5263
  }
5154
5264
  if (isNode(nextChild, ERBContentNode) && isHerbDisableComment(nextChild)) {
5155
5265
  hasTrailingHerbDisable = true;
5266
+ const childStartLine = this.stringLineCount;
5156
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
+ }
5157
5275
  const herbDisableString = this.capture(() => {
5158
5276
  const savedIndentLevel = this.indentLevel;
5159
5277
  this.indentLevel = 0;
@@ -5163,17 +5281,25 @@ class FormatPrinter extends Printer {
5163
5281
  this.indentLevel = savedIndentLevel;
5164
5282
  }).join("");
5165
5283
  this.pushToLastLine(' ' + herbDisableString);
5166
- i = j;
5284
+ index = j;
5285
+ lastMeaningfulNode = child;
5286
+ hasHandledSpacing = false;
5167
5287
  break;
5168
5288
  }
5169
5289
  break;
5170
5290
  }
5171
5291
  }
5172
5292
  if (!hasTrailingHerbDisable) {
5293
+ const childStartLine = this.stringLineCount;
5173
5294
  this.visit(child);
5174
- }
5175
- if (isNonWhitespaceNode(child)) {
5176
- 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;
5177
5303
  hasHandledSpacing = false;
5178
5304
  }
5179
5305
  }
@@ -5269,6 +5395,11 @@ class FormatPrinter extends Printer {
5269
5395
  return "";
5270
5396
  }
5271
5397
  }).join("");
5398
+ const trimmedInner = inner.trim();
5399
+ if (trimmedInner.startsWith('[if ') && trimmedInner.endsWith('<![endif]')) {
5400
+ this.pushWithIndent(open + inner + close);
5401
+ return;
5402
+ }
5272
5403
  const hasNewlines = inner.includes('\n');
5273
5404
  if (hasNewlines) {
5274
5405
  const lines = inner.split('\n');
@@ -5351,8 +5482,7 @@ class FormatPrinter extends Printer {
5351
5482
  this.pushWithIndent(IdentityPrinter.print(node));
5352
5483
  }
5353
5484
  visitERBContentNode(node) {
5354
- // TODO: this feels hacky
5355
- if (node.tag_opening?.value === "<%#") {
5485
+ if (isERBCommentNode(node)) {
5356
5486
  this.visitERBCommentNode(node);
5357
5487
  }
5358
5488
  else {
@@ -5363,83 +5493,92 @@ class FormatPrinter extends Printer {
5363
5493
  this.printERBNode(node);
5364
5494
  }
5365
5495
  visitERBYieldNode(node) {
5366
- this.printERBNode(node);
5496
+ this.trackBoundary(node, () => {
5497
+ this.printERBNode(node);
5498
+ });
5367
5499
  }
5368
5500
  visitERBInNode(node) {
5369
- this.printERBNode(node);
5370
- this.withIndent(() => this.visitAll(node.statements));
5501
+ this.trackBoundary(node, () => {
5502
+ this.printERBNode(node);
5503
+ this.withIndent(() => this.visitAll(node.statements));
5504
+ });
5371
5505
  }
5372
5506
  visitERBCaseMatchNode(node) {
5373
- this.printERBNode(node);
5374
- this.withIndent(() => this.visitAll(node.children));
5375
- this.visitAll(node.conditions);
5376
- if (node.else_clause)
5377
- this.visit(node.else_clause);
5378
- if (node.end_node)
5379
- this.visit(node.end_node);
5380
- }
5381
- visitERBBlockNode(node) {
5382
- this.printERBNode(node);
5383
- this.withIndent(() => {
5384
- const hasTextFlow = this.isInTextFlowContext(null, node.body);
5385
- if (hasTextFlow) {
5386
- this.visitTextFlowChildren(node.body);
5387
- }
5388
- else {
5389
- this.visitElementChildren(node.body, null);
5390
- }
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);
5391
5515
  });
5392
- if (node.end_node)
5393
- this.visit(node.end_node);
5394
5516
  }
5395
- visitERBIfNode(node) {
5396
- if (this.inlineMode) {
5517
+ visitERBBlockNode(node) {
5518
+ this.trackBoundary(node, () => {
5397
5519
  this.printERBNode(node);
5398
- node.statements.forEach(child => {
5399
- if (isNode(child, HTMLAttributeNode)) {
5400
- this.lines.push(" ");
5401
- 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);
5402
5524
  }
5403
5525
  else {
5404
- const shouldAddSpaces = this.isInTokenListAttribute;
5405
- 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)) {
5406
5539
  this.lines.push(" ");
5540
+ this.lines.push(this.renderAttribute(child));
5407
5541
  }
5408
- this.visit(child);
5409
- if (shouldAddSpaces) {
5410
- 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
+ }
5411
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(" ");
5412
5557
  }
5413
- });
5414
- const hasHTMLAttributes = node.statements.some(child => isNode(child, HTMLAttributeNode));
5415
- const isTokenList = this.isInTokenListAttribute;
5416
- if ((hasHTMLAttributes || isTokenList) && node.end_node) {
5417
- this.lines.push(" ");
5558
+ if (node.subsequent)
5559
+ this.visit(node.subsequent);
5560
+ if (node.end_node)
5561
+ this.visit(node.end_node);
5418
5562
  }
5419
- if (node.subsequent)
5420
- this.visit(node.subsequent);
5421
- if (node.end_node)
5422
- this.visit(node.end_node);
5423
- }
5424
- else {
5425
- this.printERBNode(node);
5426
- this.withIndent(() => {
5427
- node.statements.forEach(child => this.visit(child));
5428
- });
5429
- if (node.subsequent)
5430
- this.visit(node.subsequent);
5431
- if (node.end_node)
5432
- this.visit(node.end_node);
5433
- }
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
+ });
5434
5574
  }
5435
5575
  visitERBElseNode(node) {
5576
+ this.printERBNode(node);
5436
5577
  if (this.inlineMode) {
5437
- this.printERBNode(node);
5438
- node.statements.forEach(statement => this.visit(statement));
5578
+ this.visitAll(node.statements);
5439
5579
  }
5440
5580
  else {
5441
- this.printERBNode(node);
5442
- this.withIndent(() => node.statements.forEach(statement => this.visit(statement)));
5581
+ this.withIndent(() => this.visitAll(node.statements));
5443
5582
  }
5444
5583
  }
5445
5584
  visitERBWhenNode(node) {
@@ -5447,43 +5586,53 @@ class FormatPrinter extends Printer {
5447
5586
  this.withIndent(() => this.visitAll(node.statements));
5448
5587
  }
5449
5588
  visitERBCaseNode(node) {
5450
- this.printERBNode(node);
5451
- this.withIndent(() => this.visitAll(node.children));
5452
- this.visitAll(node.conditions);
5453
- if (node.else_clause)
5454
- this.visit(node.else_clause);
5455
- if (node.end_node)
5456
- 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
+ });
5457
5598
  }
5458
5599
  visitERBBeginNode(node) {
5459
- this.printERBNode(node);
5460
- this.withIndent(() => this.visitAll(node.statements));
5461
- if (node.rescue_clause)
5462
- this.visit(node.rescue_clause);
5463
- if (node.else_clause)
5464
- this.visit(node.else_clause);
5465
- if (node.ensure_clause)
5466
- this.visit(node.ensure_clause);
5467
- if (node.end_node)
5468
- 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
+ });
5469
5612
  }
5470
5613
  visitERBWhileNode(node) {
5471
- this.printERBNode(node);
5472
- this.withIndent(() => this.visitAll(node.statements));
5473
- if (node.end_node)
5474
- 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
+ });
5475
5620
  }
5476
5621
  visitERBUntilNode(node) {
5477
- this.printERBNode(node);
5478
- this.withIndent(() => this.visitAll(node.statements));
5479
- if (node.end_node)
5480
- 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
+ });
5481
5628
  }
5482
5629
  visitERBForNode(node) {
5483
- this.printERBNode(node);
5484
- this.withIndent(() => this.visitAll(node.statements));
5485
- if (node.end_node)
5486
- 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
+ });
5487
5636
  }
5488
5637
  visitERBRescueNode(node) {
5489
5638
  this.printERBNode(node);
@@ -5494,12 +5643,14 @@ class FormatPrinter extends Printer {
5494
5643
  this.withIndent(() => this.visitAll(node.statements));
5495
5644
  }
5496
5645
  visitERBUnlessNode(node) {
5497
- this.printERBNode(node);
5498
- this.withIndent(() => this.visitAll(node.statements));
5499
- if (node.else_clause)
5500
- this.visit(node.else_clause);
5501
- if (node.end_node)
5502
- 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
+ });
5503
5654
  }
5504
5655
  // --- Element Formatting Analysis Helpers ---
5505
5656
  /**
@@ -5544,6 +5695,14 @@ class FormatPrinter extends Printer {
5544
5695
  return false;
5545
5696
  if (children.length === 0)
5546
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;
5547
5706
  let hasLeadingHerbDisable = false;
5548
5707
  for (const child of node.body) {
5549
5708
  if (isNode(child, WhitespaceNode) || isPureWhitespaceNode(child)) {
@@ -5561,7 +5720,7 @@ class FormatPrinter extends Printer {
5561
5720
  const fullInlineResult = this.tryRenderInlineFull(node, tagName, filterNodes(node.open_tag?.children, HTMLAttributeNode), node.body);
5562
5721
  if (fullInlineResult) {
5563
5722
  const totalLength = this.indent.length + fullInlineResult.length;
5564
- return totalLength <= this.maxLineLength || totalLength <= 120;
5723
+ return totalLength <= this.maxLineLength;
5565
5724
  }
5566
5725
  return false;
5567
5726
  }
@@ -5582,7 +5741,8 @@ class FormatPrinter extends Printer {
5582
5741
  const openTagResult = this.renderInlineOpen(tagName, filterNodes(node.open_tag?.children, HTMLAttributeNode), false, [], node.open_tag?.children || []);
5583
5742
  const childrenContent = this.renderChildrenInline(children);
5584
5743
  const fullLine = openTagResult + childrenContent + `</${tagName}>`;
5585
- if ((this.indent.length + fullLine.length) <= this.maxLineLength) {
5744
+ const totalLength = this.indent.length + fullLine.length;
5745
+ if (totalLength <= this.maxLineLength) {
5586
5746
  return true;
5587
5747
  }
5588
5748
  }
@@ -5898,7 +6058,7 @@ class FormatPrinter extends Printer {
5898
6058
  return false;
5899
6059
  const firstWord = words[0];
5900
6060
  const firstChar = firstWord[0];
5901
- if (!/[a-zA-Z0-9.!?:;]/.test(firstChar)) {
6061
+ if (/\s/.test(firstChar)) {
5902
6062
  return false;
5903
6063
  }
5904
6064
  lastUnit.unit.content += firstWord;
@@ -5912,6 +6072,12 @@ class FormatPrinter extends Printer {
5912
6072
  node: textNode
5913
6073
  });
5914
6074
  }
6075
+ else if (endsWithWhitespace(textNode.content)) {
6076
+ result.push({
6077
+ unit: { content: ' ', type: 'text', isAtomic: false, breaksFlow: false },
6078
+ node: textNode
6079
+ });
6080
+ }
5915
6081
  return true;
5916
6082
  }
5917
6083
  /**
@@ -5972,9 +6138,7 @@ class FormatPrinter extends Printer {
5972
6138
  const hasWhitespace = this.hasWhitespaceBeforeNode(children, lastProcessedIndex, index, child);
5973
6139
  const lastUnit = result[result.length - 1];
5974
6140
  const lastIsAtomic = lastUnit.unit.isAtomic && (lastUnit.unit.type === 'erb' || lastUnit.unit.type === 'inline');
5975
- const trimmed = child.content.trim();
5976
- const startsWithClosingPunct = trimmed.length > 0 && /^[.!?:;]/.test(trimmed);
5977
- if (lastIsAtomic && (!hasWhitespace || startsWithClosingPunct) && this.tryMergeTextAfterAtomic(result, child)) {
6141
+ if (lastIsAtomic && !hasWhitespace && this.tryMergeTextAfterAtomic(result, child)) {
5978
6142
  return;
5979
6143
  }
5980
6144
  }
@@ -6450,6 +6614,41 @@ function resolveFormatOptions(options = {}) {
6450
6614
  };
6451
6615
  }
6452
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
+
6453
6652
  /**
6454
6653
  * Formatter uses a Herb Backend to parse the source and then
6455
6654
  * formats the resulting AST into a well-indented, wrapped string.
@@ -6494,6 +6693,8 @@ class Formatter {
6494
6693
  return source;
6495
6694
  if (isScaffoldTemplate(result))
6496
6695
  return source;
6696
+ if (hasFormatterIgnoreDirective(result.value))
6697
+ return source;
6497
6698
  const resolvedOptions = resolveFormatOptions({ ...this.options, ...options });
6498
6699
  let node = result.value;
6499
6700
  if (resolvedOptions.preRewriters.length > 0) {