Skip to content
Open
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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
3 changes: 3 additions & 0 deletions config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -617,6 +617,9 @@ nodes:
- name: element_source
type: element_source

- name: escape_content
type: boolean

- name: HTMLConditionalElementNode
fields:
- name: condition
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ function createWrapper(template: HTMLElementNode, body: Node[]): HTMLElementNode
close_tag: template.close_tag,
is_void: template.is_void,
element_source: template.element_source,
escape_content: template.escape_content,
location: Location.zero,
errors: [],
})
Expand Down
30 changes: 26 additions & 4 deletions javascript/packages/linter/src/rules/html-no-unescaped-entities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,14 +75,31 @@ const RAW_TEXT_ELEMENTS = new Set(["script", "style"])

// Per the HTML5 spec (§13.2.5.36, §13.2.5.37), no characters are parse errors
// in quoted attribute values. Entity checks only apply to text content.
interface ElementStackEntry {
tagName: string
hasAutoEscapedContent: boolean
}

class HTMLNoUnescapedEntitiesVisitor extends BaseRuleVisitor<UnescapedEntitiesAutofixContext> {
private elementStack: string[] = []
private elementStack: ElementStackEntry[] = []

visitHTMLElementNode(node: HTMLElementNode): void {
const tagName = getTagLocalName(node)

if (tagName) {
this.elementStack.push(tagName)
// The parser sets `escape_content` on elements created by ActionView tag
// helpers. When true (the default), the helper auto-escapes its string
// argument content, so unescaped characters in the source are safe.
// When false (e.g. `escape: false`), the content is rendered raw.
//
// For block bodies and regular HTML elements, `escape_content` is true
// but the content is literal template HTML — those are handled by the
// close_tag type check (block bodies have ERBEndNode, not virtual close).
const isActionViewHelper = !!node.element_source && node.element_source !== "HTML"
const hasVirtualCloseTag = node.close_tag?.type === "AST_HTML_VIRTUAL_CLOSE_TAG_NODE"
const hasAutoEscapedContent = isActionViewHelper && hasVirtualCloseTag && node.escape_content

this.elementStack.push({ tagName, hasAutoEscapedContent })
}

super.visitHTMLElementNode(node)
Expand All @@ -93,11 +110,16 @@ class HTMLNoUnescapedEntitiesVisitor extends BaseRuleVisitor<UnescapedEntitiesAu
}

private get insideRawTextElement(): boolean {
return this.elementStack.some((tagName) => RAW_TEXT_ELEMENTS.has(tagName))
return this.elementStack.some((entry) => RAW_TEXT_ELEMENTS.has(entry.tagName))
}

private get insideAutoEscapedHelper(): boolean {
const current = this.elementStack.at(-1)
return !!current?.hasAutoEscapedContent
}

visitHTMLTextNode(node: HTMLTextNode): void {
if (this.insideRawTextElement) {
if (this.insideRawTextElement || this.insideAutoEscapedHelper) {
super.visitHTMLTextNode(node)
return
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,64 @@ describe("html-no-unescaped-entities", () => {
})
})

describe("ActionView tag helpers - string arguments (auto-escaped)", () => {
it("does not flag bare & in tag.p string argument", () => {
expectNoOffenses('<%= tag.p("Tom & Jerry") %>')
})

it("does not flag bare & in content_tag string argument", () => {
expectNoOffenses('<%= content_tag :p, "Tom & Jerry" %>')
})

it("does not flag bare & in link_to string argument", () => {
expectNoOffenses('<%= link_to "Terms & Conditions", "#" %>')
})

it("still flags bare & in raw HTML alongside ActionView helpers", () => {
expectWarning("Text content contains an unescaped `&` character. Use `&amp;` instead.")

assertOffenses('<div><%= tag.p("A & B") %> Tom & Jerry</div>')
})

it("flags bare & in tag.p with escape: false", () => {
expectWarning("Text content contains an unescaped `&` character. Use `&amp;` instead.")

assertOffenses('<%= tag.p("Tom & Jerry", escape: false) %>')
})

it("flags bare & in tag.p with escape: nil", () => {
expectWarning("Text content contains an unescaped `&` character. Use `&amp;` instead.")

assertOffenses('<%= tag.p("Tom & Jerry", escape: nil) %>')
})

it("flags bare & in content_tag with escape disabled (positional arg)", () => {
expectWarning("Text content contains an unescaped `&` character. Use `&amp;` instead.")

assertOffenses('<%= content_tag :p, "Tom & Jerry", {}, false %>')
})
})

describe("ActionView tag helpers - block bodies (not auto-escaped)", () => {
it("flags bare & in link_to block body", () => {
expectWarning("Text content contains an unescaped `&` character. Use `&amp;` instead.")

assertOffenses('<%= link_to "#" do %>Tom & Jerry<% end %>')
})

it("flags bare & in content_tag block body", () => {
expectWarning("Text content contains an unescaped `&` character. Use `&amp;` instead.")

assertOffenses('<%= content_tag :div do %>Tom & Jerry<% end %>')
})

it("flags bare & in tag.div block body", () => {
expectWarning("Text content contains an unescaped `&` character. Use `&amp;` instead.")

assertOffenses('<%= tag.div do %>Tom & Jerry<% end %>')
})
})

describe("escapable raw text elements - textarea and title", () => {
it("flags bare & inside textarea text content", () => {
expectWarning("Text content contains an unescaped `&` character. Use `&amp;` instead.")
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion lib/herb/engine/debug_visitor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,7 @@ def create_debug_span_for_erb(erb_node)
)

Herb::AST::HTMLElementNode.new("HTMLElementNode", dummy_location, [], open_tag, tag_name_token, [erb_node], close_tag,
false, "Debug")
false, "Debug", false)
end

def determine_view_type
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 6 additions & 3 deletions rust/tests/snapshots/snapshot_test__parse_output.snap

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading