Module:User:Gommeh/CategoryView
Appearance
-- Define module table
local p = {}
--------------------------------------------------------
-- Utility functions
--------------------------------------------------------
local function trim(s)
return mw.text.trim(s or "")
end
local function split(s, sep)
s = s or ""
local out = {}
if s == "" then
return out
end
for part in mw.text.gsplit(s, sep, true) do
table.insert(out, part)
end
return out
end
local function startsWith(str, prefix)
return str:sub(1, #prefix) == prefix
end
-- Remove ALL templates ({{...}}) – used only for sort cleaning
local function stripTemplates(s)
s = s or ""
local n
repeat
s, n =
mw.ustring.gsub(
s,
"%b{}",
function(m)
-- keep only non-template braces; %b{} matches {}, we need {{...}} specifically
if mw.ustring.sub(m, 1, 2) == "{{" and mw.ustring.sub(m, -2) == "}}" then
return ""
end
return m
end
)
until n == 0
return mw.text.trim(s)
end
-- Yank [[A|B]] -> B ; [[A]] -> A
local function linksToText(s)
s = s or ""
s = mw.ustring.gsub(s, "%[%[([^%]|%]]-)|([^%]]-)%]%]", "%2")
s = mw.ustring.gsub(s, "%[%[([^%]]-)%]%]", "%1")
return mw.text.trim(s)
end
-- Remove any namespace prefix (everything before the first colon)
local function removeNamespacePrefix(s)
s = s or ""
-- This removes up to and including the first colon, if present
local colonPos = mw.ustring.find(s, ":", 1, true)
if colonPos then
s = mw.ustring.sub(s, colonPos + 1)
end
return mw.text.trim(s)
end
-- Clean a string for sorting: strip templates/refs/html, convert links, drop '' markup
local function cleanForSort(s)
s = s or ""
-- strip specific markup first
s = mw.ustring.gsub(s, "<ref.-</ref>", "")
s = mw.ustring.gsub(s, "<.->", "")
s = stripTemplates(s)
s = linksToText(s)
s = mw.ustring.gsub(s, "''+", "")
s = mw.ustring.gsub(s, "%s+", " ")
return mw.text.trim(s)
end
-- Extract first link target's BASEPAGENAME (for |sort=base)
local function targetBaseName(w)
if not w or w == "" then
return nil
end
local inner = mw.ustring.match(w, "%[%[([^%]]+)%]%]")
if not inner then
return nil
end
inner = mw.text.trim(inner)
local cut = #inner + 1
local p1 = inner:find("|", 1, true)
if p1 and p1 < cut then
cut = p1
end
local p2 = inner:find("#", 1, true)
if p2 and p2 < cut then
cut = p2
end
local target = mw.text.trim(inner:sub(1, cut - 1))
if target == "" then
return nil
end
local t = mw.title.new(target)
if t and t.baseText and t.baseText ~= "" then
return t.baseText
end
local last = mw.ustring.match(target, "([^/]+)$")
return last or target
end
-- Detect and remove {{icon|...}} (case/spacing tolerant), returning cleaned text + array of icons
local function extractIcons(w)
w = w or ""
local icons = {}
-- Match {{ icon | ... }} in a case-insensitive way; capture the whole template to restore later
local cleaned =
mw.ustring.gsub(
w,
"%{%{%s*[Ii][Cc][Oo][Nn]%s*|.-%}%}",
function(m)
table.insert(icons, trim(m))
return ""
end
)
cleaned = mw.ustring.gsub(cleaned, "%s%s+", " ")
return trim(cleaned), icons
end
--------------------------------------------------------
-- Strip leading articles (e.g., "The", "A", "An") from sort key
--------------------------------------------------------
local function stripArticles(s, ignoreList)
s = s or ""
local low = mw.ustring.lower(s)
for _, art in ipairs(ignoreList or {}) do
local a = mw.ustring.lower(trim(art))
if a ~= "" then
if mw.ustring.sub(low, 1, #a + 1) == a .. " " then
return trim(mw.ustring.sub(s, #a + 2))
end
end
end
return s
end
--------------------------------------------------------
-- Determine the first character heading for grouping
--------------------------------------------------------
local function firstHeadingChar(s)
local c = mw.ustring.sub(s or "", 1, 1)
if not c or c == "" then
return "#"
end
local upper = mw.ustring.upper(c)
if upper:match("%a") then
return upper
end
return "#"
end
--------------------------------------------------------
-- Parse a single list item into components
--------------------------------------------------------
local function parseItem(raw, useBase)
raw = trim(raw)
if raw == "" then
return nil
end
local left, explicitSort = raw, nil
-- Allow explicit sort key using "||"
if raw:find("%|%|", 1, true) then
local parts = mw.text.split(raw, "||", true)
left = trim(parts[1] or "")
explicitSort = trim(parts[2] or "")
end
-- Pull icons out FIRST so they can't contaminate sort
local leftNoIcons, icons = extractIcons(left)
-- Helpers: detect wikilinks/templates anywhere in the string
local function hasWikilink(s)
return mw.ustring.find(s or "", "%[%[[^%]]+%]%]") ~= nil
end
local function hasTemplate(s)
return mw.ustring.find(s or "", "%{%{") ~= nil
end
-- Preserve exact wikitext for display (minus icons).
-- Autolink ONLY if there's no link already AND no templates present.
local displayWikitext
if hasWikilink(leftNoIcons) or hasTemplate(leftNoIcons) then
-- already linked or contains templates; do NOT wrap
displayWikitext = trim(leftNoIcons)
else
if leftNoIcons == "" then
return nil
end
displayWikitext = "[[" .. leftNoIcons .. "]]"
end
-- *** KEY FIX: derive the primary visible text from the link ***
local linkVisible = linksToText(leftNoIcons)
if linkVisible == "" then
-- fallback: if no link present, use cleaned plain text
linkVisible = cleanForSort(leftNoIcons)
end
-- strip namespace prefixes like Category:, File:, Template:, etc.
linkVisible = removeNamespacePrefix(linkVisible)
-- Build sort basis
local sortBasis
if explicitSort and explicitSort ~= "" then
sortBasis = explicitSort
elseif useBase then
sortBasis = targetBaseName(leftNoIcons) or linkVisible
else
sortBasis = linkVisible
end
-- Final clean + tie-break text
local sortClean = cleanForSort(sortBasis)
local tieText = cleanForSort(linkVisible)
return {
wikitextBase = displayWikitext, -- WITHOUT icons
icons = icons, -- array of {{icon|...}} to reattach
displayText = tieText,
sort = sortClean
}
end
--------------------------------------------------------
-- Main render
--------------------------------------------------------
function p.render(frame)
local args = frame:getParent() and frame:getParent().args or frame.args
local pagesParam = args.pages or args.list or ""
local cols = tonumber(args.cols) or 3
if cols < 1 then
cols = 1
end
if cols > 6 then
cols = 6
end
local ignoreParam = args.ignore_prefix or args.ignore or ""
local ignoreList = {}
for _, x in ipairs(split(ignoreParam, ",")) do
local t = trim(x)
if t ~= "" then
table.insert(ignoreList, t)
end
end
local sortMode = (args.sort or args.sortby or ""):lower()
local useBase = (args.base == "1" or args.base == "yes" or sortMode == "base" or sortMode == "basepagename")
-- Parse input (newline, comma, semicolon)
local rawItems = {}
for _, sep in ipairs({"%c", ",", ";"}) do
if #rawItems == 0 then
if sep == "%c" then
for line in mw.text.gsplit(pagesParam, "\n", false) do
if trim(line) ~= "" then
table.insert(rawItems, line)
end
end
else
if #rawItems <= 1 then
rawItems = {}
for part in mw.text.gsplit(pagesParam, sep, false) do
if trim(part) ~= "" then
table.insert(rawItems, part)
end
end
end
end
end
end
if #rawItems == 0 and trim(pagesParam) ~= "" then
table.insert(rawItems, pagesParam)
end
-- Build items
local items = {}
for _, r in ipairs(rawItems) do
local it = parseItem(r, useBase)
if it then
it.sort = stripArticles(it.sort, ignoreList)
table.insert(items, it)
end
end
-- Group
local groups = {["#"] = {}}
for c = string.byte("A"), string.byte("Z") do
groups[string.char(c)] = {}
end
for _, it in ipairs(items) do
local first = mw.ustring.sub(it.sort or "", 1, 1)
local key = firstHeadingChar(mw.ustring.upper(first or ""))
if not groups[key] then
key = "#"
end
table.insert(groups[key], it)
end
-- Sort comparator
local function cmp(a, b)
local as = mw.ustring.upper(a.sort or "")
local bs = mw.ustring.upper(b.sort or "")
if as == bs then
local ad = mw.ustring.upper(a.displayText or "")
local bd = mw.ustring.upper(b.displayText or "")
return ad < bd
end
return as < bs
end
for _, tbl in pairs(groups) do
table.sort(tbl, cmp)
end
-- Render
local root = mw.html.create("div"):addClass("mw-category")
local colsDiv = root:tag("div"):addClass("mw-category-columns"):css("column-count", tostring(cols))
local order = {"#"}
for c = string.byte("A"), string.byte("Z") do
table.insert(order, string.char(c))
end
local hasAny = false
for _, key in ipairs(order) do
local tbl = groups[key]
if tbl and #tbl > 0 then
hasAny = true
-- Create the group container (one per letter/#)
local groupDiv = colsDiv:tag("div"):addClass("mw-category-group")
-- ALWAYS lock the heading + first item together (singleton or not)
local lock =
groupDiv:tag("div"):css("break-inside", "avoid"):css("-webkit-column-break-inside", "avoid"):css(
"-moz-column-break-inside",
"avoid"
):css("break-after", "avoid")
-- Heading inside the lock
local headingText = (key == "#") and "0–9" or key
lock:tag("div"):css("font-weight", "bold"):css("font-size", "120%"):css("margin-top", "0.6em"):css(
"margin-bottom",
"0.2em"
):wikitext(mw.text.nowiki(headingText))
-- First item inside the lock (only if list non-empty)
if #tbl >= 1 then
local firstUl = lock:tag("ul")
local it1 = tbl[1]
local wt1 = it1.wikitextBase or ""
if it1.icons and #it1.icons > 0 then
wt1 = wt1 .. " " .. table.concat(it1.icons, " ")
end
firstUl:tag("li"):wikitext(mw.text.trim(wt1))
end
-- Remaining items (if any) outside the lock so they can flow across columns
if #tbl >= 2 then
local restUl = groupDiv:tag("ul")
for i = 2, #tbl do
local it = tbl[i]
local wt = it.wikitextBase or ""
if it.icons and #it.icons > 0 then
wt = wt .. " " .. table.concat(it.icons, " ")
end
restUl:tag("li"):wikitext(mw.text.trim(wt))
end
end
end
end
if not hasAny then
return tostring(mw.html.create("div"):addClass("plainlinks"):wikitext("''No pages to display.''"))
end
return tostring(root)
end
return p