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:MilLeadership

Permanently protected module
From Wikipedia, the free encyclopedia

require('strict')
-- Leadership table renderer. Incumbent lists stay on the article;
-- award codes are resolved through Module:MilAward (PostNom / Stack).
--
-- Field separator inside a post list is " // " (space-slash-slash-space).
-- Raw "|" cannot be used as a field separator in a template parameter
-- (MediaWiki would split the parent template). Use {{MilLdr}} instead.
--
-- Multiple named footnotes on one row: use named-note-1 / named-note-2
-- (aliases NamedNote-N, note-name-N) with optional note-1 / note-2 texts.
-- Do not repeat the same parameter name (MediaWiki keeps only the last).

local p = {}
local getArgs = require('Module:Arguments').getArgs

local RESERVED = {
	caption = true,
	float = true,
	color = true,
	colour = true,
	country = true,
	ribbons = true,
	ribbon_size = true,
	['ribbon-size'] = true,
	ribbonsize = true,
	size = true,
	nocat = true,
	class = true,
	content = true,
	posts = true,
}

local KNOWN_LINE_KEYS = {
	from = true,
	['from-date'] = true,
	to = true,
	['to-date'] = true,
	rank = true,
	name = true,
	incumbent = true,
	['post-incumbent'] = true,
	awards = true,
	note = true,
	notes = true,
	['note-name'] = true,
	notename = true,
	['named-note'] = true,
	ref = true,
	link = true,
	vacant = true,
}

local DEFAULT_COLOR = '#CF9C65'
local DEFAULT_RIBBON_SIZE = 'x8px'

local function trim(s)
	if s == nil then
		return ''
	end
	return (mw.text.trim(tostring(s)))
end

local function oneLine(s)
	s = trim(s)
	s = string.gsub(s, '[\r\n]+', ' ')
	s = string.gsub(s, '%s+', ' ')
	return s
end

local function isYes(v)
	if v == nil then
		return false
	end
	local s = mw.ustring.lower(trim(v))
	return s == 'yes' or s == 'y' or s == 'true' or s == '1' or s == 'on'
end

local function splitPlain(s, delim)
	local out = {}
	local start = 1
	local dlen = #delim
	while true do
		local i = string.find(s, delim, start, true)
		if not i then
			table.insert(out, string.sub(s, start))
			break
		end
		table.insert(out, string.sub(s, start, i - 1))
		start = i + dlen
	end
	return out
end

local function splitFields(line)
	-- Exact " // " keeps an empty rank as its own field. A greedy
	-- %s+//%s* match would swallow " //  // " and leave "// name".
	-- Require the spaces so http:// in URLs is not split.
	if string.find(line, ' // ', 1, true) then
		return splitPlain(line, ' // ')
	end
	if string.find(line, ' / ', 1, true) then
		return splitPlain(line, ' / ')
	end
	if string.find(line, ' | ', 1, true) then
		return splitPlain(line, ' | ')
	end
	return { line }
end

local function splitRange(s)
	s = trim(s)
	local a, b = string.match(s, '^(.-)%s*[–—]%s*(.+)$')
	if a and trim(a) ~= '' and trim(b) ~= '' then
		return trim(a), trim(b)
	end
	local y1, y2 = string.match(s, '^(%d%d%d%d)%s*%-%s*(%d%d%d%d)$')
	if y1 then
		return y1, y2
	end
	return nil
end

local function parseAwards(raw)
	local codes = {}
	if raw == nil or trim(raw) == '' then
		return codes
	end
	raw = string.gsub(raw, '|', ',')
	for token in string.gmatch(raw, '[^,%s]+') do
		table.insert(codes, token)
	end
	return codes
end


local function isNumberedNoteKey(key)
	key = string.lower(key or '')
	return string.match(key, '^named%-note%-%d+$')
		or string.match(key, '^namednote%-%d+$')
		or string.match(key, '^note%-name%-%d+$')
		or string.match(key, '^notename%-%d+$')
		or string.match(key, '^note%-%d+$')
