Module:Track listing: Difference between revisions

From New Prairie Wiki
Jump to navigation Jump to search
np25>Est. 2021
 
Created page with "-- This module implements Template:Track listing local yesno = require('Module:Yesno') -------------------------------------------------------------------------------- -- Track class -------------------------------------------------------------------------------- local Track = {} Track.__index = Track Track.fields = { number = true, title = true, note = true, length = true, lyrics = true, music = true, writer = true, extra = true, } Track.cellMethods = {..."
Tag: Reverted
Line 1: Line 1:
-- This module implements [[Template:Track listing]]
local yesno = require('Module:Yesno')
local yesno = require('Module:Yesno')
local checkType = require('libraryUtil').checkType
local cfg = mw.loadData('Module:Track listing/configuration')
--------------------------------------------------------------------------------
-- Helper functions
--------------------------------------------------------------------------------
-- Add a mixin to a class.
local function addMixin(class, mixin)
for k, v in pairs(mixin) do
if k ~= 'init' then
class[k] = v
end
end
end
--------------------------------------------------------------------------------
-- Validation mixin
--------------------------------------------------------------------------------
local Validation = {}
function Validation.init(self)
self.warnings = {}
self.categories = {}
end
function Validation:addWarning(msg, category)
table.insert(self.warnings, msg)
table.insert(self.categories, category)
end
function Validation:addCategory(category)
table.insert(self.categories, category)
end
function Validation:getWarnings()
return self.warnings
end
function Validation:getCategories()
return self.categories
end
-- Validate a track length. If a track length is invalid, a warning is added.
-- A type error is raised if the length is not of type string or nil.
function Validation:validateLength(length)
checkType('validateLength', 1, length, 'string', true)
if length == nil then
-- Do nothing if no length specified
return nil
end
local hours, minutes, seconds
-- Try to match times like "1:23:45".
hours, minutes, seconds = length:match('^(%d+):(%d%d):(%d%d)$')
if hours and hours:sub(1, 1) == '0' then
-- Disallow times like "0:12:34"
self:addWarning(
string.format(cfg.leading_0_in_hours, mw.text.nowiki(length)),
cfg.input_error_category
)
return nil
end
if not seconds then
-- The previous attempt didn't match. Try to match times like "1:23".
minutes, seconds = length:match('^(%d?%d):(%d%d)$')
if minutes and minutes:find('^0%d$') then
-- Special case to disallow lengths like "01:23". This check has to
-- be here so that lengths like "1:01:23" are still allowed.
self:addWarning(
string.format(cfg.leading_0_in_minutes, mw.text.nowiki(length)),
cfg.input_error_category
)
return nil
end
end
-- Add a warning and return if we did not find a match.
if not seconds then
self:addWarning(
string.format(cfg.not_a_time, mw.text.nowiki(length)),
cfg.input_error_category
)
return nil
end
-- Check that the minutes are less than 60 if we have an hours field.
if hours and tonumber(minutes) >= 60 then
self:addWarning(
string.format(cfg.more_than_60_minutes, mw.text.nowiki(length)),
cfg.input_error_category
)
return nil
end
-- Check that the seconds are less than 60
if tonumber(seconds) >= 60 then
self:addWarning(
string.format(cfg.more_than_60_seconds, mw.text.nowiki(length)),
cfg.input_error_category
)
end
return nil
end


--------------------------------------------------------------------------------
--------------------------------------------------------------------------------
Line 115: Line 9:
local Track = {}
local Track = {}
Track.__index = Track
Track.__index = Track
addMixin(Track, Validation)


Track.fields = cfg.track_field_names
Track.fields = {
number = true,
title = true,
note = true,
length = true,
lyrics = true,
music = true,
writer = true,
extra = true,
}


