Edge Rewrite
// HTMLRewriter · presentation

This page was redesigned at the edge.

Cloudflare fetched the original article and streamed it through HTMLRewriter to apply an entirely new visual system without rebuilding the source page.

// request.cf · coarse context

A page that knows where it met you.

Only coarse request metadata is shown. This demo does not display or persist visitor IP addresses.

Country
US
Cloudflare location
CMH
Connection
HTTP/2
Language
Not provided

Ray ID: a24368728eef07d6

Jump to content

Module:Sandbox/TheUnit 72/Universal clickable button/sandbox

From Wikipedia, the free encyclopedia
local p = {}

local colorMap = {
    red = "#d33333",
    ["deep red"] = "#8b0000",
    ["light red"] = "#ff7f7f",

    orange = "#ff8c00",
    ["light orange"] = "#ffd1a3",

    yellow = "#ffeb3b",
    gold = "#d4af37",
    ["deep yellow"] = "#d4af37",
    ["light yellow"] = "#fff59d",

    green = "#4caf50",
    ["deep green"] = "#1b5e20",
    ["light green"] = "#88e788",

    turquoise = "#40e0d0",
    turqoise = "#40e0d0",

    blue = "#3366cc",
    ["deep blue"] = "#0d47a1",
    ["light blue"] = "#90caf9",

    purple = "#7953a9",
    ["deep purple"] = "#4a148c",
    ["light purple"] = "#ce93d8",

    pink = "#e91e63",
    magenta = "#c2185b",

    white = "#ffffff",
    black = "#000000",

    grey = "#a2a9b1",
    gray = "#a2a9b1",
    silver = "#c8ccd1",
    ["light grey"] = "#f8f9fa",
    ["deep grey"] = "#54595d",
    ["charcoal"] = "#36454f",

    beige = "#f8eaba",
    ["deep beige"] = "#c0c090",

    ["progressive"] = "#3366cc",
    ["destructive"] = "#bf3c2c",
    ["constructive"] = "#008000",
    ["effective"] = "#d4af37",
    ["neutral"] = "#f8f9fa"
}

local fontMap = {
    ["sans-serif"]      = "sans-serif",
    ["sans"]            = "sans-serif",
    ["serif"]           = "serif",
    ["monospace"]       = "monospace",
    ["mono"]            = "monospace",
    ["georgia"]         = "Georgia, serif",
    ["palatino"]        = "Palatino, serif",
    ["garamond"]        = "Garamond, serif",
    ["helvetica"]       = "Helvetica, Arial, sans-serif",
    ["arial"]           = "Arial, sans-serif",
    ["verdana"]         = "Verdana, sans-serif",
    ["trebuchet"]       = "Trebuchet, sans-serif",
    ["tahoma"]          = "Tahoma, sans-serif",
    ["courier"]         = "Courier, monospace",
    ["times"]           = "Times, serif",
    ["linux libertine"] = "Georgia, serif"
}

local DEFAULT_BG     = "#f8f9fa"
local DEFAULT_FG     = "#000"

local SPECIAL_BORDERS = {
    ["#f8f9fa"] = "#a2a9b1",
    ["#f8eaba"] = "#c0c090"
}

local SIGNAL_PRESETS = {
    activate   = true,
    deactivate = true,
    toggle     = true
}

local CONTENT_PRESETS = {
    invoke   = true,
    dropdown = true
}

local DEFAULT_PAD_V  = 5
local DEFAULT_PAD_H  = 12
local DEFAULT_FONT   = 14
local DEFAULT_RADIUS = 2

local function normalizeKey(str)
    if not str then return nil end
    str = tostring(str):lower()
        :gsub("^%s+", "")
        :gsub("%s+$", "")
        :gsub("%s+", " ")
    str = str:gsub("^dark%s+", "deep ")
    return str
end

local function resolveColor(input)
    if not input then return nil end
    local key = normalizeKey(input)
    if colorMap[key] then return colorMap[key] end
    if key:match("^#%x%x%x$") or key:match("^#%x%x%x%x%x%x$") then return key end
    if key:match("^%x%x%x$") or key:match("^%x%x%x%x%x%x$") then return "#" .. key end
    return nil
end

local function expandHex(hex)
    hex = hex:gsub("#", "")
    if #hex == 3 then
        return hex:sub(1,1) .. hex:sub(1,1)
            .. hex:sub(2,2) .. hex:sub(2,2)
            .. hex:sub(3,3) .. hex:sub(3,3)
    elseif #hex == 6 then
        return hex
    end
    return "808080"
end

local function getRGB(hex)
    local h = expandHex(hex)
    local r = tonumber(h:sub(1,2), 16)
    local g = tonumber(h:sub(3,4), 16)
    local b = tonumber(h:sub(5,6), 16)
    return r, g, b
end