end

-- Collect NamedNote-1 / named-note-2 / Note-1 pairs (and legacy single named-note + note).
local function collectNamedNotes(kv)
	local byIndex = {}
	local maxIndex = 0

	local function remember(index, name, text)
		index = tonumber(index)
		if not index then
			return
		end
		local slot = byIndex[index] or {}
		if name and trim(name) ~= '' then
			slot.name = trim(name)
		end
		if text ~= nil and trim(text) ~= '' then
			slot.text = trim(text)
		end
		byIndex[index] = slot
		if index > maxIndex then
			maxIndex = index
		end
	end

	for key, val in pairs(kv) do
		local k = string.lower(key)
		local n = string.match(k, '^named%-note%-(%d+)$')
			or string.match(k, '^namednote%-(%d+)$')
			or string.match(k, '^note%-name%-(%d+)$')
			or string.match(k, '^notename%-(%d+)$')
		if n then
			remember(n, val, nil)
		else
			n = string.match(k, '^note%-(%d+)$')
			if n then
				remember(n, nil, val)
			end
		end
	end

	-- Legacy single params map to index 1 when that slot has no name yet.
	local legacyName = kv['note-name'] or kv.notename or kv['named-note']
	if legacyName and trim(legacyName) ~= '' then
		if not byIndex[1] or not byIndex[1].name then
			remember(1, legacyName, nil)
		end
	end
	if kv.note and trim(kv.note) ~= '' then
		-- Prefer attaching bare note= to the highest named slot that still lacks text;
		-- otherwise to index 1 (legacy behaviour).
		local attached = false
		for i = maxIndex, 1, -1 do
			local slot = byIndex[i]
			if slot and slot.name and (not slot.text or slot.text == '') then
				slot.text = trim(kv.note)
				attached = true
				break
			end
		end
		if not attached then
			remember(1, nil, kv.note)
		end
	end

	local list = {}
	for i = 1, math.max(maxIndex, 1) do
		local slot = byIndex[i]
		if slot and ((slot.name and slot.name ~= '') or (slot.text and slot.text ~= '')) then
			table.insert(list, { name = slot.name, text = slot.text })
		end
	end
	return list
end

local function parseNotesList(raw)
	local notes = {}
	if raw == nil or trim(raw) == '' then
		return notes
	end
	for _, item in ipairs(splitPlain(raw, ' ;; ')) do
		item = trim(item)
		if item ~= '' then
			table.insert(notes, item)
		end
	end
	if #notes == 0 then
		for _, item in ipairs(splitPlain(raw, '; ')) do
			item = trim(item)
			if item ~= '' then
				table.insert(notes, item)
			end
		end
	end
	return notes
end

