@herb-tools/rewriter 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.
@@ -0,0 +1,8 @@
1
+ import { ASTRewriter } from "../ast-rewriter.js";
2
+ import type { RewriteContext } from "../context.js";
3
+ import type { Node } from "@herb-tools/core";
4
+ export declare class ActionViewTagHelperToHTMLRewriter extends ASTRewriter {
5
+ get name(): string;
6
+ get description(): string;
7
+ rewrite<T extends Node>(node: T, _context: RewriteContext): T;
8
+ }
@@ -0,0 +1,8 @@
1
+ import { ASTRewriter } from "../ast-rewriter.js";
2
+ import type { RewriteContext } from "../context.js";
3
+ import type { Node } from "@herb-tools/core";
4
+ export declare class HTMLToActionViewTagHelperRewriter extends ASTRewriter {
5
+ get name(): string;
6
+ get description(): string;
7
+ rewrite<T extends Node>(node: T, _context: RewriteContext): T;
8
+ }
@@ -1,5 +1,4 @@
1
1
  import type { RewriterClass } from "../type-guards.js";
2
- export { TailwindClassSorterRewriter } from "./tailwind-class-sorter.js";
3
2
  /**
4
3
  * All built-in rewriters available in the package
5
4
  */
@@ -12,3 +11,6 @@ export declare function getBuiltinRewriter(name: string): RewriterClass | undefi
12
11
  * Get all built-in rewriter names
13
12
  */
14
13
  export declare function getBuiltinRewriterNames(): string[];
14
+ export { ActionViewTagHelperToHTMLRewriter } from "./action-view-tag-helper-to-html.js";
15
+ export { HTMLToActionViewTagHelperRewriter } from "./html-to-action-view-tag-helper.js";
16
+ export { TailwindClassSorterRewriter } from "./tailwind-class-sorter.js";
@@ -1,4 +1,6 @@
1
1
  export { ASTRewriter } from "./ast-rewriter.js";
2
+ export { ActionViewTagHelperToHTMLRewriter } from "./built-ins/action-view-tag-helper-to-html.js";
3
+ export { HTMLToActionViewTagHelperRewriter } from "./built-ins/html-to-action-view-tag-helper.js";
2
4
  export { StringRewriter } from "./string-rewriter.js";
3
5
  export { asMutable } from "./mutable.js";
4
6
  export { isASTRewriterClass, isStringRewriterClass, isRewriterClass } from "./type-guards.js";
@@ -1,4 +1,6 @@
1
1
  import { TailwindClassSorterRewriter } from "./built-ins/tailwind-class-sorter.js";
2
+ import { ActionViewTagHelperToHTMLRewriter } from "./built-ins/action-view-tag-helper-to-html.js";
3
+ import { HTMLToActionViewTagHelperRewriter } from "./built-ins/html-to-action-view-tag-helper.js";
2
4
  export interface TailwindClassSorterOptions {
3
5
  /**
4
6
  * Base directory for resolving Tailwind configuration
@@ -26,3 +28,5 @@ export interface TailwindClassSorterOptions {
26
28
  * @returns A configured and initialized TailwindClassSorterRewriter instance
27
29
  */
28
30
  export declare function tailwindClassSorter(options?: TailwindClassSorterOptions): Promise<TailwindClassSorterRewriter>;
31
+ export declare function actionViewTagHelperToHTML(): ActionViewTagHelperToHTMLRewriter;
32
+ export declare function htmlToActionViewTagHelper(): HTMLToActionViewTagHelperRewriter;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@herb-tools/rewriter",
3
- "version": "0.8.10",
3
+ "version": "0.9.0",
4
4
  "description": "Rewriter system for transforming HTML+ERB AST nodes and formatted strings",
5
5
  "license": "MIT",
6
6
  "homepage": "https://herb-tools.dev",
@@ -38,12 +38,12 @@
38
38
  }
39
39
  },
40
40
  "dependencies": {
41
- "@herb-tools/core": "0.8.10",
42
- "@herb-tools/tailwind-class-sorter": "0.8.10",
41
+ "@herb-tools/core": "0.9.0",
42
+ "@herb-tools/tailwind-class-sorter": "0.9.0",
43
43
  "tinyglobby": "^0.2.15"
44
44
  },
45
45
  "devDependencies": {
46
- "@herb-tools/printer": "0.8.10"
46
+ "@herb-tools/printer": "0.9.0"
47
47
  },
