Module:HtmlBuilder

From Minetest
Revision as of 09:36, 7 June 2022 by >FnControlOption (FnControlOption changed the content model of the page Module:HtmlBuilder from "plain text" to "Scribunto")
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)

Documentation for this module may be created at Module:HtmlBuilder/doc

--[[
    A module for building complex HTML from Lua using a
    fluent interface.

    Originally written on the English Wikipedia by
    Toohool and Mr. Stradivarius.

    Modified for the Minetest Wiki.

    Code released under the GPL v2+ as per:
    https://en.wikipedia.org/w/index.php?diff=next&oldid=581399786
    https://en.wikipedia.org/w/index.php?diff=next&oldid=581403025

    @license GNU GPL v2+
    @author Marius Hoch < hoo@online.de >
    @author MrIbby < siribby@outlook.com >
]]

local HtmlBuilder = {}
local options = mw.loadData( 'Module:HtmlBuilder/options' )

local util = require( 'Module:LibraryUtil' )
local checkType = util.checkType
local checkTypeMulti = util.checkTypeMulti

local metatable = {}
local methodtable = {}

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,
}

metatable.__index = methodtable

metatable.__tostring = function( t )
    local ret = {}
    t:_build( ret )
    return table.concat( ret )
end

-- Get an attribute table (name, value) and its index
--
-- @param name
local function getAttr( t, name )
    for i, attr in ipairs( t.attributes ) do
        if attr.name == name then
            return attr, i
        end
    end
end

-- Is this a valid attribute name?
--
-- @param s
local function isValidAttributeName( s )
    -- Good estimate: http://www.w3.org/TR/2000/REC-xml-20001006#NT-Name
    return s:match( '^[a-zA-Z_:][a-zA-Z0-9_.:-]*$' )
end

-- Is this a valid tag name?
--
-- @param s
local function isValidTag( s )
    return s:match( '^[a-zA-Z0-9]+$' )
end

-- Escape a value, for use in HTML
--
-- @param s
local function htmlEncode( s )
    -- The parentheses ensure that there is only one return value
    local tmp = string.gsub( s, '[<>&"]', options.htmlencodeMap );
    -- Don't encode strip markers here (T110143)
    tmp = string.gsub( tmp, options.encodedUniqPrefixPat, options.uniqPrefixRepl )
    tmp = string.gsub( tmp, options.encodedUniqSuffixPat, options.uniqSuffixRepl )
    return tmp
end

local function cssEncode( s )
    -- mw.ustring is so slow that it's worth searching the whole string
    -- for non-ASCII characters to avoid it if possible
    return ( string.find( s, '[^%z\1-\127]' ) and mw.ustring or string )
        -- XXX: I'm not sure this character set is complete.
        -- bug #68011: allow delete character (\127)
        .gsub( s, '[^\32-\57\60-\127]', function ( m )
            return string.format( '\\%X ', mw.ustring.codepoint( m ) )
        end )
end

-- Create a builder object. This is a separate function so that we can show the
-- correct error levels in both HtmlBuilder.create and metatable.tag.
--
-- @param tagName
-- @param args
local function createBuilder( tagName, args )
    if tagName ~= nil and tagName ~= '' and not isValidTag( tagName ) then
        error( string.format( "invalid tag name '%s'", tagName ), 3 )
    end

    args = args or {}
    local builder = {}
    setmetatable( builder, metatable )
    builder.nodes = {}
    builder.attributes = {}
    builder.styles = {}

    if tagName ~= '' then
        builder.tagName = tagName
    end

    builder.parent = args.parent
    builder.selfClosing = selfClosingTags[tagName] or args.selfClosing or false
    return builder
end

-- Append a builder to the current node. This is separate from methodtable.node
-- so that we can show the correct error level in both methodtable.node and
-- methodtable.wikitext.
--
-- @param builder
local function appendBuilder( t, builder )
    if t.selfClosing then
        error( "self-closing tags can't have child nodes", 3 )
    end

    if builder then
        table.insert( t.nodes, builder )
    end
    return t
end

methodtable._buildAttributes = function( t, ret )
    for i, attr in ipairs( t.attributes ) do
        table.insert(
            ret,
            -- Note: Attribute names have already been validated
            ' ' .. attr.name .. '="' .. htmlEncode( attr.val ) .. '"'
        )
    end
end

methodtable._buildStyles = function( t, ret )
    if #t.styles > 0 then
        table.insert( ret, ' style="' )
        local css = {}
        for i, prop in ipairs( t.styles ) do
            if type( prop ) ~= 'table' then -- added with cssText()
                table.insert( css, htmlEncode( prop ) )
            else -- added with css()
                table.insert(
                    css,
                    htmlEncode( cssEncode( prop.name ) .. ':' .. cssEncode( prop.val ) )
                )
            end
        end
        table.insert( ret, table.concat( css, ';' ) )
        table.insert( ret, '"' )
    end
end

methodtable._buildNodes = function( t, ret )
    for i, node in ipairs( t.nodes ) do
        if node then
            if type( node ) == 'table' then
                node:_build( ret )
            else
                table.insert( ret, tostring( node ) )
            end
        end
    end
end

methodtable._build = function( t, ret )
    if t.tagName then
        table.insert( ret, '<' .. t.tagName )
        t:_buildAttributes( ret )
        t:_buildStyles( ret )
        if t.selfClosing then
            table.insert( ret, ' />' )
            return
        end
        table.insert( ret, '>' )
    end
    t:_buildNodes( ret )
    if t.tagName then
        table.insert( ret, '</' .. t.tagName .. '>' )
    end
