Jump to content

Module:etymon

From Wiktionary, the free dictionary

This module implements the template {{etymon}}.


local export = {}

local anchors_module = "Module:anchors"
local etymology_module = "Module:etymology"
local headword_data_module = "Module:headword/data"
local languages_module = "Module:languages"
local languages_errorgetby_module = "Module:languages/errorGetBy"
local links_module = "Module:links"
local pages_module = "Module:pages"
local parameters_module = "Module:parameters"
local parameters_data_module = "Module:parameters/data"
local string_utilities_module = "Module:string utilities"
local template_parser_module = "Module:template parser"
local templatestyles_module = "Module:TemplateStyles"
local utilities_module = "Module:utilities"

local concat = table.concat
local find = string.find
local gsub = string.gsub
local html_create = mw.html.create
local insert = table.insert
local match = string.match
local max = math.max
local new_title = mw.title.new
local next = next
local require = require
local sub = string.sub
local tostring = tostring
local type = type
local unpack = unpack

--[==[
Loaders for functions in other modules, which overwrite themselves with the target function when called. This ensures modules are only loaded when needed, retains the speed/convenience of locally-declared pre-loaded functions, and has no overhead after the first call, since the target functions are called directly in any subsequent calls.]==]
	local function check_ancestor(...)
		check_ancestor = require(etymology_module).check_ancestor
		return check_ancestor(...)
	end

	local function find_templates(...)
		find_templates = require(template_parser_module).find_templates
		return find_templates(...)
	end

	local function format_categories(...)
		format_categories = require(utilities_module).format_categories
		return format_categories(...)
	end

	local function full_link(...)
		full_link = require(links_module).full_link
		return full_link(...)
	end

	local function get_lang(...)
		get_lang = require(languages_module).getByCode
		return get_lang(...)
	end

	local function get_link_page(...)
		get_link_page = require(links_module).get_link_page
		return get_link_page(...)
	end

	local function get_section(...)
		get_section = require(pages_module).get_section
		return get_section(...)
	end

	local function language_anchor(...)
		language_anchor = require(anchors_module).language_anchor
		return language_anchor(...)
	end

	local function process_params(...)
		process_params = require(parameters_module).process
		return process_params(...)
	end

	local function split(...)
		split = require(string_utilities_module).split
		return split(...)
	end

	local function templatestyles(...)
		templatestyles = require(templatestyles_module)
		return templatestyles(...)
	end

	local function trim(...)
		trim = require(string_utilities_module).trim
		return trim(...)
	end

--[==[
Loaders for objects, which load data (or some other object) into some variable, which can then be accessed as "foo or get_foo()", where the function get_foo sets the object to "foo" and then returns it. This ensures they are only loaded when needed, and avoids the need to check for the existence of the object each time, since once "foo" has been set, "get_foo" will not be called again.]==]
	local content_page
	local function is_content_page()
		content_page, is_content_page = require(pages_module).is_content_page(mw.title.getCurrentTitle()), nil
		return content_page
	end
	
	local page_data
	local function get_page_data()
		page_data, get_page_data = mw.loadData(headword_data_module).page, nil
		return page_data
	end

local frame

-- Get a language object.
local function getLang(code)
	return get_lang(code, nil, true) or require(languages_errorgetby_module).code(code, true, true)
end

-- Normalize the language so that special handling of Chinese is accounted for.
-- This is everything in the Sinitic family which isn't a creole, pidgin or mixed language.
local function getNormLang(lang)
	if lang:inFamily("zhx") and not lang:inFamily("crp", "qfa-mix") then
		return get_lang("zh")
	else 
		return lang
	end
end

-- Given an etymon param, return its parts.
local function getParts(templateLang, etymonParam)
	local etymonLang, etymonLangcode, etymonPage, etymonId
	local parts = split(etymonParam, ">", true)
	local n = #parts
	for i = 1, n do
		parts[i] = trim(parts[i])
	end
	-- FIXME: this doesn't work properly if nested templates output HTML tags, which is likely to cause bugs that are hard for ordinary users to fix.
	if n == 2 then
		-- Assume language is the same as the template call if none is provided.
		etymonLang, etymonPage, etymonId = templateLang, unpack(parts)
	else
		etymonLangcode, etymonPage, etymonId = unpack(parts)
		etymonLang = getLang(etymonLangcode)
	end

	return etymonLang, etymonPage, etymonId
