Skip to content

Commit 2ec7dc8

Browse files
authored
Rewriter: Implement Action View Tag Helpers <-> HTML rewriters (#1348)
This pull request implements two new built-in rewriters `action-view-tag-helper-to-html` and `html-to-action-view-tag-helper` using the infrastructure implemented in #1347. This allows us to rewrite an Action View Tag Helper like: ```html+erb <%= tag.div class: classes, data: { controller: "hello" } do %> Content <% end %> ``` to HTML: ```html+erb <div class="<%= classes %>" data-controller="hello"> Content </div> ``` and back!
1 parent 423e556 commit 2ec7dc8

File tree

12 files changed

+925
-27
lines changed

12 files changed

+925
-27
lines changed

config.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -453,6 +453,7 @@ nodes:
453453
- HTMLCloseTagNode
454454
- HTMLOmittedCloseTagNode
455455
- HTMLVirtualCloseTagNode
456+
- ERBEndNode
456457
457458
- name: is_void
458459
type: boolean

javascript/packages/core/src/ast-utils.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ import {
1717
HTMLAttributeNode,
1818
HTMLAttributeNameNode,
1919
HTMLAttributeValueNode,
20-
HTMLCommentNode
20+
HTMLCommentNode,
21+
WhitespaceNode
2122
} from "./nodes.js"
2223

2324
import {
@@ -38,6 +39,9 @@ import {
3839
} from "./node-type-guards.js"
3940

4041
import { Location } from "./location.js"
42+
import { Range } from "./range.js"
43+
import { Token } from "./token.js"
44+
4145
import type { Position } from "./position.js"
4246

4347
export type ERBOutputNode = ERBNode & {
@@ -856,3 +860,16 @@ export function createLiteral(content: string): LiteralNode {
856860
errors: [],
857861
})
858862
}
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+
}

javascript/packages/language-server/src/comment_ast_utils.ts

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { ParserService } from "./parser_service"
22
import { LineContextCollector } from "./line_context_collector"
33

4-
import { HTMLCommentNode, Token, Location, Range } from "@herb-tools/core"
4+
import { HTMLCommentNode, Location, createSyntheticToken } from "@herb-tools/core"
55
import { IdentityPrinter } from "@herb-tools/printer"
66

77
import { isERBCommentNode, isLiteralNode, createLiteral } from "@herb-tools/core"
@@ -25,19 +25,15 @@ interface LineSegment {
2525
node?: ERBContentNode
2626
}
2727

28-
export function createSyntheticToken(type: string, value: string): Token {
29-
return new Token(value, Range.zero, Location.zero, type)
30-
}
31-
3228
export function commentERBNode(node: ERBContentNode): void {
3329
const mutable = asMutable(node)
3430

3531
if (mutable.tag_opening) {
3632
const currentValue = mutable.tag_opening.value
3733

3834
mutable.tag_opening = createSyntheticToken(
39-
mutable.tag_opening.type,
40-
currentValue.substring(0, 2) + "#" + currentValue.substring(2)
35+
currentValue.substring(0, 2) + "#" + currentValue.substring(2),
36+
mutable.tag_opening.type
4137
)
4238
}
4339
}
@@ -56,10 +52,10 @@ export function uncommentERBNode(node: ERBContentNode): void {
5652
contentValue.startsWith(" = ") ||
5753
contentValue.startsWith(" - ")
5854
) {
59-
mutable.tag_opening = createSyntheticToken(mutable.tag_opening.type, "<%")
60-
mutable.content = createSyntheticToken(mutable.content!.type, contentValue.substring(1))
55+
mutable.tag_opening = createSyntheticToken("<%", mutable.tag_opening.type)
56+
mutable.content = createSyntheticToken(contentValue.substring(1), mutable.content!.type)
6157
} else {
62-
mutable.tag_opening = createSyntheticToken(mutable.tag_opening.type, "<%")
58+
mutable.tag_opening = createSyntheticToken("<%", mutable.tag_opening.type)
6359
}
6460
}
6561
}
@@ -158,9 +154,9 @@ export function commentLineContent(content: string, erbNodes: ERBContentNode[],
158154
type: "AST_HTML_COMMENT_NODE",
159155
location: Location.zero,
160156
errors: [],
161-
comment_start: createSyntheticToken("TOKEN_HTML_COMMENT_START", "<!--"),
157+
comment_start: createSyntheticToken("<!--", "TOKEN_HTML_COMMENT_START"),
162158
children: [createLiteral(" "), ...(children.slice() as Node[]), createLiteral(" ")],
163-
comment_end: createSyntheticToken("TOKEN_HTML_COMMENT_END", "-->"),
159+
comment_end: createSyntheticToken("-->", "TOKEN_HTML_COMMENT_END"),
164160
})
165161