end

-- Append a builder to the current node
--
-- @param builder
methodtable.node = function( t, builder )
    return appendBuilder( t, builder )
end

-- Appends some markup to the node. This will be treated as wikitext.
methodtable.wikitext = function( t, ... )
    for k,v in ipairs{...} do
        checkTypeMulti( 'wikitext', k, v, { 'string', 'number' } )
        appendBuilder( t, v )
    end
    return t
end

-- Appends a newline character to the node.
methodtable.newline = function( t )
    return t:wikitext( '\n' )
end

-- Appends a new child node to the builder, and returns an HtmlBuilder instance
-- representing that new node.
--
-- @param tagName
-- @param args
methodtable.tag = function( t, tagName, args )
    checkType( 'tag', 1, tagName, 'string' )
    checkType( 'tag', 2, args, 'table', true )
    args = args or {}

    args.parent = t
    local builder = createBuilder( tagName, args )
    t:node( builder )
    return builder
end

-- Get the value of an html attribute
--
-- @param name
methodtable.getAttr = function( t, name )
    checkType( 'getAttr', 1, name, 'string' )

    local attr = getAttr( t, name )
    return attr and attr.val
end

-- Set an HTML attribute on the node.
--
-- @param name Attribute to set, alternative table of name-value pairs
-- @param val Value of the attribute. Nil causes the attribute to be unset
methodtable.attr = function( t, name, val )
    if type( name ) == 'table' then
        if val ~= nil then
            error(
                "bad argument #2 to 'attr' " ..
                '(if argument #1 is a table, argument #2 must be left empty)',
                2
            )
        end

        local callForTable = function()
            for attrName, attrValue in pairs( name ) do
                t:attr( attrName, attrValue )
            end
        end

        if not pcall( callForTable ) then
            error(
                "bad argument #1 to 'attr' " ..
                '(table keys must be strings, and values must be strings or numbers)',
                2
            )
        end

        return t
    end

    checkType( 'attr', 1, name, 'string' )
    checkTypeMulti( 'attr', 2, val, { 'string', 'number', 'nil' } )

    -- if caller sets the style attribute explicitly, then replace all styles
    -- previously added with css() and cssText()
    if name == 'style' then
        t.styles = { val }
        return t
    end

    if not isValidAttributeName( name ) then
        error( string.format(
            "bad argument #1 to 'attr' (invalid attribute name '%s')",
            name
        ), 2 )
    end

    local attr, i = getAttr( t, name )
    if attr then
        if val ~= nil then
            attr.val = val
        else
            table.remove( t.attributes, i )
        end
    elseif val ~= nil then
        table.insert( t.attributes, { name = name, val = val } )
    end

    return t
end

-- Adds a class name to the node's class attribute. Spaces will be
-- automatically added to delimit each added class name.
--
-- @param class
methodtable.addClass = function( t, class )
    checkTypeMulti( 'addClass', 1, class, { 'string', 'number', 'nil' } )

    if class ~= nil then
        local attr = getAttr( t, 'class' )
        if attr then
            attr.val = attr.val .. ' ' .. class
        else
            t:attr( 'class', class )
        end
    end
    return t
end

-- Set a CSS property to be added to the node's style attribute.
--
-- @param name CSS attribute to set, alternative table of name-value pairs
-- @param val The value to set. Nil causes it to be unset
methodtable.css = function( t, name, val )
    if type( name ) == 'table' then
        if val ~= nil then
            error(
                "bad argument #2 to 'css' " ..
                '(if argument #1 is a table, argument #2 must be left empty)',
                2
            )
        end

        local callForTable = function()
            for attrName, attrValue in pairs( name ) do
                t:css( attrName, attrValue )
            end
        end

        if not pcall( callForTable ) then
            error(
                "bad argument #1 to 'css' " ..
                '(table keys and values must be strings or numbers)',
                2
            )
        end

        return t
    end

    checkTypeMulti( 'css', 1, name, { 'string', 'number' } )
    checkTypeMulti( 'css', 2, val, { 'string', 'number', 'nil' } )

    for i, prop in ipairs( t.styles ) do
        if prop.name == name then
            if val ~= nil then
                prop.val = val
            else
                table.remove( t.styles, i )
            end
            return t
        end
    end

    if val ~= nil then
        table.insert( t.styles, { name = name, val = val } )
    end

    return t
end

-- Add some raw CSS to the node's style attribute. This is typically used
-- when a template allows some CSS to be passed in as a parameter
--
-- @param css
methodtable.cssText = function( t, css )
    checkTypeMulti( 'cssText', 1, css, { 'string', 'number', 'nil' } )
    table.insert( t.styles, css )
    return t
end

-- Returns the parent node under which the current node was created. Like
-- jQuery.end, this is a convenience function to allow the construction of
-- several child nodes to be chained together into a single statement.
methodtable.done = function( t )
    return t.parent or t
end

-- Like .done(), but traverses all the way to the root node of the tree and
-- returns it.
methodtable.allDone = function( t )
    while t.parent do
        t = t.parent
    end
    return t
end

-- Create a new instance
--
-- @param tagName
-- @param args
function HtmlBuilder.create( tagName, args )
    checkType( 'HtmlBuilder.create', 1, tagName, 'string', true )
    checkType( 'HtmlBuilder.create', 2, args, 'table', true )
    return createBuilder( tagName, args )
end

return HtmlBuilder