local function getLuminance(hex)
    local r, g, b = getRGB(hex)
    return (0.299 * r + 0.587 * g + 0.114 * b)
end

local function getTextColor(bg)
    return (getLuminance(bg) > 186) and "#000" or "#fff"
end

local function isLightnessScale(hex)
    local r, g, b = getRGB(hex)
    local max = math.max(r, g, b)
    local min = math.min(r, g, b)
    return (max - min) <= 20
end

local function shiftColor(hex, factor)
    local r, g, b = getRGB(hex)
    r = math.min(255, math.max(0, math.floor(r * factor)))
    g = math.min(255, math.max(0, math.floor(g * factor)))
    b = math.min(255, math.max(0, math.floor(b * factor)))
    return string.format("#%02x%02x%02x", r, g, b)
end

local function luminanceDiff(hex1, hex2)
    return math.abs(getLuminance(hex1) - getLuminance(hex2))
end

local DARKEN_FACTOR  = 0.70
local LIGHTEN_FACTOR = 1 / DARKEN_FACTOR
local MIN_DIFF       = 12

local function isTruthy(val)
    if not val then return false end
    local s = tostring(val):lower():gsub("^%s+",""):gsub("%s+$","")
    return s == "yes" or s == "true" or s == "1"
end

local function computeBorderColor(bg, lum, args)
    local override = args.bordercolor
        and tostring(args.bordercolor):gsub("^%s+",""):gsub("%s+$","")
        or nil
    if override and override ~= "" then
        return resolveColor(override) or override
    end

    if SPECIAL_BORDERS[bg] then
        return SPECIAL_BORDERS[bg]
    end

    local darkborder = isTruthy(args.darkborder)
    local autoborder = isTruthy(args.autoborder) or isTruthy(args.allborder)

    if isLightnessScale(bg) then
        if lum > 186 then
            return "#000000"
        elseif darkborder then
            return "#ffffff"
        end
    end

    if autoborder then
        local darkened = shiftColor(bg, DARKEN_FACTOR)
        if luminanceDiff(bg, darkened) >= MIN_DIFF then
            return darkened
        end
        local lightened = shiftColor(bg, LIGHTEN_FACTOR)
        if luminanceDiff(bg, lightened) >= MIN_DIFF then
            return lightened
        end
        return "#000000"
    end

    return nil
end

local function parseSize(raw)
    if not raw or raw == "" then return nil end
    raw = raw:gsub("%s+", "")

    local factor = raw:match("^([%d%.]+)x$")
    if factor then
        local f = tonumber(factor)
        if f and f > 0 then
            return { mode = "scale", factor = f }
        end
    end

    local w, h = raw:match("^(%d+)px,(%d+)px$")
    if w and h then
        return { mode = "px", w = tonumber(w), h = tonumber(h) }
    end
    local single = raw:match("^(%d+)px$")
    if single then
        local v = tonumber(single)
        return { mode = "px", w = v, h = v }
    end

    return nil
end

local function parsePadding(raw)
    if not raw or raw == "" then return 0, 0 end
    raw = raw:gsub("%s+", "")

    local v, h = raw:match("^(%d+)px,(%d+)px$")
    if v and h then return tonumber(v), tonumber(h) end

    local single = raw:match("^(%d+)px$")
    if single then
        local n = tonumber(single)
        return n, n
    end

    local bare = raw:match("^(%d+)$")
    if bare then
        local n = tonumber(bare)
        return n, n
    end

    return 0, 0
end

local function resolveFont(raw)
    if not raw or raw == "" then return nil end
    local key = tostring(raw):lower():gsub("^%s+",""):gsub("%s+$","")
    return fontMap[key] or nil
end

local function parseFontSize(raw)
    if not raw or raw == "" then return nil end
    raw = tostring(raw):gsub("%s+","")

    local mult = raw:match("^([%d%.]+)x$")
    if mult then
        local f = tonumber(mult)
        if f and f > 0 then return { mode = "scale", factor = f } end
    end

    local px = raw:match("^(%d+)px$")
    if px then return { mode = "px", value = tonumber(px) } end

    local bare = raw:match("^(%d+)$")
    if bare then return { mode = "px", value = tonumber(bare) } end

    return nil
end

local function pickBaseColor(args)
    local raw = args.color or args.colour or args[3]
    local resolved = resolveColor(raw)
    if resolved then
        return resolved, getTextColor(resolved)
    end
    return DEFAULT_BG, DEFAULT_FG
end

local function isValidUrl(url)
    if not url or url == "" then return false end
    return url:match("^https?://") ~= nil
        or url:match("^//") ~= nil
end

local ucbCounter = 0
local function nextUcbId()
    ucbCounter = ucbCounter + 1
    return "ucb-content-" .. ucbCounter
end