Track.cellMethods = {
Track.cellMethods = {
Line 131: Line 33:
function Track.new(data)
function Track.new(data)
local self = setmetatable({}, Track)
local self = setmetatable({}, Track)
Validation.init(self)
for field in pairs(Track.fields) do
for field in pairs(Track.fields) do
self[field] = data[field]
self[field] = data[field]
end
end
self.number = assert(tonumber(self.number))
self.number = assert(tonumber(self.number))
self:validateLength(self.length)
return self
return self
end
end
Line 159: Line 59:
function Track.makeSimpleCell(wikitext)
function Track.makeSimpleCell(wikitext)
return mw.html.create('td')
return mw.html.create('td')
:wikitext(wikitext or cfg.blank_cell)
:css('vertical-align', 'top')
:wikitext(wikitext or ' ')
end
end


function Track:makeNumberCell()
function Track:makeNumberCell()
return mw.html.create('th')
return mw.html.create('td')
:attr('id', string.format(cfg.track_id, self.number))
:css('padding-right', '10px')
:attr('scope', 'row')
:css('text-align', 'right')
:wikitext(string.format(cfg.number_terminated, self.number))
:css('vertical-align', 'top')
:wikitext(self.number .. '.')
end
end


function Track:makeTitleCell()
function Track:makeTitleCell()
local titleCell = mw.html.create('td')
local titleCell = mw.html.create('td')
titleCell:wikitext(
titleCell
self.title and string.format(cfg.track_title, self.title) or cfg.untitled
:css('vertical-align', 'top')
)
:wikitext(self.title and string.format('"%s"', self.title) or 'Untitled')
if self.note then
if self.note then
titleCell:wikitext(string.format(cfg.note, self.note))
titleCell
:wikitext(' ')
:tag('span')
:css('font-size', '85%')
:wikitext(string.format('(%s)', self.note))
end
end
return titleCell
return titleCell
Line 198: Line 104:
function Track:makeLengthCell()
function Track:makeLengthCell()
return mw.html.create('td')
return mw.html.create('td')
:addClass('tracklist-length')
:css('padding-right', '10px')
:wikitext(self.length or cfg.blank_cell)
:css('text-align', 'right')
:css('vertical-align', 'top')
:wikitext(self.length or ' ')
end
end


function Track:exportRow(columns)
function Track:exportRow(options)
local columns = columns or {}
options = options or {}
local columns = options.columns or {}
local row = mw.html.create('tr')
local row = mw.html.create('tr')
row:css('background-color', options.color or '#fff')
for i, column in ipairs(columns) do
for i, column in ipairs(columns) do
local method = Track.cellMethods[column]
local method = Track.cellMethods[column]
Line 220: Line 130:
local TrackListing = {}
local TrackListing = {}
TrackListing.__index = TrackListing
TrackListing.__index = TrackListing
addMixin(TrackListing, Validation)
 
TrackListing.fields = cfg.track_listing_field_names
TrackListing.fields = {
TrackListing.deprecatedFields = cfg.deprecated_track_listing_field_names
all_writing = true,
all_lyrics = true,
all_music = true,
collapsed = true,
headline = true,
extra_column = true,
total_length = true,
title_width = true,
writing_width = true,
lyrics_width = true,
music_width = true,
extra_width = true,
category = true,
}
 
TrackListing.deprecatedFields = {
writing_credits = true,
lyrics_credits = true,
music_credits = true,
}


function TrackListing.new(data)
function TrackListing.new(data)
local self = setmetatable({}, TrackListing)
local self = setmetatable({}, TrackListing)
Validation.init(self)


-- Add properties
for field in pairs(TrackListing.fields) do
self[field] = data[field]
end
-- Check for deprecated arguments
-- Check for deprecated arguments
for deprecatedField in pairs(TrackListing.deprecatedFields) do
for deprecatedField in pairs(TrackListing.deprecatedFields) do
if data[deprecatedField] then
if data[deprecatedField] then
self:addCategory(cfg.deprecated_parameter_category)
self.hasDeprecatedArgs = true
break
break
end
end
end
-- Validate total length
if data.total_length then
self:validateLength(data.total_length)
end
-- Add properties
for field in pairs(TrackListing.fields) do
self[field] = data[field]
end
end
-- Evaluate boolean properties
-- Evaluate boolean properties
self.collapsed = yesno(self.collapsed, false)
self.showCategories = yesno(self.category) ~= false
self.showCategories = yesno(self.category) ~= false
self.category = nil
self.category = nil
Line 291: Line 215:


return self
return self
end
--------------------------------------------------------------------------------
-- Function for automatic punctuation for parameters:
--      all_writing, all_lyrics, all_music
-- Punctuation defaults to '.' unless ':' or '.' already exist
-- Automatic punctuation limited to parameter values not containing parser tags
--      i.e. <ref>Text</ref>, <nowiki></nowiki>, etc.
--------------------------------------------------------------------------------
function fullstop(a)
  if a ~= mw.text.killMarkers(mw.getCurrentFrame():preprocess(a)) or
    a:sub(-1,-1) == ":" or
    a:sub(-1,-1) == "." then
  return a
  else
  return a .. '.'
  end
end
end


function TrackListing:makeIntro()
function TrackListing:makeIntro()
if self.all_writing then
if self.all_writing then
return string.format(cfg.tracks_written, fullstop(self.all_writing))
return string.format(
'All tracks written by %s.',
self.all_writing
)
elseif self.all_lyrics and self.all_music then
elseif self.all_lyrics and self.all_music then
return mw.message.newRawMessage(
return string.format(
cfg.lyrics_written_music_composed,
'All lyrics written by %s; all music composed by %s.',
self.all_lyrics,
self.all_lyrics,
fullstop(self.all_music)
self.all_music
):plain()
)
elseif self.all_lyrics then
elseif self.all_lyrics then
return string.format(cfg.lyrics_written, fullstop(self.all_lyrics))
return string.format(
'All lyrics written by %s.',
self.all_lyrics
)
elseif self.all_music then
elseif self.all_music then
return string.format(cfg.music_composed, fullstop(self.all_music))
return string.format(
'All music composed by %s.',
self.all_music
)
else
else
return nil
return ''
end
end
end
end


function TrackListing:renderTrackingCategories()
function TrackListing:renderTrackingCategories()
if not self.showCategories or mw.title.getCurrentTitle().namespace ~= 0 then
return ''
end
local ret = ''
local ret = ''
 
if self.showCategories
local function addCategory(cat)
and self.hasDeprecatedArgs
ret = ret .. string.format('[[Category:%s]]', cat)
and mw.title.getCurrentTitle().namespace == 0
then
ret = ret .. '[[Category:Track listings with deprecated parameters]]'
end
end
for i, category in ipairs(self:getCategories()) do
addCategory(category)
end
for i, track in ipairs(self.tracks) do
for j, category in ipairs(track:getCategories()) do
addCategory(category)
end
end
return ret
return ret
end
function TrackListing:renderWarnings()
if not cfg.show_warnings then
return ''
end
local ret = {}
local function addWarning(msg)
table.insert(ret, string.format(cfg.track_listing_error, msg))
end
for i, warning in ipairs(self:getWarnings()) do
addWarning(warning)
end
for i, track in ipairs(self.tracks) do
for j, warning in ipairs(track:getWarnings()) do
addWarning(warning)
end
end
return table.concat(ret, '<br>')
end
end


function TrackListing:__tostring()
function TrackListing:__tostring()
-- Root of the output
local root = mw.html.create('div')
:addClass('track-listing')
local intro = self:makeIntro()
if intro then
root:tag('p')
:wikitext(intro)
:done()
end
-- Start of track listing table
local tableRoot = mw.html.create('table')
tableRoot
:addClass('tracklist')
-- Overall table width
if self.width then
tableRoot
:css('width', self.width)
end
-- Header row
if self.headline then
tableRoot:tag('caption')
:wikitext(self.headline or cfg.track_listing)
end
-- Headers
local headerRow = tableRoot:tag('tr')
---- Track number
headerRow
:tag('th')
:addClass('tracklist-number-header')
:attr('scope', 'col')
:tag('abbr')
:attr('title', cfg.number)
:wikitext(cfg.number_abbr)
-- Find columns to output
-- Find columns to output
local columns = {'number', 'title'}
local columns = {'number', 'title'}
Line 433: Line 272:
end
end
columns[#columns + 1] = 'length'
columns[#columns + 1] = 'length'
 
-- Find column width
-- Find colspan and column width
local nColumns = #columns
local nColumns = #columns
local nOptionalColumns = nColumns - 3
local nOptionalColumns = nColumns - 3
local titleColumnWidth
local titleColumnWidth = 100
if nColumns >= 5 then
if nColumns >= 5 then
titleColumnWidth = 40
titleColumnWidth = 40
elseif nColumns >= 4 then
elseif nColumns >= 4 then
titleColumnWidth = 60
titleColumnWidth = 60
else
titleColumnWidth = 100
end
end
local optionalColumnWidth = (100 - titleColumnWidth) / nOptionalColumns
local optionalColumnWidth = ((100 - titleColumnWidth) / nOptionalColumns) .. '%'
titleColumnWidth = titleColumnWidth .. '%'
titleColumnWidth = titleColumnWidth .. '%'
optionalColumnWidth = optionalColumnWidth .. '%'
---- Title column
 
-- Root of the output
local root = mw.html.create()
 
-- Intro
root:node(self:makeIntro())
 
-- Start of track listing table
local tableRoot = root:tag('table')
tableRoot
:addClass('tracklist')
:addClass(self.collapsed and 'collapsible collapsed' or nil)
:css('display', 'block')
:css('border-spacing', '0px')
:css('border-collapse', 'collapse')
:css('border', self.collapsed and '#aaa 1px solid' or nil)
:css('padding', self.collapsed and '3px' or '4px')
 
-- Header row
if self.headline or self.collapsed then
tableRoot:tag('tr'):tag('th')
:addClass('tlheader mbox-text')
:attr('colspan', nColumns)
:css('text-align', 'left')
:css('background-color', '#fff')
:wikitext(self.headline or 'Track listing')
end
 
-- Headers
local headerRow = tableRoot:tag('tr')
 
---- Track number
headerRow
:tag('th')
:addClass('tlheader')
:attr('scope', 'col')
:css('width', '2em')
:css('padding-left', '10px')
:css('padding-right', '10px')
:css('text-align', 'right')
:css('background-color', '#eee')
:tag('abbr')
:attr('title', 'Number')
:wikitext('No.')
 
---- Title
headerRow:tag('th')
headerRow:tag('th')
:addClass('tlheader')
:attr('scope', 'col')
:attr('scope', 'col')
:css('width', self.title_width or titleColumnWidth)
:css('width', self.title_width or titleColumnWidth)
:wikitext(cfg.title)
:css('text-align', 'left')
:css('background-color', '#eee')
:wikitext('Title')


---- Optional headers: writer, lyrics, music, and extra
---- Optional headers: writer, lyrics, music, and extra
Line 458: Line 345:
if self.optionalColumns[field] then
if self.optionalColumns[field] then
headerRow:tag('th')
headerRow:tag('th')
:addClass('tlheader')
:attr('scope', 'col')
:attr('scope', 'col')
:css('width', width or optionalColumnWidth)
:css('width', width or optionalColumnWidth)
:css('text-align', 'left')
:css('background-color', '#eee')
:wikitext(headerText)
:wikitext(headerText)
end
end
end
end
addOptionalHeader('writer', cfg.writer, self.writing_width)
addOptionalHeader('writer', 'Writer(s)', self.writing_width)
addOptionalHeader('lyrics', cfg.lyrics, self.lyrics_width)
addOptionalHeader('lyrics', 'Lyrics', self.lyrics_width)
addOptionalHeader('music', cfg.music, self.music_width)
addOptionalHeader('music', 'Music', self.music_width)
addOptionalHeader(
addOptionalHeader(
'extra',
'extra',
self.extra_column or cfg.extra,
self.extra_column or '{{{extra_column}}}',
self.extra_width
self.extra_width
)
)
Line 474: Line 364:
---- Track length
---- Track length
headerRow:tag('th')
headerRow:tag('th')
:addClass('tracklist-length-header')
:addClass('tlheader')
:attr('scope', 'col')
:attr('scope', 'col')
:wikitext(cfg.length)
:css('width', '4em')
:css('padding-right', '10px')
:css('text-align', 'right')
:css('background-color', '#eee')
:wikitext('Length')


-- Tracks
-- Tracks
for i, track in ipairs(self.tracks) do
for i, track in ipairs(self.tracks) do
tableRoot:node(track:exportRow(columns))
tableRoot:node(track:exportRow({
columns = columns,
color = i % 2 == 0 and '#f7f7f7' or '#fff'
}))
end
end


Line 487: Line 384:
tableRoot
tableRoot
:tag('tr')
:tag('tr')
:addClass('tracklist-total-length')
:tag('td')
:tag('th')
:attr('colspan', nColumns - 1)
:attr('colspan', nColumns - 1)
:attr('scope', 'row')
:css('padding', 0)
:tag('span')
:tag('span')
:wikitext(cfg.total_length)
:css('width', '7.5em')
:css('float', 'right')
:css('padding-left', '10px')
:css('background-color', '#eee')
:css('margin-right', '2px')
:wikitext("'''Total length:'''")
:done()
:done()
:done()
:done()
:tag('td')
:tag('td')
:wikitext(self.total_length)
:css('padding', '0 10px 0 0')
:css('text-align', 'right')
:css('background-color', '#eee')
:wikitext(string.format("'''%s'''", self.total_length))
end
end
root:node(tableRoot)
-- Tracking categories
-- Warnings and tracking categories
root:wikitext(self:renderWarnings())
root:wikitext(self:renderTrackingCategories())
root:wikitext(self:renderTrackingCategories())
 
return mw.getCurrentFrame():extensionTag{
return tostring(root)
name = 'templatestyles', args = { src = 'Module:Track listing/styles.css' }
} .. tostring(root)
end
end



Revision as of 11:42, 17 July 2025

This module implements {{track listing}}. Please see the template page for documentation.



-- This module implements [[Template:Track listing]]

local yesno = require('Module:Yesno')

--------------------------------------------------------------------------------
-- Track class
--------------------------------------------------------------------------------

local Track = {}
Track.__index = Track

Track.fields = {
	number = true,
	title = true,
	note = true,
	length = true,
	lyrics = true,
	music = true,
	writer = true,
	extra = true,
}

Track.cellMethods = {
	number = 'makeNumberCell',
	title = 'makeTitleCell',
	writer = 'makeWriterCell',
	lyrics = 'makeLyricsCell',
	music = 'makeMusicCell',
	extra = 'makeExtraCell',
	length = 'makeLengthCell',
}

function Track.new(data)
	local self = setmetatable({}, Track)
	for field in pairs(Track.fields) do
		self[field] = data[field]
	end
	self.number = assert(tonumber(self.number))
	return self
end

function Track:getLyricsCredit()
	return self.lyrics
end

function Track:getMusicCredit()
	return self.music
end

function Track:getWriterCredit()
	return self.writer
end

function Track:getExtraField()
	return self.extra
end

-- Note: called with single dot syntax
function Track.makeSimpleCell(wikitext)
	return mw.html.create('td')
		:css('vertical-align', 'top')
		:wikitext(wikitext or '&nbsp;')
end

function Track:makeNumberCell()
	return mw.html.create('td')
		:css('padding-right', '10px')
		:css('text-align', 'right')
		:css('vertical-align', 'top')
		:wikitext(self.number .. '.')
end

function Track:makeTitleCell()
	local titleCell = mw.html.create('td')
	titleCell
		:css('vertical-align', 'top')
		:wikitext(self.title and string.format('"%s"', self.title) or 'Untitled')
	if self.note then
		titleCell
			:wikitext(' ')
			:tag('span')
				:css('font-size', '85%')
				:wikitext(string.format('(%s)', self.note))
	end
	return titleCell
end

function Track:makeWriterCell()
	return Track.makeSimpleCell(self.writer)
end

function Track:makeLyricsCell()
	return Track.makeSimpleCell(self.lyrics)
end

function Track:makeMusicCell()
	return Track.makeSimpleCell(self.music)
end

function Track:makeExtraCell()
	return Track.makeSimpleCell(self.extra)
end

function Track:makeLengthCell()
	return mw.html.create('td')
		:css('padding-right', '10px')
		:css('text-align', 'right')
		:css('vertical-align', 'top')
		:wikitext(self.length or '&nbsp;')
end

function Track:exportRow(options)
	options = options or {}
	local columns = options.columns or {}
	local row = mw.html.create('tr')
	row:css('background-color', options.color or '#fff')
	for i, column in ipairs(columns) do
		local method = Track.cellMethods[column]
		if method then
			row:node(self[method](self))
		end
	end
	return row
end

--------------------------------------------------------------------------------
-- TrackListing class
--------------------------------------------------------------------------------

local TrackListing = {}
TrackListing.__index = TrackListing

TrackListing.fields = {
	all_writing = true,
	all_lyrics = true,
	all_music = true,
	collapsed = true,
	headline = true,
	extra_column = true,
	total_length = true,
	title_width = true,
	writing_width = true,
	lyrics_width = true,
	music_width = true,
	extra_width = true,
	category = true,
}

TrackListing.deprecatedFields = {
	writing_credits = true,
	lyrics_credits = true,
	music_credits = true,
}

function TrackListing.new(data)
	local self = setmetatable({}, TrackListing)

	-- Add properties
	for field in pairs(TrackListing.fields) do
		self[field] = data[field]
	end
	
	-- Check for deprecated arguments
	for deprecatedField in pairs(TrackListing.deprecatedFields) do
		if data[deprecatedField] then
			self.hasDeprecatedArgs = true
			break
		end
	end
	
	-- Evaluate boolean properties
	self.collapsed = yesno(self.collapsed, false)
	self.showCategories = yesno(self.category) ~= false
	self.category = nil

	-- Make track objects
	self.tracks = {}
	for i, trackData in ipairs(data.tracks or {}) do
		table.insert(self.tracks, Track.new(trackData))
	end

	-- Find which of the optional columns we have.
	-- We could just check every column for every track object, but that would
	-- be no fun^H^H^H^H^H^H inefficient, so we use four different strategies
	-- to try and check only as many columns and track objects as necessary.
	do
		local optionalColumns = {}
		local columnMethods = {
			lyrics = 'getLyricsCredit',
			music = 'getMusicCredit',
			writer = 'getWriterCredit',
			extra = 'getExtraField',
		}
		local doneWriterCheck = false
		for i, trackObj in ipairs(self.tracks) do
			for column, method in pairs(columnMethods) do
				if trackObj[method](trackObj) then
					optionalColumns[column] = true
					columnMethods[column] = nil
				end
			end
			if not doneWriterCheck and optionalColumns.writer then
				doneWriterCheck = true
				optionalColumns.lyrics = nil
				optionalColumns.music = nil
				columnMethods.lyrics = nil
				columnMethods.music = nil
			end
			if not next(columnMethods) then
				break
			end
		end
		self.optionalColumns = optionalColumns
	end

	return self
end

function TrackListing:makeIntro()
	if self.all_writing then
		return string.format(
			'All tracks written by %s.',
			self.all_writing
		)
	elseif self.all_lyrics and self.all_music then
		return string.format(
			'All lyrics written by %s; all music composed by %s.',
			self.all_lyrics,
			self.all_music
		)
	elseif self.all_lyrics then
		return string.format(
			'All lyrics written by %s.',
			self.all_lyrics
		)
	elseif self.all_music then
		return string.format(
			'All music composed by %s.',
			self.all_music
		)
	else
		return ''
	end
end

function TrackListing:renderTrackingCategories()
	local ret = ''
	if self.showCategories
		and self.hasDeprecatedArgs
		and mw.title.getCurrentTitle().namespace == 0
	then
		ret = ret .. '[[Category:Track listings with deprecated parameters]]'
	end
	return ret
end

function TrackListing:__tostring()
	-- Find columns to output
	local columns = {'number', 'title'}
	if self.optionalColumns.writer then
		columns[#columns + 1] = 'writer'
	else
		if self.optionalColumns.lyrics then
			columns[#columns + 1] = 'lyrics'
		end
		if self.optionalColumns.music then
			columns[#columns + 1] = 'music'
		end
	end
	if self.optionalColumns.extra then
		columns[#columns + 1] = 'extra'
	end
	columns[#columns + 1] = 'length'

	-- Find colspan and column width
	local nColumns = #columns
	local nOptionalColumns = nColumns - 3
	local titleColumnWidth
	if nColumns >= 5 then
		titleColumnWidth = 40
	elseif nColumns >= 4 then
		titleColumnWidth = 60
	else
		titleColumnWidth = 100
	end
	local optionalColumnWidth = (100 - titleColumnWidth) / nOptionalColumns
	titleColumnWidth = titleColumnWidth .. '%'
	optionalColumnWidth = optionalColumnWidth .. '%'

	-- Root of the output
	local root = mw.html.create()

	-- Intro
	root:node(self:makeIntro())

	-- Start of track listing table
	local tableRoot = root:tag('table')
	tableRoot
		:addClass('tracklist')
		:addClass(self.collapsed and 'collapsible collapsed' or nil)
		:css('display', 'block')
		:css('border-spacing', '0px')
		:css('border-collapse', 'collapse')
		:css('border', self.collapsed and '#aaa 1px solid' or nil)
		:css('padding', self.collapsed and '3px' or '4px')

	-- Header row
	if self.headline or self.collapsed then
		tableRoot:tag('tr'):tag('th')
			:addClass('tlheader mbox-text')
			:attr('colspan', nColumns)
			:css('text-align', 'left')
			:css('background-color', '#fff')
			:wikitext(self.headline or 'Track listing')
	end

	-- Headers
	local headerRow = tableRoot:tag('tr')

	---- Track number
	headerRow
		:tag('th')
			:addClass('tlheader')
			:attr('scope', 'col')
			:css('width', '2em')
			:css('padding-left', '10px')
			:css('padding-right', '10px')
			:css('text-align', 'right')
			:css('background-color', '#eee')
			:tag('abbr')
				:attr('title', 'Number')
				:wikitext('No.')

	---- Title
	headerRow:tag('th')
		:addClass('tlheader')
		:attr('scope', 'col')
		:css('width', self.title_width or titleColumnWidth)
		:css('text-align', 'left')
		:css('background-color', '#eee')
		:wikitext('Title')

	---- Optional headers: writer, lyrics, music, and extra
	local function addOptionalHeader(field, headerText, width)
		if self.optionalColumns[field] then
			headerRow:tag('th')
				:addClass('tlheader')
				:attr('scope', 'col')
				:css('width', width or optionalColumnWidth)
				:css('text-align', 'left')
				:css('background-color', '#eee')
				:wikitext(headerText)
		end
	end
	addOptionalHeader('writer', 'Writer(s)', self.writing_width)
	addOptionalHeader('lyrics', 'Lyrics', self.lyrics_width)
	addOptionalHeader('music', 'Music', self.music_width)
	addOptionalHeader(
		'extra',
		self.extra_column or '{{{extra_column}}}',
		self.extra_width
	)

	---- Track length
	headerRow:tag('th')
		:addClass('tlheader')
		:attr('scope', 'col')
		:css('width', '4em')
		:css('padding-right', '10px')
		:css('text-align', 'right')
		:css('background-color', '#eee')
		:wikitext('Length')

	-- Tracks
	for i, track in ipairs(self.tracks) do
		tableRoot:node(track:exportRow({
			columns = columns,
			color = i % 2 == 0 and '#f7f7f7' or '#fff'
		}))
	end

	-- Total length
	if self.total_length then
		tableRoot
			:tag('tr')
				:tag('td')
					:attr('colspan', nColumns - 1)
					:css('padding', 0)
					:tag('span')
						:css('width', '7.5em')
						:css('float', 'right')
						:css('padding-left', '10px')
						:css('background-color', '#eee')
						:css('margin-right', '2px')
						:wikitext("'''Total length:'''")
						:done()
					:done()
				:tag('td')
					:css('padding', '0 10px 0 0')
					:css('text-align', 'right')
					:css('background-color', '#eee')
					:wikitext(string.format("'''%s'''", self.total_length))
	end
	
	-- Tracking categories
	root:wikitext(self:renderTrackingCategories())

	return tostring(root)
end

--------------------------------------------------------------------------------
-- Exports
--------------------------------------------------------------------------------

local p = {}

function p._main(args)
	-- Process numerical args so that we can iterate through them.
	local data, tracks = {}, {}
	for k, v in pairs(args) do
		if type(k) == 'string' then
			local prefix, num = k:match('^(%D.-)(%d+)$')
			if prefix and Track.fields[prefix] and (num == '0' or num:sub(1, 1) ~= '0') then
				-- Allow numbers like 0, 1, 2 ..., but not 00, 01, 02...,
				-- 000, 001, 002... etc.
				num = tonumber(num)
				tracks[num] = tracks[num] or {}
				tracks[num][prefix] = v
			else
				data[k] = v
			end
		end
	end
	data.tracks = (function (t)
		-- Compress sparse array
		local ret = {}
		for num, trackData in pairs(t) do
			trackData.number = num
			table.insert(ret, trackData) 
		end
		table.sort(ret, function (t1, t2)
			return t1.number < t2.number
		end)
		return ret
	end)(tracks)

	return tostring(TrackListing.new(data))
end

function p.main(frame)
	local args = require('Module:Arguments').getArgs(frame, {
		wrappers = 'Template:Track listing'
	})
	return p._main(args)
end

return p