Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion javascript/packages/node/binding.gyp
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,10 @@
"./extension/libherb/analyze/action_view/content_tag.c",
"./extension/libherb/analyze/action_view/link_to.c",
"./extension/libherb/analyze/action_view/registry.c",
"./extension/libherb/analyze/action_view/tag.c",
"./extension/libherb/analyze/action_view/tag_helper_node_builders.c",
"./extension/libherb/analyze/action_view/tag_helpers.c",
"./extension/libherb/analyze/action_view/tag.c",
"./extension/libherb/analyze/action_view/turbo_frame_tag.c",
"./extension/libherb/ast_node.c",
"./extension/libherb/ast_nodes.c",
"./extension/libherb/ast_pretty_print.c",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ class ActionViewTagHelperToHTMLVisitor extends Visitor {
tag_closing: createSyntheticToken("%>"),
parsed: false,
valid: true,
prism_node: null,
}))

continue
Expand Down Expand Up @@ -140,6 +141,7 @@ class ActionViewTagHelperToHTMLVisitor extends Visitor {
tag_closing: createSyntheticToken("%>"),
parsed: false,
valid: true,
prism_node: null,
})
}

Expand All @@ -163,7 +165,7 @@ export class ActionViewTagHelperToHTMLRewriter extends ASTRewriter {
}

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

