Module:Card

From Yugipedia
Jump to: navigation, search

--[[
	Module for creating card articles
	General flow of what happens:
	- Defines an empty `Card` object with default data
	- `Card.card()` is called with data from a template call passed in
		- Calls `Card:new()` to create a new `Card` object with the input data
			- Runs through the "setter" functions to fill the data
		- Runs through an "add row" function for each row in `Card.config.rows`
		- Calls `Card:render()` to draw the final output
			- Calls various other "render" functions to help with creating the output

	This module should not be invoked directly.
	Other modules should import this one, make some changes to configuration.
	The other module will then run through the same flow as described above.
]]--

-- ------------------------------------
-- Import modules
-- ------------------------------------
-- Library of generic functions for helping with Lua tables
TableTools = require('Module:TableTools')

-- Pseudo-classes for individual elements of cards
-- e.g. `Type:new('Fusion')` returns an object with data on the Fusion type
local Attribute  = require('Module:Card/models/Attribute')
local CardType   = require('Module:Card/models/Card type')
local EffectType = require('Module:Card/models/Effect type')
local Locale     = require('Module:Card/models/Locale')
local Type       = require('Module:Card/models/Type')



-- ------------------------------------
-- Generic functions
--   Might move these to a more dedicated module
-- ------------------------------------

-- Replace parameters within a string
-- Might move to more dedicated module
-- e.g.
-- Input: `replaceParams('Hi {$name}!', { name = 'Yugi' })`
-- Output: "Hi Yugi!"
--
-- @param message string - Text to perform the replacements on
-- @param params  table  - Map of variables names to the text to replace them.
-- @return string
local function replaceParams(message, params)
	for param, replacement in pairs(params) do
		message = string.gsub(message, '{$' .. param .. '}', replacement)
	end

	return message
end

-- Capitalize the first letter in a string
-- Might move to more dedicated module
-- e.g. `ucfirst('abc')` returns "Abc"
-- @param text string
-- @return string
local function ucfirst(text)
	return text:sub(1,1):upper() .. text:sub(2)
end



-- ------------------------------------
-- The main structure of the card object
-- - Create the object that contains data on the card itself and configuration options
-- ------------------------------------
-- Create an empty Card object with all the default values
local Card = {
	-- Store all details on the card itself here.
	cardTypes = {},
	property = nil,
	attributes = {},
	types = {},
	isMonster = nil,
	isNormalMonster = nil,
	isEffectMonster = nil,
	isPendulumMonster = nil,
	summonType = nil,
	level = nil,
	linkArrows = {},
	atk = nil,
	def = nil,
	linkRating = nil,
	limitationText = nil,
	password = nil,
	effectTypes = {},
	locales = {
		en = {
			name = nil,
			lore = nil,
			pendulumEffect = nil,
			previousNames = {}
		}
	},
	customColor = nil,
	debutDate = nil,

	-- Params for rendered output
	pageName = mw.title.getCurrentTitle().text,
	cardImageName = nil,
	categories = { 'All cards' },

	styles = {
		'Module:Card/base.css'
	},
	main = false,
	hasCustomBacking = false,
	images = {},
	rows = {},
	prev = nil,
	next = nil,

	-- Flag if the module is only being used for demonstrative purposes.
	demo = false,

	-- Configuration options
	-- Overwrite these in modules that extend this one 
	config = {
		baseClass = nil,
		colorCoded = true,
		defaultImage = 'Back-EN.png',
		enableMainLinks = true,
		footerText = nil,
		icons        = {
			-- Individual (just one image for each item)
			level            = '[[File:CG Star.svg|18px|alt=]]',
			unknownLevel     = '[[File:CG Star Unknown.svg|18px|alt=]]',
			negativeLevel    = '[[File:Negative Star.svg|18px|alt=]]',
			rank             = '[[File:Rank Star.svg|18px|alt=]]',
			unknownRank      = '[[File:Rank Star Unknown.svg|18px|alt=]]',
			unknownAttribute = '[[File:UNKNOWN.svg|28px|alt=]]',
			effect           = nil,

			-- Multiple (contain patterns that represent multiple icons)
			levels      = nil,
			cardTypes   = '[[File:{$cardType_uc}.svg|28px|alt=]]',
			properties  = '[[File:{$property}.svg|28px|alt=]]',
			attributes  = '[[File:{$attribute}.svg|28px|alt=]]',
			types       = nil,
			summonTypes = nil
		},
		-- Languages the card has details to be shown in
		langs = { 'en' },
		-- Rows to show in the rendered output
		-- This is empty and should be set in modules that extend this one.
		rows = {},
		-- The Types that are allowed. (This includes anything on the Type line
		-- e.g. 'Dragon', 'Fusion' and 'Effect' are all to be included.)
		-- @todo: categorize cards that have a Type not in this list.
		allowedTypes = {}
	}
}

