Lua

CodeDiscussionEditHistoryLinksLink count Subpages:DocumentationTestsResultsSandboxLive code All modules

For calling in template code use either

  1. {{#invoke:Catnav|main|<named_argument_list>}} or
  2. {{#invoke:Catnav|Q|<positional_argument_list>|<named_arguments_without_number_suffix>}} (putting named arguments before positional ones is fine too if your layout benefits)

The following doc is extensive. Usually things should just work. If you want to use this module for a template, start by looking at the examples. This module is intended to save template authors from typing and repeating the same phrases over and over again. If you find a bug, keep calm and if you will not fix it yourself, report it to the discussion page.

First case Second case
source of Template:Districts of Saxony for exemplary use see source of Template:Districts of Saxony/wikidata
named_argument_list is compatible with that of the legacy Template:Catnav, which served as an inspiration for this module. With default values given in braces and with X being replacable by a number in the range 0 to maxnum, the possible arguments are:
  • all or redlinks (), boolean
  • compact (), boolean
  • indent (), boolean
  • sort (1), boolean

  • imagealign (right)
  • imagewidth (30px)
  • imagestyle (), css
  • imagelink ()
  • image ()

  • iconsalign (left)
  • iconswidth (15px)
  • icons (), boolean or "only"
  • sep ()
  • prefix ()
  • article () or ("the" if all==2)
  • suffix ()
  • title ()
  • maxnum (99)

  • displayX ()
  • iconX ()
  • prefixX ()
  • articleX (article)
  • linkX ()
  • suffixX ()
  • noteX ()

For a given number X, variables with the same suffix X belong to the same entry.

To determine the link target of an entry, the value of linkX is taken literally. Should displayX be unset or empty, it is assigned the value of linkX.


If displayX contains i18n{x}, then i18n{x} will be substituted by a translation for x as found in TEMPLATE_PAGENAME/i18n, or x itself if translation failed.

Thereafter displayX is scanned for wikidata entity ids. Unless literal Q is quoted, using &#81;, let a positive integer prefixed with Q be Qm.

  • If Qm is not found in linkX, then it is substituted by its wikidata label.
  • Otherwise, as a literal of a page or category link, Qm is not necessarily a wikidata id and thus used unmodified.

For instance, with prefix=:Category:

  • displayX=Q4000|linkX=Q4000 will render as Q4000,
  • displayX=Q42|linkX=Q42 as Q42, but with displayX=q42 as Douglas Adams.
All named arguments that end in X, as described left, should not be passed in this mode as they may be overwritten internally by positional argument processing. Other named arguments may be used. The default value for prefix is :Category: in this mode.

positional_argument_list is expected to consist of valid wikidata ids. For each positional argument Qarg

  • property P373 (commons category) is looked up in wikidata to obtain a value for linkX
  • if linkX was set, displayX takes Qarg with Qm substitution done unconditionally
  • if icons ends in a wikidata property identifier, then that property of Qarg is looked up to set iconX

If no commons category (P373) could be found, then Qarg is used to set noteX, which may qualify as a seqlabel described below.

All positional arguments may be encased in simple wikitext formatting modifiers, i.e. '' or ''' should work to set the result of the lookup italic or bold.


The property identifier in icons is used to look up a commonsMedia filename for each Qarg. For instance

  • icons=only:P18 will render the image property of each Qarg as a linklabel exclusively. The entity label will be used as a fallback only if none is found or unusable.
  • icons=yes:P94 will attempt to use coats of arms images in combination with the entity label.
  • Consecutive runs of entries („sequences“) are detected if either displayX or linkX are continuously set (1st case), or settable by a successful lookup of an positional argument (2nd case), while X increments by 1. Sequences are surrounded by a div tag with class seqdata.
  • If a consecutive run is preceeded by a "lonely" noteY, then noteY is considered a seqlabel for the following seqdata div. (This is detected when displayX or linkX with X==Y+1 are set, while displayY and linkY are not.)
  • A unit of an optional seqlabel followed by seqdata is always enclosed in a div of class seq.
  • The default style for all seq, seqlabel and seqdata divs is display:inline, which means this data grouping has no visual effect.
  • If named argument indent is set or if users of this wiki style the data groups by means of their common.css settings, the default styling is overridden. In css rules, literal !important may need to be added to override the default.

icons acts as a master switch. If set to only, it turns off text display as long as an icon is available for a given entry. In the yes, true or 1 case it will put the icon next to the text given by argument displayX. Left or right icon placement can be determined for all icons by setting iconsalign accordingly, but a sensible default value is used.


sort=1, the default unless only icons are displayed, sorts each seqdata individually. It does so after all language translations have been done. For properly internationalized templates this results in a tidy appearance of the navigation list entries for each user language, not just the one the template was written in.

The sorting feature can be disabled explicitly using sort=no or sort=0 for all languages. Alternatively, by placing an additional key __sort__ in the /i18n helper template, it may be switched depending on the language used. This option was introduced, because template items may have a specific input ordering with respect to the native language a template was authored in. For example, |__sort__ = {{LangSwitch | default = yes | en = no}} enables the sort feature for all languages except English.

In compact mode (disabled by default) all seqlabel(s) are omitted and sort=once is additionally accepted. Sorting once will collect all items from all seqdata(s) and sort them into a single seqdata.

Code

-- please report issues to the discussion page
-- if you will not fix them yourself

local titlenew = mw.title.new
function exists(w)
	local t = titlenew(w, '')
	return t and t.exists
end

function isRedirect(w)
	local t = titlenew(w, '')
	return t:getContent():find('.ategory ?.edirect') or t.isRedirect
end

-- mw.getCurrentFrame():callParserFunction('PAGENAME', w)
function pagename(w)
	local t = titlenew(w, '')
	return t and t.text
end

-- mw.title.new(w, ''):inNamespace(mw.site.namespaces.File.id)
function imgexists(w)
	return w:find('File:', 1, true) == 1
		or w:find('Image:', 1, true) == 1
end

-- parser called once and cached
isRTL = mw.getLanguage(mw.getCurrentFrame():callParserFunction('int', 'Lang')):isRTL()

-- flatten table entries of t to a string
function flatten(t)
	local r = tbl()
	for u, v in ipairs(t) do
		u = { }
		for i = 1, 10 do -- v may not be a sequential table
			if v[i] and #v[i] > 0 then
				r.ins(u, v[i])
			end
		end
		if #u > 0 then
			u = mw.text.split(r.cat(u, ' '):gsub('&#x8;', '\8'), '&#x7f;')
			for i = #u-1, 1, -1 do
				u[i] = u[i] .. u[i+1]:sub(2)
			end
			u = mw.text.split(u[1], '\8')
			for i = 2, #u do
				u[i] = u[i-1]:sub(1, -2) .. u[i]
			end
			r:app(u[#u])
		end
	end
	return r
end

function tbl(t)
	t = t or { }
	t.trc = function(_, x)
		repeat until t:rm():find(x, 1, true)
		return 1
	end
	t.app = function(...)
		for i = 2, arg.n do
			t[#t + 1] = arg[i]
		end
	end
	t.cat = table.concat
	t.ins = table.insert
	t.rm = table.remove
	return t
end

-- def(x) == nil if x is empty
function def(x, y)
	return x and #x > 0 and x or y
end

function top(title, i, pfx, sfx, tpn)
	local r = tbl{
		'<div class="mw-content-', isRTL and 'right' or 'left',
		' catlinks catnav catnav_', def(tpn, ''):sub(10):gsub('[^%-%w\128-\255]+', '_'),
		'" style="clear:none;display:table;font-size:88%;line-height:normal;margin:2px 0;padding:2px"><div style="display:table-cell;min-width:36em">',
	}
	if def(i.img) then
		if imgexists(i.img) then
			local wl = def(flatten{{ pfx, i.lnk, sfx }}[1], '')
			i.lnk = exists(wl) and wl or exists(i.lnk) and i.lnk or ''
			r:app(
				'<div style="float:', i.aln, ';margin-', rvd(i.aln), ':2px;', i.stl,
				'">[[', i.img, '|', i.wth, '|border|link=', i.lnk, ']]</div>'
			)
		else
			r:app(i.img)
		end
	end
	if def(title) then
		local pn = pagename(flatten{{ pfx, title, sfx }}[1])
		if pn then
			r:app('<em>', pn, '&#x202F;:</em> ')
		end
	end
	return r:cat('')
end

function bottom()
	return '<div style="clear:both"></div></div></div>'
end

local spA = '<span style="white-space:nowrap">'
local spZ = '</span>'

function _seq(class, css, c)
	return not class and '</div>' or tbl{
		'<div class="catnav_', class, '" style="', css[class],
		def(c and c > 0 and ';' .. def(css['_' .. class], ''), ''),
		'">',
	}:cat('')
end

function row(aln, wth, pfx, sfx, all, css,
		sep, disp, link, pref, suff, ticl, note, icon)
	local r = tbl()
	if disp or link then
		local c, _l = 0
		r.rwd = function(t, l)
			if c > 1 and _l and isRedirect(_l) then
				c = c - t:trc(spA)
			end
			_l = not all and l
		end
		r:app('', '', '') -- maybe seq, seqdata, sep
		if link then
			local wl = {}
			for _p in mw.text.gsplit(pref or '', '|') do
				for _s in mw.text.gsplit(suff or '', '|') do
					if ticl then
						wl[#wl+1] = { pfx, ticl, _p, link, _s, sfx }
					end
					wl[#wl+1] = { pfx, _p, link, _s, sfx }
				end
			end
			for _, l in ipairs(flatten(wl)) do
				if all or exists(l) then
					c = c + 1
					r:rwd(l)
					r:app(spA, c > 1 and ' ≈ [[' or '[[')
					aln = aln and #r + 1
					r.aln = function(t, v)
							t:ins(aln or #t + 1, v)
						end
					if icon then
						r:aln(icon .. '|' .. wth .. '|link=' .. l)
					end
					if disp then
						r:aln(icon and ']]&nbsp;[[' or '')
						r:aln(l .. '|' .. disp)
					end
					r:app(']]', note or '', spZ)
				end
			end
			r:rwd()
		elseif disp then
			r:app(spA, disp, note or '', spZ)
			c = c + 1
		end
		local h, t = sep:seq_bounds()
		local s = c > 0 and sep:get()
		if h and h < 0 then
			r[1] = _seq('seq', css)
		end
		if h then
			r[2] = _seq('seqdata', css, h)
		end
		if s then
			r[3] = s
		end
		if t then
			r:app(_seq(), _seq())
		end
	elseif note then
		if sep:seq_ahead() then
			r:app(
				_seq('seq', css),
				_seq('seqlabel', css, sep:seq_ahead()),
				css.__indent and (note:gsub('^ *<[Bb][Rr] */?>', '')) or note,
				_seq())
		else
			r:app(note)
		end
	end
	return r:cat('')
end

function _sep(sep, compact, omit)
	local seqlabeled
	local seq
	local c = 0
	return function(ld)
		return {
			get = function(_, r)
					r = not omit and sep
					omit = nil
					return r
				end,
			seq_ahead = function()
					seqlabeled = ld
					return seqlabeled and c
				end,
			seq_bounds = function()
					local h, t
					if not seq then
						h = seqlabeled and c or (-1 - c)
						seq = true
						omit = not compact or c == 0
					end
					if seq and not ld then
						t = c
						seqlabeled = nil
						seq = nil
						c = c + 1
					end
					return h, t
				end,
		}
	end
end

function rvd(x)
	return x:find('right', 1, true) and 'left' or 'right'
end

function use(x)
	return x and #x > 0 and ('only once true yes 1 2'):find(x, 1, true) or nil
end

function named_args(_f)
	local a = _f.args
	local f = _f:getParent() or _f
	if pairs(a)(a) == nil then -- if invoked without args
		a = f.args -- take parent args, else take unset args only:
	elseif _f ~= f then
		for k, v in pairs(f.args) do
			a[k] = a[k] or v
		end
	end
	a.__art = def(a.article)
	a.__art = not a.__art and tonumber(a.all) == 2 and 'the' or a.__art
	a.__sep = def(a.sep) or '&nbsp;<b>·</b>&#32;'
	a.img = {
		img = a.img or a.image,
		aln = def(a.imgalign or a.imagealign, 'right'),
		lnk = a.imglink or a.imagelink or a.title or '',
		stl = def(a.imgstyle or a.imagestyle),
		wth = def(a.imgwidth or a.imagewidth, '30px'),
	}
	a.iconsalign = def(a.iconsalign, rvd(a.img.aln))
	a.iconswidth = def(a.iconswidth, '15px')
	a.img.aln = isRTL and rvd(a.img.aln) or a.img.aln
	f = {
		__indent = not use(a.compact) and use(a.indent),
	}
	a.__css = f
	if f.__indent then
		local side = isRTL and 'left' or 'right'
		f.seq = 'display:table-row;vertical-align:top'
		f.seqlabel = 'display:table-cell;text-align:' .. side .. ';padding-' .. side .. ':.4em;white-space:nowrap'
		f._seqlabel = 'padding-top:.2em'
		f.seqdata = 'display:table-cell'
		f._seqdata = 'padding-top:.2em'
	else
		f.seq = 'display:inline'
		f.seqlabel = 'display:inline'
		f.seqdata = 'display:inline'
	end
	return a
end

local QIDpattern = '%f[%w][Qq]%d+'
local getLabel = mw.wikibase.getLabel

function key(k)
	return type(k) == 'string' and k
		:gsub('<!%-%-(.-)%-%->', '') -- strip HTML/XML comments
		:gsub('</?%s*([%a_][%-%.:%w_]*)[^>]*>', '') -- strip HTML/XML element tags (preserve text elements)
		:gsub('^%s*(.-)%s*$', '%1') -- trim leading/trailing whitespaces
		:gsub('%s+', ' ') -- pack remaining whitespaces
end

function number_suffixed_named_args(f, a, qc, tpn)
	local r = tbl()
	local o = use(a.compact)
	local s = _sep(a.__sep, o)
	local s0 = s()
	local s1 = s(1)
	local icons = use(a.icons)
	local fuse_icons = not (icons == 1) or nil
	local srt = fuse_icons and use(def(a.sort, '1'))
	local srt_once = srt == 6 and o
	local ttr = tpn and tpn .. '/i18n'
	local tr = def
	if ttr and exists(ttr) then
		tr = function(x)
				x = x and f:expandTemplate{
					title = ttr,
					args = { x }
				}
				return def(x)
			end
		local k = tr('__sort__') or ''
		srt = (srt_once or #k == 8 or use(k)) and srt
	end
	local function Q(x, ex)
		return x and x
			:gsub('i18n{([^}]+)}', tr)
			:gsub(QIDpattern,
				function(m)
					return ex and ex:find(m, 1, true) and m
						or getLabel(m)
				end)
	end
	local t = not srt and r or tbl{
		srt = function(t, x)
			if (not srt_once or srt_once and x) and #t > 0 then
				table.sort(t,
					function(x, y)
						return y.key < x.key
					end)
				while #t > 0 do
					r:ins(t:rm())
				end
			end
		end,
	}
	local u = 1 + (qc or def(a.maxnum, 99))
	for c = 0, u do
		local i = icons and def(a['icon' .. c])
		local d = def(a['display' .. c])
		local l = def(a['link' .. c])
		local dl = d or l
		if d and imgexists(d)
		then
			i, d = d, nil
		else
			d = (fuse_icons or not i or nil) and Q(d or tr(l), not qc and l)
		end
		if r.n and (not o and dl or not dl) then
			r:ins{ note = r.n, sep = s1, }
		end
		r.n = Q(def(a['note' .. c]))
		if dl then
			t:ins{
				disp = d,
				icon = i,
				link = l,
				key = srt and key(d) or c,
				note = r.n,
				sep = s1,
				ticl = def(a['article' .. c], a.__art),
				pref = a['prefix' .. c],
				suff = a['suffix' .. c],
			}
			r.n = nil
		else
			if srt then
				t:srt(c == u)
			end
			if r[#r] then
				r[#r].sep = s0
			end
		end
	end
	return r
end

-- maintenance / tracker categories
function maintcat(tpn, r, t)
	for k, v in pairs(t) do
		if v and tpn then
			r:app('[[Category:', tpn, ' maintenance/', k, ' users]]')
		end
	end
end

function _tpn(frame)
	frame = frame:getParent()
	local tpn = frame and frame:getTitle()
	return def(tpn and tpn:find('Template:', 1, true) == 1 and tpn)
end

function main(frame, qc)
	local tpn = _tpn(frame)
	local a = named_args(frame)
	local aln = a.iconsalign:find('right', 1, true)
	local wth = a.iconswidth
	local pfx = a.prefix
	local sfx = a.suffix
	local all = use(a.all or a.redlinks)
	local css = a.__css
	local r = tbl()
	r:app(top(a.title, a.img, pfx, sfx, tpn))
	for _, v in ipairs(number_suffixed_named_args(frame, a, qc, tpn)) do
		r:app(
			row(aln, wth, pfx, sfx, all, css,
				v.sep, v.disp, v.link, v.pref, v.suff, v.ticl, v.note, v.icon
			)
		)
	end
	r:app(bottom())
	maintcat(tpn, r, {
		redlink = all,
		--indent = a.__css.__indent,
	})
	return r:cat('')
end

function Q(frame)
	local a = frame.args
	local c = 1
	local ip = def(a.icons) and (a.icons:match('^[0no]+[ly]*$') or '1') or ''
	local ps = function(m, p)
			for _, v in ipairs(mw.wikibase.getBestStatements(m, p)) do
				v = v.mainsnak and v.mainsnak.datavalue
				v = v and v.value
				if v then
					return v
				end
			end
		end
	a.icons, ip = ip, use(ip) and a.icons:match('^P%d+$')
	a.prefix = a.prefix or ':Category:'
	for i, q in ipairs(a) do
		c = c + 1
		if def(q) then
			for m in q:gmatch(QIDpattern) do
				-- commons cat
				a['link' .. i], m = ps(m, 'P373'), ip and ps(m, ip)
				a['icon' .. i] = m and 'File:' .. m
				break
			end
			if a['link' .. i] then
				a['display' .. i] = q -- q may contain wikitext
			else
				a['note' .. i] = q -- no wikidata id or no P373
			end
		end
	end
	return main(frame, c)
end

-- exported functions
return {
	main = main,
	Q = Q,
}