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

Module:Navbox

From Gensopedia

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