local function buildSiblingContent(frame, purposeinfo, contentId, isDropdown)
    local trimmed = purposeinfo:gsub("^%s+", ""):gsub("%s+$", "")
    local toExpand
    if trimmed:match("^Module:") then
        toExpand = "{{#invoke:" .. trimmed .. "}}"
    else
        toExpand = trimmed
    end

    local expanded = frame:preprocess(toExpand)
    local sibling  = mw.html.create("span")
    sibling
        :attr("id", contentId)
        :attr("data-ucb-content", "1")
        :css("display", "none")
    if isDropdown then
        sibling:addClass("ucb-dropdown")
    else
        sibling:addClass("ucb-content")
    end
    sibling:wikitext(expanded)
    return tostring(sibling)
end

local function hexToRgba(hex, alpha)
    local r, g, b = getRGB(hex)
    return string.format("rgba(%d, %d, %d, %s)", r, g, b, tostring(alpha))
end

local function buildDisplay(frame, args, bg, fg)
    local lum         = getLuminance(bg)
    local borderColor = computeBorderColor(bg, lum, args)

    local purpose     = args.purpose     and normalizeKey(args.purpose)     or nil
    local longpurpose = args.longpurpose and normalizeKey(args.longpurpose) or nil
    local purposeinfo = args.purposeinfo and tostring(args.purposeinfo)     or nil

    local sizeSpec         = parseSize(args.size)
    local addPadV, addPadH = parsePadding(args.padding)
    local fontFamily       = resolveFont(args.font)
    local fontSizeSpec     = parseFontSize(args.fontsize)

    local span = mw.html.create("span")
    span
        :addClass("ucb-button")
        :css({
            ["background-color"] = bg,
            ["color"]            = fg,
            ["vertical-align"]   = "middle",
            ["box-sizing"]       = "border-box"
        })

    local borderweight = args.borderweight and tonumber(args.borderweight) or nil
    if borderweight and borderweight > 0 and borderColor then
        span:css("border-color", "transparent")
        span:css(
            "box-shadow",
            string.format(
                "inset 0 0 0 %dpx %s",
                math.floor(borderweight),
                hexToRgba(borderColor, 1)
            )
        )
    elseif borderColor then
        span:css("border-color", borderColor)
    end

    if fontFamily then
        span:css("font-family", fontFamily)
    end

    local finalFontSize = nil
    if fontSizeSpec then
        if fontSizeSpec.mode == "px" then
            if sizeSpec and sizeSpec.mode == "scale" then
                finalFontSize = math.floor(fontSizeSpec.value * sizeSpec.factor)
            else
                finalFontSize = fontSizeSpec.value
            end
        elseif fontSizeSpec.mode == "scale" then
            local base = DEFAULT_FONT * fontSizeSpec.factor
            if sizeSpec and sizeSpec.mode == "scale" then
                finalFontSize = math.floor(base * sizeSpec.factor)
            else
                finalFontSize = math.floor(base)
            end
        end
    elseif sizeSpec and sizeSpec.mode == "scale" then
        finalFontSize = math.floor(DEFAULT_FONT * sizeSpec.factor)
    end

    if finalFontSize then
        span:css("font-size", finalFontSize .. "px")
    end

    if sizeSpec then
        if sizeSpec.mode == "scale" then
            local f = sizeSpec.factor
            span:css("border-radius", math.floor(DEFAULT_RADIUS * f) .. "px")
            span:css("padding",
                math.floor((DEFAULT_PAD_V + addPadV) * f) .. "px "
                .. math.floor((DEFAULT_PAD_H + addPadH) * f) .. "px"
            )
        elseif sizeSpec.mode == "px" then
            span:css("width",    sizeSpec.w .. "px")
            span:css("height",   sizeSpec.h .. "px")
            span:css("overflow", "hidden")
            span:css("padding",
                (DEFAULT_PAD_V + addPadV) .. "px "
                .. (DEFAULT_PAD_H + addPadH) .. "px"
            )
        end
    else
        if addPadV > 0 or addPadH > 0 then
            span:css("padding",
                (DEFAULT_PAD_V + addPadV) .. "px "
                .. (DEFAULT_PAD_H + addPadH) .. "px"
            )
        end
    end

    local siblingHtml = nil

    if purpose and purpose ~= "" then
        if not purposeinfo or purposeinfo == "" then
            return '<span class="error">Universal Clickable Button: '
                .. 'purpose="' .. purpose .. '" requires purposeinfo.</span>'
        end

        if SIGNAL_PRESETS[purpose] then
            span:attr("data-purpose",     purpose)
            span:attr("data-purposeinfo", purposeinfo)
        elseif CONTENT_PRESETS[purpose] then
            local contentId = nextUcbId()
            span:attr("data-purpose",     purpose)
            span:attr("data-purposeinfo", contentId)
            siblingHtml = buildSiblingContent(
                frame, purposeinfo, contentId, purpose == "dropdown"
            )
        else
            local contentId = nextUcbId()
            span:attr("data-purpose",     "raw")
            span:attr("data-purposeinfo", contentId)
            siblingHtml = buildSiblingContent(
                frame, purpose, contentId, false
            )
        end
    end

    if longpurpose and longpurpose ~= "" then
        if not purposeinfo or purposeinfo == "" then
            return '<span class="error">Universal Clickable Button: '
                .. 'longpurpose="' .. longpurpose .. '" requires purposeinfo.</span>'
        end

        if SIGNAL_PRESETS[longpurpose] then
            span:attr("data-longpurpose",     longpurpose)
            span:attr("data-longpurposeinfo", purposeinfo)
        elseif CONTENT_PRESETS[longpurpose] then
            local contentId = nextUcbId()
            span:attr("data-longpurpose",     longpurpose)
            span:attr("data-longpurposeinfo", purposeinfo)
            span:attr("data-longcontent-id",  contentId)

            local sibling = mw.html.create("span")
            sibling
                :attr("id", contentId)
                :attr("data-ucb-content", "1")
                :css("display", "none")
            if longpurpose == "dropdown" then
                sibling:addClass("ucb-dropdown")
            else
                sibling:addClass("ucb-content")
            end
            siblingHtml = (siblingHtml or "") .. tostring(sibling)
        else
            local contentId = nextUcbId()
            span:attr("data-longpurpose",     "raw")
            span:attr("data-longpurposeinfo", purposeinfo)
            span:attr("data-longcontent-id",  contentId)

            local sibling = mw.html.create("span")
            sibling
                :attr("id", contentId)
                :attr("data-ucb-content", "1")
                :css("display", "none")
                :addClass("ucb-content")
            siblingHtml = (siblingHtml or "") .. tostring(sibling)
        end
    end

    local userClass = args.class
        and tostring(args.class):lower():gsub("^%s+", ""):gsub("%s+$", "")
        or nil
    if userClass and userClass ~= "" and userClass ~= "mw-ui-button" then
        span:addClass(userClass)
    end

    if args.tooltip and tostring(args.tooltip) ~= "" then
        span:attr("title", tostring(args.tooltip))
    end

    if args.style then
        span:cssText(args.style)
    end

    local text = args.text or args[2] or args[1] or ""

    if args.icon and tostring(args.icon) ~= "" then
        local filename = tostring(args.icon):gsub("^[Ff]ile:", "")
        span:wikitext(string.format('[[File:%s|16px|link=]]&nbsp; ', filename))
    end

    span:wikitext(text)

    local result = tostring(span)
    if siblingHtml then
        result = result .. siblingHtml
    end
    return result
