Skip to content

Commit d68a879

Browse files
authored
Parser: Detect javascript_tag and javascript_include_tag helpers (#1374)
This pull request allows the parser to be able to detect and transform the `javascript_tag` and `javascript_include_tag` helpers using the `action_view_helpers` parser option. For example: ```html+erb <%= javascript_tag do %> alert("Hello") <% end %> ``` Gets parsed as: ```js @ DocumentNode (location: (1:0)-(4:0)) └── children: (2 items) ├── @ HTMLElementNode (location: (1:0)-(3:9)) │ ├── open_tag: │ │ └── @ ERBOpenTagNode (location: (1:0)-(1:24)) │ │ ├── tag_opening: "<%=" (location: (1:0)-(1:3)) │ │ ├── content: " javascript_tag do " (location: (1:3)-(1:22)) │ │ ├── tag_closing: "%>" (location: (1:22)-(1:24)) │ │ ├── tag_name: "script" (location: (1:4)-(1:18)) │ │ └── children: [] │ │ │ ├── tag_name: "script" (location: (1:4)-(1:18)) │ ├── body: (1 item) │ │ └── @ LiteralNode (location: (1:24)-(3:0)) │ │ └── content: "\n alert(\"Hello\")\n" │ │ │ ├── close_tag: │ │ └── @ ERBEndNode (location: (3:0)-(3:9)) │ │ ├── tag_opening: "<%" (location: (3:0)-(3:2)) │ │ ├── content: " end " (location: (3:2)-(3:7)) │ │ └── tag_closing: "%>" (location: (3:7)-(3:9)) │ │ │ ├── is_void: false │ └── element_source: "ActionView::Helpers::JavaScriptHelper#javascript_tag" │ └── @ HTMLTextNode (location: (3:9)-(4:0)) └── content: "\n" ``` This pull request also updates the rewriters to be able to rewrite from/to the `javascript_tag` syntax. The above example can be transformed to this and back: ```html+erb <script> alert('Hello') </script> ``` And for `javascript_include_tag`, the following: ```erb <%= javascript_include_tag "common.javascript", "/elsewhere/cools" %> ``` gets transformed to: ```erb <script src="<%= javascript_path("common.javascript") %>"></script> <script src="<%= javascript_path("/elsewhere/cools") %>"></script> ``` Follow up on #1122 Follow up on #1354 Fixes #991
1 parent 7faaca5 commit d68a879

File tree

54 files changed

+2723
-31
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

54 files changed

+2723
-31
lines changed
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// https://html.spec.whatwg.org/multipage/common-microsyntaxes.html#boolean-attributes
2+
export const HTML_BOOLEAN_ATTRIBUTES = new Set([
3+
"allowfullscreen", "async", "autofocus", "autoplay", "checked", "compact",
4+
"controls", "declare", "default", "defer", "disabled", "formnovalidate",
5+
"hidden", "inert", "ismap", "itemscope", "loop", "multiple", "muted",
6+
"nomodule", "nohref", "noresize", "noshade", "novalidate", "nowrap",
7+
"open", "playsinline", "readonly", "required", "reversed", "scoped",
8+
"seamless", "selected", "sortable", "truespeed", "typemustmatch",
9+
])
10+
11+
export function isBooleanAttribute(attributeName: string): boolean {
12+
return HTML_BOOLEAN_ATTRIBUTES.has(attributeName.toLowerCase())
13+
}

javascript/packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from "./ast-utils.js"
2+
export * from "./html-constants.js"
23
export * from "./backend.js"
34
export * from "./diagnostic.js"
45
export * from "./didyoumean.js"

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,12 @@ export const ACTION_VIEW_HELPERS: Record<string, ActionViewHelperInfo> = {
2020
signature: "turbo_frame_tag(*ids, src: nil, target: nil, **attributes, &block)",
2121
documentationURL: "https://www.rubydoc.info/github/hotwired/turbo-rails/Turbo/FramesHelper:turbo_frame_tag",
2222
},
23+
"ActionView::Helpers::JavaScriptHelper#javascript_tag": {
24+
signature: "javascript_tag(content_or_options_with_block = nil, html_options = {}, &block)",
25+
documentationURL: "https://api.rubyonrails.org/classes/ActionView/Helpers/JavaScriptHelper.html#method-i-javascript_tag",
26+
},
27+
"ActionView::Helpers::AssetTagHelper#javascript_include_tag": {
28+
signature: "javascript_include_tag(*sources)",
29+
documentationURL: "https://api.rubyonrails.org/classes/ActionView/Helpers/AssetTagHelper.html#method-i-javascript_include_tag",
30+
},
2331
}

