@herb-tools/core 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.
- package/dist/herb-core.browser.js +21991 -130
- package/dist/herb-core.browser.js.map +1 -1
- package/dist/herb-core.cjs +22065 -131
- package/dist/herb-core.cjs.map +1 -1
- package/dist/herb-core.esm.js +21991 -130
- package/dist/herb-core.esm.js.map +1 -1
- package/dist/herb-core.umd.js +22065 -131
- package/dist/herb-core.umd.js.map +1 -1
- package/dist/types/ast-utils.d.ts +185 -3
- package/dist/types/backend.d.ts +6 -6
- package/dist/types/errors.d.ts +250 -25
- package/dist/types/extract-ruby-options.d.ts +6 -0
- package/dist/types/herb-backend.d.ts +15 -7
- package/dist/types/index.d.ts +2 -0
- package/dist/types/node-type-guards.d.ts +95 -32
- package/dist/types/nodes.d.ts +354 -49
- package/dist/types/parse-result.d.ts +7 -1
- package/dist/types/parser-options.d.ts +30 -2
- package/dist/types/prism/index.d.ts +28 -0
- package/dist/types/prism/inspect.d.ts +3 -0
- package/dist/types/util.d.ts +0 -1
- package/dist/types/visitor.d.ts +15 -1
- package/package.json +4 -1
- package/src/ast-utils.ts +564 -7
- package/src/backend.ts +7 -7
- package/src/errors.ts +773 -76
- package/src/extract-ruby-options.ts +11 -0
- package/src/herb-backend.ts +30 -15
- package/src/index.ts +2 -0
- package/src/node-type-guards.ts +240 -33
- package/src/nodes.ts +1081 -192
- package/src/parse-result.ts +11 -0
- package/src/parser-options.ts +56 -2
- package/src/prism/index.ts +44 -0
- package/src/prism/inspect.ts +118 -0
- package/src/util.ts +0 -12
- package/src/visitor.ts +51 -1
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
|
-
|
|
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
|
|
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 === "<%#" ||
|
|
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
|
-
|
|
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 {
|
|
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?:
|
|
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
|
|