166162
children.length = 0
@@ -173,9 +169,9 @@ export function commentLineContent(content: string, erbNodes: ERBContentNode[],
173169
type: "AST_HTML_COMMENT_NODE",
174170
location: Location.zero,
175171
errors: [],
176-
comment_start: createSyntheticToken("TOKEN_HTML_COMMENT_START", "<!--"),
172+
comment_start: createSyntheticToken("<!--", "TOKEN_HTML_COMMENT_START"),
177173
children: [createLiteral(" "), ...(children.slice() as Node[]), createLiteral(" ")],
178-
comment_end: createSyntheticToken("TOKEN_HTML_COMMENT_END", "-->"),
174+
comment_end: createSyntheticToken("-->", "TOKEN_HTML_COMMENT_END"),
179175
})
180176

181177
children.length = 0

javascript/packages/linter/src/rules/html-no-space-in-tag.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Token, Location, WhitespaceNode } from "@herb-tools/core"
1+
import { Token, WhitespaceNode, createWhitespaceNode } from "@herb-tools/core"
22
import { ParserRule, BaseAutofixContext } from "../types.js"
33

44
import { findParent, BaseRuleVisitor } from "./rule-utils.js"
@@ -168,10 +168,7 @@ export class HTMLNoSpaceInTagRule extends ParserRule<HTMLNoSpaceInTagAutofixCont
168168
if (!node) return null
169169

170170
if (isHTMLOpenTagNode(node)) {
171-
const token = Token.from({ type: "TOKEN_WHITESPACE", value: " ", range: [0, 0], location: Location.zero })
172-
const whitespace = new WhitespaceNode({ type: "AST_WHITESPACE_NODE", value: token, location: Location.zero, errors: [] })
173-
174-
node.children.push(whitespace)
171+
node.children.push(createWhitespaceNode())
175172

176173
return result
177174
}
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
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+
}))
56+
57+
continue
58+
}
59+
60+
htmlChildren.push(createWhitespaceNode())
61+
62+
if (isHTMLAttributeNode(child)) {
63+
if (child.equals && child.equals.value !== "=") {
64+
asMutable(child).equals = createSyntheticToken("=")
65+
}
66+
67+
if (child.value) {
68+
this.transformAttributeValue(child.value)
69+
}
70+
71+
htmlChildren.push(child)
72+
}
73+
}
74+
75+
const htmlOpenTag = new HTMLOpenTagNode({
76+
type: "AST_HTML_OPEN_TAG_NODE",
77+
location: openTag.location,
78+
errors: [],
79+
tag_opening: createSyntheticToken("<"),
80+
tag_name: createSyntheticToken(tagName.value),
81+
tag_closing: createSyntheticToken(node.is_void ? " />" : ">"),
82+
children: htmlChildren,
83+
is_void: node.is_void,
84+
})
85+
86+
asMutable(node).open_tag = htmlOpenTag
87+
88+
if (node.is_void) {
89+
asMutable(node).close_tag = null
90+
} else if (node.close_tag) {
91+
const htmlCloseTag = new HTMLCloseTagNode({
92+
type: "AST_HTML_CLOSE_TAG_NODE",
93+
location: node.close_tag.location,
94+
errors: [],
95+
tag_opening: createSyntheticToken("</"),
96+
tag_name: createSyntheticToken(tagName.value),
97+
children: [],
98+
tag_closing: createSyntheticToken(">"),
99+
})
100+
101+
asMutable(node).close_tag = htmlCloseTag
102+
}
103+
104+
asMutable(node).element_source = "HTML"
105+
106+
if (node.body) {
107+
for (const child of node.body) {
108+
this.visit(child)
109+
}
110+
}
111+
}
112+
113+
private transformAttributeValue(value: HTMLAttributeValueNode): void {
114+
const mutableValue = asMutable(value)
115+
const hasRubyLiteral = value.children.some(child => isRubyLiteralNode(child))
116+
117+
if (hasRubyLiteral) {
118+
const newChildren: Node[] = value.children.map(child => {
119+
if (isRubyLiteralNode(child)) {
120+
return new ERBContentNode({
121+
type: "AST_ERB_CONTENT_NODE",
122+
location: child.location,
123+
errors: [],
124+
tag_opening: createSyntheticToken("<%="),
125+
content: createSyntheticToken(` ${child.content} `),
126+
tag_closing: createSyntheticToken("%>"),
127+
parsed: false,
128+
valid: true,
129+
})
130+
}
131+
132+
return child
133+
})
134+
135+
mutableValue.children = newChildren
136+
137+
if (!value.quoted) {
138+
mutableValue.quoted = true
139+
mutableValue.open_quote = createSyntheticToken('"')
140+
mutableValue.close_quote = createSyntheticToken('"')
141+
}
142+
}
143+
}
144+
}
145+
146+
export class ActionViewTagHelperToHTMLRewriter extends ASTRewriter {
147+
get name(): string {
148+
return "action-view-tag-helper-to-html"
149+
}
150+
151+
get description(): string {
152+
return "Converts ActionView tag helpers (tag.*, content_tag, link_to) to raw HTML elements"
153+
}
154+
155+
rewrite<T extends Node>(node: T, _context: RewriteContext): T {
156+
const visitor = new ActionViewTagHelperToHTMLVisitor()
157+
158+
visitor.visit(node)
159+
160+
return node
161+
}
162+
}

0 commit comments

Comments
 (0)