Module:Catnav

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.

Note that a space is generated by default on both sides of the enumerated name in the target of generated wikilinks, i.e. after the prefix (or prefixX) and before the suffix (or suffixX). In some cases this is undesired, notably when the specified prefix value is terminated by an opening parenthese (or similar bracket or quotation mark), and when the the specified prefix value is terminated by an closing parenthese (or similar bracket or quotation mark).

  • You can indicate to delete the extra space added after the specified prefix, by appending a &#x7f; (ASCII control code for DELETE, in hexadecimal) to this prefix: this deletes the following (space) character;
  • You can indicate to remove the extra space added before the specified suffix, by prefixing a &#x8; (ASCII control code for BACKSPACE, in hexadecimal) to this suffix: this removes the previous (space) character.
  • These extra control codes are invalid in HTML, but will not be present in the generated wikitext or HTML as they are handled specially internally by this module. Only the syntax &#x7f; or &#x8; (numerical character reference in lowercase hexadecimal, with no leading zeroes) for these control codes is supported by this module.

  • 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
require('strict')

local titlenew = mw.title.new
local split = mw.text.split
local concat = table.concat
local insert = table.insert
local remove = table.remove

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

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

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

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

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

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

local function tbl(t)
	t = t or {}
	t.trc = function(t, x)
		repeat until remove(t):find(x, 1, true)
		return 1
	end
	t.app = function(t, ...)
		for i = 1, arg.n do
			insert(t, arg[i])
		end
	end
	return t
end

-- flatten table entries of t to a string
local function flatten(t)
	local r = tbl()
	for u, v in ipairs(t) do
		u = {}
		for i = 1, 32 do -- v may not be a sequential table
			if v[i] and #v[i] > 0 then
				insert(u, v[i])
			end
		end
		if #u > 0 then
			u = split(concat(u, ' '):gsub('&#x8;', '\8'), '&#x7f;')
			for i = #u - 1, 1, -1 do
				u[i] = u[i] .. u[i + 1]:sub(2)
			end
			u = 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

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

local function top(title, i, pfx, sfx, tpn)
	local r = tbl{
		'<div dir="', isRTL and 'rtl' or 'ltr',
		'" class="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 concat(r, '')
end

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

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

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

local 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)
							insert(t, 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 concat(r, '')
end

local 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

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

local 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

local 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

local 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
					insert(r, remove(t))
				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
			insert(r, {
				note = r.n,
				sep = s1,
			})
		end
		r.n = Q(def(a['note' .. c]))
		if dl then
			insert(t, {
				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
local 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

local 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

local 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 concat(r, '')
end

local 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,
}