跳转到内容

模組:Place

維基詞典,自由的多語言詞典

This module implements {{place}}. Comments can be found throughout the module explaining it. Data describing the recognized placetypes and how to categorize them can be found in Module:place/data. The recognized place names are generally found in Module:place/shared-data, which (as the name suggests) is shared by Module:place/data and Module:category tree/topic cat/data/Places (which handles the generation of page contents for place categories such as Category:en:Cities in Osaka Prefecture).


local export = {}

local force_cat = false -- set to true for testing

local m_placetypes = require("Module:place/placetypes")
local m_links = require("Module:links")
local memoize = require("Module:memoize")
local m_strutils = require("Module:string utilities")
local m_table = require("Module:table")
-- Chinese localization data (zh_name, zh_suffix, qualifiers, extra-info labels, etc.)
local m_zh_data = require("Module:place/data")

local debug_track_module = "Module:debug/track"
local en_utilities_module = "Module:en-utilities"
local form_of_module = "Module:form of"
local languages_module = "Module:languages"
local parse_interface_module = "Module:parse interface"
local parse_utilities_module = "Module:parse utilities"
local parameter_utilities_module = "Module:parameter utilities"
local utilities_module = "Module:utilities"

local zhlang = require(languages_module).getByCode("zh")

local rmatch = m_strutils.match
local rfind = m_strutils.find
local ulen = m_strutils.len
local split = m_strutils.split
local dump = mw.dumpObject
local insert = table.insert
local concat = table.concat
local pluralize = require(en_utilities_module).pluralize
local extend = m_table.extend
local unpack = unpack or table.unpack -- Lua 5.2 compatibility

local internal_error = m_placetypes.internal_error
local process_error = m_placetypes.process_error
local placetype_data = m_placetypes.placetype_data

-- Shorthand for the ZH localization string table.
local zh_strings = m_zh_data.zh_strings


--[==[
ZH fork of [[Module:place]] (enwikt). Architecture and data structures are identical to the English version;
only the display-formatting functions are changed to produce Chinese-language definitions.

Key structural differences from enwikt:
* Old-style holonyms are reversed (big→small containment order) and joined with 、/和 rather than "in X, Y".
* Sentence pattern: 位於[holonyms]的[placetype] (or [holonyms]的[placetype] for "of"-type placetypes).
* No articles (a/an/the) in generated text.
* Pref/suf holonym affixes append the Chinese zh_suffix instead of English "placetype of X".
* Translations are joined with "," (full-width comma); parenthetical glosses use ().
* Extra-info labels (capital, largest city, etc.) come from Module:place/data zh_extra_info_labels.
* sentence_style is always true for ZH (extra info uses "。" separator, not ";").
]==]


--[==[ var:
List specifying the allowed form-of directives, used for former names, official names, abbreviations, etc. of places.
The key is the form-of directive and the value is an object with the following properties:
* `text`: The actual text displayed before the terms. If the value is `+`, the key is used as the text. If the value is
	a function, it is passed a single argument, the overall place spec (see comment at top of file) and should return
	the text to be displayed.
* `type_prefix`: The prefix used to generate the placetype for looking up the appropriate category or categories in the
	placetype data structure. Can be omitted if there are no categories associated with the directive.
* `conjunction`: The conjunction used to join multiple terms, defaulting to `and`.
* `cat`: Additional category or categories to add the term to, whenever this particular directive is used.
* `default_foreign`: If specified, the default language of terms given along with this directive is the language in
	{{para|1}}; otherwise it is English.
]==]
export.all_form_of_directives = {
	["former name of"] = {text = "+", type_prefix = "FORMER_NAME_OF"},
	["fmr of"] = {alias_of = "former name of"},
	["ancient name of"] = {text = "+", type_prefix = "FORMER_NAME_OF"},
	["official name of"] = {text = "+", type_prefix = "OFFICIAL_NAME_OF"},
	["former official name of"] = {text = "+", type_prefix = "FORMER_OFFICIAL_NAME_OF"},
	["long form of"] = {text = "+", type_prefix = "LONG_FORM_OF"},
	["former long form of"] = {text = "+", type_prefix = "FORMER_LONG_FORM_OF"},
	["nickname for"] = {text = "+", type_prefix = "NICKNAME_FOR"},
	["official nickname for"] = {text = "+", type_prefix = "OFFICIAL_NICKNAME_FOR"},
	["former nickname for"] = {text = "+", type_prefix = "FORMER_NICKNAME_FOR"},
	["derogatory name for"] = {text = "[[Appendix:Glossary#derogatory|derogatory]] name for", type_prefix = "DEROGATORY_NAME_FOR"},
	["synonym of"] = {text = "+"},
	["syn of"] = {alias_of = "synonym of"},
	["abbreviation of"] = {text = "[[Appendix:Glossary#abbreviation|abbreviation]] of", type_prefix = "ABBREVIATION_OF", cat = "cln:abbreviations",
		default_foreign = true},
	["abbr of"] = {alias_of = "abbreviation of"},
	["abbrev of"] = {alias_of = "abbreviation of"},
	["initialism of"] = {text = "[[Appendix:Glossary#initialism|initialism]] of", type_prefix = "ABBREVIATION_OF", cat = "cln:initialisms",
		default_foreign = true},
	["init of"] = {alias_of = "initialism of"},
	["acronym of"] = {text = "[[Appendix:Glossary#acronym|acronym]] of", type_prefix = "ABBREVIATION_OF", cat = "cln:acronyms",
		default_foreign = true},
	["syllabic abbreviation of"] = {text = "[[Appendix:Glossary#syllabic abbreviation|syllabic abbreviation]] of", type_prefix = "ABBREVIATION_OF", cat = "cln:syllabic abbreviations",
		default_foreign = true},
	["sylabbr of"] = {alias_of = "syllabic abbreviation of"},
	["sylabbrev of"] = {alias_of = "syllabic abbreviation of"},
	["ellipsis of"] = {text = "[[Appendix:Glossary#ellipsis|ellipsis]] of", type_prefix = "ELLIPSIS_OF", cat = "cln:ellipses",
		default_foreign = true},
	["ellip of"] = {alias_of = "ellipsis of"},
	["clipping of"] = {text = "[[Appendix:Glossary#clipping|clipping]] of", type_prefix = "CLIPPING_OF", cat = "cln:clippings",
		default_foreign = true},
	["clip of"] = {alias_of = "clipping of"},
	["alternative form of"] = {text = "+", default_foreign = true},
	["alt form"] = {alias_of = "alternative form of"},
	["alternative spelling of"] = {text = "+", default_foreign = true},
	["alt spell"] = {alias_of = "alternative spelling of"},
	["alt sp"] = {alias_of = "alternative spelling of"},
	["dated form of"] = {text = "[[Appendix:Glossary#dated|dated]] form of", type_prefix = "DATED_FORM_OF", cat = "cln:dated forms",
		default_foreign = true},
	["dated form"] = {alias_of = "dated form of"},
	["dated spelling of"] = {text = "[[Appendix:Glossary#dated|dated]] spelling of", type_prefix = "DATED_FORM_OF", cat = "cln:dated forms",
		default_foreign = true},
	["dated spell"] = {alias_of = "dated spelling of"},
	["dated sp"] = {alias_of = "dated spelling of"},
	["archaic form of"] = {text = "[[Appendix:Glossary#archaic|archaic]] form of", type_prefix = "ARCHAIC_FORM_OF", cat = "cln:archaic forms",
		default_foreign = true},
	["arch form"] = {alias_of = "archaic form of"},
	["archaic spelling of"] = {text = "[[Appendix:Glossary#archaic|archaic]] spelling of", type_prefix = "ARCHAIC_FORM_OF", cat = "cln:archaic forms",
		default_foreign = true},
	["arch spell"] = {alias_of = "archaic spelling of"},
	["arch sp"] = {alias_of = "archaic spelling of"},
	["obsolete form of"] = {text = "[[Appendix:Glossary#obsolete|obsolete]] form of", type_prefix = "OBSOLETE_FORM_OF", cat = "cln:obsolete forms",
		default_foreign = true},
	["obs form"] = {alias_of = "obsolete form of"},
	["obsolete spelling of"] = {text = "[[Appendix:Glossary#obsolete|obsolete]] spelling of", type_prefix = "OBSOLETE_FORM_OF", cat = "cln:obsolete forms",
		default_foreign = true},
	["obs spell"] = {alias_of = "obsolete spelling of"},
	["obs sp"] = {alias_of = "obsolete spelling of"},
}

local function get_seat_text(overall_place_spec)
	local placetype = overall_place_spec.descs[1].placetypes[1]
	if placetype == "county" or placetype == "counties" then
		return "county seat"
	elseif placetype == "parish" or placetype == "parishes" then
		return "parish seat"
	elseif placetype == "borough" or placetype == "boroughs" then
		return "borough seat"
	else
		return "seat"
	end
end

