Module:Infobox functions
Appearance
Documentation for this module may be created at Module:Infobox functions/doc
local cargo = mw.ext.cargo -- for cargo queries if needed
local str = require("Module:String utilities")
local cfmt = require("Module:Cargo format utilities")
local json = require("Module:JSON_pipeline")
local ibf = {}
-- =============================================================================
-- NOTE: LIST FIELD RENDERING (TEMPORARY ARCHITECTURE)
-- =============================================================================
-- We are currently using `raw_record` (unformatted Cargo results) for fields
-- that need structured rendering (e.g., bulleted + collapsible lists).
--
-- This is necessary because `Module:Cargo format utilities`'s format_cargo_results_with_schema() flattens
-- list fields (e.g., "~~" delimited values) into comma-separated strings for
-- inline display, which destroys list structure.
--
-- Current pattern:
-- - `record` = formatted, display-ready values (good for simple rows)
-- - `raw_record` = original values (required for structured list rendering)
--
-- This render_threshold_list pipeline below is a pragmatic, low-risk workaround to avoid modifying the global
-- formatter.
--
-- FUTURE IMPROVEMENT:
-- The formatting pipeline should be refactored to separate:
--
-- (1) STRUCTURAL FORMATTING
-- - Preserve list fields as Lua tables (not flattened strings)
-- - Example:
-- "a~~b~~c" → { "a", "b", "c" }
--
-- (2) PRESENTATION FORMATTING
-- - Decide how to render:
-- • inline ("a, b, c")
-- • bullet list
-- • collapsible list
-- • table, etc.
--
-- Ideally, `format_cargo_results_with_schema()` should support a mode like:
-- preserve_list_structure = true
--
-- Until then, any field requiring advanced rendering (lists, maps, etc.)
-- should use `raw_record` to avoid losing structure.
-- =============================================================================
-- New functions to allow collapsible bulleted lists inside infoboxes
-- NEW HELPER FUNCTIONS FOR BULLETED LISTS INSIDE INFOBOX
local function normalize_to_list(value, delim)
delim = delim or '~~'
if not value or value == '' then
return {}
end
local out = {}
if type(value) == 'table' then
for _, v in ipairs(value) do
if v ~= nil then
local s = mw.text.trim(tostring(v))
if s ~= '' then
table.insert(out, s)
end
end
end
return out
end
for item in mw.text.gsplit(tostring(value), delim, true) do
item = mw.text.trim(item)
if item ~= '' then
table.insert(out, item)
end
end
return out
end
local function build_ul(items, extra_class)
if not items or #items == 0 then
return nil
end
local ul = mw.html.create('ul')
:addClass('infobox-list')
if extra_class and extra_class ~= '' then
ul:addClass(extra_class)
end
for _, item in ipairs(items) do
ul:tag('li'):wikitext(item)
end
return ul
end
-- render_threshold_list(value, opts)
-- Splits a delimited string into items and renders as a bulleted HTML list.
-- If item count exceeds opts.threshold, splits into visible and collapsible sections.
-- opts: threshold (default 10), delim (default '~~'), link (wrap items in [[...]]),
-- collapse (bool), collapse_label, expanded_label.
-- Returns nil if value is empty, plain <ul> if collapse disabled or under threshold,
-- otherwise a <div> with visible <ul> and mw-collapsible hidden <ul>.
local function render_threshold_list(value, opts)
opts = opts or {}
local items = normalize_to_list(value, opts.delim or '~~')
-- Link items if requested
if opts.link then
for i, item in ipairs(items) do
items[i] = '[[' .. item .. ']]'
end
end
local threshold = opts.threshold or 10
local collapse_label = opts.collapse_label or 'Show less'
local expanded_label = opts.expanded_label or 'Show more'
local collapse_enabled = opts.collapse ~= false
if #items == 0 then
return nil
end
if (not collapse_enabled) or (#items <= threshold) then
return tostring(build_ul(items))
end
local wrapper = mw.html.create('div')
:addClass('infobox-list-wrapper')
local visible_items = {}
local hidden_items = {}
for i, item in ipairs(items) do
if i <= threshold then
table.insert(visible_items, item)
else
table.insert(hidden_items, item)
end
end
wrapper:node(build_ul(visible_items, 'infobox-list-visible'))
local collapsible = wrapper:tag('div')
:addClass('mw-collapsible')
:addClass('mw-collapsed')
:addClass('infobox-collapsible-list')
:attr('data-expandtext', expanded_label)
:attr('data-collapsetext', collapse_label)
collapsible:node(build_ul(hidden_items, 'infobox-list-hidden'))
return tostring(wrapper)
end
ibf.render_threshold_list = render_threshold_list
-- render_threshold_field(record, field_name, schema, opts)
-- Wrapper around render_threshold_list that resolves field values and formatting
-- behavior from a Cargo record and JSON schema before building the list HTML.
-- Schema-derived defaults: link (from "page"/"list_of_page" type), delim.
-- Caller opts always take precedence over schema-derived values.
-- Returns nil if record or field_name is missing.
function ibf.render_threshold_field(record, field_name, schema, opts)
if not record or not field_name then
return nil
end
opts = opts or {}
-- Apply defaults (only if caller didn't specify)
if opts.threshold == nil then opts.threshold = 5 end
if opts.collapse == nil then opts.collapse = true end
if opts.collapse_label == nil then opts.collapse_label = 'Show less' end
if opts.expanded_label == nil then opts.expanded_label = 'Show more' end
if schema then
local field_def = schema[field_name]
if field_def then
-- Derive link from schema type
if opts.link == nil then
local t = field_def.type
if t == "page" or t == "list_of_page" then
opts.link = true
end
end
-- Derive delimiter from schema (caller can still override)
if opts.delim == nil and field_def.delimiter then
opts.delim = field_def.delimiter
end
end
end
return render_threshold_list(record[field_name], opts)
end
function ibf.add_threshold_row(root, label, record, field_name, schema, html_theme_class, opts)
local value = ibf.render_threshold_field(record, field_name, schema, opts)
return ibf.add_list_row(root, label, value, html_theme_class)
end
-- ibf.query_and_format(cargo_table, fields, cargo_args, schema_page_title)
-- Shared boilerplate for infobox modules that query a single Cargo record and
-- format it against a JSON field-map schema page.
-- Returns raw_record, record, schema on success.
-- Returns nil, nil, nil, error_message if the query is empty or the schema fails to load.
function ibf.query_and_format(cargo_table, fields, cargo_args, schema_page_title)
local cargo_results = cargo.query(cargo_table, fields, cargo_args)
if not (cargo_results and #cargo_results > 0) then
return nil, nil, nil, "⚠️ No results returned."
end
local schema = json.load_json_page(schema_page_title)
if not schema or not next(schema) then
return nil, nil, nil, "⚠️ Error: Could not load schema page \"" .. tostring(schema_page_title) .. "\""
end
local formatted = cfmt.format_cargo_results_with_schema(cargo_results, schema)
return cargo_results[1], formatted[1], schema
end
-- ibf.resolve_image(image, fallback)
-- Returns image if it's non-nil/non-empty, otherwise fallback.
function ibf.resolve_image(image, fallback)
if not image or image == "" then
return fallback
end
return image
end
-- Helper function to escape_html
local function escape_html(text)
if not text or text == "" then return "" end -- Prevent error if text is nil
text = text:gsub("&(?![%w#]+;)", "&") -- Escape ampersands unless already part of an entity (e.g. &, ')
text = text:gsub("'", "'") -- Escape single quotes
text = text:gsub('"', """) -- Escape double quotes
text = text:gsub(">", ">") -- Escape greater-than sign
text = text:gsub("<", "<") -- Escape less-than sign
text = text:gsub("<br ?/?>", "<br/>") -- Allow <br> or <br/>
-- Allow known safe formatting tags back in (optional)
text = text:gsub("<i>", "<i>") -- Unescape <i> tag
text = text:gsub("</i>", "</i>") -- Unescape </i> tag
text = text:gsub("<b>", "<b>") -- Unescape <b> tag
text = text:gsub("</b>", "</b>") -- Unescape </b> tag
return text -- Return the processed, safe HTML text
end
function ibf.create_infobox(root, title, subtitle, html_theme_class)
-- Escape HTML characters in title
title = escape_html(title)
subtitle = escape_html(subtitle)
-- Begin creating the html table infobox template
root = mw.html.create('table')
:addClass('infobox')
:addClass('infobox' .. html_theme_class)
-- Prepare title and subtitle content
local content = mw.html.create('')
content:wikitext(title)
if subtitle and subtitle ~= '' then
-- Add the subtitle directly after the title, within the same <th> element
content
:newline()
:tag('div') -- Use a div for subtitle for more control
:addClass('infobox-subtitle')
:wikitext(subtitle)
:allDone()
end
-- Add title (and subtitle) to the infobox
root:tag('tr')
:tag('th')
:addClass('infobox-title')
-- add additional styles based on taxon
:addClass('infobox-title' .. html_theme_class)
:attr('colspan', 2)
:node(content) -- Use the prepared content that includes both title and subtitle
:done()
-- Add spacer row after the title/subtitle
root:tag('tr')
:tag('td')
:addClass('spacer-row')
:attr('colspan', 2)
:done()
return root
end
function ibf.add_subtitle(root, text, html_theme_class, colspan)
if text then
root:tag('tr')
:tag('th')
:addClass('infobox-subtitle')
:addClass('infobox-subtitle' .. html_theme_class)
:wikitext(text)
:attr('colspan', colspan or 2)
:css('text-align', 'center')
:done()
-- Add spacer row after
root:tag('tr')
:tag('td')
:addClass('spacer-row')
:attr('colspan', colspan or 2)
:done()
return root
else return root
end
end
function ibf.add_header(root, text, html_theme_class, colspan)
if text then
-- Add spacer row before header
root:tag('tr')
:tag('td')
:addClass('spacer-row') -- Use a different class if styling differs from the after-header spacer
:attr('colspan', colspan or 2)
:done()
-- Add header row
root:tag('tr')
:tag('th')
:addClass('infobox-header')
:addClass('infobox-header' .. html_theme_class)
:wikitext(text)
:attr('colspan', colspan or 2)
:css('text-align', 'center')
:done()
-- Add spacer row after header (as before)
root:tag('tr')
:tag('td')
:addClass('spacer-row')
:attr('colspan', colspan or 2)
:done()
return root
else
return root
end
end
function ibf.add_image(root, image, html_theme_class, caption)
html_theme_class = html_theme_class or ""
local function strip_file_markup(img)
local filename = img:match("%[%[File:(.-)|") or img:match("%[%[File:(.-)%]%]")
return filename and mw.text.trim(filename) or img
end
if image == nil or image == "" then
return root
end
caption = (caption ~= nil and caption ~= "") and mw.text.trim(caption) or nil
local is_url = image:match("^https?://") ~= nil
local is_preformatted_file = image:match("^%[%[File:.*%]%]") ~= nil
-- Only add "File:" prefix if not preformatted or external
if not is_url and not is_preformatted_file then
image = str.add_file_prefix_gentle(image)
end
local td = root:tag('tr'):tag('td')
td:addClass('infobox-image')
:addClass('infobox-image' .. html_theme_class)
:attr('colspan', 2)
if is_url then
-- External image/URL: leave as-is (no reliable hover overlay unless you render <img>)
td:wikitext(image)
else
-- Normalize file name for output
if is_preformatted_file then
image = strip_file_markup(image)
end
image = str.add_file_prefix_gentle(image)
local file_wikitext = string.format('[[%s|300px|frameless|center]]', image)
if caption then
-- Hover overlay version
local wrap = td:tag('div'):addClass('ib-image-wrap')
wrap:wikitext(file_wikitext)
-- Caption overlay (escape as plain text; avoid interpreting pipes/brackets)
wrap:tag('div')
:addClass('ib-image-caption')
:wikitext(mw.text.nowiki(caption))
else
-- Backward-compatible behavior
td:wikitext(file_wikitext)
end
end
td:done()
-- Spacer row after
root:tag('tr')
:tag('td')
:addClass('spacer-row')
:attr('colspan', 2)
:done()
return root
end
function ibf.add_row(root, name, value, html_theme_class)
if not (name and name ~= "" and value and value ~= "") then
return root
end
local row_type_class = 'infobox-row' .. html_theme_class
root:tag('tr')
:tag('td')
:addClass('infobox-row') -- Add base class
:addClass(row_type_class) -- Add theme class
:addClass('infobox-label')
:wikitext('<b>' .. name .. '</b>')
:done()
:tag('td')
:addClass('infobox-row') -- Add base class
:addClass(row_type_class) -- Add theme class
:wikitext(value)
:done()
-- Add spacer...
root:tag('tr')
:addClass('infobox-row-spacer')
:tag('td')
:attr('colspan', 2)
:wikitext(' ')
:done()
return root
end
function ibf.add_spanning_row(root, text, html_theme_class)
if text and text ~= '' then
root:tag('tr')
:tag('td')
:attr('colspan', '2')
:addClass('infobox-row')
:addClass('infobox-row' .. html_theme_class)
:css('text-align', 'center') -- This line centers the text
:wikitext(text)
:done()
return root
else
return root
end
end
function ibf.add_list_row(root, label, value, html_theme_class)
if not (label and label ~= "" and value and value ~= "") then
return root
end
local row_type_class = 'infobox-row' .. html_theme_class
-- Label row, full width
root:tag('tr')
:tag('th')
:attr('colspan', 2)
:addClass('infobox-row')
:addClass(row_type_class)
:addClass('infobox-label')
:wikitext(label)
:done()
-- Value row, full width
root:tag('tr')
:tag('td')
:attr('colspan', 2)
:addClass('infobox-row')
:addClass(row_type_class)
:wikitext(value)
:done()
-- Spacer
root:tag('tr')
:addClass('infobox-row-spacer')
:tag('td')
:attr('colspan', 2)
:wikitext(' ')
:done()
return root
end
return ibf