Skip to content

Commit b089e4d

Browse files
committed
Parser: Detect image_tag Action View helper
1 parent 4856642 commit b089e4d

File tree

9 files changed

+277
-11
lines changed

9 files changed

+277
-11
lines changed

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,8 @@ export const ACTION_VIEW_HELPERS: Record<string, ActionViewHelperInfo> = {
2828
signature: "javascript_include_tag(*sources)",
2929
documentationURL: "https://api.rubyonrails.org/classes/ActionView/Helpers/AssetTagHelper.html#method-i-javascript_include_tag",
3030
},
31+
"ActionView::Helpers::AssetTagHelper#image_tag": {
32+
signature: "image_tag(source, options = {})",
33+
documentationURL: "https://api.rubyonrails.org/classes/ActionView/Helpers/AssetTagHelper.html#method-i-image_tag",
34+
},
3135
}

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -137,14 +137,17 @@ export class RewriteCodeActionService {
137137
const tagName = element.node.tag_name?.value
138138
const isAnchor = tagName === "a"
139139
const isTurboFrame = tagName === "turbo-frame"
140+
const isImg = tagName === "img"
140141
const methodName = tagName?.replace(/-/g, "_")
141142
const title = isAnchor
142143
? "Herb: Convert to `link_to`"
143144
: isTurboFrame
144145
? "Herb: Convert to `turbo_frame_tag`"
145-
: methodName
146-
? `Herb: Convert to \`tag.${methodName}\``
147-
: "Herb: Convert to tag helper"
146+
: isImg
147+
? "Herb: Convert to `image_tag`"
148+
: methodName
149+
? `Herb: Convert to \`tag.${methodName}\``
150+
: "Herb: Convert to tag helper"
148151

149152
return {
150153
title,

javascript/packages/node/binding.gyp

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"./extension/libherb/analyze/transform.c",
2727
"./extension/libherb/analyze/action_view/attribute_extraction_helpers.c",
2828
"./extension/libherb/analyze/action_view/content_tag.c",
29+
"./extension/libherb/analyze/action_view/image_tag.c",
2930
"./extension/libherb/analyze/action_view/javascript_include_tag.c",
3031
"./extension/libherb/analyze/action_view/javascript_tag.c",
3132
"./extension/libherb/analyze/action_view/link_to.c",

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

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -124,9 +124,10 @@ class HTMLToActionViewTagHelperVisitor extends Visitor {
124124
const isAnchor = tagName.value === "a"
125125
const isTurboFrame = tagName.value === "turbo-frame"
126126
const isScript = tagName.value === "script"
127+
const isImg = tagName.value === "img"
127128
const attributes = openTag.children.filter(child => !isWhitespaceNode(child))
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 })
129+
const hasSrcAttribute = (isScript || isImg) && attributes.some(child => isHTMLAttributeNode(child) && getStaticAttributeName(child.name!) === "src")
130+
const { attributes: attributesString, href, id, src } = serializeAttributes(attributes, { extractHref: isAnchor, extractId: isTurboFrame, extractSrc: isScript || isImg })
130131
const hasBody = node.body && node.body.length > 0 && !node.is_void
131132
const isInlineContent = hasBody && isTextOnlyBody(node.body)
132133

@@ -145,6 +146,9 @@ class HTMLToActionViewTagHelperVisitor extends Visitor {
145146
} else if (isScript) {
146147
content = this.buildJavascriptTagContent(node, attributesString, isInlineContent)
147148
elementSource = "ActionView::Helpers::JavaScriptHelper#javascript_tag"
149+
} else if (isImg) {
150+
content = this.buildImageTagContent(attributesString, src)
151+
elementSource = "ActionView::Helpers::AssetTagHelper#image_tag"
148152
} else {
149153
content = this.buildTagContent(tagName.value, node, attributesString, isInlineContent)
150154
elementSource = "ActionView::Helpers::TagHelper#tag"
@@ -165,7 +169,7 @@ class HTMLToActionViewTagHelperVisitor extends Visitor {
165169
asMutable(node).element_source = elementSource
166170

167171
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)
172+
const isInlineForm = isInlineContent || isInlineLiteralContent || (isTurboFrame && !hasBody) || (isScript && hasSrcAttribute) || isImg
169173

170174
if (node.is_void) {
171175
asMutable(node).close_tag = null
@@ -270,6 +274,17 @@ class HTMLToActionViewTagHelperVisitor extends Visitor {
270274
return argString ? ` javascript_include_tag ${argString} ` : ` javascript_include_tag `
271275
}
272276

277+
private buildImageTagContent(attributes: string, source: string | null): string {
278+
const args: string[] = []
279+
280+
if (source) args.push(source)
281+
if (attributes) args.push(attributes)
282+
283+
const argString = args.join(", ")
284+
285+
return argString ? ` image_tag ${argString} ` : ` image_tag `
286+
}
287+
273288
private buildLinkToContent(node: HTMLElementNode, attribute: string, href: string | null, isInlineContent: boolean): string {
274289
const args: string[] = []
275290

@@ -301,7 +316,7 @@ export class HTMLToActionViewTagHelperRewriter extends ASTRewriter {
301316
}
302317

303318
get description(): string {
304-
return "Converts raw HTML elements to ActionView tag helpers (tag.*, turbo_frame_tag, javascript_tag, javascript_include_tag)"
319+
return "Converts raw HTML elements to ActionView tag helpers (tag.*, turbo_frame_tag, javascript_tag, javascript_include_tag, image_tag)"
305320
}
306321

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

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

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -554,6 +554,80 @@ describe("ActionViewTagHelperToHTMLRewriter", () => {
554554
})
555555
})
556556

557+
describe("image_tag helpers", () => {
558+
test("image_tag with string source", () => {
559+
expect(transform('<%= image_tag "icon.png" %>')).toBe(
560+
'<img src="<%= image_path("icon.png") %>" />'
561+
)
562+
})
563+
564+
test("image_tag with alt attribute", () => {
565+
expect(transform('<%= image_tag "icon.png", alt: "Icon" %>')).toBe(
566+
'<img src="<%= image_path("icon.png") %>" alt="Icon" />'
567+
)
568+
})
569+
570+
test("image_tag with multiple attributes", () => {
571+
expect(transform('<%= image_tag "photo.jpg", alt: "Photo", class: "avatar" %>')).toBe(
572+
'<img src="<%= image_path("photo.jpg") %>" alt="Photo" class="avatar" />'
573+
)
574+
})
575+
576+
test("image_tag with URL source", () => {
577+
expect(transform('<%= image_tag "http://example.com/icon.png" %>')).toBe(
578+
'<img src="http://example.com/icon.png" />'
579+
)
580+
})
581+
582+
test("image_tag with protocol-relative URL", () => {
583+
expect(transform('<%= image_tag "//cdn.example.com/icon.png" %>')).toBe(
584+
'<img src="//cdn.example.com/icon.png" />'
585+
)
586+
})
587+
588+
test("image_tag with ruby expression source wraps in image_path", () => {
589+
expect(transform('<%= image_tag user.avatar %>')).toBe(
590+
'<img src="<%= image_path(user.avatar) %>" />'
591+
)
592+
})
593+
594+
test("image_tag with image_path source passes through", () => {
595+
expect(transform('<%= image_tag image_path("icon.png") %>')).toBe(
596+
'<img src="<%= image_path("icon.png") %>" />'
597+
)
598+
})
599+
600+
test("image_tag with asset_path source passes through", () => {
601+
expect(transform('<%= image_tag asset_path("icon.png") %>')).toBe(
602+
'<img src="<%= asset_path("icon.png") %>" />'
603+
)
604+
})
605+
606+
test("image_tag with image_url source passes through", () => {
607+
expect(transform('<%= image_tag image_url("icon.png") %>')).toBe(
608+
'<img src="<%= image_url("icon.png") %>" />'
609+
)
610+
})
611+
612+
test("image_tag with asset_url source passes through", () => {
613+
expect(transform('<%= image_tag asset_url("icon.png") %>')).toBe(
614+
'<img src="<%= asset_url("icon.png") %>" />'
615+
)
616+
})
617+
618+
test("image_tag with instance variable method wraps in image_path", () => {
619+
expect(transform('<%= image_tag @post.cover_image %>')).toBe(
620+
'<img src="<%= image_path(@post.cover_image) %>" />'
621+
)
622+
})
623+
624+
test("image_tag with data attributes", () => {
625+
expect(transform('<%= image_tag "icon.png", data: { controller: "image" } %>')).toBe(
626+
'<img src="<%= image_path("icon.png") %>" data-controller="image" />'
627+
)
628+
})
629+
})
630+
557631
describe("non-ActionView elements", () => {
558632
test("regular HTML elements are not modified", () => {
559633
expect(transform('<div class="content">Hello</div>')).toBe(

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

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ describe("HTMLToActionViewTagHelperRewriter", () => {
184184

185185
test("img with attributes", () => {
186186
expect(transform('<img src="image.png" alt="Photo">')).toBe(
187-
'<%= tag.img src: "image.png", alt: "Photo" %>'
187+
'<%= image_tag "image.png", alt: "Photo" %>'
188188
)
189189
})
190190
})
@@ -425,6 +425,44 @@ describe("HTMLToActionViewTagHelperRewriter", () => {
425425
})
426426
})
427427

428+
describe("image_tag for img elements", () => {
429+
test("img with src attribute", () => {
430+
expect(transform('<img src="icon.png">')).toBe(
431+
'<%= image_tag "icon.png" %>'
432+
)
433+
})
434+
435+
test("img with src and alt", () => {
436+
expect(transform('<img src="icon.png" alt="Icon">')).toBe(
437+
'<%= image_tag "icon.png", alt: "Icon" %>'
438+
)
439+
})
440+
441+
test("img with src, alt and class", () => {
442+
expect(transform('<img src="photo.jpg" alt="Photo" class="avatar">')).toBe(
443+
'<%= image_tag "photo.jpg", alt: "Photo", class: "avatar" %>'
444+
)
445+
})
446+
447+
test("img with src and data attributes", () => {
448+
expect(transform('<img src="icon.png" data-controller="image">')).toBe(
449+
'<%= image_tag "icon.png", data: { controller: "image" } %>'
450+
)
451+
})
452+
453+
test("img self-closing", () => {
454+
expect(transform('<img src="icon.png" />')).toBe(
455+
'<%= image_tag "icon.png" %>'
456+
)
457+
})
458+
459+
test("img without src", () => {
460+
expect(transform('<img alt="Photo">')).toBe(
461+
'<%= image_tag alt: "Photo" %>'
462+
)
463+
})
464+
})
465+
428466
describe("ERB in attribute values", () => {
429467
test("single ERB expression becomes Ruby variable", () => {
430468
expect(transform('<div class="<%= class_name %>">Content</div>')).toBe(
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
#include <prism.h>
2+
#include <stdbool.h>
3+
#include <stdlib.h>
4+
#include <string.h>
5+
6+
#include "../../include/analyze/action_view/tag_helper_handler.h"
7+
8+
bool detect_image_tag(pm_call_node_t* call_node, pm_parser_t* parser) {
9+
if (!call_node || !call_node->name) { return false; }
10+
11+
pm_constant_t* constant = pm_constant_pool_id_to_constant(&parser->constant_pool, call_node->name);
12+
return constant && constant->length == 9 && strncmp((const char*) constant->start, "image_tag", 9) == 0;
13+
}
14+
15+
char* extract_image_tag_name(pm_call_node_t* _call_node, pm_parser_t* _parser, hb_allocator_T* allocator) {
16+
return hb_allocator_strdup(allocator, "img");
17+
}
18+
19+
char* extract_image_tag_content(pm_call_node_t* _call_node, pm_parser_t* _parser, hb_allocator_T* _allocator) {
20+
return NULL;
21+
}
22+
23+
char* extract_image_tag_src(pm_call_node_t* call_node, pm_parser_t* _parser, hb_allocator_T* allocator) {
24+
if (!call_node || !call_node->arguments) { return NULL; }
25+
26+
pm_arguments_node_t* arguments = call_node->arguments;
27+
if (!arguments->arguments.size) { return NULL; }
28+
29+
pm_node_t* first_argument = arguments->arguments.nodes[0];
30+
31+
if (first_argument->type == PM_STRING_NODE) {
32+
pm_string_node_t* string_node = (pm_string_node_t*) first_argument;
33+
size_t length = pm_string_length(&string_node->unescaped);
34+
35+
return hb_allocator_strndup(allocator, (const char*) pm_string_source(&string_node->unescaped), length);
36+
}
37+
38+
size_t source_length = first_argument->location.end - first_argument->location.start;
39+
return hb_allocator_strndup(allocator, (const char*) first_argument->location.start, source_length);
40+
}
41+
42+
bool image_tag_source_is_url(const char* source, size_t length) {
43+
if (!source || length == 0) { return false; }
44+
45+
if (length >= 2 && source[0] == '/' && source[1] == '/') { return true; }
46+
if (strstr(source, "://") != NULL) { return true; }
47+
48+
return false;
49+
}
50+
51+
char* wrap_in_image_path(const char* source, size_t source_length, hb_allocator_T* allocator) {
52+
const char* prefix = "image_path(";
53+
const char* suffix = ")";
54+
55+
size_t prefix_length = strlen(prefix);
56+
size_t suffix_length = strlen(suffix);
57+
size_t total_length = prefix_length + source_length + suffix_length;
58+
char* result = hb_allocator_alloc(allocator, total_length + 1);
59+
60+
memcpy(result, prefix, prefix_length);
61+
memcpy(result + prefix_length, source, source_length);
62+
memcpy(result + prefix_length + source_length, suffix, suffix_length);
63+
64+
result[total_length] = '\0';
65+
66+
return result;
67+
}
68+
69+
bool image_tag_supports_block(void) {
70+
return false;
71+
}
72+
73+
const tag_helper_handler_T image_tag_handler = { .name = "image_tag",
74+
.source =
75+
HB_STRING_LITERAL("ActionView::Helpers::AssetTagHelper#image_tag"),
76+
.detect = detect_image_tag,
77+
.extract_tag_name = extract_image_tag_name,
78+
.extract_content = extract_image_tag_content,
79+
.supports_block = image_tag_supports_block };

src/analyze/action_view/registry.c

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@ extern const tag_helper_handler_T link_to_handler;
1010
extern const tag_helper_handler_T turbo_frame_tag_handler;
1111
extern const tag_helper_handler_T javascript_tag_handler;
1212
extern const tag_helper_handler_T javascript_include_tag_handler;
13+
extern const tag_helper_handler_T image_tag_handler;
1314

14-
static size_t handlers_count = 6;
15+
static size_t handlers_count = 7;
1516

1617
tag_helper_info_T* tag_helper_info_init(hb_allocator_T* allocator) {
1718
tag_helper_info_T* info = hb_allocator_alloc(allocator, sizeof(tag_helper_info_T));
@@ -43,7 +44,7 @@ void tag_helper_info_free(tag_helper_info_T** info) {
4344
}
4445

4546
tag_helper_handler_T* get_tag_helper_handlers(void) {
46-
static tag_helper_handler_T static_handlers[6];
47+
static tag_helper_handler_T static_handlers[7];
4748
static bool initialized = false;
4849

4950
if (!initialized) {
@@ -53,6 +54,8 @@ tag_helper_handler_T* get_tag_helper_handlers(void) {
5354
static_handlers[3] = turbo_frame_tag_handler;
5455
static_handlers[4] = javascript_tag_handler;
5556
static_handlers[5] = javascript_include_tag_handler;
57+
static_handlers[6] = image_tag_handler;
58+
5659
initialized = true;
5760
}
5861

0 commit comments

Comments
 (0)