-- Exported for testcases / console.
function p._parseLine(line)
	line = trim(line)
	line = string.gsub(line, '^[*#]+%s*', '')
	-- Empty from serialises as "*  // to // rank // name". After stripping
	-- the list marker and spaces the line starts with "//", which collapses
	-- the empty first field and shifts rank into the To column. Prepend a
	-- space so splitFields keeps that empty from slot.
	if string.sub(line, 1, 2) == '//' then
		line = ' ' .. line
	end
	if line == '' then
		return nil
	end

	local fields = splitFields(line)
	local positional = {}
	local kv = {}
	local unknown = {}

	for _, field in ipairs(fields) do
		field = trim(field)
		local k, v = string.match(field, '^([%a][%w_%-]*)%s*=%s*(.*)$')
		if k then
			local key = string.lower(k)
			kv[key] = v
			if not KNOWN_LINE_KEYS[key] and not isNumberedNoteKey(key) then
				unknown[key] = v
			end
		else
			-- Keep empty positional fields (vacant rank/name).
			table.insert(positional, field)
		end
	end

	local fromDate = kv.from or kv['from-date']
	local toDate = kv.to or kv['to-date']
	local rank = kv.rank
	local name = kv.name or kv.incumbent or kv['post-incumbent']

	if fromDate == nil then
		if #positional >= 4 then
			fromDate, toDate, rank, name = positional[1], positional[2], positional[3], positional[4]
		elseif #positional == 3 then
			local a, b = splitRange(positional[1])
			if a then
				fromDate, toDate, rank, name = a, b, positional[2], positional[3]
			else
				fromDate, toDate, rank = positional[1], positional[2], positional[3]
				name = name or ''
			end
		elseif #positional == 2 then
			local a, b = splitRange(positional[1])
			if a then
				fromDate, toDate, name = a, b, positional[2]
			else
				fromDate, toDate = positional[1], positional[2]
			end
		elseif #positional == 1 then
			name = positional[1]
		end
	else
		if rank == nil and positional[1] then
			rank = positional[1]
		end
		if name == nil and positional[2] then
			name = positional[2]
		elseif name == nil and positional[1] and rank ~= nil then
			name = positional[1]
		end
	end

	fromDate = trim(fromDate)
	toDate = trim(toDate)
	rank = trim(rank)
	name = trim(name)
	fromDate = string.gsub(fromDate, '^//%s*', '')
	toDate = string.gsub(toDate, '^//%s*', '')
	rank = string.gsub(rank, '^//%s*', '')
	name = string.gsub(name, '^//%s*', '')

	local namednotes = collectNamedNotes(kv)
	-- Keep legacy single fields for older call sites / debugging.
	local first = namednotes[1] or {}
	return {
		from = trim(fromDate),
		to = trim(toDate),
		rank = trim(rank),
		name = name,
		awards = parseAwards(kv.awards),
		note = first.text or kv.note,
		notes = parseNotesList(kv.notes),
		notename = first.name or kv['note-name'] or kv.notename or kv['named-note'],
		namednotes = namednotes,
		ref = kv.ref,
		link = kv.link,
		vacant = kv.vacant,
		unknown = unknown,
	}
end

