Jump to content

Module:etymon

Wiktionary වෙතින්

This module implements the template {{etymon}}.


--[=[

This module implements the {{etymon}} template for structured etymology data on Wiktionary.
It enables the creation of etymology trees and text by parsing etymon chains,
scraping linked pages for their own {{etymon}} data, and recursively building a tree
of derivational relationships.

Authors:
- Original implementation: [[User:Ioaxxere]]
- Full refactor (September 2025): [[User:Fenakhay]] ([[Special:Diff/86717746]])

Modules:
- [[Module:etymon]]: main module handling parsing, validation, tree building, and page scraping
- [[Module:etymon/data]]: keyword definitions, configuration, and status constants
- [[Module:etymon/tree]]: etymology tree rendering
- [[Module:etymon/text]]: etymology text generation
- [[Module:etymon/categories]]: category generation logic

]=]

local export = {}

local etymon_data_module = "Module:etymon/data"
local etymon_text_module = "Module:etymon/text"
local etymon_tree_module = "Module:etymon/tree"
local etymon_categories_module = "Module:etymon/categories"
local etymon_descendants_module = "Module:etymon/descendants"

local __state = {
	cached_etymon_args = {},
	cached_etymon_pages = {},
	cached_descendants_checks = {},
	senseid_parent_etymon = {},
	available_etymon_ids = {},
	single_etymons = {},
	entry_title = nil,
	entry_lang_code = nil,
	current_page_has_inline_etymology = false,
	current_page_has_redundant_etymology = false,
	used_idless_etymon = false,
	toplevel_has_inline_etymology = false,
	toplevel_redundant_etymology = false,
	toplevel_idless_etymon = false,
	has_mismatched_id = false,
	linked_page_multiple_etymons_idless = false,
	max_depth_reached = 0,
	total_nodes = 0,
	language_count = {},
	toplevel_keyword_stats = {},
	warnings = {},
}

local loader = require("Module:module loader")

local M = loader.init({
	require = {
		data = etymon_data_module,
		tree = etymon_tree_module,
		text = etymon_text_module,
		categories = etymon_categories_module,
		descendants = etymon_descendants_module,

		anchors = "Module:anchors",
		etydate = "Module:etydate",
		etymology = "Module:etymology",
		families = "Module:families",
		languages = "Module:languages",
		languages_errorgetby = "Module:languages/errorGetBy",
		links = "Module:links",
		pages = "Module:pages",
		parameters = "Module:parameters",
		string_utilities = "Module:string utilities",
		template_parser = "Module:template parser",
		utilities = "Module:utilities",
		debug = "Module:debug",
		en_utilities = "Module:en-utilities",
		parse_utilities = "Module:parse utilities",
		references = "Module:references",
		track = "Module:debug/track",
		template_styles = "Module:TemplateStyles",
		script_utilities = "Module:script utilities",
		JSON = "Module:JSON",
		yesno = "Module:yesno",
	},
	loadData = {
		headword_data = "Module:headword/data",
		parameters_data = "Module:parameters/data",
		text_allowed = "Module:etymon/data/text_allowed",
	},
})

local Util = {}

function Util.format_error(message, preview_only)
	if preview_only and not M.pages.is_preview() then
		return nil
	end
	return '<span class="error">' .. message .. '</span>'
end

function Util.add_warning(message, preview_only)
	local formatted = Util.format_error(message, preview_only)
	if formatted then
		table.insert(__state.warnings, formatted)
	end
end

function Util.is_text_param_allowed_for_lang(lang)
	if not lang or type(lang) ~= "table" then
		return false
	end
	local types = lang.getTypes and lang:getTypes()
	if types and types.family then
		local code = lang.getCode and lang:getCode()
		return code and M.text_allowed.families[code] == true
	end
	local full_code = lang.getFullCode and lang:getFullCode()
	if full_code and M.text_allowed.langs[full_code] then
		return true
	end
	if lang.inFamily then
		for family_code in pairs(M.text_allowed.families) do
			if lang:inFamily(family_code) then
				return true
			end
		end
	end
	return false
end

function Util.get_lang(code, no_error)
	if no_error then
		return M.languages.getByCode(code, nil, true)
	end
	return M.languages.getByCode(code, nil, true) or M.languages_errorgetby.code(code, true, true)
end

function Util.get_family(code)
	return M.families.getByCode(code)
end

function Util.get_lang_exception(lang)
	-- Families have no language-specific exceptions
	if lang.getTypes and lang:getTypes().family then
		return nil
	end
	local code = lang:getCode()
	local lang_exceptions = M.data.config.lang_exceptions

	if lang_exceptions[code] then
		return lang_exceptions[code]
	end

	for norm_code, exc in pairs(lang_exceptions) do
		if exc.normalize_to and code == exc.normalize_to then
			return exc
		end
		if exc.normalize_from_families then
			local should_normalize = false
			for _, family in ipairs(exc.normalize_from_families) do
				if lang:inFamily(family) then
					should_normalize = true
					break
				end
			end

			if should_normalize and exc.normalize_exclude_families then
				for _, family in ipairs(exc.normalize_exclude_families) do
					if lang:inFamily(family) then
						should_normalize = false
						break
					end
				end
			end

			if should_normalize then
				local ret = {}
				for k, v in pairs(exc) do
					ret[k] = v
				end
				ret.suppress_tr = nil
				return ret
			end
		end
	end

	return nil
end

function Util.get_norm_lang(lang)
	local exc = Util.get_lang_exception(lang)
	if exc and exc.normalize_to then
		return M.languages.getByCode(exc.normalize_to)
	end
	return lang
end

-- Add default values for boolean modifiers (e.g., <unc> becomes <unc:1>)
-- This is needed because Module:parse utilities expects boolean modifiers to have explicit values
function Util.add_boolean_defaults(str, param_mods)
	local result = str
	for name, spec in pairs(param_mods) do
		if spec.type == "boolean" then
			-- Replace <name> with <name:1> (but not <name:...> which already has a value)
			result = result:gsub("<" .. name .. ">", "<" .. name .. ":1>")
		end
	end
	return result
end

-- Centralized term formatting: handles suppress_term, unknown_term, and regular terms
function Util.format_term(term, is_toplevel, opts)
	opts = opts or {}

	-- suppress_term (-) returns nil
	if term.suppress_term then
		return nil
	end

	local lang = term.lang
	local exc = Util.get_lang_exception(lang)

	if is_toplevel then
		local display_text = term.alt or term.title or ""
		local sc = term.sc or lang:findBestScript(display_text)
		local bold_text = tostring(mw.html.create("strong")
			:addClass("selflink")
			:wikitext(display_text))
		return M.script_utilities.tag_text(bold_text, lang, sc, "term")
	end

	local link_params = { lang = lang }

	link_params.term = not term.unknown_term and term.title or nil
	link_params.alt = term.alt
	link_params.id = (not term.unknown_term and term.id and term.id ~= "") and term.id or nil
	if not (exc and exc.suppress_tr) then
		link_params.tr = term.tr
		link_params.ts = term.ts
	else
		link_params.suppress_tr = true
	end
	link_params.lit = (opts.lit ~= "suppress") and term.lit or nil
	if opts.gloss ~= "suppress" then
		link_params.gloss = term.t
	end
	if term.g and term.g ~= "" then
		local genders = M.string_utilities.split(term.g, ",")
		for i = 1, #genders do
			genders[i] = M.string_utilities.trim(genders[i])
		end
		link_params.genders = genders
	end
	if opts.pos ~= "suppress" then
		link_params.pos = term.pos
		link_params.ng = term.ng
	end
	if exc and exc.suppress_tr then
		link_params.lit = nil
	end

	local show_qualifiers
	if opts.tree_ql ~= "suppress" then
		if term.q then
			link_params.q = term.q
		end
		if term.qq then
			link_params.qq = term.qq
		end
		if term.l then
			link_params.l = term.l
		end
		if term.ll then
			link_params.ll = term.ll
		end
		show_qualifiers = term.q or term.qq or term.l or term.ll
	end

	return M.links.full_link(link_params, "term", nil, show_qualifiers and true or nil)
end

local __is_content_page_cached
function Util.is_content_page()
	if __is_content_page_cached == nil then
		__is_content_page_cached = M.pages.is_content_page(mw.title.getCurrentTitle())
	end
	return __is_content_page_cached
