Module:Foreign names

From the Super Mario Wiki, the Mario encyclopedia
Jump to navigationJump to search

This module creates the {{foreign names}} table. It uses the languages defined here.


-- Module:Foreign names
-- Lua replacement for Template:Foreign names
--
-- Parameters:
--   origin  Prefix of the language listed first in the table (default
--           "Jpn"). Every other language follows in the order given in
--           languages.json. An unrecognised value displays the table in
--           languages.json's own order instead, with nothing moved to the
--           top.

local p = {}

-- Language definitions loaded from subpage (prefix, name, BCP 47 tag, dir, latin, variant)
local languages = mw.loadJsonData("Module:Foreign names/languages.json")

-- Prefix of the language shown first when no "origin" parameter is given.
local DEFAULT_ORIGIN = "Jpn"

-- Namespaces treated as "content" for categorization purposes (0 = main,
-- 102 = Gallery, 106 = Multimedia).
local CONTENT_NAMESPACES = { [0] = true, [102] = true, [106] = true }

-- Strip HTML tags (used to clean display names for category sort keys)
local function stripTags(s)
	return (s:gsub("<[^>]+>", ""))
end

-- Return the parameter key for entry index idx under a given prefix.
-- Index 1  -> bare prefix ("Jpn")
-- Index 2+ -> prefix .. index ("Jpn2", "Jpn3", ...)
local function paramKey(prefix, idx)
	if idx == 1 then return prefix end
	return prefix .. idx
end

-- Count how many consecutive value entries exist for a prefix.
local function countEntries(args, prefix)
	if not args[prefix] or args[prefix] == "" then return 0 end
	local n = 1
	while true do
		local val = args[paramKey(prefix, n + 1)]
		if not val or val == "" then break end
		n = n + 1
	end
	return n
end

-- Resolve a parameter for entry idx, walking back through "^" inherit
-- markers to the nearest real value. suffix = "" (base name) | "R" |
-- "RM" | "RC" | "RH" | "M" | "N" | "C" — used for the name itself, the
-- romanisations, the Meaning/Note columns, and citations.
--
-- Two inherit forms are recognised:
--   "^"       Bare caret: inherit from the previous entry under the SAME
--             prefix and suffix (e.g. Jpn2M=^ pulls JpnM). Walks back
--             through a run of bare carets to the nearest real value.
--   "^Name"   Explicit caret: inherit the raw value of parameter Name
--             directly (e.g. FreEM=^FreAM), resolved in a single hop —
--             if Name's own value is itself a "^..." reference, that is
--             returned as-is rather than followed further (this also
--             means a bare-caret walk-back that lands on a stored
--             "^Name" value gets that one hop of resolution applied).
local function resolveR(args, prefix, idx, suffix)
	local val = args[paramKey(prefix, idx) .. suffix] or ""

	-- Walk back through bare "^" inherit markers within the same prefix.
	local i = idx
	while val == "^" and i > 1 do
		i = i - 1
		val = args[paramKey(prefix, i) .. suffix] or ""
	end
	if val == "^" then val = "" end

	-- Explicit "^Name" reference: resolved in a single hop.
	if val ~= "" and val:sub(1, 1) == "^" then
		val = args[val:sub(2)] or ""
	end

	return val
end

-- Return the primary BCP 47 subtag for a language tag — everything before
-- the first "-" (or the whole tag if there is none). This is used as a
-- "same language family" key: French Canada/Europe are both tagged "fr",
-- Spanish Latin America/Europe both "es", and Chinese Simplified/Traditional
-- are "zh-Hans"/"zh-Hant", which both reduce to "zh". Unrelated languages in
-- languages.json don't collide on this (no two unrelated entries share a
-- primary subtag today), so it reliably identifies regional/script variants
-- of the SAME language without needing any extra data in languages.json.
local function langFamily(tag)
	return tag:match("^[^%-]+") or tag
end

-- ---------------------------------------------------------------------------
-- Language ordering
-- ---------------------------------------------------------------------------

