Edge Rewrite
// HTMLRewriter · presentation

This page was redesigned at the edge.

Cloudflare fetched the original article and streamed it through HTMLRewriter to apply an entirely new visual system without rebuilding the source page.

Jump to content

Module:Sandbox/Solidest/Episode table

From Wikipedia, the free encyclopedia
-- 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('&#8202;' .. 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 '&#8202;' .. 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 '&#8202;' .. EpisodeTable.reference(cellRef, background) or '')

				local altText, altRef = getAltTitle(args)
				if altText then
					cellText = cellText .. '<br> ' .. altText .. (altRef and '&#8202;' .. 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