--[==[ var:
List specifying the allowed arguments containing extra information that is sometimes added to a definition, such as the
capital, largest city, modern name, official name, etc., along with associated properties; displayed in the order given.
See enwikt [[Module:place]] for full documentation of each field.
]==]
export.extra_info_args = {
	{arg = "modern", text = "+", conjunction = "or", display_even_when_dropped = true},
	{arg = "now", text = "now,", conjunction = "or", display_even_when_dropped = true},
	{arg = "full", text = "in full,", conjunction = "or", display_even_when_dropped = true},
	{arg = "short", text = "short form", conjunction = "or"},
	{arg = "abbr", text = "abbreviation", conjunction = "or"},
	{arg = "former", text = "formerly,"},
	{arg = "official", text = "official name", match_sentence_style = true, auto_plural = true, with_colon = true},
	{arg = "capital", text = "+", match_sentence_style = true, auto_plural = true, with_colon = true},
	{arg = "largest city", text = "+", match_sentence_style = true, auto_plural = true, with_colon = true},
	{arg = "caplc", text = "capital and largest city", match_sentence_style = true, auto_plural = false,
		with_colon = true},
	{arg = "seat", text = get_seat_text, match_sentence_style = true, auto_plural = true, with_colon = true},
	{arg = "shire town", text = "+", match_sentence_style = true, auto_plural = true, with_colon = true},
	{arg = "headquarters", text = "+", match_sentence_style = true, auto_plural = false, with_colon = true},
	{arg = "center", text = "administrative center", match_sentence_style = true, auto_plural = false, with_colon = true},
	{arg = "centre", text = "administrative centre", match_sentence_style = true, auto_plural = false, with_colon = true},
}

export.extra_info_arg_map = {}

for _, spec in ipairs(export.extra_info_args) do
	export.extra_info_arg_map[spec.arg] = spec
end

----------- Wikicode utility functions


-- Return a wikilink link {{l|language|text}}
local function link(text, langcode, id)
	if not langcode then
		return text
	end
	return m_links.language_link( -- l10n, avoid simp/trad or applying special fonts
		{
			term = text, 
			alt = text,
			lang = require(languages_module).getByCode(langcode, true, "allow etym"), 
			id = id
		}
	)
end


---------- Basic utility functions


-- Add the page to a tracking "category". To see the pages in the "category",
-- go to [[Wiktionary:Tracking/place/PAGE]] and click on "What links here".
local function track(page)
	require(debug_track_module)("place/" .. page)
	return true
end


local function ucfirst_all(text)
	if text:find(" ") then
		local parts = split(text, " ", true)
		for i, part in ipairs(parts) do
			parts[i] = m_strutils.ucfirst(part)
		end
		return concat(parts, " ")
	else
		return m_strutils.ucfirst(text)
	end
end


local function lc(text)
	return mw.getContentLanguage():lc(text)
end


---------- Argument parsing functions and utilities


-- Split an argument on comma, but not comma followed by whitespace.
local function split_on_comma(val)
	if val:find(",") then
		return require(parse_interface_module).split_on_comma(val)
	else
		return {val}
	end
end


-- Split an argument on slash, but not slash occurring inside of HTML tags like </span> or <br />.
local function split_on_slash(arg)
	if arg:find("<") then
		local m_parse_utilities = require(parse_utilities_module)
		-- We implement this by parsing balanced segment runs involving <...>, and splitting on slash in the remainder.
		-- The result is a list of lists, so we have to rejoin the inner lists by concatenating.
		local segments = m_parse_utilities.parse_balanced_segment_run(arg, "<", ">")
		local slash_separated_groups = m_parse_utilities.split_alternating_runs(segments, "/")
		for i, group in ipairs(slash_separated_groups) do
			slash_separated_groups[i] = concat(group)
		end
		return slash_separated_groups
	else
		return split(arg, "/", true)
	end
end


-- Implement "implications", i.e. where the presence of a given holonym causes additional holonym(s) to be added.
-- Implications apply only to categorization.
local function handle_category_implications(place_descriptions, implication_data)
	for i, desc in ipairs(place_descriptions) do
		if desc.holonyms then
			local new_holonyms = {}
			for _, holonym in ipairs(desc.holonyms) do
				insert(new_holonyms, holonym)
				local imp_data = m_placetypes.get_equiv_placetype_prop(holonym.placetype, function(pt)
					local implication = implication_data[pt] and implication_data[pt][holonym.unlinked_placename]
					if implication then
						return implication
					end
				end)
				if imp_data then
					for _, holonym_to_add in ipairs(imp_data) do
						local split_holonym = split_on_slash(holonym_to_add)
						if #split_holonym ~= 2 then
							internal_error("Invalid holonym in implications: %s", holonym_to_add)
						end
						local holonym_placetype, holonym_placename = unpack(split_holonym, 1, 2)
						local new_holonym = {
							placetype = holonym_placetype, unlinked_placename = holonym_placename
						}
						insert(new_holonyms, new_holonym)
						m_placetypes.key_holonym_into_place_desc(desc, new_holonym)
					end
				end
			end
			desc.holonyms = new_holonyms
		end
	end
end


-- Split a holonym (e.g. "continent/Europe" or "country/en:Italy" or "in southern" or "r:suf/O'Higgins" or
-- "c/Austria,Germany,Czech Republic") into its components. Return a list of holonym objects (see top of file). Note
-- that if there isn't a slash in the holonym (e.g. "in southern"), the `placetype` field of the holonym will be nil.
-- Placetype aliases (e.g. "r" for "region") and placename aliases (e.g. "US" or "USA" for "United States") will be
-- expanded.
local function split_holonym(raw)
	local no_display, combined_holonym = raw:match("^(!)(.*)$")
	no_display = not not no_display
	combined_holonym = combined_holonym or raw
	local suppress_comma, combined_holonym_without_comma = combined_holonym:match("^(%*)(.*)$")
	suppress_comma = not not suppress_comma
	combined_holonym = combined_holonym_without_comma or combined_holonym
	local holonym_parts = split_on_slash(combined_holonym)
	if #holonym_parts == 1 then
		-- `unlinked_placename` should not be used.
		return {{display_placename = combined_holonym, no_display = no_display, suppress_comma = suppress_comma}}
	end

	-- Rejoin further slashes in case of slash in holonym placename, e.g. Admaston/Bromley.
	local placetype = holonym_parts[1]
	local placename = concat(holonym_parts, "/", 2)

	-- Check for modifiers after the holonym placetype.
	local split_holonym_placetype = split(placetype, ":", true)
	placetype = split_holonym_placetype[1]
	local affix_type
	local saw_also
	local saw_the
	for i = 2, #split_holonym_placetype do
		local modifier = split_holonym_placetype[i]
		if modifier == "also" then
			if saw_also then
				error(("Modifier ':also' occurs twice in holonym '%s'"):format(combined_holonym))
			end
			saw_also = true
		elseif modifier == "the" then
			if saw_the then
				error(("Modifier ':the' occurs twice in holonym '%s'"):format(combined_holonym))
			end
			saw_the = true
		elseif modifier == "pref" or modifier == "Pref" or modifier == "suf" or modifier == "Suf" or
			modifier == "noaff" then
			if affix_type then
				error(("Affix-type modifier ':%s' occurs twice in holonym '%s'"):format(modifier, combined_holonym))
			end
			affix_type = modifier
		else
			error(("Unrecognized holonym placetype modifier '%s', should be one of " ..
				"'pref', 'Pref', 'suf', 'Suf', 'noaff', 'also' or 'the'"):format(modifier))
		end
	end

	placetype = m_placetypes.resolve_placetype_aliases(placetype)
	local holonyms = split_on_comma(placename)
	local pluralize_affix = #holonyms > 1
	local affix_holonym_index = (affix_type == "pref" or affix_type == "Pref") and 1 or affix_type == "noaff" and 0 or
		#holonyms
	for i, placename in ipairs(holonyms) do
		-- Check for langcode before the holonym placename, but don't get tripped up by Wikipedia links, which begin
		-- "[[w:...]]" or "[[wikipedia:]]".
		local langcode, placename_without_langcode = rmatch(placename, "^([^%[%]]-):(.*)$")
		if langcode then
			placename = placename_without_langcode
		end
		placename = m_placetypes.resolve_placename_display_aliases(placetype, placename)
		holonyms[i] = {
			placetype = placetype,
			display_placename = placename,
			unlinked_placename = m_placetypes.remove_links_and_html(placename),
			langcode = langcode,
			affix_type = i == affix_holonym_index and affix_type or nil,
			pluralize_affix = i == affix_holonym_index and pluralize_affix,
			suppress_affix = i ~= affix_holonym_index,
			no_display = no_display,
			suppress_comma = suppress_comma,
			continue_cat_loop = saw_also,
			force_the = i == 1 and saw_the,
		}
	end

	return holonyms
end


local get_param_mods = memoize(function()
	local m_param_utils = require(parameter_utilities_module)
	return m_param_utils.construct_param_mods {
		{group = {"link", "q", "l", "ref"}},
		{param = "eq"},
		{param = "conj", set = {["and"] = true, ["or"] = true, ["and/or"] = true}, overall = true},
	}
end)

local function parse_term_with_inline_modifiers(term, paramname, default_lang)
	local function generate_obj(raw_term, parse_err)
		local obj = require(parameter_utilities_module).generate_obj_maybe_parsing_lang_prefix {
			term = raw_term,
			parse_err = parse_err,
			parse_lang_prefix = true,
		}
		obj.lang = obj.lang or default_lang
		return obj
	end
	return require(parse_interface_module).parse_inline_modifiers(term, {
		paramname = paramname,
		param_mods = get_param_mods(),
		generate_obj = generate_obj,
		splitchar = ",",
		outer_container = {},
	})
end


