Module:Quote

From The Languages of David J. Peterson
Revision as of 05:44, 28 March 2024 by Juelos (talk | contribs) (Created page with "--[=[ This module contains functions to implement quote-* templates. Author: Benwing2; conversion into Lua of {{quote-meta/source}} template, written by Sgconlaw with some help from Erutuon and Benwing2. The main interface is quote_t(). Note that the source display is handled by source(), which reads both the arguments passed to it *and* the arguments passed to the parent template, with the former overriding the latter. ]=] local export = {} -- Named constants f...")
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)
Jump to navigation Jump to search

Documentation for this module may be created at Module:Quote/documentation

--[=[
	This module contains functions to implement quote-* templates.

	Author: Benwing2; conversion into Lua of {{quote-meta/source}} template,
	written by Sgconlaw with some help from Erutuon and Benwing2.

	The main interface is quote_t(). Note that the source display is handled by source(), which reads both the
	arguments passed to it *and* the arguments passed to the parent template, with the former overriding the latter.
]=]

local export = {}

-- Named constants for all modules used, to make it easier to swap out sandbox versions.
local check_isxn_module = "Module:check isxn"
local debug_track_module = "Module:debug/track"
local dialect_tags_module = "Module:dialect tags"
local italics_module = "Module:italics"
local languages_module = "Module:languages"
local links_module = "Module:links"
local number_utilities_module = "Module:number utilities"
local parameters_module = "Module:parameters"
local parse_utilities_module = "Module:parse utilities"
local qualifier_module = "Module:qualifier"
local roman_numerals_module = "Module:roman numerals"
local script_utilities_module = "Module:script utilities"
local scripts_module = "Module:scripts"
local string_utilities_module = "Module:string utilities"
local table_module = "Module:table"
local usex_module = "Module:usex"
local usex_templates_module = "Module:usex/templates"
local utilities_module = "Module:utilities"
local yesno_module = "Module:yesno"

local rsubn = mw.ustring.gsub
local rmatch = mw.ustring.match
local rfind = mw.ustring.find
local rsplit = mw.text.split
local rgsplit = mw.text.gsplit
local ulen = mw.ustring.len
local usub = mw.ustring.sub
local u = mw.ustring.char

-- Use HTML entities here to avoid parsing issues (esp. with brackets)
local SEMICOLON_SPACE = "; "
local SPACE_LBRAC = " ["
local RBRAC = "]"

local TEMP_LT = u(0xFFF1)
local TEMP_GT = u(0xFFF2)
local TEMP_LBRAC = u(0xFFF3)
local TEMP_RBRAC = u(0xFFF4)
local TEMP_SEMICOLON = u(0xFFF5)
local L2R = u(0x200E)
local R2L = u(0x200F)

html_entity_to_replacement = {
  {entity="<", repl=TEMP_LT},
  {entity=">", repl=TEMP_GT},
  {entity="[", entity_pat="�*91;", repl=TEMP_LBRAC},
  {entity="]", entity_pat="�*93;", repl=TEMP_RBRAC},
}

local function track(page)
	require(debug_track_module)("quote/" .. page)
	return true
end

-- version of rsubn() that discards all but the first return value
local function rsub(term, foo, bar)
	local retval = rsubn(term, foo, bar)
	return retval
end

local function maintenance_line(text)
	return ""
end

local function isbn(text)
	return "[[Special:BookSources/" .. text .. "|→ISBN]]" ..
		require(check_isxn_module).check_isbn(text, " [[Category:Pages with ISBN errors]]")
end

local function issn(text)
	return "[https://www.worldcat.org/issn/" .. text .. " →ISSN]" ..
		require(check_isxn_module).check_issn(text, " [[Category:Pages with ISSN errors]]")
end