-- Create a new card object
-- e.g. `Card:new({ atk = 3000, def = 2500, level = 8, types = 'Dragon / Normal' })`
-- The parameters are most likely to come from template input.
-- This will create an object for a card with all those details.
-- The code will automatically fill in more details based on the input.
-- And it will be possible to call other functions on the Card object that make use of this data.
--
-- @param args table
-- @return Card
function Card:new(args)
	-- Create a new instance of the class with all the default values
	local c = mw.clone(Card)

	-- Fill it with the supplied data
	c:setData(args)
	
	return c
end

-- ------------------------------------
-- Setters
-- Functions that add/update information on an instance of the `Card` object based on input 
-- ------------------------------------

-- Main function for setting most data
-- @param args table
function Card:setData(args)
	-- Fill in the new card object with data from the arguments
	self.demo = args.demo or false

	-- The pagename can only be overwitten in demos
	if (self.demo and args.page_name) then
		self.pageName = args.page_name
	end

	self.number = args.number
	self.property = args.property
	self:setCardTypes(args.card_type, args)
	self.property = args.property
	self:setAttributes(args.attribute)
	self:setTypes(args.types)
	self:setLinkArrows(args.link_arrows)
	self.atk = args.atk
	self.def = args.def
	self.level = args.level
	self.rank = args.rank
	self.pendulumScale = args.pendulum_scale
	self.limitationText = args.limitation_text
	self.password = args.password
	self:setEffectTypes(args.effect_types)

	self.locales = Locale:createMany(self.config.langs, args)
	self:setName(args.name)

	self.customColor = args.color
	self:setMainLink(args.main)
end

-- Set the English name
-- @param name string - The card's name. Default to the page name.
function Card:setName(name)
	-- Check if a name was supplied
	if (name) then
		-- Use the supplied name as the card name
		self.locales.en.name = name
	else
		-- Use the page name before the first parenthesis as the card name.
		self.locales.en.name = mw.text.split(self.pageName, ' %(')[1]
	end

	-- Set the name to be used in card images.
	-- Strip out punctuation, spaces, control characters and select symbols
	-- todo: Establish a single source of truth between this and `Module:Card image name`
	self.cardImageName = self.locales.en.name:gsub('[%p%c%s–☆★・]', '')
end

-- Set the card types
-- e.g. 'Spell','Trap', 'Virus'
-- It is usually not necessary to set 'Monster', it can be asumed based on other information.
-- Rarely a card can have multiple card types e.g. 'Counter / Token' or 'Spell / Trap'
-- 
-- @param cardTypesString string
-- @param args table
function Card:setCardTypes(cardTypesString, args)
	-- Flag if the card is a monster.
	-- Even if the cardType is not set to "Monster", the card could be a monster
	-- e.g. if cardType is "Token".
	self.isMonster = (cardTypesString == 'Monster' or args.atk or args.def or args.attribute or args.types)
		and true
		or false

	local cardTypeNames = {}

	-- If the card type is explicitly stated use that.
	-- Default to "Monster" if the card is a monster.
	if (cardTypesString) then
		cardTypeNames = mw.text.split(cardTypesString, '%s*/%s*')
	elseif (self.isMonster) then
		cardNameTypes = { 'Monster' }
	end

	-- Fill the cardTypes table with an object for each card type.
	for _, cardTypeName in pairs(cardTypeNames) do
		table.insert(self.cardTypes, CardType:new(cardTypeName))
	end
end

-- Set Types-related data by passing in the type string
-- @param typesString string '/'-separated list of types as printed on the card
function Card:setTypes(typesString)
	-- Blank everything and start from a clean slate
	self.isNormalMonster   = false
	self.isEffectMonster   = false
	self.isPendulumMonster = false
	self.summonType        = nil
	self.types = {}
	
	if (typesString) then
		-- Split the type input by forward slash
		-- Allow spaces at either side of the slash
		-- e.g. 'Pyro / Fusion / Effect' → { 'Pyro', 'Fusion', 'Effect' }
		local typeNames = mw.text.split(typesString, '%s*/%s*')

		-- Set these values if their Types are anywhere in the array
		self.isNormalMonster   = TableTools.inArray(typeNames, 'Normal')
		self.isEffectMonster   = TableTools.inArray(typeNames, 'Effect')
		self.isPendulumMonster = TableTools.inArray(typeNames, 'Pendulum')

		-- Fill the Types table with an object for each type.
		for _, typeName in pairs(typeNames) do
			local type = Type:new(typeName)
			table.insert(self.types, type)

			-- If the category is 'Summon', set the Type as the card's SummonType
			if (self.summonType == nil and type.category == 'Summon') then
				self.summonType = type
			end
		end
	end
end

-- Set Attributes-related data by passing in the Attribute string
-- @param typesString string '/'-separated list of Attributes
function Card:setAttributes(attributesString)
	self.attributes = {}
	
	if (attributesString) then
		-- Split the Attribute input by forward slash
		-- Allow spaces at either side of the slash
		-- e.g. 'DARK / FIRE' → { 'DARK', 'FIRE' }
		local attributeNames = mw.text.split(attributesString, '%s*/%s*')

		-- Fill the Attributes table with an object for each Attribute.
		for _, attributeName in pairs(attributeNames) do
			local attribute = Attribute:new(attributeName)
			table.insert(self.attributes, attribute)
		end
	end
