diff --git a/lua/spec/components_spec.lua b/lua/spec/components_spec.lua
index 0d46d6a2bd5..d220f73c6ad 100644
--- a/lua/spec/components_spec.lua
+++ b/lua/spec/components_spec.lua
@@ -284,3 +284,53 @@ describe('Components/Html', function()
assert.are.equal('ab', result)
end)
end)
+
+describe('Components/Html attribute escaping', function()
+ it('escapes ampersand in attribute value', function()
+ local vNode = Html.Span{attributes = {title = 'a&b'}}
+ assert.is_true(tostring(vNode):find('title="a&b"', nil, true) ~= nil)
+ end)
+
+ it('escapes double-quote in attribute value', function()
+ local vNode = Html.Span{attributes = {title = 'say "hello"'}}
+ assert.is_true(tostring(vNode):find('"', nil, true) ~= nil)
+ end)
+
+ it('escapes less-than in attribute value', function()
+ local vNode = Html.Span{attributes = {title = '1<2'}}
+ assert.is_true(tostring(vNode):find('<', nil, true) ~= nil)
+ end)
+
+ it('escapes greater-than in attribute value', function()
+ local vNode = Html.Span{attributes = {title = '2>1'}}
+ assert.is_true(tostring(vNode):find('>', nil, true) ~= nil)
+ end)
+
+ it('escapes ampersand in class name', function()
+ local vNode = Html.Span{classes = {'a&b'}}
+ assert.is_true(tostring(vNode):find('class="a&b"', nil, true) ~= nil)
+ end)
+
+ it('renders boolean true attribute without a value', function()
+ local vNode = Html.Span{attributes = {disabled = true}}
+ local result = tostring(vNode)
+ assert.is_true(result:find(' disabled', nil, true) ~= nil)
+ assert.is_false(result:find('disabled=', nil, true) ~= nil)
+ end)
+
+ it('omits boolean false attribute entirely', function()
+ local vNode = Html.Span{attributes = {disabled = false}}
+ local result = tostring(vNode)
+ assert.is_false(result:find('disabled', nil, true) ~= nil)
+ end)
+
+ it('escapes css value containing special characters', function()
+ -- CSS values with < or > should be escaped in the style attribute
+ local vNode = Html.Span{css = {content = '"<>"'}}
+ local result = tostring(vNode)
+ assert.is_true(result:find('style=', nil, true) ~= nil)
+ assert.is_false(result:find('"<>"', nil, true) ~= nil)
+ assert.is_true(result:find('<>', nil, true) ~= nil)
+ assert.is_true(result:find('"', nil, true) ~= nil)
+ end)
+end)
diff --git a/lua/wikis/commons/Widget/Renderer.lua b/lua/wikis/commons/Widget/Renderer.lua
index e415ba7b506..2b5887a7e41 100644
--- a/lua/wikis/commons/Widget/Renderer.lua
+++ b/lua/wikis/commons/Widget/Renderer.lua
@@ -12,6 +12,102 @@ local Types = Lua.import('Module:Widget/Types')
local Renderer = {}
+-- List of HTML tags that cannot have children and do not need closing tags
+local selfClosingTags = {
+ area = true,
+ base = true,
+ br = true,
+ col = true,
+ command = true,
+ embed = true,
+ hr = true,
+ img = true,
+ input = true,
+ keygen = true,
+ link = true,
+ meta = true,
+ param = true,
+ source = true,
+ track = true,
+ wbr = true,
+}
+
+-- Basic attribute escaper (prevents quotes from breaking HTML)
+local htmlencodeMap = {
+ ['>'] = '>',
+ ['<'] = '<',
+ ['&'] = '&',
+ ['"'] = '"',
+}
+
+---@param str any
+---@return string
+local function escapeAttr(str)
+ if type(str) ~= 'string' then
+ str = tostring(str)
+ end
+ if not str:find('[><&"]') then return str end
+ return (str:gsub('[><&"]', htmlencodeMap))
+end
+
+--- Builds an HTML string from the given tag, props, and children
+---@param tag string
+---@param props {classes?: string[], css?: table, attributes?: table}
+---@param renderedChildren string?
+---@return string
+local function buildHtmlString(tag, props, renderedChildren)
+ local buffer = { '<', tag }
+
+ if props.classes then
+ table.insert(buffer, ' class="')
+ table.insert(buffer, escapeAttr(table.concat(props.classes, ' ')))
+ table.insert(buffer, '"')
+ end
+
+ if props.css then
+ table.insert(buffer, ' style="')
+ for key, value in pairs(props.css) do
+ table.insert(buffer, key)
+ table.insert(buffer, ':')
+ table.insert(buffer, escapeAttr(value))
+ table.insert(buffer, ';')
+ end
+ table.insert(buffer, '"')
+ end
+
+ if props.attributes then
+ for key, value in pairs(props.attributes) do
+ if type(value) == 'boolean' then
+ -- Boolean attributes like `disabled` or `checked`
+ if value == true then
+ table.insert(buffer, ' ')
+ table.insert(buffer, key)
+ end
+ else
+ table.insert(buffer, ' ')
+ table.insert(buffer, key)
+ table.insert(buffer, '="')
+ table.insert(buffer, escapeAttr(value))
+ table.insert(buffer, '"')
+ end
+ end
+ end
+
+ if selfClosingTags[tag] then
+ table.insert(buffer, ' />')
+ else
+ table.insert(buffer, '>')
+ if renderedChildren and renderedChildren ~= '' then
+ table.insert(buffer, renderedChildren)
+ end
+ table.insert(buffer, '')
+ table.insert(buffer, tag)
+ table.insert(buffer, '>')
+ end
+
+ return table.concat(buffer)
+end
+
--- Renders a Virtual Node (VNode) into a string
---@param vNode Renderable|Renderable[]|nil
---@param context Context?
@@ -80,29 +176,16 @@ function Renderer.render(vNode, context)
-- Handle HTML Tags
if type(renderFn) == 'string' then
---@cast vNode HtmlNode
- local props = vNode.props
- local tagName = renderFn
- local tag
- if tagName == 'fragment' then
- tag = mw.html.create()
- else
- tag = mw.html.create(tagName)
+ local renderedChildren = ''
+ if vNode.props.children then
+ renderedChildren = Renderer.render(vNode.props.children, context)
end
- if props.classes then
- tag:addClass(table.concat(props.classes, ' '))
- end
- if props.css then tag:css(props.css) end
- if props.attributes then tag:attr(props.attributes) end
-
- if props.children then
- local childContent = Renderer.render(props.children, context)
- if childContent ~= '' then
- tag:node(childContent)
- end
+ if renderFn == 'fragment' then
+ return renderedChildren
end
- return tostring(tag)
+ return buildHtmlString(renderFn, vNode.props, renderedChildren)
end
-- Handle Functional Components