@herb-tools/core 0.8.9 → 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.
package/src/ast-utils.ts CHANGED
@@ -14,8 +14,11 @@ import {
14
14
  HTMLElementNode,
15
15
  HTMLOpenTagNode,
16
16
  HTMLCloseTagNode,
17
+ HTMLAttributeNode,
17
18
  HTMLAttributeNameNode,
18
- HTMLCommentNode
19
+ HTMLAttributeValueNode,
20
+ HTMLCommentNode,
21
+ WhitespaceNode
19
22
  } from "./nodes.js"
20
23
 
21
24
  import {
@@ -24,11 +27,21 @@ import {
24
27
  isERBNode,
25
28
  isERBContentNode,
26
29
  isHTMLCommentNode,
30
+ isHTMLElementNode,
31
+ isHTMLOpenTagNode,
32
+ isHTMLTextNode,
33
+ isWhitespaceNode,
34
+ isHTMLAttributeNameNode,
35
+ isHTMLAttributeValueNode,
27
36
  areAllOfType,
28
- filterLiteralNodes
37
+ filterLiteralNodes,
38
+ filterHTMLAttributeNodes
29
39
  } from "./node-type-guards.js"
30
40
 
31
- import type { Location } from "./location.js"
41
+ import { Location } from "./location.js"
42
+ import { Range } from "./range.js"
43
+ import { Token } from "./token.js"
44
+
32
45
  import type { Position } from "./position.js"
33
46
 
34
47
  export type ERBOutputNode = ERBNode & {
@@ -60,7 +73,7 @@ export function isERBCommentNode(node: Node): node is ERBCommentNode {
60
73
  if (!isERBNode(node)) return false
61
74
  if (!node.tag_opening?.value) return false
62
75
 
63
- return node.tag_opening?.value === "<%#" || (node.tag_opening?.value !== "<%#" && (node.content?.value || "").trimStart().startsWith("#"))
76
+ return node.tag_opening?.value === "<%#" || (node.tag_opening?.value !== "<%#" && (node.content?.value || "").trimStart().startsWith("#"))
64
77
  }
65
78
 
66
79
 
@@ -208,10 +221,26 @@ export function getCombinedAttributeName(attributeNameNode: HTMLAttributeNameNod
208
221
  }
209
222
 
210
223
  /**
211
- * Gets the tag name of an HTML element node
224
+ * Gets the tag name of an HTML element, open tag, or close tag node.
225
+ * Returns null if the node is null/undefined.
212
226
  */
213
- export function getTagName(node: HTMLElementNode | HTMLOpenTagNode | HTMLCloseTagNode): string {
214
- return node.tag_name?.value ?? ""
227
+ export function getTagName(node: HTMLElementNode | HTMLOpenTagNode | HTMLCloseTagNode): string
228
+ export function getTagName(node: HTMLElementNode | HTMLOpenTagNode | HTMLCloseTagNode | null | undefined): string | null
229
+ export function getTagName(node: HTMLElementNode | HTMLOpenTagNode | HTMLCloseTagNode | null | undefined): string | null {
230
+ if (!node) return null
231
+
232
+ return node.tag_name?.value ?? null
233
+ }
234
+
235
+ /**
236
+ * Gets the lowercased tag name of an HTML element, open tag, or close tag node.
237
+ * Similar to `Element.localName` in the DOM API.
238
+ * Returns null if the node is null/undefined.
239
+ */
240
+ export function getTagLocalName(node: HTMLElementNode | HTMLOpenTagNode | HTMLCloseTagNode): string
241
+ export function getTagLocalName(node: HTMLElementNode | HTMLOpenTagNode | HTMLCloseTagNode | null | undefined): string | null
242
+ export function getTagLocalName(node: HTMLElementNode | HTMLOpenTagNode | HTMLCloseTagNode | null | undefined): string | null {
243
+ return getTagName(node)?.toLowerCase() ?? null
215
244
  }
216
245
 
217
246
  /**
@@ -221,6 +250,366 @@ export function isCommentNode(node: Node): node is HTMLCommentNode | ERBCommentN
221
250
  return isHTMLCommentNode(node) || isERBCommentNode(node)
222
251
  }
223
252
 
253
+ /**
254
+ * Gets the open tag node from an HTMLElementNode, handling both regular and conditional open tags.
255
+ * For conditional open tags, returns null.
256
+ * If given an HTMLOpenTagNode directly, returns it as-is.
257
+ */
258
+ export function getOpenTag(node: HTMLElementNode | HTMLOpenTagNode | null | undefined): HTMLOpenTagNode | null {
259
+ if (!node) return null
260
+ if (isHTMLOpenTagNode(node)) return node
261
+ if (isHTMLElementNode(node)) return isHTMLOpenTagNode(node.open_tag) ? node.open_tag : null
262
+
263
+ return null
264
+ }
265
+
266
+ /**
267
+ * Gets attributes from an HTMLElementNode or HTMLOpenTagNode
268
+ */
269
+ export function getAttributes(node: HTMLElementNode | HTMLOpenTagNode | null | undefined): HTMLAttributeNode[] {
270
+ const openTag = getOpenTag(node)
271
+
272
+ return openTag ? filterHTMLAttributeNodes(openTag.children) : []
273
+ }
274
+
275
+ /**
276
+ * Gets the attribute name from an HTMLAttributeNode (lowercased)
277
+ * Returns null if the attribute name contains dynamic content (ERB)
278
+ */
279
+ export function getAttributeName(attributeNode: HTMLAttributeNode, lowercase = true): string | null {
280
+ if (!isHTMLAttributeNameNode(attributeNode.name)) return null
281
+
282
+ const staticName = getStaticAttributeName(attributeNode.name)
283
+
284
+ if (!lowercase) return staticName
285
+
286
+ return staticName ? staticName.toLowerCase() : null
287
+ }
288
+
289
+ /**
290
+ * Checks if an attribute value contains only static content (no ERB).
291
+ * Accepts an HTMLAttributeNode directly, or an element/open tag + attribute name.
292
+ * Returns false for null/undefined input.
293
+ */
294
+ export function hasStaticAttributeValue(attributeNode: HTMLAttributeNode | null | undefined): boolean
295
+ export function hasStaticAttributeValue(node: HTMLElementNode | HTMLOpenTagNode | null | undefined, attributeName: string): boolean
296
+ export function hasStaticAttributeValue(nodeOrAttribute: HTMLAttributeNode | HTMLElementNode | HTMLOpenTagNode | null | undefined, attributeName?: string): boolean {
297
+ const attributeNode = attributeName
298
+ ? getAttribute(nodeOrAttribute as HTMLElementNode | HTMLOpenTagNode, attributeName)
299
+ : nodeOrAttribute as HTMLAttributeNode | null | undefined
300
+
301
+ if (!attributeNode?.value?.children) return false
302
+
303
+ return attributeNode.value.children.every(isLiteralNode)
304
+ }
305
+
306
+ /**
307
+ * Gets the static string value of an attribute (returns null if it contains ERB).
308
+ * Accepts an HTMLAttributeNode directly, or an element/open tag + attribute name.
309
+ * Returns null for null/undefined input.
310
+ */
311
+ export function getStaticAttributeValue(attributeNode: HTMLAttributeNode | null | undefined): string | null
312
+ export function getStaticAttributeValue(node: HTMLElementNode | HTMLOpenTagNode | null | undefined, attributeName: string): string | null
313
+ export function getStaticAttributeValue(nodeOrAttribute: HTMLAttributeNode | HTMLElementNode | HTMLOpenTagNode | null | undefined, attributeName?: string): string | null {
314
+ const attributeNode = attributeName
315
+ ? getAttribute(nodeOrAttribute as HTMLElementNode | HTMLOpenTagNode, attributeName)
316
+ : nodeOrAttribute as HTMLAttributeNode | null | undefined
317
+
318
+ if (!attributeNode) return null
319
+ if (!hasStaticAttributeValue(attributeNode)) return null
320
+
321
+ const valueNode = attributeNode.value
322
+ if (!valueNode) return null
323
+
324
+ return filterLiteralNodes(valueNode.children).map(child => child.content).join("") || ""
325
+ }
326
+
327
+ /**
328
+ * Attributes whose values are space-separated token lists.
329
+ */
330
+ export const TOKEN_LIST_ATTRIBUTES = new Set([
331
+ "class", "data-controller", "data-action",
332
+ ])
333
+
334
+ /**
335
+ * Splits a space-separated attribute value into individual tokens.
336
+ * Accepts a string, or an element/open tag + attribute name to look up.
337
+ * Returns an empty array for null/undefined/empty input.
338
+ */
339
+ export function getTokenList(value: string | null | undefined): string[]
340
+ export function getTokenList(node: HTMLElementNode | HTMLOpenTagNode | null | undefined, attributeName: string): string[]
341
+ export function getTokenList(valueOrNode: string | HTMLElementNode | HTMLOpenTagNode | null | undefined, attributeName?: string): string[] {
342
+ const value = attributeName
343
+ ? getStaticAttributeValue(valueOrNode as HTMLElementNode | HTMLOpenTagNode, attributeName)
344
+ : valueOrNode as string | null | undefined
345
+
346
+ if (!value) return []
347
+
348
+ return value.trim().split(/\s+/).filter(token => token.length > 0)
349
+ }
350
+
351
+ /**
352
+ * Finds an attribute by name in a list of attribute nodes
353
+ */
354
+ export function findAttributeByName(attributes: Node[], attributeName: string): HTMLAttributeNode | null {
355
+ for (const attribute of filterHTMLAttributeNodes(attributes)) {
356
+ const name = getAttributeName(attribute)
357
+
358
+ if (name === attributeName.toLowerCase()) {
359
+ return attribute
360
+ }
361
+ }
362
+
363
+ return null
364
+ }
365
+
366
+ /**
367
+ * Gets a specific attribute from an HTMLElementNode or HTMLOpenTagNode by name
368
+ */
369
+ export function getAttribute(node: HTMLElementNode | HTMLOpenTagNode | null | undefined, attributeName: string): HTMLAttributeNode | null {
370
+ const attributes = getAttributes(node)
371
+
372
+ return findAttributeByName(attributes, attributeName)
373
+ }
374
+
375
+ /**
376
+ * Checks if an element or open tag has a specific attribute
377
+ */
378
+ export function hasAttribute(node: HTMLElementNode | HTMLOpenTagNode | null | undefined, attributeName: string): boolean {
379
+ if (!node) return false
380
+
381
+ return getAttribute(node, attributeName) !== null
382
+ }
383
+
384
+ /**
385
+ * Checks if an attribute has a dynamic (ERB-containing) name.
386
+ * Accepts an HTMLAttributeNode (wraps the core HTMLAttributeNameNode-level check).
387
+ */
388
+ export function hasDynamicAttributeNameOnAttribute(attributeNode: HTMLAttributeNode): boolean {
389
+ if (!isHTMLAttributeNameNode(attributeNode.name)) return false
390
+
391
+ return hasDynamicAttributeName(attributeNode.name)
392
+ }
393
+
394
+ /**
395
+ * Gets the combined string representation of an attribute name (including ERB syntax).
396
+ * Accepts an HTMLAttributeNode (wraps the core HTMLAttributeNameNode-level check).
397
+ */
398
+ export function getCombinedAttributeNameString(attributeNode: HTMLAttributeNode): string {
399
+ if (!isHTMLAttributeNameNode(attributeNode.name)) return ""
400
+
401
+ return getCombinedAttributeName(attributeNode.name)
402
+ }
403
+
404
+ /**
405
+ * Checks if an attribute value contains dynamic content (ERB)
406
+ */
407
+ export function hasDynamicAttributeValue(attributeNode: HTMLAttributeNode): boolean {
408
+ if (!attributeNode.value?.children) return false
409
+
410
+ return attributeNode.value.children.some(isERBContentNode)
411
+ }
412
+
413
+ /**
414
+ * Gets the value nodes array from an attribute for dynamic inspection
415
+ */
416
+ export function getAttributeValueNodes(attributeNode: HTMLAttributeNode): Node[] {
417
+ return attributeNode.value?.children || []
418
+ }
419
+
420
+ /**
421
+ * Checks if an attribute value contains any static content (for validation purposes)
422
+ */
423
+ export function hasStaticAttributeValueContent(attributeNode: HTMLAttributeNode): boolean {
424
+ return hasStaticContent(getAttributeValueNodes(attributeNode))
425
+ }
426
+
427
+ /**
428
+ * Gets the static content of an attribute value (all literal parts combined).
429
+ * Unlike getStaticAttributeValue, this extracts only the static portions from mixed content.
430
+ * Returns the concatenated literal content, or null if no literal nodes exist.
431
+ */
432
+ export function getStaticAttributeValueContent(attributeNode: HTMLAttributeNode): string | null {
433
+ return getStaticContentFromNodes(getAttributeValueNodes(attributeNode))
434
+ }
435
+
436
+ /**
437
+ * Gets the combined attribute value including both static text and ERB tag syntax.
438
+ * For ERB nodes, includes the full tag syntax (e.g., "<%= foo %>").
439
+ * Returns null if the attribute has no value.
440
+ */
441
+ export function getAttributeValue(attributeNode: HTMLAttributeNode): string | null {
442
+ const valueNode = attributeNode.value
443
+ if (!valueNode) return null
444
+
445
+ if (valueNode.type !== "AST_HTML_ATTRIBUTE_VALUE_NODE" || !valueNode.children?.length) {
446
+ return null
447
+ }
448
+
449
+ let result = ""
450
+
451
+ for (const child of valueNode.children) {
452
+ if (isERBContentNode(child)) {
453
+ if (child.content) {
454
+ result += `${child.tag_opening?.value}${child.content.value}${child.tag_closing?.value}`
455
+ }
456
+ } else if (isLiteralNode(child)) {
457
+ result += child.content
458
+ }
459
+ }
460
+
461
+ return result
462
+ }
463
+
464
+ /**
465
+ * Checks if an attribute has a value node
466
+ */
467
+ export function hasAttributeValue(attributeNode: HTMLAttributeNode): boolean {
468
+ return isHTMLAttributeValueNode(attributeNode.value)
469
+ }
470
+
471
+ /**
472
+ * Gets the quote type used for an attribute value
473
+ */
474
+ export function getAttributeValueQuoteType(node: HTMLAttributeNode | HTMLAttributeValueNode): "single" | "double" | "none" | null {
475
+ const valueNode = isHTMLAttributeValueNode(node) ? node : node.value
476
+ if (!valueNode) return null
477
+
478
+ if (valueNode.quoted && valueNode.open_quote) {
479
+ return valueNode.open_quote.value === '"' ? "double" : "single"
480
+ }
481
+
482
+ return "none"
483
+ }
484
+
485
+ /**
486
+ * Checks if an attribute value is quoted
487
+ */
488
+ export function isAttributeValueQuoted(attributeNode: HTMLAttributeNode): boolean {
489
+ if (!isHTMLAttributeValueNode(attributeNode.value)) return false
490
+
491
+ return !!attributeNode.value.quoted
492
+ }
493
+
494
+ /**
495
+ * Iterates over all attributes of an element or open tag node
496
+ */
497
+ export function forEachAttribute(node: HTMLElementNode | HTMLOpenTagNode, callback: (attributeNode: HTMLAttributeNode) => void): void {
498
+ for (const attribute of getAttributes(node)) {
499
+ callback(attribute)
500
+ }
501
+ }
502
+
503
+ // --- Class Name Grouping Utilities ---
504
+
505
+ /**
506
+ * Checks if a node is a whitespace-only literal or text node (no visible content)
507
+ */
508
+ export function isPureWhitespaceNode(node: Node): boolean {
509
+ if (isWhitespaceNode(node)) return true
510
+ if (isLiteralNode(node)) return !node.content.trim()
511
+ if (isHTMLTextNode(node)) return !(node.content ?? "").trim()
512
+
513
+ return false
514
+ }
515
+
516
+ /**
517
+ * Splits literal nodes at whitespace boundaries into separate nodes.
518
+ * Non-literal nodes are passed through unchanged.
519
+ *
520
+ * For example, a literal `"bg-blue-500 text-white"` becomes two literals:
521
+ * `"bg-blue-500"` and `" "` and `"text-white"`.
522
+ */
523
+ export function splitLiteralsAtWhitespace(nodes: Node[]): Node[] {
524
+ const result: Node[] = []
525
+
526
+ for (const node of nodes) {
527
+ if (isLiteralNode(node)) {
528
+ const parts = node.content.match(/(\S+|\s+)/g) || []
529
+
530
+ for (const part of parts) {
531
+ result.push(new LiteralNode({
532
+ type: "AST_LITERAL_NODE",
533
+ content: part,
534
+ errors: [],
535
+ location: node.location
536
+ }))
537
+ }
538
+ } else {
539
+ result.push(node)
540
+ }
541
+ }
542
+
543
+ return result
544
+ }
545
+
546
+ /**
547
+ * Groups split nodes into "class groups" where each group represents a single
548
+ * class name (possibly spanning multiple nodes when ERB is interpolated).
549
+ *
550
+ * For example, `text-<%= color %>-500 bg-blue-500` produces two groups:
551
+ * - [`text-`, ERB, `-500`] (interpolated class)
552
+ * - [`bg-blue-500`] (static class)
553
+ *
554
+ * The key heuristic: a hyphen at a node boundary means the nodes are part of
555
+ * the same class name (e.g., `bg-` + ERB + `-500`), while whitespace means
556
+ * a new class name starts.
557
+ */
558
+ export function groupNodesByClass(nodes: Node[]): Node[][] {
559
+ if (nodes.length === 0) return []
560
+
561
+ const groups: Node[][] = []
562
+ let currentGroup: Node[] = []
563
+
564
+ for (let i = 0; i < nodes.length; i++) {
565
+ const node = nodes[i]
566
+ const previousNode = i > 0 ? nodes[i - 1] : null
567
+
568
+ let startNewGroup = false
569
+
570
+ if (currentGroup.length === 0) {
571
+ startNewGroup = false
572
+ } else if (isLiteralNode(node)) {
573
+ if (/^\s/.test(node.content)) {
574
+ startNewGroup = true
575
+ } else if (/^-/.test(node.content)) {
576
+ startNewGroup = false
577
+ } else if (previousNode && !isLiteralNode(previousNode)) {
578
+ startNewGroup = true
579
+ } else if (currentGroup.every(member => isPureWhitespaceNode(member))) {
580
+ startNewGroup = true
581
+ }
582
+ } else {
583
+ if (previousNode && isLiteralNode(previousNode)) {
584
+ if (/\s$/.test(previousNode.content)) {
585
+ startNewGroup = true
586
+ } else if (/-$/.test(previousNode.content)) {
587
+ startNewGroup = false
588
+ } else {
589
+ startNewGroup = true
590
+ }
591
+ } else if (previousNode && !isLiteralNode(previousNode)) {
592
+ startNewGroup = false
593
+ }
594
+ }
595
+
596
+ if (startNewGroup && currentGroup.length > 0) {
597
+ groups.push(currentGroup)
598
+ currentGroup = []
599
+ }
600
+
601
+ currentGroup.push(node)
602
+ }
603
+
604
+ if (currentGroup.length > 0) {
605
+ groups.push(currentGroup)
606
+ }
607
+
608
+ return groups
609
+ }
610
+
611
+ // --- Position Utilities ---
612
+
224
613
  /**
225
614
  * Compares two positions to determine if the first comes before the second
226
615
  * Returns true if pos1 comes before pos2 in source order
@@ -316,3 +705,171 @@ export function getNodesAfterPosition<T extends Node>(nodes: T[], position: Posi
316
705
  node.location && isPositionAfter(node.location.start, position, inclusive)
317
706
  )
318
707
  }
708
+
709
+ /**
710
+ * Checks if two attributes are structurally equivalent (same name and value),
711
+ * ignoring positional data like location and range.
712
+ */
713
+ export function isEquivalentAttribute(first: HTMLAttributeNode, second: HTMLAttributeNode): boolean {
714
+ const firstName = getAttributeName(first)
715
+ const secondName = getAttributeName(second)
716
+
717
+ if (firstName !== secondName) return false
718
+
719
+ if (firstName && TOKEN_LIST_ATTRIBUTES.has(firstName)) {
720
+ const firstTokens = getTokenList(getAttributeValue(first))
721
+ const secondTokens = getTokenList(getAttributeValue(second))
722
+
723
+ if (firstTokens.length !== secondTokens.length) return false
724
+
725
+ const sortedFirst = [...firstTokens].sort()
726
+ const sortedSecond = [...secondTokens].sort()
727
+
728
+ return sortedFirst.every((token, index) => token === sortedSecond[index])
729
+ }
730
+
731
+ return getAttributeValue(first) === getAttributeValue(second)
732
+ }
733
+
734
+ /**
735
+ * Checks if two open tags are structurally equivalent (same tag name and attributes),
736
+ * ignoring positional data like location and range.
737
+ */
738
+ export function isEquivalentOpenTag(first: HTMLOpenTagNode, second: HTMLOpenTagNode): boolean {
739
+ if (first.tag_name?.value !== second.tag_name?.value) return false
740
+
741
+ const firstAttributes = getAttributes(first)
742
+ const secondAttributes = getAttributes(second)
743
+
744
+ if (firstAttributes.length !== secondAttributes.length) return false
745
+
746
+ return firstAttributes.every((attribute) =>
747
+ secondAttributes.some((other) => isEquivalentAttribute(attribute, other))
748
+ )
749
+ }
750
+
751
+ /**
752
+ * Checks if two elements have structurally equivalent open tags (same tag name and attributes),
753
+ * ignoring positional data like location and range. Does not compare body or close tag.
754
+ */
755
+ export function isEquivalentElement(first: HTMLElementNode, second: HTMLElementNode): boolean {
756
+ if (!isHTMLOpenTagNode(first.open_tag) || !isHTMLOpenTagNode(second.open_tag)) return false
757
+
758
+ return isEquivalentOpenTag(first.open_tag, second.open_tag)
759
+ }
760
+
761
+ // --- AST Mutation Utilities ---
762
+
763
+ const CHILD_ARRAY_PROPS = ["children", "body", "statements", "conditions"]
764
+ const LINKED_NODE_PROPS = ["subsequent", "else_clause"]
765
+
766
+ /**
767
+ * Finds the array containing a target node in the AST, along with its index.
768
+ * Traverses child arrays and linked node properties (e.g., `subsequent`, `else_clause`).
769
+ *
770
+ * Useful for autofix operations that need to splice nodes in/out of their parent array.
771
+ *
772
+ * @param root - The root node to search from
773
+ * @param target - The node to find
774
+ * @returns The containing array and the target's index, or null if not found
775
+ */
776
+ export function findParentArray(root: Node, target: Node): { array: Node[], index: number } | null {
777
+ const search = (node: Node): { array: Node[], index: number } | null => {
778
+ const record = node as Record<string, any>
779
+
780
+ for (const prop of CHILD_ARRAY_PROPS) {
781
+ const array = record[prop]
782
+
783
+ if (Array.isArray(array)) {
784
+ const index = array.indexOf(target)
785
+
786
+ if (index !== -1) {
787
+ return { array, index }
788
+ }
789
+ }
790
+ }
791
+
792
+ for (const prop of CHILD_ARRAY_PROPS) {
793
+ const array = record[prop]
794
+
795
+ if (Array.isArray(array)) {
796
+ for (const child of array) {
797
+ if (child && typeof child === 'object' && 'type' in child) {
798
+ const result = search(child)
799
+
800
+ if (result) {
801
+ return result
802
+ }
803
+ }
804
+ }
805
+ }
806
+ }
807
+
808
+ for (const prop of LINKED_NODE_PROPS) {
809
+ const value = record[prop]
810
+
811
+ if (value && typeof value === 'object' && 'type' in value) {
812
+ const result = search(value)
813
+
814
+ if (result) {
815
+ return result
816
+ }
817
+ }
818
+ }
819
+
820
+ return null
821
+ }
822
+
823
+ return search(root)
824
+ }
825
+
826
+ /**
827
+ * Removes a node from an array, also removing an adjacent preceding
828
+ * whitespace-only literal if present.
829
+ */
830
+ export function removeNodeFromArray(array: Node[], node: Node): void {
831
+ const index = array.indexOf(node)
832
+ if (index === -1) return
833
+
834
+ if (index > 0 && isPureWhitespaceNode(array[index - 1])) {
835
+ array.splice(index - 1, 2)
836
+ } else {
837
+ array.splice(index, 1)
838
+ }
839
+ }
840
+
841
+ /**
842
+ * Replaces an element in an array with its body (children), effectively unwrapping it.
843
+ */
844
+ export function replaceNodeWithBody(array: Node[], element: HTMLElementNode): void {
845
+ const index = array.indexOf(element)
846
+ if (index === -1) return
847
+
848
+ array.splice(index, 1, ...element.body)
849
+ }
850
+
851
+ /**
852
+ * Creates a synthetic LiteralNode with the given content and zero location.
853
+ * Useful for inserting whitespace or newlines during AST mutations.
854
+ */
855
+ export function createLiteral(content: string): LiteralNode {
856
+ return new LiteralNode({
857
+ type: "AST_LITERAL_NODE",
858
+ content,
859
+ location: Location.zero,
860
+ errors: [],
861
+ })
862
+ }
863
+
864
+ export function createSyntheticToken(value: string, type = "TOKEN_SYNTHETIC"): Token {
865
+ return new Token(value, Range.zero, Location.zero, type)
866
+ }
867
+
868
+ export function createWhitespaceNode(): WhitespaceNode {
869
+ return new WhitespaceNode({
870
+ type: "AST_WHITESPACE_NODE",
871
+ location: Location.zero,
872
+ errors: [],
873
+ value: createSyntheticToken(" "),
874
+ })
875
+ }
package/src/backend.ts CHANGED
@@ -1,17 +1,18 @@
1
1
  import type { SerializedParseResult } from "./parse-result.js"
2
2
  import type { SerializedLexResult } from "./lex-result.js"
3
- import type { ParserOptions } from "./parser-options.js"
3
+ import type { ParseOptions } from "./parser-options.js"
4
+ import type { ExtractRubyOptions } from "./extract-ruby-options.js"
4
5
 
5
6
  interface LibHerbBackendFunctions {
6
7
  lex: (source: string) => SerializedLexResult
7
- lexFile: (path: string) => SerializedLexResult
8
8
 
9
- parse: (source: string, options?: ParserOptions) => SerializedParseResult
10
- parseFile: (path: string) => SerializedParseResult
9
+ parse: (source: string, options?: ParseOptions) => SerializedParseResult
11
10
 
12
- extractRuby: (source: string) => string
11
+ extractRuby: (source: string, options?: ExtractRubyOptions) => string
13
12
  extractHTML: (source: string) => string
14
13
 
14
+ parseRuby: (source: string) => Uint8Array | null
15
+
15
16
  version: () => string
16
17
  }
17
18
 
@@ -20,10 +21,9 @@ export type BackendPromise = () => Promise<LibHerbBackend>
20
21
  const expectedFunctions = [
21
22
  "parse",
22
23
  "lex",
23
- "parseFile",
24
- "lexFile",
25
24
  "extractRuby",
26
25
  "extractHTML",
26
+ "parseRuby",
27
27
  "version",
28
28
  ] as const
29
29