local function lccn(text)
	local origtext = text
	text = rsub(text, " ", "")
	if rfind(text, "%-") then
		-- old-style LCCN; reformat per request by [[User:The Editor's Apprentice]]
		local prefix, part1, part2 = rmatch(text, "^(.-)([0-9]+)%-([0-9]+)$")
		if prefix then
			if ulen(part2) < 6 then
				part2 = ("0"):rep(6 - ulen(part2)) .. part2
			end
			text = prefix .. part1 .. part2
		end
	end
	return "[https://lccn.loc.gov/" .. mw.uri.encode(text) .. " →LCCN]"
end

local function format_date(text)
	return mw.getCurrentFrame():callParserFunction{name="#formatdate", args=text}
end

local function tag_nowiki(text)
	return mw.getCurrentFrame():callParserFunction{name="#tag", args={"nowiki", text}}
end

local function split_on_comma(term)
	if term:find(",%s") then
		return require(parse_utilities_module).split_on_comma(term)
	else
		return rsplit(term, ",")
	end
end

local function yesno(val, default)
	if not val then
		return default
	end
	return require(yesno_module)(val, default)
end

-- Convert a raw tag= param (or nil) to a list of formatted dialect tags; unrecognized tags are passed through
-- unchanged. Return nil if nil passed in.
local function tags_to_dialects(lang, tags)
	if not tags then
		return nil
	end
	return require(dialect_tags_module).make_dialects(split_on_comma(tags), lang)
end

-- Convert a comma-separated list of language codes to a comma-separated list of language names. `fullname` is the
-- name of the parameter from which the list of language codes was fetched.
local function format_langs(langs, fullname)
	langs = rsplit(langs, ",")
	for i, langcode in ipairs(langs) do
		local lang = require(languages_module).getByCode(langcode, fullname, "allow etym")
		langs[i] = lang:getCanonicalName()
	end
	if #langs == 1 then
		return langs[1]
	else
		return require(table_module).serialCommaJoin(langs)
	end
end


local function get_first_lang(langs, fullname, get_non_etym, required)
	local langcode = langs and rsplit(langs, ",")[1] or not required and "und" or nil
	local lang = require(languages_module).getByCode(langcode, fullname, "allow etym")
	if get_non_etym then
		lang = lang:getNonEtymological()
	end
	return lang
end


--[=[
Normally we parse off inline modifiers and language code prefixes in various places, e.g. he:מריםame("urls")
		if url and urls then
			error(("Supply only one of |%s= and |%s="):format(url_fullname, urls_fullname))
		end
		local function verify_title_supplied(url_name)
			if not title then
				-- There are too many cases of this to throw an error at this time.
				-- error(("If |%s= is given, |%s= must also be supplied"):format(url_name, title_fullname))
			end
		end
		if archiveurl or url then
			verify_title_supplied(archiveurl and archiveurl_fullname or url_fullname)
			sep = nil
			add("&lrm;<sup>[" .. (archiveurl or url) .. "]</sup>")
		elseif urls then
			verify_title_supplied(urls_fullname)
			sep = nil
			add("&lrm;<sup>" .. urls .. "</sup>")
		end

		if need_comma then
			sep = ", "
		end

		local edition, edition_fullname = parse_and_format_annotated_text_with_name("edition")
		local edition_plain, edition_plain_fullname = parse_and_format_annotated_text_with_name("edition_plain")
		if edition and edition_plain then
			error(("Supply only one of |%s= and |%s="):format(edition_fullname, edition_plain_fullname))
		end
		if edition then
			add_with_sep(edition .. " edition")
		end
		if edition_plain then
			add_with_sep(edition_plain)
		end

		-- Display a numeric param such as page=, volume=, column=. For each `paramname`, four params are actually
		-- recognized, e.g. for paramname == "page", the params page=, pages=, page_plain= and pageurl= are recognized
		-- and checked (or the same with an index, e.g. page2=, pages2=, page_plain2= and pageurl2= respectively if
		-- ind == "2"). Only one of the first three can be specified; an error results if more than one are given.
		-- If none are given, the return value is nil; otherwise it is a string. The numeric spec is taken directly
		-- from e.g. page_plain= if given; otherwise if e.g. pages= is given, or if page= is given and looks like a
		-- combination of numbers (i.e. it has a hyphen or dash in it, a comma, or the word " and "), it is prefixed
		-- by `singular_desc` + "s" (e.g. "pages "), otherwise it is prefixed by just `singular_desc` (e.g. "page ").
		-- (As a special case, if either e.g. page=unnumbered or pages=unnumbered is given, the numeric spec is
		-- "unnumbered page".) The resulting spec is returned directly unless e.g. pageurl= is given, in which case
		-- it is linked to the specified URL. Note that any of the specs can be foreign text, e.g. foreign numbers
		-- (including with optional inline modifiers), and such text is handled appropriately.
		local function format_numeric_param(paramname, singular_desc)
			local sgval, sg_fullname = a_with_name(paramname)
			local sgobj = parse_annotated_text(sgval, paramname)
			local plparamname = paramname .. "s"
			local plval, pl_fullname = a_with_name(plparamname)
			local plobj = parse_annotated_text(plval, plparamname)
			local plainval, plain_fullname = parse_and_format_annotated_text_with_name(paramname .. "_plain")
			local howmany = (sgval and 1 or 0) + (plval and 1 or 0) + (plainval and 1 or 0)
			if howmany > 1 then
				local params_specified = {}
				local function insparam(param)
					if param then
						table.insert(params_specified, ("|%s="):format(param))
					end
				end
				insparam(sg_fullname)
				insparam(pl_fullname)
				insparam(plain_fullname)
				error(("Can't specify more than one of %s"):format(
					require(table_module).serialCommaJoin(params_specified, {dontTag = true})))
			end
			if howmany == 0 then
				return nil
			end
			-- Merge page= and pages= and treat alike because people often mix them up in both directions.
			local numspec
			if plainval then
				numspec = plainval
			else
				local val = sgobj and sgobj.text or plobj.text
				if val == "unnumbered" then
					numspec = "unnumbered " .. singular_desc
				else
					local function get_plural_desc()
						-- Only call when needed to potentially avoid a module load.
						return pluralize(singular_desc)
					end
					local desc
					if val:find("^!") then
						val = val:gsub("^!", "")
						desc = sgval and singular_desc or get_plural_desc()
					else
						local check_val = val
						if check_val:find("%[") then
							check_val = require(links_module).remove_links(check_val)
							-- convert URL's of the form [URL DISPLAY] to the displayed value
							check_val = check_val:gsub("%[[^ %[%]]* ([^%[%]]*)%]", "%1")
						end
						-- in case of negative page numbers (do they exist?), don't treat as multiple pages
						check_val = check_val:gsub("^%-", "")
						-- replace HTML entity en-dashes and em-dashes with their literal codes
						check_val = check_val:gsub("&ndash;", "–")
						check_val = check_val:gsub("&#8211;", "–")
						check_val = check_val:gsub("&mdash;", "—")
						check_val = check_val:gsub("&#8212;", "—")
						-- Check for en-dash or em-dash, or two numbers (possibly with stuff after like 12a-15b)
						-- separated by a hyphen or by comma a followed by a space (to avoid firing on thousands separators).
						if rfind(check_val, "[–—]") or check_val:find(" and ") or rfind(check_val, "[0-9]+[^ ]* *%- *[0-9]+")
							or rfind(check_val, "[0-9]+[^ ]* *, +[0-9]+")  then
							desc = get_plural_desc()
						else
							desc = singular_desc
						end
					end
					local obj = sgobj or plobj
					obj.text = val
					if obj.link:find("^!") then
						obj.link = obj.link:gsub("^!", "")
					end
					val = format_annotated_text(obj)
					numspec = desc .. " " .. val
				end
			end
			local url = a(paramname .. "url")
			if url then
				return "[" .. url .. " " .. numspec .. "]"
			else
				return numspec
			end
		end

		local volume = format_numeric_param("volume", a("volume_prefix") or "volume")
		if volume then
			add_with_sep(volume)
		end

		local issue = format_numeric_param("issue", a("issue_prefix") or "number")
		if issue then
			add_with_sep(issue)
		end

		-- number= is an alias for issue= (except in {{quote-av}}, where it is the episode number)
		local number = format_numeric_param("number", a("number_prefix") or "number")
		if number then
			add_with_sep(number)
		end

		local annotations = {}
		local genre = a("genre")
		if genre then
			table.insert(annotations, genre)
		end
		local format = a("format")
		if format then
			table.insert(annotations, format)
		end
		local medium = a("medium")
		if medium then
			table.insert(annotations, medium)
		end

		-- Now handle the display of language annotations like "(in French)" or
		-- "(quotation in Nauruan; overall work in German)".
		local quotelang = args.lang or args[1]
		local quotelang_fullname = 1
		if not quotelang then
			if ind == "" then
				-- This can only happen for certain non-mainspace pages, e.g. Talk pages; otherwise an error is thrown
				-- above.
				table.insert(annotations, maintenance_line("Please specify the language of the quote using |1="))
			else
				-- do nothing in newversion= portion
			end
		elseif ind == "" then
			local worklang, worklang_fullname = a_with_name("worklang")
			local termlang, termlang_fullname = a_with_name("termlang")
			worklang = worklang or quotelang
			termlang = termlang or quotelang

			if worklang == quotelang then
				if worklang == termlang then
					-- do nothing
				else
					table.insert(annotations, "in " .. format_langs(quotelang, quotelang_fullname))
				end
			else
				if quotelang ~= termlang then
					table.insert(annotations, "quotation in " .. format_langs(quotelang, quotelang_fullname))
				end
				table.insert(annotations, "overall work in " .. format_langs(worklang, worklang_fullname))
			end
		else
			local lang2, lang2_fullname = a_with_name("lang")
			if lang2 then
				table.insert(annotations, "in " .. format_langs(lang2, lang2_fullname))
			end
		end

		if #annotations > 0 then
			sep = nil
			add_with_sep(" (" .. table.concat(annotations, SEMICOLON_SPACE) .. ")")
		end

		local artist = parse_and_format_multivalued_annotated_text("artist", "and")
		if artist then
			add_with_sep("performed by " .. artist)
		end

		local feat = parse_and_format_multivalued_annotated_text("feat", "and")
		if feat then
			sep = " "
			add_with_sep("ft. " .. feat)
		end
	
		local role = parse_and_format_multivalued_annotated_text("role", "and")
		local actor_val, actor_fullname = a_with_name("actor")
		local actor_objs = parse_multivalued_annotated_text(actor_val, actor_fullname)
		local actor = format_multivalued_annotated_text(actor_objs, "and")
		if role then
			add_with_sep("spoken by " .. role)
			if actor then
				sep = nil
				add_with_sep(" (" .. actor .. ")")
			end
		elseif actor then
			add_with_sep(actor .. " (" .. (#actor_objs > 1 and "actors" or "actor") .. ")")
		end

		local others = parse_and_format_annotated_text("others")
		if others then
			add_with_sep(others)
		end
		local quoted_in = parse_and_format_annotated_text("quoted_in", tag_with_cite, tag_with_cite)
		if quoted_in then
			add_with_sep("quoted in " .. quoted_in)
			table.insert(tracking_categories, "Quotations using quoted-in parameter")
		end

		local location = parse_and_format_multivalued_annotated_text("location")
		local publisher = parse_and_format_multivalued_annotated_text("publisher", "; ")
		if publisher then
			if location then
				add_with_sep(location) -- colon
				sep = "&#58; " -- colon
			end
			add_with_sep(publisher)
		elseif location then
			add_with_sep(location)
		end

		local source = parse_and_format_multivalued_annotated_text("source", "and")
		if source then
			add_with_sep("sourced from " .. source)
		end

		local original = parse_and_format_annotated_text("original", tag_with_cite, tag_with_cite)
		local by = parse_and_format_multivalued_annotated_text("by", "and")
		local origtype = a("deriv") or "translation"
		if original or by then
			add_with_sep(origtype .. " of " .. (original or "original") .. (by and " by " .. by or ""))
		end

		-- Handle origlang=, origworklang=. How we handle them depends on whether the original title or author are explicitly
		-- given.
		local origlang, origlang_fullname = a_with_name("origlang")
		local origworklang, origworklang_fullname = a_with_name("origworklang")
		local origlangtext, origworklangtext
		if origlang then
			origlangtext = "in " .. format_langs(origlang, origlang_fullname)
		end
		if origworklang then
			origworklangtext = "overall work in " .. format_langs(origworklang, origworklang_fullname)
		end
		if origlang or origworklang then
			if original or by then
				local orig_annotations = {}
				if origlangtext then
					table.insert(orig_annotations, origlangtext)
				end
				if origworklangtext then
					table.insert(orig_annotations, origworklangtext)
				end
				sep = nil
				add_with_sep(" (" .. table.concat(orig_annotations, SEMICOLON_SPACE) .. ")")
			else
				add_with_sep(origtype .. " of original" .. (origlangtext and " " .. origlangtext or ""))
				if origworklangtext then
					sep = nil
					add_with_sep(" (" .. origworklangtext .. ")")
				end
			end
		end

		-- Fetch date_published=/year_published=/month_published= and format appropriately.
		local formatted_date_published = format_date_args(a, get_full_paramname, alias_map, "", "_published")
		local platform = parse_and_format_multivalued_annotated_text("platform", "and")
		if formatted_date_published then
			add_with_sep("published " .. formatted_date_published .. (platform and " via " .. platform or ""))
		elseif platform then
			add_with_sep("via " .. platform)
		end

		if ind ~= "" and has_newversion() then
			local formatted_new_date, this_need_date = format_date_args(a, get_full_paramname, alias_map, "", "", nil, 
				"Please provide a date or year")
			need_date = need_date or this_need_date
			if formatted_new_date then
				add_with_sep(formatted_new_date)
			end
		end

		-- From here on out, there should always be a preceding item, so we
		-- can dispense with add_with_sep() and always insert the comma.
		sep = nil

		local function small(txt)
			add(", <small>")
			add(txt)
			add("</small>")
		end

		-- Add an identifier to a book or article database such as DOI, ISBN, JSTOR, etc. `param_or_params`
		-- is a string identifying the base param, or a list of such strings to check in turn. If found, the value
		-- of the parameter is processed using `process` (a function of one argument, defaulting to mw.uri.encode()),
		-- and then the actual URL to insert is generated by preceding with `pretext`, following with `posttext`,
		-- and running the resulting string through small(), which first adds a comma and then the URL in small font.
		local function add_identifier(param_or_params, pretext, posttext, process)
			local val = a(param_or_params)
			if val then
				val = (process or mw.uri.encode)(val)
				small(pretext .. val .. posttext)
			end
		end

		add_identifier("bibcode", "[https://adsabs.harvard.edu/abs/", " →Bibcode]")
		add_identifier("doi", "<span class=\"neverexpand\">[https://doi.org/", " →DOI]</span>")
		add_identifier("isbn", "", "", isbn)
		add_identifier("issn", "", "", issn)
		add_identifier("jstor", "[https://www.jstor.org/stable/", " →JSTOR]")
		add_identifier("lccn", "", "", lccn)
		add_identifier("oclc", "[https://www.worldcat.org/title/", " →OCLC]")
		add_identifier("ol", "[https://openlibrary.org/works/OL", "/ →OL]")
		add_identifier("pmid", "[https://www.ncbi.nlm.nih.gov/pubmed/", " →PMID]")
		add_identifier("pmcid", "[https://www.ncbi.nlm.nih.gov/pmc/articles/", "/ →PMCID]")
		add_identifier("ssrn", "[https://ssrn.com/abstract=", " →SSRN]")
		local id = a("id")
		if id then
			small(id)
		end

		local archiveurl, archiveurl_fullname = aurl_with_name("archiveurl")
		if archiveurl then
			add(", archived from ")
			local url, url_fullname = aurl_with_name("url")
			if not url then
				-- attempt to infer original URL from archive URL; this works at
				-- least for Wayback Machine (web.archive.org) URL's
				url = rmatch(archiveurl, "/(https?:.*)$")
				if not url then
					error(("When |%s= is specified, |%s= must also be included"):format(archiveurl_fullname,
						url_fullname))
				end
			end
			add("[" .. url .. " the original] on ")
			local archivedate, archivedate_fullname = a_with_name("archivedate")
			if archivedate then
				add(format_date(archivedate))
			elseif (string.sub(archiveurl, 1, 28) == "https://web.archive.org/web/") then
				-- If the archive is from the Wayback Machine, then it already contains the date
				-- Get the date and format into ISO 8601
				local wayback_date = string.sub(archiveurl, 29, 29+7)
				wayback_date = string.sub(wayback_date, 1, 4) .. "-" .. string.sub(wayback_date, 5, 6) .. "-" ..
					string.sub(wayback_date, 7, 8)
				add(format_date(wayback_date))
			else
				error(("When |%s= is specified, |%s= must also be included"):format(
					archiveurl_fullname, archivedate_fullname))
			end
		end
		if a("accessdate") then
			--Otherwise do not display here, as already used as a fallback for missing date= or year= earlier.
			if (a("date") or a("nodate") or a("year")) and not a("archivedate") then
				add(", retrieved " .. format_date(a("accessdate")))
			end
		end

		local formatted_section = format_chapterlike("section", "section ")
		if formatted_section then
			add(", ")
			add(formatted_section)
		end

		-- video game stuff
		local system = parse_and_format_annotated_text("system")
		if system then
			add(", " .. system)
		end
		local scene = parse_and_format_annotated_text("scene")
		if scene then
			add(", scene: " .. scene)
		end
		local level = parse_and_format_annotated_text("level")
		if level then
			add(", level/area: " .. level)
		end

		local note = parse_and_format_annotated_text("note")
		if note then
			add(", " .. note)
		end

		local note_plain = parse_and_format_annotated_text("note_plain")
		if note_plain then
			add(" " .. note_plain)
		end

		-- Wrapper around format_numeric_param that inserts the formatted text with optional preceding text.
		local function handle_numeric_param(paramname, singular_desc, pretext)
			local numspec = format_numeric_param(paramname, singular_desc)
			if numspec then
				add((pretext or "") .. numspec)
			end
		end

		handle_numeric_param("page", a("page_prefix") or "page", ", ")
		handle_numeric_param("column", a("column_prefix") or "column", ", ")
		handle_numeric_param("line", a("line_prefix") or "line", ", ")
		-- FIXME: Does this make sense? What is other=?
		local other = parse_and_format_annotated_text("other")
		if other then
			add(", " .. other)
		end
	end

	-- Display all the text that comes after the author, for the main portion.
	postauthor("", num_authors)

	author_outputted = false

	-- If there's a "newversion" section, add the new-version text.
	if has_newversion() then
		sep = nil
		--Test for new version of work.
		add(SEMICOLON_SPACE)
		if args.newversion then -- newversion= is intended for English text, e.g. "quoted in" or "republished as".
			add(args.newversion)
		elseif not args.edition2 then
			if has_new_title_or_author() then
				add("republished as")
			else
				add("republished")
			end
		end
		add(" ")
		sep = ""
	else
		sep = ", "
	end

	-- Add the newversion author(s).
	if args["2ndauthor"] or args["2ndlast"] then
		num_authors = add_author(args["2ndauthor"], "2ndauthor", nil, nil, args["2ndauthorlink"], "2ndauthorlink", nil,
			nil, args["2ndfirst"], "2ndfirst", nil, nil, args["2ndlast"], "2ndlast", nil, nil)
		sep = ", "
	else
		for _, cant_have in ipairs { "2ndauthorlink", "2ndfirst" } do
			if args[cant_have] then
				error(("Can't have |%s= without |2ndauthor= or |2ndlast="):format(cant_have))
			end
		end
	end

	-- Display all the text that comes after the author, for the "newversion" section.
	postauthor(2, num_authors)

	if not args.nocolon then
		sep = nil
		add(":")
	end

	-- Concatenate output portions to form output text.
	local output_text = table.concat(output)

	-- Remainder of code handles adding categories. We add one or more of the following categories:
	--
	-- 1. [[Category:LANG terms with quotations]], based on the first language code in termlang= or 1=. Added to
	--    mainspace, Reconstruction: and Appendix: pages as well as Citations: pages if the corresponding mainspace
	--    page exists. Not added if nocat= is given. Note that [[Module:usex]] adds the same category using the same
	--    logic, but we do it here too because we may not have a quotation to format. (We add in those circumstances
	--    because typically when there's no quotation to format, it's because it's formatted manually underneath the
	--    citation, or using {{ja-x}}, {{th-x}} or similar.)
	-- 2. [[Category:Requests for date in LANG entries]], based on the first language code in 1=. Added to mainspace,
	--    Reconstruction:, Appendix: and Citations: pages unless nocat= is given.
	-- 3. [[Category:Quotations using nocat parameter]], if nocat= is given. Added to mainspace, Reconstruction:,
	--    Appendix: and Citations: pages.

	local categories = {}

	local termlang = get_first_lang(args.termlang or argslang, true)

	if args.nocat then
		table.insert(tracking_categories, "Quotations using nocat parameter")
	else
		local title
		if args.pagename then -- for testing, doc pages, etc.
			title = mw.title.new(args.pagename)
			if not title then
				error(("Bad value for `args.pagename`: '%s'"):format(args.pagename))
			end
		else
			title = mw.title.getCurrentTitle()
		end
		-- Only add [[Citations:foo]] to [[:Category:LANG terms with quotations]] if [[foo]] exists.
		local ok_to_add_cat
		if title.nsText ~= "Citations" then
			ok_to_add_cat = true
		else
			local mainspace_title = mw.title.new(title.text)
			if mainspace_title and mainspace_title.exists then
				ok_to_add_cat = true
			end
		end
		if ok_to_add_cat then
			table.insert(categories, termlang:getNonEtymologicalName() .. " terms with quotations")
		end
		if need_date then
			local argslangobj = get_first_lang(argslang, 1)
			table.insert(categories, "Requests for date in " .. argslangobj:getCanonicalName() .. " entries")
		end
	end

	local FULLPAGENAME = mw.title.getCurrentTitle().fullText
	return output_text .. (not lang and "" or
		(#categories > 0 and require(utilities_module).format_categories(categories, lang, args.sort) or "") ..
		(#tracking_categories > 0 and require(utilities_module).format_categories(tracking_categories, lang, args.sort,
			nil, not require(usex_templates_module).page_should_be_ignored(FULLPAGENAME)) or ""))
end


-- Alias specs for type= and type2=. Each spec is `{canon, aliases, with_newversion}` where `canon` is the canonical
-- parameter (with "2" added if type2= is being handled), `aliases` is a comma-separated string of aliases (with "2"
-- added if type2= is being handled, except for numeric params), and `with_newversion` indicates whether we should
-- process this spec if type2= is being handled.
local type_alias_specs = {
	book = {
		{"author", "3"},
		{"chapter", "entry", true},
		{"chapterurl", "entryurl", true},
		{"trans-chapter", "trans-entry", true},
		{"chapter_series", "entry_series", true},
		{"chapter_seriesvolume", "entry_seriesvolume", true},
		{"chapter_number", "entry_number", true},
		{"chapter_plain", "entry_plain", true},
		{"title", "4"},
		{"url", "5"},
		{"year", "2"},
		{"page", "6"},
		{"text", "7"},
		{"t", "8"},
	},
	journal = {
		{"year", "2"},
		{"author", "3"},
		{"chapter", "title,article,4", true},
		{"chapterurl", "titleurl,articleurl", true},
		{"trans-chapter", "trans-title,trans-article", true},
		{"chapter_tlr", "article_tlr", true},
		{"chapter_series", "article_series", true},
		{"chapter_seriesvolume", "article_seriesvolume", true},
		{"chapter_number", "article_number", true},
		{"chapter_plain", "title_plain,article_plain", true},
		{"title", "journal,magazine,newspaper,work,5", true},
		{"trans-title", "trans-journal,trans-magazine,trans-newspaper,trans-work", true},
		{"url", "6"},
		{"page", "7"},
		{"source", "newsagency", true},
		{"text", "8"},
		{"t", "9"},
	},
}


-- Process interally-handled aliases related to type= or type2=. `args` is a table of arguments; `typ` is the value of
-- type= or type2=; newversion=true if we're dealing with type2=; alias_map is used to keep track of alias mappings
-- seen.
local function process_type_aliases(args, typ, newversion, alias_map)
	local ind = newversion and "2" or ""
	local deprecated = ine(args.lang)
	if not type_alias_specs[typ] then
		local possible_values = {}
		for possible, _ in pairs(type_alias_specs) do
			table.insert(possible_values, possible)
		end
		table.sort(possible_values)
		error(("Unrecognized value '%s' for type%s=; possible values are %s"):format(
			typ, ind, table.concat(possible_values, ",")))
	end

	for _, alias_spec in ipairs(type_alias_specs[typ]) do
		local canon, aliases, with_newversion = unpack(alias_spec)
		if with_newversion or not newversion then
			canon = canon .. ind
			aliases = rsplit(aliases, ",")
			local saw_alias = nil
			for _, alias in ipairs(aliases) do
				if rfind(alias, "^[0-9]+$") then
					alias = tonumber(alias)
					if deprecated then
						alias = alias - 1
					end
				else
					alias = alias .. ind
				end
				if args[alias] then
					if saw_alias == nil then
						saw_alias = alias
					else
						error(("|%s= and |%s= are aliases; cannot specify a value for both"):format(saw_alias, alias))
					end
				end
			end
			if saw_alias and (not newversion or type(saw_alias) == "string") then
				if args[canon] then
					error(("|%s= is an alias of |%s=; cannot specify a value for both"):format(saw_alias, canon))
				end
				args[canon] = args[saw_alias]
				-- Wipe out the original after copying. This important in case of a param that has general significance
				-- but has been redefined (e.g. {{quote-av}} redefines number= for the episode number, and
				-- {{quote-journal}} redefines title= for the chapter= (article). It's also important due to unhandled
				-- parameter checking.
				args[saw_alias] = nil
				alias_map[canon] = saw_alias
			end
		end
	end
end


-- Clone and combine frame's and parent's args while also assigning nil to empty strings. Handle aliases and ignores.
local function clone_args(direct_args, parent_args)
	local args = {}

	-- Processing parent args must come first so that direct args override parent args. Note that if a direct arg is
	-- specified but is blank, it will still override the parent arg (with nil).
	for pname, param in pairs(parent_args) do
		-- [[Special:WhatLinksHere/Template:tracking/quote/param/PARAM]]
		track("param/" .. pname)
		args[pname] = ine(param)
	end

	-- Process ignores. The value of `ignore` is a comma-separated list of parameter names to ignore (erase). We need to
	-- do this before aliases due to {{quote-song}}, which sets chapter= to the value of title= in the direct params and
	-- sets title= to the value of album= using an alias. If we do the ignores after aliases, we get an error during alias
	-- processing, saying that title= and its alias album= are both present.
	local ignores = ine(direct_args.ignore)
	if ignores then
		for ignore in rgsplit(ignores, "%s*,%s*") do
			args[ignore] = nil
		end
	end

	local alias_map = {}

	-- Process internally-specified aliases using type= or type2=.
	local typ = args.type or direct_args.type
	if typ then
		process_type_aliases(args, typ, false, alias_map)
	end
	local typ2 = args.type2 or direct_args.type2
	if typ2 then
		process_type_aliases(args, typ2, true, alias_map)
	end

	-- Process externally-specified aliases. The value of `alias` is a list of semicolon-separated specs, each of which
	-- is of the form DEST:SOURCE,SOURCE,... where DEST is the canonical name of a parameter and SOURCE refers to an
	-- alias. Whitespace is allowed between all delimiters. The order of aliases may be important. For example, for
	-- {{quote-journal}}, title= contains the article name and is an alias of underlying chapter=, while journal= or
	-- work= contains the journal name and is an alias of underlying title=. As a result, the title -> chapter alias
	-- must be specified before the journal/work -> title alias.
	--
	-- Whenever we copy a value from argument SOURCE to argument DEST, we record an entry for the pair in alias_map, so
	-- that when we would display an error message about DEST, we display SOURCE instead.
	--
	-- Do alias processing (and ignore and error_if processing) before processing direct_args so that e.g. we can set up
	-- an alias of title -> chapter and then set title= to something else in the direct args ({{quote-hansard}} does
	-- this).
	--
	-- FIXME: Delete this once we've converted all alias processing to internal.
	local aliases = ine(direct_args.alias)
	if aliases then
		-- Allow and discard a trailing semicolon, to make managing multiple aliases easier.
		aliases = rsub(aliases, "%s*;$", "")
		for alias_spec in rgsplit(aliases, "%s*;%s*") do
			local alias_spec_parts = rsplit(alias_spec, "%s*:%s*")
			if #alias_spec_parts ~= 2 then
				error(("Alias spec '%s' should have one colon in it"):format(alias_spec))
			end
			local dest, sources = unpack(alias_spec_parts)
			sources = rsplit(sources, "%s*,%s*")
			local saw_source = nil
			for _, source in ipairs(sources) do
				if rfind(source, "^[0-9]+$") then
					source = tonumber(source)
				end
				if args[source] then
					if saw_source == nil then
						saw_source = source
					else
						error(("|%s= and |%s= are aliases; cannot specify a value for both"):format(saw_source, source))
					end
				end
			end
			if saw_source then
				if args[dest] then
					error(("|%s= is an alias of |%s=; cannot specify a value for both"):format(saw_source, dest))
				end
				args[dest] = args[saw_source]
				-- Wipe out the original after copying. This important in case of a param that has general significance
				-- but has been redefined (e.g. {{quote-av}} redefines number= for the episode number, and
				-- {{quote-journal}} redefines title= for the chapter= (article). It's also important due to unhandled
				-- parameter checking.
				args[saw_source] = nil
				alias_map[dest] = saw_source
			end
		end
	end

	-- Process error_if. The value of `error_if` is a comma-separated list of parameter names to throw an error if seen
	-- in parent_args (they are params we overwrite in the direct args).
	local error_ifs = ine(direct_args.error_if)
	if error_ifs then
		for error_if in rgsplit(error_ifs, "%s*,%s*") do
			if ine(parent_args[error_if]) then
				error(("Cannot specify a value |%s=%s as it would be overwritten or ignored"):format(error_if, ine(parent_args[error_if])))
			end
		end
	end
	
	for pname, param in pairs(direct_args) do
		-- ignore control params
		if pname ~= "ignore" and pname ~= "alias" and pname ~= "error_if" then
			args[pname] = ine(param)
		end
	end

	return args, alias_map
end

-- External interface, meant to be called from a template. Replaces {{quote-meta}} and meant to be the primary
-- interface for {{quote-*}} templates.
function export.quote_t(frame)
	-- FIXME: We are processing arguments twice, once in clone_args() and then again in [[Module:parameters]]. This is
	-- wasteful of memory.
	local parent_args = frame:getParent().args
	local cloned_args, alias_map = clone_args(frame.args, parent_args)
	local deprecated = ine(parent_args.lang)

	-- First, the "single" params that don't have FOO2 or FOOn versions.
	local params = {
		[deprecated and "lang" or 1] = {required = true, default = "und"},
		newversion = {},
		["2ndauthor"] = {},
		["2ndauthorlink"] = {},
		["2ndfirst"] = {},
		["2ndlast"] = {},
		nocat = {type = "boolean"},
		nocolon = {type = "boolean"},
		lang2 = {},

		-- quote params
		text = {},
		passage = {alias_of = "text"},
		tr = {},
		transliteration = {alias_of = "tr"},
		ts = {},
		transcription = {alias_of = "ts"},
		norm = {},
		normalization = {alias_of = "norm"},
		sc = {},
		normsc = {},
		sort = {},
		subst = {},
		footer = {},
		lit = {},
		t = {},
		translation = {alias_of = "t"},
		gloss = {alias_of = "t"},
		tag = {},
		brackets = {type = "boolean"},
		-- original quote params
		origtext = {},
		origtr = {},
		origts = {},
		orignorm = {},
		origsc = {},
		orignormsc = {},
		origsubst = {},
		origtag = {},
	}

	-- Then the list params (which have FOOn versions).
	local list_spec = {list = true, allow_holes = true}
	for _, list_param in ipairs {
		"author", "last", "first", "authorlink", "trans-author", "trans-last", "trans-first", "trans-authorlink"
	} do
		params[list_param] = list_spec
	end

	-- Then the newversion params (which have FOO2 versions).
	for _, param12 in ipairs {
		-- author-like params; author params themselves are either list params (author=, last=, etc.) or single params
		-- (2ndauthor=, 2ndlast=, etc.)
		"coauthors", "quotee", "tlr", "editor", "editors", "mainauthor", "compiler", "compilers", "director", "directors",
		"lyricist", "lyrics-translator", "composer", "role", "actor", "artist", "feat",

		-- author control params
		"default-authorlabel",
		"authorlabel",

		-- title
		"title", "trans-title", "series", "seriesvolume", "notitle",

		-- chapter
		"chapter", "chapterurl", "chapter_number", "chapter_plain", "chapter_series", "chapter_seriesvolume",
		"trans-chapter", "chapter_tlr",

		-- section
		"section", "sectionurl", "section_number", "section_plain", "section_series", "section_seriesvolume",
		"trans-section",

		-- other video-game params
		"system", "scene", "level",

		-- URL
		"url", "urls", "archiveurl",

		-- edition
		"edition", "edition_plain",

		-- language params
		"worklang", "termlang", "origlang", "origworklang",

		-- ID params
		"bibcode", "doi", "isbn", "issn", "jstor", "lccn", "oclc", "ol", "pmid", "pmcid", "ssrn", "id",

		-- misc date params; most date params handled below
		"archivedate", "accessdate", "nodate",

		-- numeric params handled below

		-- other params
		"type", "genre", "format", "medium", "others", "quoted_in", "location", "publisher",
		"original", "by", "deriv",
		"note", "note_plain",
		"other", "source", "platform",
	} do
		params[param12] = {}
		params[param12 .. "2"] = {}
	end

	-- Then the aliases of newversion params (which have FOO2 versions).
	for _, param12_aliased in ipairs {
		{"role", "roles"},
		{"role", "speaker"},
		{"tlr", "translator"},
		{"tlr", "translators"},
		{"doi", "DOI"},
		{"isbn", "ISBN"},
		{"issn", "ISSN"},
		{"jstor", "JSTOR"},
		{"lccn", "LCCN"},
		{"oclc", "OCLC"},
		{"ol", "OL"},
		{"pmid", "PMID"},
		{"pmcid", "PMCID"},
		{"ssrn", "SSRN"},
	} do
		local canon, alias = unpack(param12_aliased)
		params[alias] = {alias_of = canon}
		params[alias .. "2"] = {alias_of = canon .. "2"}
	end

	-- Then the date params.
	for _, datelike in ipairs { {"", ""}, {"orig", ""}, {"", "_published"} } do
		local pref, suf = unpack(datelike)
		for _, arg in ipairs { "date", "year", "month", "start_date", "start_year", "start_month" } do
			params[pref .. arg .. suf] = {}
			params[pref .. arg .. suf .. "2"] = {}
		end
	end

	-- Then the numeric params.
	for _, numeric in ipairs { "volume", "issue", "number", "line", "page", "column" } do
		for _, suf in ipairs { "", "s", "_plain", "url", "_prefix" } do
			params[numeric .. suf] = {}
			params[numeric .. suf .. "2"] = {}
		end
	end
	
	local args = require(parameters_module).process(cloned_args, params, nil, "quote", "quote_t")

	local parts = {}
	local function ins(text)
		table.insert(parts, text)
	end

	ins('<div class="citation-whole"><span class="cited-source">')
	ins(export.source(args, alias_map))
	ins("</span><dl><dd>")

	local text = args.text
	local gloss = args.t
	local tr = args.tr
	local ts = args.ts
	local norm = args.norm
	local sc = args.sc and require(scripts_module).getByCode(args.sc, "sc") or nil
	local normsc = args.normsc == "auto" and args.normsc or
		args.normsc and require(scripts_module).getByCode(args.normsc, "normsc") or nil

	-- Fetch original-text parameters.
	local origtext, origtextlang, origsc, orignormsc
	if args.origtext then
		-- Wiktionary language codes have at least two lowercase letters followed possibly by lowercase letters and/or
		-- hyphens (there are more restrictions but this is close enough). Also check for nonstandard Latin etymology
		-- language codes (e.g. VL. or LL.). (There used to be more nonstandard codes but they have all been
		-- eliminated.)
		origtextlang, origtext = args.origtext:match("^([a-z][a-z][a-z-]*):([^ ].*)$")
		if not origtextlang then
			-- Special hack for Latin variants, which can have nonstandard etym codes, e.g. VL., LL.
			origtextlang, origtext = args.origtext:match("^([A-Z]L%.):([^ ].*)$")
		end
		if not origtextlang then
			error("origtext= should begin with a language code prefix")
		end
		origtextlang = require("Module:languages").getByCode(origtextlang, "origtext", "allow etym")
		origsc = args.origsc and require(scripts_module).getByCode(args.origsc, "origsc") or nil
		orignormsc = args.orignormsc == "auto" and args.orignormsc or
			args.orignormsc and require(scripts_module).getByCode(args.orignormsc, "orignormsc") or nil
	else
		for _, noparam in ipairs { "origtr", "origts", "origsc", "orignorm", "orignormsc", "origsubst", "origtag" } do
			if args[noparam] then
				error(("Cannot specify %s= without origtext="):format(noparam))
			end
		end
	end

	-- If any quote-related args are present, display the actual quote; otherwise, display nothing.
	if text or gloss or tr or ts or norm or args.origtext then
		-- Pass "und" here rather than cause an error; there will be an error on mainspace, Citations, etc. pages
		-- in any case in source() if the language is omitted.
		local lang = get_first_lang(args[1] or args.lang, 1)
		local termlang = args.termlang and get_first_lang(args.termlang, "termlang") or lang

		local usex_data = {
			lang = lang,
			termlang = termlang,
			usex = text,
			sc = sc,
			translation = gloss,
			normalization = norm,
			normsc = normsc,
			transliteration = tr,
			transcription = ts,
			brackets = args.brackets,
			subst = args.subst,
			lit = args.lit,
			footer = args.footer,
			qq = tags_to_dialects(lang, args.tag),
			quote = "quote-meta",
			orig = origtext,
			origlang = origtextlang,
			origsc = origsc,
			orignorm = args.orignorm,
			orignormsc = orignormsc,
			origtr = args.origtr,
			origts = args.origts,
			origsubst = args.origsubst,
			origqq = tags_to_dialects(lang, args.origtag),
		}
		ins(require(usex_module).format_usex(usex_data))
	end
	ins("</dd></dl></div>")
	local retval = table.concat(parts)
	return deprecated and frame:expandTemplate{title = "check deprecated lang param usage", args = {retval, lang = args.lang}} or retval
end


-- External interface, meant to be called from a template.
function export.call_quote_template(frame)
	local iparams = {
		["template"] = {},
		["textparam"] = {},
		["pageparam"] = {},
		["allowparams"] = {list = true},
		["propagateparams"] = {list = true},
	}
	local iargs, other_direct_args = require(parameters_module).process(frame.args, iparams, "return unknown", "quote", "call_quote_template")
	local direct_args = {}
	for pname, param in pairs(other_direct_args) do
		direct_args[pname] = ine(param)
	end

	local function process_paramref(paramref)
		if not paramref then
			return {}
		end
		local params = rsplit(paramref, "%s*,%s*")
		for i, param in ipairs(params) do
			if rfind(param, "^[0-9]+$") then
				param = tonumber(param)
			end
			params[i] = param
		end
		return params
	end

	local function fetch_param(source, params)
		for _, param in ipairs(params) do
			if source[param] then
				return source[param]
			end
		end
		return nil
	end

	local params = {
		["text"] = {},
		["passage"] = {},
		["footer"] = {},
		["brackets"] = {},
	}
	local textparams = process_paramref(iargs.textparam)
	for _, param in ipairs(textparams) do
		params[param] = {}
	end
	local pageparams = process_paramref(iargs.pageparam)
	if #pageparams > 0 then
		params["page"] = {}
		params["pages"] = {}
		for _, param in ipairs(pageparams) do
			params[param] = {}
		end
	end

	local parent_args = frame:getParent().args
	local allow_all = false
	for _, allowspec in ipairs(iargs.allowparams) do
		for _, allow in ipairs(rsplit(allowspec, "%s*,%s*")) do
			local param = rmatch(allow, "^(.*):list$")
			if param then
				if rfind(param, "^[0-9]+$") then
					param = tonumber(param)
				end
				params[param] = {list = true}
			elseif allow == "*" then
				allow_all = true
			else
				if rfind(allow, "^[0-9]+$") then
					allow = tonumber(allow)
				end
				params[allow] = {}
			end
		end
	end

	local params_to_propagate = {}
	for _, propagate_spec in ipairs(iargs.propagateparams) do
		for _, param in ipairs(process_paramref(propagate_spec)) do
			table.insert(params_to_propagate, param)
			params[param] = {}
		end
	end

	local args = require(parameters_module).process(parent_args, params, allow_all, "quote", "call_quote_template")
	parent_args = require(table_module).shallowcopy(parent_args)

	if textparams[1] ~= "-" then
		other_direct_args.passage = args.text or args.passage or fetch_param(args, textparams)
	end
	if #pageparams > 0 and pageparams[1] ~= "-" then
		other_direct_args.page = fetch_param(args, pageparams) or args.page or nil
		other_direct_args.pages = args.pages
	end
	if args.footer then
		other_direct_args.footer = frame:expandTemplate { title = "small", args = {args.footer} }
	end
	other_direct_args.brackets = args.brackets
	if not other_direct_args.authorlink and other_direct_args.author and not other_direct_args.author:find("[%[<]") and not other_direct_args.author:match("w:") then
		other_direct_args.authorlink = other_direct_args.author
	end
	for _, param in ipairs(params_to_propagate) do
		if args[param] then
			other_direct_args[param] = args[param]
		end
	end

	return frame:expandTemplate { title = iargs.template or "quote-book", args = other_direct_args }
end

local paramdoc_param_replacements = {
	passage = {
		param_with_synonym = '<<synonym>>, {{para|text}}, or {{para|passage}}',
		param_no_synonym = '{{pa--ra|text}} or {{para|passage}}',
		text = [=[
* <<params>> – the passage to be quoted.]=]

	page = {
		param_with_synonym = '<<synonym>> or {{para|page}}, or {{para|pages}}',
		param_no_synonym = '{{para|page}} or {{para|pages}}',
		text = [=[
* <<params>> – '''mandatory in some cases''': the page number(s) quoted from. When quoting a range of pages, note the following:
** Separate the first and last pages of the range with an [[en dash]], like this: {{para|pages|10–11}}.
** You must also use {{para|pageref}} to indicate the page to be linked to (usually the page on which the Wiktionary entry appears).
: This parameter must be specified to have the template link to the online version of the work.]=]
	}
	page_with_roman_preface = {
		param_with_synonym = {"inherit", "page"},
		param_no_synonym = {"inherit", "page"},
		text = [=[
* <<params>> – '''mandatory in some cases''': the page number(s) quoted from. If quoting from the preface, specify the page number(s) in lowercase Roman numerals. When quoting a range of pages, note the following:
** Separate the first and last page number of the range with an [[en dash]], like this: {{para|pages|10–11}} or {{para|pages|iii–iv}}.
** You must also use {{para|pageref}} to indicate the page to be linked to (usually the page on which the Wiktionary entry appears).
: This parameter must be specified to have the template link to the online version of the work.]=]
	}
	chapter = {
		param_with_synonym = '<<synonym>> or {{para|chapter}}',
		param_no_synonym = '{{para|chapter}}',
		text = [=[
* <<params>> – the name of the chapter quoted from.]=],
	}
	roman_chapter = {
		param_with_synonym = {"inherit", "chapter"},
		param_no_synonym = {"inherit", "chapter"},
		text = [=[
* <<params>> – the chapter number quoted from in uppercase Roman numerals.]=],
	}
	arabic_chapter = {
		param_with_synonym = {"inherit", "chapter"},
		param_no_synonym = {"inherit", "chapter"},
		text = [=[
* <<params>> – the chapter number quoted from in Arabic numerals.]=],
	}
	trailing_params = {
		text = [=[
* {{para|footer}} – a comment on the passage quoted.
* {{para|brackets}} – use {{para|brackets|on}} to surround a quotation with [[bracket]]s. This indicates that the quotation either contains a mere mention of a term (for example, “some people find the word '''''manoeuvre''''' hard to spell”) rather than an actual use of it (for example, “we need to '''manoeuvre''' carefully to avoid causing upset”), or does not provide an actual instance of a term but provides information about related terms.]=],
	}


function export.paramdoc(frame)
	local params = {
		[1] = {},
	}

	local parargs = frame:getParent().args
	local args = require(parameters_module).process(parargs, params, nil, "quote", "paramdoc")

	local text = args[1]

	local function do_param_with_optional_synonym(param, text_to_sub, paramtext_synonym, paramtext_no_synonym)
		local function sub_param(synonym)
			local subbed_paramtext
			if synonym then
				subbed_paramtext = rsub(paramtext_synonym, "<<synonym>>", "{{para|" .. synonym .. "}}")
			else
				subbed_paramtext = paramtext_no_synonym
			end
			return frame:preprocess(rsub(text_to_sub, "<<params>>", subbed_paramtext))
		end
		text = rsub(text, "<<" .. param .. ">>", function() return sub_param() end)
		text = rsub(text, "<<" .. param .. ":(.-)>>", sub_param)
	end

	local function fetch_text(param_to_replace, key)
		local spec = paramdoc_param_replacements[param_to_replace]
		local val = spec[key]
		if type(val) == "string" then
			return val
		end
		if type(val) == "table" and val[1] == "inherit" then
			return fetch_text(val[2], key)
		end
		error("Internal error: Unrecognized value for param '" .. param_to_replace .. "', key '" .. key .. "': "
			.. mw.dumpObject(val))
	end

	for param_to_replace, spec in pairs(paramdoc_param_replacements) do
		local function fetch(key)
			return fetch_text(param_to_replace, key)
		end

		if not spec.param_no_synonym then
			-- Text to substitute directly.
			text = rsub(text, "<<" .. param_to_replace .. ">>", function() return frame:preprocess(fetch("text")) end)
		else
			do_param_with_optional_synonym(param_to_replace, fetch("text"), fetch("param_with_synonym"),
				fetch("param_no_synonym"))
		end
	end

	-- Remove final newline so template code can add a newline after invocation
	text = text:gsub("\n$", "")
	return text
end

return export