local function parse_form_of_directive(arg, lang, form_of_overridden_args)
	local form_of_directive, raw_terms = arg:match("^@([a-z -]+):(.*)$")
	if not form_of_directive then
		error("Misformatted @-directive: " .. dump(arg) .. " (directive name must be lowercase English letters and spaces, e.g. @official name of:比利時)")
	end
	if not export.all_form_of_directives[form_of_directive] then
		local known_directives = {}
		for k, _ in pairs(export.all_form_of_directives) do
			insert(known_directives, '"' .. k .. '"')
		end
		table.sort(known_directives)
		error(("Unrecognized form-of directive %s in @-directive %s; recognized directives are %s"):format(
			dump(form_of_directive), dump(arg), concat(known_directives, ", ")))
	end
	local spec = export.all_form_of_directives[form_of_directive]
	local canonical_directive = form_of_directive
	if spec.alias_of then
		canonical_directive = spec.alias_of
		spec = export.all_form_of_directives[canonical_directive]
		if not spec then
			internal_error("Form-of directive alias %s points to %s, which is not a directive",
				"@" .. form_of_directive, canonical_directive)
		elseif spec.alias_of then
			internal_error("Form-of directive alias %s points to %s, which is also an alias",
				"@" .. form_of_directive, canonical_directive)
		end
	end
	local default_foreign = spec.default_foreign
	local directive_param = "@" .. form_of_directive
	if form_of_overridden_args and form_of_overridden_args[canonical_directive] then
		raw_terms = form_of_overridden_args[canonical_directive].new_value
		local new_directive = form_of_overridden_args[canonical_directive].new_directive
		local new_spec = export.all_form_of_directives[new_directive]
		if not new_spec then
			error(("Internal error: [[Module:transclude]] passed in unrecognized replacement directive '@%s'"):
				format(new_directive))
		end
		if new_spec.alias_of then
			error(("Internal error: [[Module:transclude]] passed in replacement directive alias '@%s', " ..
				"should be canonical"):format(new_directive))
		end
		if new_directive ~= canonical_directive then
			directive_param = directive_param .. (" (replaced with @%s)"):format(new_directive)
			canonical_directive = new_directive
			spec = new_spec
		end
		default_foreign = true
	end
	local terms = parse_term_with_inline_modifiers(raw_terms, directive_param,
		default_foreign and lang or zhlang)
	return {
		directive = canonical_directive,
		terms = terms.terms,
		conj = terms.conj,
		spec = spec,
	}
end


-- Parse an argument containing extra information that is sometimes added to a definition, such as the capital, largest
-- city, modern name, official name, etc. `args` is the value from the parsed argument structure and can be either nil,
-- a string or a list. `spec` is the extra info spec corresponding to the type of extra info. Each value in `args` can
-- be a comma-separated list of terms with inline modifiers attached.
local function parse_extra_info_arg(args, spec, default_lang)
	if not args then
		return nil
	end
	if type(args) ~= "table" then
		args = {args}
	end
	if not args[1] then
		return nil
	end

	local terms = nil

	local conj
	for i, arg in ipairs(args) do
		local this_terms = parse_term_with_inline_modifiers(arg, spec.arg .. (i == 1 and "" or i), default_lang)
		local thisconj = this_terms.conj
		if not conj then
			conj = thisconj
		elseif thisconj and conj ~= thisconj then
			error(("Two different conjunctions '%s' and '%s' specified for |%s=; you only need to specify the " ..
				"conjunction once"):format(conj, thisconj))
		end
		if not terms then
			terms = this_terms.terms
		else
			m_table.extend(terms, this_terms.terms)
		end
	end

	return {
		spec = spec,
		terms = terms,
		conj = conj,
	}
end