end

local argsOf = {}
local disambiguationCount = {}
local function scrapePage(etymonPage, etymonTitle, key, etymonLang, etymonId, redirectedFrom)
	local content = etymonTitle:getContent()

	if content == nil then
		argsOf[key] = "redlink"
		return
	end

	-- Search only the relevant L2 entry, unless it's a redirect, in which case search the whole page.
	local redirectTarget = etymonTitle.redirectTarget
	if not redirectTarget then
		content = get_section(content, etymonLang:getFullName(), 2)
		if content == nil then
			argsOf[key] = "missing"
			return
		end
	end

	local etymonLangcode = etymonLang:getFullCode()
	local L2_key = etymonLangcode .. ">" .. etymonPage

	-- Search for the template on the page (even if this is a redirect page).
	-- FIXME: mw.uri.anchorEncode on IDs. Not possible to implement until ">" syntax is fixed (see comment in getParts).
	for template in find_templates(content) do
		if template:get_name() == "etymon" then
			local templateArgs = template:get_arguments()
			if templateArgs[1] == etymonLangcode then
				argsOf[L2_key .. ">" .. templateArgs["id"]] = templateArgs
				disambiguationCount[L2_key] = (disambiguationCount[L2_key] or 0) + 1
			end
		end
	end

	if redirectedFrom and disambiguationCount[L2_key] then
		disambiguationCount[redirectedFrom] = (disambiguationCount[redirectedFrom] or 0) + disambiguationCount[L2_key]
	end

	-- If scraping produced a result, there's nothing left to do.
	if argsOf[key] then
		return
	-- Else if we've already followed a redirect and still found nothing, record the template as missing.
	elseif redirectedFrom then
		argsOf[key] = "missing"
		return
	end

	-- Check if the page is a redirect, and if not record the template as missing.
	if not redirectTarget then
		argsOf[key] = "missing"
		return
	end

	-- Otherwise, try again with the redirect target.
	etymonPage = redirectTarget.prefixedText
	scrapePage(etymonPage, redirectTarget, L2_key .. ">" .. etymonId, etymonLang, etymonId, L2_key)

	-- Record the value as the same as the redirect's.
	argsOf[key] = argsOf[etymonLangcode .. ">" .. etymonPage .. ">" .. etymonId]
end

-- Given an etymon, scrape the page and get its parameters.
-- This function returns either: a table of the params, "missing", "redlink", or "nolink"
local function getArgs(templateLang, etymonParam)
	-- Get normalized parts of the etymon parameter.
	local etymonLang, etymonPage, etymonId = getParts(templateLang, etymonParam)
	-- "?" is a special value that unlinks the page. TODO: Figure this out...
	if etymonId == "?" then
		return "nolink"
	end

	-- If multiple terms are linked like A//B, only look at A.
	etymonPage = match(etymonPage, "^(.-)//") or etymonPage
	etymonPage = get_link_page(etymonPage, etymonLang)
	etymonLang = getNormLang(etymonLang)

	-- Find the parameters by scraping etymonPage.
	-- Store data in the argsOf table to save time in case the same etymon is accessed again.
	-- The key is a normalized version of etymonParam.
	local key = etymonLang:getFullCode() .. ">" .. etymonPage .. ">" .. etymonId
	if argsOf[key] == nil then
		local etymonTitle = new_title(etymonPage)
		if not etymonTitle then
			-- This shouldn't happen: all unsupported titles should be resolved at this stage.
			error("Invalid page title \"" .. etymonPage .. "\" encountered.")
		end
		scrapePage(etymonPage, etymonTitle, key, etymonLang, etymonId)
	end

	return argsOf[key]
end

