Module:Footballer positions
Appearance
local p = {}
local mHList = require('Module:List')
-- Position definitions: {names/abbrevs}, link, display
-- Use %D for dash placeholder, %C for centre/center placeholder
local positionDefs = {
-- Goalkeepers
{{"goalkeeper", "goalie", "keeper", "gk"}, "Goalkeeper (association football)", "goalkeeper"},
-- Defenders
{{"defender", "defence", "defense", "df"}, "Defender (association football)", "defender"},
{{"centreback", "centralback", "cb"}, "Defender (association football)#Centre-back", "%Ccentre%Dback"},
{{"centredefender"}, "Defender (association football)#Centre-back", "%Ccentre-back"},
{{"centraldefender", "cd"}, "Defender (association football)#Centre-back", "central defender"},
{{"centrehalf"}, "Defender (association football)#Centre-back", "%Ccentre%Dhalf"},
{{"fullback", "outsideback", "fb"}, "Defender (association football)#Full-back", "full%Dback"},
{{"wingback", "wb"}, "Defender (association football)#Wing-back", "wing%Dback"},
{{"sweeper", "sw"}, "Defender (association football)#Sweeper", "sweeper"},
{{"libero"}, "Defender (association football)#Sweeper", "libero"},
{{"rightback", "rightdefender", "rb"}, "Defender (association football)#Full-back", "right%Dback"},
{{"rightfullback"}, "Defender (association football)#Full-back", "right full%Dback"},
{{"leftback", "leftdefender", "lb"}, "Defender (association football)#Full-back", "left%Dback"},
{{"leftfullback"}, "Defender (association football)#Full-back", "left full%Dback"},
{{"rightwingback", "rwb"}, "Defender (association football)#Wing-back", "right wing%Dback"},
{{"leftwingback", "lwb"}, "Defender (association football)#Wing-back", "left wing%Dback"},
-- Midfielders
{{"midfielder", "midfield", "mf"}, "Midfielder", "midfielder"},
{{"halfback", "hb"}, "Midfielder", "half%Dback"},
{{"centralmidfielder", "centralmidfield", "cm"}, "Midfielder#Central midfielder", "central midfielder"},
{{"centremidfielder", "centremidfield"}, "Midfielder#Central midfielder", "%Ccentre midfielder"},
{{"defensivemidfielder", "defendingmidfielder", "defensivemidfield", "dm"}, "Midfielder#Defensive midfielder", "defensive midfielder"},
{{"attackingmidfielder", "attackingmidfield", "offensivemidfielder", "offensivemidfield", "am"}, "Midfielder#Attacking midfielder", "attacking midfielder"},
{{"widemidfielder", "wm"}, "Midfielder#Wide midfielder", "wide midfielder"},
{{"winger", "wi"}, "Midfielder#Winger", "winger"},
{{"wing"}, "Midfielder#Winger", "wing"},
{{"winghalf", "wh"}, "Midfielder#Wing-half", "wing%Dhalf"},
{{"centraldefensivemidfielder", "cdm"}, "Midfielder#Defensive midfielder", "central defensive midfielder"},
{{"centredefensivemidfielder"}, "Midfielder#Defensive midfielder", "%Ccentre defensive midfielder"},
{{"leftdefensivemidfielder", "ldm"}, "Midfielder#Defensive midfielder", "left defensive midfielder"},
{{"rightdefensivemidfielder", "rdm"}, "Midfielder#Defensive midfielder", "right defensive midfielder"},
{{"centralattackingmidfielder", "cam"}, "Midfielder#Attacking midfielder", "central attacking midfielder"},
{{"centreattackingmidfielder"}, "Midfielder#Attacking midfielder", "%Ccentre attacking midfielder"},
{{"leftattackingmidfielder", "lam"}, "Midfielder#Attacking midfielder", "left attacking midfielder"},
{{"rightattackingmidfielder", "ram"}, "Midfielder#Attacking midfielder", "right attacking midfielder"},
{{"holdingmidfielder", "holdingmidfield", "hm"}, "Midfielder#Defensive midfielder", "holding midfielder"},
{{"rightmidfielder", "rightmidfield", "rm"}, "Midfielder#Wide midfielder", "right midfielder"},
{{"leftmidfielder", "leftmidfield", "lm"}, "Midfielder#Wide midfielder", "left midfielder"},
{{"rightwinger", "rw"}, "Midfielder#Winger", "right winger"},
{{"rightwing"}, "Midfielder#Winger", "right wing"},
{{"leftwinger", "lw"}, "Midfielder#Winger", "left winger"},
{{"leftwing"}, "Midfielder#Winger", "left wing"},
{{"oldcentrehalf", "ch"}, "Midfielder#Centre-half", "%Ccentre%Dhalf"},
{{"righthalf", "righthalfback", "rh"}, "Midfielder#Wing-half", "right%Dhalf"},
{{"lefthalf", "lefthalfback", "lh"}, "Midfielder#Wing-half", "left%Dhalf"},
-- Forwards
{{"forward", "attacker", "attack", "fw"}, "Forward (association football)", "forward"},
{{"centreforward", "centralforward", "cf"}, "Forward (association football)#Centre-forward", "%Ccentre%Dforward"},
{{"striker", "st"}, "Forward (association football)#Striker", "striker"},
{{"secondstriker", "secondarystriker", "ss"}, "Forward (association football)#Second striker", "second striker"},
{{"supportingstriker"}, "Forward (association football)#Second striker", "supporting striker"},
{{"deeplyingstriker"}, "Forward (association football)#Second striker", "deep-lying striker"},
{{"insideforward", "if"}, "Forward (association football)#Inside forward", "inside forward"},
{{"outsideforward", "of"}, "Forward (association football)#Outside forward", "outside forward"},
{{"rightforward", "rf"}, "Forward (association football)", "right forward"},
{{"leftforward", "lf"}, "Forward (association football)", "left forward"},
{{"insideright", "ir"}, "Forward (association football)#Inside forward", "inside right"},
{{"insideleft", "il"}, "Forward (association football)#Inside forward", "inside left"},
{{"outsideright", "outisderight", "or"}, "Forward (association football)#Outside forward", "outside right"},
{{"outsideleft", "ol"}, "Forward (association football)#Outside forward", "outside left"},
-- Utility player
{{"utilityplayer", "utility", "ut"}, "Utility player#Association football", "utility player"},
}
-- Build lookup table from definitions
local positionData = {}
for _, def in ipairs(positionDefs) do
local names, link, display = def[1], def[2], def[3]
local data = {link, display}
for _, name in ipairs(names) do
positionData[name] = data
end
end
-- Normalize HTML entities
local function decodeEntities(str)
str = str:gsub(" ", " ")
str = mw.text.decode(str, true)
return str
end
-- Delink wikilinks
local function delink(str)
return str:gsub("%[%[(.-)%]%]", function(match)
local pipePos = match:find("|")
if pipePos then
return match:sub(pipePos + 1)
else
return match
end
end)
end
-- Extract reference placeholders from end of string
local function extractRefs(str)
local refs = ""
-- Pattern for MediaWiki strip markers
local refPattern = "\127'\"`UNIQ%-%-ref.-QINU`\"'\127"
while true do
local s, e = str:find(refPattern .. "%s*$")
if s then
refs = str:sub(s, e):gsub("%s*$", "") .. refs
str = str:sub(1, s - 1)
else
break
end
end
return mw.text.trim(str), refs
end
-- Normalize for lookup: lowercase, remove dashes/spaces, convert center->centre
local function normalizeForLookup(str)
local s = str:lower()
s = s:gsub("[-–—]", "")
s = s:gsub(" ", "")
s = s:gsub("center", "centre")
return s
end
-- Detect original formatting for centre/center and dash/space
local function detectFormatting(original, normalized)
local usesCenter = original:lower():find("center") ~= nil
-- Check for dash variants
local dashChar = original:match("%-") or original:match("–") or original:match("—")
-- If no dash found, determine if space or no separator was used
-- Compare lengths: if original (lowercased, center->centre) is longer than normalized,
-- there must be spaces
local spaceInsteadOfDash = false
local noSeparator = false
if not dashChar then
local comparable = original:lower():gsub("center", "centre")
if #comparable > #normalized then
-- Original has extra characters (spaces)
spaceInsteadOfDash = true
elseif #comparable == #normalized and #normalized > 1 then
-- Same length means no separators were used
-- But only set noSeparator if the input looks like a full word (not an abbreviation)
-- Abbreviations are short (2-3 chars) and all letters
local isAbbreviation = original:match("^%a%a%a?$") ~= nil
if not isAbbreviation then
noSeparator = true
end
-- If it's an abbreviation, we fall through to the default dash behavior
end
end
return usesCenter, dashChar, spaceInsteadOfDash, noSeparator
end
-- Apply formatting to display text
local function applyFormatting(display, usesCenter, dashChar, spaceInsteadOfDash, noSeparator)
-- Handle %C placeholder (centre/center)
if usesCenter then
display = display:gsub("%%Ccentre", "center")
else
display = display:gsub("%%C", "")
end
-- Handle %D placeholder (dash)
if dashChar then
display = display:gsub("%%D", "-")
elseif spaceInsteadOfDash then
display = display:gsub("%%D", " ")
elseif noSeparator then
display = display:gsub("%%D", "")
else
display = display:gsub("%%D", "-")
end
return display
end
-- Process a single position
local function processPosition(pos, isFirst, trackingNeeded, linkTargetReplacements, citationNeededTags)
local backup = pos
-- Extract citation needed placeholders and references from end of position
local citationNeeded = ""
local refs = ""
local cnPlaceholderPattern = "\127CITATIONNEEDED%d+\127"
local refPattern = "\127'\"`UNIQ%-%-ref.-QINU`\"'\127"
while true do
local cnStart, cnEnd = pos:find(cnPlaceholderPattern .. "%s*$")
local refStart, refEnd = pos:find(refPattern .. "%s*$")
if cnStart and (not refStart or cnStart > refStart) then
-- Citation needed is at the very end
local placeholder = pos:sub(cnStart, cnEnd):gsub("%s*$", "")
citationNeeded = (citationNeededTags[placeholder] or "") .. citationNeeded
pos = pos:sub(1, cnStart - 1)
elseif refStart then
-- Ref is at the very end
refs = pos:sub(refStart, refEnd):gsub("%s*$", "") .. refs
pos = pos:sub(1, refStart - 1)
else
break
end
end
pos = mw.text.trim(pos)
-- Handle special centre-half wikilink
local lowerPos = pos:lower()
if lowerPos:match("^%[%[midfielder#centre%-half|cent[re][re]%-?%s?half%]%]$") then
pos = "old centre-half"
end
-- Delink
pos = delink(pos)
pos = mw.text.trim(pos)
-- Lookup
local normalized = normalizeForLookup(pos)
local usesCenter, dashChar, spaceInsteadOfDash, noSeparator = detectFormatting(pos, normalized)
local data = positionData[normalized]
local result
local usedBackup = false
if data then
local link, display = data[1], data[2]
display = applyFormatting(display, usesCenter, dashChar, spaceInsteadOfDash, noSeparator)
-- Capitalize first position, lowercase others
if isFirst then
display = display:sub(1, 1):upper() .. display:sub(2):lower()
else
display = display:lower()
end
-- Simplify wikilink if link target equals display text (case insensitive)
if link:lower() == display:lower() then
result = "[[" .. display .. "]]"
else
result = "[[" .. link .. "|" .. display .. "]]"
end
else
-- No match found - check if this came from a link target replacement
if linkTargetReplacements and linkTargetReplacements[pos] then
-- Use the original link with separator
result = linkTargetReplacements[pos]
else
-- Use backup, but restore any citation needed placeholders in it first
result = backup
for placeholder, tag in pairs(citationNeededTags) do
result = result:gsub(placeholder, tag)
end
usedBackup = true -- Set the flag
end
trackingNeeded[1] = true
end
-- Re-add references
if refs ~= "" and not usedBackup then
result = result .. refs
end
-- Re-add citation needed only if we didn't use the backup
-- (backup already contains the citation needed tag)
if citationNeeded ~= "" and not usedBackup then
result = result .. citationNeeded
end
return result
end
function p.main(frame)
local args = frame.args
local input = args[1]
-- If called from a template, get parent args
if input == nil or input == "" then
args = frame:getParent().args
input = args[1]
end
input = input or ""
-- No input = no output
if input == "" or mw.text.trim(input) == "" then
return ""
end
-- Normalize HTML entities
input = decodeEntities(input)
-- Trim
input = mw.text.trim(input)
-- Handle hlist/plainlist input
local tsPattern = "\127'\"`UNIQ%-%-templatestyles%-.-%QINU`\"'\127%s*<div class=\""
local listMatch = input:match(tsPattern .. "hlist[^\"]*\"[^>]*>(.-)</div>") or
input:match(tsPattern .. "plainlist[^\"]*\"[^>]*>(.-)</div>")
if listMatch then
local listPositions = {}
-- Check for HTML list format (<ul><li>...</li></ul>)
if listMatch:match('<ul>') then
for item in listMatch:gmatch('<li>(.-)</li>') do
item = mw.text.trim(item)
if item ~= "" then
table.insert(listPositions, item)
end
end
else
-- Wikitext list format (* item)
for item in listMatch:gmatch('%*%s*([^\n]+)') do
item = mw.text.trim(item)
if item ~= "" then
table.insert(listPositions, item)
end
end
end
-- If we found list items, replace input with comma-separated positions
if #listPositions > 0 then
input = table.concat(listPositions, ", ")
end
end
-- Break tag pattern
local brPattern = "<[bB][rR]%s*/?%s*>"
-- Handle and/or adjacent to break tags (convert to just /)
input = input:gsub("%s+[Aa][Nn][Dd]%s*" .. brPattern, "/")
input = input:gsub("%s+[Oo][Rr]%s*" .. brPattern, "/")
input = input:gsub(brPattern .. "%s*[Aa][Nn][Dd]%s+", "/")
input = input:gsub(brPattern .. "%s*[Oo][Rr]%s+", "/")
-- Replace remaining br tags with /
input = input:gsub(brPattern, "/")
-- Extract citation needed templates and replace with placeholders
-- Match sup tags with Template-Fact class (citation needed)
-- Categories may precede the sup tag, so we handle them together
local citationNeededTags = {}
local citationNeededIndex = 0
-- Process from end to start to avoid position shifting issues
-- First collect all matches, then replace
local matches = {}
local searchPos = 1
while true do
-- Find next sup tag with Template-Fact
local supStart, supEnd = input:find("<sup[^>]*Template%-Fact[^>]*>.-</sup>", searchPos)
if not supStart then break end
-- Look backwards for any immediately preceding categories
local catStart = supStart
local checkPos = supStart
while checkPos > 1 do
-- Get the prefix before current position
local prefix = input:sub(1, checkPos - 1)
-- Try to match a category at the end of prefix (with optional trailing whitespace)
local catMatchStart, catMatchEnd = prefix:find("%[%[Category:[^%]]+%]%]%s*$")
if catMatchStart then
catStart = catMatchStart
checkPos = catMatchStart
else
break
end
end
-- Assign index now (during collection) to preserve order
citationNeededIndex = citationNeededIndex + 1
table.insert(matches, {catStart, supEnd, citationNeededIndex})
searchPos = supEnd + 1
end
-- Replace matches from end to start to preserve positions
for i = #matches, 1, -1 do
local startPos, endPos, idx = matches[i][1], matches[i][2], matches[i][3]
local fullMatch = input:sub(startPos, endPos)
local placeholder = "\127CITATIONNEEDED" .. idx .. "\127"
citationNeededTags[placeholder] = fullMatch
input = input:sub(1, startPos - 1) .. placeholder .. input:sub(endPos + 1)
end
-- Check for tags
if input:match("<[^>]+>") then
-- Add tracking category
local category = frame:expandTemplate{
title = "main other",
args = {"[[Category:Pages using infobox football biography with tags in position parameter]]"}
}
-- Restore citation needed tags before returning
for placeholder, tag in pairs(citationNeededTags) do
input = input:gsub(placeholder, tag)
end
return input .. category
end
-- Replace remaining line breaks with /
input = input:gsub("\n", "/")
-- Check for separators in wikilink display text and delink if found
local hadSeparatorInDisplayText = false
local originalInputBeforeDelink = input
local separatorPattern = "[/,;+&]"
local hasWordSeparator = function(text)
return text:match("%s+[Aa][Nn][Dd]%s+") or text:match("%s+[Oo][Rr]%s+")
end
local linkTargetReplacements = {} -- Track original links that had separators in target
-- Check each wikilink for separators in display text
for wikilink in input:gmatch("%[%[.-%]%]") do
local pipePos = wikilink:find("|")
local textToCheck
if pipePos then
-- Has display text - check the part after the pipe
textToCheck = wikilink:sub(pipePos + 1, -3) -- Extract display text (excluding ]])
-- Also check if separator is in the link target (before pipe)
local linkTarget = wikilink:sub(3, pipePos - 1)
if linkTarget:match(separatorPattern) or hasWordSeparator(linkTarget) then
-- Store the original link and its display text for later restoration if needed
local displayText = textToCheck
linkTargetReplacements[displayText] = wikilink
-- Remove separators from the link target in the input
local cleanedTarget = linkTarget:gsub(separatorPattern, ""):gsub("%s+and%s+", ""):gsub("%s+or%s+", "")
input = input:gsub("%[%[" .. linkTarget:gsub("([%^%$%(%)%%%.%[%]%*%+%-%?])", "%%%1") .. "|" .. displayText:gsub("([%^%$%(%)%%%.%[%]%*%+%-%?])", "%%%1") .. "%]%]",
"[[" .. cleanedTarget .. "|" .. displayText .. "]]")
end
else
-- No pipe - check the link target itself (excluding [[]])
textToCheck = wikilink:sub(3, -3)
end
if textToCheck:match(separatorPattern) or hasWordSeparator(textToCheck) then
hadSeparatorInDisplayText = true
end
end
-- If separator found in display text, delink the input
if hadSeparatorInDisplayText then
input = delink(input)
end
-- Convert word separators to / before splitting, but only outside of wikilinks
-- First, temporarily replace wikilinks with placeholders
local wikilinks = {}
local placeholderIndex = 0
input = input:gsub("%[%[.-%]%]", function(wikilink)
placeholderIndex = placeholderIndex + 1
local placeholder = "\127WIKILINK" .. placeholderIndex .. "\127"
wikilinks[placeholder] = wikilink
return placeholder
end)
-- Now convert word separators
input = input:gsub("%s+[Aa][Nn][Dd]%s+", "/")
input = input:gsub("%s+[Oo][Rr]%s+", "/")
-- Restore wikilinks
for placeholder, wikilink in pairs(wikilinks) do
input = input:gsub(placeholder, wikilink)
end
-- Split by separators
local positions = {}
local refPattern = "\127'\"`UNIQ%-%-ref.-QINU`\"'\127"
-- First, split by separators
local rawParts = {}
local lastEnd = 1
for sepStart, sepEnd in input:gmatch("()%s*" .. separatorPattern .. "%s*()") do
table.insert(rawParts, input:sub(lastEnd, sepStart - 1))
lastEnd = sepEnd
end
table.insert(rawParts, input:sub(lastEnd))
-- Now process parts: if a part starts with ref(s) or citation needed, move them to previous position
local cnPlaceholderPattern = "\127CITATIONNEEDED%d+\127"
for i, part in ipairs(rawParts) do
local leadingRefs = ""
local remainder = part
-- Extract leading refs and citation needed placeholders
while true do
local refStart, refEnd = remainder:find("^%s*" .. refPattern)
local cnStart, cnEnd = remainder:find("^%s*" .. cnPlaceholderPattern)
if refStart then
leadingRefs = leadingRefs .. remainder:sub(refStart, refEnd)
remainder = remainder:sub(refEnd + 1)
elseif cnStart then
leadingRefs = leadingRefs .. remainder:sub(cnStart, cnEnd)
remainder = remainder:sub(cnEnd + 1)
else
break
end
end
-- Attach leading refs/citation needed to previous position
if leadingRefs ~= "" and #positions > 0 then
positions[#positions] = positions[#positions] .. leadingRefs
end
-- Add remainder as new position (if not empty)
remainder = mw.text.trim(remainder)
if remainder ~= "" then
table.insert(positions, remainder)
end
end
-- If no positions found, treat whole input as one position
if #positions == 0 then
positions = {mw.text.trim(input)}
end
-- Filter out management/executive positions
local excludeWords = {'retired', 'unknown', '?', 'manager', 'coach', 'trainer', 'director', 'president', 'executive', 'chairman', 'ceo', 'referee'}
local filteredPositions = {}
local hasInvalidPosition = false
for _, pos in ipairs(positions) do
local lowerPos = pos:lower()
local exclude = false
for _, word in ipairs(excludeWords) do
if lowerPos:find(word, 1, true) then
exclude = true
hasInvalidPosition = true
break
end
end
if not exclude then
table.insert(filteredPositions, pos)
end
end
positions = filteredPositions
-- Add tracking category for invalid positions
local invalidCategory = ""
if hasInvalidPosition then
invalidCategory = frame:expandTemplate{
title = "main other",
args = {"[[Category:Pages using infobox football biography with invalid position]]"}
}
mw.addWarning('<span style="color:#d33">Error: One or more of the position(s) listed in the infobox parameter is not an actual role for a footballer\'s playing career.</span>')
end
-- If all positions were filtered out, return only categories
if #positions == 0 then
return invalidCategory
end
-- Process each position
local trackingNeeded = {false}
local results = {}
for i, pos in ipairs(positions) do
table.insert(results, processPosition(pos, i == 1, trackingNeeded, linkTargetReplacements, citationNeededTags))
end
-- Build irregular category if needed
local irregularCategory = ""
if trackingNeeded[1] then
irregularCategory = frame:expandTemplate{
title = "main other",
args = {"[[Category:Pages using infobox football biography with irregular position]]"}
}
mw.addWarning('<span style="color:#d33">Error: One or more of the position(s) listed in the infobox parameter is not a typical position in football.</span>')
end
-- Special handling: if we delinked due to separator in display text,
-- and ANY position didn't match, return original input unchanged
if hadSeparatorInDisplayText and trackingNeeded[1] then
return originalInputBeforeDelink .. invalidCategory .. irregularCategory
end
-- Build output
local output
if #results == 1 then
output = results[1]
else
-- Use Module:List hlist
output = mHList.makeList('horizontal', results)
end
return output .. invalidCategory .. irregularCategory
end
return p