Wiktionary
gdwiktionary
https://gd.wiktionary.org/wiki/Pr%C3%AComh-Dhuilleag
MediaWiki 1.47.0-wmf.18
case-sensitive
Meadhan
Sònraichte
Deasbaireachd
Cleachdaiche
Deasbaireachd a' chleachdaiche
Wiktionary
An deasbaireachd aig Wiktionary
Faidhle
Deasbaireachd an fhaidhle
MediaWiki
Deasbaireachd MediaWiki
Teamplaid
Deasbaireachd na teamplaid
Cobhair
Deasbaireachd na cobharach
Roinn-seòrsa
Deasbaireachd na roinn-seòrsa
TimedText
TimedText talk
Mòideal
Deasbaireachd mòideil
Event
Event talk
Teamplaid:enPR
10
3732
89235
74302
2026-04-15T03:52:18Z
en>Chuck Entz
0
Changed protection settings for "[[Template:enPR]]" ([Edit=Allow only template editors and administrators] (indefinite) [Move=Allow only template editors and administrators] (indefinite))
89235
wikitext
text/x-wiki
{{#if:{{{7|}}}{{{a|}}}{{{a1|}}}{{{a2|}}}{{{a3|}}}{{{a4|}}}{{{a5|}}}{{{a6|}}}{{{aa|}}}{{{aa1|}}}{{{aa2|}}}{{{aa3|}}}{{{aa4|}}}{{{aa5|}}}{{{aa6|}}}{{{q|}}}{{{q1|}}}{{{q2|}}}{{{q3|}}}{{{q4|}}}{{{q5|}}}{{{q6|}}}{{{qq|}}}{{{qq1|}}}{{{qq2|}}}{{{qq3|}}}{{{qq4|}}}{{{qq5|}}}{{{qq6|}}}|{{#invoke:IPA/templates|enPR}}|<!--
-->[[Appendix:English pronunciation|enPR]]: {{enPRchar|{{{1}}}|{{{2|}}}|{{{3|}}}|{{{4|}}}|{{{5|}}}|{{{6|}}}}}<!--
-->}}<!--
--><noinclude>{{documentation}}</noinclude>
cxf01v43h8fuhjpkt4itdlg6bocndtf
89236
89235
2026-09-02T19:00:55Z
Altronic
4137
Chaidh 1 mhùthadh ion-phortachadh o [[:en:Template:enPR]]
89235
wikitext
text/x-wiki
{{#if:{{{7|}}}{{{a|}}}{{{a1|}}}{{{a2|}}}{{{a3|}}}{{{a4|}}}{{{a5|}}}{{{a6|}}}{{{aa|}}}{{{aa1|}}}{{{aa2|}}}{{{aa3|}}}{{{aa4|}}}{{{aa5|}}}{{{aa6|}}}{{{q|}}}{{{q1|}}}{{{q2|}}}{{{q3|}}}{{{q4|}}}{{{q5|}}}{{{q6|}}}{{{qq|}}}{{{qq1|}}}{{{qq2|}}}{{{qq3|}}}{{{qq4|}}}{{{qq5|}}}{{{qq6|}}}|{{#invoke:IPA/templates|enPR}}|<!--
-->[[Appendix:English pronunciation|enPR]]: {{enPRchar|{{{1}}}|{{{2|}}}|{{{3|}}}|{{{4|}}}|{{{5|}}}|{{{6|}}}}}<!--
-->}}<!--
--><noinclude>{{documentation}}</noinclude>
cxf01v43h8fuhjpkt4itdlg6bocndtf
Teamplaid:rhymes
10
18089
89226
89198
2026-09-02T18:57:50Z
Altronic
4137
89226
wikitext
text/x-wiki
{{#invoke:rhymes|show}}<noinclude>{{documentation}}</noinclude>
d7exar60c2mrplp36hxdxeipcxxnazm
Mòideal:etymon
828
18096
89213
89212
2026-09-02T18:55:14Z
Altronic
4137
Chaidh 1 mhùthadh ion-phortachadh o [[:en:Module:etymon]]
89212
Scribunto
text/plain
--[=[
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",
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",
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 = {
nocat = { type = "boolean" },
sort = {},
y = {},
m = {},
fragment = {},
section = {},
box = { type = "boolean" },
noes = { type = "boolean" },
},
etystub = {
nocat = { type = "boolean" },
sort = {},
nocap = { type = "boolean" },
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.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
link_params.infl = term.infl
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
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 = {
unc = { type = "boolean" },
ref = {},
text = { restrict = { keywords = { "from", "derived" } } },
lit = { restrict = { affix_group = true } },
conj = {}, -- conjunction for alternatives: "and", "or", "and/or", etc.
g = { restrict = { keywords = { "nominalization" } } },
surf = { type = "boolean" },
senseid = { restrict = { keywords = { "semantic loan" } } },
}
-- Term modifier definitions
EtymonParser.etymon_param_mods = {
id = {},
t = {},
tr = {},
ts = {},
q = {},
qq = {},
l = {},
ll = {},
pos = {},
ng = {},
alt = {},
g = {},
infl = { type = "form of tags" },
ety = {},
lit = {},
unc = { type = "boolean" },
ref = {},
aftype = { restrict = { affix_group = true } },
postype = {},
bor = { type = "boolean", restrict = { affix_group = true } },
slbor = { type = "boolean", restrict = { affix_group = true } },
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,
ref = parsed.ref,
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_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 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 record_term_id_tracking(etymon_data)
if not has_linkable_term(etymon_data) then
return
end
local term_page = M.links.get_link_page(etymon_data.term, etymon_data.lang)
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 = 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
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 = {}
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
if node.supplements then
for _, supplement in ipairs(node.supplements) do
if supplement.terms then
for _, term in ipairs(supplement.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
-- Copy parsed etymon modifiers onto a tree/supplement term node.
function TreeBuilder.apply_etymon_fields(term, etymon_data)
term.id = etymon_data.id
term.t = etymon_data.t
term.tr = etymon_data.tr
term.ts = etymon_data.ts
term.alt = etymon_data.alt
term.g = etymon_data.g
term.pos = etymon_data.pos
term.ng = etymon_data.ng
term.infl = etymon_data.infl
term.ref = etymon_data.ref
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
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 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() ..
":" .. M.links.get_link_page(etymon_data.term, etymon_data.lang)
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.t,
transliteration = node.tr,
transcription = node.ts,
alt = node.alt,
g = node.g,
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)
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)
__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 = {
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.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.ref and #parsed_etydate.ref > 0) and parsed_etydate.ref or nil,
})
end
TreeBuilder.append_term_supplement(ety_data_tree, lang, "doublet", doublet)
if ety_data_tree.supplements then
parse_tree_references(ety_data_tree)
end
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
d0thspkud5zawi6og8iirurr2pt34q1
Teamplaid:audio/styles.css
10
18097
89214
2018-08-20T22:25:18Z
en>Yair rand
0
Protected "[[Template:audio/styles.css]]" ([Edit=Allow only template editors and administrators] (indefinite) [Move=Allow only template editors and administrators] (indefinite))
89214
sanitized-css
text/css
/* Work-around for [[phab:T25965]] (Kaltura advertisement) */
.k-player .k-attribution {
visibility: hidden;
}
oxfk6r6mqtgkw5nh8ifkuf7jz1mg5x0
89215
89214
2026-09-02T18:55:33Z
Altronic
4137
Chaidh 1 mhùthadh ion-phortachadh o [[:en:Template:audio/styles.css]]
89214
sanitized-css
text/css
/* Work-around for [[phab:T25965]] (Kaltura advertisement) */
.k-player .k-attribution {
visibility: hidden;
}
oxfk6r6mqtgkw5nh8ifkuf7jz1mg5x0
Teamplaid:langname
10
18098
89216
2026-04-22T16:54:46Z
en>AutoDooz
0
no existing calls with bad parameters, throw error instead of warning to avoid future misuse
89216
wikitext
text/x-wiki
{{#invoke:checkparams|error}}<!-- Validate template parameters
--><includeonly><onlyinclude>{{safesubst:<noinclude/>#invoke:languages/templates|getByCodeAllowEtym|{{{1}}}|getCanonicalName}}</onlyinclude></includeonly><!--
-->{{documentation}}
16ob92vl8e59y5wxl0fnns3c074buu1
89217
89216
2026-09-02T18:56:01Z
Altronic
4137
Chaidh 1 mhùthadh ion-phortachadh o [[:en:Template:langname]]
89216
wikitext
text/x-wiki
{{#invoke:checkparams|error}}<!-- Validate template parameters
--><includeonly><onlyinclude>{{safesubst:<noinclude/>#invoke:languages/templates|getByCodeAllowEtym|{{{1}}}|getCanonicalName}}</onlyinclude></includeonly><!--
-->{{documentation}}
16ob92vl8e59y5wxl0fnns3c074buu1
Mòideal:etymon/data
828
18099
89218
2026-08-29T21:56:13Z
en>Theknightwho
0
89218
Scribunto
text/plain
local export = {}
export.STATUS = {
OK = "ok",
INLINE = "inline",
MISSING = "missing",
REDLINK = "redlink",
AMBIGUOUS = "ambiguous",
}
export.TRANSITIVE = {
ALWAYS = "always", -- always recurse into children
NEVER = "never", -- never recurse into children
CROSS_LANG = "cross_lang", -- only recurse when source lang differs from target lang (but pos chain continues)
CROSS_LANG_NO_INTERNAL_SOURCE = "cross_lang_no_internal_source", -- like CROSS_LANG, but source breaks for internal derivations in the same language context
}
-- Deep merge tables (nested tables are merged recursively, later values override earlier)
local function deep_merge(...)
local result = {}
for _, t in ipairs({ ... }) do
for k, v in pairs(t) do
if type(v) == "table" and type(result[k]) == "table" then
result[k] = deep_merge(result[k], v)
else
result[k] = v
end
end
end
return result
end
local function make_glossary_link(term, display_text)
if not term then return display_text end
return "[[Appendix:Glossary#" .. term:gsub(" ", "_") .. "|" .. display_text .. "]]"
end
-- Extract base word and connector from text like "Borrowed from" or "calque of"
local function split_glossary_text(text)
for _, pattern in ipairs({ "^(.-)(%s+[Oo][Ff])$", "^(.-)(%s+[Ff][Rr][Oo][Mm])$" }) do
local base, rest = text:match(pattern)
if base then return base, rest end
end
return text, ""
end
local TRANSITIVE = export.TRANSITIVE
local function create_keyword(opts)
local entry = {
is_group = opts.is_group or false,
abbrev = opts.abbrev,
glossary = opts.glossary,
transitive = opts.transitive or TRANSITIVE.ALWAYS, -- default "always"
inherited_chain = opts.inherited_chain or false,
affix_categories = opts.affix_categories or false,
borrowing_type = opts.borrowing_type,
specialized_borrowing = opts.specialized_borrowing,
toplevel_category = opts.toplevel_category,
no_child_categories = opts.no_child_categories or false,
source_category_type = opts.source_category_type,
invisible = (opts.invisible == true and "all") or opts.invisible or false,
pos_override = opts.pos_override,
new_sentence = opts.new_sentence or false,
separate_clause = opts.separate_clause or false,
default_conj = opts.default_conj,
min_etymons = opts.min_etymons,
max_etymons = opts.max_etymons,
term_rules = opts.term_rules,
aliases = opts.aliases,
}
-- Only set text/phrase when visible in text (invisible ~= "all" and ~= "text")
local inv = entry.invisible
if inv ~= "all" and inv ~= "text" then
entry.phrase = opts.phrase
if opts.text then
if opts.glossary then
local base_word, rest = split_glossary_text(opts.text)
entry.text = make_glossary_link(opts.glossary, base_word) .. rest
else
entry.text = opts.text
end
end
end
return entry
end
-- Shared defaults for keyword groups
local DEFAULTS = {
-- Keywords that pass through inheritance chain
inheritance = {
transitive = TRANSITIVE.ALWAYS,
inherited_chain = true,
},
-- Standard transitive derivation
transitive = {
transitive = TRANSITIVE.ALWAYS,
},
-- Standard for internal derivations: transitive across languages, but not within them
internal_derivation = {
transitive = TRANSITIVE.CROSS_LANG,
},
-- Borrowing keywords
borrowing = {
transitive = TRANSITIVE.ALWAYS,
},
-- Affix group keywords (compound words, blends, etc.)
affix_group = {
is_group = true,
min_etymons = 2,
transitive = TRANSITIVE.CROSS_LANG,
affix_categories = true,
},
-- Calque-like keywords (calque, partial calque, semantic loan)
calque_like = {
transitive = TRANSITIVE.NEVER,
no_child_categories = true,
new_sentence = true,
},
-- Non-transitive influence
influence_like = {
transitive = TRANSITIVE.NEVER,
no_child_categories = true,
},
}
export.keywords = {
--
-- Inheritance keywords
--
["from"] = create_keyword(deep_merge(DEFAULTS.inheritance, {
text = "From", phrase = "from",
term_rules = { entry_lang = true },
})),
["inherited"] = create_keyword(deep_merge(DEFAULTS.inheritance, {
text = "Inherited from",
phrase = "from",
glossary = "inherited",
aliases = { "inh" },
term_rules = {
family = "disallowed",
family_suffix = "; use a specific language.",
ancestor_check = true,
},
})),
--
-- Basic derivation keywords
--
["uder"] = create_keyword(deep_merge(DEFAULTS.transitive, {
text = "From",
phrase = "from",
toplevel_category = "undefined derivations",
})),
["derived"] = create_keyword(deep_merge(DEFAULTS.transitive, {
text = "Derived from",
phrase = "from",
abbrev = "der.",
glossary = "derived terms",
aliases = { "der" },
})),
--
-- Affix/compound group keywords
--
["affix"] = create_keyword(deep_merge(DEFAULTS.affix_group, {
text = "From",
phrase = "from",
min_etymons = 1,
aliases = { "af" },
})),
["blend"] = create_keyword(deep_merge(DEFAULTS.affix_group, {
text = "Blend of",
phrase = "a blend of",
abbrev = "blend",
glossary = "blend",
toplevel_category = "blends",
})),
["univerbation"] = create_keyword(deep_merge(DEFAULTS.affix_group, {
text = "Univerbation of",
phrase = "univerbation of",
abbrev = "univ.",
glossary = "univerbation",
toplevel_category = "univerbations",
min_etymons = 1,
aliases = { "univ" },
})),
["vrd-af"] = create_keyword(deep_merge(DEFAULTS.affix_group, {
text = "Vṛddhi derivative of",
phrase = "a vṛddhi derivative of",
abbrev = "vṛd.",
glossary = "vṛddhi derivative",
toplevel_category = "vṛddhi derivatives",
})),
["sa-af"] = create_keyword(deep_merge(DEFAULTS.affix_group, {
text = "[[Sanskritic]] formation from",
phrase = "a [[Sanskritic]] formation of",
toplevel_category = "Sanskritic formations",
})),
--
-- Borrowing keywords
--
["bor"] = create_keyword(deep_merge(DEFAULTS.borrowing, {
text = "Borrowed from",
phrase = "borrowed from",
abbrev = "bor.",
glossary = "loanword",
borrowing_type = "borrowed",
aliases = { "borrowed" },
})),
["lbor"] = create_keyword(deep_merge(DEFAULTS.borrowing, {
text = "Learned borrowing from",
phrase = "a learned borrowing from",
abbrev = "lbor.",
glossary = "learned borrowing",
specialized_borrowing = "learned",
})),
["obor"] = create_keyword(deep_merge(DEFAULTS.borrowing, {
text = "Orthographic borrowing from",
phrase = "an orthographic borrowing from",
abbrev = "obor.",
glossary = "orthographic borrowing",
specialized_borrowing = "orthographic",
})),
["slbor"] = create_keyword(deep_merge(DEFAULTS.borrowing, {
text = "Semi-learned borrowing from",
phrase = "a semi-learned borrowing from",
abbrev = "slbor.",
glossary = "semi-learned borrowing",
specialized_borrowing = "semi-learned",
aliases = { "slb" },
})),
["ubor"] = create_keyword(deep_merge(DEFAULTS.borrowing, {
text = "Unadapted borrowing from",
phrase = "an unadapted borrowing from",
abbrev = "ubor.",
glossary = "unadapted borrowing",
specialized_borrowing = "unadapted",
})),
--
-- Calque-like keywords (non-transitive, start new sentence)
--
["calque"] = create_keyword(deep_merge(DEFAULTS.calque_like, {
text = "Calque of",
phrase = "a calque of",
abbrev = "calq.",
glossary = "calque",
specialized_borrowing = "calque",
aliases = { "cal", "clq" },
})),
["partial calque"] = create_keyword(deep_merge(DEFAULTS.calque_like, {
text = "Partial calque of",
phrase = "a partial calque of",
abbrev = "pcalq.",
glossary = "partial calque",
specialized_borrowing = "partial-calque",
aliases = { "pcal" },
})),
["semantic loan"] = create_keyword(deep_merge(DEFAULTS.calque_like, {
text = "Semantic loan from",
phrase = "a semantic loan from",
abbrev = "sl.",
glossary = "semantic loan",
specialized_borrowing = "semantic-loan",
aliases = { "sl" },
})),
["psm"] = create_keyword(deep_merge(DEFAULTS.calque_like, {
text = "Phono-semantic matching of",
phrase = "a phono-semantic matching of",
abbrev = "psm.",
glossary = "phono-semantic matching",
specialized_borrowing = "phono-semantic-matching",
aliases = { "phono-semantic matching" },
})),
--
-- Influence keywords (non-transitive, separate clause)
--
["influence"] = create_keyword(deep_merge(DEFAULTS.influence_like, {
text = "Influenced by",
phrase = "influenced by",
abbrev = "influ.",
glossary = "contamination",
separate_clause = true,
})),
--
-- Morphological derivation keywords
--
["clipping"] = create_keyword(deep_merge(DEFAULTS.transitive, {
text = "Clipping of",
phrase = "clipping of",
abbrev = "clip.",
glossary = "clipping",
toplevel_category = "clippings",
aliases = { "clip" },
})),
["ellipsis"] = create_keyword(deep_merge(DEFAULTS.transitive, {
text = "Ellipsis of",
phrase = "ellipsis of",
abbrev = "ellip.",
glossary = "ellipsis",
toplevel_category = "ellipses",
aliases = { "ellip" },
})),
["back-formation"] = create_keyword(deep_merge(DEFAULTS.transitive, {
text = "Back-formation from",
phrase = "a back-formation from",
abbrev = "bf.",
glossary = "back-formation",
toplevel_category = "back-formations",
aliases = { "bf" },
})),
["nominalization"] = create_keyword(deep_merge(DEFAULTS.transitive, {
text = "Nominalization of",
phrase = "a nominalization of",
abbrev = "nom.",
glossary = "nominalization",
toplevel_category = "nominalizations",
aliases = { "nom" },
})),
["transliteration"] = create_keyword(deep_merge(DEFAULTS.transitive, {
text = "Transliteration of",
phrase = "borrowed from",
abbrev = "translit.",
glossary = "transliteration",
aliases = { "translit" },
})),
["vrd"] = create_keyword(deep_merge(DEFAULTS.transitive, {
text = "Vṛddhi derivative of",
phrase = "a vṛddhi derivative of",
abbrev = "vṛd.",
glossary = "vṛddhi derivative",
toplevel_category = "vṛddhi derivatives",
})),
["apheretic"] = create_keyword(deep_merge(DEFAULTS.transitive, {
text = "Apheretic form of",
phrase = "an apheretic form of",
abbrev = "aph.",
glossary = "apheresis",
aliases = { "apheresis", "aphetic" },
})),
["denominal"] = create_keyword(deep_merge(DEFAULTS.transitive, {
text = "Denominal verb from",
phrase = "denominal verb from",
abbrev = "denom.",
glossary = "denominal",
toplevel_category = "denominal verbs",
aliases = { "denom" },
})),
["deverbal"] = create_keyword(deep_merge(DEFAULTS.transitive, {
text = "Deverbal from",
phrase = "deverbal from",
abbrev = "deverb.",
glossary = "deverbal",
toplevel_category = "deverbals",
})),
["reduplication"] = create_keyword(deep_merge(DEFAULTS.transitive, {
text = "Reduplication of",
phrase = "reduplication of",
abbrev = "redup.",
glossary = "reduplication",
toplevel_category = "reduplications",
aliases = { "redup" },
})),
["abbreviation"] = create_keyword(deep_merge(DEFAULTS.transitive, {
text = "Abbreviation of",
phrase = "abbreviation of",
abbrev = "abbr.",
glossary = "abbreviation",
aliases = { "abbr", "abbrev" },
})),
["syllabic abbreviation"] = create_keyword(deep_merge(DEFAULTS.transitive, {
text = "Syllabic abbreviation of",
phrase = "syllabic abbreviation of",
abbrev = "syl. abbr.",
glossary = "syllabic abbreviation",
aliases = { "sylabbr", "sylabbrev" },
})),
["acronym"] = create_keyword(deep_merge(DEFAULTS.transitive, {
text = "Acronym of",
phrase = "acronym of",
abbrev = "acronym",
glossary = "acronym",
aliases = { "acro" },
})),
["initialism"] = create_keyword(deep_merge(DEFAULTS.transitive, {
text = "Initialism of",
phrase = "initialism of",
abbrev = "init.",
glossary = "initialism",
aliases = { "init" },
})),
["metathesis"] = create_keyword(deep_merge(DEFAULTS.transitive, {
text = "Metathesis of",
phrase = "metathesis of",
abbrev = "meta.",
glossary = "metathesis",
toplevel_category = "words derived through metathesis",
aliases = { "meta" },
})),
--
-- Invisible keywords (no text output)
--
["root"] = create_keyword {
transitive = TRANSITIVE.ALWAYS,
invisible = "all",
pos_override = "root",
},
["afeq"] = create_keyword(deep_merge(DEFAULTS.affix_group, {
text = "From",
phrase = "from",
transitive = TRANSITIVE.NEVER,
min_etymons = 1,
invisible = "all",
})),
}
-- Template-parameter supplements.
export.supplements = {
doublet = {
text = "Doublet of",
phrase = "doublet of",
glossary = "doublet",
toplevel_category = "doublets",
default_conj = "and",
term_rules = {
entry_lang = true,
require_term = true,
disallow = { "suppress", "unknown", "family" },
},
},
}
local aliases_to_register = {}
local canonical_aliases = {}
-- Map every keyword (canonical or alias) to its canonical form for consistent checks and tracking.
export.keyword_canonical = {}
for name, keyword_data in pairs(export.keywords) do
export.keyword_canonical[name] = name
if keyword_data.aliases then
canonical_aliases[name] = keyword_data.aliases
for _, alias in ipairs(keyword_data.aliases) do
if export.keywords[alias] then
error("Alias '" ..
alias .. "' defined in keyword '" .. name .. "' collides with existing keyword '" .. alias .. "'.")
end
if aliases_to_register[alias] then
error("Alias '" ..
alias .. "' defined in keyword '" .. name .. "' is already claimed by another keyword.")
end
aliases_to_register[alias] = keyword_data
export.keyword_canonical[alias] = name
end
keyword_data.aliases = nil
end
end
for alias, data in pairs(aliases_to_register) do
export.keywords[alias] = data
end
--
-- Language exception presets
--
local EXCEPTION_PRESETS = {
-- Fully disallowed: no tree, no text, no categories
disallowed = {
disallow = { tree = true, text = true },
suppress_categories = true,
},
-- Suppress transliteration only
no_translit = {
suppress_tr = true,
},
-- Suppress all categories only
no_categories = {
suppress_categories = true,
},
}
--[=[
Available exception options:
disallow = { Related options for disallowing output:
tree Disallow etymology trees for this language
text Disallow etymology text generation for this language
ref Reference link shown when tree/text is disallowed
}
suppress_tr Suppress transliteration in links
suppress_categories Suppress all category generation
normalize_to Normalize language code to a different code
normalize_from_families Apply normalization to languages in these families
normalize_exclude_families Exclude these families from normalization
keyword_overrides Per-keyword categorisation overrides (e.g. { ["af"] = { transitive = TRANSITIVE.NEVER } })
]=]
local function create_exception(preset, overrides)
local base = preset and EXCEPTION_PRESETS[preset] or {}
return deep_merge(base, overrides or {})
end
export.config = {
lang_exceptions = {
["zh"] = create_exception("disallowed", {
disallow = { ref = "[[Wiktionary:Beer parlour/2025/May#Template:etymon for Chinese]]" },
suppress_tr = true,
normalize_to = "zh",
normalize_from_families = { "zhx" },
normalize_exclude_families = { "qfa-cnt" },
}),
},
}
-- Supported codes for the nominalization <g:code> modifier (subset of common gender/number-style codes)
export.nominalization_g_codes = {
["m"] = "masculine",
["f"] = "feminine",
["n"] = "neuter",
["c"] = "common",
["gneut"] = "gender-neutral",
["s"] = "singular",
["p"] = "plural",
["d"] = "dual",
["pauc"] = "paucal",
["mf"] = "masculine or feminine",
["fm"] = "masculine or feminine",
["mfn"] = "masculine, feminine or neuter",
["mnf"] = "masculine, feminine or neuter",
["fmn"] = "masculine, feminine or neuter",
["fnm"] = "masculine, feminine or neuter",
["nmf"] = "masculine, feminine or neuter",
["nfm"] = "masculine, feminine or neuter",
}
--
-- Propagate keyword overrides to aliases
--
if export.config.lang_exceptions then
for lang_code, exception in pairs(export.config.lang_exceptions) do
if exception.keyword_overrides then
for canonical, aliases in pairs(canonical_aliases) do
if exception.keyword_overrides[canonical] then
local override_data = exception.keyword_overrides[canonical]
for _, alias in ipairs(aliases) do
if not exception.keyword_overrides[alias] then
exception.keyword_overrides[alias] = override_data
end
end
end
end
end
end
end
return export
6yhx0gizjc63dudpsslfn0z8g4r5wak
89219
89218
2026-09-02T18:56:22Z
Altronic
4137
Chaidh 1 mhùthadh ion-phortachadh o [[:en:Module:etymon/data]]
89218
Scribunto
text/plain
local export = {}
export.STATUS = {
OK = "ok",
INLINE = "inline",
MISSING = "missing",
REDLINK = "redlink",
AMBIGUOUS = "ambiguous",
}
export.TRANSITIVE = {
ALWAYS = "always", -- always recurse into children
NEVER = "never", -- never recurse into children
CROSS_LANG = "cross_lang", -- only recurse when source lang differs from target lang (but pos chain continues)
CROSS_LANG_NO_INTERNAL_SOURCE = "cross_lang_no_internal_source", -- like CROSS_LANG, but source breaks for internal derivations in the same language context
}
-- Deep merge tables (nested tables are merged recursively, later values override earlier)
local function deep_merge(...)
local result = {}
for _, t in ipairs({ ... }) do
for k, v in pairs(t) do
if type(v) == "table" and type(result[k]) == "table" then
result[k] = deep_merge(result[k], v)
else
result[k] = v
end
end
end
return result
end
local function make_glossary_link(term, display_text)
if not term then return display_text end
return "[[Appendix:Glossary#" .. term:gsub(" ", "_") .. "|" .. display_text .. "]]"
end
-- Extract base word and connector from text like "Borrowed from" or "calque of"
local function split_glossary_text(text)
for _, pattern in ipairs({ "^(.-)(%s+[Oo][Ff])$", "^(.-)(%s+[Ff][Rr][Oo][Mm])$" }) do
local base, rest = text:match(pattern)
if base then return base, rest end
end
return text, ""
end
local TRANSITIVE = export.TRANSITIVE
local function create_keyword(opts)
local entry = {
is_group = opts.is_group or false,
abbrev = opts.abbrev,
glossary = opts.glossary,
transitive = opts.transitive or TRANSITIVE.ALWAYS, -- default "always"
inherited_chain = opts.inherited_chain or false,
affix_categories = opts.affix_categories or false,
borrowing_type = opts.borrowing_type,
specialized_borrowing = opts.specialized_borrowing,
toplevel_category = opts.toplevel_category,
no_child_categories = opts.no_child_categories or false,
source_category_type = opts.source_category_type,
invisible = (opts.invisible == true and "all") or opts.invisible or false,
pos_override = opts.pos_override,
new_sentence = opts.new_sentence or false,
separate_clause = opts.separate_clause or false,
default_conj = opts.default_conj,
min_etymons = opts.min_etymons,
max_etymons = opts.max_etymons,
term_rules = opts.term_rules,
aliases = opts.aliases,
}
-- Only set text/phrase when visible in text (invisible ~= "all" and ~= "text")
local inv = entry.invisible
if inv ~= "all" and inv ~= "text" then
entry.phrase = opts.phrase
if opts.text then
if opts.glossary then
local base_word, rest = split_glossary_text(opts.text)
entry.text = make_glossary_link(opts.glossary, base_word) .. rest
else
entry.text = opts.text
end
end
end
return entry
end
-- Shared defaults for keyword groups
local DEFAULTS = {
-- Keywords that pass through inheritance chain
inheritance = {
transitive = TRANSITIVE.ALWAYS,
inherited_chain = true,
},
-- Standard transitive derivation
transitive = {
transitive = TRANSITIVE.ALWAYS,
},
-- Standard for internal derivations: transitive across languages, but not within them
internal_derivation = {
transitive = TRANSITIVE.CROSS_LANG,
},
-- Borrowing keywords
borrowing = {
transitive = TRANSITIVE.ALWAYS,
},
-- Affix group keywords (compound words, blends, etc.)
affix_group = {
is_group = true,
min_etymons = 2,
transitive = TRANSITIVE.CROSS_LANG,
affix_categories = true,
},
-- Calque-like keywords (calque, partial calque, semantic loan)
calque_like = {
transitive = TRANSITIVE.NEVER,
no_child_categories = true,
new_sentence = true,
},
-- Non-transitive influence
influence_like = {
transitive = TRANSITIVE.NEVER,
no_child_categories = true,
},
}
export.keywords = {
--
-- Inheritance keywords
--
["from"] = create_keyword(deep_merge(DEFAULTS.inheritance, {
text = "From", phrase = "from",
term_rules = { entry_lang = true },
})),
["inherited"] = create_keyword(deep_merge(DEFAULTS.inheritance, {
text = "Inherited from",
phrase = "from",
glossary = "inherited",
aliases = { "inh" },
term_rules = {
family = "disallowed",
family_suffix = "; use a specific language.",
ancestor_check = true,
},
})),
--
-- Basic derivation keywords
--
["uder"] = create_keyword(deep_merge(DEFAULTS.transitive, {
text = "From",
phrase = "from",
toplevel_category = "undefined derivations",
})),
["derived"] = create_keyword(deep_merge(DEFAULTS.transitive, {
text = "Derived from",
phrase = "from",
abbrev = "der.",
glossary = "derived terms",
aliases = { "der" },
})),
--
-- Affix/compound group keywords
--
["affix"] = create_keyword(deep_merge(DEFAULTS.affix_group, {
text = "From",
phrase = "from",
min_etymons = 1,
aliases = { "af" },
})),
["blend"] = create_keyword(deep_merge(DEFAULTS.affix_group, {
text = "Blend of",
phrase = "a blend of",
abbrev = "blend",
glossary = "blend",
toplevel_category = "blends",
})),
["univerbation"] = create_keyword(deep_merge(DEFAULTS.affix_group, {
text = "Univerbation of",
phrase = "univerbation of",
abbrev = "univ.",
glossary = "univerbation",
toplevel_category = "univerbations",
min_etymons = 1,
aliases = { "univ" },
})),
["vrd-af"] = create_keyword(deep_merge(DEFAULTS.affix_group, {
text = "Vṛddhi derivative of",
phrase = "a vṛddhi derivative of",
abbrev = "vṛd.",
glossary = "vṛddhi derivative",
toplevel_category = "vṛddhi derivatives",
})),
["sa-af"] = create_keyword(deep_merge(DEFAULTS.affix_group, {
text = "[[Sanskritic]] formation from",
phrase = "a [[Sanskritic]] formation of",
toplevel_category = "Sanskritic formations",
})),
--
-- Borrowing keywords
--
["bor"] = create_keyword(deep_merge(DEFAULTS.borrowing, {
text = "Borrowed from",
phrase = "borrowed from",
abbrev = "bor.",
glossary = "loanword",
borrowing_type = "borrowed",
aliases = { "borrowed" },
})),
["lbor"] = create_keyword(deep_merge(DEFAULTS.borrowing, {
text = "Learned borrowing from",
phrase = "a learned borrowing from",
abbrev = "lbor.",
glossary = "learned borrowing",
specialized_borrowing = "learned",
})),
["obor"] = create_keyword(deep_merge(DEFAULTS.borrowing, {
text = "Orthographic borrowing from",
phrase = "an orthographic borrowing from",
abbrev = "obor.",
glossary = "orthographic borrowing",
specialized_borrowing = "orthographic",
})),
["slbor"] = create_keyword(deep_merge(DEFAULTS.borrowing, {
text = "Semi-learned borrowing from",
phrase = "a semi-learned borrowing from",
abbrev = "slbor.",
glossary = "semi-learned borrowing",
specialized_borrowing = "semi-learned",
aliases = { "slb" },
})),
["ubor"] = create_keyword(deep_merge(DEFAULTS.borrowing, {
text = "Unadapted borrowing from",
phrase = "an unadapted borrowing from",
abbrev = "ubor.",
glossary = "unadapted borrowing",
specialized_borrowing = "unadapted",
})),
--
-- Calque-like keywords (non-transitive, start new sentence)
--
["calque"] = create_keyword(deep_merge(DEFAULTS.calque_like, {
text = "Calque of",
phrase = "a calque of",
abbrev = "calq.",
glossary = "calque",
specialized_borrowing = "calque",
aliases = { "cal", "clq" },
})),
["partial calque"] = create_keyword(deep_merge(DEFAULTS.calque_like, {
text = "Partial calque of",
phrase = "a partial calque of",
abbrev = "pcalq.",
glossary = "partial calque",
specialized_borrowing = "partial-calque",
aliases = { "pcal" },
})),
["semantic loan"] = create_keyword(deep_merge(DEFAULTS.calque_like, {
text = "Semantic loan from",
phrase = "a semantic loan from",
abbrev = "sl.",
glossary = "semantic loan",
specialized_borrowing = "semantic-loan",
aliases = { "sl" },
})),
["psm"] = create_keyword(deep_merge(DEFAULTS.calque_like, {
text = "Phono-semantic matching of",
phrase = "a phono-semantic matching of",
abbrev = "psm.",
glossary = "phono-semantic matching",
specialized_borrowing = "phono-semantic-matching",
aliases = { "phono-semantic matching" },
})),
--
-- Influence keywords (non-transitive, separate clause)
--
["influence"] = create_keyword(deep_merge(DEFAULTS.influence_like, {
text = "Influenced by",
phrase = "influenced by",
abbrev = "influ.",
glossary = "contamination",
separate_clause = true,
})),
--
-- Morphological derivation keywords
--
["clipping"] = create_keyword(deep_merge(DEFAULTS.transitive, {
text = "Clipping of",
phrase = "clipping of",
abbrev = "clip.",
glossary = "clipping",
toplevel_category = "clippings",
aliases = { "clip" },
})),
["ellipsis"] = create_keyword(deep_merge(DEFAULTS.transitive, {
text = "Ellipsis of",
phrase = "ellipsis of",
abbrev = "ellip.",
glossary = "ellipsis",
toplevel_category = "ellipses",
aliases = { "ellip" },
})),
["back-formation"] = create_keyword(deep_merge(DEFAULTS.transitive, {
text = "Back-formation from",
phrase = "a back-formation from",
abbrev = "bf.",
glossary = "back-formation",
toplevel_category = "back-formations",
aliases = { "bf" },
})),
["nominalization"] = create_keyword(deep_merge(DEFAULTS.transitive, {
text = "Nominalization of",
phrase = "a nominalization of",
abbrev = "nom.",
glossary = "nominalization",
toplevel_category = "nominalizations",
aliases = { "nom" },
})),
["transliteration"] = create_keyword(deep_merge(DEFAULTS.transitive, {
text = "Transliteration of",
phrase = "borrowed from",
abbrev = "translit.",
glossary = "transliteration",
aliases = { "translit" },
})),
["vrd"] = create_keyword(deep_merge(DEFAULTS.transitive, {
text = "Vṛddhi derivative of",
phrase = "a vṛddhi derivative of",
abbrev = "vṛd.",
glossary = "vṛddhi derivative",
toplevel_category = "vṛddhi derivatives",
})),
["apheretic"] = create_keyword(deep_merge(DEFAULTS.transitive, {
text = "Apheretic form of",
phrase = "an apheretic form of",
abbrev = "aph.",
glossary = "apheresis",
aliases = { "apheresis", "aphetic" },
})),
["denominal"] = create_keyword(deep_merge(DEFAULTS.transitive, {
text = "Denominal verb from",
phrase = "denominal verb from",
abbrev = "denom.",
glossary = "denominal",
toplevel_category = "denominal verbs",
aliases = { "denom" },
})),
["deverbal"] = create_keyword(deep_merge(DEFAULTS.transitive, {
text = "Deverbal from",
phrase = "deverbal from",
abbrev = "deverb.",
glossary = "deverbal",
toplevel_category = "deverbals",
})),
["reduplication"] = create_keyword(deep_merge(DEFAULTS.transitive, {
text = "Reduplication of",
phrase = "reduplication of",
abbrev = "redup.",
glossary = "reduplication",
toplevel_category = "reduplications",
aliases = { "redup" },
})),
["abbreviation"] = create_keyword(deep_merge(DEFAULTS.transitive, {
text = "Abbreviation of",
phrase = "abbreviation of",
abbrev = "abbr.",
glossary = "abbreviation",
aliases = { "abbr", "abbrev" },
})),
["syllabic abbreviation"] = create_keyword(deep_merge(DEFAULTS.transitive, {
text = "Syllabic abbreviation of",
phrase = "syllabic abbreviation of",
abbrev = "syl. abbr.",
glossary = "syllabic abbreviation",
aliases = { "sylabbr", "sylabbrev" },
})),
["acronym"] = create_keyword(deep_merge(DEFAULTS.transitive, {
text = "Acronym of",
phrase = "acronym of",
abbrev = "acronym",
glossary = "acronym",
aliases = { "acro" },
})),
["initialism"] = create_keyword(deep_merge(DEFAULTS.transitive, {
text = "Initialism of",
phrase = "initialism of",
abbrev = "init.",
glossary = "initialism",
aliases = { "init" },
})),
["metathesis"] = create_keyword(deep_merge(DEFAULTS.transitive, {
text = "Metathesis of",
phrase = "metathesis of",
abbrev = "meta.",
glossary = "metathesis",
toplevel_category = "words derived through metathesis",
aliases = { "meta" },
})),
--
-- Invisible keywords (no text output)
--
["root"] = create_keyword {
transitive = TRANSITIVE.ALWAYS,
invisible = "all",
pos_override = "root",
},
["afeq"] = create_keyword(deep_merge(DEFAULTS.affix_group, {
text = "From",
phrase = "from",
transitive = TRANSITIVE.NEVER,
min_etymons = 1,
invisible = "all",
})),
}
-- Template-parameter supplements.
export.supplements = {
doublet = {
text = "Doublet of",
phrase = "doublet of",
glossary = "doublet",
toplevel_category = "doublets",
default_conj = "and",
term_rules = {
entry_lang = true,
require_term = true,
disallow = { "suppress", "unknown", "family" },
},
},
}
local aliases_to_register = {}
local canonical_aliases = {}
-- Map every keyword (canonical or alias) to its canonical form for consistent checks and tracking.
export.keyword_canonical = {}
for name, keyword_data in pairs(export.keywords) do
export.keyword_canonical[name] = name
if keyword_data.aliases then
canonical_aliases[name] = keyword_data.aliases
for _, alias in ipairs(keyword_data.aliases) do
if export.keywords[alias] then
error("Alias '" ..
alias .. "' defined in keyword '" .. name .. "' collides with existing keyword '" .. alias .. "'.")
end
if aliases_to_register[alias] then
error("Alias '" ..
alias .. "' defined in keyword '" .. name .. "' is already claimed by another keyword.")
end
aliases_to_register[alias] = keyword_data
export.keyword_canonical[alias] = name
end
keyword_data.aliases = nil
end
end
for alias, data in pairs(aliases_to_register) do
export.keywords[alias] = data
end
--
-- Language exception presets
--
local EXCEPTION_PRESETS = {
-- Fully disallowed: no tree, no text, no categories
disallowed = {
disallow = { tree = true, text = true },
suppress_categories = true,
},
-- Suppress transliteration only
no_translit = {
suppress_tr = true,
},
-- Suppress all categories only
no_categories = {
suppress_categories = true,
},
}
--[=[
Available exception options:
disallow = { Related options for disallowing output:
tree Disallow etymology trees for this language
text Disallow etymology text generation for this language
ref Reference link shown when tree/text is disallowed
}
suppress_tr Suppress transliteration in links
suppress_categories Suppress all category generation
normalize_to Normalize language code to a different code
normalize_from_families Apply normalization to languages in these families
normalize_exclude_families Exclude these families from normalization
keyword_overrides Per-keyword categorisation overrides (e.g. { ["af"] = { transitive = TRANSITIVE.NEVER } })
]=]
local function create_exception(preset, overrides)
local base = preset and EXCEPTION_PRESETS[preset] or {}
return deep_merge(base, overrides or {})
end
export.config = {
lang_exceptions = {
["zh"] = create_exception("disallowed", {
disallow = { ref = "[[Wiktionary:Beer parlour/2025/May#Template:etymon for Chinese]]" },
suppress_tr = true,
normalize_to = "zh",
normalize_from_families = { "zhx" },
normalize_exclude_families = { "qfa-cnt" },
}),
},
}
-- Supported codes for the nominalization <g:code> modifier (subset of common gender/number-style codes)
export.nominalization_g_codes = {
["m"] = "masculine",
["f"] = "feminine",
["n"] = "neuter",
["c"] = "common",
["gneut"] = "gender-neutral",
["s"] = "singular",
["p"] = "plural",
["d"] = "dual",
["pauc"] = "paucal",
["mf"] = "masculine or feminine",
["fm"] = "masculine or feminine",
["mfn"] = "masculine, feminine or neuter",
["mnf"] = "masculine, feminine or neuter",
["fmn"] = "masculine, feminine or neuter",
["fnm"] = "masculine, feminine or neuter",
["nmf"] = "masculine, feminine or neuter",
["nfm"] = "masculine, feminine or neuter",
}
--
-- Propagate keyword overrides to aliases
--
if export.config.lang_exceptions then
for lang_code, exception in pairs(export.config.lang_exceptions) do
if exception.keyword_overrides then
for canonical, aliases in pairs(canonical_aliases) do
if exception.keyword_overrides[canonical] then
local override_data = exception.keyword_overrides[canonical]
for _, alias in ipairs(aliases) do
if not exception.keyword_overrides[alias] then
exception.keyword_overrides[alias] = override_data
end
end
end
end
end
end
end
return export
6yhx0gizjc63dudpsslfn0z8g4r5wak
Mòideal:etymon/tracking
828
18100
89220
2026-06-04T21:37:21Z
en>Fenakhay
0
Protected "[[Module:etymon/tracking]]" ([Edit=Allow only template editors and administrators] (indefinite) [Move=Allow only template editors and administrators] (indefinite))
89220
Scribunto
text/plain
--[=[
Documentation: [[WT:Tracking#Etymon]].
]=]
local export = {}
local M = require("Module:module loader").init({
require = {
track = "Module:debug/track",
},
})
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" },
}
local TERM_PAGE_NORMALIZERS = {
{
pattern = "^Reconstruction:[^/]+/(.+)$",
normalize = function(term)
if term:sub(1, 1) ~= "*" then
return "*" .. term
end
return term
end,
},
{
pattern = "^Appendix:[^/]+/(.+)$",
normalize = function(term)
return term
end,
},
}
local function normalize_term_page(term_page)
local page = tostring(term_page)
for _, rule in ipairs(TERM_PAGE_NORMALIZERS) do
local term = page:match(rule.pattern)
if term then
return rule.normalize(term)
end
end
return page
end
local function sanitize_term_page(term_page)
return normalize_term_page(term_page):gsub(":", "-"):gsub("/", "-"):gsub(" ", "_")
end
local function sanitize_track_segment(value)
return tostring(value):gsub(":", "-"):gsub("/", "-"):gsub(" ", "_")
end
local function track_path(page_lang_code, path)
M.track(path)
if page_lang_code then
M.track("etymon/lang/" .. page_lang_code .. "/" .. path:match("^etymon/(.+)$"))
end
end
local function term_path(term_lang_code, term_page, ...)
local safe_term = sanitize_term_page(term_page)
local parts = { "etymon", "term", term_lang_code, safe_term }
for i = 1, select("#", ...) do
local segment = select(i, ...)
if segment then
table.insert(parts, segment)
end
end
return table.concat(parts, "/")
end
local function term_id_path(term_lang_code, term_page, id_value, suffix)
return term_path(term_lang_code, term_page, "id", sanitize_track_segment(id_value), suffix)
end
local function idless_term_path(term_lang_code, term_page, outcome)
return term_path(term_lang_code, term_page, outcome)
end
local function mismatched_term_path(term_lang_code, term_page, id_value)
return term_id_path(term_lang_code, term_page, id_value, "mismatched")
end
local function record_term_id(id_stats, term_lang_code, term_page, id_value, is_override)
if not term_page or term_page == "" or not id_value or id_value == "" then
return
end
id_stats.term_ids[term_lang_code] = id_stats.term_ids[term_lang_code] or {}
id_stats.term_ids[term_lang_code][term_page] = id_stats.term_ids[term_lang_code][term_page] or {}
local entry = id_stats.term_ids[term_lang_code][term_page][id_value]
if not entry then
entry = { count = 0, override = false }
id_stats.term_ids[term_lang_code][term_page][id_value] = entry
end
entry.count = entry.count + 1
if is_override then
entry.override = true
end
end
local function record_idless_term(id_stats, term_lang_code, term_page)
if not term_page or term_page == "" then
return
end
id_stats.idless_terms[term_lang_code] = id_stats.idless_terms[term_lang_code] or {}
local entry = id_stats.idless_terms[term_lang_code][term_page]
if not entry then
entry = { count = 0, outcomes = {} }
id_stats.idless_terms[term_lang_code][term_page] = entry
end
entry.count = entry.count + 1
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
function export.track_term(rest)
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
end
function export.track_title_pagename_mismatch(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
function export.record_keyword_usage(keyword_stats, keyword, target_lang, source_lang, is_toplevel)
if not is_toplevel then
return
end
if not keyword_stats[keyword] then
keyword_stats[keyword] = {
count = 0,
target_langs = {},
source_langs = {},
}
end
local keyword_data = 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
function export.track_tree_metrics(opts)
local max_depth = opts.max_depth_reached
if not max_depth or max_depth <= 0 then
return
end
local lang_code = opts.lang:getCode()
local total_nodes = opts.total_nodes
local language_count = opts.language_count
track_ranges("depth", max_depth, DEPTH_RANGES, lang_code)
track_ranges("nodes", total_nodes, NODE_RANGES, lang_code)
local unique_languages = 0
for _ in pairs(language_count) do
unique_languages = unique_languages + 1
end
track_ranges("unique-languages", unique_languages, LANGUAGE_RANGES, lang_code)
if total_nodes == max_depth + 1 then
track_ranges("linear-depth", max_depth, DEPTH_RANGES, lang_code)
end
end
function export.track_keywords(keyword_stats, target_lang)
local target_lang_code = target_lang:getCode()
for keyword, keyword_data in pairs(keyword_stats) do
M.track("etymon/keyword/" .. keyword)
M.track("etymon/keyword/" .. keyword .. "/target/" .. target_lang_code)
for source_code in pairs(keyword_data.source_langs) do
M.track("etymon/keyword/" .. keyword .. "/source/" .. source_code)
M.track("etymon/keyword/" .. keyword .. "/target/" .. target_lang_code .. "/source/" .. source_code)
end
end
end
function export.record_term_id_usage(id_stats, etymon_data, term_page)
local term_lang_code = etymon_data.lang:getCode()
if etymon_data.id and etymon_data.id ~= "" then
record_term_id(id_stats, term_lang_code, term_page, etymon_data.id, etymon_data.override)
else
record_idless_term(id_stats, term_lang_code, term_page)
end
end
function export.record_mismatched_id_usage(id_stats, term_lang, term_page, id_value)
if not term_page or term_page == "" or not id_value or id_value == "" then
return
end
local term_lang_code = term_lang:getCode()
id_stats.mismatched_ids[term_lang_code] = id_stats.mismatched_ids[term_lang_code] or {}
id_stats.mismatched_ids[term_lang_code][term_page] = id_stats.mismatched_ids[term_lang_code][term_page] or {}
id_stats.mismatched_ids[term_lang_code][term_page][id_value] =
(id_stats.mismatched_ids[term_lang_code][term_page][id_value] or 0) + 1
end
function export.record_idless_resolution(id_stats, term_lang, term_page, outcome)
if not term_page or term_page == "" then
return
end
local term_lang_code = term_lang:getCode()
id_stats.idless_terms[term_lang_code] = id_stats.idless_terms[term_lang_code] or {}
local entry = id_stats.idless_terms[term_lang_code][term_page]
if not entry then
entry = { count = 0, outcomes = {} }
id_stats.idless_terms[term_lang_code][term_page] = entry
end
entry.outcomes[outcome] = (entry.outcomes[outcome] or 0) + 1
end
function export.track_text_stop_lang_missing(page_lang, stop_code)
if not stop_code or stop_code == "" then
return
end
local page_lang_code = page_lang:getCode()
M.track("etymon/text/stop-lang/missing/" .. stop_code)
M.track("etymon/lang/" .. page_lang_code .. "/text/stop-lang/missing/" .. stop_code)
end
function export.track_page_id(page_lang, id)
local lang_code = page_lang:getCode()
if id and id ~= "" then
M.track("etymon/page-id/set")
M.track("etymon/lang/" .. lang_code .. "/page-id/set")
else
M.track("etymon/page-id/unset")
M.track("etymon/lang/" .. lang_code .. "/page-id/unset")
end
end
function export.track_ids(id_stats, page_lang)
local page_lang_code = page_lang:getCode()
for term_lang_code, terms in pairs(id_stats.term_ids or {}) do
for term_page, ids in pairs(terms) do
for id_value, entry in pairs(ids) do
if entry.count > 0 then
track_path(page_lang_code, term_id_path(term_lang_code, term_page, id_value))
if entry.override then
track_path(page_lang_code, term_id_path(term_lang_code, term_page, id_value, "override"))
end
end
end
end
end
for term_lang_code, terms in pairs(id_stats.idless_terms or {}) do
for term_page, entry in pairs(terms) do
if entry.count > 0 then
track_path(page_lang_code, idless_term_path(term_lang_code, term_page))
end
for outcome, count in pairs(entry.outcomes or {}) do
if count > 0 then
track_path(page_lang_code, idless_term_path(term_lang_code, term_page, outcome))
end
end
end
end
for term_lang_code, terms in pairs(id_stats.mismatched_ids or {}) do
for term_page, ids in pairs(terms) do
for id_value, count in pairs(ids) do
if count > 0 then
track_path(page_lang_code, mismatched_term_path(term_lang_code, term_page, id_value))
end
end
end
end
end
function export.new_id_stats()
return {
term_ids = {},
idless_terms = {},
mismatched_ids = {},
}
end
return export
1a6uqo8jj8m8p5gt11ki7rx17qdrf11
89221
89220
2026-09-02T18:56:41Z
Altronic
4137
Chaidh 1 mhùthadh ion-phortachadh o [[:en:Module:etymon/tracking]]
89220
Scribunto
text/plain
--[=[
Documentation: [[WT:Tracking#Etymon]].
]=]
local export = {}
local M = require("Module:module loader").init({
require = {
track = "Module:debug/track",
},
})
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" },
}
local TERM_PAGE_NORMALIZERS = {
{
pattern = "^Reconstruction:[^/]+/(.+)$",
normalize = function(term)
if term:sub(1, 1) ~= "*" then
return "*" .. term
end
return term
end,
},
{
pattern = "^Appendix:[^/]+/(.+)$",
normalize = function(term)
return term
end,
},
}
local function normalize_term_page(term_page)
local page = tostring(term_page)
for _, rule in ipairs(TERM_PAGE_NORMALIZERS) do
local term = page:match(rule.pattern)
if term then
return rule.normalize(term)
end
end
return page
end
local function sanitize_term_page(term_page)
return normalize_term_page(term_page):gsub(":", "-"):gsub("/", "-"):gsub(" ", "_")
end
local function sanitize_track_segment(value)
return tostring(value):gsub(":", "-"):gsub("/", "-"):gsub(" ", "_")
end
local function track_path(page_lang_code, path)
M.track(path)
if page_lang_code then
M.track("etymon/lang/" .. page_lang_code .. "/" .. path:match("^etymon/(.+)$"))
end
end
local function term_path(term_lang_code, term_page, ...)
local safe_term = sanitize_term_page(term_page)
local parts = { "etymon", "term", term_lang_code, safe_term }
for i = 1, select("#", ...) do
local segment = select(i, ...)
if segment then
table.insert(parts, segment)
end
end
return table.concat(parts, "/")
end
local function term_id_path(term_lang_code, term_page, id_value, suffix)
return term_path(term_lang_code, term_page, "id", sanitize_track_segment(id_value), suffix)
end
local function idless_term_path(term_lang_code, term_page, outcome)
return term_path(term_lang_code, term_page, outcome)
end
local function mismatched_term_path(term_lang_code, term_page, id_value)
return term_id_path(term_lang_code, term_page, id_value, "mismatched")
end
local function record_term_id(id_stats, term_lang_code, term_page, id_value, is_override)
if not term_page or term_page == "" or not id_value or id_value == "" then
return
end
id_stats.term_ids[term_lang_code] = id_stats.term_ids[term_lang_code] or {}
id_stats.term_ids[term_lang_code][term_page] = id_stats.term_ids[term_lang_code][term_page] or {}
local entry = id_stats.term_ids[term_lang_code][term_page][id_value]
if not entry then
entry = { count = 0, override = false }
id_stats.term_ids[term_lang_code][term_page][id_value] = entry
end
entry.count = entry.count + 1
if is_override then
entry.override = true
end
end
local function record_idless_term(id_stats, term_lang_code, term_page)
if not term_page or term_page == "" then
return
end
id_stats.idless_terms[term_lang_code] = id_stats.idless_terms[term_lang_code] or {}
local entry = id_stats.idless_terms[term_lang_code][term_page]
if not entry then
entry = { count = 0, outcomes = {} }
id_stats.idless_terms[term_lang_code][term_page] = entry
end
entry.count = entry.count + 1
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
function export.track_term(rest)
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
end
function export.track_title_pagename_mismatch(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
function export.record_keyword_usage(keyword_stats, keyword, target_lang, source_lang, is_toplevel)
if not is_toplevel then
return
end
if not keyword_stats[keyword] then
keyword_stats[keyword] = {
count = 0,
target_langs = {},
source_langs = {},
}
end
local keyword_data = 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
function export.track_tree_metrics(opts)
local max_depth = opts.max_depth_reached
if not max_depth or max_depth <= 0 then
return
end
local lang_code = opts.lang:getCode()
local total_nodes = opts.total_nodes
local language_count = opts.language_count
track_ranges("depth", max_depth, DEPTH_RANGES, lang_code)
track_ranges("nodes", total_nodes, NODE_RANGES, lang_code)
local unique_languages = 0
for _ in pairs(language_count) do
unique_languages = unique_languages + 1
end
track_ranges("unique-languages", unique_languages, LANGUAGE_RANGES, lang_code)
if total_nodes == max_depth + 1 then
track_ranges("linear-depth", max_depth, DEPTH_RANGES, lang_code)
end
end
function export.track_keywords(keyword_stats, target_lang)
local target_lang_code = target_lang:getCode()
for keyword, keyword_data in pairs(keyword_stats) do
M.track("etymon/keyword/" .. keyword)
M.track("etymon/keyword/" .. keyword .. "/target/" .. target_lang_code)
for source_code in pairs(keyword_data.source_langs) do
M.track("etymon/keyword/" .. keyword .. "/source/" .. source_code)
M.track("etymon/keyword/" .. keyword .. "/target/" .. target_lang_code .. "/source/" .. source_code)
end
end
end
function export.record_term_id_usage(id_stats, etymon_data, term_page)
local term_lang_code = etymon_data.lang:getCode()
if etymon_data.id and etymon_data.id ~= "" then
record_term_id(id_stats, term_lang_code, term_page, etymon_data.id, etymon_data.override)
else
record_idless_term(id_stats, term_lang_code, term_page)
end
end
function export.record_mismatched_id_usage(id_stats, term_lang, term_page, id_value)
if not term_page or term_page == "" or not id_value or id_value == "" then
return
end
local term_lang_code = term_lang:getCode()
id_stats.mismatched_ids[term_lang_code] = id_stats.mismatched_ids[term_lang_code] or {}
id_stats.mismatched_ids[term_lang_code][term_page] = id_stats.mismatched_ids[term_lang_code][term_page] or {}
id_stats.mismatched_ids[term_lang_code][term_page][id_value] =
(id_stats.mismatched_ids[term_lang_code][term_page][id_value] or 0) + 1
end
function export.record_idless_resolution(id_stats, term_lang, term_page, outcome)
if not term_page or term_page == "" then
return
end
local term_lang_code = term_lang:getCode()
id_stats.idless_terms[term_lang_code] = id_stats.idless_terms[term_lang_code] or {}
local entry = id_stats.idless_terms[term_lang_code][term_page]
if not entry then
entry = { count = 0, outcomes = {} }
id_stats.idless_terms[term_lang_code][term_page] = entry
end
entry.outcomes[outcome] = (entry.outcomes[outcome] or 0) + 1
end
function export.track_text_stop_lang_missing(page_lang, stop_code)
if not stop_code or stop_code == "" then
return
end
local page_lang_code = page_lang:getCode()
M.track("etymon/text/stop-lang/missing/" .. stop_code)
M.track("etymon/lang/" .. page_lang_code .. "/text/stop-lang/missing/" .. stop_code)
end
function export.track_page_id(page_lang, id)
local lang_code = page_lang:getCode()
if id and id ~= "" then
M.track("etymon/page-id/set")
M.track("etymon/lang/" .. lang_code .. "/page-id/set")
else
M.track("etymon/page-id/unset")
M.track("etymon/lang/" .. lang_code .. "/page-id/unset")
end
end
function export.track_ids(id_stats, page_lang)
local page_lang_code = page_lang:getCode()
for term_lang_code, terms in pairs(id_stats.term_ids or {}) do
for term_page, ids in pairs(terms) do
for id_value, entry in pairs(ids) do
if entry.count > 0 then
track_path(page_lang_code, term_id_path(term_lang_code, term_page, id_value))
if entry.override then
track_path(page_lang_code, term_id_path(term_lang_code, term_page, id_value, "override"))
end
end
end
end
end
for term_lang_code, terms in pairs(id_stats.idless_terms or {}) do
for term_page, entry in pairs(terms) do
if entry.count > 0 then
track_path(page_lang_code, idless_term_path(term_lang_code, term_page))
end
for outcome, count in pairs(entry.outcomes or {}) do
if count > 0 then
track_path(page_lang_code, idless_term_path(term_lang_code, term_page, outcome))
end
end
end
end
for term_lang_code, terms in pairs(id_stats.mismatched_ids or {}) do
for term_page, ids in pairs(terms) do
for id_value, count in pairs(ids) do
if count > 0 then
track_path(page_lang_code, mismatched_term_path(term_lang_code, term_page, id_value))
end
end
end
end
end
function export.new_id_stats()
return {
term_ids = {},
idless_terms = {},
mismatched_ids = {},
}
end
return export
1a6uqo8jj8m8p5gt11ki7rx17qdrf11
Mòideal:dercat
828
18101
89222
2024-11-19T15:32:37Z
en>Theknightwho
0
Code readability.
89222
Scribunto
text/plain
local export = {}
local etymology_module = "Module:etymology"
local parameters_module = "Module:parameters"
local utilities_module = "Module:utilities"
local insert = table.insert
local require = require
--[==[
Loaders for functions in other modules, which overwrite themselves with the target function when called. This ensures modules are only loaded when needed, retains the speed/convenience of locally-declared pre-loaded functions, and has no overhead after the first call, since the target functions are called directly in any subsequent calls.]==]
local function check_ancestor(...)
check_ancestor = require(etymology_module).check_ancestor
return check_ancestor(...)
end
local function format_categories(...)
format_categories = require(utilities_module).format_categories
return format_categories(...)
end
local function process_params(...)
process_params = require(parameters_module).process
return process_params(...)
end
local function language_or_string(val)
return val == "<" and "string" or "language"
end
function export.dercat(frame)
local args = process_params(frame:getParent().args, {
[1] = {required = true, type = "full language"},
[2] = {list = true, required = true, type = language_or_string, family = true},
["inh"] = {type = "number"},
})
local target_lang, langs, categories, cutoff = args[1], args[2], {}
local target_lang_code, target_lang_name = target_lang:getCode(), target_lang:getCanonicalName()
for i = 1, #langs do
local source_lang = langs[i]
if source_lang == "<" then
-- Disallow "inh" and "<" together.
if args.inh then
error(("Cannot specify parameter \"inh\" if \"<\" has been set (see parameter %d): use one or the other."):format(i))
-- Disallow multiple "<"s.
elseif cutoff then
-- Collate keys for all "<"s in the input; all keys need to be offset by 1, since args[1] isn't in the table.
local lt = {cutoff + 1, i + 1}
while langs[i] do
i = i + 1
if langs[i] == "<" then
insert(lt, i + 1)
end
end
error(("Cannot specify \"<\" more than once, but currently used in parameters %s."):format(mw.text.listToText(lt)))
end
cutoff = i
-- Add inheritance categories for all languages encountered so far.
for j = 1, i - 1 do
local inh_source_lang = langs[j]
check_ancestor(target_lang, inh_source_lang)
insert(categories, j * 2, target_lang_name .. " terms inherited from " .. inh_source_lang:getDisplayForm())
end
else
-- Generate the "derived from" category, accounting for the special case when a language derives from itself.
-- Note: "borrowed back into" uses :getCanonicalName(), while the other categories use :getDisplayForm().
insert(categories, target_lang_name .. " terms " .. (target_lang_code == source_lang:getCode() and
"borrowed back into " .. source_lang:getCanonicalName() or
"derived from " .. source_lang:getDisplayForm()
))
if args.inh and i <= args.inh then
check_ancestor(target_lang, source_lang)
insert(categories, target_lang_name .. " terms inherited from " .. source_lang:getDisplayForm())
end
end
end
return format_categories(categories, target_lang)
end
return export
q3dqmzjxe1e513c038q440p97jfu4t3
89223
89222
2026-09-02T18:56:51Z
Altronic
4137
Chaidh 1 mhùthadh ion-phortachadh o [[:en:Module:dercat]]
89222
Scribunto
text/plain
local export = {}
local etymology_module = "Module:etymology"
local parameters_module = "Module:parameters"
local utilities_module = "Module:utilities"
local insert = table.insert
local require = require
--[==[
Loaders for functions in other modules, which overwrite themselves with the target function when called. This ensures modules are only loaded when needed, retains the speed/convenience of locally-declared pre-loaded functions, and has no overhead after the first call, since the target functions are called directly in any subsequent calls.]==]
local function check_ancestor(...)
check_ancestor = require(etymology_module).check_ancestor
return check_ancestor(...)
end
local function format_categories(...)
format_categories = require(utilities_module).format_categories
return format_categories(...)
end
local function process_params(...)
process_params = require(parameters_module).process
return process_params(...)
end
local function language_or_string(val)
return val == "<" and "string" or "language"
end
function export.dercat(frame)
local args = process_params(frame:getParent().args, {
[1] = {required = true, type = "full language"},
[2] = {list = true, required = true, type = language_or_string, family = true},
["inh"] = {type = "number"},
})
local target_lang, langs, categories, cutoff = args[1], args[2], {}
local target_lang_code, target_lang_name = target_lang:getCode(), target_lang:getCanonicalName()
for i = 1, #langs do
local source_lang = langs[i]
if source_lang == "<" then
-- Disallow "inh" and "<" together.
if args.inh then
error(("Cannot specify parameter \"inh\" if \"<\" has been set (see parameter %d): use one or the other."):format(i))
-- Disallow multiple "<"s.
elseif cutoff then
-- Collate keys for all "<"s in the input; all keys need to be offset by 1, since args[1] isn't in the table.
local lt = {cutoff + 1, i + 1}
while langs[i] do
i = i + 1
if langs[i] == "<" then
insert(lt, i + 1)
end
end
error(("Cannot specify \"<\" more than once, but currently used in parameters %s."):format(mw.text.listToText(lt)))
end
cutoff = i
-- Add inheritance categories for all languages encountered so far.
for j = 1, i - 1 do
local inh_source_lang = langs[j]
check_ancestor(target_lang, inh_source_lang)
insert(categories, j * 2, target_lang_name .. " terms inherited from " .. inh_source_lang:getDisplayForm())
end
else
-- Generate the "derived from" category, accounting for the special case when a language derives from itself.
-- Note: "borrowed back into" uses :getCanonicalName(), while the other categories use :getDisplayForm().
insert(categories, target_lang_name .. " terms " .. (target_lang_code == source_lang:getCode() and
"borrowed back into " .. source_lang:getCanonicalName() or
"derived from " .. source_lang:getDisplayForm()
))
if args.inh and i <= args.inh then
check_ancestor(target_lang, source_lang)
insert(categories, target_lang_name .. " terms inherited from " .. source_lang:getDisplayForm())
end
end
end
return format_categories(categories, target_lang)
end
return export
q3dqmzjxe1e513c038q440p97jfu4t3
Mòideal:etymon/descendants
828
18102
89224
2026-06-04T21:39:09Z
en>Fenakhay
0
Changed protection settings for "[[Module:etymon/descendants]]" ([Edit=Allow only template editors and administrators] (indefinite) [Move=Allow only template editors and administrators] (indefinite))
89224
Scribunto
text/plain
local export = {}
local M = require("Module:module loader").init({
require = {
template_parser = "Module:template parser",
links = "Module:links",
languages = "Module:languages",
},
})
local TRACKABLE_KEYWORDS = {
inherited = true,
bor = true,
lbor = true,
slbor = true,
derived = true,
uder = true,
}
local DESCENDANT_TEMPLATES = {
["desc"] = true,
["descendant"] = true,
["desctree"] = true,
["descendants tree"] = true,
}
local SKIPPED_TERMS = {
[""] = true,
["-"] = true,
}
local CHECK_MISSING_HEADER = { missing_header = true, missing_entry = false }
local STRIPPED_REF_TEMPLATES = {
ref = true,
refn = true,
}
local ENABLE_DESCENDANTS_TRACKING = false
local function _resolve_explicit_cache_id(explicit_id, parent_etymon)
if parent_etymon then
return parent_etymon.id or "*"
end
return explicit_id
end
local function _resolve_single_cache_id(id_data)
return (type(id_data) == "table" and id_data.id) or id_data or "*"
end
local function _resolve_cache_id(lookup)
if not lookup then
return nil
end
if lookup.id then
return lookup.id
end
if lookup.explicit_id then
return _resolve_explicit_cache_id(lookup.explicit_id, lookup.parent_etymon)
end
if lookup.id_data ~= nil then
return _resolve_single_cache_id(lookup.id_data)
end
return nil
end
local function _get_cached_check(cache_table, is_toplevel, base_key, cache_id)
if not is_toplevel or not cache_table or not base_key or not cache_id then
return nil
end
return cache_table[base_key .. ":" .. cache_id]
end
local function _store_checks(cache_table, lang_page_key, checks_by_id, redirected_from)
if not cache_table or not lang_page_key or not checks_by_id then
return
end
for id, check in pairs(checks_by_id) do
cache_table[lang_page_key .. ":" .. id] = check
end
if redirected_from then
for id, check in pairs(checks_by_id) do
cache_table[redirected_from .. ":" .. id] = check
end
end
end
local function _strip_refs(value)
if type(value) ~= "string" or value == "" then
return value
end
local get_node_class = M.template_parser.class_else_type
local function trim_local(text)
return (type(text) == "string" and text:match("^%s*(.-)%s*$")) or ""
end
local function lower_local(text)
return text and mw.ustring.lower(text) or text
end
local function template_name_unexpanded(template_node)
return lower_local(trim_local(tostring(template_node[1]) or ""))
end
local function stringify_without_ref_templates(node)
if type(node) ~= "table" then
return node == nil and "" or tostring(node)
end
local node_class = get_node_class(node)
if node_class == "template" and STRIPPED_REF_TEMPLATES[template_name_unexpanded(node)] then
return ""
end
if node_class == "template" then
return tostring(node)
end
if node_class == "heading" then
return tostring(node)
end
if node_class and node_class ~= "wikitext" then
return tostring(node)
end
local pieces = {}
for i = 1, #node do
pieces[i] = stringify_without_ref_templates(node[i])
end
return table.concat(pieces)
end
local parsed = M.template_parser.parse(value)
local cleaned = stringify_without_ref_templates(parsed)
local changed = true
while changed do
local next_cleaned = mw.ustring.gsub(cleaned, "<%s*[Rr][Ee][Ff][^>]-/>", "")
next_cleaned = mw.ustring.gsub(next_cleaned, "<%s*[Rr][Ee][Ff][^>]*>.-<%s*/%s*[Rr][Ee][Ff]%s*>", "")
next_cleaned = mw.ustring.gsub(next_cleaned, "{{%s*[Rr][Ee][Ff]%s*}}", "")
next_cleaned = mw.ustring.gsub(next_cleaned, "{{%s*[Rr][Ee][Ff]%s*|[^{}]-}}", "")
changed = next_cleaned ~= cleaned
cleaned = next_cleaned
end
return cleaned
end
local function _trim(value)
if type(value) ~= "string" then
return nil
end
return value:match("^%s*(.-)%s*$")
end
local function _lower(value)
return value and mw.ustring.lower(value) or value
end
local function _is_index_in_range(index, start_index, end_index)
return index and index >= start_index and index <= end_index
end
local function _build_parse_index(lang_section)
local parsed = M.template_parser.parse(lang_section)
local headings = {}
local templates = {}
for heading in parsed:iterate_nodes("heading") do
local heading_name = heading:get_name()
table.insert(headings, {
index = heading.index or 1,
level = heading.level or 0,
name = heading_name and _lower(_trim(heading_name) or "") or nil,
length = #tostring(heading),
})
end
for template in parsed:iterate_nodes("template") do
local template_name = template:get_name()
local template_args = template:get_arguments()
table.insert(templates, {
index = template.index or 1,
name = template_name and _lower(_trim(template_name) or "") or "",
args = template_args or {},
})
end
return {
headings = headings,
templates = templates,
}
end
local function _build_regions(lang_section, headings)
local content_length = #lang_section
local etymology_headings = {}
for _, heading in ipairs(headings) do
if heading.level == 3 and heading.name and mw.ustring.match(heading.name, "^etymology") then
table.insert(etymology_headings, heading)
end
end
if #etymology_headings == 0 then
return {
{ start_index = 1, end_index = content_length }
}
end
local regions = {}
for i = 1, #etymology_headings do
local heading = etymology_headings[i]
local next_heading = etymology_headings[i + 1]
local start_index = heading.index
local end_index = next_heading and (next_heading.index - 1) or content_length
if i == 1 and heading.index > 1 then
local preamble = lang_section:sub(1, heading.index - 1)
if preamble:match("%S") then
start_index = 1
end
end
table.insert(regions, {
start_index = start_index,
end_index = end_index,
})
end
return regions
end
local function _count_region_etymons(region, templates, etymon_lang_code)
local count = 0
for _, template in ipairs(templates) do
if _is_index_in_range(template.index, region.start_index, region.end_index)
and template.name == "etymon"
and template.args[1] == etymon_lang_code then
count = count + 1
end
end
return count
end
local function _get_descendants_sections(region, headings)
local sections = {}
for i = 1, #headings do
local heading = headings[i]
if _is_index_in_range(heading.index, region.start_index, region.end_index)
and heading.name == "descendants" then
local body_start = heading.index + heading.length
local body_end = region.end_index
for j = i + 1, #headings do
local next_heading = headings[j]
if next_heading.index > region.end_index then
break
end
if next_heading.level <= heading.level then
body_end = next_heading.index - 1
break
end
end
table.insert(sections, {
start_index = body_start,
end_index = body_end,
})
end
end
return sections
end
local function _template_lang_matches_entry(template_lang_code, entry_full_code, cache)
local normalized_code = _trim(template_lang_code)
if not normalized_code or normalized_code == "" then
return false
end
local cached = cache[normalized_code]
if cached ~= nil then
return cached
end
local template_lang = M.languages.getByCode(normalized_code, nil, true)
local is_match = template_lang and template_lang:getFullCode() == entry_full_code or false
cache[normalized_code] = is_match
return is_match
end
local function _template_lists_target(template_args, first_term_index, target_page_lower, entry_lang)
for arg_index = first_term_index, #template_args do
local template_term = _trim(template_args[arg_index])
if template_term and not SKIPPED_TERMS[template_term] then
if _lower(template_term) == target_page_lower then
return true
end
local template_page = M.links.get_link_page(template_term, entry_lang)
if _lower(template_page) == target_page_lower then
return true
end
end
end
return false
end
local function _is_target_listed_in_descendants(descendants_sections, templates, entry_title, entry_lang)
if #descendants_sections == 0 then
return false
end
local target_page = M.links.get_link_page(entry_title, entry_lang)
local target_page_lower = _lower(target_page)
local entry_full_code = entry_lang:getFullCode()
local lang_match_cache = {}
for _, section in ipairs(descendants_sections) do
for _, template in ipairs(templates) do
if _is_index_in_range(template.index, section.start_index, section.end_index)
and DESCENDANT_TEMPLATES[template.name] then
local lang_arg_index = nil
for arg_index = 1, #template.args do
if _template_lang_matches_entry(template.args[arg_index], entry_full_code, lang_match_cache) then
lang_arg_index = arg_index
break
end
end
if lang_arg_index and _template_lists_target(template.args, lang_arg_index + 1, target_page_lower, entry_lang) then
return true
end
end
end
end
return false
end
local function _region_has_descendant_template(region, templates)
for _, template in ipairs(templates) do
if _is_index_in_range(template.index, region.start_index, region.end_index)
and DESCENDANT_TEMPLATES[template.name] then
return true
end
end
return false
end
local function _get_region_check(region, parsed_index, entry_title, entry_lang)
local descendants_sections = _get_descendants_sections(region, parsed_index.headings)
if #descendants_sections == 0 then
if _region_has_descendant_template(region, parsed_index.templates) then
local fallback_sections = { {
start_index = region.start_index,
end_index = region.end_index,
} }
return {
missing_header = false,
missing_entry = not _is_target_listed_in_descendants(fallback_sections, parsed_index.templates, entry_title, entry_lang),
}
end
return CHECK_MISSING_HEADER
end
return {
missing_header = false,
missing_entry = not _is_target_listed_in_descendants(descendants_sections, parsed_index.templates, entry_title, entry_lang),
}
end
local function _build_checks_by_id(lang_section, etymon_lang_code, found_templates_for_lang, entry_title, entry_lang)
local checks_by_id = {}
local parsed_index = _build_parse_index(lang_section)
local regions = _build_regions(lang_section, parsed_index.headings)
local template_list_index = 1
local mapping_failed = false
for _, region in ipairs(regions) do
local region_etymon_count = _count_region_etymons(region, parsed_index.templates, etymon_lang_code)
local region_check = nil
if region_etymon_count > 0 then
region_check = _get_region_check(region, parsed_index, entry_title, entry_lang)
end
for _ = 1, region_etymon_count do
local found_template_args = found_templates_for_lang[template_list_index]
if not found_template_args then
mapping_failed = true
break
end
checks_by_id[found_template_args.id or "*"] = region_check
template_list_index = template_list_index + 1
end
if mapping_failed then
break
end
end
if mapping_failed or template_list_index ~= (#found_templates_for_lang + 1) then
local global_region = { start_index = 1, end_index = #lang_section }
local fallback_check = _get_region_check(global_region, parsed_index, entry_title, entry_lang)
checks_by_id = {}
for _, template_args in ipairs(found_templates_for_lang) do
checks_by_id[template_args.id or "*"] = fallback_check
end
end
return checks_by_id
end
local function _compute_checks_for_page(opts)
opts = opts or {}
local found_templates_for_lang = opts.found_templates_for_lang or {}
if #found_templates_for_lang == 0 then
return {}
end
local entry_title = opts.entry_title
local entry_lang = opts.entry_lang
local etymon_lang_code = opts.etymon_lang_code
if not entry_title or not entry_lang or not etymon_lang_code then
return {}
end
local sanitized_lang_section = _strip_refs(opts.lang_section or "")
return _build_checks_by_id(
sanitized_lang_section,
etymon_lang_code,
found_templates_for_lang,
entry_title,
entry_lang
)
end
function export.cache_page_checks(opts)
if not ENABLE_DESCENDANTS_TRACKING then
return {}
end
opts = opts or {}
local cache_table = opts.cached_descendants_checks
local lang_page_key = opts.lang_page_key
if not cache_table or not lang_page_key then
return {}
end
local checks_by_id = _compute_checks_for_page(opts)
_store_checks(cache_table, lang_page_key, checks_by_id, opts.redirected_from)
return checks_by_id
end
function export.get_lookup_check(opts)
if not ENABLE_DESCENDANTS_TRACKING then
return nil
end
opts = opts or {}
return _get_cached_check(
opts.cached_descendants_checks,
opts.is_toplevel,
opts.base_key,
_resolve_cache_id(opts.lookup)
)
end
function export.get_term_sync_flags(keyword, term_status, descendants_check)
if not ENABLE_DESCENDANTS_TRACKING then
return false, false
end
local should_track = term_status == "ok"
and TRACKABLE_KEYWORDS[keyword] == true
and descendants_check ~= nil
if not should_track then
return false, false
end
return descendants_check.missing_header or false, descendants_check.missing_entry or false
end
return export
njoa75ldlbxvifpj024ykipfx44kfcd
89225
89224
2026-09-02T18:57:06Z
Altronic
4137
Chaidh 1 mhùthadh ion-phortachadh o [[:en:Module:etymon/descendants]]
89224
Scribunto
text/plain
local export = {}
local M = require("Module:module loader").init({
require = {
template_parser = "Module:template parser",
links = "Module:links",
languages = "Module:languages",
},
})
local TRACKABLE_KEYWORDS = {
inherited = true,
bor = true,
lbor = true,
slbor = true,
derived = true,
uder = true,
}
local DESCENDANT_TEMPLATES = {
["desc"] = true,
["descendant"] = true,
["desctree"] = true,
["descendants tree"] = true,
}
local SKIPPED_TERMS = {
[""] = true,
["-"] = true,
}
local CHECK_MISSING_HEADER = { missing_header = true, missing_entry = false }
local STRIPPED_REF_TEMPLATES = {
ref = true,
refn = true,
}
local ENABLE_DESCENDANTS_TRACKING = false
local function _resolve_explicit_cache_id(explicit_id, parent_etymon)
if parent_etymon then
return parent_etymon.id or "*"
end
return explicit_id
end
local function _resolve_single_cache_id(id_data)
return (type(id_data) == "table" and id_data.id) or id_data or "*"
end
local function _resolve_cache_id(lookup)
if not lookup then
return nil
end
if lookup.id then
return lookup.id
end
if lookup.explicit_id then
return _resolve_explicit_cache_id(lookup.explicit_id, lookup.parent_etymon)
end
if lookup.id_data ~= nil then
return _resolve_single_cache_id(lookup.id_data)
end
return nil
end
local function _get_cached_check(cache_table, is_toplevel, base_key, cache_id)
if not is_toplevel or not cache_table or not base_key or not cache_id then
return nil
end
return cache_table[base_key .. ":" .. cache_id]
end
local function _store_checks(cache_table, lang_page_key, checks_by_id, redirected_from)
if not cache_table or not lang_page_key or not checks_by_id then
return
end
for id, check in pairs(checks_by_id) do
cache_table[lang_page_key .. ":" .. id] = check
end
if redirected_from then
for id, check in pairs(checks_by_id) do
cache_table[redirected_from .. ":" .. id] = check
end
end
end
local function _strip_refs(value)
if type(value) ~= "string" or value == "" then
return value
end
local get_node_class = M.template_parser.class_else_type
local function trim_local(text)
return (type(text) == "string" and text:match("^%s*(.-)%s*$")) or ""
end
local function lower_local(text)
return text and mw.ustring.lower(text) or text
end
local function template_name_unexpanded(template_node)
return lower_local(trim_local(tostring(template_node[1]) or ""))
end
local function stringify_without_ref_templates(node)
if type(node) ~= "table" then
return node == nil and "" or tostring(node)
end
local node_class = get_node_class(node)
if node_class == "template" and STRIPPED_REF_TEMPLATES[template_name_unexpanded(node)] then
return ""
end
if node_class == "template" then
return tostring(node)
end
if node_class == "heading" then
return tostring(node)
end
if node_class and node_class ~= "wikitext" then
return tostring(node)
end
local pieces = {}
for i = 1, #node do
pieces[i] = stringify_without_ref_templates(node[i])
end
return table.concat(pieces)
end
local parsed = M.template_parser.parse(value)
local cleaned = stringify_without_ref_templates(parsed)
local changed = true
while changed do
local next_cleaned = mw.ustring.gsub(cleaned, "<%s*[Rr][Ee][Ff][^>]-/>", "")
next_cleaned = mw.ustring.gsub(next_cleaned, "<%s*[Rr][Ee][Ff][^>]*>.-<%s*/%s*[Rr][Ee][Ff]%s*>", "")
next_cleaned = mw.ustring.gsub(next_cleaned, "{{%s*[Rr][Ee][Ff]%s*}}", "")
next_cleaned = mw.ustring.gsub(next_cleaned, "{{%s*[Rr][Ee][Ff]%s*|[^{}]-}}", "")
changed = next_cleaned ~= cleaned
cleaned = next_cleaned
end
return cleaned
end
local function _trim(value)
if type(value) ~= "string" then
return nil
end
return value:match("^%s*(.-)%s*$")
end
local function _lower(value)
return value and mw.ustring.lower(value) or value
end
local function _is_index_in_range(index, start_index, end_index)
return index and index >= start_index and index <= end_index
end
local function _build_parse_index(lang_section)
local parsed = M.template_parser.parse(lang_section)
local headings = {}
local templates = {}
for heading in parsed:iterate_nodes("heading") do
local heading_name = heading:get_name()
table.insert(headings, {
index = heading.index or 1,
level = heading.level or 0,
name = heading_name and _lower(_trim(heading_name) or "") or nil,
length = #tostring(heading),
})
end
for template in parsed:iterate_nodes("template") do
local template_name = template:get_name()
local template_args = template:get_arguments()
table.insert(templates, {
index = template.index or 1,
name = template_name and _lower(_trim(template_name) or "") or "",
args = template_args or {},
})
end
return {
headings = headings,
templates = templates,
}
end
local function _build_regions(lang_section, headings)
local content_length = #lang_section
local etymology_headings = {}
for _, heading in ipairs(headings) do
if heading.level == 3 and heading.name and mw.ustring.match(heading.name, "^etymology") then
table.insert(etymology_headings, heading)
end
end
if #etymology_headings == 0 then
return {
{ start_index = 1, end_index = content_length }
}
end
local regions = {}
for i = 1, #etymology_headings do
local heading = etymology_headings[i]
local next_heading = etymology_headings[i + 1]
local start_index = heading.index
local end_index = next_heading and (next_heading.index - 1) or content_length
if i == 1 and heading.index > 1 then
local preamble = lang_section:sub(1, heading.index - 1)
if preamble:match("%S") then
start_index = 1
end
end
table.insert(regions, {
start_index = start_index,
end_index = end_index,
})
end
return regions
end
local function _count_region_etymons(region, templates, etymon_lang_code)
local count = 0
for _, template in ipairs(templates) do
if _is_index_in_range(template.index, region.start_index, region.end_index)
and template.name == "etymon"
and template.args[1] == etymon_lang_code then
count = count + 1
end
end
return count
end
local function _get_descendants_sections(region, headings)
local sections = {}
for i = 1, #headings do
local heading = headings[i]
if _is_index_in_range(heading.index, region.start_index, region.end_index)
and heading.name == "descendants" then
local body_start = heading.index + heading.length
local body_end = region.end_index
for j = i + 1, #headings do
local next_heading = headings[j]
if next_heading.index > region.end_index then
break
end
if next_heading.level <= heading.level then
body_end = next_heading.index - 1
break
end
end
table.insert(sections, {
start_index = body_start,
end_index = body_end,
})
end
end
return sections
end
local function _template_lang_matches_entry(template_lang_code, entry_full_code, cache)
local normalized_code = _trim(template_lang_code)
if not normalized_code or normalized_code == "" then
return false
end
local cached = cache[normalized_code]
if cached ~= nil then
return cached
end
local template_lang = M.languages.getByCode(normalized_code, nil, true)
local is_match = template_lang and template_lang:getFullCode() == entry_full_code or false
cache[normalized_code] = is_match
return is_match
end
local function _template_lists_target(template_args, first_term_index, target_page_lower, entry_lang)
for arg_index = first_term_index, #template_args do
local template_term = _trim(template_args[arg_index])
if template_term and not SKIPPED_TERMS[template_term] then
if _lower(template_term) == target_page_lower then
return true
end
local template_page = M.links.get_link_page(template_term, entry_lang)
if _lower(template_page) == target_page_lower then
return true
end
end
end
return false
end
local function _is_target_listed_in_descendants(descendants_sections, templates, entry_title, entry_lang)
if #descendants_sections == 0 then
return false
end
local target_page = M.links.get_link_page(entry_title, entry_lang)
local target_page_lower = _lower(target_page)
local entry_full_code = entry_lang:getFullCode()
local lang_match_cache = {}
for _, section in ipairs(descendants_sections) do
for _, template in ipairs(templates) do
if _is_index_in_range(template.index, section.start_index, section.end_index)
and DESCENDANT_TEMPLATES[template.name] then
local lang_arg_index = nil
for arg_index = 1, #template.args do
if _template_lang_matches_entry(template.args[arg_index], entry_full_code, lang_match_cache) then
lang_arg_index = arg_index
break
end
end
if lang_arg_index and _template_lists_target(template.args, lang_arg_index + 1, target_page_lower, entry_lang) then
return true
end
end
end
end
return false
end
local function _region_has_descendant_template(region, templates)
for _, template in ipairs(templates) do
if _is_index_in_range(template.index, region.start_index, region.end_index)
and DESCENDANT_TEMPLATES[template.name] then
return true
end
end
return false
end
local function _get_region_check(region, parsed_index, entry_title, entry_lang)
local descendants_sections = _get_descendants_sections(region, parsed_index.headings)
if #descendants_sections == 0 then
if _region_has_descendant_template(region, parsed_index.templates) then
local fallback_sections = { {
start_index = region.start_index,
end_index = region.end_index,
} }
return {
missing_header = false,
missing_entry = not _is_target_listed_in_descendants(fallback_sections, parsed_index.templates, entry_title, entry_lang),
}
end
return CHECK_MISSING_HEADER
end
return {
missing_header = false,
missing_entry = not _is_target_listed_in_descendants(descendants_sections, parsed_index.templates, entry_title, entry_lang),
}
end
local function _build_checks_by_id(lang_section, etymon_lang_code, found_templates_for_lang, entry_title, entry_lang)
local checks_by_id = {}
local parsed_index = _build_parse_index(lang_section)
local regions = _build_regions(lang_section, parsed_index.headings)
local template_list_index = 1
local mapping_failed = false
for _, region in ipairs(regions) do
local region_etymon_count = _count_region_etymons(region, parsed_index.templates, etymon_lang_code)
local region_check = nil
if region_etymon_count > 0 then
region_check = _get_region_check(region, parsed_index, entry_title, entry_lang)
end
for _ = 1, region_etymon_count do
local found_template_args = found_templates_for_lang[template_list_index]
if not found_template_args then
mapping_failed = true
break
end
checks_by_id[found_template_args.id or "*"] = region_check
template_list_index = template_list_index + 1
end
if mapping_failed then
break
end
end
if mapping_failed or template_list_index ~= (#found_templates_for_lang + 1) then
local global_region = { start_index = 1, end_index = #lang_section }
local fallback_check = _get_region_check(global_region, parsed_index, entry_title, entry_lang)
checks_by_id = {}
for _, template_args in ipairs(found_templates_for_lang) do
checks_by_id[template_args.id or "*"] = fallback_check
end
end
return checks_by_id
end
local function _compute_checks_for_page(opts)
opts = opts or {}
local found_templates_for_lang = opts.found_templates_for_lang or {}
if #found_templates_for_lang == 0 then
return {}
end
local entry_title = opts.entry_title
local entry_lang = opts.entry_lang
local etymon_lang_code = opts.etymon_lang_code
if not entry_title or not entry_lang or not etymon_lang_code then
return {}
end
local sanitized_lang_section = _strip_refs(opts.lang_section or "")
return _build_checks_by_id(
sanitized_lang_section,
etymon_lang_code,
found_templates_for_lang,
entry_title,
entry_lang
)
end
function export.cache_page_checks(opts)
if not ENABLE_DESCENDANTS_TRACKING then
return {}
end
opts = opts or {}
local cache_table = opts.cached_descendants_checks
local lang_page_key = opts.lang_page_key
if not cache_table or not lang_page_key then
return {}
end
local checks_by_id = _compute_checks_for_page(opts)
_store_checks(cache_table, lang_page_key, checks_by_id, opts.redirected_from)
return checks_by_id
end
function export.get_lookup_check(opts)
if not ENABLE_DESCENDANTS_TRACKING then
return nil
end
opts = opts or {}
return _get_cached_check(
opts.cached_descendants_checks,
opts.is_toplevel,
opts.base_key,
_resolve_cache_id(opts.lookup)
)
end
function export.get_term_sync_flags(keyword, term_status, descendants_check)
if not ENABLE_DESCENDANTS_TRACKING then
return false, false
end
local should_track = term_status == "ok"
and TRACKABLE_KEYWORDS[keyword] == true
and descendants_check ~= nil
if not should_track then
return false, false
end
return descendants_check.missing_header or false, descendants_check.missing_entry or false
end
return export
njoa75ldlbxvifpj024ykipfx44kfcd
Mòideal:etymon/data/text allowed
828
18103
89227
2026-08-26T06:44:34Z
en>Hazarasp
0
See [[Wiktionary:Beer_parlour/2026/July#c-Hazarasp-20260826064200-Ioaxxere-20260826055200]]
89227
Scribunto
text/plain
--[=[
Languages and families that may use the {{etymon}} `text=` parameter (language-community consensus).
]=]
return {
-- Mode: "off" = disabled, "warn" = warn only, "error" = enforce.
default_mode = "warn",
langs = {
["ak"] = true,
["amf"] = true,
["bg"] = true,
["bnt-sab-pro"] = true,
["cs"] = true,
["en"] = true,
["eo"] = true,
["es"] = true,
["ext"] = true,
["fa"] = true,
["gmw-msc"] = true,
["hsb"] = true,
["iir-pro"] = true,
["jbo"] = true,
["jdt"] = true,
["la"] = true,
["mul"] = true,
["ota"] = true,
["pap"] = true,
["ps"] = true,
["ro"] = true,
["sco"] = true,
["sk"] = true,
["sw"] = true,
["tg"] = true,
["tl"] = true,
["tr"] = true,
["uk"] = true,
["uz"] = true,
["sl"] = true,
["rsk"] = true,
["zlw-ocs"] = true,
["zlw-osk"] = true,
["zle-ono"] = true,
["zle-ort"] = true,
["sla-pro"] = true,
-- Austronesian
["map-pro"] = true,
["map-ata-pro"] = true,
["poz-pro"] = true,
["poz-btk-pro"] = true,
["poz-cet-pro"] = true,
["pqe-pro"] = true,
["poz-hce-pro"] = true,
["poz-oce-pro"] = true,
["poz-pol-pro"] = true,
["poz-pnp-pro"] = true,
["poz-pep-pro"] = true,
["poz-mic-pro"] = true,
["poz-lgx-pro"] = true,
["poz-msa-pro"] = true,
["poz-mcm-pro"] = true,
["cmc-pro"] = true,
["poz-mly-pro"] = true,
["poz-swa-pro"] = true,
["btk-pro"] = true,
["phi-pro"] = true,
["phi-kal-pro"] = true,
["poz-ssw-pro"] = true,
["dru-pro"] = true,
},
families = {
["ber"] = true, -- Berber
["dra"] = true, -- Dravidian
["inc"] = true, -- Indo-Aryan
["iir-nur"] = true, -- Nuristani
["mun"] = true, -- Munda
["roa-gap"] = true, -- Galician-Portuguese
["sem-ara"] = true, -- Aramaic
["sem-arb"] = true, -- Arabic
["tup"] = true, -- Tupian
["zlw-lch"] = true, -- Lechitic
},
}
kgow4gb0ziadv2y7k1g7wp62mgl1vf0
89228
89227
2026-09-02T18:58:21Z
Altronic
4137
Chaidh 1 mhùthadh ion-phortachadh o [[:en:Module:etymon/data/text_allowed]]
89227
Scribunto
text/plain
--[=[
Languages and families that may use the {{etymon}} `text=` parameter (language-community consensus).
]=]
return {
-- Mode: "off" = disabled, "warn" = warn only, "error" = enforce.
default_mode = "warn",
langs = {
["ak"] = true,
["amf"] = true,
["bg"] = true,
["bnt-sab-pro"] = true,
["cs"] = true,
["en"] = true,
["eo"] = true,
["es"] = true,
["ext"] = true,
["fa"] = true,
["gmw-msc"] = true,
["hsb"] = true,
["iir-pro"] = true,
["jbo"] = true,
["jdt"] = true,
["la"] = true,
["mul"] = true,
["ota"] = true,
["pap"] = true,
["ps"] = true,
["ro"] = true,
["sco"] = true,
["sk"] = true,
["sw"] = true,
["tg"] = true,
["tl"] = true,
["tr"] = true,
["uk"] = true,
["uz"] = true,
["sl"] = true,
["rsk"] = true,
["zlw-ocs"] = true,
["zlw-osk"] = true,
["zle-ono"] = true,
["zle-ort"] = true,
["sla-pro"] = true,
-- Austronesian
["map-pro"] = true,
["map-ata-pro"] = true,
["poz-pro"] = true,
["poz-btk-pro"] = true,
["poz-cet-pro"] = true,
["pqe-pro"] = true,
["poz-hce-pro"] = true,
["poz-oce-pro"] = true,
["poz-pol-pro"] = true,
["poz-pnp-pro"] = true,
["poz-pep-pro"] = true,
["poz-mic-pro"] = true,
["poz-lgx-pro"] = true,
["poz-msa-pro"] = true,
["poz-mcm-pro"] = true,
["cmc-pro"] = true,
["poz-mly-pro"] = true,
["poz-swa-pro"] = true,
["btk-pro"] = true,
["phi-pro"] = true,
["phi-kal-pro"] = true,
["poz-ssw-pro"] = true,
["dru-pro"] = true,
},
families = {
["ber"] = true, -- Berber
["dra"] = true, -- Dravidian
["inc"] = true, -- Indo-Aryan
["iir-nur"] = true, -- Nuristani
["mun"] = true, -- Munda
["roa-gap"] = true, -- Galician-Portuguese
["sem-ara"] = true, -- Aramaic
["sem-arb"] = true, -- Arabic
["tup"] = true, -- Tupian
["zlw-lch"] = true, -- Lechitic
},
}
kgow4gb0ziadv2y7k1g7wp62mgl1vf0
Mòideal:etymon/categories
828
18104
89229
2026-08-23T19:25:13Z
en>Surjection
0
Reverted edits by [[Special:Contributions/Surjection|Surjection]]. If you think this rollback is in error, please leave a message on my [[User talk:Surjection|talk page]].
89229
Scribunto
text/plain
local export = {}
local M = require("Module:module loader").init({
require = {
etymology = "Module:etymology",
affix = "Module:affix",
etymology_specialized = "Module:etymology/specialized",
utilities = "Module:utilities",
roots = "Module:roots",
},
loadData = {
data = "Module:etymon/data",
},
})
-- Evaluate whether a keyword is transitive for a given term
local function is_transitive(transitive_mode, page_lang, term_lang)
if transitive_mode == M.data.TRANSITIVE.ALWAYS then
return true
elseif transitive_mode == M.data.TRANSITIVE.NEVER then
return false
elseif transitive_mode == M.data.TRANSITIVE.CROSS_LANG then
return page_lang:getCode() ~= term_lang:getCode()
elseif transitive_mode == M.data.TRANSITIVE.CROSS_LANG_NO_INTERNAL_SOURCE then
return page_lang:getCode() ~= term_lang:getCode()
end
error("Unknown transitive mode: " .. tostring(transitive_mode))
end
-- Get keyword config with language-specific overrides
local function get_keyword_config(keyword, lang_exc)
local base_config = M.data.keywords[keyword]
if not base_config then
return nil -- Invalid keyword
end
local overrides = lang_exc and lang_exc.keyword_overrides and lang_exc.keyword_overrides[keyword]
if not overrides then
return base_config
end
-- Merge overrides into base config
local merged = {}
for k, v in pairs(base_config) do
merged[k] = v
end
for k, v in pairs(overrides) do
merged[k] = v
end
return merged
end
function export.get_cat_name(source)
local _, cat_name = M.etymology.get_display_and_cat_name(source, true)
return cat_name
end
-- Normalize affix type aliases
local aftype_aliases = {
["pre"] = "prefix",
["suf"] = "suffix",
["in"] = "infix",
["inter"] = "interfix",
["circum"] = "circumfix",
["naf"] = "non-affix",
["root"] = "non-affix",
}
local function add_category(categories, cat_name, sort_key, sort_base)
if categories[cat_name] == nil then
categories[cat_name] = {
sort_key = sort_key,
sort_base = sort_base,
}
return
end
local existing = categories[cat_name]
if existing.sort_key == nil and sort_key ~= nil then
existing.sort_key = sort_key
end
if existing.sort_base == nil and sort_base ~= nil then
existing.sort_base = sort_base
end
end
-- Collect affix categories from top-level group containers
local function collect_affix_categories(node, page_lang, available_etymon_ids, senseid_parent_etymon, lang_exc)
local parts = {}
local part_index = 1
for _, container in ipairs(node.children or {}) do
local config = container.keyword_info
if config and config.affix_categories then
for _, term in ipairs(container.terms or {}) do
if not term.unknown_term then
local part_data = {
term = term.title,
tr = term.tr,
ts = term.ts,
alt = term.alt,
itemno = part_index,
orig_index = part_index
}
-- Determine affix type: explicit aftype > pos=root > auto-detect
local aftype = term.aftype
if aftype then
aftype = aftype_aliases[aftype] or aftype
part_data.type = aftype
elseif term.args and term.args.pos and term.args.pos == "root" then
part_data.type = "non-affix"
end
if term.lang:getCode() ~= page_lang:getCode() then
part_data.lang = term.lang
end
local target_ids = available_etymon_ids[term.target_key]
local has_multiple_ids = target_ids and #target_ids > 1
local id_exists_in_disambiguation = false
local matched_id = nil
-- Count available senseids for the target page
local senseid_count = 0
local target_prefix = term.target_key .. ":"
if senseid_parent_etymon then
for key, _ in pairs(senseid_parent_etymon) do
if key:sub(1, #target_prefix) == target_prefix then
senseid_count = senseid_count + 1
end
end
end
local has_multiple_senseids = senseid_count > 1
if term.id then
-- Check if user provided a valid senseid
local senseid_key = term.target_key .. ":" .. term.id
if senseid_parent_etymon and senseid_parent_etymon[senseid_key] then
if has_multiple_senseids then
-- Ambiguous senseid: use senseid
matched_id = term.id
id_exists_in_disambiguation = true
elseif has_multiple_ids then
-- Unique senseid but ambiguous etymon: use etymon ID
matched_id = term.etymon_id or term.id
id_exists_in_disambiguation = true
end
else
-- Check if user provided a valid etymon ID
if has_multiple_ids and target_ids then
for _, id_data in ipairs(target_ids) do
local stored_id = type(id_data) == "table" and id_data.id or id_data
if stored_id == term.id then
-- Ambiguous etymon: use etymon ID
id_exists_in_disambiguation = true
matched_id = term.id
break
end
end
end
-- Fallback: check resolved etymon_id (e.g. from previous steps)
if not id_exists_in_disambiguation and has_multiple_ids and term.etymon_id and target_ids then
for _, id_data in ipairs(target_ids) do
local stored_id = type(id_data) == "table" and id_data.id or id_data
if stored_id == term.etymon_id then
id_exists_in_disambiguation = true
matched_id = term.etymon_id
break
end
end
end
end
end
-- Use the matched ID if found
if term.override or id_exists_in_disambiguation then
part_data.id = matched_id or term.id
end
table.insert(parts, part_data)
part_index = part_index + 1
end
end
end
end
if #parts == 0 then return {} end
local affix_data = {
lang = page_lang,
parts = parts,
pos = "term",
sort_key = nil,
}
if #parts == 1 then
affix_data.allow_no_affixes_or_compounds = true
end
local affix_categories = M.affix.get_affix_categories_only(affix_data)
local result = {}
for _, cat in ipairs(affix_categories) do
if type(cat) == "table" then
table.insert(result, { cat = cat.cat, sort_key = cat.sort_key, sort_base = cat.sort_base })
else
table.insert(result, { cat = cat })
end
end
return result
end
local function lang_is_source(page_lang, source)
return page_lang:getCode() == source:getCode() or page_lang:hasParent(source)
end
local function is_borrowing_keyword_config(config)
return config and (config.borrowing_type or config.specialized_borrowing)
end
local function add_reborrow_category(categories, page_lang)
local lang_name = page_lang:getFullName()
add_category(categories, lang_name .. " terms borrowed back into " .. lang_name)
end
local function borrow_returns_to_page_lang(page_lang, source, in_foreign_branch)
if not in_foreign_branch then
return false
end
if source:getFullCode() == page_lang:getFullCode() then
return true
end
return page_lang:hasParent(source)
end
local function node_borrows_from_lang(node, page_lang, visited, in_foreign_branch)
visited = visited or {}
if not node or visited[node] then
return false
end
visited[node] = true
if node.is_duplicate then
if node.duplicate_of then
return node_borrows_from_lang(node.duplicate_of, page_lang, visited, in_foreign_branch)
end
return false
end
local node_is_foreign = in_foreign_branch
or (node.lang and node.lang:getFullCode() ~= page_lang:getFullCode())
for _, container in ipairs(node.children or {}) do
if is_borrowing_keyword_config(container.keyword_info) then
for _, child_term in ipairs(container.terms or {}) do
if borrow_returns_to_page_lang(page_lang, child_term.lang, node_is_foreign) then
return true
end
end
end
for _, child_term in ipairs(container.terms or {}) do
if child_term.status == M.data.STATUS.OK or child_term.status == M.data.STATUS.INLINE then
if node_borrows_from_lang(child_term, page_lang, visited, node_is_foreign) then
return true
end
end
end
end
return false
end
local function should_add_reborrow_category(page_lang, term)
if page_lang:getCode() == term.lang:getCode() then
return false
end
if term.status ~= M.data.STATUS.OK and term.status ~= M.data.STATUS.INLINE then
return false
end
return node_borrows_from_lang(term, page_lang, {}, false)
end
-- Add borrowing-related categories (top-level only)
local function collect_borrowing_categories(categories, page_lang, term, config, check_reborrow_path)
if check_reborrow_path and should_add_reborrow_category(page_lang, term) then
add_reborrow_category(categories, page_lang)
end
if config.borrowing_type == "borrowed" and not lang_is_source(page_lang, term.lang) then
local temp_categories = {}
M.etymology.insert_borrowed_cat(temp_categories, page_lang, term.lang)
for _, cat in ipairs(temp_categories) do
add_category(categories, cat)
end
end
if config.specialized_borrowing and not lang_is_source(page_lang, term.lang) then
local result = M.etymology_specialized.specialized_borrowing {
bortype = config.specialized_borrowing,
lang = page_lang,
sources = { term.lang },
terms = { { lang = term.lang, term = "-" } },
notext = true,
nocat = false,
}
for cat_name in result:gmatch("%[%[Category:([^%]]+)%]%]") do
add_category(categories, cat_name)
end
end
end
-- Add source-based derivation categories (top-level only)
local function collect_source_derivation_categories(categories, page_lang, term, config)
if not config.source_category_type then
return
end
local temp_categories = {}
M.etymology.insert_source_cat_get_display {
lang = page_lang,
source = term.lang,
categories = temp_categories,
borrowing_type = config.source_category_type,
nocat = false,
}
for _, cat in ipairs(temp_categories) do
add_category(categories, cat)
end
end
-- Add source language categories
local function collect_source_categories(categories, page_lang, term, chain, get_norm_lang_func)
if page_lang:getCode() == get_norm_lang_func(term.lang):getCode() then
return
end
local temp_categories = {}
M.etymology.insert_source_cat_get_display {
lang = page_lang,
source = term.lang,
categories = temp_categories,
nocat = false,
}
for _, cat in ipairs(temp_categories) do
add_category(categories, cat)
end
if chain.inherited then
temp_categories = {}
M.etymology.insert_source_cat_get_display {
lang = page_lang,
source = term.lang,
categories = temp_categories,
borrowing_type = "terms inherited",
nocat = false,
}
for _, cat in ipairs(temp_categories) do
add_category(categories, cat)
end
end
end
-- Add root/word categories
local function collect_pos_categories(categories, page_lang, root_title, term, available_etymon_ids, chain,
get_norm_lang_func, lang_exc, keyword)
local pos_types = { root = "root", word = "word" }
-- Determine pos: from term's postype, keyword's pos_override, or args.pos
local pos
local config = get_keyword_config(keyword, lang_exc)
if term.postype then
-- Term-level postype modifier takes highest priority
pos = term.postype
elseif config and config.pos_override then
pos = config.pos_override
elseif type(term.args) == "table" and term.args.pos then
pos = term.args.pos
end
local pos_type = pos_types[pos]
if not pos_type or term.unknown_term then
return
end
-- Skip root/word categories for descendants of affix groups
-- if pos_type then
-- return
-- end
local same_language = get_norm_lang_func(page_lang):getFullCode() == get_norm_lang_func(term.lang):getFullCode()
-- Skip self-references
if same_language and root_title == term.title then
return
end
local entry_name
if pos_type == "root" then
entry_name = term.title
M.roots.assert_root(term.lang, entry_name)
else
entry_name = term.lang:makeEntryName(term.title)
end
local lang_name = page_lang:getCanonicalName()
local cat_name
if chain.passed_through then
local etymon_lang_name = export.get_cat_name(term.lang)
cat_name = lang_name .. " terms derived from the " .. etymon_lang_name .. " " .. pos_type .. " " .. entry_name
else
cat_name = lang_name .. " terms belonging to the " .. pos_type .. " " .. entry_name
end
-- Add ID disambiguation if needed (for roots/words: use etymon_id if resolved via senseid, otherwise use id)
local target_ids = available_etymon_ids[term.target_key]
local effective_id = term.etymon_id or term.id -- etymon_id if senseid, otherwise id is already an etymon id
if target_ids and effective_id then
local same_pos_count = 0
for _, id_data in ipairs(target_ids) do
if type(id_data) == "table" and id_data.pos == pos then
same_pos_count = same_pos_count + 1
end
end
if same_pos_count > 1 then
cat_name = cat_name .. " (" .. effective_id .. ")"
end
end
add_category(categories, cat_name)
end
-- Compute chain state for a term based on parent chain and keyword config
-- Hyphen patterns for affix detection (regular hyphen + script-specific)
local AFFIX_HYPHEN_PATTERN = "[%-%־ـ᠊]" -- regular hyphen, Hebrew maqqef, Arabic tatweel, Mongolian hyphen
-- Check if a term is an actual affix (not a non-affix member of an affix group)
local function is_actual_affix(term)
-- Check explicit aftype modifier
if term.aftype then
local normalized = aftype_aliases[term.aftype] or term.aftype
return normalized ~= "non-affix"
end
-- Check if pos=root (treated as non-affix)
if term.args and term.args.pos and term.args.pos == "root" then
return false
end
-- Auto-detect by hyphen: prefix ends with -, suffix starts with -, etc.
if term.title then
local title = term.title
-- Strip leading * for reconstructed terms before checking hyphens
title = title:gsub("^%*", "")
-- Check for hyphens at start or end (handles script-specific hyphens too)
if title:match("^" .. AFFIX_HYPHEN_PATTERN) or title:match(AFFIX_HYPHEN_PATTERN .. "$") then
return true
end
end
-- Default: not an affix
return false
end
local function compute_category_chain(parent_chain, config, page_lang, term_lang, get_norm_lang_func, parent_term_lang, term)
-- Track if we're inside an actual affix (for suppressing root categories on descendants)
-- Only set if the term is an actual affix (prefix, suffix, etc.), not a non-affix member
local inside_affix = parent_chain.inside_affix
if config.affix_categories and term and is_actual_affix(term) then
inside_affix = true
end
-- If no_child_categories is set, disable everything
if config.no_child_categories then
return {
passed_through = parent_chain.passed_through or page_lang:getCode() ~= get_norm_lang_func(term_lang):getCode(),
inherited = false,
source = false,
pos = false,
recurse = false,
inside_affix = inside_affix,
}
end
local term_is_transitive = is_transitive(config.transitive, page_lang, term_lang)
local new_source = parent_chain.source and term_is_transitive
-- For CROSS_LANG_NO_INTERNAL_SOURCE: track internal derivation language context
-- Check if this term is internal relative to parent term's language (if parent_term_lang provided)
-- or relative to page language (if no parent_term_lang)
local internal_lang = parent_chain.internal_lang
local is_internal_in_context = false
if config.transitive == M.data.TRANSITIVE.CROSS_LANG_NO_INTERNAL_SOURCE then
local check_lang = parent_term_lang or page_lang
local term_lang_code = get_norm_lang_func(term_lang):getCode()
local check_lang_code = get_norm_lang_func(check_lang):getCode()
if internal_lang then
-- Already in an internal derivation context: check if this term is also internal
is_internal_in_context = term_lang_code == internal_lang
else
-- Check if this term is internal relative to parent term (or page if no parent)
is_internal_in_context = term_lang_code == check_lang_code
end
end
-- Source chain behavior for CROSS_LANG_NO_INTERNAL_SOURCE
if config.transitive == M.data.TRANSITIVE.CROSS_LANG_NO_INTERNAL_SOURCE then
if is_internal_in_context then
-- Internal derivation
new_source = false
internal_lang = get_norm_lang_func(term_lang):getCode()
else
-- Cross-language
new_source = parent_chain.source and term_is_transitive
internal_lang = nil
end
end
local new_pos = parent_chain.pos
return {
passed_through = parent_chain.passed_through or page_lang:getCode() ~= get_norm_lang_func(term_lang):getCode(),
inherited = parent_chain.inherited and config.inherited_chain,
source = new_source,
pos = new_pos,
internal_lang = internal_lang,
recurse = new_source or new_pos,
inside_affix = inside_affix,
}
end
function export.render(opts)
opts = opts or {}
local data_tree = opts.data_tree
local page_lang = opts.page_lang
local available_etymon_ids = opts.available_etymon_ids
local senseid_parent_etymon = opts.senseid_parent_etymon
local get_norm_lang_func = opts.get_norm_lang_func
local lang_exc = opts.lang_exc
local categories = {}
local seen = {}
local lang_name = page_lang:getCanonicalName()
local root_title = data_tree.title
-- Collect the tree recursively
local function collect(node, parent_chain, is_toplevel)
-- Avoid processing same node twice
if not node.unknown_term and node.title then
local key = node.lang:getFullCode() .. ":" .. (node.title or "") .. ":" .. (node.id or "")
if seen[key] then return end
seen[key] = true
end
-- Collect affix categories at top level only
if is_toplevel then
local affix_cats = collect_affix_categories(node, page_lang, available_etymon_ids, senseid_parent_etymon, lang_exc)
for _, cat in ipairs(affix_cats) do
add_category(categories, lang_name .. " " .. cat.cat, cat.sort_key, cat.sort_base)
end
if node.supplements then
for _, supplement in ipairs(node.supplements) do
local config = supplement.config
if config and config.toplevel_category then
add_category(categories, lang_name .. " " .. config.toplevel_category)
end
end
end
end
-- Process each container
for _, container in ipairs(node.children or {}) do
local keyword = container.keyword
local config = get_keyword_config(keyword, lang_exc)
-- Skip invalid keywords
if config then
-- Process each term in the container
for _, term in ipairs(container.terms or {}) do
local term_chain = compute_category_chain(parent_chain, config, page_lang, term.lang, get_norm_lang_func, node.lang, term)
local no_child_categories = config.no_child_categories == true
local term_is_transitive = is_transitive(config.transitive, page_lang, term.lang)
-- Top-level only processing
if is_toplevel then
-- Missing/ambiguous etymon tracking
if not term.unknown_term and (term.status == M.data.STATUS.MISSING or term.status == M.data.STATUS.REDLINK) then
add_category(categories, lang_name .. " entries referencing missing etymons")
end
if not term.unknown_term and term.status == M.data.STATUS.AMBIGUOUS then
add_category(categories, lang_name .. " entries referencing ambiguous etymons")
end
if term.missing_descendants_header then
add_category(categories, lang_name .. " entries referencing etymons without Descendants sections")
end
if term.missing_descendants_entry then
add_category(categories, lang_name .. " entries referencing etymons without this term in Descendants sections")
end
-- Top-level category (e.g., "undefined derivations")
if config.toplevel_category then
add_category(categories, lang_name .. " " .. config.toplevel_category)
end
-- Borrowing categories (bor, lbor, slbor, ubor, obor)
if config.borrowing_type or config.specialized_borrowing then
collect_borrowing_categories(categories, page_lang, term, config, true)
end
-- Borrowing categories from <bor>, <lbor>, or <slbor> modifiers on affix-group terms
local kw_config = M.data.keywords[keyword]
if kw_config and kw_config.affix_categories then
if term.bor then
local bor_config = { borrowing_type = "borrowed" }
collect_borrowing_categories(categories, page_lang, term, bor_config, true)
elseif term.lbor then
local bor_config = { specialized_borrowing = "learned" }
collect_borrowing_categories(categories, page_lang, term, bor_config, true)
elseif term.slbor then
local bor_config = { specialized_borrowing = "semi-learned" }
collect_borrowing_categories(categories, page_lang, term, bor_config, true)
end
end
-- Source-based derivation categories (sl, calque, pcal)
if config.source_category_type then
collect_source_derivation_categories(categories, page_lang, term, config)
end
-- Skip all child categorisation if no_child_categories is set
if not no_child_categories then
-- Source categories only if transitive
if term_is_transitive then
collect_source_categories(categories, page_lang, term, term_chain, get_norm_lang_func)
end
-- Pos categories always (unless no_child_categories)
collect_pos_categories(categories, page_lang, root_title, term, available_etymon_ids, term_chain,
get_norm_lang_func, lang_exc, keyword)
end
else
-- Below top level, respect the parent chain
if parent_chain.source then
collect_source_categories(categories, page_lang, term, term_chain, get_norm_lang_func)
end
if parent_chain.pos then
collect_pos_categories(categories, page_lang, root_title, term, available_etymon_ids, term_chain,
get_norm_lang_func, lang_exc, keyword)
end
end
-- Recurse into term's children if needed and status allows
if term_chain.recurse and (term.status == M.data.STATUS.OK or term.status == M.data.STATUS.INLINE) then
collect(term, term_chain, false)
end
end
end
end
end
-- Initial chain state
local initial_chain = {
passed_through = false,
inherited = true,
source = true,
pos = true,
internal_lang = nil,
recurse = true,
inside_affix = false,
}
collect(data_tree, initial_chain, true)
local cat_list = {}
for cat_name, sort_data in pairs(categories) do
if sort_data.sort_key ~= nil or sort_data.sort_base ~= nil then
table.insert(cat_list, {
name = cat_name,
sort_key = sort_data.sort_key,
sort_base = sort_data.sort_base,
})
else
table.insert(cat_list, cat_name)
end
end
return cat_list
end
function export.build(opts)
opts = opts or {}
local categories = {}
if not opts.suppress_categories and not opts.nocat then
categories = export.render({
data_tree = opts.data_tree,
page_lang = opts.page_lang,
available_etymon_ids = opts.available_etymon_ids,
senseid_parent_etymon = opts.senseid_parent_etymon,
get_norm_lang_func = opts.get_norm_lang_func,
lang_exc = opts.lang_exc,
})
end
local page_lang = opts.page_lang
if not page_lang then
return categories
end
local lang_name = page_lang:getCanonicalName()
table.insert(categories, "Pages with etymon")
table.insert(categories, lang_name .. " entries with etymon")
if opts.tree then
table.insert(categories, "Pages with etymology trees")
table.insert(categories, lang_name .. " entries with etymology trees")
end
if opts.text then
table.insert(categories, lang_name .. " entries with etymology texts")
end
if opts.exnihilo then
table.insert(categories, lang_name .. " terms coined ex nihilo")
end
if opts.toplevel_has_inline_etymology then
table.insert(categories, "Pages with inline etymon for redlinks")
end
if opts.toplevel_redundant_etymology then
table.insert(categories, "Pages with redundant inline etymon")
end
if opts.toplevel_idless_etymon then
table.insert(categories, "Pages using etymon with no ID")
end
if opts.has_mismatched_id then
table.insert(categories, lang_name .. " entries referencing etymons with mismatched IDs")
end
if opts.linked_page_multiple_etymons_idless then
table.insert(categories,
lang_name .. " entries referencing pages with multiple etymons missing IDs")
end
if opts.linked_page_partial_etymology_sections then
table.insert(categories,
lang_name .. " entries referencing pages with etymology sections missing etymons")
end
if opts.text_stop_lang_missing then
table.insert(categories, "Pages with etymology text stop language not in chain")
table.insert(categories, lang_name .. " entries with etymology text stop language not in chain")
end
return categories
end
function export.format(entries, lang)
if type(entries) ~= "table" or #entries == 0 then
return ""
end
local parts = {}
for _, category in ipairs(entries) do
if type(category) == "table" and type(category.name) == "string" then
table.insert(parts, M.utilities.format_categories({ category.name }, lang, category.sort_key, category.sort_base))
elseif type(category) == "string" then
table.insert(parts, M.utilities.format_categories({ category }, lang))
end
end
return table.concat(parts)
end
return export
rzmmziltpt0x9e9bh7pas72fpwpnrji
89230
89229
2026-09-02T18:58:46Z
Altronic
4137
Chaidh 1 mhùthadh ion-phortachadh o [[:en:Module:etymon/categories]]
89229
Scribunto
text/plain
local export = {}
local M = require("Module:module loader").init({
require = {
etymology = "Module:etymology",
affix = "Module:affix",
etymology_specialized = "Module:etymology/specialized",
utilities = "Module:utilities",
roots = "Module:roots",
},
loadData = {
data = "Module:etymon/data",
},
})
-- Evaluate whether a keyword is transitive for a given term
local function is_transitive(transitive_mode, page_lang, term_lang)
if transitive_mode == M.data.TRANSITIVE.ALWAYS then
return true
elseif transitive_mode == M.data.TRANSITIVE.NEVER then
return false
elseif transitive_mode == M.data.TRANSITIVE.CROSS_LANG then
return page_lang:getCode() ~= term_lang:getCode()
elseif transitive_mode == M.data.TRANSITIVE.CROSS_LANG_NO_INTERNAL_SOURCE then
return page_lang:getCode() ~= term_lang:getCode()
end
error("Unknown transitive mode: " .. tostring(transitive_mode))
end
-- Get keyword config with language-specific overrides
local function get_keyword_config(keyword, lang_exc)
local base_config = M.data.keywords[keyword]
if not base_config then
return nil -- Invalid keyword
end
local overrides = lang_exc and lang_exc.keyword_overrides and lang_exc.keyword_overrides[keyword]
if not overrides then
return base_config
end
-- Merge overrides into base config
local merged = {}
for k, v in pairs(base_config) do
merged[k] = v
end
for k, v in pairs(overrides) do
merged[k] = v
end
return merged
end
function export.get_cat_name(source)
local _, cat_name = M.etymology.get_display_and_cat_name(source, true)
return cat_name
end
-- Normalize affix type aliases
local aftype_aliases = {
["pre"] = "prefix",
["suf"] = "suffix",
["in"] = "infix",
["inter"] = "interfix",
["circum"] = "circumfix",
["naf"] = "non-affix",
["root"] = "non-affix",
}
local function add_category(categories, cat_name, sort_key, sort_base)
if categories[cat_name] == nil then
categories[cat_name] = {
sort_key = sort_key,
sort_base = sort_base,
}
return
end
local existing = categories[cat_name]
if existing.sort_key == nil and sort_key ~= nil then
existing.sort_key = sort_key
end
if existing.sort_base == nil and sort_base ~= nil then
existing.sort_base = sort_base
end
end
-- Collect affix categories from top-level group containers
local function collect_affix_categories(node, page_lang, available_etymon_ids, senseid_parent_etymon, lang_exc)
local parts = {}
local part_index = 1
for _, container in ipairs(node.children or {}) do
local config = container.keyword_info
if config and config.affix_categories then
for _, term in ipairs(container.terms or {}) do
if not term.unknown_term then
local part_data = {
term = term.title,
tr = term.tr,
ts = term.ts,
alt = term.alt,
itemno = part_index,
orig_index = part_index
}
-- Determine affix type: explicit aftype > pos=root > auto-detect
local aftype = term.aftype
if aftype then
aftype = aftype_aliases[aftype] or aftype
part_data.type = aftype
elseif term.args and term.args.pos and term.args.pos == "root" then
part_data.type = "non-affix"
end
if term.lang:getCode() ~= page_lang:getCode() then
part_data.lang = term.lang
end
local target_ids = available_etymon_ids[term.target_key]
local has_multiple_ids = target_ids and #target_ids > 1
local id_exists_in_disambiguation = false
local matched_id = nil
-- Count available senseids for the target page
local senseid_count = 0
local target_prefix = term.target_key .. ":"
if senseid_parent_etymon then
for key, _ in pairs(senseid_parent_etymon) do
if key:sub(1, #target_prefix) == target_prefix then
senseid_count = senseid_count + 1
end
end
end
local has_multiple_senseids = senseid_count > 1
if term.id then
-- Check if user provided a valid senseid
local senseid_key = term.target_key .. ":" .. term.id
if senseid_parent_etymon and senseid_parent_etymon[senseid_key] then
if has_multiple_senseids then
-- Ambiguous senseid: use senseid
matched_id = term.id
id_exists_in_disambiguation = true
elseif has_multiple_ids then
-- Unique senseid but ambiguous etymon: use etymon ID
matched_id = term.etymon_id or term.id
id_exists_in_disambiguation = true
end
else
-- Check if user provided a valid etymon ID
if has_multiple_ids and target_ids then
for _, id_data in ipairs(target_ids) do
local stored_id = type(id_data) == "table" and id_data.id or id_data
if stored_id == term.id then
-- Ambiguous etymon: use etymon ID
id_exists_in_disambiguation = true
matched_id = term.id
break
end
end
end
-- Fallback: check resolved etymon_id (e.g. from previous steps)
if not id_exists_in_disambiguation and has_multiple_ids and term.etymon_id and target_ids then
for _, id_data in ipairs(target_ids) do
local stored_id = type(id_data) == "table" and id_data.id or id_data
if stored_id == term.etymon_id then
id_exists_in_disambiguation = true
matched_id = term.etymon_id
break
end
end
end
end
end
-- Use the matched ID if found
if term.override or id_exists_in_disambiguation then
part_data.id = matched_id or term.id
end
table.insert(parts, part_data)
part_index = part_index + 1
end
end
end
end
if #parts == 0 then return {} end
local affix_data = {
lang = page_lang,
parts = parts,
pos = "term",
sort_key = nil,
}
if #parts == 1 then
affix_data.allow_no_affixes_or_compounds = true
end
local affix_categories = M.affix.get_affix_categories_only(affix_data)
local result = {}
for _, cat in ipairs(affix_categories) do
if type(cat) == "table" then
table.insert(result, { cat = cat.cat, sort_key = cat.sort_key, sort_base = cat.sort_base })
else
table.insert(result, { cat = cat })
end
end
return result
end
local function lang_is_source(page_lang, source)
return page_lang:getCode() == source:getCode() or page_lang:hasParent(source)
end
local function is_borrowing_keyword_config(config)
return config and (config.borrowing_type or config.specialized_borrowing)
end
local function add_reborrow_category(categories, page_lang)
local lang_name = page_lang:getFullName()
add_category(categories, lang_name .. " terms borrowed back into " .. lang_name)
end
local function borrow_returns_to_page_lang(page_lang, source, in_foreign_branch)
if not in_foreign_branch then
return false
end
if source:getFullCode() == page_lang:getFullCode() then
return true
end
return page_lang:hasParent(source)
end
local function node_borrows_from_lang(node, page_lang, visited, in_foreign_branch)
visited = visited or {}
if not node or visited[node] then
return false
end
visited[node] = true
if node.is_duplicate then
if node.duplicate_of then
return node_borrows_from_lang(node.duplicate_of, page_lang, visited, in_foreign_branch)
end
return false
end
local node_is_foreign = in_foreign_branch
or (node.lang and node.lang:getFullCode() ~= page_lang:getFullCode())
for _, container in ipairs(node.children or {}) do
if is_borrowing_keyword_config(container.keyword_info) then
for _, child_term in ipairs(container.terms or {}) do
if borrow_returns_to_page_lang(page_lang, child_term.lang, node_is_foreign) then
return true
end
end
end
for _, child_term in ipairs(container.terms or {}) do
if child_term.status == M.data.STATUS.OK or child_term.status == M.data.STATUS.INLINE then
if node_borrows_from_lang(child_term, page_lang, visited, node_is_foreign) then
return true
end
end
end
end
return false
end
local function should_add_reborrow_category(page_lang, term)
if page_lang:getCode() == term.lang:getCode() then
return false
end
if term.status ~= M.data.STATUS.OK and term.status ~= M.data.STATUS.INLINE then
return false
end
return node_borrows_from_lang(term, page_lang, {}, false)
end
-- Add borrowing-related categories (top-level only)
local function collect_borrowing_categories(categories, page_lang, term, config, check_reborrow_path)
if check_reborrow_path and should_add_reborrow_category(page_lang, term) then
add_reborrow_category(categories, page_lang)
end
if config.borrowing_type == "borrowed" and not lang_is_source(page_lang, term.lang) then
local temp_categories = {}
M.etymology.insert_borrowed_cat(temp_categories, page_lang, term.lang)
for _, cat in ipairs(temp_categories) do
add_category(categories, cat)
end
end
if config.specialized_borrowing and not lang_is_source(page_lang, term.lang) then
local result = M.etymology_specialized.specialized_borrowing {
bortype = config.specialized_borrowing,
lang = page_lang,
sources = { term.lang },
terms = { { lang = term.lang, term = "-" } },
notext = true,
nocat = false,
}
for cat_name in result:gmatch("%[%[Category:([^%]]+)%]%]") do
add_category(categories, cat_name)
end
end
end
-- Add source-based derivation categories (top-level only)
local function collect_source_derivation_categories(categories, page_lang, term, config)
if not config.source_category_type then
return
end
local temp_categories = {}
M.etymology.insert_source_cat_get_display {
lang = page_lang,
source = term.lang,
categories = temp_categories,
borrowing_type = config.source_category_type,
nocat = false,
}
for _, cat in ipairs(temp_categories) do
add_category(categories, cat)
end
end
-- Add source language categories
local function collect_source_categories(categories, page_lang, term, chain, get_norm_lang_func)
if page_lang:getCode() == get_norm_lang_func(term.lang):getCode() then
return
end
local temp_categories = {}
M.etymology.insert_source_cat_get_display {
lang = page_lang,
source = term.lang,
categories = temp_categories,
nocat = false,
}
for _, cat in ipairs(temp_categories) do
add_category(categories, cat)
end
if chain.inherited then
temp_categories = {}
M.etymology.insert_source_cat_get_display {
lang = page_lang,
source = term.lang,
categories = temp_categories,
borrowing_type = "terms inherited",
nocat = false,
}
for _, cat in ipairs(temp_categories) do
add_category(categories, cat)
end
end
end
-- Add root/word categories
local function collect_pos_categories(categories, page_lang, root_title, term, available_etymon_ids, chain,
get_norm_lang_func, lang_exc, keyword)
local pos_types = { root = "root", word = "word" }
-- Determine pos: from term's postype, keyword's pos_override, or args.pos
local pos
local config = get_keyword_config(keyword, lang_exc)
if term.postype then
-- Term-level postype modifier takes highest priority
pos = term.postype
elseif config and config.pos_override then
pos = config.pos_override
elseif type(term.args) == "table" and term.args.pos then
pos = term.args.pos
end
local pos_type = pos_types[pos]
if not pos_type or term.unknown_term then
return
end
-- Skip root/word categories for descendants of affix groups
-- if pos_type then
-- return
-- end
local same_language = get_norm_lang_func(page_lang):getFullCode() == get_norm_lang_func(term.lang):getFullCode()
-- Skip self-references
if same_language and root_title == term.title then
return
end
local entry_name
if pos_type == "root" then
entry_name = term.title
M.roots.assert_root(term.lang, entry_name)
else
entry_name = term.lang:makeEntryName(term.title)
end
local lang_name = page_lang:getCanonicalName()
local cat_name
if chain.passed_through then
local etymon_lang_name = export.get_cat_name(term.lang)
cat_name = lang_name .. " terms derived from the " .. etymon_lang_name .. " " .. pos_type .. " " .. entry_name
else
cat_name = lang_name .. " terms belonging to the " .. pos_type .. " " .. entry_name
end
-- Add ID disambiguation if needed (for roots/words: use etymon_id if resolved via senseid, otherwise use id)
local target_ids = available_etymon_ids[term.target_key]
local effective_id = term.etymon_id or term.id -- etymon_id if senseid, otherwise id is already an etymon id
if target_ids and effective_id then
local same_pos_count = 0
for _, id_data in ipairs(target_ids) do
if type(id_data) == "table" and id_data.pos == pos then
same_pos_count = same_pos_count + 1
end
end
if same_pos_count > 1 then
cat_name = cat_name .. " (" .. effective_id .. ")"
end
end
add_category(categories, cat_name)
end
-- Compute chain state for a term based on parent chain and keyword config
-- Hyphen patterns for affix detection (regular hyphen + script-specific)
local AFFIX_HYPHEN_PATTERN = "[%-%־ـ᠊]" -- regular hyphen, Hebrew maqqef, Arabic tatweel, Mongolian hyphen
-- Check if a term is an actual affix (not a non-affix member of an affix group)
local function is_actual_affix(term)
-- Check explicit aftype modifier
if term.aftype then
local normalized = aftype_aliases[term.aftype] or term.aftype
return normalized ~= "non-affix"
end
-- Check if pos=root (treated as non-affix)
if term.args and term.args.pos and term.args.pos == "root" then
return false
end
-- Auto-detect by hyphen: prefix ends with -, suffix starts with -, etc.
if term.title then
local title = term.title
-- Strip leading * for reconstructed terms before checking hyphens
title = title:gsub("^%*", "")
-- Check for hyphens at start or end (handles script-specific hyphens too)
if title:match("^" .. AFFIX_HYPHEN_PATTERN) or title:match(AFFIX_HYPHEN_PATTERN .. "$") then
return true
end
end
-- Default: not an affix
return false
end
local function compute_category_chain(parent_chain, config, page_lang, term_lang, get_norm_lang_func, parent_term_lang, term)
-- Track if we're inside an actual affix (for suppressing root categories on descendants)
-- Only set if the term is an actual affix (prefix, suffix, etc.), not a non-affix member
local inside_affix = parent_chain.inside_affix
if config.affix_categories and term and is_actual_affix(term) then
inside_affix = true
end
-- If no_child_categories is set, disable everything
if config.no_child_categories then
return {
passed_through = parent_chain.passed_through or page_lang:getCode() ~= get_norm_lang_func(term_lang):getCode(),
inherited = false,
source = false,
pos = false,
recurse = false,
inside_affix = inside_affix,
}
end
local term_is_transitive = is_transitive(config.transitive, page_lang, term_lang)
local new_source = parent_chain.source and term_is_transitive
-- For CROSS_LANG_NO_INTERNAL_SOURCE: track internal derivation language context
-- Check if this term is internal relative to parent term's language (if parent_term_lang provided)
-- or relative to page language (if no parent_term_lang)
local internal_lang = parent_chain.internal_lang
local is_internal_in_context = false
if config.transitive == M.data.TRANSITIVE.CROSS_LANG_NO_INTERNAL_SOURCE then
local check_lang = parent_term_lang or page_lang
local term_lang_code = get_norm_lang_func(term_lang):getCode()
local check_lang_code = get_norm_lang_func(check_lang):getCode()
if internal_lang then
-- Already in an internal derivation context: check if this term is also internal
is_internal_in_context = term_lang_code == internal_lang
else
-- Check if this term is internal relative to parent term (or page if no parent)
is_internal_in_context = term_lang_code == check_lang_code
end
end
-- Source chain behavior for CROSS_LANG_NO_INTERNAL_SOURCE
if config.transitive == M.data.TRANSITIVE.CROSS_LANG_NO_INTERNAL_SOURCE then
if is_internal_in_context then
-- Internal derivation
new_source = false
internal_lang = get_norm_lang_func(term_lang):getCode()
else
-- Cross-language
new_source = parent_chain.source and term_is_transitive
internal_lang = nil
end
end
local new_pos = parent_chain.pos
return {
passed_through = parent_chain.passed_through or page_lang:getCode() ~= get_norm_lang_func(term_lang):getCode(),
inherited = parent_chain.inherited and config.inherited_chain,
source = new_source,
pos = new_pos,
internal_lang = internal_lang,
recurse = new_source or new_pos,
inside_affix = inside_affix,
}
end
function export.render(opts)
opts = opts or {}
local data_tree = opts.data_tree
local page_lang = opts.page_lang
local available_etymon_ids = opts.available_etymon_ids
local senseid_parent_etymon = opts.senseid_parent_etymon
local get_norm_lang_func = opts.get_norm_lang_func
local lang_exc = opts.lang_exc
local categories = {}
local seen = {}
local lang_name = page_lang:getCanonicalName()
local root_title = data_tree.title
-- Collect the tree recursively
local function collect(node, parent_chain, is_toplevel)
-- Avoid processing same node twice
if not node.unknown_term and node.title then
local key = node.lang:getFullCode() .. ":" .. (node.title or "") .. ":" .. (node.id or "")
if seen[key] then return end
seen[key] = true
end
-- Collect affix categories at top level only
if is_toplevel then
local affix_cats = collect_affix_categories(node, page_lang, available_etymon_ids, senseid_parent_etymon, lang_exc)
for _, cat in ipairs(affix_cats) do
add_category(categories, lang_name .. " " .. cat.cat, cat.sort_key, cat.sort_base)
end
if node.supplements then
for _, supplement in ipairs(node.supplements) do
local config = supplement.config
if config and config.toplevel_category then
add_category(categories, lang_name .. " " .. config.toplevel_category)
end
end
end
end
-- Process each container
for _, container in ipairs(node.children or {}) do
local keyword = container.keyword
local config = get_keyword_config(keyword, lang_exc)
-- Skip invalid keywords
if config then
-- Process each term in the container
for _, term in ipairs(container.terms or {}) do
local term_chain = compute_category_chain(parent_chain, config, page_lang, term.lang, get_norm_lang_func, node.lang, term)
local no_child_categories = config.no_child_categories == true
local term_is_transitive = is_transitive(config.transitive, page_lang, term.lang)
-- Top-level only processing
if is_toplevel then
-- Missing/ambiguous etymon tracking
if not term.unknown_term and (term.status == M.data.STATUS.MISSING or term.status == M.data.STATUS.REDLINK) then
add_category(categories, lang_name .. " entries referencing missing etymons")
end
if not term.unknown_term and term.status == M.data.STATUS.AMBIGUOUS then
add_category(categories, lang_name .. " entries referencing ambiguous etymons")
end
if term.missing_descendants_header then
add_category(categories, lang_name .. " entries referencing etymons without Descendants sections")
end
if term.missing_descendants_entry then
add_category(categories, lang_name .. " entries referencing etymons without this term in Descendants sections")
end
-- Top-level category (e.g., "undefined derivations")
if config.toplevel_category then
add_category(categories, lang_name .. " " .. config.toplevel_category)
end
-- Borrowing categories (bor, lbor, slbor, ubor, obor)
if config.borrowing_type or config.specialized_borrowing then
collect_borrowing_categories(categories, page_lang, term, config, true)
end
-- Borrowing categories from <bor>, <lbor>, or <slbor> modifiers on affix-group terms
local kw_config = M.data.keywords[keyword]
if kw_config and kw_config.affix_categories then
if term.bor then
local bor_config = { borrowing_type = "borrowed" }
collect_borrowing_categories(categories, page_lang, term, bor_config, true)
elseif term.lbor then
local bor_config = { specialized_borrowing = "learned" }
collect_borrowing_categories(categories, page_lang, term, bor_config, true)
elseif term.slbor then
local bor_config = { specialized_borrowing = "semi-learned" }
collect_borrowing_categories(categories, page_lang, term, bor_config, true)
end
end
-- Source-based derivation categories (sl, calque, pcal)
if config.source_category_type then
collect_source_derivation_categories(categories, page_lang, term, config)
end
-- Skip all child categorisation if no_child_categories is set
if not no_child_categories then
-- Source categories only if transitive
if term_is_transitive then
collect_source_categories(categories, page_lang, term, term_chain, get_norm_lang_func)
end
-- Pos categories always (unless no_child_categories)
collect_pos_categories(categories, page_lang, root_title, term, available_etymon_ids, term_chain,
get_norm_lang_func, lang_exc, keyword)
end
else
-- Below top level, respect the parent chain
if parent_chain.source then
collect_source_categories(categories, page_lang, term, term_chain, get_norm_lang_func)
end
if parent_chain.pos then
collect_pos_categories(categories, page_lang, root_title, term, available_etymon_ids, term_chain,
get_norm_lang_func, lang_exc, keyword)
end
end
-- Recurse into term's children if needed and status allows
if term_chain.recurse and (term.status == M.data.STATUS.OK or term.status == M.data.STATUS.INLINE) then
collect(term, term_chain, false)
end
end
end
end
end
-- Initial chain state
local initial_chain = {
passed_through = false,
inherited = true,
source = true,
pos = true,
internal_lang = nil,
recurse = true,
inside_affix = false,
}
collect(data_tree, initial_chain, true)
local cat_list = {}
for cat_name, sort_data in pairs(categories) do
if sort_data.sort_key ~= nil or sort_data.sort_base ~= nil then
table.insert(cat_list, {
name = cat_name,
sort_key = sort_data.sort_key,
sort_base = sort_data.sort_base,
})
else
table.insert(cat_list, cat_name)
end
end
return cat_list
end
function export.build(opts)
opts = opts or {}
local categories = {}
if not opts.suppress_categories and not opts.nocat then
categories = export.render({
data_tree = opts.data_tree,
page_lang = opts.page_lang,
available_etymon_ids = opts.available_etymon_ids,
senseid_parent_etymon = opts.senseid_parent_etymon,
get_norm_lang_func = opts.get_norm_lang_func,
lang_exc = opts.lang_exc,
})
end
local page_lang = opts.page_lang
if not page_lang then
return categories
end
local lang_name = page_lang:getCanonicalName()
table.insert(categories, "Pages with etymon")
table.insert(categories, lang_name .. " entries with etymon")
if opts.tree then
table.insert(categories, "Pages with etymology trees")
table.insert(categories, lang_name .. " entries with etymology trees")
end
if opts.text then
table.insert(categories, lang_name .. " entries with etymology texts")
end
if opts.exnihilo then
table.insert(categories, lang_name .. " terms coined ex nihilo")
end
if opts.toplevel_has_inline_etymology then
table.insert(categories, "Pages with inline etymon for redlinks")
end
if opts.toplevel_redundant_etymology then
table.insert(categories, "Pages with redundant inline etymon")
end
if opts.toplevel_idless_etymon then
table.insert(categories, "Pages using etymon with no ID")
end
if opts.has_mismatched_id then
table.insert(categories, lang_name .. " entries referencing etymons with mismatched IDs")
end
if opts.linked_page_multiple_etymons_idless then
table.insert(categories,
lang_name .. " entries referencing pages with multiple etymons missing IDs")
end
if opts.linked_page_partial_etymology_sections then
table.insert(categories,
lang_name .. " entries referencing pages with etymology sections missing etymons")
end
if opts.text_stop_lang_missing then
table.insert(categories, "Pages with etymology text stop language not in chain")
table.insert(categories, lang_name .. " entries with etymology text stop language not in chain")
end
return categories
end
function export.format(entries, lang)
if type(entries) ~= "table" or #entries == 0 then
return ""
end
local parts = {}
for _, category in ipairs(entries) do
if type(category) == "table" and type(category.name) == "string" then
table.insert(parts, M.utilities.format_categories({ category.name }, lang, category.sort_key, category.sort_base))
elseif type(category) == "string" then
table.insert(parts, M.utilities.format_categories({ category }, lang))
end
end
return table.concat(parts)
end
return export
rzmmziltpt0x9e9bh7pas72fpwpnrji
Mòideal:rhymes
828
18105
89231
2026-08-13T00:28:38Z
en>Catonif
0
link to category instead of manual namespace on a language-by-language basis
89231
Scribunto
text/plain
local export = {}
local force_cat = false -- for testing
local rhymes_styles_css_module = "Module:rhymes/styles.css"
local IPA_module = "Module:IPA"
local parameters_module = "Module:parameters"
local parameter_utilities_module = "Module:parameter utilities"
local pron_qualifier_module = "Module:pron qualifier"
local script_utilities_module = "Module:script utilities"
local string_utilities_module = "Module:string utilities"
local TemplateStyles_module = "Module:TemplateStyles"
local utilities_module = "Module:utilities"
local rhymes_data = require("Module:rhymes/data")
local concat = table.concat
local insert = table.insert
local function rsplit(text, pattern)
return require(string_utilities_module).split(text, pattern)
end
local function track(page)
require("Module:debug/track")("rhymes/" .. page)
return true
end
local function tag_rhyme(rhyme, lang)
local formatted_rhyme, cats, err
formatted_rhyme, cats, err = require(IPA_module).format_IPA(lang, rhyme, "raw")
return formatted_rhyme, cats, err
end
local function make_rhyme_link(lang, link_rhyme, display_rhyme)
local retval, cats
local prefix = "[[Rhymes:"
if rhymes_data.link_to_category_langs[lang:getCode()] then
prefix = "[[:Category:Rhymes:"
end
if not link_rhyme then
retval = concat{prefix, lang:getCanonicalName(), "|", lang:getCanonicalName(), "]]"}
cats = {}
else
local formatted_rhyme, err
formatted_rhyme, cats, err = tag_rhyme(display_rhyme or link_rhyme, lang)
retval = concat{prefix, lang:getCanonicalName(), "/", link_rhyme, "|", formatted_rhyme, "]]", err}
end
return retval, cats
end
--[==[
Implementation of {{tl|rhymes row}}.
]==]
function export.show_row(frame)
local args = require(parameters_module).process(
frame.getParent and frame:getParent().args or frame,
{
[1] = {required = true, type = "full language"},
[2] = {required = true},
[3] = {},
}
)
if not args[1] then
return "[[Rhymes:English/aɪmz|<span class=\"IPA\">-aɪmz</span>]]"
end
-- Discard cleanup categories from make_rhyme_link().
return (make_rhyme_link(args[1], args[2], "-" .. args[2])) .. (args[3] and (" (''" .. args[3] .. "'')") or "")
end
do
local function add_syllable_categories(categories, lang, rhyme, num_syl)
local prefix = "Rhymes:" .. lang .. "/" .. rhyme
insert(categories, prefix)
if num_syl then
for _, n in ipairs(num_syl) do
local c
if n > 1 then
c = prefix .. "/" .. n .. " syllables"
else
c = prefix .. "/1 syllable"
end
insert(categories, c)
end
end
end
--[==[
Meant to be called from a module. `data` is a table containing the following fields:
* `lang`: language object for the rhymes;
* `rhymes`: a list of rhymes, each described by an object which specifies the rhyme, optional number of syllables, and
optional left and right regular and accent qualifier fields:
** `rhyme`: the rhyme itself;
** `num_syl`: {nil} or a list of numbers, specifying the number of syllables of the word with this rhyme; optional and
currently used only for categorization; if omitted, defaults to the top-level `num_syl`;
** `separator`: {nil} or the string used to separate this rhyme from the preceding one when displayed; defaults to the
top-level `separator`;
** `q`: {nil} or a list of left regular qualifier strings, formatted using {format_qualifier()} in [[Module:qualifier]]
and displayed directly before the rhyme in question;
** `qq`: {nil} or a list of right regular qualifier strings, displayed directly after the rhyme in question;
** `qualifiers`: {nil} or a list of qualifier strings; also displayed on the left; for compatibility purposes only, do
not use in new code;
** `a`: {nil} or a list of left accent qualifier strings, formatted using {format_qualifiers()} in
[[Module:accent qualifier]] and displayed directly before the rhyme in question;
** `aa`: {nil} or a list of right accent qualifier strings, displayed directly after the rhyme in question;
** `refs`: {nil} or a list of references or reference specs to add directly after the rhyme; the value of a list item is
either a string containing the reference text (typically a call to a citation template such as {{tl|cite-book}}, or a
template wrapping such a call), or an object with fields `text` (the reference text), `name` (the name of the
reference, as in {{cd|<nowiki><ref name="foo">...</ref></nowiki>}} or {{cd|<nowiki><ref name="foo" /></nowiki>}})
and/or `group` (the group of the reference, as in {{cd|<nowiki><ref name="foo" group="bar">...</ref></nowiki>}} or
{{cd|<nowiki><ref name="foo" group="bar"/></nowiki>}}); this uses a parser function to format the reference
appropriately and insert a footnote number that hyperlinks to the actual reference, located in the
{{cd|<nowiki><references /></nowiki>}} section;
** `nocat`: if {true}, suppress categorization for this rhyme only;
* `num_syl`: {nil} or a list of numbers, specifying the number of syllables for all rhymes; optional and currently used
only for categorization; overridable at the individual rhyme level;
* `separator`: {nil} or a string, specifying the separator displayed before all rhymes but the first; by default,
{", "}; overridable at the individual rhyme level;
* `q`: {nil} or a list of left regular qualifier strings, formatted using {format_qualifier()} in [[Module:qualifier]]
and displayed before the initial caption;
* `qq`: {nil} or a list of right regular qualifier strings, displayed after all rhymes;
* `qualifiers`: {nil} or a list of left regular qualifier strings; for compatibility purposes only, do not use in new
code;
* `a`: {nil} or a list of left accent qualifier strings, formatted using {format_qualifiers()} in
[[Module:accent qualifier]] and dispalyed before the initial caption;
* `aa`: {nil} or a list of right accent qualifier strings, displayed after all rhymes;
* `sort`: {nil} or sort key;
* `caption`: {nil} or string specifying the caption to use, in place of {"Rhymes"}; a colon and space is automatically
added after the caption;
* `nocaption`: if {true}, suppress the caption display;
* `nocat`: if {true}, suppress categorization;
* `force_cat`: if {true}, force categorization even on non-mainspace pages.
If both regular and accent qualifiers on the same side and at the same level are specified, the accent qualifiers
precede the regular qualifiers on both left and right.
'''WARNING''': Destructively modifies the objects inside the `rhymes` field.
Note that the number of syllables is currently used only for categorization; if present, an extra category will
be added such as [[:Category:Rhymes:Italian/ino/3 syllables]] in addition to [[:Category:Rhymes:Italian/ino]].
]==]
function export.format_rhymes(data)
local langname = data.lang:getFullName()
local parts = {}
local categories = {}
local overall_sep = data.separator or ", "
for i, r in ipairs(data.rhymes) do
local rhyme = r.rhyme
local link, link_cats = make_rhyme_link(data.lang, rhyme, "-" .. rhyme)
if not r.nocat and not data.nocat then
for _, cat in ipairs(link_cats) do
insert(categories, cat)
end
end
if r.qualifiers then
track("old-qualifiers")
end
if r.q and r.q[1] or r.qq and r.qq[1] or r.qualifiers and r.qualifiers[1]
or r.a and r.a[1] or r.aa and r.aa[1] or r.refs and r.refs[1] then
link = require(pron_qualifier_module).format_qualifiers {
lang = data.lang,
text = link,
q = r.q,
qq = r.qq,
qualifiers = r.qualifiers,
a = r.a,
aa = r.aa,
refs = r.refs,
}
end
insert(parts, r.separator or i > 1 and overall_sep or "")
insert(parts, link)
if not r.nocat and not data.nocat then
add_syllable_categories(categories, langname, rhyme, r.num_syl or data.num_syl)
end
end
local text = concat(parts)
if not data.nocaption then
text = (data.caption or "Rhymes") .. ": " .. text
end
if data.q and data.q[1] or data.qq and data.qq[1] or data.a and data.a[1] or data.aa and data.aa[1] then
text = require(pron_qualifier_module).format_qualifiers {
lang = data.lang,
text = text,
q = data.q,
qq = data.qq,
a = data.a,
aa = data.aa,
}
end
if categories[1] then
local categories = require(utilities_module).format_categories(categories, data.lang, data.sort, nil,
force_cat or data.force_cat)
text = text .. categories
end
return text
end
end
--[==[
Implementation of {{tl|rhymes}}.
]==]
function export.show(frame)
local parent_args = frame:getParent().args
local compat = parent_args.lang
local offset = compat and 0 or 1
local lang_param = compat and "lang" or 1
local plain = {}
local boolean = {type = "boolean"}
local params = {
[lang_param] = {required = true, type = "language", default = "en"},
[1 + offset] = {list = true, required = true, disallow_holes = true, default = "aɪmz"},
["caption"] = plain,
["nocaption"] = boolean,
["nocat"] = boolean,
["sort"] = plain,
}
local m_param_utils = require(parameter_utilities_module)
local param_mods = m_param_utils.construct_param_mods {
{
param = "s",
item_dest = "num_syl",
separate_no_index = true,
type = "number",
sublist = true,
},
{group = {"q", "a", "ref"}},
}
local rhymes, args = m_param_utils.parse_list_with_inline_modifiers_and_separate_params {
params = params,
param_mods = param_mods,
raw_args = parent_args,
termarg = 1 + offset,
term_dest = "rhyme",
track_module = "rhymes",
}
local lang = args[lang_param]
local data = {
lang = lang,
rhymes = rhymes,
num_syl = args.s.default,
caption = args.caption,
nocaption = args.nocaption,
nocat = args.nocat,
sort = args.sort,
q = args.q.default,
qq = args.qq.default,
a = args.a.default,
aa = args.aa.default,
}
return export.format_rhymes(data)
end
--[==[
Implementation of {{tl|rhymes nav}}.
]==]
function export.show_nav(frame)
local args = require(parameters_module).process(
frame:getParent().args,
{
[1] = {required = true, type = "full language", default = "und"},
[2] = {list = true, allow_holes = true},
["nocat"] = {type = "boolean"},
}
)
local lang = args[1]
local langname = lang:getCanonicalName()
local parts = args[2]
-- Create steps
-- FIXME: We should probably use format_categories() in [[Module:utilities]] rather than constructing categories
-- manually.
local categories = {}
-- Here and below, we ignore any cleanup categories coming out of make_rhyme_link() by adding an extra set of parens
-- around the call to make_rhyme_link() to cause the second argument (the categories) to be ignored. {{rhymes nav}}
-- is run on a rhymes page so it's not clear we want the page to be added to any such categories, if they exist.
local steps = {"[[Wiktionary:Rhymes|Rhymes]]", (make_rhyme_link(lang))}
if #parts > 0 then
local last = parts[#parts]
parts[#parts] = nil
local prefix = ""
for i, part in ipairs(parts) do
prefix = prefix .. part
parts[i] = prefix
end
for _, part in ipairs(parts) do
insert(steps, (make_rhyme_link(lang, part .. "-", "-" .. part .. "-")))
end
if last == "-" then
insert(steps, (make_rhyme_link(lang, prefix, "-" .. prefix)))
insert(categories, "[[Category:" .. langname .. " rhymes" .. (prefix == "" and "" or "/" .. prefix .. "-") .. "| ]]")
elseif mw.title.getCurrentTitle().text == langname .. "/" .. prefix .. last .. "-" then -- DO NOT replace with mw.loadData("Module:headword/data").pagename as we need the root portion
insert(steps, (make_rhyme_link(lang, prefix .. last .. "-", "-" .. prefix .. last .. "-")))
insert(categories, "[[Category:" .. langname .. " rhymes/" .. prefix .. last .. "-|-]]")
else
insert(steps, (make_rhyme_link(lang, prefix .. last, "-" .. prefix .. last)))
insert(categories, "[[Category:" .. langname .. " rhymes" .. (prefix == "" and "" or "/" .. prefix .. "-") .. "|" .. last .. "]]")
end
elseif lang:getCode() ~= "und" then
insert(categories, "[[Category:" .. langname .. " rhymes| ]]")
end
if mw.title.getCurrentTitle().nsText == "Rhymes" then
frame:callParserFunction("DISPLAYTITLE",
mw.title.getCurrentTitle().fullText:gsub(
"/(.+)$",
function (rhyme)
return "/" .. (tag_rhyme(rhyme, lang)) -- ignore cleanup categories
end))
end
local templateStyles = require(TemplateStyles_module)(rhymes_styles_css_module)
local ol = mw.html.create("ol")
for _, step in ipairs(steps) do
ol:node(mw.html.create("li"):wikitext(step))
end
local div = mw.html.create("div")
:attr("role", "navigation")
:attr("aria-label", "Breadcrumb")
:addClass("ts-rhymesBreadcrumbs")
:node(ol)
local formatted_cats = args.nocat and "" or concat(categories)
return templateStyles .. tostring(div) .. formatted_cats
end
return export
moz1rihksrd50d0mckwibdl4r8ddfm0
89232
89231
2026-09-02T18:58:52Z
Altronic
4137
Chaidh 1 mhùthadh ion-phortachadh o [[:en:Module:rhymes]]
89231
Scribunto
text/plain
local export = {}
local force_cat = false -- for testing
local rhymes_styles_css_module = "Module:rhymes/styles.css"
local IPA_module = "Module:IPA"
local parameters_module = "Module:parameters"
local parameter_utilities_module = "Module:parameter utilities"
local pron_qualifier_module = "Module:pron qualifier"
local script_utilities_module = "Module:script utilities"
local string_utilities_module = "Module:string utilities"
local TemplateStyles_module = "Module:TemplateStyles"
local utilities_module = "Module:utilities"
local rhymes_data = require("Module:rhymes/data")
local concat = table.concat
local insert = table.insert
local function rsplit(text, pattern)
return require(string_utilities_module).split(text, pattern)
end
local function track(page)
require("Module:debug/track")("rhymes/" .. page)
return true
end
local function tag_rhyme(rhyme, lang)
local formatted_rhyme, cats, err
formatted_rhyme, cats, err = require(IPA_module).format_IPA(lang, rhyme, "raw")
return formatted_rhyme, cats, err
end
local function make_rhyme_link(lang, link_rhyme, display_rhyme)
local retval, cats
local prefix = "[[Rhymes:"
if rhymes_data.link_to_category_langs[lang:getCode()] then
prefix = "[[:Category:Rhymes:"
end
if not link_rhyme then
retval = concat{prefix, lang:getCanonicalName(), "|", lang:getCanonicalName(), "]]"}
cats = {}
else
local formatted_rhyme, err
formatted_rhyme, cats, err = tag_rhyme(display_rhyme or link_rhyme, lang)
retval = concat{prefix, lang:getCanonicalName(), "/", link_rhyme, "|", formatted_rhyme, "]]", err}
end
return retval, cats
end
--[==[
Implementation of {{tl|rhymes row}}.
]==]
function export.show_row(frame)
local args = require(parameters_module).process(
frame.getParent and frame:getParent().args or frame,
{
[1] = {required = true, type = "full language"},
[2] = {required = true},
[3] = {},
}
)
if not args[1] then
return "[[Rhymes:English/aɪmz|<span class=\"IPA\">-aɪmz</span>]]"
end
-- Discard cleanup categories from make_rhyme_link().
return (make_rhyme_link(args[1], args[2], "-" .. args[2])) .. (args[3] and (" (''" .. args[3] .. "'')") or "")
end
do
local function add_syllable_categories(categories, lang, rhyme, num_syl)
local prefix = "Rhymes:" .. lang .. "/" .. rhyme
insert(categories, prefix)
if num_syl then
for _, n in ipairs(num_syl) do
local c
if n > 1 then
c = prefix .. "/" .. n .. " syllables"
else
c = prefix .. "/1 syllable"
end
insert(categories, c)
end
end
end
--[==[
Meant to be called from a module. `data` is a table containing the following fields:
* `lang`: language object for the rhymes;
* `rhymes`: a list of rhymes, each described by an object which specifies the rhyme, optional number of syllables, and
optional left and right regular and accent qualifier fields:
** `rhyme`: the rhyme itself;
** `num_syl`: {nil} or a list of numbers, specifying the number of syllables of the word with this rhyme; optional and
currently used only for categorization; if omitted, defaults to the top-level `num_syl`;
** `separator`: {nil} or the string used to separate this rhyme from the preceding one when displayed; defaults to the
top-level `separator`;
** `q`: {nil} or a list of left regular qualifier strings, formatted using {format_qualifier()} in [[Module:qualifier]]
and displayed directly before the rhyme in question;
** `qq`: {nil} or a list of right regular qualifier strings, displayed directly after the rhyme in question;
** `qualifiers`: {nil} or a list of qualifier strings; also displayed on the left; for compatibility purposes only, do
not use in new code;
** `a`: {nil} or a list of left accent qualifier strings, formatted using {format_qualifiers()} in
[[Module:accent qualifier]] and displayed directly before the rhyme in question;
** `aa`: {nil} or a list of right accent qualifier strings, displayed directly after the rhyme in question;
** `refs`: {nil} or a list of references or reference specs to add directly after the rhyme; the value of a list item is
either a string containing the reference text (typically a call to a citation template such as {{tl|cite-book}}, or a
template wrapping such a call), or an object with fields `text` (the reference text), `name` (the name of the
reference, as in {{cd|<nowiki><ref name="foo">...</ref></nowiki>}} or {{cd|<nowiki><ref name="foo" /></nowiki>}})
and/or `group` (the group of the reference, as in {{cd|<nowiki><ref name="foo" group="bar">...</ref></nowiki>}} or
{{cd|<nowiki><ref name="foo" group="bar"/></nowiki>}}); this uses a parser function to format the reference
appropriately and insert a footnote number that hyperlinks to the actual reference, located in the
{{cd|<nowiki><references /></nowiki>}} section;
** `nocat`: if {true}, suppress categorization for this rhyme only;
* `num_syl`: {nil} or a list of numbers, specifying the number of syllables for all rhymes; optional and currently used
only for categorization; overridable at the individual rhyme level;
* `separator`: {nil} or a string, specifying the separator displayed before all rhymes but the first; by default,
{", "}; overridable at the individual rhyme level;
* `q`: {nil} or a list of left regular qualifier strings, formatted using {format_qualifier()} in [[Module:qualifier]]
and displayed before the initial caption;
* `qq`: {nil} or a list of right regular qualifier strings, displayed after all rhymes;
* `qualifiers`: {nil} or a list of left regular qualifier strings; for compatibility purposes only, do not use in new
code;
* `a`: {nil} or a list of left accent qualifier strings, formatted using {format_qualifiers()} in
[[Module:accent qualifier]] and dispalyed before the initial caption;
* `aa`: {nil} or a list of right accent qualifier strings, displayed after all rhymes;
* `sort`: {nil} or sort key;
* `caption`: {nil} or string specifying the caption to use, in place of {"Rhymes"}; a colon and space is automatically
added after the caption;
* `nocaption`: if {true}, suppress the caption display;
* `nocat`: if {true}, suppress categorization;
* `force_cat`: if {true}, force categorization even on non-mainspace pages.
If both regular and accent qualifiers on the same side and at the same level are specified, the accent qualifiers
precede the regular qualifiers on both left and right.
'''WARNING''': Destructively modifies the objects inside the `rhymes` field.
Note that the number of syllables is currently used only for categorization; if present, an extra category will
be added such as [[:Category:Rhymes:Italian/ino/3 syllables]] in addition to [[:Category:Rhymes:Italian/ino]].
]==]
function export.format_rhymes(data)
local langname = data.lang:getFullName()
local parts = {}
local categories = {}
local overall_sep = data.separator or ", "
for i, r in ipairs(data.rhymes) do
local rhyme = r.rhyme
local link, link_cats = make_rhyme_link(data.lang, rhyme, "-" .. rhyme)
if not r.nocat and not data.nocat then
for _, cat in ipairs(link_cats) do
insert(categories, cat)
end
end
if r.qualifiers then
track("old-qualifiers")
end
if r.q and r.q[1] or r.qq and r.qq[1] or r.qualifiers and r.qualifiers[1]
or r.a and r.a[1] or r.aa and r.aa[1] or r.refs and r.refs[1] then
link = require(pron_qualifier_module).format_qualifiers {
lang = data.lang,
text = link,
q = r.q,
qq = r.qq,
qualifiers = r.qualifiers,
a = r.a,
aa = r.aa,
refs = r.refs,
}
end
insert(parts, r.separator or i > 1 and overall_sep or "")
insert(parts, link)
if not r.nocat and not data.nocat then
add_syllable_categories(categories, langname, rhyme, r.num_syl or data.num_syl)
end
end
local text = concat(parts)
if not data.nocaption then
text = (data.caption or "Rhymes") .. ": " .. text
end
if data.q and data.q[1] or data.qq and data.qq[1] or data.a and data.a[1] or data.aa and data.aa[1] then
text = require(pron_qualifier_module).format_qualifiers {
lang = data.lang,
text = text,
q = data.q,
qq = data.qq,
a = data.a,
aa = data.aa,
}
end
if categories[1] then
local categories = require(utilities_module).format_categories(categories, data.lang, data.sort, nil,
force_cat or data.force_cat)
text = text .. categories
end
return text
end
end
--[==[
Implementation of {{tl|rhymes}}.
]==]
function export.show(frame)
local parent_args = frame:getParent().args
local compat = parent_args.lang
local offset = compat and 0 or 1
local lang_param = compat and "lang" or 1
local plain = {}
local boolean = {type = "boolean"}
local params = {
[lang_param] = {required = true, type = "language", default = "en"},
[1 + offset] = {list = true, required = true, disallow_holes = true, default = "aɪmz"},
["caption"] = plain,
["nocaption"] = boolean,
["nocat"] = boolean,
["sort"] = plain,
}
local m_param_utils = require(parameter_utilities_module)
local param_mods = m_param_utils.construct_param_mods {
{
param = "s",
item_dest = "num_syl",
separate_no_index = true,
type = "number",
sublist = true,
},
{group = {"q", "a", "ref"}},
}
local rhymes, args = m_param_utils.parse_list_with_inline_modifiers_and_separate_params {
params = params,
param_mods = param_mods,
raw_args = parent_args,
termarg = 1 + offset,
term_dest = "rhyme",
track_module = "rhymes",
}
local lang = args[lang_param]
local data = {
lang = lang,
rhymes = rhymes,
num_syl = args.s.default,
caption = args.caption,
nocaption = args.nocaption,
nocat = args.nocat,
sort = args.sort,
q = args.q.default,
qq = args.qq.default,
a = args.a.default,
aa = args.aa.default,
}
return export.format_rhymes(data)
end
--[==[
Implementation of {{tl|rhymes nav}}.
]==]
function export.show_nav(frame)
local args = require(parameters_module).process(
frame:getParent().args,
{
[1] = {required = true, type = "full language", default = "und"},
[2] = {list = true, allow_holes = true},
["nocat"] = {type = "boolean"},
}
)
local lang = args[1]
local langname = lang:getCanonicalName()
local parts = args[2]
-- Create steps
-- FIXME: We should probably use format_categories() in [[Module:utilities]] rather than constructing categories
-- manually.
local categories = {}
-- Here and below, we ignore any cleanup categories coming out of make_rhyme_link() by adding an extra set of parens
-- around the call to make_rhyme_link() to cause the second argument (the categories) to be ignored. {{rhymes nav}}
-- is run on a rhymes page so it's not clear we want the page to be added to any such categories, if they exist.
local steps = {"[[Wiktionary:Rhymes|Rhymes]]", (make_rhyme_link(lang))}
if #parts > 0 then
local last = parts[#parts]
parts[#parts] = nil
local prefix = ""
for i, part in ipairs(parts) do
prefix = prefix .. part
parts[i] = prefix
end
for _, part in ipairs(parts) do
insert(steps, (make_rhyme_link(lang, part .. "-", "-" .. part .. "-")))
end
if last == "-" then
insert(steps, (make_rhyme_link(lang, prefix, "-" .. prefix)))
insert(categories, "[[Category:" .. langname .. " rhymes" .. (prefix == "" and "" or "/" .. prefix .. "-") .. "| ]]")
elseif mw.title.getCurrentTitle().text == langname .. "/" .. prefix .. last .. "-" then -- DO NOT replace with mw.loadData("Module:headword/data").pagename as we need the root portion
insert(steps, (make_rhyme_link(lang, prefix .. last .. "-", "-" .. prefix .. last .. "-")))
insert(categories, "[[Category:" .. langname .. " rhymes/" .. prefix .. last .. "-|-]]")
else
insert(steps, (make_rhyme_link(lang, prefix .. last, "-" .. prefix .. last)))
insert(categories, "[[Category:" .. langname .. " rhymes" .. (prefix == "" and "" or "/" .. prefix .. "-") .. "|" .. last .. "]]")
end
elseif lang:getCode() ~= "und" then
insert(categories, "[[Category:" .. langname .. " rhymes| ]]")
end
if mw.title.getCurrentTitle().nsText == "Rhymes" then
frame:callParserFunction("DISPLAYTITLE",
mw.title.getCurrentTitle().fullText:gsub(
"/(.+)$",
function (rhyme)
return "/" .. (tag_rhyme(rhyme, lang)) -- ignore cleanup categories
end))
end
local templateStyles = require(TemplateStyles_module)(rhymes_styles_css_module)
local ol = mw.html.create("ol")
for _, step in ipairs(steps) do
ol:node(mw.html.create("li"):wikitext(step))
end
local div = mw.html.create("div")
:attr("role", "navigation")
:attr("aria-label", "Breadcrumb")
:addClass("ts-rhymesBreadcrumbs")
:node(ol)
local formatted_cats = args.nocat and "" or concat(categories)
return templateStyles .. tostring(div) .. formatted_cats
end
return export
moz1rihksrd50d0mckwibdl4r8ddfm0
Mòideal:rhymes/data
828
18106
89233
2026-08-13T18:02:10Z
en>Fenakhay
0
89233
Scribunto
text/plain
local export = {}
-- List of languages which do not have entries in the Rhymes
-- namespace and link to the automatic category instead.
export.link_to_category_langs = {
["izh"] = true,
["mt"] = true,
["sq"] = true,
}
return export
88uttm5ad5u5las70bryawduhf0aqug
89234
89233
2026-09-02T18:59:10Z
Altronic
4137
Chaidh 1 mhùthadh ion-phortachadh o [[:en:Module:rhymes/data]]
89233
Scribunto
text/plain
local export = {}
-- List of languages which do not have entries in the Rhymes
-- namespace and link to the automatic category instead.
export.link_to_category_langs = {
["izh"] = true,
["mt"] = true,
["sq"] = true,
}
return export
88uttm5ad5u5las70bryawduhf0aqug
Mòideal:etymon/doc
828
18107
89237
2026-08-01T16:57:38Z
en>Fenakhay
0
Use [[Module:inflection-table]] directly
89237
Scribunto
text/plain
local export = {}
local M = require("Module:module loader").init({
require = {
families = "Module:families",
languages = "Module:languages",
inflection_table = "Module:inflection-table",
},
loadData = {
text_allowed = "Module:etymon/data/text_allowed",
},
})
function export.text_allowed_list(frame)
local data = M.text_allowed or {}
local langs = data.langs or {}
local families = data.families or {}
local lang_codes = {}
for code in pairs(langs) do
table.insert(lang_codes, code)
end
table.sort(lang_codes)
local family_codes = {}
for code in pairs(families) do
table.insert(family_codes, code)
end
table.sort(family_codes)
local function wrap_lang_code(lang_code)
local lang_obj = M.languages.getByCode(lang_code, nil, true)
local lang_name = lang_obj and lang_obj.getCanonicalName and lang_obj:getCanonicalName()
if lang_name and lang_name ~= "" then
return "<code>" .. lang_code .. "</code> <small>(" .. lang_name .. ")</small>"
end
return "<code>" .. lang_code .. "</code>"
end
local function wrap_family_code(family_code)
local family_obj = M.families.getByCode(family_code)
if not family_obj then
return "<code>" .. family_code .. "</code>"
end
local family_name = family_obj.getCanonicalName and family_obj:getCanonicalName()
if family_name and family_name ~= "" then
return "<code>" .. family_code .. "</code> <small>(" .. family_name .. ")</small>"
end
return "<code>" .. family_code .. "</code>"
end
local function is_etymology_only_lang(lang_code)
local lang_obj = M.languages.getByCode(lang_code, nil, true)
return lang_obj and lang_obj.hasType and lang_obj:hasType("language", "etymology-only")
end
local function get_family_lang_codes(family_code)
local family_obj = M.families.getByCode(family_code)
if not family_obj or not family_obj.getDescendantCodes then
return nil
end
local codes = family_obj:getDescendantCodes()
if not codes or #codes == 0 then
return nil
end
table.sort(codes)
local out = {}
for _, lang_code in ipairs(codes) do
if not is_etymology_only_lang(lang_code) then
out[#out + 1] = lang_code
end
end
if #out == 0 then
return nil
end
return out
end
local wikicode = M.inflection_table.make_top{
title = "Text allowlist",
tall = "yes",
}
wikicode = wikicode .. "|-\n"
wikicode = wikicode .. '! Code / family !! Languages'
if #lang_codes > 0 then
local lang_parts = {}
for _, lang_code in ipairs(lang_codes) do
if not is_etymology_only_lang(lang_code) then
lang_parts[#lang_parts + 1] = wrap_lang_code(lang_code)
end
end
if #lang_parts > 0 then
wikicode = wikicode .. "\n|-\n"
wikicode = wikicode .. "| '''Language codes''' || " .. table.concat(lang_parts, ", ")
end
end
for _, family_code in ipairs(family_codes) do
local codes = get_family_lang_codes(family_code)
local lang_list
if codes and #codes > 0 then
local lang_parts = {}
for _, code in ipairs(codes) do
lang_parts[#lang_parts + 1] = wrap_lang_code(code)
end
lang_list = table.concat(lang_parts, ", ")
else
lang_list = ""
end
wikicode = wikicode .. "\n|-\n"
wikicode = wikicode .. "| " .. wrap_family_code(family_code) .. " || " .. lang_list
end
wikicode = wikicode .. "\n" .. M.inflection_table.make_bottom{}
wikicode = wikicode .. "\n\nThe list is maintained in [[Module:etymon/data/text_allowed]]. Mode is <code>" .. (data.default_mode or "off") .. "</code> (<code>off</code> = disabled, <code>warn</code> = warning only, <code>error</code> = enforce)."
return wikicode
end
return export
lbpjeun1f643cd4lm8957y719kfvmo0
89238
89237
2026-09-02T19:02:38Z
Altronic
4137
Chaidh 1 mhùthadh ion-phortachadh o [[:en:Module:etymon/doc]]
89237
Scribunto
text/plain
local export = {}
local M = require("Module:module loader").init({
require = {
families = "Module:families",
languages = "Module:languages",
inflection_table = "Module:inflection-table",
},
loadData = {
text_allowed = "Module:etymon/data/text_allowed",
},
})
function export.text_allowed_list(frame)
local data = M.text_allowed or {}
local langs = data.langs or {}
local families = data.families or {}
local lang_codes = {}
for code in pairs(langs) do
table.insert(lang_codes, code)
end
table.sort(lang_codes)
local family_codes = {}
for code in pairs(families) do
table.insert(family_codes, code)
end
table.sort(family_codes)
local function wrap_lang_code(lang_code)
local lang_obj = M.languages.getByCode(lang_code, nil, true)
local lang_name = lang_obj and lang_obj.getCanonicalName and lang_obj:getCanonicalName()
if lang_name and lang_name ~= "" then
return "<code>" .. lang_code .. "</code> <small>(" .. lang_name .. ")</small>"
end
return "<code>" .. lang_code .. "</code>"
end
local function wrap_family_code(family_code)
local family_obj = M.families.getByCode(family_code)
if not family_obj then
return "<code>" .. family_code .. "</code>"
end
local family_name = family_obj.getCanonicalName and family_obj:getCanonicalName()
if family_name and family_name ~= "" then
return "<code>" .. family_code .. "</code> <small>(" .. family_name .. ")</small>"
end
return "<code>" .. family_code .. "</code>"
end
local function is_etymology_only_lang(lang_code)
local lang_obj = M.languages.getByCode(lang_code, nil, true)
return lang_obj and lang_obj.hasType and lang_obj:hasType("language", "etymology-only")
end
local function get_family_lang_codes(family_code)
local family_obj = M.families.getByCode(family_code)
if not family_obj or not family_obj.getDescendantCodes then
return nil
end
local codes = family_obj:getDescendantCodes()
if not codes or #codes == 0 then
return nil
end
table.sort(codes)
local out = {}
for _, lang_code in ipairs(codes) do
if not is_etymology_only_lang(lang_code) then
out[#out + 1] = lang_code
end
end
if #out == 0 then
return nil
end
return out
end
local wikicode = M.inflection_table.make_top{
title = "Text allowlist",
tall = "yes",
}
wikicode = wikicode .. "|-\n"
wikicode = wikicode .. '! Code / family !! Languages'
if #lang_codes > 0 then
local lang_parts = {}
for _, lang_code in ipairs(lang_codes) do
if not is_etymology_only_lang(lang_code) then
lang_parts[#lang_parts + 1] = wrap_lang_code(lang_code)
end
end
if #lang_parts > 0 then
wikicode = wikicode .. "\n|-\n"
wikicode = wikicode .. "| '''Language codes''' || " .. table.concat(lang_parts, ", ")
end
end
for _, family_code in ipairs(family_codes) do
local codes = get_family_lang_codes(family_code)
local lang_list
if codes and #codes > 0 then
local lang_parts = {}
for _, code in ipairs(codes) do
lang_parts[#lang_parts + 1] = wrap_lang_code(code)
end
lang_list = table.concat(lang_parts, ", ")
else
lang_list = ""
end
wikicode = wikicode .. "\n|-\n"
wikicode = wikicode .. "| " .. wrap_family_code(family_code) .. " || " .. lang_list
end
wikicode = wikicode .. "\n" .. M.inflection_table.make_bottom{}
wikicode = wikicode .. "\n\nThe list is maintained in [[Module:etymon/data/text_allowed]]. Mode is <code>" .. (data.default_mode or "off") .. "</code> (<code>off</code> = disabled, <code>warn</code> = warning only, <code>error</code> = enforce)."
return wikicode
end
return export
lbpjeun1f643cd4lm8957y719kfvmo0
Mòideal:etymon/text
828
18108
89239
2026-06-04T22:45:48Z
en>Fenakhay
0
89239
Scribunto
text/plain
local export = {}
local loader = require("Module:module loader")
local M = loader.init({
require = {
en_utilities = "Module:en-utilities",
references = "Module:references",
senseno = "Module:senseno",
},
loadData = {
data = "Module:etymon/data",
},
})
function export.render(opts)
opts = opts or {}
local data_tree = opts.data_tree
local format_term_func = opts.format_term_func
local max_depth = opts.max_depth
local stop_at_blue_link = opts.stop_at_blue_link
local curr_page = opts.curr_page
local nodot = opts.nodot and opts.nodot ~= "" and opts.nodot ~= "0"
local function explicit_dot_override()
if opts.dot == nil or opts.dot == false then
return nil
end
local d = mw.text.trim(tostring(opts.dot))
if d == "" or d == "." then
return nil
end
return d
end
local dot_override = explicit_dot_override()
local function find_deepest_last_part(tree)
if not tree or not tree.container_parts or #tree.container_parts == 0 then
return nil
end
local last_part = tree.container_parts[#tree.container_parts]
if last_part.continuation then
return find_deepest_last_part(last_part.continuation)
end
return last_part
end
local function apply_final_punctuation_override(tree, punct)
if not tree or punct == nil then
return
end
local last_part = find_deepest_last_part(tree)
if last_part then
last_part.punctuation = punct
end
end
local function apply_closing_punctuation_override(tree, is_last_segment)
if not is_last_segment then
return
end
local punct
if nodot then
punct = ""
elseif dot_override ~= nil then
punct = dot_override
else
return
end
apply_final_punctuation_override(tree, punct)
end
local function has_supplements()
return data_tree.supplements and #data_tree.supplements > 0
end
local stop_at_lang = opts.stop_at_lang
local stop_at_lang_or_bluelink = opts.stop_at_lang_or_bluelink
local lang_matches_stop_code = opts.lang_matches_stop_code
local stop_lang_reached = false
local function term_matches_stop_code(term_lang, stop_code)
if lang_matches_stop_code then
return lang_matches_stop_code(term_lang, stop_code)
end
return term_lang and term_lang:getCode() == stop_code
end
local children = data_tree.children
local function has_text_supplements()
if not data_tree.supplements then
return false
end
for _, supplement in ipairs(data_tree.supplements) do
if supplement.type == "doublet" and supplement.terms and #supplement.terms > 0 then
return true
end
if supplement.type == "etydate" and supplement.etydate_text and supplement.etydate_text ~= "" then
return true
end
end
return false
end
if (not children or #children == 0) and not has_text_supplements() then
if stop_at_lang then
return "", { stop_lang_reached = false }
end
return ""
end
local top_l2 = data_tree.lang:getFullCode() .. ":" .. curr_page
local entry_lang = data_tree.lang
local function lowercase_glossary_link_display(wikitext)
return wikitext:gsub("(%[%[Appendix:Glossary#[^|]+|)([^%]])([^%]]*)%]%]", function(prefix, first, rest)
return prefix .. mw.ustring.lower(first) .. rest .. "]]"
end)
end
local function format_sl_senseid_intro(senseid, keyword_text, keyword_phrase, capitalize_senseno, is_uncertain)
local senseids = mw.text.split(senseid, "!!", true)
local senseno_parts = {}
for i, id in ipairs(senseids) do
id = mw.text.trim(id)
if id ~= "" then
table.insert(senseno_parts, M.senseno.link_text(entry_lang:getCode(), { id }, {
title = curr_page,
uc = (i == 1 and capitalize_senseno) or nil,
}))
end
end
if #senseno_parts == 0 then
return keyword_text, keyword_phrase
end
local senseno_text = mw.text.listToText(senseno_parts)
local glossary_link = (keyword_text or ""):gsub(" from$", "")
local glossary_lower = lowercase_glossary_link_display(glossary_link)
if #senseno_parts > 1 then
local plural_link = glossary_lower:gsub("|semantic loan%]%]", "|semantic loans]]")
if is_uncertain then
return senseno_text .. " are possibly " .. plural_link .. " from",
senseno_text .. " are possibly semantic loans from"
end
return senseno_text .. " are " .. plural_link .. " from",
senseno_text .. " are semantic loans from"
end
if is_uncertain then
return senseno_text .. " is possibly a " .. glossary_lower .. " from",
senseno_text .. " is possibly a semantic loan from"
end
return senseno_text .. " is a " .. glossary_lower .. " from",
senseno_text .. " is a semantic loan from"
end
-- Get refs for a term
local function get_term_refs(term, term_lang, depth)
local term_l2 = term_lang:getFullCode() .. ":" .. curr_page
if term.parsed_ref and (depth == 1 or term_l2 == top_l2) then
return M.references.format_references(term.parsed_ref)
end
return ""
end
-- Build a text part for a single term
local function build_term_part(term, current_lang, depth)
local text = ""
local new_lang = current_lang
local lang_changed = term.lang:getCanonicalName() ~= current_lang:getCanonicalName()
-- Use centralized format_term (handles suppress_term, unknown_term, and regular terms)
local term_text = format_term_func(term)
if lang_changed then
new_lang = term.lang
if term_text then
text = term.lang:makeWikipediaLink() .. " " .. term_text
elseif term.is_family then
text = M.en_utilities.add_indefinite_article(term.lang:makeWikipediaLink() .. " language", false)
else
-- suppress_term with language change: show only language
text = term.lang:makeWikipediaLink()
end
else
text = term_text or ""
end
return {
type = "term",
text = text,
refs = get_term_refs(term, new_lang, depth),
lang = new_lang,
is_uncertain = term.is_uncertain or false,
}
end
-- Build text parts for a container
local function build_container_part(container, node, depth, allow_continuation, fallback_to_bluelink)
local keyword_info = container.keyword_info
local keyword_modifiers = container.keyword_modifiers or {}
local terms = container.terms or {}
if not keyword_info or #terms == 0 then
return nil
end
-- Skip building text part when invisible in text ("all", "text", or true)
local inv = keyword_info.invisible
if inv == "all" or inv == true or inv == "text" then
return nil
end
local is_group = keyword_info.is_group
local keyword_uncertain = keyword_modifiers.unc or false
-- Determine text and phrase (allowing for overrides)
local intro_text = keyword_info.text
local phrase = keyword_info.phrase
local new_sentence = keyword_info.new_sentence or false
if keyword_modifiers.text then
-- User-provided override: assumed to be lowercase
phrase = keyword_modifiers.text
-- Auto-capitalize for intro text (e.g., "derived from" -> "Derived from")
intro_text = mw.ustring.upper(phrase:sub(1, 1)) .. phrase:sub(2)
end
-- Get keyword references
local keyword_refs = ""
if keyword_modifiers.ref then
local parsed_keyword_refs = M.references.parse_references(keyword_modifiers.ref)
if parsed_keyword_refs and parsed_keyword_refs ~= "" then
keyword_refs = M.references.format_references(parsed_keyword_refs)
end
end
-- Build term parts
local term_parts = {}
local current_lang = node.lang
for _, term in ipairs(terms) do
local term_part = build_term_part(term, current_lang, depth)
if term_part.text ~= "" then
table.insert(term_parts, term_part)
current_lang = term_part.lang
end
end
-- Check uncertainty distribution
local uncertain_count = 0
for _, term_part in ipairs(term_parts) do
if term_part.is_uncertain then
uncertain_count = uncertain_count + 1
end
end
-- If keyword itself is uncertain, treat all terms as uncertain
local all_uncertain = keyword_uncertain or (uncertain_count == #term_parts and #term_parts > 0)
if is_group and uncertain_count > 0 then
all_uncertain = true
end
local has_mixed_uncertainty = not all_uncertain and uncertain_count > 0
-- Check if there are more steps (only if continuation is allowed)
local has_more_steps = false
local next_node = nil
local first_term = terms[1]
-- Check if we should stop at this language
local reached_stop_lang = false
if stop_at_lang then
for _, term in ipairs(terms) do
if term.lang and term_matches_stop_code(term.lang, stop_at_lang) then
reached_stop_lang = true
stop_lang_reached = true
break
end
end
elseif stop_at_lang_or_bluelink then
-- Check if we should stop at this language, or at the first bluelink if it's a redlink
for _, term in ipairs(terms) do
if term.lang and term_matches_stop_code(term.lang, stop_at_lang_or_bluelink) then
if first_term.status == M.data.STATUS.OK then
reached_stop_lang = true
else
fallback_to_bluelink = true
end
break
end
end
if fallback_to_bluelink and first_term.status == M.data.STATUS.OK then
reached_stop_lang = true
end
end
if allow_continuation and not is_group and #terms == 1 and not reached_stop_lang then
local first_term_children = first_term.children
if first_term_children and #first_term_children > 0 and (not max_depth or depth < max_depth) then
local next_container = first_term_children[1]
local next_keyword_info = next_container and next_container.keyword_info
if not (next_keyword_info and next_keyword_info.invisible) then
if stop_at_blue_link then
if first_term.status ~= M.data.STATUS.OK then
has_more_steps = true
next_node = first_term
end
else
has_more_steps = true
next_node = first_term
end
end
end
end
return {
type = "container",
intro_text = intro_text,
phrase = phrase,
senseid = keyword_modifiers.senseid,
sl_keyword_text = keyword_modifiers.senseid and keyword_info.text or nil,
is_uncertain = all_uncertain,
has_mixed_uncertainty = has_mixed_uncertainty,
term_parts = term_parts,
is_group = is_group,
has_more_steps = has_more_steps,
next_node = next_node,
new_sentence = new_sentence,
separate_clause = keyword_info.separate_clause or false,
conj = keyword_modifiers.conj or keyword_info.default_conj, -- custom conjunction: "and", "or", "and/or", etc.
lit = keyword_modifiers.lit,
keyword_refs = keyword_refs,
fallback_to_bluelink = fallback_to_bluelink,
}
end
-- Build the full tree of text parts
local function build_text_tree(node, depth, allow_continuation, fallback_to_bluelink)
local containers = node.children
if not containers or #containers == 0 then
return nil
end
local container_parts = {}
-- Count containers that get a text part (invisible in text = "all", "text", or true)
local visible_container_count = 0
for _, container in ipairs(containers) do
local keyword_info = container.keyword_info
local inv = keyword_info and keyword_info.invisible
if not (inv == "all" or inv == true or inv == "text") then
visible_container_count = visible_container_count + 1
end
end
-- If there are multiple visible containers at this level, don't allow continuation for any
local has_multiple_containers = visible_container_count > 1
local should_allow_continuation = allow_continuation and not has_multiple_containers
for _, container in ipairs(containers) do
local part = build_container_part(container, node, depth, should_allow_continuation, fallback_to_bluelink)
if part then
-- Recursively build children if there are more steps
if part.has_more_steps and part.next_node then
part.continuation = build_text_tree(part.next_node, depth + 1, true, part.fallback_to_bluelink)
end
table.insert(container_parts, part)
end
end
if #container_parts == 0 then
return nil
end
return {
type = "tree",
container_parts = container_parts,
depth = depth,
}
end
-- Check if tree has mixed joining types
local function container_join_kind(part)
if part.type == "etydate" then
return nil
end
if part.new_sentence or part.separate_clause then
return "supplement"
end
return "or_join"
end
local function check_complexity(tree)
if not tree then return nil end
local parts = tree.container_parts
if #parts <= 1 then
-- Single container
if parts[1] and parts[1].continuation then
return check_complexity(parts[1].continuation)
end
return nil
end
-- Or-join containers must precede any supplemental (calque-like / influence) containers.
local seen_supplement = false
for _, part in ipairs(parts) do
local kind = container_join_kind(part)
if kind == "supplement" then
seen_supplement = true
elseif kind == "or_join" and seen_supplement then
error(
"Cannot generate etymology text: a main derivation step cannot follow a calque, semantic loan, or influence clause in the same list.")
end
end
for _, part in ipairs(parts) do
if part.continuation then
check_complexity(part.continuation)
end
end
return nil
end
-- Analyze tree and assign punctuation
local function analyze_punctuation(tree, is_toplevel)
if not tree then return end
local parts = tree.container_parts
local num_parts = #parts
for i, part in ipairs(parts) do
local is_first = (i == 1)
local is_last = (i == num_parts)
local next_part = parts[i + 1]
-- Analyze term punctuation within container
if part.term_parts then
-- Terms use Oxford comma style: "A, B, or C"
-- Custom conjunction can be specified via conj modifier (e.g., "and/or", "and")
local num_terms = #part.term_parts
local term_conj = part.conj or "or" -- default to "or"
for j, term_part in ipairs(part.term_parts) do
local is_last_term = (j == num_terms)
if part.is_group then
-- Group: terms joined with " + "
term_part.joiner = is_last_term and "" or " + "
elseif num_terms > 1 then
-- Multiple terms not in a group: Oxford comma style
if is_last_term then
term_part.joiner = ""
elseif j == num_terms - 1 then
-- Second to last term
if num_terms == 2 then
term_part.joiner = " " .. term_conj .. " "
else
term_part.joiner = ", " .. term_conj .. " "
end
else
term_part.joiner = ", "
end
else
-- Single term
term_part.joiner = ""
end
end
end
-- Determine container punctuation based on what comes next
if part.continuation then
-- Has continuation
part.punctuation = ","
-- Recursively analyze continuation
analyze_punctuation(part.continuation, false)
elseif is_last then
-- Last container at this level (may still continue in part.continuation)
part.punctuation = "."
elseif next_part and next_part.new_sentence then
-- Next container starts a new sentence
part.punctuation = "."
elseif next_part and next_part.separate_clause then
-- Next container is a separate clause
part.punctuation = ","
else
-- Not last, next is joined with "or"
-- Containers use repeated "or" style: "A, or B, or C"
part.punctuation = ","
end
-- Determine joiner to next part
-- Containers use repeated "or" style: ", or" between each
-- Custom conjunction can be specified via conj modifier
local container_conj = part.conj or "or" -- default to "or"
if not is_last then
if next_part and next_part.new_sentence then
-- New sentence
part.joiner = " "
elseif next_part and next_part.separate_clause then
-- Separate clause
part.joiner = " "
else
-- Same sentence: use custom conjunction or default "or"
part.joiner = " " .. container_conj .. " "
end
else
part.joiner = ""
end
-- Determine intro formatting
-- Capitalize if first at top level, OR if this container starts a new sentence
if (is_first and is_toplevel) or part.new_sentence then
part.intro_capitalized = true
part.use_full_intro = true
else
part.intro_capitalized = false
part.use_full_intro = false
end
end
end
-- Assemble text from analyzed tree
local function assemble_text(tree)
if not tree then return "" end
local result = ""
for i, part in ipairs(tree.container_parts) do
if part.type == "etydate" then
result = result .. part.etydate_text
if part.punctuation and part.punctuation ~= "" then
result = result .. part.punctuation
end
if part.etydate_refs and next(part.etydate_refs) then
result = result .. M.references.format_references(part.etydate_refs)
end
if part.joiner and part.joiner ~= "" then
result = result .. part.joiner
end
else
-- Build intro
local intro_text = part.intro_text
local phrase = part.phrase
if part.senseid then
intro_text, phrase = format_sl_senseid_intro(
part.senseid,
part.sl_keyword_text,
part.phrase,
part.intro_capitalized,
part.is_uncertain
)
end
local intro
if part.use_full_intro then
if part.is_uncertain and not part.senseid then
intro = "Possibly " .. phrase
else
intro = intro_text
end
else
if part.is_uncertain and not part.senseid then
intro = "possibly " .. phrase
else
intro = phrase
end
end
result = result .. intro
-- Build terms
if #part.term_parts > 0 then
result = result .. " "
for j, term_part in ipairs(part.term_parts) do
-- Add "possibly" prefix for uncertain terms when there's mixed uncertainty
if part.has_mixed_uncertainty and term_part.is_uncertain then
result = result .. "possibly "
end
result = result .. term_part.text
-- Add joiner between terms
if term_part.joiner ~= "" then
-- Check if joiner contains comma (punctuation)
local comma_pos = term_part.joiner:find(",")
if comma_pos then
-- Add up to and including comma
result = result .. term_part.joiner:sub(1, comma_pos)
-- Add refs after comma
if term_part.refs ~= "" then
result = result .. term_part.refs
end
-- Add rest of joiner
result = result .. term_part.joiner:sub(comma_pos + 1)
else
-- No comma, add refs before joiner
if term_part.refs ~= "" then
result = result .. term_part.refs
end
result = result .. term_part.joiner
end
end
end
-- For the last term, add punctuation then refs
local last_term = part.term_parts[#part.term_parts]
if last_term and last_term.joiner == "" then
if part.punctuation ~= "" then
-- If we have literal text, punctuation goes AFTER it
if part.lit then
-- Add refs first (attached to term)
if last_term.refs ~= "" then
result = result .. last_term.refs
end
-- Add keyword refs
if part.keyword_refs and part.keyword_refs ~= "" then
result = result .. part.keyword_refs
end
-- Add literal text
result = result .. ", literally “" .. part.lit .. "”"
-- Add punctuation
result = result .. part.punctuation
else
-- Normal behavior: punctuation then refs
result = result .. part.punctuation
if last_term.refs ~= "" then
result = result .. last_term.refs
end
-- Add keyword refs after term refs
if part.keyword_refs and part.keyword_refs ~= "" then
result = result .. part.keyword_refs
end
end
else
-- No punctuation
if last_term.refs ~= "" then
result = result .. last_term.refs
end
-- Add keyword refs
if part.keyword_refs and part.keyword_refs ~= "" then
result = result .. part.keyword_refs
end
-- Add literal text if present (even without punctuation)
if part.lit then
result = result .. ", literally “" .. part.lit .. "”"
end
end
end
else
-- No terms, just add punctuation and keyword refs
if part.punctuation ~= "" then
result = result .. part.punctuation
end
-- Add keyword refs even when there are no terms
if part.keyword_refs and part.keyword_refs ~= "" then
result = result .. part.keyword_refs
end
end
-- Add continuation
if part.continuation then
result = result .. " " .. assemble_text(part.continuation)
end
-- Add joiner to next container
if part.joiner ~= "" then
result = result .. part.joiner
end
end
end
return result
end
local text_tree = build_text_tree(data_tree, 1, true, false)
-- Supplements (doublets, etydate, …) are rendered outside the main derivation text tree.
local function assemble_supplements()
if not data_tree.supplements then
return ""
end
local chunks = {}
local pending_trees = {}
local function flush_pending_trees()
local num = #pending_trees
for i, supplement_tree in ipairs(pending_trees) do
analyze_punctuation(supplement_tree, true)
apply_closing_punctuation_override(supplement_tree, i == num)
local chunk = assemble_text(supplement_tree)
if chunk ~= "" then
table.insert(chunks, chunk)
end
end
pending_trees = {}
end
for _, supplement in ipairs(data_tree.supplements) do
local supplement_tree
if supplement.type == "doublet" and supplement.config
and supplement.terms and #supplement.terms > 0 then
local config = supplement.config
local term_parts = {}
for _, term in ipairs(supplement.terms) do
local term_part = build_term_part(term, entry_lang, 1)
if term_part.text ~= "" then
table.insert(term_parts, term_part)
end
end
if #term_parts == 0 then
supplement_tree = nil
else
supplement_tree = {
type = "tree",
container_parts = {
{
type = "doublet",
intro_text = config.text,
phrase = config.phrase,
term_parts = term_parts,
conj = config.default_conj or "and",
new_sentence = true,
},
},
depth = 1,
}
end
elseif supplement.type == "etydate" and supplement.etydate_text and supplement.etydate_text ~= "" then
supplement_tree = {
type = "tree",
container_parts = {
{
type = "etydate",
etydate_text = supplement.etydate_text,
etydate_refs = supplement.etydate_refs,
new_sentence = true,
},
},
depth = 1,
}
end
if supplement_tree then
table.insert(pending_trees, supplement_tree)
end
end
flush_pending_trees()
return table.concat(chunks, " ")
end
if not text_tree then
local supplement_text = assemble_supplements()
if supplement_text == "" then
if stop_at_lang then
return "", { stop_lang_reached = false }
end
return ""
end
if stop_at_lang then
return supplement_text, { stop_lang_reached = false }
end
return supplement_text
end
local rendered = ""
if text_tree then
check_complexity(text_tree)
analyze_punctuation(text_tree, true)
apply_closing_punctuation_override(text_tree, not has_supplements())
rendered = assemble_text(text_tree)
end
local supplement_text = assemble_supplements()
if supplement_text ~= "" then
if rendered ~= "" then
rendered = rendered .. " " .. supplement_text
else
rendered = supplement_text
end
end
if stop_at_lang then
return rendered, { stop_lang_reached = stop_lang_reached }
end
return rendered
end
return export
qx68l3q4qraak3orfbdf9tqhd7hl5em
89240
89239
2026-09-02T19:03:04Z
Altronic
4137
Chaidh 1 mhùthadh ion-phortachadh o [[:en:Module:etymon/text]]
89239
Scribunto
text/plain
local export = {}
local loader = require("Module:module loader")
local M = loader.init({
require = {
en_utilities = "Module:en-utilities",
references = "Module:references",
senseno = "Module:senseno",
},
loadData = {
data = "Module:etymon/data",
},
})
function export.render(opts)
opts = opts or {}
local data_tree = opts.data_tree
local format_term_func = opts.format_term_func
local max_depth = opts.max_depth
local stop_at_blue_link = opts.stop_at_blue_link
local curr_page = opts.curr_page
local nodot = opts.nodot and opts.nodot ~= "" and opts.nodot ~= "0"
local function explicit_dot_override()
if opts.dot == nil or opts.dot == false then
return nil
end
local d = mw.text.trim(tostring(opts.dot))
if d == "" or d == "." then
return nil
end
return d
end
local dot_override = explicit_dot_override()
local function find_deepest_last_part(tree)
if not tree or not tree.container_parts or #tree.container_parts == 0 then
return nil
end
local last_part = tree.container_parts[#tree.container_parts]
if last_part.continuation then
return find_deepest_last_part(last_part.continuation)
end
return last_part
end
local function apply_final_punctuation_override(tree, punct)
if not tree or punct == nil then
return
end
local last_part = find_deepest_last_part(tree)
if last_part then
last_part.punctuation = punct
end
end
local function apply_closing_punctuation_override(tree, is_last_segment)
if not is_last_segment then
return
end
local punct
if nodot then
punct = ""
elseif dot_override ~= nil then
punct = dot_override
else
return
end
apply_final_punctuation_override(tree, punct)
end
local function has_supplements()
return data_tree.supplements and #data_tree.supplements > 0
end
local stop_at_lang = opts.stop_at_lang
local stop_at_lang_or_bluelink = opts.stop_at_lang_or_bluelink
local lang_matches_stop_code = opts.lang_matches_stop_code
local stop_lang_reached = false
local function term_matches_stop_code(term_lang, stop_code)
if lang_matches_stop_code then
return lang_matches_stop_code(term_lang, stop_code)
end
return term_lang and term_lang:getCode() == stop_code
end
local children = data_tree.children
local function has_text_supplements()
if not data_tree.supplements then
return false
end
for _, supplement in ipairs(data_tree.supplements) do
if supplement.type == "doublet" and supplement.terms and #supplement.terms > 0 then
return true
end
if supplement.type == "etydate" and supplement.etydate_text and supplement.etydate_text ~= "" then
return true
end
end
return false
end
if (not children or #children == 0) and not has_text_supplements() then
if stop_at_lang then
return "", { stop_lang_reached = false }
end
return ""
end
local top_l2 = data_tree.lang:getFullCode() .. ":" .. curr_page
local entry_lang = data_tree.lang
local function lowercase_glossary_link_display(wikitext)
return wikitext:gsub("(%[%[Appendix:Glossary#[^|]+|)([^%]])([^%]]*)%]%]", function(prefix, first, rest)
return prefix .. mw.ustring.lower(first) .. rest .. "]]"
end)
end
local function format_sl_senseid_intro(senseid, keyword_text, keyword_phrase, capitalize_senseno, is_uncertain)
local senseids = mw.text.split(senseid, "!!", true)
local senseno_parts = {}
for i, id in ipairs(senseids) do
id = mw.text.trim(id)
if id ~= "" then
table.insert(senseno_parts, M.senseno.link_text(entry_lang:getCode(), { id }, {
title = curr_page,
uc = (i == 1 and capitalize_senseno) or nil,
}))
end
end
if #senseno_parts == 0 then
return keyword_text, keyword_phrase
end
local senseno_text = mw.text.listToText(senseno_parts)
local glossary_link = (keyword_text or ""):gsub(" from$", "")
local glossary_lower = lowercase_glossary_link_display(glossary_link)
if #senseno_parts > 1 then
local plural_link = glossary_lower:gsub("|semantic loan%]%]", "|semantic loans]]")
if is_uncertain then
return senseno_text .. " are possibly " .. plural_link .. " from",
senseno_text .. " are possibly semantic loans from"
end
return senseno_text .. " are " .. plural_link .. " from",
senseno_text .. " are semantic loans from"
end
if is_uncertain then
return senseno_text .. " is possibly a " .. glossary_lower .. " from",
senseno_text .. " is possibly a semantic loan from"
end
return senseno_text .. " is a " .. glossary_lower .. " from",
senseno_text .. " is a semantic loan from"
end
-- Get refs for a term
local function get_term_refs(term, term_lang, depth)
local term_l2 = term_lang:getFullCode() .. ":" .. curr_page
if term.parsed_ref and (depth == 1 or term_l2 == top_l2) then
return M.references.format_references(term.parsed_ref)
end
return ""
end
-- Build a text part for a single term
local function build_term_part(term, current_lang, depth)
local text = ""
local new_lang = current_lang
local lang_changed = term.lang:getCanonicalName() ~= current_lang:getCanonicalName()
-- Use centralized format_term (handles suppress_term, unknown_term, and regular terms)
local term_text = format_term_func(term)
if lang_changed then
new_lang = term.lang
if term_text then
text = term.lang:makeWikipediaLink() .. " " .. term_text
elseif term.is_family then
text = M.en_utilities.add_indefinite_article(term.lang:makeWikipediaLink() .. " language", false)
else
-- suppress_term with language change: show only language
text = term.lang:makeWikipediaLink()
end
else
text = term_text or ""
end
return {
type = "term",
text = text,
refs = get_term_refs(term, new_lang, depth),
lang = new_lang,
is_uncertain = term.is_uncertain or false,
}
end
-- Build text parts for a container
local function build_container_part(container, node, depth, allow_continuation, fallback_to_bluelink)
local keyword_info = container.keyword_info
local keyword_modifiers = container.keyword_modifiers or {}
local terms = container.terms or {}
if not keyword_info or #terms == 0 then
return nil
end
-- Skip building text part when invisible in text ("all", "text", or true)
local inv = keyword_info.invisible
if inv == "all" or inv == true or inv == "text" then
return nil
end
local is_group = keyword_info.is_group
local keyword_uncertain = keyword_modifiers.unc or false
-- Determine text and phrase (allowing for overrides)
local intro_text = keyword_info.text
local phrase = keyword_info.phrase
local new_sentence = keyword_info.new_sentence or false
if keyword_modifiers.text then
-- User-provided override: assumed to be lowercase
phrase = keyword_modifiers.text
-- Auto-capitalize for intro text (e.g., "derived from" -> "Derived from")
intro_text = mw.ustring.upper(phrase:sub(1, 1)) .. phrase:sub(2)
end
-- Get keyword references
local keyword_refs = ""
if keyword_modifiers.ref then
local parsed_keyword_refs = M.references.parse_references(keyword_modifiers.ref)
if parsed_keyword_refs and parsed_keyword_refs ~= "" then
keyword_refs = M.references.format_references(parsed_keyword_refs)
end
end
-- Build term parts
local term_parts = {}
local current_lang = node.lang
for _, term in ipairs(terms) do
local term_part = build_term_part(term, current_lang, depth)
if term_part.text ~= "" then
table.insert(term_parts, term_part)
current_lang = term_part.lang
end
end
-- Check uncertainty distribution
local uncertain_count = 0
for _, term_part in ipairs(term_parts) do
if term_part.is_uncertain then
uncertain_count = uncertain_count + 1
end
end
-- If keyword itself is uncertain, treat all terms as uncertain
local all_uncertain = keyword_uncertain or (uncertain_count == #term_parts and #term_parts > 0)
if is_group and uncertain_count > 0 then
all_uncertain = true
end
local has_mixed_uncertainty = not all_uncertain and uncertain_count > 0
-- Check if there are more steps (only if continuation is allowed)
local has_more_steps = false
local next_node = nil
local first_term = terms[1]
-- Check if we should stop at this language
local reached_stop_lang = false
if stop_at_lang then
for _, term in ipairs(terms) do
if term.lang and term_matches_stop_code(term.lang, stop_at_lang) then
reached_stop_lang = true
stop_lang_reached = true
break
end
end
elseif stop_at_lang_or_bluelink then
-- Check if we should stop at this language, or at the first bluelink if it's a redlink
for _, term in ipairs(terms) do
if term.lang and term_matches_stop_code(term.lang, stop_at_lang_or_bluelink) then
if first_term.status == M.data.STATUS.OK then
reached_stop_lang = true
else
fallback_to_bluelink = true
end
break
end
end
if fallback_to_bluelink and first_term.status == M.data.STATUS.OK then
reached_stop_lang = true
end
end
if allow_continuation and not is_group and #terms == 1 and not reached_stop_lang then
local first_term_children = first_term.children
if first_term_children and #first_term_children > 0 and (not max_depth or depth < max_depth) then
local next_container = first_term_children[1]
local next_keyword_info = next_container and next_container.keyword_info
if not (next_keyword_info and next_keyword_info.invisible) then
if stop_at_blue_link then
if first_term.status ~= M.data.STATUS.OK then
has_more_steps = true
next_node = first_term
end
else
has_more_steps = true
next_node = first_term
end
end
end
end
return {
type = "container",
intro_text = intro_text,
phrase = phrase,
senseid = keyword_modifiers.senseid,
sl_keyword_text = keyword_modifiers.senseid and keyword_info.text or nil,
is_uncertain = all_uncertain,
has_mixed_uncertainty = has_mixed_uncertainty,
term_parts = term_parts,
is_group = is_group,
has_more_steps = has_more_steps,
next_node = next_node,
new_sentence = new_sentence,
separate_clause = keyword_info.separate_clause or false,
conj = keyword_modifiers.conj or keyword_info.default_conj, -- custom conjunction: "and", "or", "and/or", etc.
lit = keyword_modifiers.lit,
keyword_refs = keyword_refs,
fallback_to_bluelink = fallback_to_bluelink,
}
end
-- Build the full tree of text parts
local function build_text_tree(node, depth, allow_continuation, fallback_to_bluelink)
local containers = node.children
if not containers or #containers == 0 then
return nil
end
local container_parts = {}
-- Count containers that get a text part (invisible in text = "all", "text", or true)
local visible_container_count = 0
for _, container in ipairs(containers) do
local keyword_info = container.keyword_info
local inv = keyword_info and keyword_info.invisible
if not (inv == "all" or inv == true or inv == "text") then
visible_container_count = visible_container_count + 1
end
end
-- If there are multiple visible containers at this level, don't allow continuation for any
local has_multiple_containers = visible_container_count > 1
local should_allow_continuation = allow_continuation and not has_multiple_containers
for _, container in ipairs(containers) do
local part = build_container_part(container, node, depth, should_allow_continuation, fallback_to_bluelink)
if part then
-- Recursively build children if there are more steps
if part.has_more_steps and part.next_node then
part.continuation = build_text_tree(part.next_node, depth + 1, true, part.fallback_to_bluelink)
end
table.insert(container_parts, part)
end
end
if #container_parts == 0 then
return nil
end
return {
type = "tree",
container_parts = container_parts,
depth = depth,
}
end
-- Check if tree has mixed joining types
local function container_join_kind(part)
if part.type == "etydate" then
return nil
end
if part.new_sentence or part.separate_clause then
return "supplement"
end
return "or_join"
end
local function check_complexity(tree)
if not tree then return nil end
local parts = tree.container_parts
if #parts <= 1 then
-- Single container
if parts[1] and parts[1].continuation then
return check_complexity(parts[1].continuation)
end
return nil
end
-- Or-join containers must precede any supplemental (calque-like / influence) containers.
local seen_supplement = false
for _, part in ipairs(parts) do
local kind = container_join_kind(part)
if kind == "supplement" then
seen_supplement = true
elseif kind == "or_join" and seen_supplement then
error(
"Cannot generate etymology text: a main derivation step cannot follow a calque, semantic loan, or influence clause in the same list.")
end
end
for _, part in ipairs(parts) do
if part.continuation then
check_complexity(part.continuation)
end
end
return nil
end
-- Analyze tree and assign punctuation
local function analyze_punctuation(tree, is_toplevel)
if not tree then return end
local parts = tree.container_parts
local num_parts = #parts
for i, part in ipairs(parts) do
local is_first = (i == 1)
local is_last = (i == num_parts)
local next_part = parts[i + 1]
-- Analyze term punctuation within container
if part.term_parts then
-- Terms use Oxford comma style: "A, B, or C"
-- Custom conjunction can be specified via conj modifier (e.g., "and/or", "and")
local num_terms = #part.term_parts
local term_conj = part.conj or "or" -- default to "or"
for j, term_part in ipairs(part.term_parts) do
local is_last_term = (j == num_terms)
if part.is_group then
-- Group: terms joined with " + "
term_part.joiner = is_last_term and "" or " + "
elseif num_terms > 1 then
-- Multiple terms not in a group: Oxford comma style
if is_last_term then
term_part.joiner = ""
elseif j == num_terms - 1 then
-- Second to last term
if num_terms == 2 then
term_part.joiner = " " .. term_conj .. " "
else
term_part.joiner = ", " .. term_conj .. " "
end
else
term_part.joiner = ", "
end
else
-- Single term
term_part.joiner = ""
end
end
end
-- Determine container punctuation based on what comes next
if part.continuation then
-- Has continuation
part.punctuation = ","
-- Recursively analyze continuation
analyze_punctuation(part.continuation, false)
elseif is_last then
-- Last container at this level (may still continue in part.continuation)
part.punctuation = "."
elseif next_part and next_part.new_sentence then
-- Next container starts a new sentence
part.punctuation = "."
elseif next_part and next_part.separate_clause then
-- Next container is a separate clause
part.punctuation = ","
else
-- Not last, next is joined with "or"
-- Containers use repeated "or" style: "A, or B, or C"
part.punctuation = ","
end
-- Determine joiner to next part
-- Containers use repeated "or" style: ", or" between each
-- Custom conjunction can be specified via conj modifier
local container_conj = part.conj or "or" -- default to "or"
if not is_last then
if next_part and next_part.new_sentence then
-- New sentence
part.joiner = " "
elseif next_part and next_part.separate_clause then
-- Separate clause
part.joiner = " "
else
-- Same sentence: use custom conjunction or default "or"
part.joiner = " " .. container_conj .. " "
end
else
part.joiner = ""
end
-- Determine intro formatting
-- Capitalize if first at top level, OR if this container starts a new sentence
if (is_first and is_toplevel) or part.new_sentence then
part.intro_capitalized = true
part.use_full_intro = true
else
part.intro_capitalized = false
part.use_full_intro = false
end
end
end
-- Assemble text from analyzed tree
local function assemble_text(tree)
if not tree then return "" end
local result = ""
for i, part in ipairs(tree.container_parts) do
if part.type == "etydate" then
result = result .. part.etydate_text
if part.punctuation and part.punctuation ~= "" then
result = result .. part.punctuation
end
if part.etydate_refs and next(part.etydate_refs) then
result = result .. M.references.format_references(part.etydate_refs)
end
if part.joiner and part.joiner ~= "" then
result = result .. part.joiner
end
else
-- Build intro
local intro_text = part.intro_text
local phrase = part.phrase
if part.senseid then
intro_text, phrase = format_sl_senseid_intro(
part.senseid,
part.sl_keyword_text,
part.phrase,
part.intro_capitalized,
part.is_uncertain
)
end
local intro
if part.use_full_intro then
if part.is_uncertain and not part.senseid then
intro = "Possibly " .. phrase
else
intro = intro_text
end
else
if part.is_uncertain and not part.senseid then
intro = "possibly " .. phrase
else
intro = phrase
end
end
result = result .. intro
-- Build terms
if #part.term_parts > 0 then
result = result .. " "
for j, term_part in ipairs(part.term_parts) do
-- Add "possibly" prefix for uncertain terms when there's mixed uncertainty
if part.has_mixed_uncertainty and term_part.is_uncertain then
result = result .. "possibly "
end
result = result .. term_part.text
-- Add joiner between terms
if term_part.joiner ~= "" then
-- Check if joiner contains comma (punctuation)
local comma_pos = term_part.joiner:find(",")
if comma_pos then
-- Add up to and including comma
result = result .. term_part.joiner:sub(1, comma_pos)
-- Add refs after comma
if term_part.refs ~= "" then
result = result .. term_part.refs
end
-- Add rest of joiner
result = result .. term_part.joiner:sub(comma_pos + 1)
else
-- No comma, add refs before joiner
if term_part.refs ~= "" then
result = result .. term_part.refs
end
result = result .. term_part.joiner
end
end
end
-- For the last term, add punctuation then refs
local last_term = part.term_parts[#part.term_parts]
if last_term and last_term.joiner == "" then
if part.punctuation ~= "" then
-- If we have literal text, punctuation goes AFTER it
if part.lit then
-- Add refs first (attached to term)
if last_term.refs ~= "" then
result = result .. last_term.refs
end
-- Add keyword refs
if part.keyword_refs and part.keyword_refs ~= "" then
result = result .. part.keyword_refs
end
-- Add literal text
result = result .. ", literally “" .. part.lit .. "”"
-- Add punctuation
result = result .. part.punctuation
else
-- Normal behavior: punctuation then refs
result = result .. part.punctuation
if last_term.refs ~= "" then
result = result .. last_term.refs
end
-- Add keyword refs after term refs
if part.keyword_refs and part.keyword_refs ~= "" then
result = result .. part.keyword_refs
end
end
else
-- No punctuation
if last_term.refs ~= "" then
result = result .. last_term.refs
end
-- Add keyword refs
if part.keyword_refs and part.keyword_refs ~= "" then
result = result .. part.keyword_refs
end
-- Add literal text if present (even without punctuation)
if part.lit then
result = result .. ", literally “" .. part.lit .. "”"
end
end
end
else
-- No terms, just add punctuation and keyword refs
if part.punctuation ~= "" then
result = result .. part.punctuation
end
-- Add keyword refs even when there are no terms
if part.keyword_refs and part.keyword_refs ~= "" then
result = result .. part.keyword_refs
end
end
-- Add continuation
if part.continuation then
result = result .. " " .. assemble_text(part.continuation)
end
-- Add joiner to next container
if part.joiner ~= "" then
result = result .. part.joiner
end
end
end
return result
end
local text_tree = build_text_tree(data_tree, 1, true, false)
-- Supplements (doublets, etydate, …) are rendered outside the main derivation text tree.
local function assemble_supplements()
if not data_tree.supplements then
return ""
end
local chunks = {}
local pending_trees = {}
local function flush_pending_trees()
local num = #pending_trees
for i, supplement_tree in ipairs(pending_trees) do
analyze_punctuation(supplement_tree, true)
apply_closing_punctuation_override(supplement_tree, i == num)
local chunk = assemble_text(supplement_tree)
if chunk ~= "" then
table.insert(chunks, chunk)
end
end
pending_trees = {}
end
for _, supplement in ipairs(data_tree.supplements) do
local supplement_tree
if supplement.type == "doublet" and supplement.config
and supplement.terms and #supplement.terms > 0 then
local config = supplement.config
local term_parts = {}
for _, term in ipairs(supplement.terms) do
local term_part = build_term_part(term, entry_lang, 1)
if term_part.text ~= "" then
table.insert(term_parts, term_part)
end
end
if #term_parts == 0 then
supplement_tree = nil
else
supplement_tree = {
type = "tree",
container_parts = {
{
type = "doublet",
intro_text = config.text,
phrase = config.phrase,
term_parts = term_parts,
conj = config.default_conj or "and",
new_sentence = true,
},
},
depth = 1,
}
end
elseif supplement.type == "etydate" and supplement.etydate_text and supplement.etydate_text ~= "" then
supplement_tree = {
type = "tree",
container_parts = {
{
type = "etydate",
etydate_text = supplement.etydate_text,
etydate_refs = supplement.etydate_refs,
new_sentence = true,
},
},
depth = 1,
}
end
if supplement_tree then
table.insert(pending_trees, supplement_tree)
end
end
flush_pending_trees()
return table.concat(chunks, " ")
end
if not text_tree then
local supplement_text = assemble_supplements()
if supplement_text == "" then
if stop_at_lang then
return "", { stop_lang_reached = false }
end
return ""
end
if stop_at_lang then
return supplement_text, { stop_lang_reached = false }
end
return supplement_text
end
local rendered = ""
if text_tree then
check_complexity(text_tree)
analyze_punctuation(text_tree, true)
apply_closing_punctuation_override(text_tree, not has_supplements())
rendered = assemble_text(text_tree)
end
local supplement_text = assemble_supplements()
if supplement_text ~= "" then
if rendered ~= "" then
rendered = rendered .. " " .. supplement_text
else
rendered = supplement_text
end
end
if stop_at_lang then
return rendered, { stop_lang_reached = stop_lang_reached }
end
return rendered
end
return export
qx68l3q4qraak3orfbdf9tqhd7hl5em
Mòideal:etymon/tree
828
18109
89241
2026-06-04T21:38:09Z
en>Fenakhay
0
Changed protection settings for "[[Module:etymon/tree]]" ([Edit=Allow only template editors and administrators] (indefinite) [Move=Allow only template editors and administrators] (indefinite))
89241
Scribunto
text/plain
local export = {}
local html_create = mw.html.create
local max = math.max
local function create_vertical_connector()
return html_create('span'):addClass('etytree-connector-vertical')
end
local function create_abbr(text, title, glossary)
local abbr = html_create('abbr')
:attr('title', title)
:wikitext(text)
if glossary then
abbr = '[[Appendix:Glossary#' .. glossary .. '|' .. tostring(abbr) .. ']]'
end
return html_create('span'):addClass('etytree-label'):node(abbr)
end
local function create_uncertainty_marker()
return html_create('abbr')
:addClass('etytree-unc')
:attr('title', 'uncertain')
:wikitext('?')
end
local function create_label_container()
return html_create('span'):addClass('etytree-label-container')
end
local function invisible_in_tree(inv)
return inv == "all" or inv == true or inv == "tree"
end
local function render_label(term_block, keyword_info, keyword_modifiers, is_uncertain, is_group_child, term_labels)
-- Skip label when invisible in tree
local has_label = keyword_info and keyword_info.abbrev and not is_group_child and not invisible_in_tree(keyword_info.invisible)
-- For group children, keyword uncertainty is shown on the group label, not on individual terms
local keyword_uncertain = keyword_modifiers and keyword_modifiers.unc and not is_group_child
local show_term_uncertainty = is_uncertain
-- Check if we have term-specific labels
local has_term_labels = term_labels and #term_labels > 0
if not has_label and not show_term_uncertainty and not keyword_uncertain and not has_term_labels then
return
end
local label_span = create_label_container()
if has_label then
local glossary_title = keyword_info.glossary
and keyword_info.glossary:gsub("_", " ")
or keyword_info.abbrev
label_span:node(create_abbr(
keyword_info.abbrev,
glossary_title,
keyword_info.glossary
))
-- Show uncertainty marker if term or keyword is uncertain
if show_term_uncertainty or keyword_uncertain then
label_span:node(create_uncertainty_marker())
end
else
-- No label, but term or keyword is uncertain
if show_term_uncertainty or keyword_uncertain then
label_span:node(create_uncertainty_marker())
end
end
-- Add term-specific labels
if has_term_labels then
for _, label_info in ipairs(term_labels) do
label_span:node(create_abbr(label_info.abbrev, label_info.title, label_info.glossary))
end
end
term_block:node(label_span)
end
local function render_term_block(node_data, format_term_func, is_toplevel)
local link_content = html_create()
link_content
:tag('span')
:addClass('etyl')
:wikitext(node_data.lang:getCanonicalName())
:done()
local term_text = format_term_func(node_data, is_toplevel)
if term_text then
link_content
:wikitext(' ')
:tag('span')
:addClass('etytree-term')
:wikitext(term_text)
:done()
end
local block = html_create('div'):addClass('etytree-block'):node(link_content)
-- Add duplicate styling if this is a duplicate node
if node_data.is_duplicate then
block:addClass('etytree-duplicate')
end
return block
end
local function create_dotted_connector()
return html_create('span'):addClass('etytree-connector-dotted')
end
-- Create an L-shaped connector for nodes with hidden ancestry (duplicate or no_child_categories)
local function create_duplicate_connector()
local container = html_create('div'):addClass('etytree-duplicate-connector')
local inner_wrapper = container:tag('div')
inner_wrapper:tag('span'):addClass('etytree-dup-right')
inner_wrapper:tag('span'):addClass('etytree-dup-horiz')
inner_wrapper:tag('span'):addClass('etytree-dup-left')
inner_wrapper:tag('span'):addClass('etytree-dup-arrow'):wikitext('▲')
return container
end
local function render_group_label(connecting_line, keyword_info, keyword_modifiers)
local has_abbrev = keyword_info and keyword_info.abbrev
local keyword_uncertain = keyword_modifiers and keyword_modifiers.unc
-- Nothing to show if no abbrev and no uncertainty
if not has_abbrev and not keyword_uncertain then
return
end
local label_span = connecting_line:tag('span'):addClass('etytree-group-label')
if has_abbrev then
local glossary_title = keyword_info.glossary
and keyword_info.glossary:gsub("_", " ")
or keyword_info.abbrev
label_span:node(create_abbr(keyword_info.abbrev, glossary_title, nil))
end
-- Add uncertainty marker if keyword has <unc> modifier
if keyword_uncertain then
label_span:node(create_uncertainty_marker())
end
end
local function add_branch_connector(column, index, total)
if index == 1 then
column:tag('span'):addClass('etytree-branch-left')
elseif index == total then
column:tag('span'):addClass('etytree-branch-right')
else
column:tag('span'):addClass('etytree-connector-vertical-short')
column:tag('span'):addClass('etytree-branch-mid')
end
end
function export.render(opts)
opts = opts or {}
local data_tree = opts.data_tree
local format_term_func = opts.format_term_func
-- Forward declaration
local render_term
-- Render a container (keyword + its terms)
local function render_container(container, is_toplevel)
local keyword_info = container.keyword_info
local keyword_modifiers = container.keyword_modifiers or {}
local is_group = keyword_info and keyword_info.is_group
local terms = container.terms or {}
-- Skip container entirely only when invisible = "all" (or true)
if keyword_info and invisible_in_tree(keyword_info.invisible) then
return nil, 0, 0
end
if #terms == 0 then
return nil, 0, 0
end
-- For no_child_categories keywords (calque, semantic loan, etc.), don't render term's children
local skip_child_rendering = keyword_info and keyword_info.no_child_categories
-- Render each term in the container
local rendered_terms = {}
local container_height = 0
local container_width = 0
for _, term in ipairs(terms) do
-- Collect term-specific labels
local term_labels = {}
if term.bor then
table.insert(term_labels, { abbrev = "bor.", title = "borrowed", glossary = "loanword" })
end
if term.slbor then
table.insert(term_labels, { abbrev = "slbor.", title = "semi-learned borrowing", glossary = "semi-learned_borrowing" })
end
if term.lbor then
table.insert(term_labels, { abbrev = "lbor.", title = "learned borrowing", glossary = "learned_borrowing" })
end
local term_tree, term_height, term_width = render_term(term, keyword_info, keyword_modifiers, is_group, false, skip_child_rendering, term_labels)
table.insert(rendered_terms, {
tree = term_tree,
height = term_height,
width = term_width,
is_uncertain = term.is_uncertain,
})
container_height = max(container_height, term_height)
container_width = container_width + term_width
end
local rendered_html
local has_connector = false
if #rendered_terms == 1 then
-- Single term: just return it directly
rendered_html = rendered_terms[1].tree
container_height = rendered_terms[1].height
container_width = rendered_terms[1].width
else
-- Multiple terms: group them together
local subtree_container = html_create('div'):addClass('etytree-branch-group')
for i, term_data in ipairs(rendered_terms) do
local column = html_create('div'):addClass('etytree-branch')
column:node(term_data.tree)
add_branch_connector(column, i, #rendered_terms)
subtree_container:node(column)
end
local connecting_line = create_vertical_connector()
-- Add group label for group keywords
if is_group and not invisible_in_tree(keyword_info.invisible) then
render_group_label(connecting_line, keyword_info, keyword_modifiers)
end
rendered_html = html_create()
:node(subtree_container)
:node(connecting_line)
has_connector = true
end
return rendered_html, container_height, container_width, has_connector
end
-- Render a term node
render_term = function(term_node, keyword_info, keyword_modifiers, is_group_child, is_toplevel_term, skip_child_rendering, term_labels)
local tree_width, tree_height = 0, 0
local subtrees = {}
-- Process term's children (which are containers)
local has_hidden_children = false
if not term_node.is_duplicate and not skip_child_rendering then
for _, container in ipairs(term_node.children or {}) do
local subtree, sub_height, sub_width, subtree_has_connector = render_container(container, is_toplevel_term)
if subtree then
table.insert(subtrees, {
tree = subtree,
height = sub_height,
width = sub_width,
has_connector = subtree_has_connector,
})
tree_height = max(tree_height, sub_height)
tree_width = tree_width + sub_width
end
end
elseif skip_child_rendering then
-- Check if there are any visible children
-- When stop_recursion is true, children aren't parsed, but has_visible_children flag is set
if term_node.has_visible_children then
has_hidden_children = true
elseif term_node.children and #term_node.children > 0 then
-- Fallback: check parsed children for visibility
for _, container in ipairs(term_node.children) do
local child_keyword_info = container.keyword_info
if not (child_keyword_info and (child_keyword_info.invisible == "all" or child_keyword_info.invisible == true)) then
has_hidden_children = true
break
end
end
end
end
local is_toplevel_node = (keyword_info == nil)
local term_block = render_term_block(term_node, format_term_func, is_toplevel_node)
render_label(term_block, keyword_info, keyword_modifiers, term_node.is_uncertain, is_group_child, term_labels or {})
local term_html = html_create()
if #subtrees == 0 then
local show_connector = (term_node.is_duplicate and term_node.original_has_children) or has_hidden_children
if show_connector then
term_html:node(create_duplicate_connector())
end
term_html:node(term_block)
tree_width = tree_width + 1
elseif #subtrees == 1 then
term_html:node(subtrees[1].tree)
if not subtrees[1].has_connector then
term_html:node(create_vertical_connector())
end
term_html:node(term_block)
else
-- Multiple containers: need to merge them
local subtree_container = html_create('div'):addClass('etytree-branch-group')
for i, subtree_data in ipairs(subtrees) do
local column = html_create('div'):addClass('etytree-branch')
column:node(subtree_data.tree)
add_branch_connector(column, i, #subtrees)
subtree_container:node(column)
end
local connecting_line = create_vertical_connector()
term_html
:node(subtree_container)
:node(connecting_line)
:node(term_block)
end
return term_html, tree_height + 1, tree_width
end
local final_tree, final_height, final_width = render_term(data_tree, nil, nil, false, true)
local container = html_create('div')
:addClass('etytree-body')
:node(final_tree)
return tostring(html_create('div')
:addClass('etytree NavFrame')
:attr('data-etytree-height', final_height)
:attr('data-etytree-width', final_width)
:tag('div')
:addClass('NavHead')
:tag('div')
:wikitext('Etymology tree')
:done()
:done()
:tag('div')
:addClass('NavContent')
:node(container)
:done())
end
return export
ok2w6x96bozaxhyj2cy4qex9anfbtx5
89242
89241
2026-09-02T19:03:09Z
Altronic
4137
Chaidh 1 mhùthadh ion-phortachadh o [[:en:Module:etymon/tree]]
89241
Scribunto
text/plain
local export = {}
local html_create = mw.html.create
local max = math.max
local function create_vertical_connector()
return html_create('span'):addClass('etytree-connector-vertical')
end
local function create_abbr(text, title, glossary)
local abbr = html_create('abbr')
:attr('title', title)
:wikitext(text)
if glossary then
abbr = '[[Appendix:Glossary#' .. glossary .. '|' .. tostring(abbr) .. ']]'
end
return html_create('span'):addClass('etytree-label'):node(abbr)
end
local function create_uncertainty_marker()
return html_create('abbr')
:addClass('etytree-unc')
:attr('title', 'uncertain')
:wikitext('?')
end
local function create_label_container()
return html_create('span'):addClass('etytree-label-container')
end
local function invisible_in_tree(inv)
return inv == "all" or inv == true or inv == "tree"
end
local function render_label(term_block, keyword_info, keyword_modifiers, is_uncertain, is_group_child, term_labels)
-- Skip label when invisible in tree
local has_label = keyword_info and keyword_info.abbrev and not is_group_child and not invisible_in_tree(keyword_info.invisible)
-- For group children, keyword uncertainty is shown on the group label, not on individual terms
local keyword_uncertain = keyword_modifiers and keyword_modifiers.unc and not is_group_child
local show_term_uncertainty = is_uncertain
-- Check if we have term-specific labels
local has_term_labels = term_labels and #term_labels > 0
if not has_label and not show_term_uncertainty and not keyword_uncertain and not has_term_labels then
return
end
local label_span = create_label_container()
if has_label then
local glossary_title = keyword_info.glossary
and keyword_info.glossary:gsub("_", " ")
or keyword_info.abbrev
label_span:node(create_abbr(
keyword_info.abbrev,
glossary_title,
keyword_info.glossary
))
-- Show uncertainty marker if term or keyword is uncertain
if show_term_uncertainty or keyword_uncertain then
label_span:node(create_uncertainty_marker())
end
else
-- No label, but term or keyword is uncertain
if show_term_uncertainty or keyword_uncertain then
label_span:node(create_uncertainty_marker())
end
end
-- Add term-specific labels
if has_term_labels then
for _, label_info in ipairs(term_labels) do
label_span:node(create_abbr(label_info.abbrev, label_info.title, label_info.glossary))
end
end
term_block:node(label_span)
end
local function render_term_block(node_data, format_term_func, is_toplevel)
local link_content = html_create()
link_content
:tag('span')
:addClass('etyl')
:wikitext(node_data.lang:getCanonicalName())
:done()
local term_text = format_term_func(node_data, is_toplevel)
if term_text then
link_content
:wikitext(' ')
:tag('span')
:addClass('etytree-term')
:wikitext(term_text)
:done()
end
local block = html_create('div'):addClass('etytree-block'):node(link_content)
-- Add duplicate styling if this is a duplicate node
if node_data.is_duplicate then
block:addClass('etytree-duplicate')
end
return block
end
local function create_dotted_connector()
return html_create('span'):addClass('etytree-connector-dotted')
end
-- Create an L-shaped connector for nodes with hidden ancestry (duplicate or no_child_categories)
local function create_duplicate_connector()
local container = html_create('div'):addClass('etytree-duplicate-connector')
local inner_wrapper = container:tag('div')
inner_wrapper:tag('span'):addClass('etytree-dup-right')
inner_wrapper:tag('span'):addClass('etytree-dup-horiz')
inner_wrapper:tag('span'):addClass('etytree-dup-left')
inner_wrapper:tag('span'):addClass('etytree-dup-arrow'):wikitext('▲')
return container
end
local function render_group_label(connecting_line, keyword_info, keyword_modifiers)
local has_abbrev = keyword_info and keyword_info.abbrev
local keyword_uncertain = keyword_modifiers and keyword_modifiers.unc
-- Nothing to show if no abbrev and no uncertainty
if not has_abbrev and not keyword_uncertain then
return
end
local label_span = connecting_line:tag('span'):addClass('etytree-group-label')
if has_abbrev then
local glossary_title = keyword_info.glossary
and keyword_info.glossary:gsub("_", " ")
or keyword_info.abbrev
label_span:node(create_abbr(keyword_info.abbrev, glossary_title, nil))
end
-- Add uncertainty marker if keyword has <unc> modifier
if keyword_uncertain then
label_span:node(create_uncertainty_marker())
end
end
local function add_branch_connector(column, index, total)
if index == 1 then
column:tag('span'):addClass('etytree-branch-left')
elseif index == total then
column:tag('span'):addClass('etytree-branch-right')
else
column:tag('span'):addClass('etytree-connector-vertical-short')
column:tag('span'):addClass('etytree-branch-mid')
end
end
function export.render(opts)
opts = opts or {}
local data_tree = opts.data_tree
local format_term_func = opts.format_term_func
-- Forward declaration
local render_term
-- Render a container (keyword + its terms)
local function render_container(container, is_toplevel)
local keyword_info = container.keyword_info
local keyword_modifiers = container.keyword_modifiers or {}
local is_group = keyword_info and keyword_info.is_group
local terms = container.terms or {}
-- Skip container entirely only when invisible = "all" (or true)
if keyword_info and invisible_in_tree(keyword_info.invisible) then
return nil, 0, 0
end
if #terms == 0 then
return nil, 0, 0
end
-- For no_child_categories keywords (calque, semantic loan, etc.), don't render term's children
local skip_child_rendering = keyword_info and keyword_info.no_child_categories
-- Render each term in the container
local rendered_terms = {}
local container_height = 0
local container_width = 0
for _, term in ipairs(terms) do
-- Collect term-specific labels
local term_labels = {}
if term.bor then
table.insert(term_labels, { abbrev = "bor.", title = "borrowed", glossary = "loanword" })
end
if term.slbor then
table.insert(term_labels, { abbrev = "slbor.", title = "semi-learned borrowing", glossary = "semi-learned_borrowing" })
end
if term.lbor then
table.insert(term_labels, { abbrev = "lbor.", title = "learned borrowing", glossary = "learned_borrowing" })
end
local term_tree, term_height, term_width = render_term(term, keyword_info, keyword_modifiers, is_group, false, skip_child_rendering, term_labels)
table.insert(rendered_terms, {
tree = term_tree,
height = term_height,
width = term_width,
is_uncertain = term.is_uncertain,
})
container_height = max(container_height, term_height)
container_width = container_width + term_width
end
local rendered_html
local has_connector = false
if #rendered_terms == 1 then
-- Single term: just return it directly
rendered_html = rendered_terms[1].tree
container_height = rendered_terms[1].height
container_width = rendered_terms[1].width
else
-- Multiple terms: group them together
local subtree_container = html_create('div'):addClass('etytree-branch-group')
for i, term_data in ipairs(rendered_terms) do
local column = html_create('div'):addClass('etytree-branch')
column:node(term_data.tree)
add_branch_connector(column, i, #rendered_terms)
subtree_container:node(column)
end
local connecting_line = create_vertical_connector()
-- Add group label for group keywords
if is_group and not invisible_in_tree(keyword_info.invisible) then
render_group_label(connecting_line, keyword_info, keyword_modifiers)
end
rendered_html = html_create()
:node(subtree_container)
:node(connecting_line)
has_connector = true
end
return rendered_html, container_height, container_width, has_connector
end
-- Render a term node
render_term = function(term_node, keyword_info, keyword_modifiers, is_group_child, is_toplevel_term, skip_child_rendering, term_labels)
local tree_width, tree_height = 0, 0
local subtrees = {}
-- Process term's children (which are containers)
local has_hidden_children = false
if not term_node.is_duplicate and not skip_child_rendering then
for _, container in ipairs(term_node.children or {}) do
local subtree, sub_height, sub_width, subtree_has_connector = render_container(container, is_toplevel_term)
if subtree then
table.insert(subtrees, {
tree = subtree,
height = sub_height,
width = sub_width,
has_connector = subtree_has_connector,
})
tree_height = max(tree_height, sub_height)
tree_width = tree_width + sub_width
end
end
elseif skip_child_rendering then
-- Check if there are any visible children
-- When stop_recursion is true, children aren't parsed, but has_visible_children flag is set
if term_node.has_visible_children then
has_hidden_children = true
elseif term_node.children and #term_node.children > 0 then
-- Fallback: check parsed children for visibility
for _, container in ipairs(term_node.children) do
local child_keyword_info = container.keyword_info
if not (child_keyword_info and (child_keyword_info.invisible == "all" or child_keyword_info.invisible == true)) then
has_hidden_children = true
break
end
end
end
end
local is_toplevel_node = (keyword_info == nil)
local term_block = render_term_block(term_node, format_term_func, is_toplevel_node)
render_label(term_block, keyword_info, keyword_modifiers, term_node.is_uncertain, is_group_child, term_labels or {})
local term_html = html_create()
if #subtrees == 0 then
local show_connector = (term_node.is_duplicate and term_node.original_has_children) or has_hidden_children
if show_connector then
term_html:node(create_duplicate_connector())
end
term_html:node(term_block)
tree_width = tree_width + 1
elseif #subtrees == 1 then
term_html:node(subtrees[1].tree)
if not subtrees[1].has_connector then
term_html:node(create_vertical_connector())
end
term_html:node(term_block)
else
-- Multiple containers: need to merge them
local subtree_container = html_create('div'):addClass('etytree-branch-group')
for i, subtree_data in ipairs(subtrees) do
local column = html_create('div'):addClass('etytree-branch')
column:node(subtree_data.tree)
add_branch_connector(column, i, #subtrees)
subtree_container:node(column)
end
local connecting_line = create_vertical_connector()
term_html
:node(subtree_container)
:node(connecting_line)
:node(term_block)
end
return term_html, tree_height + 1, tree_width
end
local final_tree, final_height, final_width = render_term(data_tree, nil, nil, false, true)
local container = html_create('div')
:addClass('etytree-body')
:node(final_tree)
return tostring(html_create('div')
:addClass('etytree NavFrame')
:attr('data-etytree-height', final_height)
:attr('data-etytree-width', final_width)
:tag('div')
:addClass('NavHead')
:tag('div')
:wikitext('Etymology tree')
:done()
:done()
:tag('div')
:addClass('NavContent')
:node(container)
:done())
end
return export
ok2w6x96bozaxhyj2cy4qex9anfbtx5