-- [tag]: {abbreviation, label glossary anchor, start text, start text plus, middle text, forms groups}
-- Note: the keywords `afeq`, `conf`, and `unc` are also recognized, but do not use this dictionary.
-- Please do not add any new keywords without discussion or this list will get extremely unwieldy.
-- If we decide to add keywords for each thing I will have to figure out a systematic way to organize them.
local keywordDict = {
	["from"] = {false, false, "From", "From", "from", false, false},
	["inh"] = {false, false, "From", "[[Appendix:Glossary#inherited|Inherited]] from", "from", false},
	["af"] = {false, false, "From", "From", "from", true},
	["blend"] = {"blend.", "blend", "Blend of", "[[Appendix:Glossary#blend|Blend]] of", "a blend of", true},
	["bor"] = {"bor.", "borrowing", "Borrowed from", "[[Appendix:Glossary#borrowing|Borrowed]] from", "borrowed from", false},
	["lbor"] = {"lbor.", "learned_borrowing", "Learned borrowing from", "[[Appendix:Glossary#learned_borrowing|Learned borrowing]] from", "borrowed from", false},
	["obor"] = {"obor.", "orthographic_borrowing", "Orthographic borrowing from", "[[Appendix:Glossary#orthographic_borrowing|Orthographic borrowing]] from", "borrowed from", false},
	["slbor"] = {"slbor.", "semi-learned_borrowing", "Semi-learned borrowing from", "[[Appendix:Glossary#semi-learned_borrowing|Semi-learned borrowing]] from", "borrowed from", false},
	["der"] = {"der.", "derived_terms", "Derived from", "[[Appendix:Glossary#derived_terms|Derived]] from", "from", false},
	["calque"] = {"calq.", "calque", "Calque of", "[[Appendix:Glossary#calque|Calque]] of", "a calque of", false},
	["sl"] = {"sl.", "semantic loan", "Semantic loan of", "[[Appendix:Glossary#semantic_loan|Semantic loan]] of", "a semantic loan of", false},
	["bf"] = {"bf.", "back-formation", "Back-formation from", "[[Appendix:Glossary#Back-formation|Back-formation]] from", "a back-formation from", false},
	["translit"] = {"translit.", "transliteration", "Transliteration", "[[Appendix:Glossary#transliteration|Transliteration]] of", "borrowed from", false},
	["vrd"] = {"vrd.", "vṛddhi derivative", "Vṛddhi derivative of", "[[vṛddhi|Vṛddhi]] derivative of", "a vṛddhi derivative of", false},
	["influence"] = {"influ.", "contamination", "", "", "", false}
}

