diff --git a/lua/wikis/commons/Widget/Renderer.lua b/lua/wikis/commons/Widget/Renderer.lua index c2674a9a91a..3da066c886d 100644 --- a/lua/wikis/commons/Widget/Renderer.lua +++ b/lua/wikis/commons/Widget/Renderer.lua @@ -12,6 +12,94 @@ 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 +---@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 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 key, value in pairs(props.css) do + table.insert(buffer, key .. ':' .. escapeAttr(tostring(value)) .. ';') + 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, ' ' .. key) + end + else + table.insert(buffer, ' ' .. key .. '="' .. escapeAttr(value) .. '"') + 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 +168,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