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.

Jump to content

Module:Footballer positions

Permanently protected module
From Wikipedia, the free encyclopedia
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", "back", "df"}, "Defender (association football)", "defender"},
	{{"centreback", "centralback", "cb"}, "Defender (association football)#Centre-back", "%Ccentre%Dback"},
	{{"centredefender"}, "Defender (association football)#Centre-back", "%Ccentre-back"},
	{{"centraldefender", "centraldefence", "centraldefense", "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", "centremid", "centermid"}, "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", "sidemidfielder", "wm"}, "Midfielder#Wide midfielder", "wide midfielder"},
	{{"winger", "wi"}, "Midfielder#Winger", "winger"},
	{{"wing"}, "Midfielder#Winger", "wing"},
	{{"winghalf", "winghalfback", "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", "centrehalfback", "ch"}, "Midfielder#Centre-half", "%Ccentre%Dhalf"},
	{{"righthalf", "righthalfback", "rightwinghalf", "rh"}, "Midfielder#Wing-half", "right%Dhalf"},
	{{"lefthalf", "lefthalfback", "leftwinghalf", "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", "secondforward", "ss"}, "Forward (association football)#Second striker", "second striker"},
	{{"supportingstriker", "supportstriker"}, "Forward (association football)#Second striker", "supporting striker"},
	{{"deeplyingstriker", "deeplyingforward"}, "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", "rightinsideforward", "ir"}, "Forward (association football)#Inside forward", "inside right"},
	{{"insideleft", "leftinsideforward", "il"}, "Forward (association football)#Inside forward", "inside left"},
	{{"outsideright", "rightoutsideforward", "or"}, "Forward (association football)#Outside forward", "outside right"},
	{{"outsideleft", "leftoutsideforward", "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("—") 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