-- This function takes an etymon and recursively builds a tree to display in an entry.
local function etyTree(currTitle, lang, args, alreadySeen, isTopLevel, isUncertain, label)
	local treeWidth = 0
	local treeHeight = 0
	local subtree, subtreeHeight, subtreeWidth, etymonLang, etymonPage, etymonArgs
	local subtrees = {}
	local currId = ""
	if type(args) == "table" then
		currId = args["id"]
	end
	local key = getNormLang(lang):getFullCode() .. ">" .. get_link_page(currTitle, lang) .. ">" .. currId
	local derType, confidence, ignoreEtymons = "from", "conf", false

	-- Only recurse when an etymon has params and was not included in the tree previously.
	if type(args) == "table" and alreadySeen[key] == nil then
		local i, templateLang = 1, getLang(args[1])
		-- Add the page to alreadySeen, which keeps track of what's already been added to the tree and the depth reached.
		alreadySeen[key] = true
		-- Loop over each parameter in the current template, starting from 2.
		while true do
			i = i + 1
			local param = args[i]
			if param == nil then
				break
			elseif find(param, ">", nil, true) and not ignoreEtymons then
				etymonLang, etymonPage = getParts(templateLang, param)
				-- Scrape the page and get the parameters.
				etymonArgs = getArgs(templateLang, param)
				-- Recurse into the etymon and append its tree to the list of subtrees.
				subtree, subtreeHeight, subtreeWidth = etyTree(etymonPage, etymonLang, etymonArgs, alreadySeen, false, confidence == "unc", derType)
				insert(subtrees, subtree)
				treeHeight = max(treeHeight, subtreeHeight)
				treeWidth = treeWidth + subtreeWidth
			else
				-- Reached a keyword.
				if param == "conf" or param == "unc" then
					confidence = param
				elseif keywordDict[param] ~= nil then
					ignoreEtymons = false
					confidence = "conf"
					derType = param
				else
					ignoreEtymons = true
				end
			end
		end
	end

	-- Create link.
	local link = "<span style=\"display:inline-block\" class=\"etyl\">" .. lang:getCanonicalName() .. "</span> <span style=\"display:inline-block\">"
	if isTopLevel then
		link = link .. full_link({lang = lang, alt = "'''" .. currTitle .. "'''"}, "term")
	elseif currId == "" then
		link = link .. full_link({lang = lang, term = currTitle}, "term")
	else
		link = link .. full_link({lang = lang, term = currTitle, id = currId}, "term")
	end
	link = link .. "</span>"

	-- Create tree.
	local tree = ""
	if #subtrees == 1 then
		-- Add long top connector.
		tree = tree .. "<span style=\"position:relative;height:20px;border-right:2px solid var(--wikt-palette-grey,#9e9e9e)\"></span>"
	elseif #subtrees >= 2 then
		--Add short top connector.
		tree = tree .. "<span style=\"position:relative;height:10px;border-right:2px solid var(--wikt-palette-grey,#9e9e9e)\"></span>"
	end

	--Create term block.
	tree = tree .. "<div style=\"position:relative;text-align:center;padding:5px 10px;background:var(--wikt-palette-beige,#fffbf2);color:inherit;border:1px solid var(--wikt-palette-lightgrey,#ccc);border-radius:4px\">" .. link

	-- Add derivation and uncertainty labels.
	-- TODO: make the CSS less horrible.
	if (label ~= "" and keywordDict[label][1] ~= false) or isUncertain then
		tree = tree .. "<span style=\"z-index:1;position:absolute;transform:translate(-50%);top:calc(100% + 5px);left:50%;border-radius:2px;background:var(--wikt-palette-cyan,#eaffff);color:inherit;font-size:12px;height:10px;line-height:10px\">"
		if label ~= "" and keywordDict[label][1] ~= false then
			tree = tree .. "[[Appendix:Glossary#" .. keywordDict[label][2] .. "|<abbr title=\"" .. gsub(keywordDict[label][2], "_", " ") .. "\" style=\"color:var(--wikt-palette-black,#202122);font-style:italic;text-decoration:none\">" .. keywordDict[label][1] .. "</abbr>]]"
			if isUncertain then
				-- Add uncertainty label next to the derivation label.
				tree = tree .. "<abbr title=\"uncertain\" style=\"position:absolute;top:50%;transform:translate(0,-48%);left:calc(100% + 2px);font-size:10px;border-radius:2px;background:var(--wikt-palette-pink,#ffe0f0);color:inherit;padding:1px 2px;font-weight:bold;text-decoration:none\">?</abbr>"
			end
		elseif isUncertain then
			-- Add uncertainty label in the middle.
			tree = tree .. "<abbr title=\"uncertain\" style=\"position:absolute;top:50%;left:50%;transform:translate(calc(-50% - 1px),-50%);font-size:10px;border-radius:2px;background:var(--wikt-palette-pink,#ffe0f0);color:inherit;padding:1px 2px;font-weight:bold;text-decoration:none\">?</span>"
		end
		tree = tree .. "</span>"
	end

	tree = tree .. "</div>"

	-- Append subtrees.
	local n_subtrees = #subtrees
	if n_subtrees == 1 then
		tree = subtrees[1] .. tree
	elseif n_subtrees >= 2 then
		local i, subtreeString = 0, ""
		while true do
			i = i + 1
			local v = subtrees[i]
			if v == nil then
				break
			elseif i == 1 then
				-- Add left connector.
				v = v .. "<span style=\"align-self:start;left:50%;width:calc(50% + 0.25em);height:10px;position:relative;border-bottom:2px solid var(--wikt-palette-grey,#9e9e9e);border-left:2px solid var(--wikt-palette-grey,#9e9e9e);border-bottom-left-radius:4px\"></span>"
			elseif i == n_subtrees then
				-- Add right connector.
				v = v .. "<span style=\"align-self:end;right:50%;width:calc(50% + 0.25em);height:10px;position:relative;border-bottom:2px solid var(--wikt-palette-grey,#9e9e9e);border-right:2px solid var(--wikt-palette-grey,#9e9e9e);border-bottom-right-radius:4px\"></span>"
			else
				-- Add a short bottom connector and middle connector.
				v = v .. "<span style=\"position:relative;height:10px;border-right:2px solid var(--wikt-palette-grey,#9e9e9e)\"></span><span style=\"position:relative;width:calc(100% + 0.5em);border-bottom:2px solid var(--wikt-palette-grey,#9e9e9e)\"></span>"
			end
			-- Add column div.
			v = "<div style=\"display:flex;flex-direction:column;align-items:center\">" .. v .. "</div>"
			subtreeString = subtreeString .. v
		end
		tree = "<div style=\"position:relative;display:flex;column-gap:0.5em;align-items:end\">" .. subtreeString .. "</div>" .. tree
	else
		--Reached a leaf node.
		treeWidth = treeWidth + 1
	end

	-- Add outer divs.
	if isTopLevel then
		tree = "<div style=\"width:fit-content;margin:auto;padding:0.5em;display:flex;flex-direction:column;align-items:center\">" .. tree .. "</div>"
		tree = "<div class=\"etytree NavFrame\" data-etytree-height=\"" .. treeHeight + 1 .. "\" data-etytree-width=\"" .. treeWidth .. "\"><div class=\"NavHead\" style=\"background:var(--wikt-palette-lightergrey,#eeeeee);color:inherit\"><div style=\"width:25em\">Etymology tree</div></div><div class=\"NavContent\" style=\"overflow:auto\">" .. tree .. "</div></div>"
	end

	return tree, treeHeight + 1, treeWidth
