Module:Sandbox/Solidest/Episode table
Appearance
-- This module implements {{Episode table}} and {{Episode table/part}}.
-- Central configuration for names, labels, parameters, and tracking output.
-- The rendering code below should only contain behavior that cannot be described
-- as data here.
local config = {
-- Required module pages. Keeping them here makes dependencies easy to audit
-- and avoids repeated string literals in the implementation.
modules = {
arguments = 'Module:Arguments',
checkUnknown = 'Module:Check for unknown parameters',
colorContrast = 'Module:Color contrast',
colorData = 'Module:Color contrast/colors',
plainText = 'Module:Plain text',
},
-- Template and stylesheet pages expanded or emitted by this module.
templates = {
main = 'Template:Episode table',
part = 'Template:Episode table/part',
screenReaderOnly = 'Template:Screen reader-only',
styles = 'Module:Episode table/styles.css',
},
-- Default header background used when no background is supplied.
defaultColor = '#CCCCFF',
-- Classes added to the root table, in order.
classes = {'wikitable', 'plainrowheaders', 'wikiepisodetable'},
-- Optional sticky header support. When enabled, the configured class is
-- added to the table and the configured stylesheet is emitted with the
-- module stylesheet.
stickyHeader = {
enabled = true,
class = 'sticky-header',
styles = 'Template:Sticky header/styles.css',
},
-- Contrast thresholds used for tracking invalid table colors and for making
-- references readable on dark or low-contrast backgrounds.
contrast = {
invalidColor = 7,
reference = 7,
},
-- Parameter groups used outside the header-column definitions. Each key is an
-- internal group name; each value is the list of accepted template parameter
-- names for that group, checked from left to right.
params = {
id = {'id'},
background = {'background'},
caption = {'caption'},
showCaption = {'show_caption'},
country = {'country'},
episodes = {'episodes'},
anchor = {'anchor'},
totalWidth = {'total_width'},
dontclose = {'dontclose'},
forceoverall = {'forceoverall'},
overallType = {'overall_type'},
seasonType = {'season_type'},
seriesType = {'series_type'},
viewersType = {'viewers_type'},
partColor = {'c'},
partReference = {'r'},
partSubtitle = {'subtitle'},
partNobold = {'nobold'},
refReference = {'r'},
refBackground = {'b'},
altTitle = {'alttitle', 'AltTitle'},
},
-- Text used by dynamic column headings. Fixed column headings live directly
-- in config.columns; this section covers headings assembled from smaller
-- pieces or from template parameters.
headings = {
number = {text = 'No.', title = 'Number'},
overall = {defaultType = 'overall', separator = '<br>'},
season = {defaultType = 'season', prefix = ' in<br>'},
series = {defaultType = 'series', prefix = ' in<br>'},
prodcode = {text = 'Prod.', title = 'Production', suffix = '<br>code'},
viewers = {
defaultLabel = ' Viewers',
countrySuffix = ' viewers',
defaultType = 'millions',
typePrefix = '<br>(',
typeSuffix = ')',
},
},
-- Suffixes appended to column parameter names for custom heading text and
-- heading references. For example, airdateT changes the airdate heading and
-- airdateR adds a reference to it.
suffixes = {
text = {'T'},
reference = {'R'},
},
-- Rows inserted by {{Episode table/part}}. params enables the row label;
-- label is the text shown before the supplied value.
partTypes = {
{params = {'act'}, label = 'Act'},
{params = {'chapter'}, label = 'Chapter'},
{params = {'part'}, label = 'Part'},
{params = {'volume'}, label = 'Volume'},
{params = {'week'}, label = 'Week'},
},
-- {{Episode table/part}} spans across all possible columns.
partRow = {
colspan = 13,
},
-- Header columns, in render order.
-- key: internal column identifier used by the rendering code.
-- params: accepted parameter names; the first present parameter enables the column.
-- heading: fixed heading text; omit it for headings built from config.headings.
-- defaultWidth: width used when the enabling parameter is present but blank.
-- title: appends the alternate-title heading below the title heading.
-- skipIfSeasonUsed: suppresses this column when the season column is present.
columns = {
{
key = 'overall',
params = {'overall', 'EpisodeNumber'},
defaultWidth = 5,
},
{
key = 'overall2',
params = {'overall2', '*'},
defaultWidth = 5,
heading = '',
},
{
key = 'season',
params = {'season', 'EpisodeNumber2'},
defaultWidth = 5,
},
{
key = 'series',
params = {'series', 'EpisodeNumber2Series'},
defaultWidth = 5,
skipIfSeasonUsed = true,
},
{
key = 'title',
params = {'title', 'Title'},
heading = 'Title',
title = true,
},
{
key = 'aux1',
params = {'aux1', 'Aux1'},
heading = '',
},
{
key = 'director',
params = {'director', 'DirectedBy'},
heading = 'Directed by',
},
{
key = 'writer',
params = {'writer', 'WrittenBy'},
heading = 'Written by',
},
{
key = 'aux2',
params = {'aux2', 'Aux2'},
heading = '',
},
{
key = 'aux3',
params = {'aux3', 'Aux3'},
heading = '',
},
{
key = 'airdate',
params = {'airdate', 'OriginalAirDate'},
heading = 'Original release date',
},
{
key = 'altdate',
params = {'altdate', 'AltDate'},
heading = '',
},
{
key = 'guests',
params = {'guests', 'Guests'},
heading = 'Guest(s)',
},
{
key = 'musicalguests',
params = {'musicalguests', 'MusicalGuests'},
heading = 'Musical/entertainment guest(s)',
},
{
key = 'prodcode',
params = {'prodcode', 'ProdCode'},
defaultWidth = 7,
},
{
key = 'viewers',
params = {'viewers', 'Viewers'},
defaultWidth = 10,
},
{
key = 'aux4',
params = {'aux4', 'Aux4'},
heading = '',
},
},
-- Tracking categories appended to rendered output. These should stay in one
-- place because they are public maintenance signals, not rendering details.
categories = {
visibleCaption = '[[Category:Articles using Template:Episode table with a visible caption]]',
invalidColor = '[[Category:Articles using Template:Episode table with invalid colour combination]]',
noColumns = '[[Category:Articles using Template:Episode table with no column parameters]]',
unknown = '[[Category:Pages using episode table with unknown parameters|_VALUE_%s]]',
},
-- Preview-only messages used by unknown-parameter tracking.
preview = {
unknown = 'Page using [[Template:Episode table]] with unknown parameter "_VALUE_"',
},
-- Unknown-parameter tracking is generated from config.params and
-- config.columns so new parameters do not need to be listed twice.
check = {
-- Individual parameters accepted by the module but not listed as known
-- parameters for unknown-parameter tracking.
excludedParams = {'*'},
-- Parameter groups from config.params that are not valid for the main
-- template and should not be listed as known parameters.
excludedParamGroups = {'partNobold'},
-- Parameter groups from config.params that are only valid when one of
-- the configured suffixes is appended.
paramGroupsRequiringSuffix = {'altTitle'},
-- Passed through to [[Module:Check for unknown parameters]].
showblankpositional = 'y',
-- Regular expressions passed through to [[Module:Check for unknown parameters]].
regexps = {
'[%w_]+NULL',
},
},
}
local HTMLcolor = mw.loadData(config.modules.colorData)
local contrast_ratio = require(config.modules.colorContrast)._ratio
--------------------------------------------------------------------------------
-- Local functions
--------------------------------------------------------------------------------
local function getArg(args, names)
for _, name in ipairs(names) do
if args[name] ~= nil then
return args[name], name
end
end
end
local function hasArg(args, names)
local value = getArg(args, names)
return value ~= nil
end
local function getSuffixedArg(args, names, suffixes)
for _, name in ipairs(names) do
for _, suffix in ipairs(suffixes) do
local argName = name .. suffix
if args[argName] ~= nil then
return args[argName], argName
end
end
end
end
local function normalizeColor(color, default)
if color == nil or color == '' or color == '#' then
return default
end
if HTMLcolor[color] == nil then
color = '#' .. (mw.ustring.match(color, '^[%s#]*([a-fA-F0-9]*)[%s]*$') or '')
end
return color
end
local function makeCellWidth(width, defaultWidth)
if width == nil then
return nil
end
if width == '' and defaultWidth then
width = tostring(defaultWidth)
end
if width == 'auto' then
return 'auto'
end
if tonumber(width) ~= nil then
return width .. '%'
end
return nil
end
local function makeTableWidth(width)
local tableWidth = string.gsub(width or '', '%%', '')
if width == 'auto' or width == '' then
return 'auto'
end
if tonumber(tableWidth) ~= nil then
return tableWidth .. '%'
end
return '100%'
end
local function getTableClasses()
local classes = {}
for _, class in ipairs(config.classes) do
table.insert(classes, class)
end
if config.stickyHeader.enabled then
table.insert(classes, config.stickyHeader.class)
end
return classes
end
local function addClassList(node, classes)
for _, class in ipairs(classes) do
node:addClass(class)
end
return node
end
local function makeTemplateStyles()
local frame = mw.getCurrentFrame()
local output = ''
if config.templates.styles then
output = output .. frame:extensionTag{
name = 'templatestyles',
args = {src = config.templates.styles},
}
end
if config.stickyHeader.enabled and config.stickyHeader.styles then
output = output .. frame:extensionTag{
name = 'templatestyles',
args = {src = config.stickyHeader.styles},
}
end
return output
end
local function appendParameter(list, seen, name)
if name and not seen[name] then
table.insert(list, name)
seen[name] = true
end
end
local function makeSet(list)
local set = {}
for _, item in ipairs(list or {}) do
set[item] = true
end
return set
end
local function appendSuffixedParameters(list, seen, paramNames, suffixes, includeBase, excludedParams)
excludedParams = excludedParams or {}
for _, paramName in ipairs(paramNames) do
if includeBase and not excludedParams[paramName] then
appendParameter(list, seen, paramName)
end
if not excludedParams[paramName] then
for _, suffix in ipairs(suffixes) do
appendParameter(list, seen, paramName .. suffix)
end
end
end
end
local function getSortedKeys(source)
local keys = {}
for key in pairs(source) do
table.insert(keys, key)
end
table.sort(keys)
return keys
end
local function buildCheckConfig(title)
local checkConfig = {
unknown = string.format(config.categories.unknown, title.text),
preview = config.preview.unknown,
showblankpositional = config.check.showblankpositional,
}
local seen = {}
for i, regexp in ipairs(config.check.regexps) do
checkConfig['regexp' .. i] = regexp
end
local excludedParams = makeSet(config.check.excludedParams)
local excludedParamGroups = makeSet(config.check.excludedParamGroups)
for _, paramGroup in ipairs(config.check.paramGroupsRequiringSuffix) do
excludedParamGroups[paramGroup] = true
end
for _, paramGroup in ipairs(getSortedKeys(config.params)) do
if not excludedParamGroups[paramGroup] then
for _, param in ipairs(config.params[paramGroup]) do
if not excludedParams[param] then
appendParameter(checkConfig, seen, param)
end
end
end
end
for _, column in ipairs(config.columns) do
appendSuffixedParameters(checkConfig, seen, column.params, {}, true, excludedParams)
appendSuffixedParameters(checkConfig, seen, column.textParams or column.params, config.suffixes.text, false, excludedParams)
appendSuffixedParameters(checkConfig, seen, column.referenceParams or column.params, config.suffixes.reference, false, excludedParams)
appendSuffixedParameters(checkConfig, seen, column.extraTextParams or {}, config.suffixes.extraText or {}, false, excludedParams)
appendSuffixedParameters(checkConfig, seen, column.extraReferenceParams or {}, config.suffixes.extraReference or {}, false, excludedParams)
end
for _, paramGroup in ipairs(config.check.paramGroupsRequiringSuffix) do
appendSuffixedParameters(checkConfig, seen, config.params[paramGroup], config.suffixes.text, false, excludedParams)
appendSuffixedParameters(checkConfig, seen, config.params[paramGroup], config.suffixes.reference, false, excludedParams)
end
return checkConfig
end
--------------------------------------------------------------------------------
-- EpisodeTable class
-- The main class.
--------------------------------------------------------------------------------
local EpisodeTable = {}
function EpisodeTable.abbr(text, title)
local abbr = mw.html.create('abbr')
:attr('title', title)
:wikitext(text)
return tostring(abbr)
end
local function hasColumn(args, key)
for _, column in ipairs(config.columns) do
if column.key == key then
return hasArg(args, column.params)
end
end
return false
end
local function buildHeadings(args)
local countryValue = getArg(args, config.params.country)
local country = countryValue ~= '' and countryValue ~= nil
local viewersType = getArg(args, config.params.viewersType)
local number = config.headings.number
local overall = config.headings.overall
local season = config.headings.season
local series = config.headings.series
local prodcode = config.headings.prodcode
local viewersConfig = config.headings.viewers
local viewersLabel = country
and (viewersConfig.countryPrefix or '') .. countryValue .. (viewersConfig.countrySuffix or '')
or viewersConfig.defaultLabel
local viewers = viewersLabel ..
((not viewersType or viewersType ~= '') and viewersConfig.typePrefix .. (viewersType or viewersConfig.defaultType) .. viewersConfig.typeSuffix or '')
return {
overall = EpisodeTable.abbr(number.text, number.title) ..
((hasColumn(args, 'season') or hasColumn(args, 'series') or hasArg(args, config.params.forceoverall)) and overall.separator .. (getArg(args, config.params.overallType) or overall.defaultType) or ''),
season = EpisodeTable.abbr(number.text, number.title) .. season.prefix .. (getArg(args, config.params.seasonType) or season.defaultType),
series = EpisodeTable.abbr(number.text, number.title) .. series.prefix .. (getArg(args, config.params.seriesType) or series.defaultType),
prodcode = EpisodeTable.abbr(prodcode.text, prodcode.title) .. prodcode.suffix,
viewers = viewers,
}
end
local function getColumnHeading(column, headings)
if column.heading ~= nil then
return column.heading
end
return headings[column.key] or ''
end
local function getColumnText(args, column)
local text = getSuffixedArg(args, column.textParams or column.params, config.suffixes.text)
if text ~= nil then
return text
end
return getSuffixedArg(args, column.extraTextParams or {}, config.suffixes.extraText or {})
end
local function getColumnReference(args, column)
local reference = getSuffixedArg(args, column.referenceParams or column.params, config.suffixes.reference)
if reference ~= nil then
return reference
end
return getSuffixedArg(args, column.extraReferenceParams or {}, config.suffixes.extraReference or {})
end
local function getAltTitle(args)
local text = getSuffixedArg(args, config.params.altTitle, config.suffixes.text)
local reference = getSuffixedArg(args, config.params.altTitle, config.suffixes.reference)
return text, reference
end
function EpisodeTable.cell(background, width, text, reference, textColor, defaultWidth)
local cell = mw.html.create('th')
cell:attr('scope', 'col')
:css('background', background or config.defaultColor)
:css('width', makeCellWidth(width, defaultWidth))
:css('color', textColor)
:wikitext(text)
if reference and reference ~= '' then
cell:wikitext(' ' .. EpisodeTable.reference(reference, background))
end
return cell
end
function EpisodeTable.reference(reference, background)
local link1_cr = contrast_ratio{'#0645AD', background or config.defaultColor, ['error'] = 0}
local link2_cr = contrast_ratio{'#0B0080', background or config.defaultColor, ['error'] = 0}
local refspan = mw.html.create('span')
:wikitext(reference)
if link1_cr < config.contrast.reference or link2_cr < config.contrast.reference then
local severe = config.contrast.referenceSevere and
link1_cr < config.contrast.referenceSevere and link2_cr < config.contrast.referenceSevere
refspan
:css('color', severe and (config.contrast.referenceSevereColor or 'white') or 'black')
:css('background-color', severe and (config.contrast.referenceSevereBackground or '#f8f9fa') or (config.contrast.referenceBackground or 'white'))
:css('padding', '1px')
:css('display', 'inline-block')
:css('line-height', '50%')
end
return tostring(refspan)
end
function EpisodeTable.part(args)
local row = mw.html.create('tr')
local cellColor = normalizeColor(getArg(args, config.params.partColor), config.defaultColor)
local cellRef = getArg(args, config.params.partReference)
local subTitle = getArg(args, config.params.partSubtitle)
local black_cr = contrast_ratio{cellColor or config.defaultColor, 'black', ['error'] = 0}
local white_cr = contrast_ratio{'white', cellColor or config.defaultColor, ['error'] = 0}
local displaytext = ''
local isAnyPartSet = false
for _, partType in ipairs(config.partTypes) do
local partValue = getArg(args, partType.params)
if partValue then
isAnyPartSet = true
displaytext = partType.label .. ' ' .. partValue
end
end
if subTitle then
displaytext = displaytext .. ((isAnyPartSet and ': ' or '') .. subTitle)
end
local displayTextAnchor = require(config.modules.plainText)._main(displaytext)
local text = (hasArg(args, config.params.partNobold) and displaytext or "'''" .. displaytext .. "'''") ..
(cellRef and ' ' .. EpisodeTable.reference(cellRef, cellColor) or '')
row:tag('td')
:attr('colspan', config.partRow.colspan)
:attr('id', getArg(args, config.params.id) or displayTextAnchor)
:css('text-align', 'center')
:css('background-color', cellColor or config.defaultColor)
:css('color', black_cr > white_cr and 'black' or 'white')
:wikitext(text)
return tostring(row)
end
function EpisodeTable.new(frame, args)
args = args or {}
local categories = ''
local rawBackground = getArg(args, config.params.background)
local background = normalizeColor(rawBackground, nil)
local root = addClassList(mw.html.create('table'), getTableClasses())
:css('width', makeTableWidth(getArg(args, config.params.totalWidth)))
local caption = getArg(args, config.params.caption)
if hasArg(args, config.params.showCaption) then
root:tag('caption'):wikitext(caption)
categories = categories .. config.categories.visibleCaption
elseif caption then
root:tag('caption'):wikitext(frame:expandTemplate{title = config.templates.screenReaderOnly, args = {caption}})
end
local title = mw.title.getCurrentTitle()
local black_cr = contrast_ratio{background, 'black', ['error'] = 0}
local white_cr = contrast_ratio{'white', background, ['error'] = 0}
if title.namespace == 0 and rawBackground and rawBackground ~= '' and rawBackground ~= '#'
and black_cr < config.contrast.invalidColor and white_cr < config.contrast.invalidColor
then
categories = categories .. config.categories.invalidColor
end
local textColor = background and (black_cr > white_cr and 'black' or 'white') or 'black'
local mainRow = root:tag('tr')
mainRow
:css('color', textColor)
:css('text-align', 'center')
local usedColumn = false
local usedSeason = false
local headings = buildHeadings(args)
for _, column in ipairs(config.columns) do
local width = getArg(args, column.params)
if width and not (column.skipIfSeasonUsed and usedSeason) then
usedColumn = true
if column.key == 'season' then
usedSeason = true
end
local cellText = getColumnText(args, column)
local cellRef = getColumnReference(args, column)
cellText = cellText or getColumnHeading(column, headings)
if column.title then
cellText = cellText .. (cellRef and ' ' .. EpisodeTable.reference(cellRef, background) or '')
local altText, altRef = getAltTitle(args)
if altText then
cellText = cellText .. '<br> ' .. altText .. (altRef and ' ' .. EpisodeTable.reference(altRef, background) or '')
end
mainRow:node(EpisodeTable.cell(background, width, cellText, false, textColor, column.defaultWidth))
else
mainRow:node(EpisodeTable.cell(background, width, cellText, cellRef, textColor, column.defaultWidth))
end
end
end
if title.namespace == 0 and usedColumn == false then
categories = categories .. config.categories.noColumns
end
local episodes = getArg(args, config.params.episodes)
if episodes then
local anchor = getArg(args, config.params.anchor)
if anchor then
episodes = string.gsub(episodes, '(id=")(ep%w+")', '%1' .. anchor .. '%2')
end
root:node(episodes)
end
local templateStyles = makeTemplateStyles()
local dontclose = getArg(args, config.params.dontclose)
local output = ((dontclose or '') ~= '') and mw.ustring.gsub(tostring(root), '</table>', '') or tostring(root)
return output .. categories .. templateStyles
end
--------------------------------------------------------------------------------
-- Exports
--------------------------------------------------------------------------------
local p = {}
function p.main(frame)
local args = require(config.modules.arguments).getArgs(frame, {
removeBlanks = false,
wrappers = config.templates.main,
})
local tracking = ''
local title = mw.title.getCurrentTitle()
if title.namespace == 0 then
tracking = require(config.modules.checkUnknown)._check(buildCheckConfig(title), args)
end
return EpisodeTable.new(frame, args) .. tracking
end
function p.part(frame)
local args = require(config.modules.arguments).getArgs(frame, {
removeBlanks = false,
wrappers = config.templates.part,
})
return EpisodeTable.part(args)
end
function p.ref(frame)
local args = require(config.modules.arguments).getArgs(frame, {
removeBlanks = false,
})
return EpisodeTable.reference(getArg(args, config.params.refReference), getArg(args, config.params.refBackground))
end
return p