Module:Swatches

From Dune: Awakening Community Wiki
Jump to navigation Jump to search

Module:Swatches

Renders cosmetic swatches defined in Module:Swatches/data.

This module provides two functions:

  • get - renders a single swatch infobox by name.
  • list - renders a filtered list of swatch infoboxes.

Usage

Get a single swatch

Renders a single swatch infobox by name. The swatch name is case-sensitive and must match the key defined in Module:Swatches/data.

{{#invoke:Swatches|get|<NAME>}}

List all swatches

If no scope is provided, all swatches in the dataset are shown:

{{#invoke:Swatches|list}}

List swatches for a scope page

Scope can be specified to show all swatches that are compatible with at least one class in that scope:

{{#invoke:Swatches|list|<SCOPE>}}

List swatches for a class page

Class can additionally be specified to show only swatches that are compatible with that class:

{{#invoke:Swatches|list|<SCOPE>|<CLASS>}}

Render swatches expanded

By default, the swatch list is collapsed. To render it expanded, pass a truthy value to the expanded parameter.

{{#invoke:Swatches|list|expanded=yes}}

Parameters

get

1
string
Swatch name (case-sensitive). Must match a key in Module:Swatches/data.
Returns an error message if the swatch does not exist.

list

1 (optional)
string
Compatibility scope to filter swatches (case-insensitive).
If omitted, all swatches are shown.
If specified, must match a scope defined in Module:Swatches/data. An error message is returned if the value is invalid.
Valid values (singular and plural forms both accepted):
Vehicle
Weapon
Garment
Placeable
2 (optional)
string
Name of a class within the specified scope, to further filter swatch compatibility (case-insensitive).
{{PAGENAME}} can be used if the page title matches the dataset naming.
Examples:
Sandbike
Flamethrowers
Heavy Armor
expanded (optional)
boolean
If specified with a truthy value, output will be expanded by default.
Valid values:
true
yes
1

Notes

list

  • Listed content is placed inside a collapsible container, which is collapsed by default (mw-collapsible mw-collapsed).
  • When expanded, the swatch infobox cards are wrapped in an outer container that inherits the shared `flex-container` layout class, which provides standardised responsive flex behaviour across the wiki.
  • Swatch compatibility rules are defined in Module:Swatches/data using the optional scopes and onlyClasses fields.
  • class is matched case-insensitively against class names within onlyClasses tokens in Module:Swatches/data.

local p = {}

local data = require("Module:Swatches/data")

-- ---------- Helpers ----------

local function lowerTrim(s)
    return mw.ustring.lower(mw.text.trim(s or ""))
end

local function isTruthy(s)
    s = lowerTrim(s)
    return s == "true" or s == "yes" or s == "1"
end

local function listHasToken(list, token)
    token = lowerTrim(token)

    for _, item in ipairs(list) do
        if lowerTrim(item) == token then return true end
    end

    return
end

-- ---------- Data ----------

local SORTED_DATA = {}
for name, swatch in pairs(data) do
    swatch.name = name
    SORTED_DATA[#SORTED_DATA + 1] = swatch
end

table.sort(SORTED_DATA, function(a, b)
    return a.name:lower() < b.name:lower()
end)

local SCOPE_MAP = {
    vehicle = "Vehicles",
    weapon = "Weapons",
    garment = "Garments",
    placeable = "Placeables",
}

-- ---------- Matching ----------

local function normalizeInputScope(s)
    s = lowerTrim(s)

    if s == "" then return "" end

    -- match singular
    if SCOPE_MAP[s] then return s end

    -- match plural
    if s:sub(-1) == "s" then
        local singular = s:sub(1, -2)
        if SCOPE_MAP[singular] then return singular end
    end

    return
end

local function deriveCompatibilityDisplay(swatch)
    local scopes = swatch.scopes
    if not scopes or #scopes == 0 then
        scopes = { "all" }
    end

    if listHasToken(scopes, "all") then return end

    local classByScope = {}

    if swatch.onlyClasses then
        for _, token in ipairs(swatch.onlyClasses) do
            local scope, class = token:match("^([^:]+):(.+)$")

            if scope and class then
                classByScope[scope] = classByScope[scope] or {}
                table.insert(classByScope[scope], class)
            end
        end
    end

    local labels = {}

    for _, scope in ipairs(scopes) do
        local classes = classByScope[scope]

        if classes and #classes > 0 then
            for _, class in ipairs(classes) do
                labels[#labels + 1] = class
            end
        else
            labels[#labels + 1] = "All " .. (SCOPE_MAP[scope] or scope)
        end
    end

    table.sort(labels)

    return labels
end

local function swatchAppliesTo(swatch, scope, class)
    -- if no scope specified, show all swatches
    if scope == "" then return true end

    -- if 'scopes' not specified in dataset, default to "all"
    local swatchScopes = swatch.scopes or { "all" }
    if listHasToken(swatchScopes, "all") then return true end

    -- otherwise must match the requested scope
    if not listHasToken(swatchScopes, scope) then return end

    -- if no class specified, scope match is enough
    if class == "" then return true end

    -- if 'onlyClasses' is not present, applies to all classes in this scope
    if not swatch.onlyClasses or #swatch.onlyClasses == 0 then return true end

    local hasRestrictionForScope = false
    local token = scope .. ":" .. class

    for _, classToken in ipairs(swatch.onlyClasses) do
        local normalized = lowerTrim(classToken)

        if normalized:match("^" .. scope .. ":") then
            hasRestrictionForScope = true
            if normalized == token then return true end
        end
    end

    -- if onlyClass was specified for this scope, and none matched → false
    -- if there were no restrictions for this scope → true
    return not hasRestrictionForScope
end

-- ---------- Rendering ----------

local function renderColorBox(color)
    if not color or color == "" then
        return mw.html.create("span"):wikitext("NULL")
    end

    local box = mw.html.create("div")
        :addClass("copyable")
        :css({
            width = "38px",
            height = "38px",
            ["background-color"] = color,
            display = "inline-block",
            border = "1px solid #000"
        })

    -- copyable color code
    box:tag("span")
        :attr("data-value", color)
        :addClass("copy-text")

    -- tooltip anchor
    box:tag("span")
        :attr("aria-hidden", "true")
        :addClass("tooltiptext")
        :wikitext("Copy to clipboard")        

    return box
end

local function renderCompatibilityHTML(swatch)
    local labels = deriveCompatibilityDisplay(swatch)

    if not labels then
        return mw.html.create("span"):wikitext("All")
    end

    if #labels == 1
        then return mw.html.create("span"):wikitext(labels[1])
    end

    local ul = mw.html.create("ul")
    for _, label in ipairs(labels) do
        ul:tag("li"):wikitext(label)
    end

    return ul
end

local function renderInfoboxHTML(swatch)
    local infobox = mw.html.create("table")
        :addClass("infobox")

    -- title
    local trAbove = infobox:tag("tr")
    local thAbove = trAbove:tag("th")
        :attr("colspan", "2")
        :addClass("infobox-above")

    if swatch.link then
        thAbove:wikitext(string.format("[[%s|%s]]", swatch.link, swatch.name))
    else
        thAbove:wikitext(swatch.name)
    end

    -- image
    local trImage = infobox:tag("tr")
    local tdImage = trImage:tag("td")
        :attr("colspan", "2")
        :addClass("infobox-image")

    local image = string.format(
        "[[File:%s|200px|link=",
        swatch.image or "Placeholder.jpg"
    )

    if swatch.link then
        image = image .. swatch.link
    end

    tdImage:wikitext(image .. "]]")

    -- color boxes
    local trColors = infobox:tag("tr")
    local tdColors = trColors:tag("td")
        :attr("colspan", "2")
        :addClass("infobox-full-data")

    local div = tdColors:tag("div")
        :css("white-space", "nowrap")

    local colors = swatch.colors
    for i = 1, 4 do
        div:node(renderColorBox(colors and colors[i]))
    end

    -- cosmetic type
    local trType = infobox:tag("tr")

    trType:tag("th")
        :attr("scope", "row")
        :addClass("infobox-label")
        :wikitext("[[Cosmetics|Cosmetic]] Type")

    trType:tag("td")
        :addClass("infobox-data")
        :wikitext("Swatch")

    -- compatibility
    local trCompat = infobox:tag("tr")

    trCompat:tag("th")
        :attr("scope", "row")
        :addClass("infobox-label")
        :wikitext("Compatibility")

    local tdCompat = trCompat:tag("td")
        :addClass("infobox-data")

    tdCompat:node(renderCompatibilityHTML(swatch))

    return infobox
end

function p.get(frame)
    local name = mw.text.trim(frame.args[1] or "")

    if name == "" then
        return '<span class="error">⚠ No swatch name provided.</span>'
    end

    -- lookup swatch by key (case-sensitive)
    local lookup = data[name]

    if not lookup then
        return string.format(
            '<span class="error">⚠ Swatch "%s" not found. Note that name is case-sensitive.</span>',
            mw.text.encode(name)
        )
    end

    -- insert name into swatch
    local swatch = mw.clone(lookup)
    swatch.name = name

    -- force infobox to float left by default
    local content = mw.html.create("div")
        :css({float = "left"})
        :node(renderInfoboxHTML(swatch))

    return tostring(content)
end

function p.list(frame)
    local args = frame.args
    local scope = normalizeInputScope(args[1])

    if args[1] and args[1] ~= "" and not scope then
        return string.format(
            '<span class="error">⚠ Invalid scope "%s".</span>',
            mw.text.encode(args[1])
        )
    end

    local class = lowerTrim(args[2])

    local outer = mw.html.create("div")
        :addClass("mw-collapsible")

    if not isTruthy(args.expanded) then outer:addClass("mw-collapsed") end

    outer:tag("h2"):wikitext("Swatches")

    local content = outer:tag("div")
        :addClass("mw-collapsible-content")
        :addClass("flex-container")

    local rendered = false
    for _, swatch in ipairs(SORTED_DATA) do
        if swatchAppliesTo(swatch, scope, class) then
            content:node(renderInfoboxHTML(swatch))
            rendered = true
        end
    end

    if not rendered then return "No matching swatches." end

    return tostring(outer)
end

return p