end

-- This function takes an etymon and generates some text to display in an entry.
-- Currently, it is only able to handle simple combinations of parameters.
local function etyText(title, lang, args, usePlusTemplates, maxDepth)
	local text = ""
	local depth = 1
	local alreadyWritten = {}
	local key, currLang, group, groupType, groupConfidence, confidence, derType, foundGroup, complexParams, ignoreEtymons, etymonLang, etymonTitle, etymonId, templateLang

	-- Loop and continuously expand the sentence until we reach the end of the chain.
	while not maxDepth or depth <= maxDepth do
		group, groupType, groupConfidence, confidence, derType, foundGroup, complexParams, ignoreEtymons, currLang = {}, "from", "conf", "conf", "from", false, false, false, lang
		key = getNormLang(lang):getFullCode() .. ">" .. get_link_page(title, lang) .. ">" .. args["id"]
		templateLang = getLang(args[1])
		-- Stop if we encounter an already-seen term.
		if alreadyWritten[key] ~= nil then
			break
		end
		alreadyWritten[key] = true
		local i = 1 -- Iterate from 2.
		while true do
			i = i + 1
			local param = args[i]
			if param == nil then
				break
			elseif find(param, ">", nil, true) and not ignoreEtymons then
				-- The text should only continue if `args` is either (not including `influence` or `afeq` etymons):
				-- A single etymon, or single `af` group. Otherwise the parameters are too "complex" and are rejected.
				-- TODO: add smarter handling for complex parameters.
				if foundGroup or (#group == 1 and not keywordDict[derType][6]) then
					complexParams = true
					break
				end
				groupType = derType
				if confidence == "unc" then
					groupConfidence = "unc"
				end
				insert(group, param)
			else
				-- Reached a keyword.
				if param == "unc" then
					confidence = param
				elseif param == "afeq" or param == "influence" then
					ignoreEtymons = true
					if #group == 1 then
						foundGroup = true
					end
				else
					ignoreEtymons = false
					confidence = "conf"
					derType = param
					if #group == 1 then
						foundGroup = true
					end
				end
			end
		end
		if complexParams or #group == 0 then
			break
		end
		if #group == 1 then
			args = getArgs(templateLang, group[1])
		end
		if text == "" then
			-- Start the sentence.
			if groupConfidence == "conf" and not usePlusTemplates then
				text = keywordDict[groupType][3]
			elseif groupConfidence == "conf" and usePlusTemplates then
				text = keywordDict[groupType][4]
			else
				text = "Possibly " .. keywordDict[groupType][5]
			end
		else
			-- Add a phrase onto the sentence.
			if groupConfidence == "conf" then
				text = text .. ", " .. keywordDict[groupType][5]
			else
				text = text .. ", possibly " .. keywordDict[groupType][5]
			end
		end
		-- Add the links.
		for i = 1, #group do
			etymonLang, etymonTitle, etymonId = getParts(templateLang, group[i])
			
			--Make sure ID exists prior to linking to it.
			if type(getArgs(templateLang, group[i])) ~= "table" then
				etymonId = nil
			end
			
			if etymonLang:getCanonicalName() ~= currLang:getCanonicalName() then
				group[i] = etymonLang:makeWikipediaLink() .. " " .. full_link({lang = etymonLang, term = etymonTitle, id = etymonId}, "term")
				currLang = etymonLang
			else
				group[i] = full_link({lang = etymonLang, term = etymonTitle, id = etymonId}, "term")
			end
		end
		text = text .. " " .. concat(group, " + ")
		depth = depth + 1
		if #group >= 2 then
			break
		end
		lang = etymonLang
		title = etymonTitle
		if type(args) ~= "table" then
			break
		end
	end
	-- Add a period at the end of the sentence.
	if text ~= "" then
		text = text .. "."
	end
	return text
end

-- This function take an etymon and recursively generates categories to add to the entry.
-- Currently the behaviour tries to emulate existing templates including {{dercat}}.
-- More specific and useful categories are planned pending consensus (e.g. take confidence into account).
local function etyCategories(title, langName, args, passedThroughOtherLanguage, inInhChain, categories, seen)
	local etymonLang, categoryEtymonTitle, etymonTitle, normTitle, etymonId, etymonLangName, etymonNormLangName, etymonArgs, key, L2_key, etymonPassedThroughOtherLanguage, etymonInInhChain, categoryName
	local templateLang, currGroupLength, derType, isTopLevel = getLang(args[1]), 0, "from"
	if categories == nil then
		categories, isTopLevel = {}, true
	end
	local i = 1 -- Iterate from 2.
	while true do
		i = i + 1
		local param = args[i]
		if param == nil then
			break
		elseif find(param, ">", nil, true) then
			currGroupLength = currGroupLength + 1
			etymonLang, etymonTitle, etymonId = getParts(templateLang, param)
			normTitle = get_link_page(etymonTitle, etymonLang)
			L2_key = getNormLang(etymonLang):getFullCode() .. ">" .. normTitle
			key = L2_key .. ">" .. etymonId

			etymonLangName = etymonLang:getCanonicalName()
			etymonNormLangName = getNormLang(etymonLang):getFullName()
			etymonInInhChain = inInhChain and (derType == "from" or derType == "inh")
			etymonPassedThroughOtherLanguage = passedThroughOtherLanguage or langName ~= etymonNormLangName
			etymonArgs = getArgs(templateLang, param)

			-- FIXME: this should use :getCanonicalName() for the target language name and :getDisplayForm() for the source language name. Currently uses :getCanonicalName() for both.
			if isTopLevel then
				--Add a maintenance category if an invalid ID is provided.
				if etymonArgs == "missing" or etymonArgs == "redlink" then
					if content_page == nil and is_content_page() or content_page then
						categories[langName .. " entries referencing etymons with invalid IDs"] = true
					else
						categories["Entries referencing etymons with invalid IDs/hidden"] = true
					end
				end
				-- Add borrowing categories at the top level only.
				if derType == "bor" or derType == "lbor" or derType == "slbor" then
					categories[langName .. " terms borrowed from " .. etymonLangName] = true
				end
				if derType == "lbor" then
					categories[langName .. " learned borrowings from " .. etymonLangName] = true
				elseif derType == "calque" then
					categories[langName .. " terms calqued from " .. etymonLangName] = true
				elseif derType == "sl" then
					categories[langName .. " semantic loans from " .. etymonLangName] = true
				elseif derType == "slbor" then
					categories[langName .. " semi-learned borrowings from " .. etymonLangName] = true
				elseif derType == "translit" then
					categories[langName .. " transliterations of " .. etymonLangName .. " terms"] = true
				elseif derType == "bf" then
					categories[langName .. " back-formations"] = true
				elseif derType == "blend" then
					categories[langName .. " blends"] = true
				elseif derType == "vrd" then
					categories[langName .. " vrddhi derivatives"] = true
				elseif derType == "obor" then
					categories[langName .. " orthographic borrowings from " .. etymonLangName] = true
				end
			end
			
			-- Add basic derivation categories.
			if etymonPassedThroughOtherLanguage and langName == etymonNormLangName then
				categories[langName .. " terms borrowed back into " .. etymonLangName] = true
			end
			if etymonNormLangName ~= langName then
				categories[langName .. " terms derived from " .. etymonLangName] = true
			end
			if etymonNormLangName ~= langName and etymonInInhChain then
				categories[langName .. " terms inherited from " .. etymonLangName] = true
			end

			categoryEtymonTitle = normTitle
			if sub(categoryEtymonTitle, 1, 15) == "Reconstruction:" then
				categoryEtymonTitle = gsub(categoryEtymonTitle, "^Reconstruction:[^/]+/", "*")
			end

			-- Add affix categories.
			local etymonArgsType = type(etymonArgs)
			if etymonArgsType == "table" and etymonArgs["pos"] ~= nil and (derType == "af" or "derType" == "afeq") and not etymonPassedThroughOtherLanguage then
				-- Ugly duplicated code...
				if (etymonArgs["pos"] == "prefix" or etymonArgs["pos"] == "suffix" or etymonArgs["pos"] == "interfix" or etymonArgs["pos"] == "infix") then
					if etymonArgs["pos"] == "prefix" then
						categoryName = langName .. " terms prefixed with " .. categoryEtymonTitle
					elseif etymonArgs["pos"] == "suffix" then
						categoryName = langName .. " terms suffixed with " .. categoryEtymonTitle
					elseif etymonArgs["pos"] == "interfix" then
						categoryName = langName .. " terms interfixed with " .. categoryEtymonTitle
					elseif etymonArgs["pos"] == "infix" then
						categoryName = langName .. " terms infixed with " .. categoryEtymonTitle
					end
					-- Add ID if necessary for disambiguation.
					if disambiguationCount[L2_key] > 1 then
						categoryName = categoryName .. " (" .. etymonId .. ")"
					end
					categories[categoryName] = true
				end
			end

			-- Add root categories.
			if etymonArgsType == "table" and etymonArgs["pos"] == "root" then
				if etymonPassedThroughOtherLanguage then
					categoryName = langName .. " terms derived from the " .. etymonLangName .. " root " .. categoryEtymonTitle
				else
					categoryName = langName .. " terms belonging to the root " .. categoryEtymonTitle
				end
				-- Add ID if necessary for disambiguation.
				if disambiguationCount[L2_key] > 1 then
					categoryName = categoryName .. " (" .. etymonId .. ")"
				end
				categories[categoryName] = true
			end
			
			-- Recurse into the etymon.
			if (
				not (derType == "afeq" or derType == "influence") and
				(seen == nil or seen[key] == nil) and
				etymonArgsType == "table"
			) then
				if seen == nil then
					seen = {}
				end
				seen[key] = true
				etyCategories(title, langName, etymonArgs, etymonPassedThroughOtherLanguage, etymonInInhChain, categories, seen)
			end
		elseif not (param == "unc" or param == "conf") then
			derType = param
			currGroupLength = 0
		end
	end
	return categories
end

-- TODO: this should all be integrated into etyCategories at the top-level pass.
local function paramsSanityCheck(lang, params, id, title, pos)
	if mw.ustring.len(id) < 2 then
		error("The `id` parameter must have at least two characters. See the [[Template:etymon/documentation#Parameters|documentation]] for more details.")
	elseif id == title or id == (page_data or get_page_data()).pagename then
		error("The `id` parameter must not be the same as the page title. Be more creative. See the [[Template:etymon/documentation#Parameters|documentation]] for more details.")
	end

	if pos and pos ~= "prefix" and pos ~= "suffix" and pos ~= "interfix" and pos ~= "infix" and pos ~= "root" then
		error("Unknown value provided for `pos`. Allowed values are: prefix, suffix, interfix, infix, root.")
	end

	local i, currKeyword, singleAfParam, paramLang = 0, "from", "not in group"
	while true do
		i = i + 1
		local param = params[i]
		if param == nil then
			break
		elseif find(param, ">", nil, true) then
			--In this case, `templateLang` is the same as `lang` because we are at the top level.
			paramLang = getParts(lang, param)
			if currKeyword == "from" then		
				if paramLang:getFullCode() ~= lang:getFullCode() then
					error("Error: " .. param .. " is associated with `from` (same-language derivation) but is of language `" .. paramLang:getFullCode() .. "`, which does not match the current entry language (`" .. lang:getFullCode() .. "`); see the [[Template:etymon/documentation#Derivation keywords|documentation]] for more details.")
				end
			elseif currKeyword == "inh" then
				check_ancestor(lang, paramLang)
			elseif keywordDict[currKeyword] and keywordDict[currKeyword][6] then
				singleAfParam = singleAfParam ~= "not in group" and "found group" or param
			elseif (currKeyword == "bor" or currKeyword == "lbor" or currKeyword == "obor" or currKeyword == "slbor" or currKeyword == "der" or currKeyword == "calque" or currKeyword == "sl") and (paramLang:getCode() == lang:getCode()) then
				error("Error: " .. param .. " is associated with `" .. currKeyword .. "` but has the same language (`" .. paramLang:getCode() .. "`) as the current entry; see the [[Template:etymon/documentation#Derivation keywords|documentation]] for more details.")
			end
		elseif param ~= "unc" and param ~= "conf" and param ~= "afeq" and keywordDict[param] == nil then
			error("Received unknown keyword: " .. param)
		elseif param ~= "unc" and param ~= "conf" then
			currKeyword = param
			if singleAfParam == "found group" then
				singleAfParam = "not in group"
			end
		end
	end
	if singleAfParam ~= "not in group" and singleAfParam ~= "found group" then
		error("Detected `af` or group containing only a single etymon: `" .. singleAfParam .. "`; note that `af` and `afeq` groups must have at least two etymons. See the [[Template:etymon/documentation#Derivation keywords|documentation]] for more details.")
	end
end

function export.main(_frame)
	frame = _frame
	-- Process argument input.
	local args = process_params(frame:getParent().args, mw.loadData(parameters_data_module).etymon)
	local lang = args[1]
	-- Store non-numeric parameters as locals, then treat the main numeric list as `args`.
	local id = args["id"]
	local title = args["title"]
	local text = args["text"]
	local tree = args["tree"]
	local exnihilo = args["exnihilo"]
	local pos = args["pos"]
	args = args[2]

	-- The `title` parameter is used for overriding the page title.
	if title == nil then
		-- Get the canonical pagename.
		title = (page_data or get_page_data()).pagename
		-- Determine if current term is reconstructed.
		if page_data.namespace == "Reconstruction" then
			title = "*" .. title
		end
	end
	
	paramsSanityCheck(lang, args, id, title, pos)

	-- Add the langcode and `id`, to match the format of scraped parameters.
	insert(args, 1, lang:getCode())
	args["id"] = id
	argsOf[args[1] .. ">" .. title .. ">" .. id] = args

	-- Add anchor to output.
	local output = {tostring(html_create("ul")
		:attr("id", language_anchor(lang, id))
		:allDone()
	)}
	
	local langName, categories = lang:getFullName(), {}
	if content_page == nil and is_content_page() or content_page then
		local categorySet = etyCategories(title, langName, args, false, true)
		for category in next, categorySet do
			insert(categories, category)
		end
	end

	-- Special categories.
	if exnihilo then
		insert(categories, langName .. " terms coined ex nihilo")
	end

	-- Insert tree.
	if tree then
		insert(output, templatestyles("Module:etymon/styles.css"))
		insert(output, (etyTree(title, lang, args, {}, true, false, "")))
		insert(categories, langName .. " entries with etymology trees")
	end

	-- Insert text.
	if text then
		insert(categories, langName .. " entries with etymology texts")
	end
	if text == "++" then
		insert(output, etyText(title, lang, args, true, false))
	elseif text == "+" then
		insert(output, etyText(title, lang, args, true, 1))
	elseif text == "-" then
		insert(output, etyText(title, lang, args, false, 1))
	elseif text ~= nil then
		insert(output, etyText(title, lang, args, false, false))
	end
	if #categories > 0 then
		insert(output, format_categories(categories, lang))
	end

	return concat(output)
end

return export