rewrite<T extends Node>(node: T, _context: RewriteContext): T {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,15 @@ function dashToUnderscore(string: string): string {
interface SerializedAttributes {
attributes: string
href: string | null
id: string | null
}

function serializeAttributes(children: Node[], extractHref = false): SerializedAttributes {
function serializeAttributes(children: Node[], options: { extractHref?: boolean, extractId?: boolean } = {}): SerializedAttributes {
const regular: string[] = []
const prefixed: Map<string, string[]> = new Map()

let href: string | null = null
let id: string | null = null

for (const child of children) {
if (!isHTMLAttributeNode(child)) continue
Expand All @@ -50,11 +52,16 @@ function serializeAttributes(children: Node[], extractHref = false): SerializedA

const value = child.value ? serializeAttributeValue(child.value) : "true"

if (extractHref && name === "href") {
if (options.extractHref && name === "href") {
href = value
continue
}

if (options.extractId && name === "id") {
id = value
continue
}

const dataMatch = name.match(/^(data|aria)-(.+)$/)

if (dataMatch) {
Expand All @@ -76,7 +83,7 @@ function serializeAttributes(children: Node[], extractHref = false): SerializedA
parts.push(`${prefix}: { ${entries.join(", ")} }`)
}

return { attributes: parts.join(", "), href }
return { attributes: parts.join(", "), href, id }
}

function isTextOnlyBody(body: Node[]): boolean {
Expand Down Expand Up @@ -108,8 +115,9 @@ class HTMLToActionViewTagHelperVisitor extends Visitor {
}

const isAnchor = tagName.value === "a"
const isTurboFrame = tagName.value === "turbo-frame"
const attributes = openTag.children.filter(child => !isWhitespaceNode(child))
const { attributes: attributesString, href } = serializeAttributes(attributes, isAnchor)
const { attributes: attributesString, href, id } = serializeAttributes(attributes, { extractHref: isAnchor, extractId: isTurboFrame })
const hasBody = node.body && node.body.length > 0 && !node.is_void
const isInlineContent = hasBody && isTextOnlyBody(node.body)

Expand All @@ -119,6 +127,9 @@ class HTMLToActionViewTagHelperVisitor extends Visitor {
if (isAnchor) {
content = this.buildLinkToContent(node, attributesString, href, isInlineContent)
elementSource = "ActionView::Helpers::UrlHelper#link_to"
} else if (isTurboFrame) {
content = this.buildTurboFrameTagContent(node, attributesString, id, isInlineContent)
elementSource = "Turbo::FramesHelper#turbo_frame_tag"
} else {
content = this.buildTagContent(tagName.value, node, attributesString, isInlineContent)
elementSource = "ActionView::Helpers::TagHelper#tag"
Expand All @@ -131,16 +142,18 @@ class HTMLToActionViewTagHelperVisitor extends Visitor {
tag_opening: createSyntheticToken("<%="),
content: createSyntheticToken(content),
tag_closing: createSyntheticToken("%>"),
tag_name: createSyntheticToken(isAnchor ? "a" : tagName.value),
tag_name: createSyntheticToken(tagName.value),
children: [],
})

asMutable(node).open_tag = erbOpenTag
asMutable(node).element_source = elementSource

const isInlineForm = isInlineContent || (isTurboFrame && !hasBody)

if (node.is_void) {
asMutable(node).close_tag = null
} else if (isInlineContent) {
} else if (isInlineForm) {
asMutable(node).body = []

const virtualClose = new HTMLVirtualCloseTagNode({
Expand Down Expand Up @@ -185,6 +198,30 @@ class HTMLToActionViewTagHelperVisitor extends Visitor {
: ` tag.${tag} do `
}

private buildTurboFrameTagContent(node: HTMLElementNode, attributes: string, id: string | null, isInlineContent: boolean): string {
const args: string[] = []

if (id) {
args.push(id)
}

if (isInlineContent && isHTMLTextNode(node.body[0])) {
args.push(`"${node.body[0].content}"`)
}

if (attributes) {
args.push(attributes)
}

const argString = args.join(", ")

if (isInlineContent || !node.body || node.body.length === 0) {
return argString ? ` turbo_frame_tag ${argString} ` : ` turbo_frame_tag `
}

return argString ? ` turbo_frame_tag ${argString} do ` : ` turbo_frame_tag do `
}

private buildLinkToContent(node: HTMLElementNode, attribute: string, href: string | null, isInlineContent: boolean): string {
const args: string[] = []

Expand Down Expand Up @@ -216,7 +253,7 @@ export class HTMLToActionViewTagHelperRewriter extends ASTRewriter {
}

get description(): string {
return "Converts raw HTML elements to ActionView tag helpers (tag.*)"
return "Converts raw HTML elements to ActionView tag helpers (tag.*, turbo_frame_tag)"
}

rewrite<T extends Node>(node: T, _context: RewriteContext): T {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,112 @@ describe("ActionViewTagHelperToHTMLRewriter", () => {
})
})

describe("turbo_frame_tag helpers", () => {
test("turbo_frame_tag with block", () => {
const input = dedent`
<%= turbo_frame_tag "tray" do %>
Content
<% end %>
`

const expected = dedent`
<turbo-frame id="tray">
Content
</turbo-frame>
`

expect(transform(input)).toBe(expected)
})

test("turbo_frame_tag without block", () => {
expect(transform('<%= turbo_frame_tag "tray" %>')).toBe(
'<turbo-frame id="tray"></turbo-frame>'
)
})

test("turbo_frame_tag with src attribute", () => {
expect(transform('<%= turbo_frame_tag "tray", src: tray_path(tray) %>')).toBe(
'<turbo-frame id="tray" src="<%= tray_path(tray) %>"></turbo-frame>'
)
})

test("turbo_frame_tag with src and target attributes", () => {
expect(transform('<%= turbo_frame_tag "tray", src: tray_path(tray), target: "_top" %>')).toBe(
'<turbo-frame id="tray" src="<%= tray_path(tray) %>" target="_top"></turbo-frame>'
)
})

test("turbo_frame_tag with loading lazy", () => {
expect(transform('<%= turbo_frame_tag "tray", src: tray_path(tray), loading: "lazy" %>')).toBe(
'<turbo-frame id="tray" src="<%= tray_path(tray) %>" loading="lazy"></turbo-frame>'
)
})

test("turbo_frame_tag with class attribute and block", () => {
const input = dedent`
<%= turbo_frame_tag "tray", class: "frame" do %>
Content
<% end %>
`

const expected = dedent`
<turbo-frame id="tray" class="frame">
Content
</turbo-frame>
`

expect(transform(input)).toBe(expected)
})

test("turbo_frame_tag with variable id", () => {
const input = dedent`
<%= turbo_frame_tag dom_id(post) do %>
Content
<% end %>
`

const expected = dedent`
<turbo-frame id="<%= dom_id(post) %>">
Content
</turbo-frame>
`

expect(transform(input)).toBe(expected)
})

test("turbo_frame_tag with data attributes", () => {
const input = dedent`
<%= turbo_frame_tag "tray", data: { controller: "frame" } do %>
Content
<% end %>
`

const expected = dedent`
<turbo-frame id="tray" data-controller="frame">
Content
</turbo-frame>
`

expect(transform(input)).toBe(expected)
})

test("turbo_frame_tag with splat attributes", () => {
const input = dedent`
<%= turbo_frame_tag "tray", **attributes do %>
Content
<% end %>
`

const expected = dedent`
<turbo-frame id="tray" <%= **attributes %>>
Content
</turbo-frame>
`

expect(transform(input)).toBe(expected)
})
})

describe("non-ActionView elements", () => {
test("regular HTML elements are not modified", () => {
expect(transform('<div class="content">Hello</div>')).toBe(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,102 @@ describe("HTMLToActionViewTagHelperRewriter", () => {
})
})

describe("turbo_frame_tag for turbo-frame elements", () => {
test("turbo-frame with id and body", () => {
const input = dedent`
<turbo-frame id="tray">
Content
</turbo-frame>
`

const expected = dedent`
<%= turbo_frame_tag "tray" do %>
Content
<% end %>
`

expect(transform(input)).toBe(expected)
})

test("turbo-frame with id only", () => {
expect(transform('<turbo-frame id="tray"></turbo-frame>')).toBe(
'<%= turbo_frame_tag "tray" %>'
)
})

test("turbo-frame with id and src", () => {
expect(transform('<turbo-frame id="tray" src="/trays/1"></turbo-frame>')).toBe(
'<%= turbo_frame_tag "tray", src: "/trays/1" %>'
)
})

test("turbo-frame with id, src and target", () => {
expect(transform('<turbo-frame id="tray" src="/trays/1" target="_top"></turbo-frame>')).toBe(
'<%= turbo_frame_tag "tray", src: "/trays/1", target: "_top" %>'
)
})

test("turbo-frame with id and class", () => {
const input = dedent`
<turbo-frame id="tray" class="frame">
Content
</turbo-frame>
`

const expected = dedent`
<%= turbo_frame_tag "tray", class: "frame" do %>
Content
<% end %>
`

expect(transform(input)).toBe(expected)
})

test("turbo-frame with id and data attributes", () => {
expect(transform('<turbo-frame id="tray" data-controller="frame"></turbo-frame>')).toBe(
'<%= turbo_frame_tag "tray", data: { controller: "frame" } %>'
)
})

test("turbo-frame with ERB id", () => {
const input = dedent`
<turbo-frame id="<%= dom_id(post) %>">
Content
</turbo-frame>
`

const expected = dedent`
<%= turbo_frame_tag dom_id(post) do %>
Content
<% end %>
`

expect(transform(input)).toBe(expected)
})

test("turbo-frame with loading lazy", () => {
expect(transform('<turbo-frame id="tray" src="/trays/1" loading="lazy"></turbo-frame>')).toBe(
'<%= turbo_frame_tag "tray", src: "/trays/1", loading: "lazy" %>'
)
})

test("turbo-frame without id", () => {
const input = dedent`
<turbo-frame>
Content
</turbo-frame>
`

const expected = dedent`
<%= turbo_frame_tag do %>
Content
<% end %>
`

expect(transform(input)).toBe(expected)
})
})

describe("ERB in attribute values", () => {
test("single ERB expression becomes Ruby variable", () => {
expect(transform('<div class="<%= class_name %>">Content</div>')).toBe(
Expand Down
Loading
Loading