end

function p.main(frame)
    local args = frame:getParent().args

    local styleLink = frame:extensionTag("templatestyles", "", {
        src = "Module:Universal clickable button/styles.css"
    })

    local purpose     = args.purpose     and normalizeKey(args.purpose)     or nil
    local longpurpose = args.longpurpose and normalizeKey(args.longpurpose) or nil
    local hasPurpose  = (purpose and purpose ~= "")
        or (longpurpose and longpurpose ~= "")

    local bg, fg = pickBaseColor(args)

    if hasPurpose then
        return styleLink .. buildDisplay(frame, args, bg, fg)
    end

    local link = args.url or args[1] or args.link
    if not link or tostring(link) == "" then
        return ""
    end

    local isUrl = args.url ~= nil and tostring(args.url) ~= ""

    if isUrl and not isValidUrl(tostring(args.url)) then
        return '<span class="error">Universal Clickable Button: invalid or unsafe URL.</span>'
    end

    local useHover = not isUrl
        and args.hover
        and tostring(args.hover):lower():gsub("^%s+", ""):gsub("%s+$", "") == "yes"

    if not isUrl then
        local currentTitle = mw.title.getCurrentTitle()
        local targetTitle  = mw.title.new(tostring(link))
        if targetTitle and currentTitle
            and targetTitle.prefixedText == currentTitle.prefixedText then
            return styleLink .. buildDisplay(frame, args, bg, fg)
        end
    end

    local display = buildDisplay(frame, args, bg, fg)
    local linkHtml

    if useHover then
        linkHtml = string.format('[[%s|%s]]', tostring(link), display)
        return styleLink .. string.format('<span class="plainlinks">%s</span>', linkHtml)
    end

    local href
    if isUrl then
        href = tostring(args.url)
    else
        local titleObj = mw.title.new(tostring(link))
        href = titleObj and titleObj:fullUrl() or tostring(link)
    end

    linkHtml = string.format('[%s %s]', href, display)
    return styleLink .. string.format('<span class="plainlinks">%s</span>', linkHtml)
end

return p