@herb-tools/formatter 0.8.10 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { isNode, isERBNode, getTagName, isAnyOf, isERBControlFlowNode, hasERBOutput } from "@herb-tools/core"
1
+ import { isNode, isERBNode, getTagName, isAnyOf, isERBControlFlowNode, hasERBOutput, getStaticAttributeValue, getTokenList, isPureWhitespaceNode } from "@herb-tools/core"
2
2
  import { Node, HTMLDoctypeNode, HTMLTextNode, HTMLElementNode, HTMLCommentNode, HTMLOpenTagNode, HTMLCloseTagNode, ERBIfNode, ERBContentNode, WhitespaceNode } from "@herb-tools/core"
3
3
 
4
4
  // --- Types ---
@@ -34,6 +34,12 @@ export interface ContentUnitWithNode {
34
34
 
35
35
  // --- Constants ---
36
36
 
37
+ /**
38
+ * ASCII whitespace pattern - use instead of \s to preserve Unicode whitespace
39
+ * characters like NBSP (U+00A0) and full-width space (U+3000)
40
+ */
41
+ export const ASCII_WHITESPACE = /[ \t\n\r]+/g
42
+
37
43
  // TODO: we can probably expand this list with more tags/attributes
38
44
  export const FORMATTABLE_ATTRIBUTES: Record<string, string[]> = {
39
45
  '*': ['class'],
@@ -51,37 +57,35 @@ export const CONTENT_PRESERVING_ELEMENTS = new Set([
51
57
  'script', 'style', 'pre', 'textarea'
52
58
  ])
53
59
 
60
+ // https://tailwindcss.com/docs/white-space
61
+ export const WHITESPACE_PRESERVING_CLASSES = [
62
+ 'whitespace-pre-line',
63
+ 'whitespace-pre-wrap',
64
+ 'whitespace-pre',
65
+ 'whitespace-break-spaces',
66
+ ]
67
+
68
+ // https://developer.mozilla.org/en-US/docs/Web/CSS/white-space
69
+ export const WHITESPACE_PRESERVING_STYLE_VALUES = new Set([
70
+ 'pre',
71
+ 'pre-line',
72
+ 'pre-wrap',
73
+ 'break-spaces',
74
+ ])
75
+
54
76
  export const SPACEABLE_CONTAINERS = new Set([
55
77
  'div', 'section', 'article', 'main', 'header', 'footer', 'aside',
56
78
  'figure', 'details', 'summary', 'dialog', 'fieldset'
57
79
  ])
58
80
 
59
- /**
60
- * Token list attributes that contain space-separated values and benefit from
61
- * spacing around ERB content for readability
62
- */
63
- export const TOKEN_LIST_ATTRIBUTES = new Set([
64
- 'class', 'data-controller', 'data-action'
65
- ])
66
-
67
81
 
68
82
  // --- Node Utility Functions ---
69
83
 
70
- /**
71
- * Check if a node is pure whitespace (empty text node with only whitespace)
72
- */
73
- export function isPureWhitespaceNode(node: Node): boolean {
74
- return isNode(node, HTMLTextNode) && node.content.trim() === ""
75
- }
76
-
77
84
  /**
78
85
  * Check if a node is non-whitespace (has meaningful content)
79
86
  */
80
87
  export function isNonWhitespaceNode(node: Node): boolean {
81
- if (isNode(node, WhitespaceNode)) return false
82
- if (isNode(node, HTMLTextNode)) return node.content.trim() !== ""
83
-
84
- return true
88
+ return !isPureWhitespaceNode(node)
85
89
  }
86
90
 
87
91
  /**
@@ -136,10 +140,9 @@ export function filterEmptyNodesForHerbDisable(nodes: Node[]): Node[] {
136
140
  let pendingWhitespace: Node | null = null
137
141
 
138
142
  for (const node of nodes) {
139
- const isWhitespace = isNode(node, WhitespaceNode) || (isNode(node, HTMLTextNode) && node.content.trim() === "")
140
143
  const isHerbDisable = isNode(node, ERBContentNode) && isHerbDisableComment(node)
141
144
 
142
- if (isWhitespace) {
145
+ if (isPureWhitespaceNode(node)) {
143
146
  if (!pendingWhitespace) {
144
147
  pendingWhitespace = node
145
148
  }
@@ -169,7 +172,7 @@ export function isClosingPunctuation(word: string): boolean {
169
172
  * Check if a line ends with opening punctuation
170
173
  */
171
174
  export function lineEndsWithOpeningPunctuation(line: string): boolean {
172
- return /[(\[]$/.test(line)
175
+ return /[([]$/.test(line)
173
176
  }
174
177
 
175
178
  /**
@@ -185,14 +188,14 @@ export function isERBTag(text: string): boolean {
185
188
  export function endsWithERBTag(text: string): boolean {
186
189
  const trimmed = text.trim()
187
190
 
188
- return /%>$/.test(trimmed) || /%>\S+$/.test(trimmed)
191
+ return trimmed.endsWith('%>') || /%>\S+$/.test(trimmed)
189
192
  }
190
193
 
191
194
  /**
192
195
  * Check if a string starts with an ERB tag
193
196
  */
194
197
  export function startsWithERBTag(text: string): boolean {
195
- return /^<%/.test(text.trim())
198
+ return text.trim().startsWith('<%')
196
199
  }
197
200
 
198
201
  /**
@@ -391,26 +394,52 @@ export function hasMixedTextAndInlineContent(children: Node[]): boolean {
391
394
  return (hasText && hasInlineElements) || (hasERBOutput(children) && hasText)
392
395
  }
393
396
 
397
+ export function hasWhitespacePreservingStyle(element: HTMLElementNode): boolean {
398
+ if (getTokenList(element, "class").some(klass => WHITESPACE_PRESERVING_CLASSES.some(whitespace => klass.includes(whitespace)))) return true
399
+
400
+ const styleValue = getStaticAttributeValue(element, "style")
401
+ if (styleValue) {
402
+ const match = styleValue.match(/white-space\s*:\s*([^;!]+)/)
403
+
404
+ if (match) {
405
+ const value = match[1].trim().toLowerCase()
406
+ if (WHITESPACE_PRESERVING_STYLE_VALUES.has(value)) return true
407
+ }
408
+ }
409
+
410
+ return false
411
+ }
412
+
394
413
  export function isContentPreserving(element: HTMLElementNode | HTMLOpenTagNode | HTMLCloseTagNode): boolean {
395
414
  const tagName = getTagName(element)
415
+ if (CONTENT_PRESERVING_ELEMENTS.has(tagName)) return true
416
+
417
+ if (isNode(element, HTMLElementNode)) {
418
+ return hasWhitespacePreservingStyle(element)
419
+ }
396
420
 
397
- return CONTENT_PRESERVING_ELEMENTS.has(tagName)
421
+ return false
398
422
  }
399
423
 
400
424
  /**
401
- * Count consecutive inline elements/ERB at the start of children (with no whitespace between)
425
+ * Count consecutive inline elements/ERB with no whitespace between them.
426
+ * Starts from startIndex and skips indices in processedIndices.
402
427
  */
403
- export function countAdjacentInlineElements(children: Node[]): number {
428
+ export function countAdjacentInlineElements(children: Node[], startIndex = 0, processedIndices?: Set<number>): number {
404
429
  let count = 0
405
430
  let lastSignificantIndex = -1
406
431
 
407
- for (let i = 0; i < children.length; i++) {
432
+ for (let i = startIndex; i < children.length; i++) {
408
433
  const child = children[i]
409
434
 
410
435
  if (isPureWhitespaceNode(child) || isNode(child, WhitespaceNode)) {
411
436
  continue
412
437
  }
413
438
 
439
+ if (processedIndices?.has(i)) {
440
+ break
441
+ }
442
+
414
443
  const isInlineOrERB = (isNode(child, HTMLElementNode) && isInlineElement(getTagName(child))) || isNode(child, ERBContentNode)
415
444
 
416
445
  if (!isInlineOrERB) {
@@ -487,6 +516,21 @@ export function isHerbDisableComment(node: Node): boolean {
487
516
  return trimmed.startsWith("herb:disable")
488
517
  }
489
518
 
519
+ /**
520
+ * Check if children contain a leading herb:disable comment (after optional whitespace)
521
+ */
522
+ export function hasLeadingHerbDisable(children: Node[]): boolean {
523
+ for (const child of children) {
524
+ if (isNode(child, WhitespaceNode) || (isNode(child, HTMLTextNode) && child.content.trim() === "")) {
525
+ continue
526
+ }
527
+
528
+ return isNode(child, ERBContentNode) && isHerbDisableComment(child)
529
+ }
530
+
531
+ return false
532
+ }
533
+
490
534
  /**
491
535
  * Check if a text node is YAML frontmatter (starts and ends with ---)
492
536
  */