javascript/packages/linter/src/rules/rule-utils.ts

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -188,13 +188,7 @@ export const HTML_VOID_ELEMENTS = new Set([
188188
"param", "source", "track", "wbr",
189189
])
190190

191-
export const HTML_BOOLEAN_ATTRIBUTES = new Set([
192-
"autofocus", "autoplay", "checked", "controls", "defer", "disabled", "hidden",
193-
"loop", "multiple", "muted", "readonly", "required", "reversed", "selected",
194-
"open", "default", "formnovalidate", "novalidate", "itemscope", "scoped",
195-
"seamless", "allowfullscreen", "async", "compact", "declare", "nohref",
196-
"noresize", "noshade", "nowrap", "sortable", "truespeed", "typemustmatch"
197-
])
191+
export { HTML_BOOLEAN_ATTRIBUTES, isBooleanAttribute } from "@herb-tools/core"
198192

199193
export const HEADING_TAGS = new Set(["h1", "h2", "h3", "h4", "h5", "h6"])
200194

@@ -401,13 +395,6 @@ export function isVoidElement(tagName: string): boolean {
401395
return HTML_VOID_ELEMENTS.has(tagName.toLowerCase())
402396
}
403397

404-
/**
405-
* Checks if an attribute is a boolean attribute
406-
*/
407-
export function isBooleanAttribute(attributeName: string): boolean {
408-
return HTML_BOOLEAN_ATTRIBUTES.has(attributeName.toLowerCase())
409-
}
410-
411398
/**
412399
* Attribute visitor that provides granular processing based on both
413400
* attribute name type (static/dynamic) and value type (static/dynamic)

javascript/packages/node/binding.gyp

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
"./extension/libherb/analyze/transform.c",
2626
"./extension/libherb/analyze/action_view/attribute_extraction_helpers.c",
2727
"./extension/libherb/analyze/action_view/content_tag.c",
28+
"./extension/libherb/analyze/action_view/javascript_include_tag.c",
29+
"./extension/libherb/analyze/action_view/javascript_tag.c",
2830
"./extension/libherb/analyze/action_view/link_to.c",
2931
"./extension/libherb/analyze/action_view/registry.c",
3032
"./extension/libherb/analyze/action_view/tag_helper_node_builders.c",

javascript/packages/rewriter/src/built-ins/action-view-tag-helper-to-html.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ export class ActionViewTagHelperToHTMLRewriter extends ASTRewriter {
166166
}
167167

168168
get description(): string {
169-
return "Converts ActionView tag helpers (tag.*, content_tag, link_to, turbo_frame_tag) to raw HTML elements"
169+
return "Converts ActionView tag helpers to raw HTML elements"
170170
}
171171

172172
rewrite<T extends Node>(node: T, _context: RewriteContext): T {

javascript/packages/rewriter/src/built-ins/html-to-action-view-tag-helper.ts

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,14 +35,16 @@ interface SerializedAttributes {
3535
attributes: string
3636
href: string | null
3737
id: string | null
38+
src: string | null
3839
}
3940

40-
function serializeAttributes(children: Node[], options: { extractHref?: boolean, extractId?: boolean } = {}): SerializedAttributes {
41+
function serializeAttributes(children: Node[], options: { extractHref?: boolean, extractId?: boolean, extractSrc?: boolean } = {}): SerializedAttributes {
4142
const regular: string[] = []
4243
const prefixed: Map<string, string[]> = new Map()
4344

4445
let href: string | null = null
4546
let id: string | null = null
47+
let src: string | null = null
4648

4749
for (const child of children) {
4850
if (!isHTMLAttributeNode(child)) continue
@@ -62,6 +64,11 @@ function serializeAttributes(children: Node[], options: { extractHref?: boolean,
6264
continue
6365
}
6466

67+
if (options.extractSrc && name === "src") {
68+
src = value
69+
continue
70+
}
71+
6572
const dataMatch = name.match(/^(data|aria)-(.+)$/)
6673

6774
if (dataMatch) {
@@ -83,7 +90,7 @@ function serializeAttributes(children: Node[], options: { extractHref?: boolean,
8390
parts.push(`${prefix}: { ${entries.join(", ")} }`)
8491
}
8592

86-
return { attributes: parts.join(", "), href, id }
93+
return { attributes: parts.join(", "), href, id, src }
8794
}
8895

8996
function isTextOnlyBody(body: Node[]): boolean {
@@ -116,8 +123,10 @@ class HTMLToActionViewTagHelperVisitor extends Visitor {
116123

117124
const isAnchor = tagName.value === "a"
118125
const isTurboFrame = tagName.value === "turbo-frame"
126+
const isScript = tagName.value === "script"
119127
const attributes = openTag.children.filter(child => !isWhitespaceNode(child))
120-
const { attributes: attributesString, href, id } = serializeAttributes(attributes, { extractHref: isAnchor, extractId: isTurboFrame })
128+
const hasSrcAttribute = isScript && attributes.some(child => isHTMLAttributeNode(child) && getStaticAttributeName(child.name!) === "src")
129+
const { attributes: attributesString, href, id, src } = serializeAttributes(attributes, { extractHref: isAnchor, extractId: isTurboFrame, extractSrc: isScript })
121130
const hasBody = node.body && node.body.length > 0 && !node.is_void
122131
const isInlineContent = hasBody && isTextOnlyBody(node.body)
123132

@@ -130,6 +139,12 @@ class HTMLToActionViewTagHelperVisitor extends Visitor {
130139
} else if (isTurboFrame) {
131140
content = this.buildTurboFrameTagContent(node, attributesString, id, isInlineContent)
132141
elementSource = "Turbo::FramesHelper#turbo_frame_tag"
142+
} else if (isScript && hasSrcAttribute) {
143+
content = this.buildJavascriptIncludeTagContent(attributesString, src)
144+
elementSource = "ActionView::Helpers::AssetTagHelper#javascript_include_tag"
145+
} else if (isScript) {
146+
content = this.buildJavascriptTagContent(node, attributesString, isInlineContent)
147+
elementSource = "ActionView::Helpers::JavaScriptHelper#javascript_tag"
133148
} else {
134149
content = this.buildTagContent(tagName.value, node, attributesString, isInlineContent)
135150
elementSource = "ActionView::Helpers::TagHelper#tag"
@@ -149,7 +164,8 @@ class HTMLToActionViewTagHelperVisitor extends Visitor {
149164
asMutable(node).open_tag = erbOpenTag
150165
asMutable(node).element_source = elementSource
151166

152-
const isInlineForm = isInlineContent || (isTurboFrame && !hasBody)
167+
const isInlineLiteralContent = isScript && hasBody && node.body.length === 1 && isLiteralNode(node.body[0]) && !node.body[0].content.includes("\n")
168+
const isInlineForm = isInlineContent || isInlineLiteralContent || (isTurboFrame && !hasBody) || (isScript && hasSrcAttribute)
153169

154170
if (node.is_void) {
155171
asMutable(node).close_tag = null
@@ -224,6 +240,36 @@ class HTMLToActionViewTagHelperVisitor extends Visitor {
224240
return argString ? ` turbo_frame_tag ${argString} do ` : ` turbo_frame_tag do `
225241
}
226242

243+
private buildJavascriptTagContent(node: HTMLElementNode, attributes: string, isInlineContent: boolean): string {
244+
const bodyNode = node.body?.[0]
245+
const isInlineLiteral = bodyNode && isLiteralNode(bodyNode) && !bodyNode.content.includes("\n")
246+
const isInlineText = isInlineContent && isHTMLTextNode(bodyNode)
247+
248+
if (isInlineText || isInlineLiteral) {
249+
const textContent = isHTMLTextNode(bodyNode) ? bodyNode.content : bodyNode.content
250+
const args = [`"${textContent}"`]
251+
252+
if (attributes) args.push(attributes)
253+
254+
return ` javascript_tag ${args.join(", ")} `
255+
}
256+
257+
return attributes
258+
? ` javascript_tag ${attributes} do `
259+
: ` javascript_tag do `
260+
}
261+
262+
private buildJavascriptIncludeTagContent(attributes: string, source: string | null): string {
263+
const args: string[] = []
264+
265+
if (source) args.push(source)
266+
if (attributes) args.push(attributes)
267+
268+
const argString = args.join(", ")
269+
270+
return argString ? ` javascript_include_tag ${argString} ` : ` javascript_include_tag `
271+
}
272+
227273
private buildLinkToContent(node: HTMLElementNode, attribute: string, href: string | null, isInlineContent: boolean): string {
228274
const args: string[] = []
229275

@@ -255,7 +301,7 @@ export class HTMLToActionViewTagHelperRewriter extends ASTRewriter {
255301
}
256302

257303
get description(): string {
258-
return "Converts raw HTML elements to ActionView tag helpers (tag.*, turbo_frame_tag)"
304+
return "Converts raw HTML elements to ActionView tag helpers (tag.*, turbo_frame_tag, javascript_tag, javascript_include_tag)"
259305
}
260306

261307
rewrite<T extends Node>(node: T, _context: RewriteContext): T {

javascript/packages/rewriter/test/action-view-tag-helper-to-html.test.ts

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,158 @@ describe("ActionViewTagHelperToHTMLRewriter", () => {
396396
})
397397
})
398398

399+
describe("javascript_tag helpers", () => {
400+
test("javascript_tag with content as argument", () => {
401+
expect(transform(`<%= javascript_tag "alert('Hello')" %>`)).toBe(
402+
`<script>alert('Hello')</script>`
403+
)
404+
})
405+
406+
test("javascript_tag with block", () => {
407+
const input = dedent`
408+
<%= javascript_tag do %>
409+
alert('Hello')
410+
<% end %>
411+
`
412+
413+
const expected = dedent`
414+
<script>
415+
alert('Hello')
416+
</script>
417+
`
418+
419+
expect(transform(input)).toBe(expected)
420+
})
421+
422+
test("javascript_tag with type attribute", () => {
423+
expect(transform(`<%= javascript_tag "alert('Hello')", type: "application/javascript" %>`)).toBe(
424+
`<script type="application/javascript">alert('Hello')</script>`
425+
)
426+
})
427+
})
428+
429+
describe("javascript_include_tag helpers", () => {
430+
test("javascript_include_tag with single source", () => {
431+
expect(transform(`<%= javascript_include_tag "application" %>`)).toBe(
432+
`<script src="<%= javascript_path("application") %>"></script>`
433+
)
434+
})
435+
436+
test("javascript_include_tag with defer", () => {
437+
expect(transform(`<%= javascript_include_tag "application", defer: true %>`)).toBe(
438+
`<script src="<%= javascript_path("application") %>" defer></script>`
439+
)
440+
})
441+
442+
test("javascript_include_tag with multiple sources", () => {
443+
expect(transform(`<%= javascript_include_tag "application", "vendor" %>`)).toBe(
444+
dedent`
445+
<script src="<%= javascript_path("application") %>"></script>
446+
<script src="<%= javascript_path("vendor") %>"></script>
447+
`
448+
)
449+
})
450+
451+
test("javascript_include_tag with nonce", () => {
452+
expect(transform(`<%= javascript_include_tag "application", nonce: true %>`)).toBe(
453+
`<script src="<%= javascript_path("application") %>" nonce="true"></script>`
454+
)
455+
})
456+
457+
test("javascript_include_tag with nonce false", () => {
458+
expect(transform(`<%= javascript_include_tag "application", nonce: false %>`)).toBe(
459+
`<script src="<%= javascript_path("application") %>" nonce="false"></script>`
460+
)
461+
})
462+
463+
test("javascript_include_tag with interpolated nonce", () => {
464+
expect(transform('<%= javascript_include_tag "application", nonce: "static-#{dynamic}" %>')).toBe(
465+
'<script src="<%= javascript_path("application") %>" nonce="static-<%= dynamic %>"></script>'
466+
)
467+
})
468+
469+
test("javascript_include_tag with data attributes", () => {
470+
expect(transform(`<%= javascript_include_tag "application", data: { turbo_track: "reload" } %>`)).toBe(
471+
`<script src="<%= javascript_path("application") %>" data-turbo-track="reload"></script>`
472+
)
473+
})
474+
475+
test("javascript_include_tag with .js extension", () => {
476+
expect(transform(`<%= javascript_include_tag "xmlhr.js" %>`)).toBe(
477+
`<script src="<%= javascript_path("xmlhr.js") %>"></script>`
478+
)
479+
})
480+
481+
test("javascript_include_tag with URL", () => {
482+
expect(transform(`<%= javascript_include_tag "http://www.example.com/xmlhr" %>`)).toBe(
483+
`<script src="http://www.example.com/xmlhr"></script>`
484+
)
485+
})
486+
487+
test("javascript_include_tag with URL ending in .js", () => {
488+
expect(transform(`<%= javascript_include_tag "http://www.example.com/xmlhr.js" %>`)).toBe(
489+
`<script src="http://www.example.com/xmlhr.js"></script>`
490+
)
491+
})
492+
493+
test("javascript_include_tag with protocol-relative URL", () => {
494+
expect(transform(`<%= javascript_include_tag "//cdn.example.com/app.js" %>`)).toBe(
495+
`<script src="//cdn.example.com/app.js"></script>`
496+
)
497+
})
498+
499+
test("javascript_include_tag with URL and nonce", () => {
500+
expect(transform(`<%= javascript_include_tag "http://www.example.com/xmlhr.js", nonce: true %>`)).toBe(
501+
`<script src="http://www.example.com/xmlhr.js" nonce="true"></script>`
502+
)
503+
})
504+
505+
test("javascript_include_tag with URL and async", () => {
506+
expect(transform(`<%= javascript_include_tag "http://www.example.com/xmlhr.js", async: true %>`)).toBe(
507+
`<script src="http://www.example.com/xmlhr.js" async></script>`
508+
)
509+
})
510+
511+
test("javascript_include_tag with URL and defer", () => {
512+
expect(transform(`<%= javascript_include_tag "http://www.example.com/xmlhr.js", defer: true %>`)).toBe(
513+
`<script src="http://www.example.com/xmlhr.js" defer></script>`
514+
)
515+
})
516+
517+
test("javascript_include_tag with defer as string", () => {
518+
expect(transform(`<%= javascript_include_tag "application", defer: "true" %>`)).toBe(
519+
`<script src="<%= javascript_path("application") %>" defer="true"></script>`
520+
)
521+
})
522+
523+
test("javascript_include_tag with extname false", () => {
524+
expect(transform(`<%= javascript_include_tag "template.jst", extname: false %>`)).toBe(
525+
`<script src="<%= javascript_path("template.jst") %>" extname="false"></script>`
526+
)
527+
})
528+
529+
test("javascript_include_tag with host and protocol", () => {
530+
expect(transform(`<%= javascript_include_tag "xmlhr", host: "localhost", protocol: "https" %>`)).toBe(
531+
`<script src="<%= javascript_path("xmlhr") %>" host="localhost" protocol="https"></script>`
532+
)
533+
})
534+
535+
test("javascript_include_tag with multiple sources including path", () => {
536+
expect(transform(`<%= javascript_include_tag "common.javascript", "/elsewhere/cools" %>`)).toBe(
537+
dedent`
538+
<script src="<%= javascript_path("common.javascript") %>"></script>
539+
<script src="<%= javascript_path("/elsewhere/cools") %>"></script>
540+
`
541+
)
542+
})
543+
544+
test("javascript_include_tag with asset_path", () => {
545+
expect(transform(`<%= javascript_include_tag asset_path("application.js") %>`)).toBe(
546+
`<script src="<%= asset_path("application.js") %>"></script>`
547+
)
548+
})
549+
})
550+
399551
describe("non-ActionView elements", () => {
400552
test("regular HTML elements are not modified", () => {
401553
expect(transform('<div class="content">Hello</div>')).toBe(

0 commit comments

Comments
 (0)