end

local __page_data_cached
function Util.get_page_data()
	if not __page_data_cached then
		__page_data_cached = M.headword_data.page
	end
	return __page_data_cached
end

-- Extract base keyword from param (without modifiers)
local function get_keyword_base(param)
	if type(param) ~= "string" then return nil end
	local base = param:match("^:?([^<]+)") or param:gsub("^:", "")
	return base
end

local function is_keyword(param, allow_colon_less)
	if type(param) ~= "string" then return false end
	local keywords = M.data.keywords
	if param:sub(1, 1) == ":" then
		local base = get_keyword_base(param)
		return keywords[base] ~= nil
	end
	if allow_colon_less then
		local base = get_keyword_base(param)
		return keywords[base] ~= nil
	end
	return false
end

local function get_keyword(param, allow_colon_less)
	if type(param) ~= "string" then return nil end
	local keywords = M.data.keywords
	if param:sub(1, 1) == ":" then
		return get_keyword_base(param)
	end
	if allow_colon_less then
		local base = get_keyword_base(param)
		if keywords[base] then
			return base
		end
	end
	return nil
end

local function normalize_keyword(keyword)
	if keyword:sub(1, 1) == ":" then
		return keyword
	end
	return ":" .. keyword
end

-- Resolve keyword (possibly an alias) to its canonical form. Used only at input boundaries
local function get_canonical_keyword(keyword)
	if not keyword then return keyword end
	return M.data.keyword_canonical[keyword] or keyword
end

-- Build text/phrase for nominalization with <g:code> (uses data module for codes only).
local function get_nominalization_label_for_g(code)
	if not code or code == "" then return nil end
	local codes = M.data.nominalization_g_codes
	local adj = codes[code]
	if not adj and #code == 2 then
		local gender_adj = codes[code:sub(1, 1)]
		local number_adj = codes[code:sub(2, 2)]
		if gender_adj and number_adj then
			adj = gender_adj .. " " .. number_adj
		end
	end
	if not adj then return nil end
	local text = adj:gsub("^%l", function(c) return string.upper(c) end) .. " [[Appendix:Glossary#nominalization|nominalization]] of"
	local phrase = M.en_utilities.add_indefinite_article(adj .. " [[Appendix:Glossary#nominalization|nominalization]] of", false)
	return { text = text, phrase = phrase }
end

local EtymonParser = {}

-- Keyword modifier definitions
EtymonParser.keyword_param_mods = {
	unc = { type = "boolean" },
	ref = {},
	text = { restrict = { keywords = { "from", "derived" } } },
	lit = { restrict = { keywords = { "affix", "surf", "univerbation" } } },
	conj = {}, -- conjunction for alternatives: "and", "or", "and/or", etc.
	g = { restrict = { keywords = { "nominalization" } } },
}

-- Term modifier definitions
EtymonParser.etymon_param_mods = {
	id = {},
	t = {},
	tr = {},
	ts = {},
	q = {},
	qq = {},
	l = {},
	ll = {},
	pos = {},
	ng = {},
	alt = {},
	g = {},
	ety = {},
	lit = {},
	unc = { type = "boolean" },
	ref = {},
	aftype = { restrict = { keywords = { "affix", "surf", "afeq" } } },
	postype = {},
	bor = { type = "boolean", restrict = { keywords = { "affix", "surf" } } },
	slbor = { type = "boolean", restrict = { keywords = { "affix", "surf" } } },
	lbor = { type = "boolean", restrict = { keywords = { "affix", "surf" } } },
}

local function get_clean_param_mods(param_mods)
	local clean = {}
	for mod_name, mod_def in pairs(param_mods) do
		clean[mod_name] = {}
		for key, value in pairs(mod_def) do
			if key ~= "restrict" then
				clean[mod_name][key] = value
			end
		end
	end
	return clean
end