end


-- Set the `effectTypes` property by passing the input effect types
-- @param effectTypesString string comma-separated list of effect type names
function Card:setEffectTypes(effectTypesString)
	-- Ensure this starts from a clean slate
	self.effectTypes = {}

	-- If there's nothing to set, end early
	if (not effectTypesString or effectTypesString == '') then return end

	-- Split the effect type input by cammo
	-- Allow spaces at either side of the comma
	local effectTypeNames = mw.text.split(effectTypesString, '%s*,%s*')

	-- Fill the effect types list with an object for each effect type.
	for _, effectTypeName in pairs(effectTypeNames) do
		table.insert(self.effectTypes, EffectType:new(effectTypeName))
	end
end

-- Set the Link Arrows data
-- Doing this will also set the Link Rating equal to the number of arrows
-- @param linkArrowsInput string
function Card:setLinkArrows(linkArrowsInput)
	-- Split the input by comma to form an array
	self.linkArrows = mw.text.split(linkArrowsInput or '', '%s*,%s*')

	-- Link Rating is equal to the number of Link Arrows
	self.linkRating = #self.linkArrows
end

-- Set the main link
-- @param main string - manually specified main link.
--                      The link will determined automatically otherwise.
function Card:setMainLink(main)
	-- If main links are not enabled, "main" is always `false`.
	if (not self.config.enableMainLinks) then
		self.main = false
		return
	end

	-- If the input specifically says not to use a main link, "main" is `false`.
	if (TableTools.inArray({ 'no', 'none', 'false' }, main)) then
		self.main = false
		return
	end

	-- If the main link is specifically provided, use that.
	if (main) then
		self.main = main
		table.insert(self.styles, 'Module:Hatnote/styles.css')
		return
	end

	-- Get the name by, stripping parentheses text from the page name.
	local cardName = mw.text.split(self.pageName, ' %(')[1]

	-- If there's a difference, use the non-paretheses version as the main.
	if (self.pageName ~= cardName) then
		self.main = cardName
		table.insert(self.styles, 'Module:Hatnote/styles.css')
		return
	end

	-- If "main" is not found at this point, assume there is none.
	self.main = false
end




-- ------------------------------------
-- Row-adding functions
-- Functions that add key-value pairs to `Card.rows`
-- ------------------------------------
-- Add something to the list of rows to be rendered in the output
-- @param string label - Text to display in the label cell
-- @param string value - Text/icons to display in the data cell
function Card:addRow(label, value)
	if (value and value ~= '') then
		table.insert(self.rows, { label = label, value = value })
	end
end

-- Add row specifically for the Number
function Card:addNumberRow()
	self:addRow('Number', self.number)
end

