Toggle menu
Toggle preferences menu
Toggle personal menu
Not logged in
Your IP address will be publicly visible if you make any edits.

Documentation for this module may be created at Module:Navbox/doc

-- version 1.1.1

-- config table for RANGER.
-- If you want to change the default config, DO NOT change it here,
-- please do it via the `onLoadConfig` hook in [[Module:Navbox/Hooks]].
local config = {
	default_navbox_class = "navigation-not-searchable",   -- Base value of the `class` parameter.
	default_title_class = nil,    -- Base value of the `title_class` parameter.
	default_above_class = nil,    -- Base value of the `above_class` parameter.
	default_below_class = nil,    -- Base value of the `below_class` parameter.
	default_section_class =nil,   -- Base value of the `section_class` parameter.
	default_header_class = nil,   -- Base value of the `header_class` parameter.
	default_group_class = nil,    -- Base value of the `group_class` parameter.
	default_list_class = 'hlist', -- Base value of the `list_class` parameter.
	
	default_header_state = nil, -- Base value of the `state` parameter.

	editlink_hover_message_key = 'Navbox-edit-hover', -- The system message name for hover text of the edit icon. 
	
	auto_flatten_top_level = true, -- If true, when a section has only one list with no content and no corresponding group but has sublists, these sublists will be moved to top level.
	-- This helps make the hierarchy of sections and content clearer.
	-- An example:
	-- {{navbox
	-- ...
	--   |header-1 = Items
	--   | group-1.1 = Weapons
	--   |  list-1.1 = Swords · Guns · Wands
	--   | group-1.2 = Armors
	--   |  list-1.2 = Head pieces · Capes
	--   |header-2 = NPCs
	--   | group-2.1 = Town NPCs
	--   |  list-2.1 = Guide · Witch
	-- ...
	-- }}
	-- will be equal to:
	-- {{navbox
	-- ...
	--   |header-1 = Items
	--   | group-2 = Weapons
	--   |  list-2 = Swords · Guns · Wands
	--   | group-3 = Armors
	--   |  list-3 = Head pieces · Capes
	--   |header-5 = NPCs
	--   | group-6 = Town NPCs
	--   |  list-6 = Guide · Witch
	-- ...
	-- }}
	
	custom_render_handle = nil, -- usually for debugging purposes only. if set, it should be a function accept 2 parameters: `dataTree` and `args`, and return a string as module output.
}

---------------------------------------------------------------------

-- Argument alias.
local CANONICAL_NAMES = {
	['titlestyle'] = 'title_style',
	['listclass'] = 'list_class',
	['groupstyle'] = 'group_style',
	['collapsible'] = 'state',
	['editlink'] = 'meta',
	['editlinks'] = 'meta',
	['editicon'] = 'meta',
	['edit_link'] = 'meta',
	['edit_links'] = 'meta',
	['edit_icon'] = 'meta',
	['navbar'] = 'meta',
	['evenodd'] = 'striped',
	['class'] = 'navbox_class',
	['css'] = 'navbox_style',
	['style'] = 'navbox_style',
}

local STATES = {
	['no'] = '',
	['off'] = '',
	['plain'] = '',
	['collapsed'] = 'mw-collapsible mw-collapsed',
}

local BOOL_FALSE = {
	['no'] = true,
	['off'] = true,
	['false'] = true,
}

local STRIPED = {
	['odd'] = 'striped-odd',
	['swap'] = 'striped-odd',
	['y'] = 'striped-even',
	['yes'] = 'striped-even',
	['on'] = 'striped-even',
	['even'] = 'striped-even',
	['striped'] = 'striped-even',
}

local DEFAULT_ARGS = {
	['meta'] = true,
}

local NAVBOX_CHILD_INDICATOR = '!!C$H$I$L$D!!'
local NAVBOX_CHILD_INDICATOR_LENGTH = string.len( NAVBOX_CHILD_INDICATOR )

local CLASS_PREFIX = 'ranger-'

---------------------------------------------------------------------

local p = {}
local h = {} -- non-public
local hooks = mw.title.new('Module:Navbox/Hooks').exists and require('Module:Navbox/Hooks') or {}

---------------------------------------------------------------------