--[==[
Parse a "new-style" place description, with placetypes and holonyms surrounded by `<<...>>` amid otherwise raw text.
Return value is a place description object as documented at the top of the file. Exported for use by
[[Module:demonyms]].
]==]
function export.parse_new_style_place_desc(text, lang, form_of_directives, form_of_overridden_args)
	local placetypes = {}
	local segments = split(text, "<<(.-)>>")
	local retval = {holonyms = {}, order = {}}
	local form_of_directives_already_present = form_of_directives and not not form_of_directives[1]
	for i, segment in ipairs(segments) do
		if i % 2 == 1 then
			insert(retval.order, {type = "raw", value = segment})
		elseif segment:find("@") then
			if not form_of_directives then
				error(("Form-of directive '%s' not allowed in this context"):format(segment))
			elseif form_of_directives_already_present then
				error(("Saw form-of directive '%s' in new-style place desc followed by direct (separate-parameter) form-of directives; not allowed"):format(
					segment))
			elseif placetypes[1] or retval.holonyms[1] then
				error(("Form-of directive '%s' must come first, before placetypes and holonyms"):format(segment))
			else
				local form_of_directive = parse_form_of_directive(segment, lang, form_of_overridden_args)
				if not retval.order[1] or retval.order[1].type ~= "raw" or retval.order[2] then
					internal_error("`retval.order` should have a single raw element: %s", retval.order)
				end
				form_of_directive.pretext = retval.order[1].value
				retval.order[1] = nil
				insert(form_of_directives, form_of_directive)
			end
		elseif segment:find("/") then
			local holonyms = split_holonym(segment)
			for j, holonym in ipairs(holonyms) do
				if j > 1 then
					if not holonym.no_display then
						if j == #holonyms then
							insert(retval.order, {type = "raw", value = zh_strings.and_conj})
						else
							insert(retval.order, {type = "raw", value = zh_strings.enum_sep})
						end
					end
					holonym.needs_article = true
				end
				insert(retval.holonyms, holonym)
				if not holonym.no_display then
					insert(retval.order, {type = "holonym", value = #retval.holonyms})
				end
				m_placetypes.key_holonym_into_place_desc(retval, holonym)
			end
		else
			local treat_as, display = segment:match("^(..-):(.+)$")
			if treat_as then
				segment = treat_as
			else
				display = segment
			end
			-- see if the placetype segment is just qualifiers
			local only_qualifiers = true
			local split_segments = split(segment, " ", true)
			for _, split_segment in ipairs(split_segments) do
				if m_placetypes.placetype_qualifiers[split_segment] == nil then
					only_qualifiers = false
					break
				end
			end
			insert(placetypes, {placetype = segment, only_qualifiers = only_qualifiers})
			if only_qualifiers then
				insert(retval.order, {type = "qualifier", value = display})
			else
				insert(retval.order, {type = "placetype", value = display})
			end
		end
	end

	if not form_of_directives_already_present and form_of_directives and form_of_directives[1] then
		form_of_directives[#form_of_directives].posttext = ""
	end

	local final_placetypes = {}
	for i, placetype in ipairs(placetypes) do
		if i > 1 and placetypes[i - 1].only_qualifiers then
			final_placetypes[#final_placetypes] = final_placetypes[#final_placetypes] .. " " .. placetypes[i].placetype
		else
			insert(final_placetypes, placetypes[i].placetype)
		end
	end
	retval.placetypes = final_placetypes
	return retval
end


--[==[
Parse one or more "new-style" place descriptions, with placetypes and holonyms surrounded by `<<...>>` amid otherwise
raw text. Multiple descriptions are separated by two semicolons in a row. Return value is a list of place description
objects as documented at the top of the file.
]==]
local function parse_conjoined_new_style_place_desc(text, lang, form_of_directives, form_of_overridden_args)
	local separate_specs = split(text, ";(;[^ ]*)")
	local descs = {}
	for i = 1, #separate_specs do
		if i % 2 == 1 then
			insert(descs, export.parse_new_style_place_desc(separate_specs[i], lang, form_of_directives,
				form_of_overridden_args))
			form_of_directives = nil
		else
			descs[#descs].separator = separate_specs[i]
		end
	end
	return descs
end


--[=[
Process numeric and "extra info" arguments into an overall place spec, as described at the top of the file.
]=]
local function parse_overall_place_spec(data)
	local args, from_tcl, extra_info_overridden_set, form_of_overridden_args =
		data.args, data.from_tcl, data.extra_info_overridden_set, data.form_of_overridden_args
	local descs = {}
	local this_desc
	-- Index of separate (semicolon-separated) place descriptions within `descs`.
	local desc_index = 1
	-- Index of separate holonyms within a place description. 0 means we've seen no holonyms and have yet to process
	-- the placetypes that precede the holonyms. 1 means we've seen no holonyms but have already processed the
	-- placetypes.
	local holonym_index = 0
	local in_place_desc = false

	local form_of_directives = {}

	-- ZH: use Chinese semicolon and space characters for multi-description joiners.
	local function set_desc_joiner(desc, separator)
		if separator == ";" then
			this_desc.joiner = zh_strings.semicolon  -- ";" instead of "; "
			this_desc.include_following_article = true
		elseif separator == ";;" then
			this_desc.joiner = " "
		else
			local joiner = separator:sub(2)
			if rfind(joiner, "^%a") then
				this_desc.joiner = " " .. joiner .. " "
			elseif joiner == "," then
				-- ZH: ASCII comma → full-width ,(無空格)
				this_desc.joiner = zh_strings.trans_sep
			else
				this_desc.joiner = joiner .. " "
			end
		end
	end

	for _, arg in ipairs(args[2]) do
		if arg:find("^@") then
			if not (desc_index == 1 and holonym_index == 0) then
				error("@-directives cannot follow place descriptions")
			end
			local form_of_directive = parse_form_of_directive(arg, args[1], form_of_overridden_args)
			if form_of_directives[1] then
				form_of_directive.pretext = ", "
			else
				form_of_directive.pretext = ""
			end
			insert(form_of_directives, form_of_directive)
		elseif arg == ";" or arg:find("^;[^ ]") then
			if not this_desc then
				error("Saw semicolon joiner without preceding place description")
			end
			set_desc_joiner(this_desc, arg)
			desc_index = desc_index + 1
			holonym_index = 0
			in_place_desc = false
		else
			if arg:find("<<") then
				if in_place_desc then
					error("New-style place description must come first or following a separator (semicolon or similar), not directly following another description")
				end
				in_place_desc = true
				local this_descs = parse_conjoined_new_style_place_desc(arg, args[1], form_of_directives,
					form_of_overridden_args)
				for j, desc in ipairs(this_descs) do
					this_desc = desc
					if holonym_index > 0 then
						desc_index = desc_index + 1
						holonym_index = 0
					end
					if j < #this_descs then
						set_desc_joiner(this_desc, this_desc.separator)
					end
					descs[desc_index] = this_desc
					last_was_new_style = true
					holonym_index = #this_desc.holonyms + 1
				end
			else
				-- Old-style arguments can directly follow a new-style argument; they become additional holonyms
				-- tacked onto the end of the holonym list, and are displayed old-style except that there is no
				-- prefix before the first one following the new-style argument.
				in_place_desc = true
				if holonym_index == 0 then
					local entry_placetypes = split_on_slash(arg)
					this_desc = {placetypes = entry_placetypes, holonyms = {}}
					descs[desc_index] = this_desc
					holonym_index = holonym_index + 1
				else
					local holonyms = split_holonym(arg)
					for j, holonym in ipairs(holonyms) do
						if j > 1 then
							-- All but the first in a multi-holonym need an article. Not for the first one because e.g.
							-- {{place|en|city|s/Arizona|c/United States}} should not display as "a city in Arizona, the
							-- United States". The overall first holonym in the place description gets an article if
							-- needed regardless of our setting here.
							holonym.needs_article = true
							-- Insert "and" before the last holonym.
							if j == #holonyms then
								this_desc.holonyms[holonym_index] = {
									-- Use the no_display value from the first holonym; it should be the same for all
									-- holonyms. `unlinked_placename` should not be used.
									display_placename = "and", no_display = holonyms[1].no_display
								}
								holonym_index = holonym_index + 1
							end
						end
						this_desc.holonyms[holonym_index] = holonym
						m_placetypes.key_holonym_into_place_desc(this_desc, this_desc.holonyms[holonym_index])
						holonym_index = holonym_index + 1
					end
				end
			end
		end
	end

	if form_of_directives[1] and not form_of_directives[#form_of_directives].posttext then
		form_of_directives[#form_of_directives].posttext =
			(args.def and args.def ~= "-" or not args.def and descs[1]) and ":" or ""
	end

	-- Tracking code (unchanged from enwikt).
	for _, desc in ipairs(descs) do
		for _, entry_placetype in ipairs(desc.placetypes) do
			local splits = m_placetypes.split_qualifiers_from_placetype(entry_placetype, "no canon qualifiers")
			for _, split in ipairs(splits) do
				local prev_qualifier, this_qualifier, bare_placetype = unpack(split, 1, 3)
				track("entry-placetype/" .. bare_placetype)
				if this_qualifier then
					track("entry-qualifier/" .. this_qualifier)
				end
			end
		end
		for _, holonym in ipairs(desc.holonyms) do
			if holonym.placetype then
				track("holonym-placetype/" .. holonym.placetype)
			end
		end
	end

	local extra_info = {}
	for _, extra_info_spec in ipairs(export.extra_info_args) do
		local extra_info_terms = parse_extra_info_arg(args[extra_info_spec.arg], extra_info_spec,
			from_tcl and extra_info_overridden_set and extra_info_overridden_set[extra_info_spec.arg] and args[1] or
				zhlang)
		if extra_info_terms then
			insert(extra_info, extra_info_terms)
		end
	end

	return {
		lang = args[1],
		args = args,
		directives = form_of_directives,
		descs = descs,
		extra_info = extra_info,
	}
end



-------- Definition-generating functions



-- Return a string with the wikilinks to the ZH translations of the word.
-- ZH: joins multiple translations with "," (full-width comma) instead of ", ".
local function get_translations(transl, ids)
	local ret = {}

	for i, t in ipairs(transl) do
		local arg_transls = split_on_comma(t)
		local arg_ids = ids[i]
		if arg_ids then
			arg_ids = split_on_comma(arg_ids)
			if #arg_transls ~= #arg_ids then
				error(("Saw %s translation%s in t%s=%s but %s ID%s in tid%s=%s"):format(
					#arg_transls, #arg_transls > 1 and "s" or "", i == 1 and "" or i, t, #arg_ids,
					#arg_ids > 1 and "'s" or "", i == 1 and "" or i, ids[i]))
			end
		end
		for j, arg_transl in ipairs(arg_transls) do
			insert(ret, link(arg_transl, "zh", arg_ids and arg_ids[j] or nil))
		end
	end

	return concat(ret, zh_strings.trans_sep)  -- "," instead of ", "
end


-- Chinese has no articles; these stubs keep the call-sites intact.
local function get_placename_article(decorated_placename, placetypes, placename, suppress_holonym_use_the_check)
	return nil
end

local function get_holonym_article(decorated_placename, place_desc, holonym_index)
	return nil
end


-- Convert a holonym into display format. For ZH:
-- * No article prepended (Chinese has no articles).
-- * Both prefix and suffix affix types append the zh_suffix (e.g. "州", "省") directly to the linked name,
--   instead of the English "placetype of placename" / "placename placetype" construction.
-- * Display handlers are applied unchanged (they may add text based on the holonym's surrounding context).
local function format_holonym(place_desc, holonym_index, needs_article)
	local holonym = place_desc.holonyms[holonym_index]
	if holonym.no_display then
		return ""
	end

	-- needs_article and force_the are not used in Chinese.

	local output = holonym.display_placename
	local placetype = holonym.placetype
	local affix_type_pt_data, affix_type, affix_is_prefix, affix, prefix, suffix, no_affix_strings
	local pt_equiv_for_affix_type, already_seen_affix, need_affix
	local affix_pt_data, pt_equiv_for_affix

	-- Implement display handlers (unchanged from enwikt).
	local display_handler = m_placetypes.get_equiv_placetype_prop(placetype,
		function(pt) return placetype_data[pt] and placetype_data[pt].display_handler end)
	if display_handler then
		output = display_handler(placetype, output)
	end
	if not holonym.suppress_affix then
		-- Affix detection logic is unchanged from enwikt; only the rendering differs below.
		affix_type_pt_data, pt_equiv_for_affix_type = m_placetypes.get_equiv_placetype_prop(placetype,
			function(pt)
				local cdpt = placetype_data[pt]
				return cdpt and cdpt.affix_type and cdpt or nil
			end
		)
		affix_pt_data, pt_equiv_for_affix = m_placetypes.get_equiv_placetype_prop(placetype,
			function(pt)
				local cdpt = placetype_data[pt]
				return cdpt and (cdpt.affix_type or cdpt.affix or cdpt.prefix or cdpt.suffix) and cdpt or nil
			end
		)
		if affix_type_pt_data then
			affix_type = affix_type_pt_data.affix_type
			need_affix = true
		end
		if affix_pt_data then
			prefix = affix_pt_data.prefix or affix_pt_data.affix
			suffix = affix_pt_data.suffix or affix_pt_data.affix
			need_affix = true
		end
		no_affix_strings = affix_pt_data and affix_pt_data.no_affix_strings or
			affix_type_pt_data and affix_type_pt_data.no_affix_strings
		if holonym.affix_type and placetype then
			affix_type = holonym.affix_type
			prefix = prefix or placetype
			suffix = suffix or placetype
			need_affix = true
		end
		if need_affix then
			affix_is_prefix = affix_type == "pref" or affix_type == "Pref"
			if affix_is_prefix then
				affix = prefix
			else
				affix = suffix
			end
			if not affix then
				if not pt_equiv_for_affix_type then
					internal_error("Something wrong, `pt_equiv_for_affix_type` not set processing holonym: %s",
						holonym)
				end
				affix = pt_equiv_for_affix_type.placetype
				if not affix then
					internal_error("Something wrong, no affix could be located in `pt_equiv_for_affix_type` for " ..
						"holonym %s: %s", holonym, pt_equiv_for_affix_type)
				end
			end
			no_affix_strings = no_affix_strings or lc(affix)
			-- ZH: skip pluralize_affix (Chinese doesn't pluralize)
		end
	end

	-- ZH: resolve zh_suffix for the affix placetype before linking.
	local zh_sfx_to_append = nil
	if need_affix then
		local _, zh_sfx = m_zh_data.get_zh_placetype_props(affix, m_placetypes)
		if zh_sfx then
			local zh_sfx_str = m_zh_data.format_zh_name(zh_sfx)
			-- Check if zh_suffix already present in the unlinked output.
			local unlinked = m_placetypes.remove_links_and_html(output)
			if not unlinked:find(zh_sfx_str, 1, true) then
				zh_sfx_to_append = zh_sfx_str
			end
		end
	end

	output = link(output, holonym.langcode or placetype and "zh" or nil)

	-- ZH: for both pref and suf, append zh_suffix directly after the linked name.
	-- (EN prefix construction "placetype of placename" is not used in Chinese.)
	if zh_sfx_to_append then
		output = output .. zh_sfx_to_append
	end

	-- No article for Chinese.
	return output
end


-- Format holonyms for a ZH old-style place description.
-- Collects all holonyms, filters "and"/"or" connectors, reverses for big→small containment order,
-- groups consecutive same-type holonyms (peer locations), then joins with ZH enumeration (、/和).
-- Returns the concatenated holonym string, e.g. "美國賓夕法尼亞州", "波蘭、白俄羅斯和烏克蘭".
local function format_holonyms_zh(place_desc)
	local holonyms = place_desc.holonyms
	if not holonyms or #holonyms == 0 then
		return ""
	end

	-- Collect non-"and"/"or"/"的" holonym items with their original indices.
	-- A raw "的" holonym (from |的| in the template, like enwikt's |of|) signals possessive preposition;
	-- filter it from display since format_old_style_place_desc_for_display already appends de_particle.
	local items = {}
	local has_de_joiner = false
	for i, h in ipairs(holonyms) do
		if not h.no_display then
			local dname = h.display_placename
			if dname ~= "and" and dname ~= "or" then
				if not h.placetype and dname == zh_strings.de_particle then
					has_de_joiner = true
				else
					insert(items, {holonym_index = i, placetype = h.placetype})
				end
			end
		end
	end

	-- Separate trailing raw text (items after the last structural holonym) from non-trailing.
	-- Trailing raw text should NOT be reversed; it is appended after the full description with ,.
	local last_structural_idx = 0
	for i = #items, 1, -1 do
		if items[i].placetype then
			last_structural_idx = i
			break
		end
	end
	local non_trailing = {}
	local trailing_texts = {}
	for i = 1, #items do
		if i <= last_structural_idx then
			insert(non_trailing, items[i])
		else
			insert(trailing_texts, holonyms[items[i].holonym_index].display_placename)
		end
	end

	-- Reverse non-trailing for big→small order.
	local reversed = {}
	for i = #non_trailing, 1, -1 do
		insert(reversed, non_trailing[i])
	end

	-- Group consecutive items with the same placetype (peers like multiple countries).
	-- Raw-text holonyms (no placetype) break any current structural group.
	local groups = {}
	local i = 1
	while i <= #reversed do
		local item = reversed[i]
		if not item.placetype then
			-- Raw text (e.g. "南部", "東北部") forms its own singleton group.
			local h = holonyms[item.holonym_index]
			insert(groups, {type = "raw", text = h.display_placename})
			i = i + 1
		else
			-- Structural holonym: collect consecutive ones of the same placetype.
			local group = {type = "structural", items = {item}}
			local pt = item.placetype
			i = i + 1
			while i <= #reversed do
				local next_item = reversed[i]
				if next_item.placetype == pt then
					insert(group.items, next_item)
					i = i + 1
				else
					break
				end
			end
			insert(groups, group)
		end
	end

	-- Format each group and assemble.
	local parts = {}
	for _, group in ipairs(groups) do
		if group.type == "raw" then
			insert(parts, group.text)
		else
			local formatted = {}
			for _, item in ipairs(group.items) do
				insert(formatted, format_holonym(place_desc, item.holonym_index, false))
			end
			if #formatted == 1 then
				insert(parts, formatted[1])
			elseif #formatted == 2 then
				insert(parts, formatted[1] .. zh_strings.and_conj .. formatted[2])
			else
				local last = table.remove(formatted)
				insert(parts, concat(formatted, zh_strings.enum_sep) .. zh_strings.and_conj .. last)
			end
		end
	end

	return concat(parts), has_de_joiner, trailing_texts
end


-- Get the Chinese display name for a single (possibly qualified) placetype.
-- Strips qualifiers, looks up zh_name via m_zh_data, and prepends translated qualifier text.
-- `first_holonym_placetype` enables context-sensitive names (e.g. capital city → 首都/省會/首府).
local function get_zh_placetype_name(placetype, first_holonym_placetype)
	local resolved_pt = m_placetypes.resolve_placetype_aliases(placetype)
	local zh_name, _, zh_by_holonym = m_zh_data.get_zh_placetype_props(resolved_pt, m_placetypes)
	if zh_name then
		local resolved = m_zh_data.resolve_zh_name(zh_name, zh_by_holonym, first_holonym_placetype)
		return m_zh_data.format_zh_name(resolved)
	end
	-- Fallback to English name; add tracking for missing zh_data entries.
	track("missing-zh-placetype/" .. tostring(placetype))
	return placetype
end


-- Format a single entry placetype (possibly with qualifiers) for ZH display.
-- Strips qualifiers from the placetype, translates each qualifier via zh_qualifiers,
-- and prepends them to the Chinese placetype name (with wikilink markup applied).
local function format_one_zh_placetype(pt, first_holonym_placetype)
	-- Try progressive qualifier splits; use the first that has a zh_name entry.
	local splits = m_placetypes.split_qualifiers_from_placetype(pt)
	local qualifier_prefix = ""
	for _, sp in ipairs(splits) do
		local prev_qualifier, this_qualifier, bare_pt = unpack(sp, 1, 3)
		-- Accumulate qualifier strings.
		local q_str = ""
		if prev_qualifier then
			q_str = (m_zh_data.zh_qualifiers[prev_qualifier] or prev_qualifier) ..
			        (m_zh_data.zh_qualifiers[this_qualifier] or (this_qualifier or ""))
		elseif this_qualifier then
			q_str = m_zh_data.zh_qualifiers[this_qualifier] or this_qualifier
		end
		local zh_n = m_placetypes.get_zh_placetype_display_form(bare_pt, first_holonym_placetype)
		if zh_n then
			-- Found a known ZH entry; return qualifier + linked display form.
			return q_str .. zh_n
		end
		qualifier_prefix = q_str
	end
	-- Fallback: translate the full (possibly unrecognized) placetype; link if known.
	return qualifier_prefix .. (m_placetypes.get_zh_placetype_display_form(pt, first_holonym_placetype)
	                            or get_zh_placetype_name(pt, first_holonym_placetype))
end


-- Build the ZH entry placetype description from the `placetypes` list.
-- Handles "and"/"or" splitting (e.g. "城市/and/市鎮" → "城市和市鎮"),
-- multiple placetypes without and/or (joined with "、...和"),
-- and context-sensitive names via first_holonym_placetype.
local function get_zh_entry_placetype_desc(placetypes, first_holonym_placetype)
	local is_ignorable = m_placetypes.placetype_is_ignorable

	-- Find last and/or position.
	local and_or_pos
	for i, pt in ipairs(placetypes) do
		if pt == "and" or pt == "or" then
			and_or_pos = i
		end
	end

	if and_or_pos then
		-- Split into group before and group after the conjunction.
		local group1, group2 = {}, {}
		for i = 1, and_or_pos - 1 do
			if not is_ignorable(placetypes[i]) then
				insert(group1, format_one_zh_placetype(placetypes[i], first_holonym_placetype))
			end
		end
		for i = and_or_pos + 1, #placetypes do
			if not is_ignorable(placetypes[i]) then
				insert(group2, format_one_zh_placetype(placetypes[i], first_holonym_placetype))
			end
		end
		local conj_str = placetypes[and_or_pos] == "or" and "或" or zh_strings.and_conj
		local all = {}
		for _, v in ipairs(group1) do insert(all, v) end
		for _, v in ipairs(group2) do insert(all, v) end
		if #all == 0 then return "" end
		if #all == 1 then return all[1] end
		local last = table.remove(all)
		return concat(all, zh_strings.enum_sep) .. conj_str .. last
	else
		-- No explicit and/or: collect all non-ignorable placetypes.
		local formatted = {}
		for _, pt in ipairs(placetypes) do
			if not is_ignorable(pt) then
				insert(formatted, format_one_zh_placetype(pt, first_holonym_placetype))
			end
		end
		if #formatted == 0 then return "" end
		if #formatted == 1 then return formatted[1] end
		local last = table.remove(formatted)
		return concat(formatted, zh_strings.enum_sep) .. zh_strings.and_conj .. last
	end
end


-- Return the ZH description of a placetype for use in new-style <<placetype>> segments.
local function get_placetype_description(placetype, first_holonym_placetype)
	return format_one_zh_placetype(placetype, first_holonym_placetype)
end


-- Return the ZH description of a qualifier for use in new-style <<qualifier>> segments.
local function get_qualifier_description(qualifier)
	-- Trim to just the qualifier word (split_qualifiers_from_placetype requires a trailing non-qualifier word).
	local splits = m_placetypes.split_qualifiers_from_placetype(qualifier .. " foo")
	local sp = splits[#splits]
	local prev_qualifier, this_qualifier = unpack(sp, 1, 2)
	if this_qualifier then
		local q = m_zh_data.zh_qualifiers[this_qualifier] or this_qualifier
		if prev_qualifier then
			return (m_zh_data.zh_qualifiers[prev_qualifier] or prev_qualifier) .. q
		end
		return q
	end
	return qualifier
end


-- Format a set of form-of directive terms (unchanged from enwikt, only conjunction uses zh_strings).
local function format_form_of_directive(overall_place_spec, directive_terms, ucfirst, from_tcl)
	local formatted_terms = {}

	local placetypes
	if not overall_place_spec.descs[2] then
		placetypes = overall_place_spec.descs[1].placetypes
	else
		placetypes = {}
		for _, desc in ipairs(overall_place_spec.descs) do
			m_table.extend(placetypes, desc.placetypes)
		end
	end
	for _, termobj in ipairs(directive_terms.terms) do
		local placename_article
		if not termobj.alt and termobj.term and not termobj.term:find("%[%[") then
			placename_article = get_placename_article(termobj.term, placetypes)
		end
		local linked_term = m_links.full_link(termobj, "term", nil, "show qualifiers")
		linked_term = "<span class='form-of-definition-link'>" .. linked_term .. "</span>"
		if termobj.eq then
			linked_term = linked_term .. " (= " .. m_links.full_link {term = termobj.eq, lang = zhlang} .. ")"
		end
		if placename_article then
			linked_term = placename_article .. " " .. linked_term
		end
		insert(formatted_terms, linked_term)
	end

	local spec = directive_terms.spec
	local text = spec.text
	if type(text) == "function" then
		text = text(overall_place_spec)
	end
	if text == "+" then
		text = directive_terms.directive
	end
	-- Look up Chinese translation; all non-"+" directives also get translated.
	local zh_text = m_zh_data.zh_form_of_directive_labels[directive_terms.directive]
	if zh_text then
		text = zh_text
	end
	if ucfirst then
		text = m_strutils.ucfirst(text)
	end
	if not from_tcl then
		local tracking_prefix = "form-of/" .. directive_terms.directive
		track(tracking_prefix)
		local langcode = overall_place_spec.lang:getCode()
		local full_langcode = overall_place_spec.lang:getFullCode()
		track(tracking_prefix .. "/" .. langcode)
		if full_langcode ~= langcode then
			track(tracking_prefix .. "/" .. full_langcode)
		end
		if full_langcode ~= "zh" then
			track(tracking_prefix .. "/non-chinese")
		end
	end
	return (require(form_of_module).format_form_of {
		text = text,
		lemmas = m_table.serialCommaJoin(formatted_terms, {conj = directive_terms.conj or spec.conjunction or zh_strings.and_conj}),
		lemma_classes = false,
	})
end


-- Format extra-info terms (capital, largest city, modern name, etc.) for ZH.
-- * Labels come from m_zh_data.zh_extra_info_labels; capital/caplc use zh_capital_label_by_holonym_type,
--   resolved first by entry placetype (國家→首都, 省→省會…), then by holonym placetype as fallback.
-- * Separator: "。" + label in sentence style; ";" + label otherwise.
-- * Multiple terms joined with 、/和 instead of serial comma.
-- * auto_plural is skipped (Chinese doesn't pluralize).
local function format_extra_info(overall_place_spec, extra_info_terms, sentence_style)
	local formatted_terms = {}

	for _, termobj in ipairs(extra_info_terms.terms) do
		insert(formatted_terms, m_links.full_link(termobj, nil, nil, "show qualifiers"))
	end

	local spec = extra_info_terms.spec

	-- Resolve ZH label: look up by spec.arg, then spec.text as fallback.
	local zh_label = m_zh_data.zh_extra_info_labels[spec.arg]
	if not zh_label then
		-- Try spec.text as the lookup key (e.g. "capital and largest city").
		local spec_text = spec.text
		if type(spec_text) == "function" then
			spec_text = spec_text(overall_place_spec)
		end
		zh_label = m_zh_data.zh_extra_info_labels[spec_text] or spec_text or spec.arg
	end

	-- Context-sensitive capital label (首都/省會/首府/縣治…) for `capital=` and `caplc=`.
	-- Priority: entry placetype (alias-resolved) → holonym placetype → keep existing zh_label.
	-- For caplc, appends "和最大城市" to the resolved capital label.
	if spec.arg == "capital" or spec.arg == "caplc" then
		local cap_label = nil
		local desc1 = overall_place_spec.descs and overall_place_spec.descs[1]
		-- 1. Entry placetype (e.g. 國家→country→首都, 省→province→省會)
		if desc1 then
			for _, raw_pt in ipairs(desc1.placetypes) do
				if not m_placetypes.placetype_is_ignorable(raw_pt) and raw_pt ~= "and" and raw_pt ~= "or" then
					cap_label = m_placetypes.get_equiv_placetype_prop(raw_pt, function(pt)
						return m_zh_data.zh_capital_label_by_holonym_type[pt]
					end)
					if cap_label then break end
				end
			end
		end
		-- 2. Holonym placetype fallback (previous behaviour)
		if not cap_label and desc1 and desc1.holonyms then
			for _, h in ipairs(desc1.holonyms) do
				if h.placetype then
					cap_label = m_zh_data.zh_capital_label_by_holonym_type[h.placetype]
					if cap_label then break end
				end
			end
		end
		if cap_label then
			zh_label = spec.arg == "caplc" and (cap_label .. "和最大城市") or cap_label
		end
	end

	local text = zh_label
	if spec.with_colon then
		text = text .. ":"  -- full-width colon
	end

	if sentence_style and spec.match_sentence_style then
		text = "。" .. text
	else
		text = zh_strings.semicolon .. text
	end

	-- Join terms with ZH enumeration.
	local conj = extra_info_terms.conj or zh_strings.and_conj
	local joined
	if #formatted_terms == 0 then
		joined = ""
	elseif #formatted_terms == 1 then
		joined = formatted_terms[1]
	elseif #formatted_terms == 2 then
		joined = formatted_terms[1] .. conj .. formatted_terms[2]
	else
		local last = table.remove(formatted_terms)
		joined = concat(formatted_terms, zh_strings.enum_sep) .. conj .. last
	end

	return text .. joined
end


-- Format an old-style place description for ZH display.
-- Chinese pattern: [位於|""][holonym_string][的][zh_placetype_name]
-- * Holonyms are reversed (big→small), grouped, and joined with 、/和.
-- * "位於" is prepended for "in"-type placetypes; omitted for "of"-type (e.g. capitals).
-- * args.a (article override) is used as a prefix to the placetype name.
local function format_old_style_place_desc_for_display(args, place_desc, desc_index, with_article, ucfirst_flag)
	local placetypes = place_desc.placetypes

	-- Determine the entry placetype (last non-ignorable, non-conjunction item) for preposition lookup.
	local entry_placetype_for_prep = nil
	for i = #placetypes, 1, -1 do
		local pt = placetypes[i]
		if not m_placetypes.placetype_is_ignorable(pt) and pt ~= "and" and pt ~= "or" then
			entry_placetype_for_prep = pt
			break
		end
	end

	-- Determine first structured holonym placetype for context-sensitive zh_name lookups.
	local first_holonym_placetype = nil
	if place_desc.holonyms then
		for _, h in ipairs(place_desc.holonyms) do
			if h.placetype then
				first_holonym_placetype = h.placetype
				break
			end
		end
	end

	-- Build the Chinese placetype description (possibly with qualifiers and multiple types).
	local zh_pt_name = get_zh_entry_placetype_desc(placetypes, first_holonym_placetype)

	-- Handle args.a (article override): used as a direct prefix in Chinese, e.g. a=盎格魯撒克遜 → "盎格魯撒克遜王國".
	if with_article and desc_index == 1 and args.a then
		zh_pt_name = args.a .. zh_pt_name
	end

	-- Build holonym string (reversed, grouped, ZH-joined).
	-- has_de_joiner is true when a bare |的| token was in the holonym list (like enwikt's |of|).
	local holonym_str, has_de_joiner, trailing_texts = "", false, {}
	if place_desc.holonyms and #place_desc.holonyms > 0 then
		holonym_str, has_de_joiner, trailing_texts = format_holonyms_zh(place_desc)
	end

	-- Determine preposition style ("in" → 位於…的; "of" → …的 with no 位於).
	-- An explicit |的| joiner in the template overrides to possessive (no 位於).
	local use_wei_yu = true
	if has_de_joiner then
		use_wei_yu = false
	elseif entry_placetype_for_prep then
		local prep = m_placetypes.get_placetype_entry_preposition(entry_placetype_for_prep)
		if prep == "of" then
			use_wei_yu = false
		end
	end

	-- Assemble final string.
	local result
	if holonym_str ~= "" then
		if use_wei_yu then
			result = zh_strings.in_preposition .. holonym_str .. zh_strings.de_particle .. zh_pt_name
		else
			result = holonym_str .. zh_strings.de_particle .. zh_pt_name
		end
	else
		result = zh_pt_name
	end
	if #trailing_texts > 0 then
		result = result .. zh_strings.trans_sep .. concat(trailing_texts, zh_strings.trans_sep)
	end
	return result
end


--[==[
Get the full gloss (Chinese description) of a new-style place description. New-style place descriptions are
specified with a single string containing raw text interspersed with placetypes and holonyms surrounded by `<<...>>`.
Exported for use by [[Module:demonyms]].
]==]
function export.format_new_style_place_desc_for_display(args, place_desc, with_article)
	local parts = {}
	local function ins(txt)
		insert(parts, txt)
	end

	-- args.a not used in Chinese (no article system).

	local first_holonym_placetype = nil
	if place_desc.holonyms then
		for _, h in ipairs(place_desc.holonyms) do
			if h.placetype then
				first_holonym_placetype = h.placetype
				break
			end
		end
	end

	local max_holonym = 0
	for _, order in ipairs(place_desc.order) do
		local segment_type, segment = order.type, order.value
		if segment_type == "raw" then
			ins(segment)
		elseif segment_type == "placetype" then
			ins(get_placetype_description(segment, first_holonym_placetype))
		elseif segment_type == "qualifier" then
			ins(get_qualifier_description(segment))
		elseif segment_type == "holonym" then
			ins(format_holonym(place_desc, segment, false))
			if segment > max_holonym then
				max_holonym = segment
			end
		else
			internal_error("Unrecognized segment type %s", segment_type)
		end
	end

	if place_desc.holonyms and max_holonym < #place_desc.holonyms then
		local holonym_no_prefix = true
		for holonym_index = max_holonym + 1, #place_desc.holonyms do
			-- Append remaining holonyms with a space prefix (no "in"/"of" for new-style).
			ins(" " .. format_holonym(place_desc, holonym_index, false))
			holonym_no_prefix = false
		end
	end

	return concat(parts)
end


-- Return a string with the gloss (the description of the place itself, as opposed to translations).
-- `sentence_style` controls whether extra-info uses "。" (sentence) or ";" (semicolon) separation.
local function get_display_form(data)
	local overall_place_spec, ucfirst, sentence_style, drop_extra_info, extra_info_overridden_set, from_tcl =
		data.overall_place_spec, data.ucfirst, data.sentence_style, data.drop_extra_info,
		data.extra_info_overridden_set, data.from_tcl
	local args = overall_place_spec.args
	local parts = {}
	local function ins(txt)
		table.insert(parts, txt)
	end

	if overall_place_spec.directives and overall_place_spec.directives[1] then
		for i, directive_terms in ipairs(overall_place_spec.directives) do
			ins(directive_terms.pretext)
			if directive_terms.pretext ~= "" then
				ucfirst = false
			end
			if not args.def or args.def == "-" then
				ins(format_form_of_directive(overall_place_spec, directive_terms, ucfirst, from_tcl))
				ucfirst = false
				if i == #overall_place_spec.directives and directive_terms.posttext then
					ins(directive_terms.posttext)
				end
			end
		end
	end
	if args.def == "-" then
		return concat(parts)
	end
	if args.def then
		if args.def:find("<<") then
			local def_desc = export.parse_new_style_place_desc(args.def, args[1])
			ins(export.format_new_style_place_desc_for_display({}, def_desc, false))
		else
			ins(args.def)
		end
	else
		local include_article = true
		for n, desc in ipairs(overall_place_spec.descs) do
			if desc.order then
				ins(export.format_new_style_place_desc_for_display(args, desc, n == 1))
			else
				ins(format_old_style_place_desc_for_display(args, desc, n, include_article, ucfirst))
			end
			if desc.joiner then
				ins(desc.joiner)
			end
			include_article = desc.include_following_article
			ucfirst = false
		end
	end

	local addl = args.addl
	if addl then
		if addl:find("^[;:]") then
			ins(addl)
		elseif addl:find("^_") then
			ins(" " .. addl:sub(2))
		else
			ins("," .. addl)
		end
	end

	for _, extra_info_terms in ipairs(overall_place_spec.extra_info) do
		if not drop_extra_info or extra_info_terms.spec.display_even_when_dropped or
			extra_info_overridden_set and extra_info_overridden_set[extra_info_terms.spec.arg] then
			ins(format_extra_info(overall_place_spec, extra_info_terms, sentence_style))
		end
	end

	return concat(parts)
end

-- Return the definition line.
-- ZH: sentence_style is always true; ucfirst is always false (no case in Chinese);
-- parentheses around glosses are full-width ().
local function get_def(data)
	local overall_place_spec, from_tcl, drop_extra_info, extra_info_overridden_set, translation_follows =
		data.overall_place_spec, data.from_tcl, data.drop_extra_info, data.extra_info_overridden_set,
		data.translation_follows
	local args = overall_place_spec.args
	local sentence_style = true    -- always sentence style in Chinese
	local ucfirst = false          -- Chinese has no case
	if #args.t > 0 then
		local gloss = get_display_form {
			overall_place_spec = overall_place_spec,
			ucfirst = false,
			sentence_style = false,
			drop_extra_info = drop_extra_info,
			extra_info_overridden_set = extra_info_overridden_set,
			from_tcl = from_tcl,
		}
		if from_tcl and not args.tcl_nolc then
			gloss = m_strutils.lcfirst(gloss)
		end
		if translation_follows then
			return (gloss == "" and "" or gloss .. ":") .. get_translations(args.t, args.tid)
		else
			return get_translations(args.t, args.tid) ..
				(gloss == "" and "" or zh_strings.paren_open .. gloss .. zh_strings.paren_close)
		end
	else
		return get_display_form {
			overall_place_spec = overall_place_spec,
			ucfirst = ucfirst,
			sentence_style = sentence_style,
			drop_extra_info = drop_extra_info,
			extra_info_overridden_set = extra_info_overridden_set,
			from_tcl = from_tcl,
		}
	end
end



---------- Functions for the category wikicode

-- The code in this section finds the categories to which a given place belongs. See enwikt [[Module:place]]
-- for detailed documentation of the algorithm.

--[=[
Find the appropriate category specs for a given place description and placetype.
See enwikt [[Module:place]] for detailed documentation.
]=]
local function find_placetype_cat_specs(data)
	local entry_placetype, place_desc, first_holonym_index, overriding_holonym, from_demonym =
		data.entry_placetype, data.place_desc, data.first_holonym_index, data.overriding_holonym, data.from_demonym
	local form_of_directive = data.form_of_directive
	local function fetch_cat_specs(holonym_to_match, index, no_fallback)
		local holonym_placetype = holonym_to_match.placetype
		if not holonym_placetype then
			return nil
		end
		local holonym_placename = holonym_to_match.unlinked_placename
		if not holonym_placename then
			internal_error("Missing unlinked_placename in holonym (index %s): %s", index, holonym_to_match)
		end

		local cat_specs, equiv_entry_placetype_and_qualifier = m_placetypes.get_equiv_placetype_prop(entry_placetype,
			function(equiv_entry_pt)
				return m_placetypes.get_equiv_placetype_prop(holonym_placetype,
					function(equiv_holonym_pt) return m_placetypes.political_division_cat_handler {
						entry_placetype = equiv_entry_pt,
						holonym_placetype = equiv_holonym_pt,
						holonym_placename = holonym_placename,
						holonym_index = index,
						place_desc = place_desc,
						from_demonym = from_demonym,
					} end)
			end,
			{no_fallback = no_fallback, form_of_directive = form_of_directive}
		)
		if cat_specs and cat_specs[1] then
			return cat_specs, equiv_entry_placetype_and_qualifier.placetype
		end
		local cat_handler, equiv_entry_placetype_and_qualifier = m_placetypes.get_equiv_placetype_prop(entry_placetype,
			function(equiv_entry_pt)
				local entry_placetype_data = m_placetypes.placetype_data[equiv_entry_pt]
				if entry_placetype_data and entry_placetype_data.cat_handler then
					return entry_placetype_data.cat_handler
				end
			end,
			{no_fallback = no_fallback, form_of_directive = form_of_directive}
		)
		if cat_handler then
			local cat_specs = m_placetypes.get_equiv_placetype_prop(holonym_placetype,
				function(equiv_holonym_pt) return cat_handler {
					entry_placetype = equiv_entry_placetype_and_qualifier.placetype,
					holonym_placetype = equiv_holonym_pt,
					holonym_placename = holonym_placename,
					holonym_index = index,
					place_desc = place_desc,
					from_demonym = from_demonym,
				} end)
			if cat_specs and cat_specs[1] then
				return cat_specs, equiv_entry_placetype_and_qualifier.placetype
			end
		end
		if not no_fallback then
			local cat_specs, equiv_entry_placetype_and_qualifier = m_placetypes.get_equiv_placetype_prop(entry_placetype,
				function(equiv_entry_pt)
					local entry_placetype_data = m_placetypes.placetype_data[equiv_entry_pt]
					if entry_placetype_data then
						return m_placetypes.get_equiv_placetype_prop(holonym_placetype,
							function(equiv_holonym_pt)
								return entry_placetype_data[equiv_holonym_pt .. "/*"]
							end)
					end
				end,
				{form_of_directive = form_of_directive}
			)
			if cat_specs and cat_specs[1] then
				return cat_specs, equiv_entry_placetype_and_qualifier.placetype
			end
		end
		return nil
	end

	if overriding_holonym then
		local cat_specs, fetched_entry_placetype = fetch_cat_specs(overriding_holonym, nil)
		if cat_specs and cat_specs[1] then
			return {
				entry_placetype = fetched_entry_placetype,
				cat_specs = cat_specs,
				triggering_holonym = overriding_holonym,
			}
		end
	else
		-- First pass: no fallback (exact entry placetype match only).
		for i, holonym in ipairs(place_desc.holonyms) do
			if first_holonym_index and i < first_holonym_index then
				-- continue
			else
				local cat_specs, fetched_entry_placetype = fetch_cat_specs(holonym, i, "no_fallback")
				if cat_specs and cat_specs[1] then
					return {
						entry_placetype = fetched_entry_placetype,
						cat_specs = cat_specs,
						triggering_holonym = holonym,
						triggering_holonym_index = i,
					}
				end
			end
		end
		-- Second pass: with fallback.
		for i, holonym in ipairs(place_desc.holonyms) do
			if first_holonym_index and i < first_holonym_index then
				-- continue
			else
				local cat_specs, fetched_entry_placetype = fetch_cat_specs(holonym, i)
				if cat_specs and cat_specs[1] then
					return {
						entry_placetype = fetched_entry_placetype,
						cat_specs = cat_specs,
						triggering_holonym = holonym,
						triggering_holonym_index = i,
					}
				end
			end
		end
	end

	return nil
end


-- Turn a list of category specs (see comment at section top) into the corresponding categories
-- (minus the language code prefix).
local function cat_specs_to_categories(place_desc, cat_data)
	local all_cats = {}

	local cat_specs, entry_placetype, triggering_holonym, triggering_holonym_index =
		cat_data.cat_specs, cat_data.entry_placetype, cat_data.triggering_holonym, cat_data.triggering_holonym_index
	if triggering_holonym then
		for _, cat_spec in ipairs(cat_specs) do
			local cat
			if cat_spec == true then
				-- ZH: 1-char zh_name → "的" + name (e.g. "的省"); 2+ chars → no "的" (e.g. "城市").
				local zh_raw = m_placetypes.get_zh_placetype_name(entry_placetype)
				local de = zh_raw and m_placetypes.zh_cat_de(zh_raw) or zh_strings.de_particle
				local zh_str = zh_raw and m_zh_data.format_zh_name(zh_raw) or entry_placetype
				cat = "+++" .. de .. zh_str
			else
				cat = cat_spec
			end

			if cat:find("%+%+%+") then
				local group, key, spec, container_trail = m_placetypes.find_matching_holonym_location {
					holonym_placetype = triggering_holonym.placetype,
					holonym_placename = triggering_holonym.unlinked_placename,
					holonym_index = triggering_holonym_index,
					place_desc = place_desc,
				}
				if group then
					cat = cat:gsub("%+%+%+", m_strutils.replacement_escape(m_placetypes.get_prefixed_key(key, spec)))
					insert(all_cats, cat)
				else
					mw.log(("Unable to insert category for cat spec '%s' because holonym '%s/%s' did not match a " ..
						"known location"):format(cat, triggering_holonym.placetype, triggering_holonym.unlinked_placename))
					track("cant-match-holonym-for-category-spec")
				end
			else
				insert(all_cats, cat)
			end
		end
	else
		for _, cat_spec in ipairs(cat_specs) do
			local cat
			if cat_spec == true then
				-- ZH: bare placetype category (e.g. "城市")
				cat = get_zh_placetype_name(entry_placetype)
			else
				cat = cat_spec
				if cat:find("%+%+%+") then
					internal_error("Category %s contains +++ but there is no holonym to substitute", cat)
				end
			end
			insert(all_cats, cat)
		end
	end

	return all_cats
end


-- Return the categories (without initial lang code) that should be added to the entry, given the place description
-- and a particular entry placetype.
local function get_placetype_cats(place_desc, entry_placetype, from_demonym, form_of_directive)
	local cats = {}

	local first_holonym_index = 1
	while first_holonym_index <= #place_desc.holonyms do
		local cat_data = find_placetype_cat_specs {
			entry_placetype = entry_placetype,
			place_desc = place_desc,
			first_holonym_index = first_holonym_index,
			from_demonym = from_demonym,
			form_of_directive = form_of_directive,
		}

		if not cat_data then
			break
		end

		local triggering_holonym = cat_data.triggering_holonym
		if not triggering_holonym then
			internal_error("find_placetype_cat_specs should have returned a triggering holonym: %s", cat_data)
		end

		extend(cats, cat_specs_to_categories(place_desc, cat_data))

		first_holonym_index = cat_data.triggering_holonym_index

		local equiv_triggering_placetypes = m_placetypes.get_placetype_equivs(triggering_holonym.placetype,
			{no_fallback = true})
		for _, equiv in ipairs(equiv_triggering_placetypes) do
			local other_holonyms_of_same_type = place_desc.holonyms_by_placetype[equiv.placetype]
			if other_holonyms_of_same_type then
				for _, other_placename_of_same_type in ipairs(other_holonyms_of_same_type) do
					if other_placename_of_same_type ~= triggering_holonym.unlinked_placename then
						local overriding_holonym = {
							placetype = triggering_holonym.placetype,
							unlinked_placename = other_placename_of_same_type,
						}
						local other_cat_data = find_placetype_cat_specs {
							entry_placetype = entry_placetype,
							place_desc = place_desc,
							overriding_holonym = overriding_holonym,
							from_demonym = from_demonym,
							form_of_directive = form_of_directive,
						}
						if other_cat_data then
							extend(cats, cat_specs_to_categories(place_desc, other_cat_data))
						end
					end
				end
			end
		end

		first_holonym_index = first_holonym_index + 1
		while first_holonym_index <= #place_desc.holonyms do
			if place_desc.holonyms[first_holonym_index].continue_cat_loop then
				break
			end
			first_holonym_index = first_holonym_index + 1
		end
	end

	if cats[1] then
		return cats
	end

	local entry_pt_default, equiv_entry_placetype_and_qualifier =
		m_placetypes.get_equiv_placetype_prop(entry_placetype, function(pt)
			return m_placetypes.placetype_data[pt] and m_placetypes.placetype_data[pt].default
		end,
		{form_of_directive = form_of_directive})

	if entry_pt_default then
		return cat_specs_to_categories(place_desc, {
			cat_specs = entry_pt_default,
			entry_placetype = equiv_entry_placetype_and_qualifier.placetype,
		})
	end

	return {}
end


--[==[
Iterate through each type of place and return a list of the categories that need to be added to the entry.
See enwikt [[Module:place]] for detailed documentation.
]==]
function export.get_cats(args, overall_place_spec, from_demonym)
	local cats = {}
	local place_descriptions = overall_place_spec.descs

	handle_category_implications(place_descriptions, m_placetypes.cat_implications)
	m_placetypes.augment_holonyms_with_container(place_descriptions)

	if overall_place_spec.directives then
		for _, directive_terms in ipairs(overall_place_spec.directives) do
			local spec_cats = directive_terms.spec.cat
			if spec_cats then
				if type(spec_cats) == "string" then
					spec_cats = {spec_cats}
				end
				for _, spec_cat in ipairs(spec_cats) do
					insert(cats, spec_cat)
				end
			end
			if directive_terms.spec.type_prefix then
				for _, place_desc in ipairs(place_descriptions) do
					for _, placetype in ipairs(place_desc.placetypes) do
						if not m_placetypes.placetype_is_ignorable(placetype) then
							extend(cats, get_placetype_cats(place_desc, placetype, from_demonym,
								directive_terms.spec.type_prefix))
						end
					end
				end
			end
		end
	end

	if not from_demonym then
		local bare_categories = m_placetypes.get_bare_categories(args, overall_place_spec)
		extend(cats, bare_categories)
	end

	for _, place_desc in ipairs(place_descriptions) do
		if not from_demonym then
			for _, placetype in ipairs(place_desc.placetypes) do
				if not m_placetypes.placetype_is_ignorable(placetype) then
					extend(cats, get_placetype_cats(place_desc, placetype))
				end
			end
		end
		extend(cats, get_placetype_cats(place_desc, "*", from_demonym))
	end

	if args.cat then
		for _, cat in ipairs(args.cat) do
			local split_cats = split_on_comma(cat)
			extend(cats, split_cats)
		end
	end

	return cats
end


-- Return the category wikicode for a list of categories.
-- ZH: uses `lang:getFullName() .. " " .. cat` format for regular categories (same as cln: format in enwikt),
-- matching zhwikt's convention of "漢語 XX" category names instead of "zh:XX".
local function format_cats(lang, cats, sort_key)
	local full_cats = {}
	local langname = lang:getFullName()
	for _, cat in ipairs(cats) do
		local cln_cat = cat:match("^cln:(.*)$")
		if cln_cat then
			insert(full_cats, langname .. " " .. cln_cat)
		else
			-- ZH: use langname prefix instead of langcode:cat
			insert(full_cats, langname .. " " .. cat)
		end
	end
	return require(utilities_module).format_categories(full_cats, lang, sort_key, nil,
		force_cat or m_placetypes.get_force_cat())
end



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


--[==[
Implementation of {{tl|place}}. Meant to be callable from another module.
See enwikt [[Module:place]] for full documentation of the `data` argument fields.
]==]
function export.format(data)
	local template_args = data.template_args
	local list_param = {list = true}
	local boolean_param = {type = "boolean"}
	local params = {
		[1] = {required = true, type = "language", default = "und"},
		[2] = {required = true, list = true},
		["t"] = list_param,
		["tid"] = {list = true, allow_holes = true},
		["cat"] = list_param,
		["nocat"] = boolean_param,
		["nocap"] = boolean_param,
		["sort"] = true,
		["pagename"] = true, -- for testing or documentation purposes

		["a"] = true,
		["addl"] = true,
		["def"] = true,

		-- params that are only used when transcluding using {{tcl}}/{{transclude}}, to transmit information to {{tcl}}.
		["tcl"] = true,
		["tcl_t"] = list_param,
		["tcl_tid"] = list_param,
		["tcl_nolb"] = true,
		["tcl_nolc"] = boolean_param,
		["tcl_noextratext"] = boolean_param,
	}

	-- add "extra info" parameters
	for _, extra_arg_spec in ipairs(export.extra_info_args) do
		params[extra_arg_spec.arg] = list_param
	end

	if template_args.def == "" then
		error("Cannot currently pass def= as an empty parameter; use def=- if you want to suppress the definition display")
	end
	local args = require("Module:parameters").process(template_args, params)
	if args.a then
		track("a")
	end
	data.args = args
	local overall_place_spec = parse_overall_place_spec(data)
	data.overall_place_spec = overall_place_spec

	return get_def(data) .. (
		args.nocat and "" or format_cats(args[1], export.get_cats(args, overall_place_spec), args.sort))
end


--[==[
Actual entry point of {{tl|place}}.
]==]
function export.show(frame)
	return export.format {
		template_args = frame:getParent().args,
	}
end


return export