local function parseMemberLines(raw)
	local members = {}
	if raw == nil then
		return members
	end
	-- Fold wrapped note text back onto the previous item. A newline inside
	-- note= must not become a new table row.
	local folded = {}
	for line in string.gmatch(tostring(raw) .. '\n', '([^\r\n]*)\r?\n') do
		local t = trim(line)
		if t == '' then
			-- skip
		elseif string.match(t, '^[*#]') or string.find(t, ' // ', 1, true) then
			table.insert(folded, t)
		elseif #folded > 0 then
			folded[#folded] = folded[#folded] .. ' ' .. t
		else
			table.insert(folded, t)
		end
	end
	for _, line in ipairs(folded) do
		local row = p._parseLine(line)
		if row then
			table.insert(members, row)
		end
	end
	return members
end

local function parseContent(raw)
	local posts = {}
	if raw == nil or trim(raw) == '' then
		return posts
	end
	local currentName = nil
	local buf = {}
	local function flush()
		if currentName then
			table.insert(posts, {
				name = currentName,
				members = parseMemberLines(table.concat(buf, '\n')),
			})
		end
		buf = {}
	end
	for line in string.gmatch(tostring(raw) .. '\n', '([^\r\n]*)\r?\n') do
		local heading = string.match(line, '^%s*==+%s*(.-)%s*==+%s*$')
		if heading then
			flush()
			currentName = trim(heading)
		else
			table.insert(buf, line)
		end
	end
	flush()
	return posts
end

local function collectNumberedPosts(args)
	local posts = {}
	local i = 1
	while true do
		local title = args['title' .. i] or args['post' .. i]
		local list = args['list' .. i] or args['members' .. i]
		if title == nil and list == nil then
			break
		end
		table.insert(posts, {
			name = trim(title),
			members = parseMemberLines(list),
		})
		i = i + 1
	end
	return posts
end

local function collectNamedPosts(src)
	local posts = {}
	if src == nil then
		return posts
	end
	-- Scribunto frame.args pairs() yields parameters in the order they were
	-- written. Do not copy through a new table first or Lua 5.1 will scramble
	-- Commanding Officers / RSM / Chaplains.
	for key, value in pairs(src) do
		if type(key) == 'string' then
			local lk = string.lower(key)
			if not RESERVED[lk] and not string.match(lk, '^title%d+$')
				and not string.match(lk, '^post%d+$')
				and not string.match(lk, '^list%d+$')
				and not string.match(lk, '^members%d+$') then
				table.insert(posts, {
					name = key,
					members = parseMemberLines(value),
				})
			end
		end
	end
	return posts
end

local function collectPosts(args, src)
	local numbered = collectNumberedPosts(args)
	if #numbered > 0 then
		return numbered
	end
	local content = args.content or args.posts
	if content then
		return parseContent(content)
	end
	return collectNamedPosts(src or args)
end

local function expand(frame, title, targs)
	local ok, result = pcall(function()
		return frame:expandTemplate{ title = title, args = targs }
	end)
	if ok then
		return result
	end
	return '<span style="color:#d33"> (MilLeadership: failed to expand ' .. title .. ')</span>'
end

local function formatAwards(frame, codes)
	if #codes == 0 then
		return ''
	end
	local targs = { size = '85%', sep = ', ' }
	for i, code in ipairs(codes) do
		targs[i] = code
	end
	return expand(frame, 'MilAward PostNom', targs)
end

local function formatRibbons(frame, codes, size)
	if #codes == 0 then
		return ''
	end
	local targs = { size = size or DEFAULT_RIBBON_SIZE }
	for i, code in ipairs(codes) do
		targs[i] = code
	end
	return expand(frame, 'MilAward Stack', targs)
end

local function formatEfn(frame, text, name)
	local targs = {}
	if text and trim(text) ~= '' then
		targs[1] = text
	end
	if name and trim(name) ~= '' then
		targs.name = name
	end
	if targs[1] == nil and targs.name == nil then
		return ''
	end
	return expand(frame, 'Efn', targs)
end

local function formatName(row)
	local name = row.name
	if isYes(row.vacant) and (name == '' or string.lower(name) == 'post vacant') then
		return "''No permanent appointment''"
	end
	if name == '' and isYes(row.vacant) then
		return "''No permanent appointment''"
	end
	local link = row.link
	if link and trim(link) ~= '' and not string.find(name, '%[%[', 1) then
		if isYes(link) then
			return '[[' .. name .. ']]'
		end
		return '[[' .. trim(link) .. '|' .. name .. ']]'
	end
	return name
end

local function formatIncumbent(frame, row, definedEfn)
	local parts = {}
	local rank = row.rank
	if rank ~= '' then
		table.insert(parts, rank)
	end
	local name = formatName(row)
	if name ~= '' then
		table.insert(parts, name)
	elseif #parts == 0 then
		table.insert(parts, "''Post vacant''")
	end

	local awards = formatAwards(frame, row.awards)
	if awards ~= '' then
		table.insert(parts, awards)
	end

	local namednotes = row.namednotes
	if namednotes == nil then
		namednotes = {}
		if row.notename or row.note then
			table.insert(namednotes, { name = row.notename, text = row.note })
		end
	end
	for _, nn in ipairs(namednotes) do
		local nname = nn.name
		local ntext = nn.text
		if nname and ntext then
			table.insert(parts, formatEfn(frame, ntext, nname))
			definedEfn[nname] = true
		elseif nname then
			table.insert(parts, formatEfn(frame, nil, nname))
		elseif ntext then
			table.insert(parts, formatEfn(frame, ntext, nil))
		end
	end

	for _, item in ipairs(row.notes or {}) do
		local named, rest = string.match(item, '^([%w_%-]+)%s*:%s*(.+)$')
		if named then
			table.insert(parts, formatEfn(frame, rest, named))
			definedEfn[named] = true
		elseif string.match(item, '^[%w_%-]+$') then
			-- Bare token: named efn reference (definition comes from note-name + note).
			table.insert(parts, formatEfn(frame, nil, item))
			definedEfn[item] = true
		else
			table.insert(parts, formatEfn(frame, item, nil))
		end
	end

	if row.ref and trim(row.ref) ~= '' then
		if string.find(row.ref, '<ref', 1, true) then
			table.insert(parts, row.ref)
		else
			table.insert(parts, '<ref>' .. row.ref .. '</ref>')
		end
	end

	return table.concat(parts, ' ')
end

local function tracking(nocat, errors)
	if nocat then
		return ''
	end
	local ns = mw.title.getCurrentTitle().namespace
	local out = {}
	if ns == 0 then
		table.insert(out, '[[Category:Pages using MilLeadership]]')
	end
	if errors > 0 then
		table.insert(out, '[[Category:MilLeadership errors]]')
	end
	return table.concat(out)
end

function p._table(args, frame, src)
	frame = frame or mw.getCurrentFrame()
	local caption = args.caption or ''
	local float = trim(args.float)
	local color = args.color or args.colour or DEFAULT_COLOR
	local showRibbons = isYes(args.ribbons)
	local ribbonSize = args.ribbon_size or args['ribbon-size'] or args.ribbonsize or args.size or DEFAULT_RIBBON_SIZE
	local nocat = isYes(args.nocat)
	local extraClass = args.class or ''

	local posts = collectPosts(args, src)
	local errors = 0
	local definedEfn = {}

	local root = mw.html.create('table')
	root:addClass('wikitable')
	if extraClass ~= '' then
		root:addClass(extraClass)
	end
	if float == 'right' or float == 'floatright' then
		root:addClass('floatright')
	elseif float == 'left' or float == 'floatleft' then
		root:addClass('floatleft')
	elseif float == 'center' or float == 'centre' then
		root:addClass('center')
	end
	if float == 'center' or float == 'centre' then
		root:css('margin', '0.5em auto')
	else
		root:css('margin', '0.5em 0')
	end
	root:css('font-size', '95%')
	root:css('border-bottom', '5px solid ' .. color)
	root:css('border-top', '5px solid ' .. color)

	if caption ~= '' then
		root:tag('caption'):wikitext(caption)
	end

	local fromWidth = showRibbons and '15%' or '20%'
	local nameWidth = showRibbons and '50%' or '60%'
	local toWidth = showRibbons and '15%' or '20%'
	local ribbonWidth = '20%'

	if #posts == 0 then
		errors = errors + 1
		root:tag('tr'):tag('td'):attr('colspan', showRibbons and '4' or '3')
			:wikitext('<span style="color:#d33">MilLeadership: no posts listed</span>')
	end

	for _, post in ipairs(posts) do
		local header = root:tag('tr')
		header:css('border-top', '5px solid ' .. color)
		local function th(width, text)
			header:tag('th')
				:css('width', width)
				:css('text-align', 'center')
				:css('background', 'lightgrey')
				:wikitext(text)
		end
		th(fromWidth, 'From')
		th(nameWidth, post.name ~= '' and post.name or 'No post name')
		th(toWidth, 'To')
		if showRibbons then
			th(ribbonWidth, 'Ribbon')
		end

		if #post.members == 0 then
			errors = errors + 1
			local empty = root:tag('tr'):css('text-align', 'center')
			empty:tag('td'):attr('colspan', showRibbons and '4' or '3')
				:wikitext('<span style="color:#d33">MilLeadership: no members for ' .. post.name .. '</span>')
		end

		for _, row in ipairs(post.members) do
			for uk, _ in pairs(row.unknown) do
				errors = errors + 1
				row.name = (row.name ~= '' and row.name or '') ..
					' <span style="color:#d33">(unknown field: ' .. uk .. ')</span>'
			end
			local tr = root:tag('tr'):css('text-align', 'center')
			tr:tag('td'):wikitext(row.from)
			tr:tag('td'):wikitext(formatIncumbent(frame, row, definedEfn))
			tr:tag('td'):wikitext(row.to)
			if showRibbons then
				tr:tag('td'):wikitext(formatRibbons(frame, row.awards, ribbonSize))
			end
		end
	end

	return tostring(root) .. tracking(nocat, errors)
end

function p.table(frame)
	local wrappers = {
		'Template:MilLeadership',
		'Template:MilLeadership/sandbox',
		'User:BoonDock/MilLeadership',
	}
	local args = getArgs(frame, {
		wrappers = wrappers,
		removeBlanks = false,
	})
	local src = frame.args
	local parent = frame.getParent and frame:getParent()
	if parent and parent.getTitle then
		local title = parent:getTitle():gsub('/sandbox$', '')
		for _, wrapper in ipairs(wrappers) do
			if title == wrapper then
				src = parent.args
				break
			end
		end
	end
	return p._table(args, frame, src)
end

p.main = p.table

function p.line(frame)
	-- Serialise one row. Used by {{MilLdr}} / {{User:BoonDock/MilLdr}} so
	-- editors can use pipes without MediaWiki splitting the parent template.
	local args = getArgs(frame, {
		wrappers = {
			'Template:MilLdr',
			'Template:MilLdr/sandbox',
			'User:BoonDock/MilLdr',
		},
		removeBlanks = false,
	})
	local function val(name, n)
		local v = args[name]
		if v == nil or trim(v) == '' then
			v = args[n]
		end
		return oneLine(v)
	end
	local line = '* ' .. val('from', 1) .. ' // ' .. val('to', 2) .. ' // ' .. val('rank', 3) .. ' // ' .. val('name', 4)
	local function append(key, aliases)
		local v = args[key]
		if (v == nil or trim(v) == '') and aliases then
			for _, alt in ipairs(aliases) do
				v = args[alt]
				if v and trim(v) ~= '' then
					break
				end
			end
		end
		if v and trim(v) ~= '' then
			line = line .. ' // ' .. key .. '=' .. oneLine(v)
		end
	end
	append('awards')
	-- Numbered named notes: named-note-1 / NamedNote-2 / note-1 / Note-2 ...
	local noteIndexes = {}
	for key, _ in pairs(args) do
		local k = string.lower(tostring(key))
		local n = string.match(k, '^named%-note%-(%d+)$')
			or string.match(k, '^namednote%-(%d+)$')
			or string.match(k, '^note%-name%-(%d+)$')
			or string.match(k, '^notename%-(%d+)$')
			or string.match(k, '^note%-(%d+)$')
		if n then
			noteIndexes[tonumber(n)] = true
		end
	end
	local sorted = {}
	for n, _ in pairs(noteIndexes) do
		table.insert(sorted, n)
	end
	table.sort(sorted)
	for _, n in ipairs(sorted) do
		local name = args['named-note-' .. n] or args['NamedNote-' .. n] or args['namednote-' .. n]
			or args['note-name-' .. n] or args['notename-' .. n]
		local text = args['note-' .. n] or args['Note-' .. n]
		if name and trim(name) ~= '' then
			line = line .. ' // named-note-' .. n .. '=' .. oneLine(name)
		end
		if text and trim(text) ~= '' then
			line = line .. ' // note-' .. n .. '=' .. oneLine(text)
		end
	end
	-- Legacy single note-name / named-note / note (only if no numbered notes present)
	if #sorted == 0 then
		append('note')
		append('note-name', { 'notename', 'named-note', 'NamedNote' })
	end
	append('notes')
	append('ref')
	append('link')
	append('vacant')
	return line
end

return p