-- For templates: {{#invoke:navbox|main|...}}
function p.main(frame)
	local args = p.mergeArgs(frame)
	args = h.parseArgs(args)
	return p.build(args)
end

-- For modules: return require('module:navbox').build(args)
-- By default this method will skip the arguments sanitizing phase 
-- (and onSanitizeArgsStart/onSanitizeArgsEnd hooks).
-- Set `doParseArgs` to true to do arguments sanitizing.
-- If `customConfig` table is provided, it will be merged into default config table.
-- If `customHooks` table is provided, all default hook handles will be overrided, unprovided hooks will be empty.
function p.build(args, doParseArgs, customConfig, customHooks)
	if customHooks then
		hooks = customHooks
	end
	if customConfig then
		for k,v in pairs(customConfig) do
			config[k] = v
		end
	end
	if doParseArgs then 
		args = h.parseArgs(args)
	end

	h.runHook('onLoadConfig', config, args)
	
	--merge default args
	for k,v in pairs(DEFAULT_ARGS) do
		if args[k] == nil then
			args[k] = DEFAULT_ARGS[k]
		end
	end

	h.runHook('onBuildTreeStart', args)
	local dataTree = h.buildDataTree(args)
	h.runHook('onBuildTreeEnd', dataTree, args)
	
	if type(config.custom_render_handle) == 'function' then
		return config.custom_render_handle(dataTree, args)
	else
		return h.render(dataTree)
	end
end

-- merge args from frame and frame:getParent()
-- It may be used when creating custom wrapping navbox module.
--
-- For example, Module:PillNavbox
--
-- local RANGER = require('Module:Navbox')
-- local p = {}
-- function p.main(frame)
--     return RANGER.build(RANGER.mergeArgs(frame), true, {
--         default_navbox_class = 'pill', -- use "pill" style by default.
--     })
-- end
-- return p
--
function p.mergeArgs(frame)
	local inputArgs = {}
	
	for k, v in pairs(frame.args) do
		v = mw.text.trim(tostring(v))
		if v ~= '' then
			inputArgs[k] = v
		end
	end
	
	for k, v in pairs(frame:getParent().args) do
		v = mw.text.trim(v)
		if v ~= '' then
			inputArgs[k] = v
		end
	end
	
	return inputArgs
end

------------------------------------------------------------------------

function h.parseArgs(inputArgs)
	
	h.runHook('onSanitizeArgsStart', inputArgs)
	
	local args = {}
	
	for k, v in pairs(inputArgs) do
		-- all args have already been trimmed
		if type(k) == 'string' then
			local key = h.normalizeKey(k)
			args[key] = h.normalizeValue(key, v)
		else
			args[k] = mw.text.trim(v) -- keep number-index arguments (for {{navbox|child|...}})
		end
	end
	
	h.runHook('onSanitizeArgsEnd', args, inputArgs)
	
	return args
end

-- Normalize the name string of arguments.
-- the normalized form is (index:)?name, in which:
-- index is number index such as 1, 1.3, 1.2.45,
-- name is in lowercase underscore-case, such as group, group_style
-- e.g: header_state, 1.3:list_style
-- the input argument name can be:
-- * camel-case: listStyle, ListStyle
-- * space separated: list style
-- * prefix+index+postfix?, and can be in camel-case or space/hyphen separated or mixed: list 1 style, list1, list1Style, list1_style
-- * index.name: 1.3.list
-- * index_name: 1.3_list (Space separated are treated as underscore separated, therefore 1.3 list are vaild too)
function h.normalizeKey(s)
	-- camel-case to lowercase underscore-case
	s = s:gsub('%l%f[%u]', '%0_') -- listStyle to list_style
	s = (s:gsub(' ', '_')):lower() -- space to underscore 
	s = s:gsub('%l%f[%d]', '%0_') -- group1* to group_1*
	s = s:gsub('%d%f[%l]', '%0_') -- *1style to *1_style
	
	-- number format x_y_z to x.y.z
	s = s:gsub('(%d)_%f[%d]', '%1%.')
	
	-- move index to the beginning:
	-- group_1.2_style to 1.2:group_style
	-- group_1 to 1:group
	s = s:gsub('^([%l_]+)_([%d%.]+)', '%2:%1')
	
	-- support index.name and index_name:
	-- 1.2.group / 1.2_group to 1.2:group
	s = s:gsub('^([%d%.]+)[%._]%f[%l]', '%1:')
	
	-- now the key should be in normalized form, if the origin key is vaild

	-- standardize *_css to *_style
	s = s:gsub('_css$', '_style')
	
	-- standardize all aliases to the canonical name
	return CANONICAL_NAMES[s] or s
end

function h.normalizeValue(k, v)
	k = tostring(k)
	if k:find('_style$') then
		v = (v .. ';'):gsub(';;', ';')
		return v
	elseif k == 'striped' then
		return STRIPED[v]
	elseif v:sub(1, 2) == '{|' or v:match('^[*:;#]') then
		-- Applying nowrap to lines in a table does not make sense.
		-- Add newlines to compensate for trim of x in |parm=x in a template.
		return '\n' .. v ..'\n'
	elseif k == 'meta' then
		return not BOOL_FALSE[v]
	end
	return v
end

-- we need a default value for all empty state_* arguments, so we can not do this in h.normalizeValue()
function h.normalizeStateValue(v)
	return STATES[v] or 'mw-collapsible'
end

-- parse arguments, convert them to structured data tree
function h.buildDataTree(args)
	local data = {
		state = h.normalizeStateValue(args.state),
		striped = args.striped,
		class = h.mergeAttrs(args.navbox_class, config.default_navbox_class),
		style = args.navbox_style,
	}
	
	if args.title or args.meta or data.state ~= '' then
		data.title = {
			content = args.title,
			class = h.mergeAttrs(args.title_class, config.default_title_class),
			style = args.title_style,
		}
		if args.meta then
			data.metaLinks = {
				template = args.name or mw.getCurrentFrame():getParent():getTitle()
			}
		end
	end
	
	if args.above then
		data.above = {
			content = args.above,
			class= h.mergeAttrs(args.above_class, config.default_above_class),
			style = args.above_style,
		}
	end

	if args.below then
		data.below = {
			content = args.below,
			class= h.mergeAttrs(args.below_class, config.default_below_class),
			style = args.below_style,
		}
	end
	
	local tree = h.buildTree(args, {
		listClass = h.mergeAttrs(args.list_class, config.default_list_class),
		listStyle =  args.list_style,
		groupClass = h.mergeAttrs(args.group_class, config.default_group_class),
		groupStyle = args.group_style,
		headerClass = h.mergeAttrs(args.header_class, config.default_header_class),
		headerStyle = args.header_style,
	})
	
	-- handle {{navbox|child|...}} syntax:
	if args[1] == 'child' then
		return NAVBOX_CHILD_INDICATOR..mw.text.jsonEncode(tree)
	end
	
	-- normal case
	local sectionClass = h.mergeAttrs(args.section_class, config.default_section_class)
	local sectionStyle = args.section_style
	local headerState = args.header_state or config.default_header_state
	data.sections = {}
	local section
	for k, v in h.orderedPairs(tree or {}) do
		if v.header or not section then
			--start a new section
			section = { 
				class = h.mergeAttrs(args[k..':section_class'], sectionClass),
				style = h.mergeAttrs(args[k..':section_style'], sectionStyle),
				body = {},
			}
			-- Section header if needed.
			-- If the value of a `|header_n=` is two or more consecutive "-" characters (e.g. --, -----), 
			-- it means start a new section without header, and the new section will be not collapsable.
			if v.header and not string.match(v.header.content, '^%-%-+$') then
				section.header =v.header
				section.state = h.normalizeStateValue(args[k..':state'] or headerState)
			end
			v.header = nil
			data.sections[#data.sections+1] = section
		end
		-- check for section above/below areas
		if v.above then
			section.above = v.above
			v.above = nil
		end
		if v.below then
			section.below = v.below
			v.below = nil
		end
		if next(v) then -- v is not empty (with group/list/sub)
			section.body[#section.body+1] = v
		end
	end
	
	if config.auto_flatten_top_level then
		for _, sect in ipairs(data.sections) do
			if #sect.body == 1 then
				local node = sect.body[1]
				if not node.group and not node.list and node.sub then
					sect.body = node.sub
				end
			end
		end
	end
	
	return data
end

function h.buildTree(args, defaults)
	local tree = {}
	local check = function(key, value)
		local index, name = string.match(key, '^([%d%.]+):(.+)$')

		if not index then return end -- no number index found
		if name ~= 'list' and name ~= 'group' and name ~= 'header' and name ~= 'above' and name ~= 'below' then return end -- check only the names we are interested in
		if string.match(index, '^%.') or string.match(index, '%.$') or string.match(index, '%.%.') then return end -- invalid number index
		
		-- find the node that matches the index in the tree
		local arr = mw.text.split(index, '.', true)
		n = tonumber(table.remove(arr))
		local node = tree
		for _, v in ipairs(arr) do
			v = tonumber(v)
			if not node[v] then 
				node[v] = {['sub'] = {}} 
			elseif not node[v]['sub'] then
				node[v]['sub'] = {}
			end
			node = node[v]['sub']
		end
		if not node[n] then node[n] = {} end
		
		if name == 'list' and string.sub(value, 1, NAVBOX_CHILD_INDICATOR_LENGTH) == NAVBOX_CHILD_INDICATOR then
			-- it is from {{navbox|child| ... }}
			node[n]['sub'] = mw.text.jsonDecode(string.sub(value, NAVBOX_CHILD_INDICATOR_LENGTH+1))
		else
			node[n][name] = {
				content = value,
				class= h.mergeAttrs(args[key..'_class'], defaults[name..'Class']),
				style = h.mergeAttrs(args[key..'_style'], defaults[name..'Style'])
			}
		end
	end
	for k,v in pairs(args) do
		check(k, v)
	end
	return tree
end

function h.render(data)
	-- handle {{navbox|child|...}} syntax
	if type(data) == 'string' then
		return data
	end

	-----  normal case -----
	
	local out = mw.html.create()
	
	-- build navbox container
	local navbox = out:tag('div')
		:attr('role', 'navigation'):attr('aria-label', 'Navbox')
		:addClass(CLASS_PREFIX..'navbox')
		:addClass(data.class)
		:addClass(data.striped)
		:addClass(data.state)
		:cssText(data.style)

	--title bar
	if data.title then
    local titlebar = navbox:tag('div'):addClass(CLASS_PREFIX..'title')
    titlebar:tag('div'):addClass('mw-collapsible-toggle-placeholder')
    if data.metaLinks then
        titlebar:node(h.renderMetaLinks(data.metaLinks))
    end
    if data.title then
        titlebar:addClass(data.title.class) -- Apply class to titlebar
        titlebar:cssText(data.title.style) -- Apply style to titlebar
        titlebar:wikitext(data.title.content) -- Apply content to titlebar
    end
end

	--above
	if data.above then
		navbox:tag('div')
		:addClass(CLASS_PREFIX..'above mw-collapsible-content')
		:addClass(data.above.class)
		:cssText(data.above.style)
		:wikitext(data.above.content)
		:attr('id', (not data.title) and mw.uri.anchorEncode(data.above.content) or nil) -- id for aria-labelledby attribute, if no title
	end
	
	-- sections
	for i,sect in ipairs(data.sections) do
		--section box
		local section = navbox:tag('div')
			:addClass(CLASS_PREFIX..'section mw-collapsible-content')
			:addClass(sect.class)
			:addClass(sect.state)
			:cssText(sect.style)
		-- section header
		if sect.header then
			section:tag('div')
			:addClass(CLASS_PREFIX..'header')
			:addClass(sect.header.class)
			:cssText(sect.header.style)
			:tag('div'):addClass('mw-collapsible-toggle-placeholder'):done()
			:tag('div'):addClass(CLASS_PREFIX..'header-text'):wikitext(sect.header.content)
		end
		-- above:
		if sect.above then
			section:tag('div')
			:addClass(CLASS_PREFIX..'above mw-collapsible-content')
			:addClass(sect.above.class)
			:cssText(sect.above.style)
			:wikitext(sect.above.content)
		end
		-- body: groups&lists
		local box = section:tag('div'):addClass(CLASS_PREFIX..'section-body mw-collapsible-content')
		h.renderBody(sect.body, box, 0, true) -- reset even status each section
		-- below:
		if sect.below then
			section:tag('div')
			:addClass(CLASS_PREFIX..'below mw-collapsible-content')
			:addClass(sect.below.class)
			:cssText(sect.below.style)
			:wikitext(sect.below.content)
		end
	end
	-- Insert a blank section for completely empty navbox to ensure it behaves correctly when collapsed.
	if #data.sections == 0 and not data.above and not data.below then 
		navbox:tag('div'):addClass(CLASS_PREFIX..'section mw-collapsible-content')
	end

	--below
	if data.below then
		navbox:tag('div')
		:addClass(CLASS_PREFIX..'below mw-collapsible-content')
		:addClass(data.below.class)
		:cssText(data.below.style)
		:wikitext(data.below.content)
	end

	return out
end

function h.renderMetaLinks(info)
	local title = mw.title.new(mw.text.trim(info.template), 'Template')
	if not title then
		error('Invalid title ' .. info.template)
	end
	
	local msg = mw.message.new(config.editlink_hover_message_key)
	local hoverText = msg:exists() and msg:plain() or 'View or edit this template'
	
	return mw.html.create('span'):addClass(CLASS_PREFIX..'meta')
		:tag('span'):addClass('nv nv-view')
			:wikitext('[['..title.fullText..'|')
			:tag('span'):wikitext(hoverText):attr('title', hoverText):done()
			:wikitext(']]')
		:done()
end

function h.renderBody(info, box, level, even)
	local count = 0
	for _,v in h.orderedPairs(info) do
		if v.group or v.list or v.sub then
			count = count + 1
			-- row container
			local row = box:tag('div'):addClass(CLASS_PREFIX..'row')
			-- group cell
			if v.group or (v.sub and level > 0 and not v.list) then
				local groupCell = row:tag('div')
					:addClass(CLASS_PREFIX..'group level-'..level)
					:addClass((level > 0) and CLASS_PREFIX..'subgroup' or nil)
				local groupContentWrap = groupCell:tag('div'):addClass(CLASS_PREFIX..'wrap')
				if v.group then
					groupCell:addClass(v.group.class):cssText(v.group.style)
					groupContentWrap:wikitext(v.group.content)
				else
					groupCell:addClass('empty')
					row:addClass('empty-group-list')
				end
			else
				row:addClass('empty-group')
			end
			-- list cell
			local listCell = row:tag('div'):addClass(CLASS_PREFIX..'listbox')
			if not v.list and not v.sub then
				listCell:addClass('empty')
				row:addClass('empty-list')
			end
			if v.list or (v.group and not v.sub) then
				--listCell:node(h.renderList(v['list'] or '', k, level, args))
				even = not even -- flip even/odd status
				local cell = listCell:tag('div')
				:addClass(CLASS_PREFIX..'wrap')
				:addClass(even and CLASS_PREFIX..'even' or CLASS_PREFIX..'odd')
				if v.list then
					cell:addClass(v.list.class):cssText(v.list.style)
					:tag('div'):addClass(CLASS_PREFIX..'list'):wikitext(v.list.content)
				end
			end
			if v.sub then
				local sublistBox = listCell:tag('div'):addClass(CLASS_PREFIX..'sublist level-'..level)
				even = h.renderBody(v.sub, sublistBox, level+1, even)
			end
		end
	end
	if count > 0 then
		box:css('--count', count) -- for flex-grow
	end
	return even
end

-- pairs, but sort the keys alphabetically
function h.orderedPairs(t, f)
	local a = {}
	for n in pairs(t) do table.insert(a, n) end
	table.sort(a, f)
	local i = 0      -- iterator variable
	local iter = function ()   -- iterator function
		i = i + 1
		if a[i] == nil then return nil
		else return a[i], t[a[i]]
		end
	end
	return iter
end

-- For cascading parameters, such as style or class, they are merged in exact order (from general to specific). 
-- Any parameter starting with multiple hyphens(minus signs) will terminate the cascade.
-- An example:
-- For group_1.1, its style is affected by parameters |group_1.1_style=... , |subgroup_level_1_style=... , and |subgroup_style=... .
-- If we have |group_1.1_style= color:red; |subgroup_level_1_style= font-weight: bold; and |subgroup_style= color: green; ,
-- the style of group_1.1 will be style="color:green; font-weight: bold; color: red;" ;
-- if we have |group_1.1_style= -- color:red; |subgroup_level_1_style= font-weight: bold; and |subgroup_style= color: green; ,
-- the style of group_1.1 will be style="color: red;" only, and the cascade is no longer performed for |subgroup_level_1_style and |subgroup_style.
function h.mergeAttrs(...)
	local trim = mw.text.trim
	local s = ''
	for i=1, select('#', ...) do
		local v = trim(select(i, ...) or '')
		local str = string.match(v, '^%-%-+(.*)$')
		if str then
			s = trim(str..' '..s)
			break
		else
			s = trim(v..' '..s)
		end
	end
	if s == '' then s = nil end
	return s
end

function h.runHook(key, ...)
	if hooks[key] then
		hooks[key](...)
	end
end

-----------------------------------------------
return p