48
48
  "files": [
49
49
  "package.json",
@@ -0,0 +1,179 @@
1
+ import { Visitor, Location, HTMLOpenTagNode, HTMLCloseTagNode, HTMLElementNode, HTMLAttributeValueNode, WhitespaceNode, ERBContentNode } from "@herb-tools/core"
2
+ import { isHTMLAttributeNode, isERBOpenTagNode, isRubyLiteralNode, isRubyHTMLAttributesSplatNode, createSyntheticToken } from "@herb-tools/core"
3
+
4
+ import { ASTRewriter } from "../ast-rewriter.js"
5
+ import { asMutable } from "../mutable.js"
6
+
7
+ import type { RewriteContext } from "../context.js"
8
+ import type { Node } from "@herb-tools/core"
9
+
10
+ function createWhitespaceNode(): WhitespaceNode {
11
+ return new WhitespaceNode({
12
+ type: "AST_WHITESPACE_NODE",
13
+ location: Location.zero,
14
+ errors: [],
15
+ value: createSyntheticToken(" "),
16
+ })
17
+ }
18
+
19
+ class ActionViewTagHelperToHTMLVisitor extends Visitor {
20
+ visitHTMLElementNode(node: HTMLElementNode): void {
21
+ if (!node.element_source) {
22
+ this.visitChildNodes(node)
23
+ return
24
+ }
25
+
26
+ const openTag = node.open_tag
27
+
28
+ if (!isERBOpenTagNode(openTag)) {
29
+ this.visitChildNodes(node)
30
+ return
31
+ }
32
+
33
+ const tagName = openTag.tag_name
34
+
35
+ if (!tagName) {
36
+ this.visitChildNodes(node)
37
+ return
38
+ }
39
+
40
+ const htmlChildren: Node[] = []
41
+
42
+ for (const child of openTag.children) {
43
+ if (isRubyHTMLAttributesSplatNode(child)) {
44
+ htmlChildren.push(createWhitespaceNode())
45
+
46
+ htmlChildren.push(new ERBContentNode({
47
+ type: "AST_ERB_CONTENT_NODE",
48
+ location: Location.zero,
49
+ errors: [],
50
+ tag_opening: createSyntheticToken("<%="),
51
+ content: createSyntheticToken(` ${child.content} `),
52
+ tag_closing: createSyntheticToken("%>"),
53
+ parsed: false,
54
+ valid: true,
55
+ prism_node: null,
56
+ }))
57
+
58
+ continue
59
+ }
60
+
61
+ htmlChildren.push(createWhitespaceNode())
62
+
63
+ if (isHTMLAttributeNode(child)) {
64
+ if (child.equals && child.equals.value !== "=") {
65
+ asMutable(child).equals = createSyntheticToken("=")
66
+ }
67
+
68
+ if (child.value) {
69
+ this.transformAttributeValue(child.value)
70
+ }
71
+
72
+ htmlChildren.push(child)
73
+ }
74
+ }
75
+
76
+ const htmlOpenTag = new HTMLOpenTagNode({
77
+ type: "AST_HTML_OPEN_TAG_NODE",
78
+ location: openTag.location,
79
+ errors: [],
80
+ tag_opening: createSyntheticToken("<"),
81
+ tag_name: createSyntheticToken(tagName.value),
82
+ tag_closing: createSyntheticToken(node.is_void ? " />" : ">"),
83
+ children: htmlChildren,
84
+ is_void: node.is_void,
85
+ })
86
+
87
+ asMutable(node).open_tag = htmlOpenTag
88
+
89
+ if (node.is_void) {
90
+ asMutable(node).close_tag = null
91
+ } else if (node.close_tag) {
92
+ const htmlCloseTag = new HTMLCloseTagNode({
93
+ type: "AST_HTML_CLOSE_TAG_NODE",
94
+ location: node.close_tag.location,
95
+ errors: [],
96
+ tag_opening: createSyntheticToken("</"),
97
+ tag_name: createSyntheticToken(tagName.value),
98
+ children: [],
99
+ tag_closing: createSyntheticToken(">"),
100
+ })
101
+
102
+ asMutable(node).close_tag = htmlCloseTag
103
+ }
104
+
105
+ asMutable(node).element_source = "HTML"
106
+
107
+ if (node.body) {
108
+ asMutable(node).body = node.body.map(child => {
109
+ if (isRubyLiteralNode(child)) {
110
+ return new ERBContentNode({
111
+ type: "AST_ERB_CONTENT_NODE",
112
+ location: child.location,
113
+ errors: [],
114
+ tag_opening: createSyntheticToken("<%="),
115
+ content: createSyntheticToken(` ${child.content} `),
116
+ tag_closing: createSyntheticToken("%>"),
117
+ parsed: false,
118
+ valid: true,
119
+ prism_node: null
120
+ })
121
+ }
122
+
123
+ this.visit(child)
124
+ return child
125
+ })
126
+ }
127
+ }
128
+
129
+ private transformAttributeValue(value: HTMLAttributeValueNode): void {
130
+ const mutableValue = asMutable(value)
131
+ const hasRubyLiteral = value.children.some(child => isRubyLiteralNode(child))
132
+
133
+ if (hasRubyLiteral) {
134
+ const newChildren: Node[] = value.children.map(child => {
135
+ if (isRubyLiteralNode(child)) {
136
+ return new ERBContentNode({
137
+ type: "AST_ERB_CONTENT_NODE",
138
+ location: child.location,
139
+ errors: [],
140
+ tag_opening: createSyntheticToken("<%="),
141
+ content: createSyntheticToken(` ${child.content} `),
142
+ tag_closing: createSyntheticToken("%>"),
143
+ parsed: false,
144
+ valid: true,
145
+ prism_node: null,
146
+ })
147
+ }
148
+
149
+ return child
150
+ })
151
+
152
+ mutableValue.children = newChildren
153
+
154
+ if (!value.quoted) {
155
+ mutableValue.quoted = true
156
+ mutableValue.open_quote = createSyntheticToken('"')
157
+ mutableValue.close_quote = createSyntheticToken('"')
158
+ }
159
+ }
160
+ }
161
+ }
162
+
163
+ export class ActionViewTagHelperToHTMLRewriter extends ASTRewriter {
164
+ get name(): string {
165
+ return "action-view-tag-helper-to-html"
166
+ }
167
+
168
+ get description(): string {
169
+ return "Converts ActionView tag helpers (tag.*, content_tag, link_to, turbo_frame_tag) to raw HTML elements"
170
+ }
171
+
172
+ rewrite<T extends Node>(node: T, _context: RewriteContext): T {
173
+ const visitor = new ActionViewTagHelperToHTMLVisitor()
174
+
175
+ visitor.visit(node)
176
+
177
+ return node
178
+ }
179
+ }
@@ -0,0 +1,268 @@
1
+ import { Visitor, Location, ERBOpenTagNode, ERBEndNode, HTMLElementNode, HTMLVirtualCloseTagNode, createSyntheticToken } from "@herb-tools/core"
2
+ import { getStaticAttributeName, isLiteralNode, isHTMLOpenTagNode, isHTMLTextNode, isHTMLAttributeNode, isERBContentNode, isWhitespaceNode } from "@herb-tools/core"
3
+
4
+ import { ASTRewriter } from "../ast-rewriter.js"
5
+ import { asMutable } from "../mutable.js"
6
+
7
+ import type { RewriteContext } from "../context.js"
8
+ import type { Node, HTMLAttributeValueNode } from "@herb-tools/core"
9
+
10
+ function serializeAttributeValue(value: HTMLAttributeValueNode): string {
11
+ const hasERB = value.children.some(child => isERBContentNode(child))
12
+
13
+ if (hasERB && value.children.length === 1 && isERBContentNode(value.children[0])) {
14
+ return value.children[0].content?.value?.trim() ?? '""'
15
+ }
16
+
17
+ const parts: string[] = []
18
+
19
+ for (const child of value.children) {
20
+ if (isLiteralNode(child)) {
21
+ parts.push(child.content)
22
+ } else if (isERBContentNode(child)) {
23
+ parts.push(`#{${child.content?.value?.trim() ?? ""}}`)
24
+ }
25
+ }
26
+
27
+ return `"${parts.join("")}"`
28
+ }
29
+
30
+ function dashToUnderscore(string: string): string {
31
+ return string.replace(/-/g, "_")
32
+ }
33
+
34
+ interface SerializedAttributes {
35
+ attributes: string
36
+ href: string | null
37
+ id: string | null
38
+ }
39
+
40
+ function serializeAttributes(children: Node[], options: { extractHref?: boolean, extractId?: boolean } = {}): SerializedAttributes {
41
+ const regular: string[] = []
42
+ const prefixed: Map<string, string[]> = new Map()
43
+
44
+ let href: string | null = null
45
+ let id: string | null = null
46
+
47
+ for (const child of children) {
48
+ if (!isHTMLAttributeNode(child)) continue
49
+
50
+ const name = getStaticAttributeName(child.name!)
51
+ if (!name) continue
52
+
53
+ const value = child.value ? serializeAttributeValue(child.value) : "true"
54
+
55
+ if (options.extractHref && name === "href") {
56
+ href = value
57
+ continue
58
+ }
59
+
60
+ if (options.extractId && name === "id") {
61
+ id = value
62
+ continue
63
+ }
64
+
65
+ const dataMatch = name.match(/^(data|aria)-(.+)$/)
66
+
67
+ if (dataMatch) {
68
+ const [, prefix, rest] = dataMatch
69
+
70
+ if (!prefixed.has(prefix)) {
71
+ prefixed.set(prefix, [])
72
+ }
73
+
74
+ prefixed.get(prefix)!.push(`${dashToUnderscore(rest)}: ${value}`)
75
+ } else {
76
+ regular.push(`${name}: ${value}`)
77
+ }
78
+ }
79
+
80
+ const parts = [...regular]
81
+
82
+ for (const [prefix, entries] of prefixed) {
83
+ parts.push(`${prefix}: { ${entries.join(", ")} }`)
84
+ }
85
+
86
+ return { attributes: parts.join(", "), href, id }
87
+ }
88
+
89
+ function isTextOnlyBody(body: Node[]): boolean {
90
+ if (body.length !== 1 || !isHTMLTextNode(body[0])) return false
91
+
92
+ return !body[0].content.includes("\n")
93
+ }
94
+
95
+ class HTMLToActionViewTagHelperVisitor extends Visitor {
96
+ visitHTMLElementNode(node: HTMLElementNode): void {
97
+ const openTag = node.open_tag
98
+
99
+ if (!isHTMLOpenTagNode(openTag)) {
100
+ this.visitChildNodes(node)
101
+ return
102
+ }
103
+
104
+ const tagName = openTag.tag_name
105
+
106
+ if (!tagName) {
107
+ this.visitChildNodes(node)
108
+ return
109
+ }
110
+
111
+ if (node.body) {
112
+ for (const child of node.body) {
113
+ this.visit(child)
114
+ }
115
+ }
116
+
117
+ const isAnchor = tagName.value === "a"
118
+ const isTurboFrame = tagName.value === "turbo-frame"
119
+ const attributes = openTag.children.filter(child => !isWhitespaceNode(child))
120
+ const { attributes: attributesString, href, id } = serializeAttributes(attributes, { extractHref: isAnchor, extractId: isTurboFrame })
121
+ const hasBody = node.body && node.body.length > 0 && !node.is_void
122
+ const isInlineContent = hasBody && isTextOnlyBody(node.body)
123
+
124
+ let content: string
125
+ let elementSource: string
126
+
127
+ if (isAnchor) {
128
+ content = this.buildLinkToContent(node, attributesString, href, isInlineContent)
129
+ elementSource = "ActionView::Helpers::UrlHelper#link_to"
130
+ } else if (isTurboFrame) {
131
+ content = this.buildTurboFrameTagContent(node, attributesString, id, isInlineContent)
132
+ elementSource = "Turbo::FramesHelper#turbo_frame_tag"
133
+ } else {
134
+ content = this.buildTagContent(tagName.value, node, attributesString, isInlineContent)
135
+ elementSource = "ActionView::Helpers::TagHelper#tag"
136
+ }
137
+
138
+ const erbOpenTag = new ERBOpenTagNode({
139
+ type: "AST_ERB_OPEN_TAG_NODE",
140
+ location: openTag.location,
141
+ errors: [],
142
+ tag_opening: createSyntheticToken("<%="),
143
+ content: createSyntheticToken(content),
144
+ tag_closing: createSyntheticToken("%>"),
145
+ tag_name: createSyntheticToken(tagName.value),
146
+ children: [],
147
+ })
148
+
149
+ asMutable(node).open_tag = erbOpenTag
150
+ asMutable(node).element_source = elementSource
151
+
152
+ const isInlineForm = isInlineContent || (isTurboFrame && !hasBody)
153
+
154
+ if (node.is_void) {
155
+ asMutable(node).close_tag = null
156
+ } else if (isInlineForm) {
157
+ asMutable(node).body = []
158
+
159
+ const virtualClose = new HTMLVirtualCloseTagNode({
160
+ type: "AST_HTML_VIRTUAL_CLOSE_TAG_NODE",
161
+ location: Location.zero,
162
+ errors: [],
163
+ tag_name: createSyntheticToken(tagName.value),
164
+ })
165
+
166
+ asMutable(node).close_tag = virtualClose
167
+ } else if (node.close_tag) {
168
+ const erbEnd = new ERBEndNode({
169
+ type: "AST_ERB_END_NODE",
170
+ location: node.close_tag.location,
171
+ errors: [],
172
+ tag_opening: createSyntheticToken("<%"),
173
+ content: createSyntheticToken(" end "),
174
+ tag_closing: createSyntheticToken("%>"),
175
+ })
176
+
177
+ asMutable(node).close_tag = erbEnd
178
+ }
179
+ }
180
+
181
+ private buildTagContent(tag: string, node: HTMLElementNode, attributes: string, isInlineContent: boolean): string {
182
+ const methodName = dashToUnderscore(tag)
183
+
184
+ if (node.is_void) {
185
+ return attributes
186
+ ? ` tag.${methodName} ${attributes} `
187
+ : ` tag.${methodName} `
188
+ }
189
+
190
+ if (isInlineContent && isHTMLTextNode(node.body[0])) {
191
+ const textContent = node.body[0].content
192
+
193
+ return attributes
194
+ ? ` tag.${methodName} "${textContent}", ${attributes} `
195
+ : ` tag.${methodName} "${textContent}" `
196
+ }
197
+
198
+ return attributes
199
+ ? ` tag.${methodName} ${attributes} do `
200
+ : ` tag.${methodName} do `
201
+ }
202
+
203
+ private buildTurboFrameTagContent(node: HTMLElementNode, attributes: string, id: string | null, isInlineContent: boolean): string {
204
+ const args: string[] = []
205
+
206
+ if (id) {
207
+ args.push(id)
208
+ }
209
+
210
+ if (isInlineContent && isHTMLTextNode(node.body[0])) {
211
+ args.push(`"${node.body[0].content}"`)
212
+ }
213
+
214
+ if (attributes) {
215
+ args.push(attributes)
216
+ }
217
+
218
+ const argString = args.join(", ")
219
+
220
+ if (isInlineContent || !node.body || node.body.length === 0) {
221
+ return argString ? ` turbo_frame_tag ${argString} ` : ` turbo_frame_tag `
222
+ }
223
+
224
+ return argString ? ` turbo_frame_tag ${argString} do ` : ` turbo_frame_tag do `
225
+ }
226
+
227
+ private buildLinkToContent(node: HTMLElementNode, attribute: string, href: string | null, isInlineContent: boolean): string {
228
+ const args: string[] = []
229
+
230
+ if (isInlineContent && isHTMLTextNode(node.body[0])) {
231
+ args.push(`"${node.body[0].content}"`)
232
+ }
233
+
234
+ if (href) {
235
+ args.push(href)
236
+ }
237
+
238
+ if (attribute) {
239
+ args.push(attribute)
240
+ }
241
+
242
+ const argString = args.join(", ")
243
+
244
+ if (isInlineContent) {
245
+ return argString ? ` link_to ${argString} ` : ` link_to `
246
+ }
247
+
248
+ return argString ? ` link_to ${argString} do ` : ` link_to do `
249
+ }
250
+ }
251
+
252
+ export class HTMLToActionViewTagHelperRewriter extends ASTRewriter {
253
+ get name(): string {
254
+ return "html-to-action-view-tag-helper"
255
+ }
256
+
257
+ get description(): string {
258
+ return "Converts raw HTML elements to ActionView tag helpers (tag.*, turbo_frame_tag)"
259
+ }
260
+
261
+ rewrite<T extends Node>(node: T, _context: RewriteContext): T {
262
+ const visitor = new HTMLToActionViewTagHelperVisitor()
263
+
264
+ visitor.visit(node)
265
+
266
+ return node
267
+ }
268
+ }
@@ -1,12 +1,15 @@
1
- import type { RewriterClass } from "../type-guards.js"
2
-
3
- export { TailwindClassSorterRewriter } from "./tailwind-class-sorter.js"
1
+ import { ActionViewTagHelperToHTMLRewriter } from "./action-view-tag-helper-to-html.js"
2
+ import { HTMLToActionViewTagHelperRewriter } from "./html-to-action-view-tag-helper.js"
4
3
  import { TailwindClassSorterRewriter } from "./tailwind-class-sorter.js"
5
4
 
5
+ import type { RewriterClass } from "../type-guards.js"
6
+
6
7
  /**
7
8
  * All built-in rewriters available in the package
8
9
  */
9
10
  export const builtinRewriters: RewriterClass[] = [
11
+ ActionViewTagHelperToHTMLRewriter,
12
+ HTMLToActionViewTagHelperRewriter,
10
13
  TailwindClassSorterRewriter
11
14
  ]
12
15
 
@@ -31,3 +34,7 @@ export function getBuiltinRewriterNames(): string[] {
31
34
  return instance.name
32
35
  })
33
36
  }
37
+
38
+ export { ActionViewTagHelperToHTMLRewriter } from "./action-view-tag-helper-to-html.js"
39
+ export { HTMLToActionViewTagHelperRewriter } from "./html-to-action-view-tag-helper.js"
40
+ export { TailwindClassSorterRewriter } from "./tailwind-class-sorter.js"