function EtymonParser.check_modifier_restrictions(modifiers, current_keyword, param_mods)
	for mod_name, mod_value in pairs(modifiers) do
		-- Only check restrictions if the modifier has a non-false/nil value
		if mod_value then
			local mod_def = param_mods[mod_name]
			if mod_def and mod_def.restrict and mod_def.restrict.keywords then
				local allowed_keywords = mod_def.restrict.keywords
				local is_allowed = false
				for _, allowed_keyword in ipairs(allowed_keywords) do
					if current_keyword == allowed_keyword then
						is_allowed = true
						break
					end
				end
				if not is_allowed then
					local keyword_list = {}
					for _, kw in ipairs(allowed_keywords) do
						table.insert(keyword_list, ":" .. kw)
					end
					local keyword_str = table.concat(keyword_list, #keyword_list == 2 and " or " or ", ")
					if #keyword_list > 2 then
						-- Replace last comma with "or"
						keyword_str = keyword_str:gsub(", ([^,]+)$", " or %1")
					end
					local mod_display = mod_value == true and "<" .. mod_name .. ">" or "<" .. mod_name .. ":" .. tostring(mod_value) .. ">"
					error("The modifier `" .. mod_display .. "` is only allowed for the keyword" .. (#keyword_list > 1 and "s " or " ") .. keyword_str .. ".")
				end
			end
		end
	end
end

-- Parse keyword with modifiers (e.g., ":bor<unc>" or ":bor<ref:{{R:example}}>")
function EtymonParser.parse_keyword_modifiers(param)
	if type(param) ~= "string" then return nil, {} end

	local base_keyword = get_keyword_base(param)
	if not base_keyword then return nil, {} end

	local canonical_keyword = get_canonical_keyword(base_keyword)

	-- Check if there are any modifiers
	if not param:find("<", 1, true) then
		return canonical_keyword, {}
	end

	-- Parse modifiers using the same mechanism as etymon parsing
	local rest_with_defaults = Util.add_boolean_defaults(param, EtymonParser.keyword_param_mods)

	local function generate_obj(ignored)
		return {}
	end

	local parsed = M.parse_utilities.parse_inline_modifiers(rest_with_defaults:gsub("^:?[^<]+", ""),
		{ param_mods = get_clean_param_mods(EtymonParser.keyword_param_mods), generate_obj = generate_obj })

	local modifiers = {
		unc = parsed.unc or false,
		ref = parsed.ref,
		text = parsed.text,
		lit = parsed.lit,
		conj = parsed.conj,
		g = parsed.g,
	}

	-- Validate modifiers against restrictions
	EtymonParser.check_modifier_restrictions(modifiers, canonical_keyword, EtymonParser.keyword_param_mods)

	return canonical_keyword, modifiers
end

function EtymonParser.parse_balanced_segments(str)
	local segments = {}
	local current = ""
	local depth = 0
	local i = 1

	while i <= #str do
		local char = str:sub(i, i)

		if char == "<" then
			if depth == 0 and current ~= "" then
				table.insert(segments, current)
				current = ""
			end
			depth = depth + 1
			current = current .. char
		elseif char == ">" then
			current = current .. char
			depth = depth - 1
			if depth == 0 then
				table.insert(segments, current)
				current = ""
			elseif depth < 0 then
				error("Unbalanced brackets in etymon: unexpected '>'")
			end
		else
			current = current .. char
		end

		i = i + 1
	end

	if depth ~= 0 then
		error("Unbalanced brackets in etymon: missing '>'")
	end

	if current ~= "" then
		table.insert(segments, current)
	end

	return segments
end

function EtymonParser.parse_inline_ety(ety_string, context_lang)
	local segments = EtymonParser.parse_balanced_segments(ety_string)

	if #segments == 0 then
		error("Empty inline etymology")
	end

	local keyword = M.string_utilities.trim(segments[1])

	if not is_keyword(keyword, true) then
		error("Invalid keyword '" .. keyword .. "' in inline etymology <ety:" .. keyword .. "...>")
	end

	local args = { context_lang:getCode(), normalize_keyword(get_canonical_keyword(keyword)) }

	for i = 2, #segments do
		local segment = segments[i]
		if segment:sub(1, 1) == "<" and segment:sub(-1) == ">" then
			local inner = segment:sub(2, -2)
			if inner ~= "" then
				table.insert(args, inner)
			end
		elseif is_keyword(segment, true) then
			-- Handle keywords that appear between bracketed segments
			table.insert(args, normalize_keyword(get_canonical_keyword(get_keyword(segment, true))))
		end
	end

	return args
end

function EtymonParser.parse_etymon(param, context_lang)
	if is_keyword(param) then
		return nil
	end

	if type(param) ~= "string" then
		return nil
	end

	local lang, rest
	local is_family = false

	local before_bracket = param:match("^([^<]*)") or param
	local lang_code, rest_match = before_bracket:match("^([a-zA-Z][a-zA-Z0-9._-]*):(.*)$")

	if lang_code then
		local potential_lang = Util.get_lang(lang_code, true)
		if potential_lang then
			lang = potential_lang
			rest = param:sub(#lang_code + 2)
		else
			local potential_family = Util.get_family(lang_code)
			if potential_family then
				lang = potential_family
				rest = param:sub(#lang_code + 2)
				is_family = true
			else
				lang = context_lang
				rest = param
			end
		end
	else
		lang = context_lang
		rest = param
	end

	if rest == "" then
		M.track("etymon/term/empty")
	elseif rest == "?" then
		M.track("etymon/term/question-mark")
	elseif rest == "-" then
		M.track("etymon/term/hyphen")
	end

	if rest == "" then
		return {
			lang = lang,
			term = nil,
			unknown_term = true,
			is_family = is_family,
		}
	end

	if rest == "-" then
		return {
			lang = lang,
			term = nil,
			suppress_term = true,
			is_family = is_family,
		}
	end

	if not rest:find("<", 1, true) then
		return {
			lang = lang,
			term = M.string_utilities.trim(rest),
			is_family = is_family,
		}
	end

	local term_text = rest:match("^([^<]*)") or ""
	local is_unknown = (term_text == "")
	local is_suppress = (term_text == "-")

	local function generate_obj(ignored_term)
		return { term = (is_unknown or is_suppress) and nil or M.string_utilities.trim(term_text) }
	end

	local rest_with_defaults = Util.add_boolean_defaults(rest, EtymonParser.etymon_param_mods)

	local parsed_obj = M.parse_utilities.parse_inline_modifiers(rest_with_defaults,
		{ param_mods = get_clean_param_mods(EtymonParser.etymon_param_mods), generate_obj = generate_obj })

	if parsed_obj.id and parsed_obj.id:match("^!") then
		parsed_obj.id = parsed_obj.id:sub(2)
		parsed_obj.override = true
	end

	parsed_obj.lang = lang
	parsed_obj.is_family = is_family
	if is_unknown then
		parsed_obj.unknown_term = true
	elseif is_suppress then
		parsed_obj.suppress_term = true
	end

	return parsed_obj
end

function EtymonParser.validate(lang, args, id, title, pos, starts_with_lang_code)
	-- id is now optional, so only validate if provided
	if id then
		if mw.ustring.len(id) < 2 then
			error("The `id` parameter must have at least two characters.")
		end
		if id == title or id == Util.get_page_data().pagename then
			error("The `id` parameter must not be the same as the page title.")
		end
	end

	local valid_pos = { prefix = true, suffix = true, interfix = true, infix = true, root = true, word = true }
	if pos and not valid_pos[pos] then
		error("Unknown value provided for `pos`. Valid values: " .. table.concat(require("Module:table").keysToList(valid_pos), ", ") .. ".")
	end

	local current_keyword = "from"
	local etymons_in_group = {}
	local keywords = M.data.keywords

	local function checkGroup()
		if keywords[current_keyword] and keywords[current_keyword].is_group and current_keyword ~= "affix" and current_keyword ~= "surf" and current_keyword ~= "afeq" and current_keyword ~= "univerbation" and #etymons_in_group <= 1 then
			error("Detected `:" .. current_keyword .. "` group with fewer than two etymons.")
		end
		etymons_in_group = {}
	end

	local start_index = starts_with_lang_code and 2 or 1

	for i = start_index, #args do
		local param = args[i]

		if type(param) ~= "string" then
		elseif param:sub(1, 1) == ":" and not is_keyword(param) then
			error("Invalid keyword '" .. param .. "'. Did you mean a valid keyword like ':bor', ':inh', etc.?")
		elseif is_keyword(param) then
			checkGroup()
			current_keyword = get_canonical_keyword(get_keyword(param))
		else
			local etymon_data = EtymonParser.parse_etymon(param, lang)
			if etymon_data then
				table.insert(etymons_in_group, param)
				local param_lang = etymon_data.lang

				if etymon_data.is_family and current_keyword == "inherited" then
					error("`:inh` does not support family codes; use a specific language.")
				end
				if etymon_data.is_family and not etymon_data.suppress_term then
					error("Family codes require suppressed term (use family:-).")
				end
				if current_keyword == "from" and param_lang:getFullCode() ~= lang:getFullCode() then
					error("`:from` is for same-language derivation, but language does not match. " ..
						"Expected '" .. lang:getFullCode() .. "', got '" .. param_lang:getFullCode() .. "'.")
				elseif current_keyword == "inherited" then
					M.etymology.check_ancestor(lang, param_lang)
				end

				-- Check modifier restrictions
				EtymonParser.check_modifier_restrictions(etymon_data, current_keyword, EtymonParser.etymon_param_mods)

				-- postype must be "root" or "word"
				local VALID_POSTYPES = { root = true, word = true }
				if etymon_data.postype and not VALID_POSTYPES[etymon_data.postype] then
					error("Invalid <postype:" .. etymon_data.postype .. ">; must be \"root\" or \"word\".")
				end

				if etymon_data.ety then
					local inline_args = EtymonParser.parse_inline_ety(etymon_data.ety, etymon_data.lang)
					EtymonParser.validate(etymon_data.lang, inline_args, nil, nil, nil, true)
				end
			else
				table.insert(etymons_in_group, param)
			end
		end
	end

	checkGroup()
end

local DataRetriever = {}

local function format_etymon_id_hint(id_data, idx)
	local id = type(id_data) == "table" and id_data.id or id_data
	local pos = type(id_data) == "table" and id_data.pos
	if id and id ~= "" and id ~= "*" then
		return '"' .. id .. '"'
	end
	if pos and pos ~= "" then
		return "unnamed (|pos=" .. pos .. "|)"
	end
	return "etymon #" .. idx .. " (no |id= on page)"
end

local function etymon_target_page_link(page, norm_lang)
	return M.links.full_link({
		term = page,
		lang = norm_lang,
		no_generate_forms = true,
	}, "term")
end

-- Summarize {{etymon}} id slots on a linked page for preview warnings.
local function summarize_available_etymon_ids(ids)
	local id_list = {}
	local all_idless = true
	local target_has_idless = false
	local any_pos = false
	for i, id_data in ipairs(ids) do
		local id = type(id_data) == "table" and id_data.id or id_data
		local pos = type(id_data) == "table" and id_data.pos
		if id and id ~= "" and id ~= "*" then
			all_idless = false
		else
			target_has_idless = true
		end
		if pos and pos ~= "" then
			any_pos = true
		end
		table.insert(id_list, format_etymon_id_hint(id_data, i))
	end
	return {
		id_list = id_list,
		all_idless = all_idless,
		target_has_idless = target_has_idless,
		any_pos = any_pos,
		count = #ids,
		options_text = mw.text.listToText(id_list),
	}
end

local function ambiguous_etymon_suggestion(page_link, summary)
	if summary.all_idless then
		if summary.any_pos then
			return " None set `|id=` yet; add a unique `|id=` to each on " .. page_link
				.. ", then `<id:identifier>` after the term here. Section order / hints: "
				.. summary.options_text .. "."
		end
		return " None set `|id=` yet; add a unique `|id=` to each {{etymon}} in that section from top to bottom, then `<id:identifier>` after the term here (same value as `|id=`)."
	end
	return " Specify which one with `<id:identifier>` after the term. Options: " .. summary.options_text .. "."
end

local function warn_ambiguous_etymon_link(page, norm_lang, ids, is_toplevel)
	local page_link = etymon_target_page_link(page, norm_lang)
	local summary = summarize_available_etymon_ids(ids)
	if is_toplevel and summary.target_has_idless then
		__state.linked_page_multiple_etymons_idless = true
	end
	local lang_name = norm_lang:getCanonicalName()
	local lead = "Etymology link to " .. page_link .. " is ambiguous (" .. summary.count
		.. " {{etymon}} templates for " .. lang_name .. ")."
	Util.add_warning(lead .. ambiguous_etymon_suggestion(page_link, summary), true)
end

local function is_mismatched_explicit_id(base_key, cached_args, parent_etymon)
	return cached_args == M.data.STATUS.MISSING and not parent_etymon
		and #(__state.available_etymon_ids[base_key] or {}) > 0
end

local function warn_mismatched_explicit_id(page, norm_lang, base_key, etymon_id)
	local page_link = etymon_target_page_link(page, norm_lang)
	local summary = summarize_available_etymon_ids(__state.available_etymon_ids[base_key] or {})
	local lang_name = norm_lang:getCanonicalName()
	local lead = "Etymology link to " .. page_link .. " uses `<id:" .. etymon_id
		.. ">`, but no {{etymon}} on that page has `|id=" .. etymon_id .. "|` for " .. lang_name .. "."
	Util.add_warning(lead .. " Valid IDs: " .. summary.options_text .. ".", true)
end

-- Given an etymon data, scrape its page and cache the result in the global state object.
function DataRetriever.cache_page_etymons(etymon_page, etymon_title, key, etymon_lang, etymon_id, redirected_from, descendants_is_toplevel)
	local content = etymon_title:getContent()
	if not content then
		__state.cached_etymon_args[key] = M.data.STATUS.REDLINK
		return
	end

	-- Check if the linked page is a redirect. If it is, the template parsing
	-- code below will be effectively skipped, and `scrape_page` will be called
	-- again on the redirect target (see the bottom of this function)
	local lang_section_for_descendants = nil
	local redirect_target = etymon_title.redirect_target
	if not redirect_target then
		content = M.pages.get_section(content, etymon_lang:getFullName(), 2)
		if not content then
			__state.cached_etymon_args[key] = M.data.STATUS.MISSING
			return
		end
		lang_section_for_descendants = content
	end

	local etymon_lang_code = etymon_lang:getFullCode()
	local lang_page_key = etymon_lang_code .. ":" .. etymon_page
	local found_templates_for_lang = {}
	local found_ids = {}
	local get_node_class = M.template_parser.class_else_type

	-- Look for all {{etymon}} templates within the page content using the template parser
	-- This way the same page is never parsed more than once

	-- Build a map from senseids to their parent etymonids.
	local active_etymon_args = nil

	for node in M.template_parser.parse(content):iterate_nodes() do
		local node_class = get_node_class(node)

		if node_class == "heading" then
			-- A new L2 or etymology section acts as a barrier: an {{etymon}} usage
			-- used previously cannot be the parent of any subsequent senseids.
			-- Note that we don't have to check for L2s due to the usage of `M.pages.get_section` above.
			if node:get_name():find("^Etymology") then
				active_etymon_args = nil
			end
		elseif node_class == "template" then
			local template_name = node:get_name()

			if template_name == "etymon" then
				local template_args = node:get_arguments()

				-- Check if this etymon is for our language
				if template_args[1] == etymon_lang_code then
					table.insert(found_templates_for_lang, template_args)

					if template_args.id then
						local etymon_key = lang_page_key .. ":" .. template_args.id
						__state.cached_etymon_args[etymon_key] = template_args
						__state.cached_etymon_pages[etymon_key] = tostring(etymon_page)
						table.insert(found_ids, template_args.id)
						active_etymon_args = template_args
					else
						-- Store idless etymon with default key
						local etymon_key = lang_page_key .. ":*"
						__state.cached_etymon_args[etymon_key] = template_args
						__state.cached_etymon_pages[etymon_key] = tostring(etymon_page)
						table.insert(found_ids, "*")
						active_etymon_args = template_args
					end
				end
			elseif active_etymon_args and template_name == "senseid" then
				local template_args = node:get_arguments()

				-- This should always be true for proper usages of {{senseid}}.
				if template_args[1] == etymon_lang_code and template_args[2] then
					local sense_id_key = lang_page_key .. ":" .. template_args[2]
					__state.senseid_parent_etymon[sense_id_key] = active_etymon_args
					__state.cached_etymon_pages[sense_id_key] = tostring(etymon_page)
				end
			end
		end
	end

	if descendants_is_toplevel and lang_section_for_descendants and #found_templates_for_lang > 0 then
		M.descendants.cache_page_checks({
			lang_section = lang_section_for_descendants,
			etymon_lang_code = etymon_lang_code,
			found_templates_for_lang = found_templates_for_lang,
			entry_title = __state.entry_title,
			entry_lang_code = __state.entry_lang_code,
			entry_lang = __state.entry_lang_code and Util.get_lang(__state.entry_lang_code, true) or nil,
			cached_descendants_checks = __state.cached_descendants_checks,
			lang_page_key = lang_page_key,
			redirected_from = redirected_from,
		})
	end

	local id_data_list = {}
	for _, args in ipairs(found_templates_for_lang) do
		local id = args.id or "*"
		table.insert(id_data_list, { id = id, pos = args.pos })
	end
	__state.available_etymon_ids[lang_page_key] = id_data_list

	if #found_templates_for_lang == 1 then
		__state.single_etymons[lang_page_key] = found_templates_for_lang[1]
	end

	if redirected_from and __state.available_etymon_ids[lang_page_key] then
		__state.available_etymon_ids[redirected_from] = __state.available_etymon_ids[redirected_from] or {}
		for _, id_data in ipairs(__state.available_etymon_ids[lang_page_key]) do
			table.insert(__state.available_etymon_ids[redirected_from], id_data)
		end
	end

	if __state.cached_etymon_args[key] ~= nil or __state.senseid_parent_etymon[key] ~= nil then
		-- All done!
		return
	elseif redirect_target and not redirected_from then
		-- Try scraping the redirect.
		etymon_page = redirect_target.prefixedText
		DataRetriever.cache_page_etymons(etymon_page, redirect_target, lang_page_key .. ":" .. etymon_id, etymon_lang, etymon_id, lang_page_key, descendants_is_toplevel)
		__state.cached_etymon_args[key] = __state.cached_etymon_args[etymon_lang_code .. ":" .. etymon_page .. ":" .. etymon_id]
	else
		__state.cached_etymon_args[key] = M.data.STATUS.MISSING
	end
end

-- Given an etymon object, scrape its page (if necessary) and return its own etymon arguments as well as the page name.
function DataRetriever.get_etymon_args(etymon_data, is_toplevel)
	local page = M.links.get_link_page(etymon_data.term, etymon_data.lang)
	local norm_lang = Util.get_norm_lang(etymon_data.lang)
	local base_key = norm_lang:getFullCode() .. ":" .. page

	if etymon_data.id then
		local key = base_key .. ":" .. etymon_data.id

		local cached_args = __state.cached_etymon_args[key] or __state.senseid_parent_etymon[key]
		if cached_args == nil then
			local title = mw.title.new(page)
			if not title then error('Invalid page title "' .. page .. '" encountered.') end
			DataRetriever.cache_page_etymons(page, title, key, norm_lang, etymon_data.id, nil, is_toplevel)
		end
		cached_args = __state.cached_etymon_args[key] or __state.senseid_parent_etymon[key] -- refresh

		-- Get etymon_id from parent if this was resolved via senseid
		local parent_etymon = __state.senseid_parent_etymon[key]
		local resolved_etymon_id = parent_etymon and parent_etymon.id
		local descendants_check = M.descendants.get_lookup_check({
			cached_descendants_checks = __state.cached_descendants_checks,
			is_toplevel = is_toplevel,
			base_key = base_key,
			lookup = {
				explicit_id = etymon_data.id,
				parent_etymon = parent_etymon,
			},
		})
		if is_toplevel and descendants_check == nil then
			local title = mw.title.new(page)
			if title then
				DataRetriever.cache_page_etymons(page, title, key, norm_lang, etymon_data.id, nil, true)
				descendants_check = M.descendants.get_lookup_check({
					cached_descendants_checks = __state.cached_descendants_checks,
					is_toplevel = true,
					base_key = base_key,
					lookup = {
						explicit_id = etymon_data.id,
						parent_etymon = parent_etymon,
					},
				})
			end
		end
		local mismatched_id = is_mismatched_explicit_id(base_key, cached_args, parent_etymon)
		if mismatched_id and is_toplevel then
			__state.has_mismatched_id = true
			warn_mismatched_explicit_id(page, norm_lang, base_key, etymon_data.id)
		end
		return cached_args, __state.cached_etymon_pages[key], resolved_etymon_id, descendants_check
	else
		__state.used_idless_etymon = true
		if is_toplevel then
			__state.toplevel_idless_etymon = true
		end

		if __state.available_etymon_ids[base_key] == nil then
			local title = mw.title.new(page)
			if not title then error('Invalid page title "' .. page .. '" encountered.') end
			DataRetriever.cache_page_etymons(page, title, base_key .. ":*", norm_lang, "*", nil, is_toplevel)
		end

		local ids = __state.available_etymon_ids[base_key] or {}
		local count = #ids

		-- Try to filter by postype if available and we have multiple candidates
		if count > 1 and etymon_data.postype then
			local matching_ids = {}
			for _, id_data in ipairs(ids) do
				if id_data.pos == etymon_data.postype then
					table.insert(matching_ids, id_data)
				end
			end
			
			if #matching_ids == 1 then
				local matched_id = matching_ids[1].id
				local matched_key = base_key .. ":" .. matched_id
				local descendants_check = M.descendants.get_lookup_check({
					cached_descendants_checks = __state.cached_descendants_checks,
					is_toplevel = is_toplevel,
					base_key = base_key,
					lookup = { id = matched_id },
				})
				if is_toplevel and descendants_check == nil then
					local title = mw.title.new(page)
					if title then
						DataRetriever.cache_page_etymons(page, title, base_key .. ":*", norm_lang, "*", nil, true)
						descendants_check = M.descendants.get_lookup_check({
							cached_descendants_checks = __state.cached_descendants_checks,
							is_toplevel = true,
							base_key = base_key,
							lookup = { id = matched_id },
						})
					end
				end
				return __state.cached_etymon_args[matched_key], __state.cached_etymon_pages[matched_key], nil, descendants_check
			end
		end

		if count == 1 then
			local only_id_data = ids[1]
			local only_id = (type(only_id_data) == "table" and only_id_data.id) or only_id_data or "*"
			local descendants_check = M.descendants.get_lookup_check({
				cached_descendants_checks = __state.cached_descendants_checks,
				is_toplevel = is_toplevel,
				base_key = base_key,
				lookup = { id_data = only_id_data },
			})
			if is_toplevel and descendants_check == nil then
				local title = mw.title.new(page)
				if title then
					DataRetriever.cache_page_etymons(page, title, base_key .. ":*", norm_lang, "*", nil, true)
					descendants_check = M.descendants.get_lookup_check({
						cached_descendants_checks = __state.cached_descendants_checks,
						is_toplevel = true,
						base_key = base_key,
						lookup = { id_data = only_id_data },
					})
				end
			end
			return __state.single_etymons[base_key], __state.cached_etymon_pages[base_key .. ":" .. only_id], nil, descendants_check
		elseif count > 1 then
			warn_ambiguous_etymon_link(page, norm_lang, ids, is_toplevel)
			return M.data.STATUS.AMBIGUOUS, nil, nil, nil
		else
			return M.data.STATUS.MISSING, nil, nil, nil
		end
	end
end

local TreeBuilder = {}

local function parse_etymon_references(refs_text)
	if not refs_text or refs_text == "" then
		return ""
	end
	return M.references.parse_references(refs_text)
end

local function parse_tree_references(node)
	if node.ref then
		node.parsed_ref = parse_etymon_references(node.ref)
	end

	if node.children then
		for _, container in ipairs(node.children) do
			if container.terms then
				for _, term in ipairs(container.terms) do
					parse_tree_references(term)
				end
			end
		end
	end
end

-- Build a unique key for deduplication in the seen table
function TreeBuilder.build_key(lang, title, args)
	local norm_lang_code = Util.get_norm_lang(lang):getFullCode()
	local is_table = type(args) == "table"
	local id = (is_table and args.id) or ""
	
	if title then
		return norm_lang_code .. ":" .. M.links.get_link_page(title, lang) .. ":" .. id
	end
	
	if is_table and args.status == M.data.STATUS.INLINE then
		local content_parts = {}
		for i = 1, #args do
			content_parts[i] = tostring(args[i])
		end
		return norm_lang_code .. ":*:" .. id .. "\0" .. table.concat(content_parts, "\0")
	end
	
	return norm_lang_code .. ":*:" .. id
end

function TreeBuilder.build(lang, title, args, seen, depth, stop_recursion)
	seen = seen or {}
	depth = depth or 0
	local is_toplevel = (depth == 0)

	if depth > __state.max_depth_reached then
		__state.max_depth_reached = depth
	end

	__state.total_nodes = __state.total_nodes + 1

	local lang_code = lang:getCode()
	__state.language_count[lang_code] = (__state.language_count[lang_code] or 0) + 1

	local current_id = (type(args) == "table" and args.id) or ""
	local key = TreeBuilder.build_key(lang, title, args)
	
	local node = { lang = lang, title = title, id = current_id, args = args, children = {}, status = M.data.STATUS.OK }

	if type(args) ~= "table" or seen[key] then
		node.status = args or M.data.STATUS.MISSING
		-- Mark as duplicate if we've seen this node before
		if seen[key] then
			node.is_duplicate = true
			node.duplicate_key = key
			local original_node = seen[key]
			if type(original_node) == "table" and original_node.children and #original_node.children > 0 then
				node.original_has_children = true
			end
		end
		return node
	end

	node.status = args.status or M.data.STATUS.OK

	seen[key] = node

	-- If stop_recursion is set, skip parsing children but check for visible children
	if stop_recursion then
		local keywords = M.data.keywords
		local has_visible_children = false
		for i = 2, #args do
			local param = args[i]
			if type(param) == "string" then
				local keyword_base = get_keyword_base(param)
				if keyword_base and keywords[keyword_base] then
					-- It's a keyword, check if visible in tree (invisible "all" or "tree" = hidden in tree)
					local keyword_info = keywords[keyword_base]
					local inv = keyword_info.invisible
					if not (inv == "all" or inv == true or inv == "tree") then
						has_visible_children = true
						break
					end
				elseif param:sub(1, 1) ~= ":" then
					-- It's a term (not a keyword), so there are visible children
					has_visible_children = true
					break
				end
			end
		end
		node.has_visible_children = has_visible_children
		return node
	end

	-- Parse args into keyword containers
	local current_keyword = "from"
	local current_keyword_modifiers = {}
	local current_container = nil

	-- Helper to track keyword usage at top level
	local function track_keyword_usage(keyword, target_lang, source_lang)
		if not is_toplevel then return end

		if not __state.toplevel_keyword_stats[keyword] then
			__state.toplevel_keyword_stats[keyword] = {
				count = 0,
				target_langs = {},
				source_langs = {},
			}
		end

		local keyword_data = __state.toplevel_keyword_stats[keyword]
		keyword_data.count = keyword_data.count + 1

		local target_code = target_lang:getCode()
		keyword_data.target_langs[target_code] = (keyword_data.target_langs[target_code] or 0) + 1

		if source_lang then
			local source_code = source_lang:getCode()
			keyword_data.source_langs[source_code] = (keyword_data.source_langs[source_code] or 0) + 1
		end
	end

	local function ensure_container()
		if not current_container or current_container.keyword ~= current_keyword then
			current_container = {
				keyword = current_keyword,
				keyword_info = M.data.keywords[current_keyword],
				keyword_modifiers = current_keyword_modifiers,
				terms = {},
			}
			table.insert(node.children, current_container)
			-- Override keyword text/phrase for nominalization with <g:code>
			if current_keyword_modifiers.g and current_keyword == "nominalization" then
				local labels = get_nominalization_label_for_g(current_keyword_modifiers.g)
				if not labels then
					local codes = {}
					for c in pairs(M.data.nominalization_g_codes) do table.insert(codes, c) end
					table.sort(codes)
					error("Invalid <g:" .. tostring(current_keyword_modifiers.g) .. ">. Supported codes for nominalization: " .. table.concat(codes, ", "))
				end
				current_container.keyword_info = {}
				for k, v in pairs(M.data.keywords[current_keyword]) do current_container.keyword_info[k] = v end
				current_container.keyword_info.text = labels.text
				current_container.keyword_info.phrase = labels.phrase
			end
		end
	end

	for i = 2, #args do
		local param = args[i]

		if is_keyword(param) then
			local keyword, modifiers = EtymonParser.parse_keyword_modifiers(param)
			current_keyword = keyword
			current_keyword_modifiers = modifiers
			current_container = nil -- Force new container for new keyword
		elseif type(param) == "string" and param:sub(1, 1) == ":" then
			error("Invalid keyword '" .. param .. "'. Did you mean a valid keyword like ':bor', ':inh', etc.?")
		elseif type(param) == "string" then
			local etymon_data = EtymonParser.parse_etymon(param, lang)

			if etymon_data then
				-- Track keyword usage at top level
				track_keyword_usage(current_keyword, lang, etymon_data.lang)

				local term_node = {}

				-- Handle suppress_term (-) and unknown_term (empty) directly
				if etymon_data.suppress_term or etymon_data.unknown_term then
					ensure_container()
					if etymon_data.ety then
						local inline_args = EtymonParser.parse_inline_ety(etymon_data.ety, etymon_data.lang)
						inline_args.id = etymon_data.id
						inline_args.status = M.data.STATUS.INLINE
						term_node = TreeBuilder.build(etymon_data.lang, nil, inline_args, seen, depth + 1)
					else
						term_node = {
							lang = etymon_data.lang,
							children = {},
							status = M.data.STATUS.OK,
						}
					end
					term_node.suppress_term = etymon_data.suppress_term
					term_node.unknown_term = etymon_data.unknown_term
					term_node.is_family = etymon_data.is_family
					term_node.is_uncertain = etymon_data.unc
					term_node.ref = etymon_data.ref
					term_node.t = etymon_data.t
					term_node.tr = etymon_data.tr
					term_node.ts = etymon_data.ts
					term_node.alt = etymon_data.alt
					term_node.g = etymon_data.g
					term_node.pos = etymon_data.pos
					term_node.ng = etymon_data.ng
					term_node.lit = etymon_data.lit
					term_node.q = etymon_data.q
					term_node.qq = etymon_data.qq
					term_node.l = etymon_data.l
					term_node.ll = etymon_data.ll
				else
					-- Regular term: fetch arguments from page
					local etymon_args, page_of, resolved_etymon_id, descendants_check =
						DataRetriever.get_etymon_args(etymon_data, is_toplevel)

					-- Check for <ety> inline parameter doesn't override the scraped arguments, unless the latter are missing
					if etymon_data.ety then
						if etymon_args == M.data.STATUS.REDLINK or etymon_args == M.data.STATUS.MISSING then
							__state.current_page_has_inline_etymology = true
							if is_toplevel then
								__state.toplevel_has_inline_etymology = true
							end

							local inline_args = EtymonParser.parse_inline_ety(etymon_data.ety, etymon_data.lang)

							-- Track inline ety keywords too
							local inline_keyword = get_keyword(inline_args[2], true)
							if inline_keyword and #inline_args >= 3 then
								local inline_etymon = EtymonParser.parse_etymon(inline_args[3], etymon_data.lang)
								if inline_etymon then
									track_keyword_usage(inline_keyword, etymon_data.lang, inline_etymon.lang)
								end
							end

							inline_args.id = etymon_data.id
							inline_args.status = M.data.STATUS.INLINE
							etymon_args = inline_args
							term_node.page_of = __state.cached_etymon_pages[key] -- term node is on the same page as the parent
						else
							-- Scraped arguments exist, <ety> is redundant and ignored
							__state.current_page_has_redundant_etymology = true
							if is_toplevel then
								__state.toplevel_redundant_etymology = true
							end
						end
					end

					-- Ensure container exists before checking keyword info
					ensure_container()
					
					-- Check if current keyword has no_child_categories - if so, stop recursion
					local keyword_info = current_container.keyword_info
					local should_stop_recursion = (stop_recursion or (keyword_info and keyword_info.no_child_categories))
					
					term_node = TreeBuilder.build(etymon_data.lang, etymon_data.term, etymon_args, seen, depth + 1, should_stop_recursion)
					term_node.target_key = Util.get_norm_lang(etymon_data.lang):getFullCode() ..
						":" .. M.links.get_link_page(etymon_data.term, etymon_data.lang)
					term_node.id = etymon_data.id
					term_node.etymon_id = resolved_etymon_id  -- The actual etymon id when resolved via senseid
					term_node.t = etymon_data.t
					term_node.tr = etymon_data.tr
					term_node.ts = etymon_data.ts
					term_node.pos = etymon_data.pos
					term_node.alt = etymon_data.alt
					term_node.g = etymon_data.g
					term_node.ng = etymon_data.ng
					term_node.ref = etymon_data.ref
					term_node.is_uncertain = etymon_data.unc
					term_node.override = etymon_data.override
					term_node.page_of = page_of
					term_node.aftype = etymon_data.aftype
					term_node.postype = etymon_data.postype
					term_node.bor = etymon_data.bor
					term_node.lbor = etymon_data.lbor
					term_node.slbor = etymon_data.slbor
					term_node.lit = etymon_data.lit
					term_node.is_family = etymon_data.is_family
					term_node.q = etymon_data.q
					term_node.qq = etymon_data.qq
					term_node.l = etymon_data.l
					term_node.ll = etymon_data.ll
					term_node.missing_descendants_header, term_node.missing_descendants_entry =
						M.descendants.get_term_sync_flags(current_keyword, term_node.status, descendants_check)
				end

				table.insert(current_container.terms, term_node)
			end
		end
	end

	return node
end

-- Convert etymology tree to JSON-serializable table
local function tree_to_json(node)
	local obj = {
		term = node.title,
		lang = node.lang:getCode(),
		lang_name = node.lang:getCanonicalName(),
		id = (node.id and node.id ~= "") and node.id or nil,
		status = node.status,
		is_uncertain = node.is_uncertain or nil,
		is_duplicate = node.is_duplicate or nil,
		gloss = node.t,
		transliteration = node.tr,
		transcription = node.ts,
		alt = node.alt,
		g = node.g,
		pos = node.pos,
		ng = node.ng,
		children = {},
	}

	for _, container in ipairs(node.children or {}) do
		local keyword_info = container.keyword_info
		if keyword_info then
			local container_obj = {
				keyword = container.keyword,
				keyword_label = keyword_info.text,
				keyword_abbrev = keyword_info.abbrev,
				is_group = keyword_info.is_group or nil,
				is_invisible = keyword_info.invisible or nil,
				is_uncertain = (container.keyword_modifiers and container.keyword_modifiers.unc) or nil,
				terms = {},
			}

			for _, term in ipairs(container.terms or {}) do
				table.insert(container_obj.terms, tree_to_json(term))
			end

			table.insert(obj.children, container_obj)
		end
	end

	return obj
end

local function track_ranges(base_key, value, ranges, lang_code)
	M.track("etymon/" .. base_key .. "/" .. value)
	if lang_code then
		M.track("etymon/lang/" .. lang_code .. "/" .. base_key .. "/" .. value)
	end

	for _, range in ipairs(ranges) do
		local matches = false
		if range.min and range.max then
			matches = value >= range.min and value <= range.max
		elseif range.min then
			matches = value >= range.min
		elseif range.max then
			matches = value <= range.max
		elseif range.exact then
			matches = value == range.exact
		end

		if matches then
			M.track("etymon/" .. base_key .. "/" .. range.label)
			if lang_code then
				M.track("etymon/lang/" .. lang_code .. "/" .. base_key .. "/" .. range.label)
			end
			break
		end
	end
end

local function track_title_pagename_mismatch_after_strip(lang)
	local lang_code = lang:getCode()
	M.track("etymon/title/pagename-mismatch-after-strip-diacritics")
	M.track("etymon/lang/" .. lang_code .. "/title/pagename-mismatch-after-strip-diacritics")
end

-- Build and return the etymology data tree for a given term.
function export.get_tree(lang, title, args, options)
	options = options or {}
	__state.entry_title = title
	__state.entry_lang_code = lang:getCode()

	if options.validate then
		EtymonParser.validate(lang, args, options.id, title, options.pos, false)
	end

	local lang_code = lang:getCode()

	local start_index = (args[1] == lang_code) and 2 or 1
	local tree_args = { [1] = lang_code, id = options.id or args.id }
	for i = start_index, #args do
		table.insert(tree_args, args[i])
	end

	__state.cached_etymon_args[lang_code .. ":" .. title .. ":" .. (tree_args.id or "")] = tree_args

	local ety_data_tree = TreeBuilder.build(lang, title, tree_args)
	parse_tree_references(ety_data_tree)

	if options.json then
		return M.JSON.toJSON(tree_to_json(ety_data_tree))
	end

	return ety_data_tree
end

-- Given a language code, page name and optionally the id= parameter,
-- render the tree and only the etymology tree for the relevant page.
-- Fetches and parses the corresponding {{etymon}} from the requested page,
-- and any further pages needed to render the tree.
-- Parameters can be passed either through the #invoke or as
-- template parameters *through* an #invoke.
function export.render_tree_for_etymon_on_page(frame)
	local frame_args = frame.args
	local parent_args = frame:getParent().args
	local langcode = frame_args[1] or parent_args[1]
	local pagename = frame_args[2] or parent_args[2]
	local id = frame_args["id"] or parent_args["id"]
	local display_title = frame_args["title"] or parent_args["title"]

	local parsed_title = mw.title.new(pagename, 0)
	local title
	if parsed_title.namespace == 0 then
		title = M.pages.safe_page_name(parsed_title)
	elseif parsed_title.namespace == 118 then
		title = "*" .. M.pages.safe_page_name(parsed_title)
	else
		error("Unsupported namespace for render_tree_for_etymon_on_page: " .. parsed_title.namespace)
	end

	local lang = Util.get_lang(langcode)
	-- Construct etymon_data for DataRetriever.get_args.
	local etymon_data = {
		lang = lang,
		term = title,
		id = id
	}
	local args, pagename = DataRetriever.get_etymon_args(etymon_data, true)
	if args == M.data.STATUS.MISSING then
		error("The etymon template was not found (language " ..
			langcode ..
			", title '" ..
			title ..
			"'" ..
			(id and ", ID '" .. id .. "'" or ", no ID given") .. "). Page contents may have changed in the interim.")
	end

	local tree_title = display_title or title
	if lang:stripDiacritics(M.links.remove_links(tree_title)) ~= lang:stripDiacritics(M.links.remove_links(title)) then
		track_title_pagename_mismatch_after_strip(lang)
	end

	local ety_data_tree = export.get_tree(lang, tree_title, args, {
		validate = true,
		id = id,
	})

	local output = {}
	table.insert(output, M.template_styles("Module:etymon/styles.css"))
	table.insert(output, M.tree.render({
		data_tree = ety_data_tree,
		format_term_func = function(term, is_toplevel)
			return Util.format_term(term, is_toplevel, {
				gloss = "suppress",
				pos = "suppress",
				lit = "suppress",
				tree_ql = "suppress",
			})
		end,
	}))

	return table.concat(output)
end

function export.main(frame)
	local parent_args = frame:getParent().args
	local args = M.parameters.process(parent_args, M.parameters_data.etymon)
	local lang = args[1]
	local etymon_args = args[2]
	local id = args.id
	local title = args.title
	local text = args.text
	local tree = args.tree
	local etydate = args.etydate
	local rfe = args.rfe
	local page_data = Util.get_page_data()

	if not title then
		title = page_data.pagename
		if page_data.namespace == "Reconstruction" then title = "*" .. title end
	end

	local entry_pagename = page_data.pagename
	if page_data.namespace == "Reconstruction" then
		entry_pagename = "*" .. entry_pagename
	end
	if lang:stripDiacritics(M.links.remove_links(title)) ~= lang:stripDiacritics(M.links.remove_links(entry_pagename)) then
		track_title_pagename_mismatch_after_strip(lang)
	end

	local current_L2 = M.pages.get_current_L2()
	if current_L2 then
		local norm_lang = Util.get_norm_lang(lang)
		local norm_name = norm_lang:getCanonicalName()
		if current_L2 ~= norm_name then
			local lang_desc = lang:getCode() .. " (" .. lang:getCanonicalName() .. ")"
			if norm_lang:getCode() ~= lang:getCode() then
				lang_desc = lang_desc .. ", normalized to " .. norm_lang:getCode() .. " (" .. norm_name .. ")"
			end
			error("Language '" .. lang_desc .. "' does not match the L2 header (" .. current_L2 .. ").")
		end
	end

	local ety_data_tree = export.get_tree(lang, title, etymon_args, {
		validate = true,
		pos = args.pos,
		id = id,
		json = args.json,
	})

	if args.json then
		return ety_data_tree
	end

	local output = {}

	local text_allowlist_mode = M.text_allowed.default_mode or "off"
	if text and text_allowlist_mode ~= "off" and not Util.is_text_param_allowed_for_lang(lang) then
		local msg = "Etymology texts (parameter <code>text=</code>) are not allowed for " .. lang:getFullName() ..
			"; see [[Template:etymon#Text allowlist|Template:etymon § Text allowlist]] for the list of languages that may use the <code>text=</code> parameter."
		if text_allowlist_mode == "error" then
			error(msg)
		else
			Util.add_warning(msg, true)
		end
	end

	local lang_exc = Util.get_lang_exception(lang)
	if lang_exc and lang_exc.disallow then
		local disallow = lang_exc.disallow
		local error_text = " for " .. lang:getFullName()
		if disallow.ref then
			error_text = error_text .. "; see " .. disallow.ref
		else
			error_text = error_text .. "."
		end
		if tree and disallow.tree then
			error("Etymology trees are not allowed" .. error_text)
		end
		if text and disallow.text then
			error("Etymology texts are not allowed" .. error_text)
		end
	end

	if etydate then
		local etydate_param_mods = {
			ref = { list = true, type = "references", allow_holes = true },
			refn = { list = true, allow_holes = true },
			nocap = { type = "boolean" },
		}

		local function generate_etydate_obj(etydate_text)
			local etydate_specs = {}
			for spec in etydate_text:gmatch("[^,]+") do
				table.insert(etydate_specs, mw.text.trim(spec))
			end
			return { [1] = etydate_specs }
		end

		local parsed_etydate = M.parse_utilities.parse_inline_modifiers(etydate, { param_mods = etydate_param_mods, generate_obj = generate_etydate_obj })

		local etydate_args = {
			[1] = parsed_etydate[1],
			nocap = parsed_etydate.nocap or false,
		}

		ety_data_tree.etydate = M.etydate.format_etydate(etydate_args, { omit_refs = true })

		if parsed_etydate.ref and #parsed_etydate.ref > 0 then
			ety_data_tree.etydate_refs = parsed_etydate.ref
		end
	end

	if tree then
		table.insert(output, M.template_styles("Module:etymon/styles.css"))
		table.insert(output, M.tree.render({
			data_tree = ety_data_tree,
			format_term_func = function(term, is_toplevel)
				return Util.format_term(term, is_toplevel, {
					gloss = "suppress",
					pos = "suppress",
					lit = "suppress",
					tree_ql = "suppress",
				})
			end,
		}))
	end

	-- Check if there are any visible children in tree (invisible "all" or "tree" = hidden in tree)
	local has_visible_children = false
	for _, child in ipairs(ety_data_tree.children or {}) do
		local child_keyword_info = child.keyword_info
		local inv = child_keyword_info and child_keyword_info.invisible
		if not (inv == "all" or inv == true or inv == "tree") then
			has_visible_children = true
			break
		end
	end

	local tree_disallowed = lang_exc and lang_exc.disallow and lang_exc.disallow.tree
	local ety_tree_json = M.JSON.toJSON(tree_to_json(ety_data_tree))
	local anchor = M.anchors.etymonid(lang, id, {
		no_tree = args.notree,
		title = title,
		empty_tree = (not has_visible_children) or tree_disallowed,
		ety_tree_json = ety_tree_json,
	})
	table.insert(output, anchor)

	if text then
		local max_depth, stop_at_blue_link, stop_at_lang, stop_at_lang_or_bluelink
		if text == "++" then
			max_depth, stop_at_blue_link = false, false
		elseif text == "+" then
			max_depth, stop_at_blue_link = 1, false
		elseif text == "*" then
			max_depth, stop_at_blue_link = false, true
		elseif text:match("^:[^*]+%*$") then
			-- Stop at a specific language OR first bluelink after it, e.g., ":ota*"
			-- If the target language is a redlink, continue to the first bluelink
			local lang_code = text:match("^:([^*]+)%*$")
			if lang_code and lang_code ~= "" then
				local lang_obj = Util.get_lang(lang_code, true)
				if lang_obj then
					stop_at_lang_or_bluelink = lang_code
				else
					Util.add_warning('Invalid language code "' .. lang_code .. '" in text parameter. Showing full chain instead.')
					max_depth, stop_at_blue_link = false, false
				end
			else
				Util.add_warning('Empty language code in text parameter. Showing full chain instead.')
				max_depth, stop_at_blue_link = false, false
			end
		elseif text:sub(1, 1) == ":" then
			-- Stop at a specific language, e.g., ":ar" stops at first Arabic term
			local lang_code = text:sub(2)
			if lang_code ~= "" then
				-- Validate the language code
				local lang_obj = Util.get_lang(lang_code, true)
				if lang_obj then
					stop_at_lang = lang_code
				else
					Util.add_warning('Invalid language code "' .. lang_code .. '" in text parameter. Showing full chain instead.')
					max_depth, stop_at_blue_link = false, false -- default to ++
				end
			else
				Util.add_warning('Empty language code in text parameter. Showing full chain instead.')
				max_depth, stop_at_blue_link = false, false -- default to ++
			end
		else
			local num = tonumber(text)
			if num and num >= 1 then
				max_depth, stop_at_blue_link = num, false
			else
				error('Invalid text value "' ..
					text .. '". Valid values are: "++" (full chain), "+" (first step only), "*" (until first blue link), a number (max steps), ":lang" (stop at language), or ":lang*" (stop at language or first bluelink if redlink)')
			end
		end
		table.insert(output, M.text.render({
			data_tree = ety_data_tree,
			format_term_func = Util.format_term,
			max_depth = max_depth,
			stop_at_blue_link = stop_at_blue_link,
			curr_page = page_data.pagename,
			nodot = args.nodot,
			stop_at_lang = stop_at_lang,
			stop_at_lang_or_bluelink = stop_at_lang_or_bluelink,
		}))
	end

	if rfe then
		local rfe_param_mods = {
			nocat = { type = "boolean" },
			sort = {},
			y = {},
			m = {},
			fragment = {},
			section = {},
			box = { type = "boolean" },
			noes = { type = "boolean" },
		}

		local function generate_rfe_obj(rfe_text)
			-- Check if it's a boolean true value
			if M.yesno(rfe_text, false) then
				return { is_boolean = true }
			else
				return { text = rfe_text }
			end
		end

		local rfe_with_defaults = Util.add_boolean_defaults(rfe, rfe_param_mods)
		local parsed_rfe = M.parse_utilities.parse_inline_modifiers(rfe_with_defaults, { 
			param_mods = rfe_param_mods, 
			generate_obj = generate_rfe_obj 
		})

		local rfe_args = {
			[1] = lang:getCode(),
			nocat = parsed_rfe.nocat,
			sort = parsed_rfe.sort,
			y = parsed_rfe.y,
			m = parsed_rfe.m,
			fragment = parsed_rfe.fragment,
			section = parsed_rfe.section,
			box = parsed_rfe.box,
			noes = parsed_rfe.noes,
		}

		if not parsed_rfe.is_boolean then
			rfe_args[2] = parsed_rfe.text
		end

		table.insert(output, frame:expandTemplate({
			title = "rfe",
			args = rfe_args
		}))
	end

	if Util.is_content_page() and __state.max_depth_reached > 0 then
		local lang_code = lang:getCode()

		local depth_ranges = {
			{ min = 50, label = "extremely-deep" },
			{ min = 20, label = "20+" },
			{ min = 10, max = 19,                label = "10-19" },
			{ min = 5,  max = 9,                 label = "5-9" },
			{ min = 3,  max = 4,                 label = "3-4" },
			{ max = 2,  label = "1-2" }
		}

		local node_ranges = {
			{ min = 100, label = "extremely-large" },
			{ min = 50,  label = "50+" },
			{ min = 20,  max = 49,                 label = "20-49" },
			{ min = 10,  max = 19,                 label = "10-19" },
			{ min = 5,   max = 9,                  label = "5-9" },
			{ max = 4,   label = "1-4" }
		}

		local language_ranges = {
			{ min = 10,  label = "10+" },
			{ min = 5,   max = 9,      label = "5-9" },
			{ min = 3,   max = 4,      label = "3-4" },
			{ exact = 2, label = "2" },
			{ exact = 1, label = "1" }
		}

		track_ranges("depth", __state.max_depth_reached, depth_ranges, lang_code)
		track_ranges("nodes", __state.total_nodes, node_ranges, lang_code)

		local unique_languages = 0
		for _ in pairs(__state.language_count) do
			unique_languages = unique_languages + 1
		end
		track_ranges("unique-languages", unique_languages, language_ranges, lang_code)

		if __state.total_nodes == __state.max_depth_reached + 1 then
			track_ranges("linear-depth", __state.max_depth_reached, depth_ranges, lang_code)
		end
	end

	local categories = {}
	if Util.is_content_page() then
		local should_suppress_categories = lang_exc and lang_exc.suppress_categories
		if not should_suppress_categories and not args.nocat then
			categories = M.categories.render({
				data_tree = ety_data_tree,
				page_lang = lang,
				available_etymon_ids = __state.available_etymon_ids,
				senseid_parent_etymon = __state.senseid_parent_etymon,
				get_norm_lang_func = Util.get_norm_lang,
				lang_exc = lang_exc,
			})
		end

		local target_lang_code = lang:getCode()
		for keyword, keyword_data in pairs(__state.toplevel_keyword_stats) do
			-- Track keyword globally
			M.track("etymon/keyword/" .. keyword)

			-- Track keyword per target language
			M.track("etymon/keyword/" .. keyword .. "/target/" .. target_lang_code)

			-- Track keyword per source language
			for source_code, count in pairs(keyword_data.source_langs) do
				M.track("etymon/keyword/" .. keyword .. "/source/" .. source_code)

				-- Track keyword per target+source combination
				M.track("etymon/keyword/" .. keyword .. "/target/" .. target_lang_code .. "/source/" .. source_code)
			end
		end

		if tree then
			table.insert(categories, "Pages with etymology trees")
			table.insert(categories, lang:getCanonicalName() .. " ප්‍රවේශ, නිරුක්ති ශාඛා සහිත")
		end
		if text then table.insert(categories, lang:getCanonicalName() .. " entries with etymology texts") end
		if args.exnihilo then table.insert(categories, lang:getCanonicalName() .. " terms coined ex nihilo") end
		if __state.toplevel_has_inline_etymology then
			table.insert(categories, "Pages with inline etymon for redlinks")
		end
		if __state.toplevel_redundant_etymology then
			table.insert(categories, "Pages with redundant inline etymon")
		end
		if __state.toplevel_idless_etymon then
			table.insert(categories, "Pages using etymon with no ID")
		end
		if __state.has_mismatched_id then
			table.insert(categories, lang:getCanonicalName() .. " entries referencing etymons with mismatched IDs")
		end
		if __state.linked_page_multiple_etymons_idless then
			table.insert(categories,
				lang:getCanonicalName() .. " entries referencing pages with multiple etymons missing IDs")
		end
	end

	if #categories > 0 then
		table.insert(output, M.categories.format(categories, lang))
	end

	if __state.warnings then
		for i, warning in ipairs(__state.warnings) do
			table.insert(output, (i == 1 and "\n" or "") .. warning .. "\n")
		end
	end

	return table.concat(output)
end

return export
"https://si.wiktionary.org/w/index.php?title=Module:etymon&oldid=235418" වෙතින් සම්ප්‍රවේශනය කෙරිණි