From b84256cd5abcaf599a7056b9094e1ffe117efa49 Mon Sep 17 00:00:00 2001 From: Rikard Blixt Date: Wed, 6 May 2026 14:06:45 +0200 Subject: [PATCH 1/7] feat: use custom html builder for widget3 --- lua/wikis/commons/Widget/Renderer.lua | 108 ++++++++++++++++++++++---- 1 file changed, 92 insertions(+), 16 deletions(-) diff --git a/lua/wikis/commons/Widget/Renderer.lua b/lua/wikis/commons/Widget/Renderer.lua index c2674a9a91a..b59d3d5aa70 100644 --- a/lua/wikis/commons/Widget/Renderer.lua +++ b/lua/wikis/commons/Widget/Renderer.lua @@ -12,6 +12,92 @@ 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 + for char, escape in pairs(htmlencodeMap) do + str = str:gsub(char, escape) + end + return str +end + +--- Builds an HTML string from the given tag, props, and children +---@param tag string|nil +---@param props table +---@param renderedChildren string? +---@return string +local function buildHtmlString(tag, props, renderedChildren) + local buffer = { '<', tag } + + if props.classes and #props.classes > 0 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 k, v in pairs(props.css) do + table.insert(buffer, k .. ':' .. tostring(v) .. ';') + end + table.insert(buffer, '"') + end + + if props.attr then + for k, v in pairs(props.attr) do + if type(v) == 'boolean' then + -- Boolean attributes like `disabled` or `checked` + if v then table.insert(buffer, ' ' .. k) end + else + table.insert(buffer, ' ' .. k .. '="' .. escapeAttr(v) .. '"') + 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, '') + end + + return table.concat(buffer) +end + --- Renders a Virtual Node (VNode) into a string ---@param vNode Renderable|Renderable[]|nil ---@param context Context? @@ -80,26 +166,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) - end - - if props.classes then - tag:addClass(table.concat(props.classes, ' ')) + local renderedChildren = '' + if vNode.props.children then + renderedChildren = Renderer.render(vNode.props.children, context) end - if props.css then tag:css(props.css) end - if props.attributes then tag:attr(props.attributes) end - if props.children then - tag:node(Renderer.render(props.children, context)) + if renderFn == 'fragment' then + return renderedChildren end - return tostring(tag) + return buildHtmlString(renderFn, vNode.props, renderedChildren) end -- Handle Functional Components From 89b5e73acdec9c9c039beacdc1f71e580977440e Mon Sep 17 00:00:00 2001 From: Rikard Blixt Date: Wed, 6 May 2026 15:58:09 +0200 Subject: [PATCH 2/7] rename --- lua/wikis/commons/Widget/Renderer.lua | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lua/wikis/commons/Widget/Renderer.lua b/lua/wikis/commons/Widget/Renderer.lua index b59d3d5aa70..6b344a65f9c 100644 --- a/lua/wikis/commons/Widget/Renderer.lua +++ b/lua/wikis/commons/Widget/Renderer.lua @@ -53,8 +53,8 @@ local function escapeAttr(str) end --- Builds an HTML string from the given tag, props, and children ----@param tag string|nil ----@param props table +---@param tag string +---@param props {classes?: string[], css: table, attributes: table} ---@param renderedChildren string? ---@return string local function buildHtmlString(tag, props, renderedChildren) @@ -74,8 +74,8 @@ local function buildHtmlString(tag, props, renderedChildren) table.insert(buffer, '"') end - if props.attr then - for k, v in pairs(props.attr) do + if props.attributes then + for k, v in pairs(props.attributes) do if type(v) == 'boolean' then -- Boolean attributes like `disabled` or `checked` if v then table.insert(buffer, ' ' .. k) end From 0abfab5803df405e6544eab87166e8c5f7d97a27 Mon Sep 17 00:00:00 2001 From: Rikard Blixt Date: Thu, 7 May 2026 10:51:09 +0200 Subject: [PATCH 3/7] cleanup --- lua/wikis/commons/Widget/Renderer.lua | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/lua/wikis/commons/Widget/Renderer.lua b/lua/wikis/commons/Widget/Renderer.lua index 6b344a65f9c..3da066c886d 100644 --- a/lua/wikis/commons/Widget/Renderer.lua +++ b/lua/wikis/commons/Widget/Renderer.lua @@ -68,19 +68,21 @@ local function buildHtmlString(tag, props, renderedChildren) if props.css then table.insert(buffer, ' style="') - for k, v in pairs(props.css) do - table.insert(buffer, k .. ':' .. tostring(v) .. ';') + for key, value in pairs(props.css) do + table.insert(buffer, key .. ':' .. escapeAttr(tostring(value)) .. ';') end table.insert(buffer, '"') end if props.attributes then - for k, v in pairs(props.attributes) do - if type(v) == 'boolean' then + for key, value in pairs(props.attributes) do + if type(value) == 'boolean' then -- Boolean attributes like `disabled` or `checked` - if v then table.insert(buffer, ' ' .. k) end + if value == true then + table.insert(buffer, ' ' .. key) + end else - table.insert(buffer, ' ' .. k .. '="' .. escapeAttr(v) .. '"') + table.insert(buffer, ' ' .. key .. '="' .. escapeAttr(value) .. '"') end end end From ec059c9fabe8e1f43db5c55be58d578ccaca34e6 Mon Sep 17 00:00:00 2001 From: Rikard Blixt Date: Tue, 12 May 2026 13:12:42 +0200 Subject: [PATCH 4/7] add tests --- lua/spec/components_spec.lua | 50 +++++++++++++++++++++++++++ lua/wikis/commons/Widget/Renderer.lua | 7 ++-- 2 files changed, 52 insertions(+), 5 deletions(-) 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 3da066c886d..f7304401a71 100644 --- a/lua/wikis/commons/Widget/Renderer.lua +++ b/lua/wikis/commons/Widget/Renderer.lua @@ -46,15 +46,12 @@ local function escapeAttr(str) if type(str) ~= 'string' then str = tostring(str) end - for char, escape in pairs(htmlencodeMap) do - str = str:gsub(char, escape) - end - return str + 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 props {classes?: string[], css?: table, attributes?: table} ---@param renderedChildren string? ---@return string local function buildHtmlString(tag, props, renderedChildren) From b37b51a72e6d7a54501789851c5639648c35d8b0 Mon Sep 17 00:00:00 2001 From: Rikard Blixt Date: Wed, 13 May 2026 11:58:27 +0200 Subject: [PATCH 5/7] theorical improvment --- lua/wikis/commons/Widget/Renderer.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/wikis/commons/Widget/Renderer.lua b/lua/wikis/commons/Widget/Renderer.lua index f7304401a71..da73b5c6076 100644 --- a/lua/wikis/commons/Widget/Renderer.lua +++ b/lua/wikis/commons/Widget/Renderer.lua @@ -66,7 +66,7 @@ local function buildHtmlString(tag, props, renderedChildren) if props.css then table.insert(buffer, ' style="') for key, value in pairs(props.css) do - table.insert(buffer, key .. ':' .. escapeAttr(tostring(value)) .. ';') + table.insert(buffer, key .. ':' .. escapeAttr(value) .. ';') end table.insert(buffer, '"') end From 0df49cdadc61cd8b6de3222450c433a19f770918 Mon Sep 17 00:00:00 2001 From: Rikard Blixt Date: Wed, 13 May 2026 13:48:20 +0200 Subject: [PATCH 6/7] possible improvement --- lua/wikis/commons/Widget/Renderer.lua | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/lua/wikis/commons/Widget/Renderer.lua b/lua/wikis/commons/Widget/Renderer.lua index da73b5c6076..2c24b32dde7 100644 --- a/lua/wikis/commons/Widget/Renderer.lua +++ b/lua/wikis/commons/Widget/Renderer.lua @@ -66,7 +66,10 @@ local function buildHtmlString(tag, props, renderedChildren) if props.css then table.insert(buffer, ' style="') for key, value in pairs(props.css) do - table.insert(buffer, key .. ':' .. escapeAttr(value) .. ';') + table.insert(buffer, key) + table.insert(buffer, ':') + table.insert(buffer, escapeAttr(value)) + table.insert(buffer, ';') end table.insert(buffer, '"') end @@ -76,10 +79,15 @@ local function buildHtmlString(tag, props, renderedChildren) if type(value) == 'boolean' then -- Boolean attributes like `disabled` or `checked` if value == true then - table.insert(buffer, ' ' .. key) + table.insert(buffer, ' ') + table.insert(buffer, key) end else - table.insert(buffer, ' ' .. key .. '="' .. escapeAttr(value) .. '"') + table.insert(buffer, ' ') + table.insert(buffer, key) + table.insert(buffer, '="') + table.insert(buffer, escapeAttr(value)) + table.insert(buffer, '"') end end end @@ -91,7 +99,9 @@ local function buildHtmlString(tag, props, renderedChildren) if renderedChildren and renderedChildren ~= '' then table.insert(buffer, renderedChildren) end - table.insert(buffer, '') + table.insert(buffer, '') end return table.concat(buffer) From b5a7155228012daa45c5eb916931e39a037419d0 Mon Sep 17 00:00:00 2001 From: Rikard Blixt Date: Wed, 13 May 2026 15:36:26 +0200 Subject: [PATCH 7/7] curious if this as any effect, doubt it though --- lua/wikis/commons/Widget/Renderer.lua | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lua/wikis/commons/Widget/Renderer.lua b/lua/wikis/commons/Widget/Renderer.lua index 2c24b32dde7..2b5887a7e41 100644 --- a/lua/wikis/commons/Widget/Renderer.lua +++ b/lua/wikis/commons/Widget/Renderer.lua @@ -46,6 +46,7 @@ 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 @@ -57,7 +58,7 @@ end local function buildHtmlString(tag, props, renderedChildren) local buffer = { '<', tag } - if props.classes and #props.classes > 0 then + if props.classes then table.insert(buffer, ' class="') table.insert(buffer, escapeAttr(table.concat(props.classes, ' '))) table.insert(buffer, '"')