-- Return the language definitions in display order: the origin language
-- first, then every other language in the order given by languages.json.
-- origin is matched against each definition's prefix, ignoring case. If it
-- matches nothing, languages.json's own order is returned unchanged (no
-- language moved to the top).
local function orderLanguages(origin)
	local function findIndex(prefix)
		prefix = prefix:lower()
		for i, langDef in ipairs(languages) do
			if langDef.prefix:lower() == prefix then
				return i
			end
		end
		return nil
	end

	local originIdx = findIndex(origin)
	if not originIdx then
		return languages
	end

	local ordered = {}
	ordered[1] = languages[originIdx]
	for i, langDef in ipairs(languages) do
		if i ~= originIdx then
			ordered[#ordered + 1] = langDef
		end
	end
	return ordered
end

-- ---------------------------------------------------------------------------
-- Table-wide rowspan merging
-- ---------------------------------------------------------------------------
-- Given a list of merge KEYS and a parallel list of EMPTY flags, both in
-- final output-row order, compute a rowspan merge plan: consecutive rows
-- with an equal key, neither flagged empty, are merged into a single cell
-- spanning all of them. Two rows flagged empty are never merged, since that
-- would draw a meaningless empty cell across unrelated rows. This is a
-- generic algorithm — see buildMergeKeys and its callers in p.main for what
-- the keys actually mean (language family, resolved value, etc).
--
-- Returns an array `plan` the same length as `keys`: plan[i] is the rowspan
-- to use when emitting row i's cell, or `false` when row i's cell is
-- absorbed into an earlier row's rowspan and should be skipped entirely.
local function computeSpanPlan(keys, empty)
	local plan = {}
	local n = #keys
	local i = 1
	while i <= n do
		if empty[i] then
			plan[i] = 1
			i = i + 1
		else
			local k = keys[i]
			local span = 1
			local j = i + 1
			while j <= n and not empty[j] and keys[j] == k do
				span = span + 1
				j = j + 1
			end
			plan[i] = span
			for x = i + 1, i + span - 1 do
				plan[x] = false
			end
			i = i + span
		end
	end
	return plan
end

-- Build the (keys, empty) pair computeSpanPlan needs for one mergeable
-- column. `valueOf(row)` returns that row's raw value for the column; the
-- key is that value scoped to the row's language family (see langFamily),
-- so rows only merge with others in the same family, and a row is flagged
-- empty exactly when its raw value is "" (a composite value like the Name
-- column's "dir\1content" is never legitimately empty, so it never gets
-- flagged — matching the old per-column behavior without special-casing it
-- here).
local function buildMergeKeys(rows, valueOf)
	local keys, empty = {}, {}
	for i, row in ipairs(rows) do
		local v = valueOf(row)
		keys[i] = row.family .. "\1" .. v
		empty[i] = v == ""
	end
	return keys, empty
end

-- Test whether a string contains non-ASCII characters, indicating a non-Latin
-- script that needs romanisation. Only called for languages without the latin
-- flag, so we don't need to worry about accented Latin characters.
-- Certain non-ASCII ranges are stripped before checking:
--   U+FF01–U+FF5E  Fullwidth ASCII variants (e.g. A, 3)
--   U+2000–U+2BFF  Symbols and punctuation (e.g. ☆, →, …)
--   U+3000–U+303F  CJK punctuation (e.g. 。、「」)
local function needsRomanisation(s)
	local normalized = s
		:gsub("\239\188[\129-\191]", "x")  -- U+FF01–U+FF3F
		:gsub("\239\189[\128-\158]", "x")  -- U+FF40–U+FF5E
		:gsub("\226[\128-\175][\128-\191]", "x")  -- U+2000–U+2BFF
		:gsub("\227\128[\128-\191]", "x")  -- U+3000–U+303F
	return normalized:find("[\128-\255]") ~= nil
end

-- Wrap text in a lang-tagged span.
local function langSpan(tag, text)
	return '<span lang="' .. tag .. '">' .. text .. '</span>'
end

-- ---------------------------------------------------------------------------
-- Cell formatting helpers
-- ---------------------------------------------------------------------------

-- Build a "<td>" (optionally "<td rowspan="...">", optionally carrying extra
-- attributes such as dir=) wrapping content.
local function tdCell(content, span, attrs)
	local attrStr = attrs and (' ' .. attrs) or ''
	if span and span > 1 then
		return '<td rowspan="' .. span .. '"' .. attrStr .. '>' .. content .. '</td>'
	end
	return '<td' .. attrStr .. '>' .. content .. '</td>'
end

-- Append a "<br />''text''" romanisation fragment to nameParts, optionally
-- labelled (e.g. "(Mandarin)"). No-op when text is empty.
local function appendRomanisation(nameParts, nw, text, label)
	if text == "" then return end
	local part = "<br />" .. nw("''" .. text .. "''")
	if label then part = part .. " <small>(" .. label .. ")</small>" end
	table.insert(nameParts, part)
end

-- Append a category link when a name is missing a romanisation it needs
-- (content namespace, non-Latin script, and the name actually needs one).
local function appendMissingRomanisationCategory(nameParts, isContent, isLatin, value, displayClean)
	if isContent and not isLatin and needsRomanisation(value) then
		table.insert(nameParts, "[[Category:Articles with missing foreign name romanizations|" .. displayClean .. "]]")
	end
end

-- Content for a Meaning cell given its resolved value.
local function meaningContent(value, displayClean, isContent)
	if value == "" then
		local cat = isContent
			and ("[[Category:Articles with unknown foreign name meanings|" .. displayClean .. "]]")
			or ""
		return '<span class="foreign-meaning">?</span>' .. cat
	elseif value == "-" then
		return '<span class="no-foreign-meaning">-</span>'
	else
		return '<span class="foreign-meaning">' .. value .. '</span>'
	end
end

-- Content for a Note cell given its resolved value.
local function noteContent(value)
	if value ~= "" then
		return '<span class="foreign-note"><small>' .. value .. '</small></span>'
	else
		return '<span class="no-foreign-note"></span>'
	end
end

-- Content for a Reference cell given its resolved citation value.
local function refContentFor(citation, displayClean, isContent, nocitMode, nw)
	if citation ~= "" then
		return '<span class="foreign-ref">' .. nw(citation) .. '</span>'
	elseif nocitMode then
		return '<span class="no-foreign-ref"></span>'
	else
		-- nocitMode is only false when isContent is true, so this is
		-- always a content-namespace page missing a citation.
		local cat = '[[Category:Articles with unsourced ' .. displayClean .. ' names]]'
		return '<sup><abbr title="Citation needed">[?]</abbr></sup>' .. cat
	end
end

-- ---------------------------------------------------------------------------
-- Row collection
-- ---------------------------------------------------------------------------
-- Builds one row descriptor per entry of `langDef` and appends them to the
-- shared, table-wide `rows` array. Unlike a fully self-contained per-language
-- row builder, this does NOT decide any rowspans itself: it only resolves
-- and records each row's Name/Meaning/Note/Reference values. All rowspan
-- merging happens afterwards, once, across the complete `rows` list — see
-- computeSpanPlan — so that adjacent rows from DIFFERENT languages can merge
-- with each other, not just repeat entries within one language.
local function collectLangRows(rows, args, langDef, ctx, nw)
	local prefix  = langDef.prefix
	local display = langDef.name
	local langTag = langDef.tag
	local dir     = langDef.dir or "ltr"
	local isLatin = langDef.latin ~= false
	local variant = langDef.variant

	local isContent = ctx.isContent

	local total = countEntries(args, prefix)
	if total == 0 then return end

	local displayClean = stripTags(display)

	for idx = 1, total do
		-- Base name value: also goes through resolveR so it can be
		-- inherited via bare "^" or an explicit "^Name" reference,
		-- same as the R/M/N/C fields below.
		local value = resolveR(args, prefix, idx, "")

		-- ----------------------------------------------------------------
		-- Name cell content
		-- ----------------------------------------------------------------
		local nameParts = {}
		local isChinese = variant == "chinese" or variant == "chineseT"

		-- Resolve the Chinese romanisation fields once, through the "^"
		-- inheritance chain, up front — they're needed both for the lang-tag
		-- decision below and for the romanisation lines further down, and
		-- checking the raw param would treat an unresolved "^" as "present"
		-- even when it ultimately inherits an empty value.
		local chRM, chRC, chRH, chR
		if isChinese then
			chRM = resolveR(args, prefix, idx, "RM")
			chRC = resolveR(args, prefix, idx, "RC")
			chRH = resolveR(args, prefix, idx, "RH")
			chR  = resolveR(args, prefix, idx, "R")
		end

		-- Determine the lang subtag (ChiT may need -HK for Cantonese-only).
		local ltag = langTag
		if variant == "chineseT" and chRC ~= "" and chRM == "" and chR == "" and chRH == "" then
			ltag = "zh-Hant-HK"
		end

		table.insert(nameParts, nw(langSpan(ltag, value)))

		if isChinese then
			-- Chinese: separate Mandarin (RM), Cantonese (RC), and Hokkien (RH) romanisations
			appendRomanisation(nameParts, nw, chRM, "Mandarin")
			appendRomanisation(nameParts, nw, chRC, "Cantonese")
			appendRomanisation(nameParts, nw, chRH, "Hokkien")

			if chR ~= "" then
				appendRomanisation(nameParts, nw, chR, nil)
			elseif chRM == "" and chRC == "" and chRH == "" then
				appendMissingRomanisationCategory(nameParts, isContent, isLatin, value, displayClean)
			end
		else
			local r = resolveR(args, prefix, idx, "R")
			if r ~= "" then
				appendRomanisation(nameParts, nw, r, nil)
			else
				appendMissingRomanisationCategory(nameParts, isContent, isLatin, value, displayClean)
			end
		end

		local row = {
			displayClean = displayClean,
			family       = langFamily(langTag),
			nameContent  = table.concat(nameParts),
			dir          = dir,
			mValue       = resolveR(args, prefix, idx, "M"),
			nValue       = resolveR(args, prefix, idx, "N"),
			citation     = resolveR(args, prefix, idx, "C"),
		}

		-- Language label cell: only on the first entry, spans all of this
		-- language's own rows (never merges with a different language).
		if idx == 1 then
			row.langLabelCell = tdCell(nw(display), total)
		end

		table.insert(rows, row)
	end
end

-- ---------------------------------------------------------------------------
-- Main entry point
-- ---------------------------------------------------------------------------

function p.main(frame)
	-- Support both transclusion (parent frame) and direct invocation
	local parent = frame:getParent()
	local args = parent and parent.args or frame.args

	local title     = mw.title.getCurrentTitle()
	local isContent = CONTENT_NAMESPACES[title.namespace] == true

	local ctx = {
		isContent = isContent,
		nocitMode = (args["citations"] or "") ~= "" or not isContent,
		wrapMode  = (args["wrap"] or "") ~= "",
	}

	-- Language listed first in the table (see orderLanguages).
	local origin = args["origin"]
	if not origin or origin == "" then
		origin = DEFAULT_ORIGIN
	end

	-- Wrap text in a nowrap span (disabled when wrap mode is active). This is
	-- a table-wide setting, so one instance is shared by every language.
	local function nw(text)
		if ctx.wrapMode then return text end
		return '<span class="nowrap">' .. text .. '</span>'
	end

	-- Phase 1: collect every row's data across all languages, without
	-- deciding any rowspans yet.
	local rows = {}
	for _, langDef in ipairs(orderLanguages(origin)) do
		collectLangRows(rows, args, langDef, ctx, nw)
	end

	-- Phase 2: compute rowspan merge plans across the WHOLE table for each
	-- mergeable column. Every merge key is prefixed with the row's language
	-- family (see langFamily), so a merge can only happen between repeat
	-- entries of one language or between regional/script variants of the
	-- SAME language (e.g. FreA/FreE, SpaA/SpaE, ChiS/ChiT) — never between
	-- two unrelated languages that just happen to share a value. The Name
	-- column additionally requires the writing direction and rendered
	-- content to match exactly, which in practice fires for FreA/FreE and
	-- SpaA/SpaE (both tag their name "fr"/"es" regardless of region) but not
	-- for scripts that carry different tags even with matching visible text
	-- (e.g. zh-Hans vs zh-Hant).
	local nameSpans = computeSpanPlan(buildMergeKeys(rows, function(row) return row.dir .. "\1" .. row.nameContent end))
	local mSpans    = computeSpanPlan(buildMergeKeys(rows, function(row) return row.mValue end))
	local nSpans    = computeSpanPlan(buildMergeKeys(rows, function(row) return row.nValue end))

	-- Phase 3: emit the table, skipping any cell absorbed into an earlier
	-- row's rowspan.
	local out = {}
	table.insert(out, '<table id="foreign-names" class="wikitable">')
	table.insert(out, '<tr><th>Language</th><th>Name</th><th>Meaning</th><th>Note(s)</th>'
		.. '<th><abbr title="Reference(s)">Ref.</abbr></th></tr>')

	for i, row in ipairs(rows) do
		local cells = {}

		if row.langLabelCell then
			table.insert(cells, row.langLabelCell)
		end

		if nameSpans[i] ~= false then
			table.insert(cells, tdCell(row.nameContent, nameSpans[i], 'dir="' .. row.dir .. '"'))
		end

		if mSpans[i] ~= false then
			table.insert(cells, tdCell(meaningContent(row.mValue, row.displayClean, ctx.isContent), mSpans[i]))
		end

		if nSpans[i] ~= false then
			table.insert(cells, tdCell(noteContent(row.nValue), nSpans[i]))
		end

		table.insert(cells, tdCell(refContentFor(row.citation, row.displayClean, ctx.isContent, ctx.nocitMode, nw)))

		table.insert(out, '<tr>' .. table.concat(cells) .. '</tr>')
	end

	table.insert(out, '</table>')

	return table.concat(out, "\n")
end

return p