Roovet Articles

Module:Sidebar

This module implements the templates {{sidebar}} and {{sidebar with collapsible lists}}. See the individual template pages for documentation.


-- Module:Sidebar — safe, collapsible version
-- Fixes "args table is read-only" by ALWAYS copying frame args to a plain table.
-- Features:
--   * p.main(frame): build a sidebar (title, optional image, up to 10 sections)
--   * Each section can be collapsible: |collapsibleN=yes| (N=1..10), optional |stateN=collapsed|
--   * Section content via |listN= (semicolon OR newline separated) OR |contentN=
--   * p.collapsible(frame): simple collapsible box (for legacy {{#invoke:Sidebar|collapsible|...}} calls)

local p = {}

------------------------------------------------------------
-- Safe argument handling
------------------------------------------------------------
local function shallow_copy(t)
	if type(t) ~= 'table' then return {} end
	local out = {}
	for k, v in pairs(t) do out[k] = v end
	return out
end

local function trim(s)
	if type(s) ~= 'string' then return s end
	if mw and mw.text and mw.text.trim then
		return mw.text.trim(s)
	end
	return (s:gsub('^%s+', ''):gsub('%s+$', ''))
end

-- Return a brand-new, writable args table (never the read-only proxy)
local function getArgsPlain(frame, opt)
	opt = opt or { parentOnly = true }
	local ok, Arguments = pcall(require, 'Module:Arguments')
	if ok and type(Arguments) == 'table' and type(Arguments.getArgs) == 'function' then
		-- Make an extra copy in case getArgs returns a proxy
		return shallow_copy(Arguments.getArgs(frame, opt))
	end
	local srcFrame = frame
	if opt.parentOnly ~= false and frame.getParent then
		srcFrame = frame:getParent() or frame
	end
	return shallow_copy(srcFrame.args or {})
end

local function normalizeArgs(inArgs)
	local a = {}
	for k, v in pairs(inArgs) do
		if type(v) == 'string' then
			local t = trim(v)
			if t ~= '' then a[k] = t end
		else
			a[k] = v
		end
	end
	return a
end

local function is_blank(s)
	return s == nil or (type(s) == 'string' and trim(s) == '')
end

------------------------------------------------------------
-- Utilities
------------------------------------------------------------
local function split_items(s)
	if type(s) ~= 'string' then return {} end
	s = trim(s); if s == '' then return {} end
	s = s:gsub('\r\n?', '\n')
	local items = {}
	if s:find(';', 1, true) then
		for part in s:gmatch('([^;]+)') do
			local t = trim(part); if t ~= '' then table.insert(items, t) end
		end
	else
		for line in s:gmatch('([^\n]+)') do
			local t = trim(line); if t ~= '' then table.insert(items, t) end
		end
	end
	return items
end

local function normalize_width(w)
	w = trim(w or '')
	if w == '' then return '22em' end
	if w:match('^%d+$') then return w .. 'px' end
	if w:match('^%d+%s*[%a%%]+$') or w:match('^[%d%.]+%s*ch$') then return w end
	return '22em'
end

------------------------------------------------------------
-- Pieces
------------------------------------------------------------
local function buildImage(args)
	local file = args.image or args.file or args.img
	if is_blank(file) then return '' end
	local size = trim(args.image_size or args.size or '220px')
	local alt  = trim(args.image_alt or args.alt or '')
	local cap  = trim(args.image_caption or args.caption or '')

	if not file:match('^[Ff]ile:') and not file:match('^[Ii]mage:') then
		file = 'File:' .. file
	end
	local opts = size
	if not is_blank(alt) then opts = opts .. '|alt=' .. alt end

	local out = {}
	out[#out+1] = "<div style='text-align:center; padding:6px 8px;'>"
	out[#out+1] = string.format('[[%s|%s]]', file, opts)
	if not is_blank(cap) then
		out[#out+1] = string.format("<div style='color:#54595d; font-size:90%%; margin-top:4px;'>%s</div>", cap)
	end
	out[#out+1] = "</div>"
	return table.concat(out)
end

-- Collapsible section wrapper
local function wrapCollapsible(innerHtml, headingText, initialState)
	local classes = 'mw-collapsible'
	if initialState == 'collapsed' or initialState == 'close' or initialState == 'closed' then
		classes = classes .. ' mw-collapsed'
	end
	local out = {}
	out[#out+1] = string.format('<div class="%s" style="border-top:1px solid #a2a9b1;">', classes)
	-- Header with toggle
	out[#out+1] = '<div style="background:#eaecf0;border-bottom:1px solid #a2a9b1;padding:6px 8px;font-weight:600;display:flex;align-items:center;gap:8px;">'
	out[#out+1] = '<span class="mw-collapsible-toggle"></span>'
	out[#out+1] = headingText or ''
	out[#out+1] = '</div>'
	-- Collapsible content
	out[#out+1] = '<div class="mw-collapsible-content" style="padding:6px 8px;">'
	out[#out+1] = innerHtml or ''
	out[#out+1] = '</div>'
	out[#out+1] = '</div>'
	return table.concat(out)
end

local function buildSection(args, i)
	local heading   = args['heading'..i] or args['h'..i]
	local listRaw   = args['list'..i]
	local content   = args['content'..i]
	local collaps   = args['collapsible'..i] or args['collapse'..i] -- yes/true to enable collapsible
	local stateInit = args['state'..i] or args['collapsed'..i]      -- collapsed/expanded

	if is_blank(heading) and is_blank(listRaw) and is_blank(content) then
		return ''
	end

	-- Build body (list or custom content)
	local body = {}
	body[#body+1] = "<div>"
	if not is_blank(listRaw) then
		local items = split_items(listRaw)
		if #items > 0 then
			body[#body+1] = "<ul style='margin:0; padding-left:18px;'>"
			for _, li in ipairs(items) do
				body[#body+1] = '* ' .. li
			end
			body[#body+1] = "</ul>"
		end
	elseif not is_blank(content) then
		body[#body+1] = content
	end
	body[#body+1] = "</div>"
	local bodyHtml = table.concat(body)

	-- Collapsible?
	if collaps and collaps:lower() ~= '' and collaps:lower() ~= 'no' and collaps:lower() ~= 'false' then
		return wrapCollapsible(bodyHtml, heading or '', stateInit or '')
	end

	-- Non-collapsible section (Wikipedia-ish chrome)
	local out = {}
	if not is_blank(heading) then
		out[#out+1] = string.format(
			"<div style='font-weight:600; padding:6px 8px; background:#eaecf0; border-top:1px solid #a2a9b1;'>%s</div>",
			heading
		)
	else
		out[#out+1] = "<div style='border-top:1px solid #a2a9b1'></div>"
	end
	out[#out+1] = "<div style='padding:6px 8px'>" .. bodyHtml .. "</div>"
	return table.concat(out)
end

------------------------------------------------------------
-- Renderer
------------------------------------------------------------
function p._render(args)
	local title      = args.title or args.name or 'Sidebar'
	local title_link = args.title_link or args.titlelink
	local align      = (args.align == 'left') and 'left' or 'right'
	local width      = normalize_width(args.width)

	local floatStyle = (align == 'left')
		and "float:left; clear:left; margin:0 1em 1em 0;"
		or  "float:right; clear:right; margin:0 0 1em 1em;"

	local out = {}
	out[#out+1] = string.format(
		"<div class='sidebar plainlinks noprint' style='%s width:%s; background:#f8f9fa; border:1px solid #a2a9b1; border-radius:2px; overflow:hidden;'>",
		floatStyle, width
	)

	if not is_blank(title_link) then
		title = string.format('[[%s|%s]]', title_link, title)
	end
	out[#out+1] = string.format("<div style='background:#eaecf0; border-bottom:1px solid #a2a9b1; padding:8px 10px; font-weight:700;'>%s</div>", title)

	-- Optional image
	out[#out+1] = buildImage(args)

	-- Sections 1..10
	for i = 1, 10 do
		out[#out+1] = buildSection(args, i)
	end

	out[#out+1] = "</div>"
	return table.concat(out)
end

------------------------------------------------------------
-- Entry points
------------------------------------------------------------
function p.main(frame)
	local raw  = getArgsPlain(frame, { parentOnly = true })
	local args = normalizeArgs(raw)  -- make a fresh, trimmed copy
	return p._render(args)
end

-- Legacy / convenience: collapsible box builder
function p.collapsible(frame)
	-- If a dedicated Module:Collapsible exists, delegate to it
	local ok, Collapsible = pcall(require, 'Module:Collapsible')
	if ok and type(Collapsible) == 'table' and type(Collapsible.collapsible) == 'function' then
		return Collapsible.collapsible(frame)
	end

	-- Inline fallback
	local raw  = getArgsPlain(frame, { parentOnly = true })
	local a    = normalizeArgs(raw)

	local title   = a.title or a.heading or 'Details'
	local state   = a.state or a.collapse or ''
	local content = a.content or a[1] or ''
	local width   = a.width or ''
	local class   = a.class or ''
	local style   = a.style or ''

	if width ~= '' then
		if width:match('^%d+$') then width = width .. 'px'
		elseif not width:match('^%d+%s*[%a%%]+$') and not width:match('^[%d%.]+%s*ch$') then width = '' end
	end

	local classes = 'mw-collapsible' .. ((state == 'collapsed' or state == 'close' or state == 'closed') and ' mw-collapsed' or '')
	if class ~= '' then classes = classes .. ' ' .. class end

	local boxStyle = 'border:1px solid #a2a9b1;background:#f8f9fa;border-radius:2px;overflow:hidden;margin:0.4em 0;'
	if width ~= '' then boxStyle = boxStyle .. 'width:' .. width .. ';' end
	if style ~= '' then boxStyle = boxStyle .. style end

	local out = {}
	out[#out+1] = string.format('<div class="%s" style="%s">', classes, boxStyle)
	out[#out+1] = '<div style="background:#eaecf0;border-bottom:1px solid #a2a9b1;padding:6px 8px;font-weight:600;display:flex;align-items:center;gap:8px;">'
	out[#out+1] = '<span class="mw-collapsible-toggle"></span>'
	out[#out+1] = title
	out[#out+1] = '</div>'
	out[#out+1] = '<div class="mw-collapsible-content" style="padding:8px 10px;">'
	out[#out+1] = content
	out[#out+1] = '</div></div>'
	return table.concat(out)
end

-- Roovet compatibility: imported Template:Sidebar invokes
-- the historical "sidebar" entry point.
p.sidebar = p.main

return p