Jump to content

Module:etymon

From Wiktionary, the free dictionary

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
- [[Module:etymon/tracking]]: tracking

]=]

local export = {}

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,
	linked_page_partial_etymology_sections = false,
	partial_etymology_targets = {},
	skip_partial_etymology_category = false,
	max_depth_reached = 0,
	total_nodes = 0,
	language_count = {},
	toplevel_keyword_stats = {},
	id_stats = nil,
	warnings = {},
}

local function reset_invocation_state()
	__state.current_page_has_inline_etymology = false
	__state.current_page_has_redundant_etymology = false
	__state.used_idless_etymon = false
	__state.toplevel_has_inline_etymology = false
	__state.toplevel_redundant_etymology = false
	__state.toplevel_idless_etymon = false
	__state.has_mismatched_id = false
	__state.linked_page_multiple_etymons_idless = false
	__state.linked_page_partial_etymology_sections = false
	__state.max_depth_reached = 0
	__state.total_nodes = 0
	__state.language_count = {}
	__state.toplevel_keyword_stats = {}
	__state.warnings = {}
end

local M = require("Module:module loader").init({
	require = {
		data = "Module:etymon/data",
		tree = "Module:etymon/tree",
		text = "Module:etymon/text",
		categories = "Module:etymon/categories",
		tracking = "Module:etymon/tracking",
		descendants = "Module:etymon/descendants",

		affix = "Module:affix",
		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",
		roots = "Module:roots",
		string_utilities = "Module:string utilities",
		template_parser = "Module:template parser",
		utilities = "Module:utilities",
		debug = "Module:debug",
		en_utilities = "Module:en-utilities",
		parameter_utilities = "Module:parameter utilities",
		parse_utilities = "Module:parse utilities",
		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

-- Match a term language against a text=:lang stop target (supports etymology-only codes).
function Util.lang_matches_stop_code(term_lang, stop_code)
	if not term_lang or not stop_code or stop_code == "" then
		return false
	end

	local stop_lang = Util.get_lang(stop_code, true)
	if not stop_lang then
		return false
	end

	if term_lang:getCode() == stop_lang:getCode() then
		return true
	end

	if stop_lang:getFullCode() == stop_lang:getCode() then
		return term_lang:getFullCode() == stop_lang:getCode()
	end

	return false
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

function Util.resolve_context_lang(lang, node_args)
	if type(node_args) ~= "table" then return lang end
	if node_args.status == M.data.STATUS.INLINE then return lang end

	if not (lang.hasType and lang:hasType("etymology-only")) then return lang end

	local full = lang.getFull and lang:getFull()
	if not full or full:getCode() == lang:getCode() then return lang end

	if full.hasAncestor and full:hasAncestor(lang) then return lang end

	return full
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

local REQUEST_TEMPLATE_PARAM_MODS = {
	rfe = M.parameter_utilities.construct_param_mods {
		{ param = { "sort", "y", "m", "fragment", "section" } },
		{ param = { "nocat", "box", "noes" }, type = "boolean" },
	},
	etystub = M.parameter_utilities.construct_param_mods {
		{ param = "sort" },
		{ param = { "nocat", "nocap", "nodot" }, type = "boolean" },
	},
}

function Util.expand_request_template(frame, template_name, param_value, lang_code)
	local param_mods = REQUEST_TEMPLATE_PARAM_MODS[template_name]
	local with_defaults = Util.add_boolean_defaults(param_value, param_mods)
	local parsed = M.parse_utilities.parse_inline_modifiers(with_defaults, {
		param_mods = param_mods,
		generate_obj = function(text)
			if M.yesno(text, false) then
				return { is_boolean = true }
			end
			return { text = text }
		end,
	})

	local template_args = { [1] = lang_code }
	for name in pairs(param_mods) do
		template_args[name] = parsed[name]
	end
	if not parsed.is_boolean then
		template_args[2] = parsed.text
	end

	return " " .. frame:expandTemplate({
		title = template_name,
		args = template_args,
	})
end

-- Centralized term formatting: handles suppress_term (-), unknown_term (empty/+), 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.link_page or term.title) or nil
	link_params.alt = term.alt or term.link_page and term.title or nil
	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.gloss
	end
	link_params.genders = term.genders
	if opts.pos ~= "suppress" then
		link_params.pos = term.pos
		link_params.ng = term.ng
		link_params.infl = term.infl
	end
	if exc and exc.suppress_tr then
		link_params.lit = nil
	end

	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
		link_params.show_decorations = term.q or term.qq or term.l or term.ll
	end

	return M.links.full_link(link_params, "term")
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

local function is_affix_group_keyword(keyword)
	local config = keyword and M.data.keywords[keyword]
	return config and config.affix_categories or false
end

local function reject_removed_surf_keyword(param)
	local base = get_keyword_base(param)
	if base == "surf" then
		error("The `:surf` keyword has been removed. Use `<surf>` on a formation keyword instead (e.g. `:af<surf>`, `:bor<surf>`).")
	end
end

local function copy_keyword_info(source)
	local copy = {}
	for k, v in pairs(source) do
		copy[k] = v
	end
	return copy
end

local function lowercase_glossary_display(text)
	return text:gsub("(%[%[Appendix:Glossary#[^|]+|)([^%]])([^%]]*)%]%]", function(prefix, first, rest)
		return prefix .. mw.ustring.lower(first) .. rest .. "]]"
	end)
end

local function surf_should_keep_formation_phrase(base)
	if not base.phrase then
		return false
	end
	if base.glossary then
		return true
	end
	return not (base.phrase == "from" and (base.text == "From" or base.text == "from"))
end

-- Runtime overrides when <surf> is present on a keyword.
local function get_effective_keyword_info(keyword, modifiers)
	local base = M.data.keywords[keyword]
	if not base or not modifiers or not modifiers.surf then
		return base
	end
	local effective = copy_keyword_info(base)
	local surf_text = "By [[Appendix:Glossary#surface_analysis|surface analysis]],"
	local surf_phrase = "by surface analysis,"
	effective.new_sentence = true
	effective.invisible = "tree"
	if surf_should_keep_formation_phrase(base) then
		effective.phrase = surf_phrase .. " " .. base.phrase
		if base.text then
			effective.text = surf_text .. " " .. lowercase_glossary_display(base.text)
		else
			effective.text = surf_text .. " " .. base.phrase
		end
	else
		effective.text = surf_text
		effective.phrase = surf_phrase
	end
	return effective
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 = M.parameter_utilities.construct_param_mods {
	{ group = "ref" },
	{ param = "conj" }, -- conjunction for alternatives: "and", "or", "and/or", etc.
	{ param = { "unc", "surf" }, type = "boolean" },
	{ param = "text", restrict = { keywords = { "from", "derived" } } },
	{ param = "lit", restrict = { affix_group = true } },
	{ param = "g", restrict = { keywords = { "nominalization" } } },
	{ param = "senseid", restrict = { keywords = { "semantic loan" } } },
}

-- Term modifier definitions
EtymonParser.etymon_param_mods = M.parameter_utilities.construct_param_mods {
	{group = {"link", "q", "l", "ref", "infl"}, exclude = {"sc"}},
	{param = {"ety", "postype"}},
	{param = "unc", type = "boolean"},
	{param = "aftype", restrict = {affix_group = true}},
	{param = {"bor", "slbor", "lbor"}, type = "boolean", restrict = {affix_group = true}},
}

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 then
				if mod_def.restrict.affix_group then
					if not is_affix_group_keyword(current_keyword) then
						local mod_display = mod_value == true and "<" .. mod_name .. ">" or "<" .. mod_name .. ":" .. tostring(mod_value) .. ">"
						error("The modifier `" .. mod_display .. "` is only allowed for affix-group keywords (e.g. `:af`, `:blend`, `:univ`).")
					end
				elseif 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
end

local TERM_RULE_DISALLOW = {
	suppress = { field = "suppress_term", label = "suppressed" },
	unknown = { field = "unknown_term", label = "unknown" },
	family = { field = "is_family", label = "family" },
}

function EtymonParser.check_etymon_limits(count, limits, label, opts)
	if not limits then
		return
	end
	opts = opts or {}

	local min_etymons = limits.min_etymons
	if min_etymons == nil and not opts.skip_default_min then
		min_etymons = 1
	end

	if min_etymons and count < min_etymons then
		if min_etymons > 1 then
			error("Detected " .. label .. " group with fewer than " .. min_etymons .. " etymons.")
		else
			error("Detected " .. label .. " with no etymons.")
		end
	end

	if limits.max_etymons and count > limits.max_etymons then
		local unit = (limits.max_etymons == 1) and "etymon" or "etymons"
		error("Detected " .. label .. " with more than " .. limits.max_etymons .. " " .. unit .. ".")
	end
end

function EtymonParser.check_term_rules(etymon_data, entry_lang, rules, label)
	label = label or "term"

	if rules and rules.disallow then
		local disallowed = {}
		for _, typ in ipairs(rules.disallow) do
			local spec = TERM_RULE_DISALLOW[typ]
			if spec and etymon_data[spec.field] then
				table.insert(disallowed, spec.label)
			end
		end
		if #disallowed > 0 then
			error(label .. " does not support " ..
				mw.text.listToText(disallowed, "or") .. " etymons.")
		end
	end

	if etymon_data.is_family then
		if rules and rules.family == "disallowed" then
			error(label .. " does not support family codes" .. (rules.family_suffix or "."))
		elseif not etymon_data.suppress_term then
			error("Family codes require suppressed term (use family:-).")
		end
	end

	if rules then
		if rules.require_term and (not etymon_data.term or etymon_data.term == "") then
			error(label .. " requires a term for each listed form.")
		end
		if rules.entry_lang then
			if Util.get_norm_lang(etymon_data.lang):getFullCode() ~=
				Util.get_norm_lang(entry_lang):getFullCode() then
				error(label .. " terms must be in the entry language (" ..
					entry_lang:getFullCode() .. "), got '" .. etymon_data.lang:getFullCode() .. "'.")
			end
		end
		if rules.ancestor_check then
			M.etymology.check_ancestor(entry_lang, etymon_data.lang)
		end
	elseif etymon_data.is_family and not etymon_data.suppress_term then
		error("Family codes require suppressed term (use family:-).")
	end
end

function EtymonParser.check_keyword_term(etymon_data, entry_lang, keyword)
	local config = M.data.keywords[keyword]
	EtymonParser.check_term_rules(etymon_data, entry_lang, config and config.term_rules, "`:" .. keyword .. "`")
end

function EtymonParser.check_supplement_term(etymon_data, entry_lang, supplement_type)
	local config = M.data.supplements[supplement_type]
	EtymonParser.check_term_rules(etymon_data, entry_lang, config and config.term_rules, "|" .. supplement_type .. "=")
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,
		refs = parsed.refs,
		text = parsed.text,
		lit = parsed.lit,
		conj = parsed.conj,
		g = parsed.g,
		surf = parsed.surf or false,
		senseid = parsed.senseid,
	}

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

	return canonical_keyword, modifiers
end

local function normalize_keyword_param(keyword_with_mods)
	local trimmed = M.string_utilities.trim(keyword_with_mods)
	reject_removed_surf_keyword(trimmed:match("^:") and trimmed or (":" .. trimmed))
	local base = get_keyword_base(trimmed)
	if not base or not M.data.keywords[base] then
		error("Invalid keyword '" .. trimmed .. "' in inline etymology")
	end
	local canonical_base = get_canonical_keyword(base)
	local without_colon = trimmed:gsub("^:", "")
	local mods_part = without_colon:sub(#base + 1)
	local kw_param = normalize_keyword(canonical_base .. mods_part)
	EtymonParser.parse_keyword_modifiers(kw_param)
	return kw_param
end

local function get_keyword_mod_names()
	local names = {}
	for mod_name in pairs(EtymonParser.keyword_param_mods) do
		names[mod_name] = true
	end
	return names
end

local function parse_inline_ety_run(ety_string)
	local body = ety_string or ""
	if body == "" then
		error("Empty inline etymology")
	end

	local keyword_mod_names = get_keyword_mod_names()
	local pos = 1
	local len = #body

	local function parse_err(msg)
		error(msg .. " in inline etymology: '" .. body .. "'")
	end

	local function peek_double()
		return body:sub(pos, pos + 1) == "<<"
	end

	local function mod_name_from_unwrapped(unwrapped)
		return unwrapped:match("^<([^:>]+)")
	end

	local function is_keyword_mod(unwrapped)
		local name = mod_name_from_unwrapped(unwrapped)
		return name and keyword_mod_names[name] or false
	end

	local function read_double_bracket()
		if not peek_double() then
			return nil
		end
		local start = pos
		pos = pos + 2
		while pos <= len - 1 do
			if body:sub(pos, pos + 1) == ">>" then
				local token = body:sub(start, pos + 1)
				pos = pos + 2
				return token, token:sub(2, -2)
			end
			pos = pos + 1
		end
		parse_err("Unmatched <<")
	end

	local function read_angle_cell()
		if body:sub(pos, pos) ~= "<" or peek_double() then
			return nil
		end
		local open = pos
		pos = pos + 1
		local depth = 1
		local i = pos
		while i <= len do
			local ch = body:sub(i, i)
			if ch == "<" then
				depth = depth + 1
			elseif ch == ">" then
				depth = depth - 1
				if depth == 0 then
					local inner = body:sub(open + 1, i - 1)
					pos = i + 1
					return inner
				end
			end
			i = i + 1
		end
		parse_err("Unmatched <")
	end

	local function read_bare_run()
		local start = pos
		while pos <= len and body:sub(pos, pos) ~= "<" do
			pos = pos + 1
		end
		return body:sub(start, pos - 1)
	end

	local function absorb_double_keyword_mods(keyword_str)
		while peek_double() do
			local saved = pos
			local _, unwrapped = read_double_bracket()
			if is_keyword_mod(unwrapped) then
				keyword_str = keyword_str .. unwrapped
			else
				pos = saved
				break
			end
		end
		return keyword_str
	end

	local kw_start = pos
	while pos <= len and body:sub(pos, pos) ~= "<" do
		pos = pos + 1
	end
	local keyword = body:sub(kw_start, pos - 1)
	if keyword:match("^%s*$") then
		parse_err("Missing keyword")
	end
	keyword = absorb_double_keyword_mods(keyword)

	local cells = {}

	while pos <= len do
		if peek_double() then
			local _, unwrapped = read_double_bracket()
			if is_keyword_mod(unwrapped) then
				parse_err("Unexpected keyword modifier " .. unwrapped .. " outside of a keyword")
			end
			table.insert(cells, "+" .. unwrapped)
		elseif body:sub(pos, pos) == "<" then
			local inner = read_angle_cell()
			if inner ~= "" then
				table.insert(cells, inner)
			end
		else
			local bare = read_bare_run()
			if bare ~= "" then
				if bare:sub(1, 1) ~= ":" then
					parse_err("Unexpected bare text '" .. bare .. "' (use :keyword for nested keywords in inline etymology)")
				end
				if not is_keyword(bare, true) then
					parse_err("Invalid keyword '" .. bare .. "' in inline etymology")
				end
				table.insert(cells, absorb_double_keyword_mods(bare))
			end
		end
	end

	return {
		keyword = keyword,
		cells = cells,
	}
end

function EtymonParser.inline_ety_to_pipe(ety_string)
	local run = parse_inline_ety_run(ety_string)
	if not run.keyword or run.keyword:match("^%s*$") then
		return "|"
	end

	local pipe_parts = { normalize_keyword_param(M.string_utilities.trim(run.keyword)) }

	for _, segment in ipairs(run.cells) do
		if is_keyword(segment, true) then
			table.insert(pipe_parts, normalize_keyword_param(segment))
		else
			table.insert(pipe_parts, segment)
		end
	end

	return "|" .. table.concat(pipe_parts, "|") .. "|"
end

function EtymonParser.pipe_to_inline_ety(pipe_string)
	local cells = {}
	for cell in pipe_string:gmatch("([^|]+)") do
		if cell ~= "" then
			table.insert(cells, cell)
		end
	end

	if #cells == 0 then
		return ""
	end

	local inline_parts = {}
	for index, cell in ipairs(cells) do
		local base = get_keyword_base(cell)
		if base and M.data.keywords[base] then
			local without_colon = cell:gsub("^:", "")
			local kw_base, mods = without_colon:match("^([^<]+)(.*)$")
			local inline_kw = (kw_base or without_colon) .. (mods or ""):gsub("<([^>]+)>", "<<%1>>")
			if index > 1 then
				inline_kw = ":" .. inline_kw
			end
			table.insert(inline_parts, inline_kw)
		elseif cell:sub(1, 1) == "+" then
			local mod = cell:sub(2)
			if mod:match("^<.->$") then
				mod = mod:sub(2, -2)
			end
			table.insert(inline_parts, "<<" .. mod .. ">>")
		else
			table.insert(inline_parts, "<" .. cell .. ">")
		end
	end

	return table.concat(inline_parts, "")
end

function EtymonParser.parse_inline_ety(ety_string, context_lang)
	local run = parse_inline_ety_run(ety_string)
	local keyword = M.string_utilities.trim(run.keyword)

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

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

	for _, segment in ipairs(run.cells) do
		if is_keyword(segment, true) then
			table.insert(args, normalize_keyword_param(segment))
		else
			table.insert(args, segment)
		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

	M.tracking.track_term(rest)

	if rest == "" or 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 == "" or 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 current_keyword_explicit = false
	local keyword_etymons = {}
	local keywords = M.data.keywords

	local function checkKeyword()
		local config = keywords[current_keyword]
		if current_keyword == "from" and not current_keyword_explicit and #keyword_etymons == 0 then
			keyword_etymons = {}
			return
		end
		EtymonParser.check_etymon_limits(#keyword_etymons, config, "`:" .. current_keyword .. "`")
		keyword_etymons = {}
	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
			reject_removed_surf_keyword(param)
			error("Invalid keyword '" .. param .. "'. Did you mean a valid keyword like ':bor', ':inh', etc.?")
		elseif is_keyword(param) then
			checkKeyword()
			current_keyword = get_canonical_keyword(get_keyword(param))
			current_keyword_explicit = true
		else
			local etymon_data = EtymonParser.parse_etymon(param, lang)
			if etymon_data then
				table.insert(keyword_etymons, param)
				EtymonParser.check_keyword_term(etymon_data, lang, current_keyword)

				-- 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(keyword_etymons, param)
			end
		end
	end

	checkKeyword()
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_alternants = 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 maybe_flag_partial_etymology_reference(base_key, etymon_data, cached_args, is_toplevel)
	if not is_toplevel or __state.skip_partial_etymology_category then
		return
	end
	if not __state.partial_etymology_targets[base_key] then
		return
	end
	if etymon_data.id and type(cached_args) == "table" then
		return
	end
	__state.linked_page_partial_etymology_sections = true
end

local function is_nonlemma_etymon_template(template_args)
	return template_args and M.yesno(template_args.nl, false)
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
	local etymology_section_count = 0
	local etymology_sections_with_etymon = 0
	local current_etymology_has_etymon = false
	local current_etymology_has_nonlemma = false

	local function finalize_current_etymology_section()
		if etymology_section_count == 0 then
			return
		end
		if current_etymology_has_etymon or current_etymology_has_nonlemma then
			etymology_sections_with_etymon = etymology_sections_with_etymon + 1
		end
		current_etymology_has_etymon = false
		current_etymology_has_nonlemma = false
	end

	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
				finalize_current_etymology_section()
				etymology_section_count = etymology_section_count + 1
				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
					if is_nonlemma_etymon_template(template_args) then
						if etymology_section_count > 0 then
							current_etymology_has_nonlemma = true
						end
					else
						if etymology_section_count > 0 then
							current_etymology_has_etymon = true
						end
						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
				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

	finalize_current_etymology_section()

	if lang_section_for_descendants
		and etymology_section_count > 1
		and etymology_sections_with_etymon > 0
		and etymology_sections_with_etymon < etymology_section_count
	then
		__state.partial_etymology_targets[lang_page_key] = true
	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

local function has_linkable_term(etymon_data)
	if etymon_data.is_family or etymon_data.suppress_term or etymon_data.unknown_term then
		return false
	end
	local term = etymon_data.term
	if term == nil or term == "" then
		return false
	end
	return M.string_utilities.trim(term) ~= ""
end

local function map_affix_part(etymon_data)
	if etymon_data.alt or not has_linkable_term(etymon_data) or etymon_data.term:find("^%^") then
		return
	end
	local affix_type = etymon_data.aftype and (M.data.aftype_aliases[etymon_data.aftype] or etymon_data.aftype) or nil
	local _, link_term, display_term = M.affix.parse_term_for_affixes(etymon_data.term, etymon_data.lang, nil,
		affix_type, true, nil, etymon_data.id)
	if link_term ~= display_term then
		etymon_data.term = link_term
		etymon_data.alt = display_term
	end
end

local function get_term_page(etymon_data)
	return etymon_data.link_page or M.links.get_link_page(etymon_data.term, etymon_data.lang)
end

local function is_root_term(etymon_data, keyword)
	if etymon_data.postype then
		return etymon_data.postype == "root"
	end
	local config = keyword and M.data.keywords[keyword]
	return config ~= nil and config.pos_override == "root"
end

local function record_term_id_tracking(etymon_data)
	if not has_linkable_term(etymon_data) then
		return
	end
	local term_page = get_term_page(etymon_data)
	M.tracking.record_term_id_usage(__state.id_stats, etymon_data, term_page)
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)
	if not has_linkable_term(etymon_data) then
		return M.data.STATUS.MISSING, nil, nil, nil
	end
	local page = get_term_page(etymon_data)
	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
			M.tracking.record_mismatched_id_usage(__state.id_stats, norm_lang, page, etymon_data.id)
			warn_mismatched_explicit_id(page, norm_lang, base_key, etymon_data.id)
		end
		maybe_flag_partial_etymology_reference(base_key, etymon_data, cached_args, is_toplevel)
		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
				M.tracking.record_idless_resolution(__state.id_stats, norm_lang, page, "postype")
				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
				local matched_args = __state.cached_etymon_args[matched_key]
				maybe_flag_partial_etymology_reference(base_key, etymon_data, matched_args, is_toplevel)
				return matched_args, __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 "*"
			M.tracking.record_idless_resolution(__state.id_stats, norm_lang, page, "single")
			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
			local single_args = __state.single_etymons[base_key]
			maybe_flag_partial_etymology_reference(base_key, etymon_data, single_args, is_toplevel)
			return single_args, __state.cached_etymon_pages[base_key .. ":" .. only_id], nil, descendants_check
		elseif count > 1 then
			M.tracking.record_idless_resolution(__state.id_stats, norm_lang, page, "ambiguous")
			warn_ambiguous_etymon_link(page, norm_lang, ids, is_toplevel)
			maybe_flag_partial_etymology_reference(base_key, etymon_data, M.data.STATUS.AMBIGUOUS, is_toplevel)
			return M.data.STATUS.AMBIGUOUS, nil, nil, nil
		else
			M.tracking.record_idless_resolution(__state.id_stats, norm_lang, page, "missing")
			maybe_flag_partial_etymology_reference(base_key, etymon_data, M.data.STATUS.MISSING, is_toplevel)
			return M.data.STATUS.MISSING, nil, nil, nil
		end
	end
end

local function keyword_invisible_in_tree(keyword_info)
	if not keyword_info then
		return false
	end
	local inv = keyword_info.invisible
	return inv == "all" or inv == true or inv == "tree"
end

-- True when the node has at least one top-level child container visible in the tree.
local function node_has_visible_tree_children(node)
	for _, container in ipairs(node.children or {}) do
		if not keyword_invisible_in_tree(container.keyword_info) then
			return true
		end
	end
	return false
end

-- Count visible term nodes in the tree.
local function get_visible_tree_depth(node, skip_child_rendering)
	local max_depth = 1
	if skip_child_rendering or not node then
		return max_depth
	end

	for _, container in ipairs(node.children or {}) do
		local keyword_info = container.keyword_info
		if not keyword_invisible_in_tree(keyword_info) then
			local skip_grandchildren = keyword_info and keyword_info.no_child_categories
			for _, term in ipairs(container.terms or {}) do
				if term.is_duplicate then
					if term.original_has_children then
						max_depth = math.max(max_depth, 2)
					end
				else
					max_depth = math.max(max_depth, 1 + get_visible_tree_depth(term, skip_grandchildren))
				end
			end
		end
	end

	return max_depth
end

local function as_param_list(val)
	if val == nil then
		return {}
	end
	if type(val) == "table" then
		return val
	end
	if type(val) == "string" and val ~= "" then
		return { val }
	end
	return {}
end

local TreeBuilder = {}

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

-- Copy parsed etymon modifiers onto a tree/supplement term node.
function TreeBuilder.apply_etymon_fields(term, etymon_data)
	term.id = etymon_data.id
	term.gloss = etymon_data.gloss
	term.tr = etymon_data.tr
	term.ts = etymon_data.ts
	term.alt = etymon_data.alt
	term.genders = etymon_data.genders
	term.pos = etymon_data.pos
	term.ng = etymon_data.ng
	term.infl = etymon_data.infl
	term.refs = etymon_data.refs
	term.is_uncertain = etymon_data.unc
	term.lit = etymon_data.lit
	term.q = etymon_data.q
	term.qq = etymon_data.qq
	term.l = etymon_data.l
	term.ll = etymon_data.ll
	term.suppress_term = etymon_data.suppress_term
	term.unknown_term = etymon_data.unknown_term
	term.is_family = etymon_data.is_family
	term.override = etymon_data.override
	term.aftype = etymon_data.aftype
	term.postype = etymon_data.postype
	term.bor = etymon_data.bor
	term.lbor = etymon_data.lbor
	term.slbor = etymon_data.slbor
	term.link_page = etymon_data.link_page
end

function TreeBuilder.build_supplement_term(etymon_data, entry_lang, supplement_type)
	EtymonParser.check_supplement_term(etymon_data, entry_lang, supplement_type)
	local term = {
		lang = etymon_data.lang,
		title = etymon_data.term,
		children = {},
		status = M.data.STATUS.OK,
	}
	TreeBuilder.apply_etymon_fields(term, etymon_data)
	return term
end

function TreeBuilder.build_supplement_terms(entry_lang, supplement_type, param_value)
	local terms = {}
	for _, term_param in ipairs(as_param_list(param_value)) do
		if type(term_param) == "string" and term_param ~= "" then
			local etymon_data = EtymonParser.parse_etymon(term_param, entry_lang)
			if etymon_data then
				table.insert(terms, TreeBuilder.build_supplement_term(etymon_data, entry_lang, supplement_type))
			end
		end
	end
	return terms
end

-- Attach a |param= supplement defined in etymon_data.supplements (e.g. doublet=).
function TreeBuilder.append_term_supplement(data_tree, entry_lang, supplement_type, param_value)
	local config = M.data.supplements[supplement_type]
	if not config then
		error("Unknown supplement '" .. tostring(supplement_type) .. "'.")
	end
	local terms = TreeBuilder.build_supplement_terms(entry_lang, supplement_type, param_value)
	if #terms == 0 then
		return
	end
	data_tree.supplements = data_tree.supplements or {}
	table.insert(data_tree.supplements, {
		type = supplement_type,
		config = config,
		terms = terms,
	})
	M.tracking.record_keyword_usage(__state.toplevel_keyword_stats, supplement_type, entry_lang, entry_lang, true)
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
					local _, kw_modifiers = EtymonParser.parse_keyword_modifiers(param:sub(1, 1) == ":" and param or (":" .. param))
					if not keyword_invisible_in_tree(get_effective_keyword_info(keyword_base, kw_modifiers)) 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

	local function ensure_container()
		if not current_container or current_container.keyword ~= current_keyword then
			local keyword_info = get_effective_keyword_info(current_keyword, current_keyword_modifiers)
			current_container = {
				keyword = current_keyword,
				keyword_info = keyword_info,
				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 = copy_keyword_info(keyword_info)
				current_container.keyword_info.text = labels.text
				current_container.keyword_info.phrase = labels.phrase
			end
		end
		return current_container
	end

	local parse_context_lang = Util.resolve_context_lang(lang, args)

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

		if is_keyword(param) then
			local keyword, modifiers = EtymonParser.parse_keyword_modifiers(param)
			if not keyword then
				error("Invalid keyword '" .. param .. "'.")
			end
			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
			reject_removed_surf_keyword(param)
			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, parse_context_lang)

			if etymon_data and is_affix_group_keyword(current_keyword) then
				map_affix_part(etymon_data)
			end

			if etymon_data and has_linkable_term(etymon_data) and is_root_term(etymon_data, current_keyword) then
				etymon_data.link_page = M.roots.get_link_page(etymon_data.lang, etymon_data.term)
			end

			if etymon_data then
				-- Track keyword usage at top level
				M.tracking.record_keyword_usage(__state.toplevel_keyword_stats, current_keyword, lang, etymon_data.lang, is_toplevel)

				local term_node = {}
				local container

				-- Handle suppress_term (-) and unknown_term (empty or +) directly
				if etymon_data.suppress_term or etymon_data.unknown_term then
					container = 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
					TreeBuilder.apply_etymon_fields(term_node, etymon_data)
				else
					-- Regular term: fetch arguments from page
					record_term_id_tracking(etymon_data)
					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
									M.tracking.record_keyword_usage(__state.toplevel_keyword_stats, inline_keyword, etymon_data.lang, inline_etymon.lang, is_toplevel)
								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
					container = ensure_container()

					-- Check if current keyword has no_child_categories - if so, stop recursion
					local keyword_info = 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() ..
						":" .. get_term_page(etymon_data)
					term_node.etymon_id = resolved_etymon_id  -- The actual etymon id when resolved via senseid
					term_node.page_of = page_of
					TreeBuilder.apply_etymon_fields(term_node, etymon_data)
					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(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.gloss,
		transliteration = node.tr,
		transcription = node.ts,
		alt = node.alt,
		g = node.genders,
		pos = node.pos,
		ng = node.ng,
		infl = node.infl,
		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

-- 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()
	__state.id_stats = M.tracking.new_id_stats()
	__state.skip_partial_etymology_category = options.skip_partial_etymology_category == true

	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)

	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)
	
	__state.entry_title = title
	__state.entry_lang_code = lang:getCode()
	__state.id_stats = M.tracking.new_id_stats()
	
	-- 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
		M.tracking.track_title_pagename_mismatch(lang)
	end

	reset_invocation_state()
	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 doublet = args.doublet
	local rfe = args.rfe
	local etystub = args.etystub
	local is_nonlemma = M.yesno(args.nl, false)
	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
		M.tracking.track_title_pagename_mismatch(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

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

	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 = M.parameter_utilities.construct_param_mods {
			{ group = "ref" },
			{ param = "refn" },
			{ param = "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.supplements = ety_data_tree.supplements or {}
		table.insert(ety_data_tree.supplements, {
			type = "etydate",
			etydate_text = M.etydate.format_etydate(etydate_args, { omit_refs = true }),
			etydate_refs = (parsed_etydate.refs and #parsed_etydate.refs > 0) and parsed_etydate.refs or nil,
		})
	end

	TreeBuilder.append_term_supplement(ety_data_tree, lang, "doublet", doublet)

	local has_visible_children = node_has_visible_tree_children(ety_data_tree)

	-- Suppress trees for multiword entries and one-step chains
	local visible_tree_depth = get_visible_tree_depth(ety_data_tree)
	local is_trivial_tree = visible_tree_depth <= 2
	local is_multiword = title:find("%s") ~= nil or title:find("_") ~= nil
	if tree and (is_multiword or is_trivial_tree) then
		tree = false
	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

	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)

	local text_stop_lang_missing = nil

	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
		local text_output, text_render_meta = M.text.render({
			data_tree = ety_data_tree,
			format_term_func = Util.format_term,
			lang_matches_stop_code = Util.lang_matches_stop_code,
			max_depth = max_depth,
			stop_at_blue_link = stop_at_blue_link,
			curr_page = page_data.pagename,
			nodot = args.nodot,
			dot = args.dot,
			stop_at_lang = stop_at_lang,
			stop_at_lang_or_bluelink = stop_at_lang_or_bluelink,
		})
		table.insert(output, text_output)
		if stop_at_lang and text_render_meta and not text_render_meta.stop_lang_reached then
			M.tracking.track_text_stop_lang_missing(lang, stop_at_lang)
			text_stop_lang_missing = stop_at_lang
		end
	end

	if rfe then
		table.insert(output, Util.expand_request_template(frame, "rfe", rfe, lang:getCode()))
	end

	if etystub then
		table.insert(output, Util.expand_request_template(frame, "etystub", etystub, lang:getCode()))
	end

	if is_nonlemma then
		table.insert(output, " " .. frame:expandTemplate({
			title = "nonlemma",
			args = {},
		}))
	end

	local categories = {}
	if Util.is_content_page() then
		M.tracking.track_tree_metrics({
			max_depth_reached = __state.max_depth_reached,
			total_nodes = __state.total_nodes,
			language_count = __state.language_count,
			lang = lang,
		})

		categories = M.categories.build({
			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,
			suppress_categories = lang_exc and lang_exc.suppress_categories,
			nocat = args.nocat,
			tree = tree,
			text = text,
			exnihilo = args.exnihilo,
			toplevel_has_inline_etymology = __state.toplevel_has_inline_etymology,
			toplevel_redundant_etymology = __state.toplevel_redundant_etymology,
			toplevel_idless_etymon = __state.toplevel_idless_etymon,
			has_mismatched_id = __state.has_mismatched_id,
			linked_page_multiple_etymons_idless = __state.linked_page_multiple_etymons_idless,
			linked_page_partial_etymology_sections = __state.linked_page_partial_etymology_sections,
			text_stop_lang_missing = text_stop_lang_missing,
		})

		M.tracking.track_keywords(__state.toplevel_keyword_stats, lang)
		M.tracking.track_page_id(lang, id)
		M.tracking.track_ids(__state.id_stats, lang)
	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