-- Add a row specifically for the card type to be rendered in the output
function Card:addCardTypeRow()
	if (self.cardTypes == nil or #self.cardTypes == 0) then return end

	local links = {}

	for _, cardType in pairs(self.cardTypes) do
		table.insert(links, '[[' .. cardType.link .. '|' .. cardType.name .. ']]')
	end

	local icon = self:renderCardTypeIcon() or ''
	
	self:addRow('[[Card type]]', table.concat(links, ' / ') .. ' ' .. icon)
end

-- Add a row specifically for the property to be rendered in the output
function Card:addPropertyRow()
	-- Exit early if no card type or property
	if (self.cardTypes == nil or #self.cardTypes == 0 or self.property == nil) then return end
	
	local text = '[[' .. self.property .. ' ' .. self.cardTypes[1].link .. ' |' .. self.property .. ']]'
	local icon = self:renderPropertyIcon()
	
	self:addRow('[[Property]]', text .. ' ' .. icon)
end

-- Add a row specifically for the Attribute to be rendered in the output
function Card:addAttributeRow()
	-- If it's not a monster, don't continue
	if (not self.isMonster) then return end

	local attributesWithIcons = {}
	for _, attribute in pairs(self.attributes) do
		local link = '[[' .. attribute.link .. '|' .. attribute.name .. ']]'
		local icon = self:renderAttributeIcon(attribute.name)
		table.insert(attributesWithIcons, link .. ' ' .. icon)
	end

	self:addRow('[[Attribute]]', table.concat(attributesWithIcons, ' / '))
end

-- Add a row specifically for the Type
function Card:addTypeRow()
	-- If it's not a monster, don't continue
	if (not self.isMonster) then return end
	
	local text  = self:renderTypeString()
	local icons = self:renderTypeIcons()
	
	self:addRow('[[Type]]s', mw.text.trim(text .. ' ' .. icons))
end

-- Add a row specifically for the Level to be rendered in the output
function Card:addLevelRow()
	-- Don't show if the card doesn't have a Level
	if (not self.level) then return end

	-- Don't show for Xyz or Link monsters
	if (self.summonType and TableTools.inArray({'Xyz', 'Link'}, self.summonType.name)) then
		return
	end

	self:addRow('[[Level]]', (self.level) .. ' ' .. self:renderLevelStars())
end

-- Add a row specifically for the Rank to be rendered in the output
function Card:addRankRow()
	-- Only show for Xyz Monsters
	if (not self.summonType or self.summonType.name ~= 'Xyz') then return end

	self:addRow('[[Rank]]', (self.rank or '???') .. ' ' .. self:renderRankStars())
end

-- Add a row specifically for the Link Arrows
function Card:addLinkArrowsRow()
	-- Not a Link Monster, exit early
	if (not self.summonType or self.summonType.name ~= 'Link') then return end

	-- Cell contains the map showing the different arrows and a textual list
	-- Wrap the two in a flex div to vertically center them
	local cell = mw.html.create('div')
	cell
		:css('display', 'flex')
		:css('align-items', 'center')
		:css('gap', '.25em')
		:wikitext(self:renderLinkMap())
		:tag('div')
		:wikitext(table.concat(self.linkArrows, ', '))

	self:addRow('[[Link Arrow]]s', tostring(cell))
end

-- Add a row specifically for the Pendulum Scale
function Card:addPendulumScaleRow()
	-- Only allow this row for Pendulum Monsters with a Pendulum Scale
	if (not self.isPendulumMonster or self.pendulumScale == nil) then
		return nil
	end

	local icon = '[[File:Pendulum Scale.png|22px|alt=|class=noviewer]]'

	self:addRow('[[Pendulum Scale]]', icon .. ' ' .. self.pendulumScale)
end

-- Add a row specifically for ATK and DEF to be rendered in the output
-- Only shows for monsters that are not Link Monsters
function Card:addAtkDefRow()
	-- If the card is not a monster don't show this row.
	if (not self.isMonster) then return nil end

	-- If the card is a Link Monster don't show this row.
	if (self.summonType and self.summonType.name == 'Link') then return nil end

	-- If both values are blank and the cardType is "Token" don't show the row.
	if (not self.atk and not self.def and self:hasCardType('Token')) then
		return nil
	end

	local atk = self.atk or '???'
	local def = self.def or '???'

	self:addRow('[[ATK]] / [[DEF]]', atk .. ' / ' .. def)
end

-- Add a row specifically for ATK and Link to be rendered in the output
-- Only shows for Link Monsters
function Card:addAtkLinkRow()
	-- Don't show this row for non-Link monsters
	if (not self.summonType or self.summonType ~= 'Link') then return end

	local atk = self.atk or '???'
	local link = self.linkRating or '???'

	self:addRow('[[ATK]] / [[Link Rating|LINK]]', atk .. ' / ' .. link)
end

-- Add a row specifically for the effect types
function Card:addEffectTypeRow()
	if (#self.effectTypes == 0) then return end

	local linkedEffectTypes = {}

	-- Loop through all the card's effect types
	for _, effectType in pairs(self.effectTypes) do
		-- Create a pipe link and add it to the array.
		local linkedEffectType = ('[[%s|%s]]'):format(effectType.link, effectType.name)
		table.insert(linkedEffectTypes, linkedEffectType)
	end

	-- Join all elements in the array, separating them with commas
	local effectTypesString = table.concat(linkedEffectTypes, ', ')

	self:addRow('Effect types', effectTypesString)
end

-- Add a rowspecifically for the lore
function Card:addLoreRow()
	local lore = self.locales.en:getFullLore(self)

	if (lore == nil or lore == '') then return end
	
	local loreHtml = mw.html.create('div')
	loreHtml:attr('class', 'lore')

	if (self.locales.en.pendulumEffect) then
		-- Place Pendulum Effect and monster text in a description list
		local dl = mw.html.create('dl')
		dl:css('margin', '.5em')
		dl:tag('dt'):wikitext("'''Pendulum Effect'''")
		dl:tag('dd'):wikitext(self.locales.en.pendulumEffect)
		dl:tag('dt'):wikitext("'''Monster text'''")
		dl:tag('dd'):wikitext(lore)

		loreHtml:wikitext(tostring(dl))
	else
		-- Place lore in a single paragraph in the 
		loreHtml:tag('p'):wikitext(lore)
	end

	self:addRow(nil, tostring(loreHtml))
end

-- Add row specifically for the Limitation text
function Card:addLimitationTextRow()
	self:addRow('[[Limitation text]]', self.limitationText)
end

-- Add row specifically for the Password
function Card:addPasswordRow()
	self:addRow('[[Password]]', self.password)
end



-- ------------------------------------
-- Getters
-- Functions that retrieve information from the instance of the `Card` object
-- ------------------------------------

-- Get the card's (standard) Type
-- @return string
function Card:getType()
	-- Exit early, if not a monster
	if (not self.isMonster) then return nil end

	-- loop through the Types until the standard Type is found
	for _, type in pairs(self.types) do
		if (type.category == 'Type') then
			return type.name
		end
	end

	-- Type was not found, return `nil`
	return nil
end

-- Get the CSS class for the card's color
-- (Does not include Pendulum)
-- @return string
function Card:getColorClass()
	-- If the card has a custom color, use that
	if (self.customColor) then
		return self.customColor .. '-card'
	end

	-- If the card type has a color class use that
	if (self.cardTypes[1] and self.cardTypes[1].colorClass) then
		return self.cardTypes[1].colorClass
	end

	-- If the summon type (Fusion, Ritual, etc.) has a colour, use that
	if (self.summonType and self.summonType.colorClass) then
		return self.summonType.colorClass
	end

	-- If the card is an Effect Monster, base the color on that
	if (self.isEffectMonster) then
		return 'effect-card'
	end

	-- If the card is a Normal Monster, base the color on that
	if (self.isNormalMonster) then
		return 'normal-card'
	end

	-- If the color couldn't be determined above, use this as the default
	return 'blank-card'
end

-- Get the variable CSS classes used in the HTML output
-- Includes the main color class and the pendulum color class
-- @return string
function Card:getCssClass()
	local cssClass = ''

	-- If there is a class to be used for all cards, add that
	if (self.config.baseClass) then
		cssClass = cssClass .. self.config.baseClass
	end

	-- If the cards are color-coded, add more classes
	if (self.config.colorCoded) then
		cssClass = cssClass .. self:getColorClass()
	
		if (self.isPendulumMonster) then
			cssClass = cssClass .. ' pendulum-card'
		end
	end
	
	return cssClass
end

-- Check if the card has a specified card type
-- @param cardTypeName string
-- @return boolean
function Card:hasCardType(cardTypeName)
	-- Loop through each card type
	for _, cardType in pairs(self.cardTypes) do
		-- If one of them has the supplied name, return true
		if (cardType.name == cardTypeName) then
			return true
		end
	end

	-- Otherwise return false
	return false
end



-- ------------------------------------
-- Rendering functions
-- Functions that format details from the card in a certain way
-- ------------------------------------
-- Render an icon representing the card type
-- @return string
function Card:renderCardTypeIcon()
	-- If the card is not a Spell or Trap, exit early
	-- Only bother with the first supplied card type.
	-- (Things with multiple card types don't use this icon.)
	if (not self.cardTypes[1].hasIcon) then
		return ''
	end

	-- String containing a pattern matching the card type icons
	local pattern = self.config.icons.cardTypes

	-- If there's no pattern, don't try to render an icon.
	if (not pattern) then return '' end

	-- Replace variables in the pattern.
	-- e.g. replace '{$cardType}' with 'Spell' if this is a Spell Card.
	local params = {
		cardType    = self.cardTypes[1].name,
		cardType_uc = string.upper(self.cardTypes[1].name)
	}

	return replaceParams(pattern, params)
end

-- Render an icon representing the Property
-- @return string
function Card:renderPropertyIcon()
	-- If the card type doesn't have an icon, end early
	if (#self.cardTypes[1] == 0 or self.cardTypes[1].hasIcon == nil) then
		return ''
	end

	-- String containing a pattern matching the property icons
	local pattern = self.config.icons.properties

	-- If there's no pattern, don't try to render an icon.
	if (not pattern) then return '' end

	-- Replace variables in the pattern.
	-- e.g. replace '{$cardType}' with 'Spell' if this is a Spell Card.
	local params = {
		cardType = self.cardTypes[1].name,
		property = self.property
	}

	return replaceParams(pattern, params)
end

-- Render an icon representing the Attribute
-- @return string
function Card:renderAttributeIcon(attributeName)
	-- Exit early, if not a monster
	if (not self.isMonster) then return '' end

	if (attributeName == nil or attributeName == '???' or attributeName == '?') then
		return self.config.icons.unknownAttribute
	end

	local pattern = self.config.icons.attributes

	-- If there's no pattern, don't try to render an icon.
	if (not pattern) then return '' end

	return replaceParams(pattern, { attribute = attributeName })
end

-- Render the list of Types as a string with links and separators
-- @return string
function Card:renderTypeString()
	-- Exit early, if not a monster
	if (not self.isMonster) then return end

	-- Array containing links to each of the card's Types.
	local linkedTypes = {}

	-- Loop through all the card's Types
	for _, type in pairs(self.types) do
		-- Create a pipe link and add it to the array.
		local linkedType = ('[[%s|%s]]'):format(type.link, type.name)
		table.insert(linkedTypes, linkedType)
	end

	-- Join all elements in the array, separating them with slashes.
	return table.concat(linkedTypes, ' / ')
end

-- Render an icon representing the Attribute
-- @return string
function Card:renderTypeIcons()
	-- Exit early, if not a monster
	if (not self.isMonster) then return '' end

	local icons = ''

	local standardType       = self:getType()
	local hasEffect          = self.isEffectMonster

	local typePattern        = self.config.icons.types
	local summonTypePattern  = self.config.icons.summonTypes
	local effectIcon         = self.config.icons.effect

	if (standardType and typePattern) then
		icons = icons .. replaceParams(typePattern, { type = standardType })
	end

	if (self.summonType and summonTypePattern) then
		icons = icons .. replaceParams(summonTypePattern, { type = self.summonType.name })
	end

	if (hasEffect and effectIcon) then
		icons = icons .. effectIcon
	end

	return icons
end

-- Render the Level as star icons
-- @return string
function Card:renderLevelStars()
	-- Don't show for non-monsters
	if (not self.isMonster) then return '' end

	-- Don't show for Xyz or Link monsters
	if (self.summonType and TableTools.inArray({'Xyz', 'Link'}, self.summonType.name)) then
		return ''
	end
	
	local icons = self.config.icons

	-- If unknown Level, just show a single instance of the unknown icon.
	if (self.level == nil or self.level == '?' or self.level == '???') then
		return icons.unknownLevel
	end

	-- Now that unknown is out of the way, this can be cast as a number.
	local level = tonumber(self.level)

	-- Exit early if the Level is 0
	if (level == 0) then return '' end

	-- If there is one icon pattern to cover all Levels use that.
	if (icons.levels ~= nil) then
		return replaceParams(icons.levels, { level = self.level})
	end

	-- Use different icons if Level is positive or negative
	local icon = level >= 0 and icons.level or icons.negativeLevel

	-- Repeat the same icon a number of times equal to the Level/Rank (stars)
	return '<span class="card-stars">' .. 
		string.rep(icon, math.abs(level)) .. 
		'</span>'
end

-- Render the Rank as star icons
-- @return string
function Card:renderRankStars()
	-- Exit early, if not an Xyz Monster
	if (not self.summontype or self.summonType.name ~= 'Xyz') then return '' end

	local icons = self.config.icons

	-- If unknown Level/Rank, just show a single instance of the unknown icon.
	if (self.rank == nil or self.rank == '?' or self.rank == '???') then
		return icons.unknownRank
	end

	-- Exit early if the Rank is 0
	if (self.rank == 0) then return '' end

	-- Repeat the same icon a number of times equal to the Rank
	return '<span class="card-stars">' .. string.rep(icons.rank, self.rank) .. '</span>'
end

-- Render a grid showing the Link Arrows
-- @return string
function Card:renderLinkMap()
	local gridPositions = {
		'Top-Left',    'Top-Center',    'Top-Right',
		'Middle-Left', 'Middle-Center', 'Middle-Right',
		'Bottom-Left', 'Bottom-Center', 'Bottom-Right'
	}

	-- `div` for a 3x3 grid
	local map = mw.html.create('div')
	map
		:css('display', 'inline-grid')
		:css('width', '36px'):css('height', '46px')
		:css('grid-template-rows', '12px 12px 12px')
		:css('grid-template-columns', '10px 15px 10px')

	-- Add an element for each position in the grid
	for _, position in pairs(gridPositions) do
		if (position == 'Middle-Center') then
			-- Nothing in the center square
			map:wikitext('&nbsp;')
		else
			-- Arrow will be highlighted in in the list of Link Arrows
			local active = TableTools.inArray(self.linkArrows, position)

			-- These two images have a different width than the others
			local size = TableTools.inArray({ 'Top-Center', 'Bottom-Center' }, position) and 'x10px' or '10px'

			-- Form the file name
			-- Remove '-' from the position name, add "2" if the position is inactive
			local file = 'LM-' .. (position:gsub('-', '')) ..(not active and '2' or '') .. '.png'
			map:wikitext('[[File:' .. file .. '|' ..size .. '|alt=]]')
		end
	end

	return tostring(map)
end

-- Render the card image section
-- Either a single image or a switcher for multiple images
-- @return string
function Card:renderImages()
	-- If there's only one image, show it
	if (#self.images == 1) then
		local imagePageName = self.images[1].image ~= ''
			and self.images[1].image
			or self.config.defaultImage
		return '[[File:' .. imagePageName .. '|200px]]'
	end

	-- Create the HTML for the image switcher wrapper
	local imageSwitcher = mw.html.create('div')
	imageSwitcher
		:addClass('switcher-container')
		:css('margin', '0 auto')
		:css('max-width', '200px')
		:css('text-align', 'left')

	-- Add a child element for each image and its label
	for _, image in pairs(self.images) do
		local title = nil
		if (image.name) then
			title = image.name
		-- If this is the only image other than the backing, set "Front" as its caption.
		elseif (#self.images == 2 and self.hasCustomBacking and not image.isBack) then
			title = 'Front'
		else
			title =  'Artwork ' .. image.artwork
		end

		local imagePageName = image.image ~= ''
			and image.image
			or self.config.defaultImage
		local imageDisplay = '[[File:' .. imagePageName .. '|200px|alt=' .. title .. ']]'
		local labelHtml = mw.html.create('div')
		
		labelHtml:attr('class', 'switcher-label'):wikitext(title)
		
		if (image.isCurrent) then
			labelHtml:attr('data-switcher-default', 'true')
		end

		imageSwitcher
			:tag('div')
			:css('margin-bottom', '5px')
			:wikitext(imageDisplay .. tostring(labelHtml))
	end

	return tostring(imageSwitcher)
end

-- Create a TemplateStyles tag
local function renderTemplateStyles(page)
	if (page == nil or page == '') then return end
	
	return tostring( mw.getCurrentFrame():extensionTag{ name = 'templatestyles', args = { src = page } } )
end

-- Generate the wikitext output
function Card:render()
	local output = self:renderCardSection()
		.. self:renderAdditionalSections()
		.. self:renderLocalesSection()
		.. self:renderCategories()

	-- Show the debut date
	-- Might add a section for debugging information later.
	-- Or something to show SMW data when that's set up.
	if (self.debutDate) then
		output = output .. '<pre>Debut date: ' .. self.debutDate .. '</pre>'
	end

	return output
end

-- Generate the wikitext for the card details portion of the output
-- @return string
function Card:renderCardSection()
	local output = ''

	-- Add template styles
	for _, style in pairs(self.styles) do
		output = output .. '\n' .. renderTemplateStyles(style)
	end

	if (self.main) then
		output = output .. '<div role="note" class="hatnote navigation-not-searchable">Main card page: "[[' .. self.main .. ']]"</div>'
	end

	output = output .. '\n<div class="card-table ' .. self:getCssClass() .. '">'

	output = output .. '\n  <div class="heading">' .. self.locales.en.name .. '</div>'

	if (self.locales.ja) then
		output = output .. '\n  <div class="above hlist">' .. self:renderJaNamesList() .. '</div>'
	end

	output = output .. '\n  <div class="card-table-columns">'
	output = output .. '\n    <div class="imagecolumn">'
	output = output .. self:renderImages()
	output = output .. '\n    </div>' -- .imagecolumn

	output = output .. '\n    <div class="infocolumn">'
	output = output .. '\n      <table class="innertable">'

	for _, row in pairs(self.rows) do
		output = output .. '\n<tr>'
		if (row.label) then
			output = output .. '\n  <th scope="row" style="text-align: left;">' .. row.label .. '</th>'
			output = output .. '\n  <td>' .. row.value .. '</td>'
		else
			output = output .. '\n <td colspan="2">' .. row.value .. '</td>'
		end
		output = output .. '\n</tr>'
	end

	output = output .. '\n      </table>'
	output = output .. '\n    </div>' -- .infocolumn

	output = output .. '\n  </div>' -- .card-table-columns

	output = output .. self:renderFooter()

	output = output .. '\n</div>' -- .card-table

	return output
end

-- Generate a section
-- This function is necessary for sections to be collapsible on mobile.
-- Elements generated by Scribunto are added after MobileFrontEnd processes page
-- content and makes headings collapsible.
-- This function will prep them to be collapsible. It's not an ideal solution.
--
-- @param title string
-- @param content string
-- @return string
function Card.renderSection(title, content)
	local heading = mw.html.create('h2')
		:addClass('section-heading')
		:addClass('collapsible-heading')
		:addClass('collapsible-heading--imitation')
		:wikitext(title)

	local body = mw.html.create('div')
		:addClass('collapsible-block')
		:addClass('collapsible-block--imitation')
		:wikitext(content)

	return tostring(heading) .. tostring(body);
end

-- Render the footer section of the card-details output
-- @return string
function Card:renderFooter()
	if (not self.config.footerText or self.config.footerText == '') then return '' end
	
	local output = '<div class="below">'
	if (self.prev or self.next) then
		output = output .. '\n  <div class="chronology">'
		output = output .. '\n    <div class="prev">' .. (self.prev or '') .. '</div>'
		output = output .. '\n    <div class="curr">' .. self.config.footerText .. '</div>'
		output = output .. '\n    <div class="prev">' .. (self.next or '') .. '</div>'
		output = output .. '\n  </div>'
	else
		output = output .. self.config.footerText
	end
	output = output .. '\n</div>'

	return output
end

-- Generate the wikitext for the "sets" section of the output
-- (Empty here. Can be overwritten/extended in modules that extend this one.)
-- @return string
function Card:renderAdditionalSections()
	return ''
end

-- Generate the wikitext for the "other languages" section
-- @return string
function Card:renderLocalesSection()
	-- Needs to have at least two languages
	-- i.e. Needs at least one language other than English
	if (TableTools.size(self.locales) < 2) then return '' end

	output = '\n<table class="wikitable">'
	output = output .. '\n  <tr>'
	output = output .. '\n    <th scope="col">Language</th>'
	output = output .. '\n    <th scope="col">Name</th>'
	output = output .. '\n    <th scope="col">Card text</th>'
	output = output .. '\n  </tr>'

	-- Iterate through `self.config.langs`, rather than `self.locales`
	-- because "langs" is numerically indexed, so its ordering is respected
	for _, lang in pairs(self.config.langs) do
		-- Only include languages that have recorded data.
		-- Don't include English.
		if (lang ~= 'en' and self.locales[lang]) then
			local locale = self.locales[lang]
			local langCode = locale:getHtmlLang()

			output = output .. '\n<tr>'
			output = output .. '\n  <th scope="row">' .. locale.language.name .. '</th>'
			output = output .. '\n  <td lang="' .. langCode .. '">'
			output = output .. (locale.name or '')
			if (locale.romanizedName) then
				output = output .. '<br />(<i lang="' .. locale:getRomanizedHtmlLang() .. '">' .. locale.romanizedName .. '</i>)'
			end
			output = output .. '</td>'
			output = output .. '\n  <td lang="' .. langCode .. '">' .. locale:getFullLore(self) .. '</td>'
			output = output .. '\n</tr>'
		end
	end

	output = output .. '</table>'

	return self.renderSection('Other languages', output)
end

-- Render a description list for the different forms of the Japanese name
-- @return string
function Card:renderJaNamesList()
	if not self.locales.ja then return '' end
	
	local ja = self.locales.ja
	local jaBaseName = ja:getBaseName()
	local jaTopName  = ja:getTopName()

	local list = mw.html.create('dl')

	-- Main Japanese name
	if (ja.name) then
		list:tag('dt'):wikitext('Japanese')
		list:tag('dd'):tag('span'):attr('lang', 'ja-Japn'):wikitext(' ' .. ja.name)
	end

	-- Base Japanese name, if different than main
	if (jaBaseName and jaBaseName ~= ja.name) then
		list:tag('dt'):wikitext('Base')
		list:tag('dd'):tag('span'):attr('lang', 'ja-Japn'):wikitext(' ' .. jaBaseName)
	end

	-- Base rōmaji name, if different than main rōmaji name
	if (ja.baseRomanizedName and ja.baseRomanizedName ~= ja.romanizedName) then
		list:tag('dt'):wikitext("Base ''rōmaji''")
		list:tag('dd'):tag('span'):attr('lang', 'ja-Latn-hepburn'):wikitext(' <i>' .. ja.BaseRomanizedName .. '</i>')
	end

	-- Base translated name, if different than main translated name
	if (ja.baseTranslatedName and ja.baseTranslatedName ~= ja.translatedName) then
		list:tag('dt'):wikitext("Base translated")
		list:tag('dd'):tag('span'):wikitext(' ' .. jaBaseTranslatedName)
	end

	-- Furigana name, if different than main
	if (jaTopName and jaTopName ~= ja.name) then
		list:tag('dt'):wikitext('Furigana')
		list:tag('dd'):tag('span'):attr('lang', 'ja-Hrkt'):wikitext(' ' .. jaTopName)
	end

	-- Rōmaji name
	if (ja.romanizedName) then
		local label = (ja.baseRomanizedName)
			and "Furigana ''rōmaji''"
			or "''Rōmaji''"
		list:tag('dt'):wikitext(label)
		list:tag('dd'):tag('span'):attr('lang', 'ja-Latn-hepburn'):wikitext(' <i>' .. ja.romanizedName .. '</i>')
	end

	-- Translated name
	if (ja.translatedName) then
		local label = (ja.baseTranslatedName)
			and 'Furigana translated'
			or 'Translated'
		list:tag('dt'):wikitext(label)
		list:tag('dd'):tag('span'):wikitext(' ' .. ja.translatedName)
	end

	return tostring(list)
end

-- Render the categories
-- If this is a demo page, show a section with links to each category.
-- For non-demo pages, actually add the categories.
-- @return string
function Card:renderCategories()
	if not self.demo then
		local categoryText = ''

		for _, category in pairs(self.categories) do
			categoryText = categoryText .. '[[Category:' .. category .. ']]'
		end

		return categoryText
	end

	local categoryBlock = mw.html.create('div')
		:attr('class', 'catlinks')
		:wikitext('Categories: ')

	for i, category in pairs(self.categories) do
		if (i ~= 1) then
			categoryBlock:wikitext(' | ' )
		end
		categoryBlock:wikitext('[[:Category:' .. category .. '|' .. category .. ']]')
	end

	return tostring(categoryBlock)
end


-- Function to be invoked by the module
-- Takes all template parameters and returns the HTML
function Card.card(frame)
	-- Get the template parameters
	local args = frame:getParent().args

	-- If a parameter contains an empty string, ignore it
	local normalizedArgs = {}
	for param, value in pairs(args) do
	    if (value and mw.text.trim(value) ~= '') then
	    	normalizedArgs[param] = value
	    end
	end

	-- Create a new card object
	local c = Card:new(normalizedArgs)

	-- For each row configured to show, call the function to add it
	for _, row in pairs(c.config.rows) do
	    c['add' .. ucfirst(row) .. 'Row'](c)
	end

	-- Render the output
	return c:render()
end

return Card