Module:Documentation/Module: Difference between revisions

From Zelda Wiki, the Zelda encyclopedia
Jump to navigation Jump to search
No edit summary
No edit summary
 
(2 intermediate revisions by the same user not shown)
Line 238: Line 238:
local title = mw.title.new(modulePage)
local title = mw.title.new(modulePage)
local fn = title.subpageText -- function name
local fn = title.subpageText -- function name
fn = string.gsub(fn, "^ ", "_")
local module = { [fn] = require(modulePage) }
local module = { [fn] = require(modulePage) }
local spec = require(modulePage.."/Documentation/Spec")
local spec = require(modulePage.."/Documentation/Spec")
Line 246: Line 247:
local schemas = spec.Schemas and { [fn] = spec.Schemas() } or {}
local schemas = spec.Schemas and { [fn] = spec.Schemas() } or {}
local fnDoc = h.resolveFunctionDoc(module, doc, schemas, fn)
local fnDoc = h.resolveFunctionDoc(module, doc, schemas, fn)
return h.printFunctionDoc(fnDoc, modulePage)
local result = h.printFunctionDoc(fnDoc, modulePage)
if string.find(fn, "^_") then
-- Prevents the _ from being shown as a blank space in the h1 header for pages like Module:Util/strings/_startsWith
local displayTitle = string.gsub(mw.title.getCurrentTitle().fullText, "/ ", "/_")
result = result .. mw.getCurrentFrame():preprocess("{{DISPLAYTITLE:"..displayTitle.."}}")
local nonCurriedFnPage = string.gsub(modulePage, "/ ", "/")
local nonCurriedFnName = string.gsub(fn, "^_", "")
local curryMsg = string.format("<p>A [[Module:Util/tables#Currying|curried]] version of [[%s]].</p>", nonCurriedFnPage, nonCurriedFnName)
result = curryMsg .. result
end
return result
end
end



Latest revision as of 00:22, 20 May 2024


local p = {}
local h = {}
local styles = mw.getCurrentFrame() and mw.getCurrentFrame():extensionTag({
	name = "templatestyles",
	args = { src = "Module:Documentation/Styles.css" }
})

local i18n = require("Module:I18n")
local s = i18n.getString
local lex = require("Module:Documentation/Lexer")
local utilsFunction = require("Module:UtilsFunction")
local utilsLayout = require("Module:UtilsLayout")
local utilsMarkup = require("Module:UtilsMarkup")
local utilsPage = require("Module:UtilsPage")
local utilsSchema = require("Module:UtilsSchema")
local utilsString = require("Module:UtilsString")
local utilsTable = require("Module:UtilsTable")
local utilsVar = require("Module:UtilsVar")

local caseCounter = utilsVar.counter("testCases")

local DocumentationTemplate = require("Module:Documentation/Template")

local MAX_ARGS_LENGTH = 50

function getModulePage(frame)
	local docPage = mw.title.new(frame:getParent():getTitle())
	local modulePage = docPage.basePageTitle
	local subpageText = modulePage.subpageText
	if subpageText == "Data" then
		modulePage = modulePage.basePageTitle
	end
	return modulePage.fullText, subpageText, docPage.fullText
end

function p.Schema(frame)
	local modulePage = frame.args.module or getModulePage(frame)
	local module = require(modulePage)
	local schemaName = frame.args[1]
	return styles..p.schema(module.Schemas()[schemaName], schemaName)
end

function p.Module(frame)
	local modulePage, subpage, docPage = getModulePage(frame)
	local categories = utilsMarkup.categories(h.getCategories(frame.args.type))
	
	local result
	if utilsString.endsWith(docPage, "/Documentation/Snippets/Documentation") then
		local category = mw.title.getCurrentTitle().subpageText == "Documentation" and "[[Category:Module Snippets Documentation]]" or "[[Category:Module Snippets]]"
		result = string.format("This module contains snippets used to [[Module:Documentation|generate documentation and unit tests]] for [[Module:%s]]. %s", mw.title.getCurrentTitle().rootText, category)
	elseif utilsString.endsWith(docPage, "/TemplateData/Documentation") then
		local category = mw.title.getCurrentTitle().subpageText == "Documentation" and "[[Category:Template Data Documentation]]" or "[[Category:Template Data]]"
		result = string.format("This data is used to [[Module:Documentation|auto-generate documentation]] and [[Module:UtilsArg|validate input]] for templates backed by [[Module:%s]]. %s", mw.title.getCurrentTitle().rootText, category)
	elseif subpage == "Data" and modulePage ~= "Module:Data" then -- documenting data
		result = p.dataDoc(modulePage) .. categories
	elseif utilsString.startsWith(modulePage, "Module:Util/") then -- documentating new style of utils which export one function per page
		result = p.moduleDocUtil(modulePage)
	else --documenting a module
		result = p.moduleDoc(modulePage) .. categories
	end
	return styles..result
end

function p.dataDoc(modulePage)
	local result = "''For information on editing module data in general, see [[Guidelines:Modules/Data]].''\n"
	local module = require(modulePage)
	local tabs = {}
	if type(module.Data) == "function" then
		table.insert(tabs, {
			label = "Data",
			content = module.Data(mw.getCurrentFrame())
		})
	end
	local schemas = module.Schemas and module.Schemas()
	local dataSchema = schemas and schemas.Data
	if dataSchema then
		table.insert(tabs, {
			label = "Schema",
			content = p.schema(dataSchema, "Data")
		})
		local data = mw.loadData(modulePage.."/Data")
		local err = utilsSchema.validate(dataSchema, "Data", data, "data")
		if err then
			result = result.."[[Category:Modules with invalid data]]"
		end
	end
	result = result .. utilsLayout.tabs(tabs, {
		{
			tabs = {
				collapse = true
			}
		}
	})
	return result
end

function p.schema(schema, schemaName)
	local referenceDefinitions = p.collectReferenceDefinitions(schema)
	local dt, dds = p.printSchemaNode(referenceDefinitions, schema, schemaName)
	local schemaDef = utilsTable.flatten({dt, dds})
	schemaDef = utilsMarkup.definitionList({schemaDef})
	
	return schemaDef
end
function p.collectReferenceDefinitions(schema)
	local defs = schema.definitions or {}
	for k, v in pairs(schema) do
		if k == "_id" then
			defs[v] = schema
		elseif type(v) == "table" then
			defs = utilsTable.merge({}, defs, p.collectReferenceDefinitions(v))
		end
	end
	return defs
end

local seenRefs = {}
function p.printSchemaNode(definitions, schemaNode, schemaName, isCollectionItem)
	local dds = {}
	local nodeType
		
	local refName = schemaNode._ref and string.gsub(schemaNode._ref, "#/definitions/", "")
	if schemaNode._hideSubkeys then
		nodeType = refName
	elseif refName then
		local ref = definitions[refName]

		if not ref and not seenRefs[refName] then
			error(string.format("Definition '%s' not found", refName))
		end
		if not ref and seenRefs[refName] then
			nodeType = refName
		else 
			schemaNode = utilsTable.merge({}, schemaNode, ref)
		end
		
		-- prevents infinite recursion when definitions reference themselves
		seenRefs[refName] = true
		definitions = utilsTable.clone(definitions)
		definitions[refName] = nil
	end
	
	nodeType = nodeType or schemaNode.type

	if schemaNode.desc then
		table.insert(dds, schemaNode.desc)
	end
	if nodeType == "record" then
		local dl = {}
		for i, property in ipairs(schemaNode.properties or {}) do
			local subdt, subdds = p.printSchemaNode(definitions, property, property.name)
			table.insert(dl, utilsTable.flatten({subdt, subdds}))
		end
		dl = utilsMarkup.definitionList(dl)
		table.insert(dds, dl)
	end
	if nodeType == "map" then
		local dt, subdds, valueSubtype = p.printSchemaNode(definitions, schemaNode.values, nil, true)
		local _, keydds, keySubtype = p.printSchemaNode(definitions, schemaNode.keys, nil, true)
		local keyPlaceholder = string.format("[[Module:Documentation#Schemas|<code>&lt;%s&gt;</code>]]", schemaNode.keyPlaceholder or keySubtype)
		if #subdds > 0 then
			local dl = utilsMarkup.definitionList({utilsTable.flatten({keyPlaceholder, keydds, subdds})})
			table.insert(dds, dl)
		end
		nodeType = string.format("map<%s, %s>", keySubtype, valueSubtype)
	end
	if nodeType == "array" then
		local dt, subdds, subtype = p.printSchemaNode(definitions, schemaNode.items, nil, true)
		dds = utilsTable.concat(dds, subdds)
		nodeType = "{ "..(subtype).." }"
	end
	if schemaNode.allOf then
		local subtypes = {}
		for i, node in ipairs(schemaNode.allOf) do
			local dt, subdds, subtype = p.printSchemaNode(definitions, node)
			dds = utilsTable.concat(dds, subdds)
			table.insert(subtypes, subtype)
		end
		nodeType = table.concat(subtypes, "&")
	end
	if schemaNode.oneOf then
		local tabs = {}
		local subtypes = {}
		for k, node in pairs(schemaNode.oneOf) do
			local subdt, subdds, subtype = p.printSchemaNode(definitions, node, nil, true)
			
			if subtype == "record" and subtypes[#subtypes] == "record" then
				-- no-op: don't bother showing record|record
			else
				table.insert(subtypes, subtype)
			end

			local tabname = tostring(k)
			if type(k) == "number" and node._tabName then
				tabname = node._tabName
			elseif type(k) == "number" and node._ref then
				tabname = string.gsub(node._ref, "#/definitions/", "")
			elseif type(k) == "number" then
				tabname = subtype
			end
			if #subdds > 0 then
				table.insert(subdds, 1, nil)
				table.insert(tabs, {
					label = tabname,
					content = utilsMarkup.definitionList({subdds})
				})
			end
		end
		if #tabs > 0 then
			tabs = utilsLayout.tabs(tabs, {
				tabOptions = {
					collapse = true,
				},
			})
			table.insert(dds, tabs)
		end
		nodeType = table.concat(subtypes, "|")
	end
	if schemaNode.required and not isCollectionItem then
		nodeType = nodeType and nodeType.."!"
	elseif not isCollectionItem then
		nodeType = nodeType and "["..nodeType.."]"
		if schemaNode.default then
			schemaName = schemaName .."="..tostring(schemaNode.default)
		end
		schemaName = schemaName and "["..schemaName.."]"
	end
	local dt = schemaName and utilsMarkup.inline(schemaName, {
		code = true,
		tooltip = nodeType,
	})
	dt = utilsMarkup.link("Module:Documentation#Schemas", dt)

	return dt, dds, nodeType
end

function p.moduleDocUtil(modulePage)
	local title = mw.title.new(modulePage)
	local fn = title.subpageText -- function name
	fn = string.gsub(fn, "^ ", "_")
	local module = { [fn] = require(modulePage) }
	local spec = require(modulePage.."/Documentation/Spec")
	local doc = { 
		[fn] = spec.Documentation(),
		snippets = h.snippets(modulePage),
	}
	local schemas = spec.Schemas and { [fn] = spec.Schemas() } or {}
	local fnDoc = h.resolveFunctionDoc(module, doc, schemas, fn)
	local result = h.printFunctionDoc(fnDoc, modulePage)
	if string.find(fn, "^_") then
		-- Prevents the _ from being shown as a blank space in the h1 header for pages like Module:Util/strings/_startsWith
		local displayTitle = string.gsub(mw.title.getCurrentTitle().fullText, "/ ", "/_")
		result = result .. mw.getCurrentFrame():preprocess("{{DISPLAYTITLE:"..displayTitle.."}}")
		
		local nonCurriedFnPage = string.gsub(modulePage, "/ ", "/")
		local nonCurriedFnName = string.gsub(fn, "^_", "")
		local curryMsg = string.format("<p>A [[Module:Util/tables#Currying|curried]] version of [[%s]].</p>", nonCurriedFnPage, nonCurriedFnName)
		result = curryMsg .. result
	end
	return result
end

function p.moduleDoc(modulePage, section)
	local headingLevel = section and 3 or 2
	local module, doc, templates = h.resolveDoc(modulePage, section)
	local output = ""
	if not section then
		if templates ~= nil then
			local templates = utilsTable.keys(templates)
			table.sort(templates)
			local templateLinks = {}
			for i, templateName in ipairs(templates) do
				local templatePage = "Template:"..templateName
				if utilsPage.exists(templatePage) then
					local templateLink = utilsMarkup.link(templatePage)
					table.insert(templateLinks, templateLink)
				end
			end
			if #templateLinks > 0 then
				local templateList = utilsMarkup.bulletList(templateLinks)
				output = "This is the main module for the following templates:" .. templateList .. "\n"
			end
			if #templateLinks > 0 and #doc.functions > 0 or doc.sections then
				output = output .. "In addition, this module exports the following functions. __TOC__\n"
			end
		elseif #doc.functions > 0 or doc.sections then
			output = "This module exports the following functions. __TOC__\n"
		end
	end
	for _, functionDoc in ipairs(doc.functions or {}) do
		output = output .. utilsMarkup.heading(headingLevel, functionDoc.name) .. "\n"
		if functionDoc.wip then
			output = output .. mw.getCurrentFrame():expandTemplate({
				title = "WIP",
				args = {
					align = "left",
				}	
			}) .. '<div style="clear:left"/>'
		end
		
		if functionDoc.fp then
			output = output .. utilsLayout.tabs({
				{
					label = functionDoc.name,
					content = h.printFunctionDoc(functionDoc)
				},
				{
					label = functionDoc.fp.name,
					content = h.printFunctionDoc(functionDoc.fp)
				}
			})
		else
			output = output .. h.printFunctionDoc(functionDoc, modulePage)
		end
	end
	if doc.sections then
		for _, section in ipairs(doc.sections) do
			local sectionModule = type(section.section) == "string" and section.section or modulePage
			if section.heading then
				output = output .. utilsMarkup.heading(headingLevel, section.heading)
				output = output .. p.moduleDoc(sectionModule, section.section) .. "\n"
			else
				output = output .. p.moduleDoc(sectionModule, section.section)
			end
		end
	end
	return output
end

function h.resolveDoc(modulePage, section)
	local module = require(modulePage)
	local doc
	local schemas
	local templates
	local templateDataPage = modulePage .. "/TemplateData"
	-- For performance reasons, template data may be placed on a separate page
	-- We try to load it from there first. If it doesn't exist, we load it directly from the module page itself
	if utilsPage.exists(templateDataPage) then
		local templateData = require(templateDataPage)
		if type(templateData) == "table" then
			templates = templateData
		end
	end
	if type(module) == "table" then
		doc = module.Documentation and module.Documentation()
		schemas = module.Schemas and module.Schemas()
		templates = templates or module.Templates
	end
	if type(section) == "table" then
		doc = section
	end
	doc = doc or {}
	schemas = schemas or {}
	local err = utilsSchema.validate(p.Schemas().Documentation, "Documentation", doc, "p.Documentation")
	if err then
		mw.logObject(err)
	end
	if doc.sections then
		doc.functions = {}
		doc.snippets = h.snippets(modulePage)
		return module, doc, templates
	end
	local functionNamesInSource = h.functionNamesInSource(modulePage)
	local functionNamesInDoc = {}
	for k, v in pairs(doc) do
		table.insert(functionNamesInDoc, k)
		if doc._params then
			table.insert(functionNamesInDoc, "_" .. k)
		end
	end
	local functionNames = utilsTable.intersection(functionNamesInSource, functionNamesInDoc)
	local undefinedFunctions = utilsTable.difference(functionNamesInDoc, functionNames)
	if #undefinedFunctions > 0 then
		local msg = string.format("Documentation references functions that do not exist: <code>%s</code>", utilsTable.print(undefinedFunctions, true))
		mw.addWarning(msg)
	end
	local functions = {}
	doc.snippets = h.snippets(modulePage)
	for _, functionName in ipairs(functionNames) do
		table.insert(functions, h.resolveFunctionDoc(module, doc, schemas, functionName))
		doc[functionName] = nil
	end
	doc.functions = functions
	return module, doc, templates
end
function h.functionNamesInSource(modulePage)
	local source = mw.title.new(modulePage):getContent()
	local lexLines = lex(source)
	local functionNames = {}
	for _, tokens in ipairs(lexLines) do
		tokens = utilsTable.filter(tokens, function(token) 
			return token.type ~= "whitespace" 
		end)
		tokens = utilsTable.map(tokens, "data")
		if utilsTable.isEqual(
			utilsTable.slice(tokens, 1, 3),
			{"function", "p", "."}
		) then
			table.insert(functionNames, tokens[4])
		end
	end
	return functionNames
end

function h.resolveFunctionDoc(module, moduleDoc, schemas, functionName)
	local functionDoc = moduleDoc[functionName]
	functionDoc.name = functionName
	functionDoc.fn = module[functionDoc.name]
	functionDoc.cases = functionDoc.cases or {}
	functionDoc.snippets = h.getFunctionSnippets(moduleDoc.snippets, functionDoc.name)
	if type(functionDoc.returns) ~= "table" then
		functionDoc.returns = {functionDoc.returns}
		for i, case in ipairs(functionDoc.cases) do
			case.expect = {case.expect}
		end
	end
	local paramSchemas = schemas[functionDoc.name] or {}
	local resolvedParams = utilsTable.map(functionDoc.params or {}, function(param)
		return { name = param, schema = paramSchemas[param] }
	end)
	if functionDoc._params then
		functionDoc.fp = mw.clone(functionDoc)
		functionDoc.fp.name = "_" .. functionDoc.name
		functionDoc.fp.fn = module[functionDoc.fp.name]
		for _, case in ipairs(functionDoc.fp.cases) do
			case.args = case.args and utilsTable.map(functionDoc._params, function(paramGroup)
				return utilsTable.map(paramGroup, function(param)
					return case.args[utilsTable.keyOf(functionDoc.params, param)]
				end)
			end)
		end
		functionDoc.fp.params = utilsTable.map(functionDoc._params, function(paramGroup)
			return utilsTable.map(paramGroup, function(param)
				return resolvedParams[utilsTable.keyOf(functionDoc.params, param)]
			end)
		end)
		functionDoc.fp.snippets = h.getFunctionSnippets(moduleDoc.snippets, functionDoc.fp.name)
	end
	functionDoc.params = {resolvedParams}
	if not functionDoc.frameParams then
		for _, case in ipairs(functionDoc.cases) do
			case.args = {case.args}
		end
	end
	return functionDoc
end
function h.getFunctionSnippets(moduleSnippets, functionName)
	local functionSnippets = {}
	for k, v in pairs(moduleSnippets or {}) do
		local s, e = k:find(functionName)
		if e then
			local snippetKey = string.sub(k, e + 1)
			functionSnippets[snippetKey] = v
		end
	end
	return functionSnippets
end

function h.printFunctionDoc(functionDoc, modulePage)
	local result = ""
	local moduleName = modulePage and string.gsub(modulePage, "Module:", "")
	result = result .. h.printFunctionSyntax(functionDoc, moduleName)
	result = result .. h.printFunctionDescription(functionDoc)
	result = result .. h.printParamsDescription(functionDoc)
	result = result .. h.printReturnsDescription(functionDoc.returns)
	result = result .. h.printFrameParams(functionDoc)
	result = result .. h.printFunctionCases(functionDoc, moduleName)
	return result
end

function h.printFunctionSyntax(functionDoc, moduleName)
	local result = ""
	if functionDoc.frameParams then
		result = string.format("{{#invoke:%s|%s%s%s}}", moduleName, functionDoc.name, h.printFrameParamsSyntax(functionDoc), functionDoc.frameParamsFormat == "multiLine" and "\n" or "")
	else
		for _, params in ipairs(functionDoc.params) do
			result = functionDoc.name .. "(" .. h.printParamsSyntax(params) .. ")"
		end
	end
	if functionDoc.frameParams then
		return '<pre class="module-documentation__invoke-syntax">'..result..'</pre>'
	else
		return utilsMarkup.code(result) .. "\n"
	end
end
function h.printParamsSyntax(params)
	local paramsSyntax = {}
	for _, param in ipairs(params or {}) do
		local paramSyntax = param.name
		if param.schema and not param.schema.required then
			paramSyntax = "[" .. paramSyntax .. "]"
		end
		table.insert(paramsSyntax, paramSyntax)
	end
	return table.concat(paramsSyntax, ", ") 
end
function h.printFrameParamsSyntax(functionDoc)
	local paramsSyntax = ""
	
	local orderedParams = h.sortFrameParams(functionDoc, functionDoc.frameParams)

	for i, param in ipairs(orderedParams) do
		if functionDoc.frameParamsFormat == "multiLine" and not param.inline then
			paramsSyntax = paramsSyntax .. "\n"
		end
		if functionDoc.frameParamsFormat == "multiLine" and param.spaceBefore then
			paramsSyntax = paramsSyntax .. "\n"
		end
		paramsSyntax = paramsSyntax.."|"
		if type(param.param) == "string" then
			paramsSyntax = paramsSyntax .. param.param .. "="
		else
			paramsSyntax = paramsSyntax.."<"..(param.name or "")..">"
		end
	end
	return paramsSyntax
end
function h.sortFrameParams(functionDoc, frameParams)
	local orderedParams = {}
	local seenParams = {}
	
	-- First the positional parameters 
	for i, param in ipairs(frameParams) do
		param = utilsTable.merge({}, param, {
			param = i -- needs to have "param" key for Module:Documentation/Template to print the parameter table
		})
		table.insert(orderedParams, param)
		seenParams[i] = true
	end
	
	-- Then, according to frameParamsOrder
	for i, paramKey in ipairs(functionDoc.frameParamsOrder  or {}) do
		local param = frameParams[paramKey]
		if param then
			param = utilsTable.merge({}, param, {
				param = paramKey
			})
			table.insert(orderedParams, param)
		end
		seenParams[paramKey] = true
	end
	
	-- Then, any remaining params
	local paramKeys = utilsTable.keys(frameParams or {})
	local seenParamKeys = utilsTable.keys(seenParams)
	local remainingParamNames = utilsTable.difference(paramKeys, seenParamKeys)
	for i, paramKey in ipairs(remainingParamNames) do
		local param = frameParams[paramKey]
		param = utilsTable.merge({}, param, {
			param = paramKey
		})
		table.insert(orderedParams, param)
	end

	return orderedParams
end

function h.printFunctionDescription(functionDoc)
	local result = ""
	if functionDoc.desc then
		result = "\n" .. mw.getCurrentFrame():preprocess(functionDoc.desc) .. "\n"
	end
	return result
end

function h.printParamsDescription(functionDoc)
	if not functionDoc.params or #functionDoc.params == 0 then
		return ""
	end
	local allParams = utilsTable.flatten(functionDoc.params)
	local paramDefinitions = {}
	for _, param in ipairs(allParams) do
		if param.schema then
			table.insert(paramDefinitions, p.schema(param.schema, param.name))
		end
	end
	if #paramDefinitions == 0 then
		return ""
	end
	local paramList = utilsMarkup.list(paramDefinitions)
	local heading = "" .. '<p class="module-documentation__function-subheading">' .. s("headers.parameters") .. "</p>\n"
	return heading .. paramList
end

function h.printReturnsDescription(returns)
	if not returns or #returns == 0 then
		return ""
	end
	local returnsList = utilsMarkup.bulletList(returns)
	local heading = "\n" .. '<p class="module-documentation__function-subheading">' .. s("headers.returns") .. "</p>\n"
	local result = heading .. mw.getCurrentFrame():preprocess(returnsList)
	return result
end

function h.printFrameParams(doc)
	if not doc.frameParams then
		return ""
	end
	local params = h.sortFrameParams(doc, doc.frameParams)

	local heading = '<p class="module-documentation__function-subheading">' .. s("headers.parameters") .. "</p>\n"
	local paramTable = DocumentationTemplate.params(params)
	return heading..paramTable
end

function h.printFunctionCases(doc, moduleName)
	if not doc.cases or #doc.cases == 0 then
		return ""
	end
	local result = "\n" .. '<p class="module-documentation__function-subheading">' .. s("headers.examples") .. "</p>\n"
	
	local inputColumn = s("headers.input")
	local outputColumn = s("headers.output")
	local resultColumn = s("headers.result")
	local statusColumn = utilsMarkup.tooltip(s("headers.status"), s("explainStatusColumn"))
	
	local headerCells = utilsTable.compact({
		"#",
		inputColumn, 
		not doc.cases.resultOnly and outputColumn or nil, 
		not doc.cases.outputOnly and resultColumn or nil,
		doc.frameParams and "Categories added" or nil,
		statusColumn,
	})
	local tableData = {
		hideEmptyColumns = true,
		rows = {
			{
				header = true,
				cells = headerCells,
			}
		},
	}
	for _, case in ipairs(doc.cases) do
		local caseRows = h.case(doc, case, moduleName, doc.cases)
		tableData.rows = utilsTable.concat(tableData.rows, caseRows)
	end
	result = result .. utilsLayout.table(tableData) .. "\n"
	return result
end

h.snippets = utilsFunction.memoize(function(modulePage)
	local snippetPagename = modulePage .. "/Documentation/Snippets"
	if not utilsPage.exists(snippetPagename) then
		return nil
	end
	local snippets = {}
	local snippetPage = mw.title.new(snippetPagename)
	local module = require(snippetPagename)
	local text = snippetPage:getContent()
	local lexLines = lex(text)
	local names = {}
	local starts = {}
	local ends = {}
	for i, line in ipairs(lexLines) do
		if line[1] and line[1].type == "keyword" and line[1].data == "function" then
			local isOpenParens = function(token)
				return utilsString.startsWith(token.data, "(")
			end
			local fnName = line[utilsTable.findIndex(line, isOpenParens) - 1].data
			table.insert(starts, i + 1)
			table.insert(names, fnName)
		end
		if #line == 1 and line[1].type == "keyword" and line[1].data == "end" then
			table.insert(ends, i - 1)
		end
	end
	local lines = utilsString.split(text, "\n")
	for i, fnName in ipairs(names) do
		local fnLines = utilsTable.slice(lines, starts[i], ends[i])
		fnLines = utilsTable.map(fnLines, function(line)
			line = string.gsub(line, "^\t", "")
			line = string.gsub(line, "\t", "  ")
			return line
		end)
		local fnCode = table.concat(fnLines, "\n")
		snippets[fnName] = {
			fn = module[fnName],
			code = fnCode,
		}
	end
	return snippets
end)

function h.case(doc, case, moduleName, options)
	local caseId = caseCounter.increment()
	local rows = {}
	local input, outputs
	local snippet = case.snippet and doc.snippets[tostring(case.snippet)]
	if snippet then
		input = utilsMarkup.lua(snippet.code, { wrapLines = false })
		outputs = {snippet.fn()}
	elseif doc.frameParams and case.input then
		input = utilsMarkup.pre(case.input)
		outputs = {mw.getCurrentFrame():preprocess(case.input)}
	elseif doc.frameParams and case.args then
		local orderedParams = h.sortFrameParams(doc, doc.frameParams)
		input, rawinput = h.printFrameInput(moduleName, doc.name, orderedParams, case.args)
		local output = mw.getCurrentFrame():preprocess(rawinput)
		if output == "" then
			output = " " -- forces output column to always show for #invoke examples
		end
		outputs = {output}
	elseif case.args then
		input = h.printInput(doc, case.args)
		outputs = h.evaluateFunction(doc.fn, case.args)
	else
		return {}
	end
	
	-- Raw output doesn't make as much sense for #invoke functions so we disable it by default
	if doc.frameParams and options.resultOnly == nil then
		options.resultOnly = true
	end
	
	local outputCount = doc.frameParams and 1 or #doc.returns
	if options.outputOnly == nil then
		-- showing result for any type other than string or nil is pretty much useless compared to showing output
		options.outputOnly = outputCount == 1 and type(outputs[1]) ~= "string" and outputs[1] ~= nil
	end
	for i = 1, outputCount do
		local outputData, resultData, statusData, categories = h.evaluateOutput(outputs[i], case.expect and case.expect[i])
		local categoryList = categories and utilsMarkup.bulletList(categories)
		table.insert(rows, utilsTable.compact({
			not options.resultOnly and outputData or nil,
			not options.outputOnly and resultData or nil,
			doc.frameParams and categoryList or nil,
			case.expect and statusData,
		}))
	end
	rows[1] = rows[1] or {}
	table.insert(rows[1], 1, {
		content = input,
		rowspan = outputCount,
	})
	table.insert(rows[1], 1, {
		content = utilsMarkup.anchor("case-"..tostring(caseId), caseId),
		rowspan = outputCount,
	})
	if case.desc then
		table.insert(rows, 1, {
			{
				header = true,
				colspan = -1,
				styles = {
					["text-align"] = "left"
				},
				content = case.desc,
			}
		})
	end
	return rows
end

function h.printFrameInput(moduleName, functionName, params, args)
	local result = string.format("{{#invoke:%s|%s", moduleName, functionName)
	local seenArgs = {}
	-- First, arguments of known parameters
	for i, param in ipairs(params) do
		local paramKey = param.param
		local argValue = args[paramKey]
		
		if argValue then
			seenArgs[paramKey] = true
			result = result..h.printFrameArg(paramKey, argValue)
		end
	end
	-- Then arguments of unknown parameters, used by Module:Franchise List for example
	for argKey, argValue in pairs(args) do
		if not seenArgs[argKey] then
			result = result..h.printFrameArg(argKey, argValue)
		end
	end
	result = result.."}}"
	return utilsMarkup.pre(result), result
end
function h.printFrameArg(param, arg)
	local result = "|"
	if type(param) == "string" then
		if arg ~= "" then
			arg = " "..arg
		end
		result = result..param.."="..arg
	else
		result = result..arg
	end
	return result
end

function h.printInput(doc, argsList)
	local result = doc.name
	local allArgs = utilsTable.flatten(argsList)
	local lineWrap = #allArgs == 1 and type(allArgs[1]) == "string"
	for i, args in ipairs(argsList) do
		result = result .. "(" .. h.printInputArgs(args, doc.params[i], lineWrap) .. ")"
	end
	return utilsMarkup.lua(result, {
		wrapLines = lineWrap
	})
end
function h.printInputArgs(args, params)
	args = args or {}
	local argsText = {}
	for i = 1, math.max(#params, #args) do
		local argText = args[i] == nil and "nil" or utilsTable.print(args[i])
		if not (#args == 1 and type(args[i]) == "table") then
			argText = string.gsub(argText, "\n", "\n  ") --ensures proper indentation of multiline table args
		end
		table.insert(argsText, argText)
	end
	
	-- Trim nil arguments off the end so long as they're optional
	local argsText = utilsTable.dropRightWhile(argsText, function(argText, i)
		return argText == "nil" and params[i] and params[i].schema and not params[i].schema.required
	end)
	
	local result = table.concat(argsText, ", ")
	local lines = mw.text.split(result, "\n")
	-- print multiline if there's multiple args with at least one table or a line longer than the max length
	if #args > 1 and (#lines > 1 or #lines[1] > MAX_ARGS_LENGTH) then
		result = "\n  " .. table.concat(argsText, ",\n  ") .. "\n"
	end
	
	return result
end

function h.evaluateFunction(fn, args)
	for i = 1, #args - 1 do
		fn = fn(unpack(args[i]))
	end
	return {fn(unpack(args[#args]))}
end

function h.evaluateOutput(output, expected)
	local formattedOutput = h.formatValue(output)
	local outputData = formattedOutput
	local resultData = nil
	local categories
	if type(output) == "string" then
		resultData = utilsMarkup.killBacklinks(output)
		resultData, categories = utilsMarkup.stripCategories(output)
	elseif output == nil then
		resultData = " " -- #invoke treats nil as empty space, so we want to show blank as the output
	else
		resultData = tostring(output)
	end
	if type(expected) == "string" then
		expected = string.gsub(expected, "\t", "")
	end
	local passed = utilsTable.isEqual(expected, output)
	local statusData = (expected ~= nil or output == nil) and h.printStatus(passed) or nil
	if statusData and not passed then
		local expectedOutput = h.formatValue(expected)
		outputData = utilsLayout.table({
			hideEmptyColumns = true,
			styles = { width = "100%" },
			rows = {
				{ 
					{ 
						header = true, 
						content = "Expected", 
						styles = { width = "1em"}, -- "shrink-wraps" this column
					}, 
					{ content = expectedOutput },
				},
				{
					{ header = true, content = "Actual" }, 
					{ content = formattedOutput },
				},
			}
		})
	end
	return outputData, resultData, statusData, categories
end

function h.formatValue(val)
	if type(val) == "string" then
		val = string.gsub(val, "&#", "&#38;#") -- show entity codes
		val = utilsTable.print(val)
		val = string.gsub(val, "\n", "\\n\n") -- Show newlines	
	end
	return utilsMarkup.lua(val)
end

function h.printStatus(success)
	local img = success and "[[File:Green check.svg|16px|center|link=]]" or "[[File:TFH Red Link desperate.png|48px|center|link=]]"
	local msg = success and s("explainStatusGood") or s("explainStatusBad")
	img = utilsMarkup.tooltip(img, msg)
	local cat = ""
	if not success and mw.title.getCurrentTitle().subpageText ~= "Documentation" then
		cat = utilsMarkup.category(s("failingTestsCategory"))
	end
	return img .. cat
end

function h.getCategories(type)
	local title = mw.title.getCurrentTitle()
	local isDoc = title.subpageText == "Documentation"
	local moduleTitle = isDoc and mw.title.new(title.baseText) or title
	local isData = moduleTitle.subpageText == "Data"
	local isUtil = utilsString.startsWith(moduleTitle.text, "Utils")
	local isSubmodule = moduleTitle.subpageText ~= moduleTitle.text
	if type == "submodule" then
		isSubmodule = true
	end

	if isDoc and isData then
		return {s("cat.dataDoc")}
	end
	if isDoc and isSubmodule then
		return {s("cat.submoduleDoc")}
	end
	if isDoc then
		return  {s("cat.moduleDoc")}
	end
	
	if isData then
		return {s("cat.data")}
	end
	if isSubmodule then
		return {s("cat.submodules")}
	end
	if isUtil then
		return {s("cat.modules"), s("cat.utilityModules")}
	end

	return {s("cat.modules")}
end

i18n.loadStrings({
	en = {
		failingTestsCategory = "Category:Modules with failing tests",
		explainStatusColumn = "Indicates whether a feature is working as expected",
		explainStatusGood = "This feature is working as expected",
		explainStatusBad = "This feature is not working as expected",
		headers = {
			parameters = "Parameters",
			returns = "Returns",
			examples = "Examples",
			input = "Input",
			output = "Output",
			result = "Result",
			categories = "Categories",
			categoriesAdded = "Categories added",
			status = "Status",
		},
		cat = {
			modules = "Category:Modules",
			submodules = "Category:Submodules",
			utilityModules = "Category:Utility Modules",
			moduleDoc = "Category:Module Documentation",
			submoduleDoc = "Category:Submodule Documentation",
			data = "Category:Module Data",
			dataDoc = "Category:Module Data Documentation",
		}
	}
})

function p.Schemas() 
	return {
		Documentation = {
			required = true,
			oneOf = {
				{ _ref = "#/definitions/functions" },
				{ _ref = "#/definitions/sections" },
			},
			definitions = {
				functions = {
					desc = "Map of function names to function documentation. Functions are printed in the order in which they appear in the source code. A function's documentation object has the following properties, depending on whether it is a template function or a module function.",
					type = "map",
					keyPlaceholder = "function name",
					keys = { type = "string" },
					values = {
						oneOf = {
							{
								_tabName = "Module function",
								type = "record",
								desc = "A function which is to be invoked by other modules.",
								properties = {
									{
										name = "wip",
										type = "boolean",
										desc = "Tags the function doc with [[Template:WIP]]."
									},
									{
										name = "desc",
										type = "string",
										desc = "Description of the function. Use only when clarification is needed—usually the param/returns/cases doc speaks for itself.",
									},
									{
										name = "params",
										type = "array",
										items = { type = "string" },
										desc = "An array of parameter names. Integrates with [[Module:Documentation#Schemas|Schemas]].",
									},
									{
										name = "_params",
										type = "array",
										items = {
											type = "array", 
											items = { type = "string" },
										},
										desc = "To be specified for functions with an alternative [[Guidelines:Modules#Higher Order Function|higher-order function]]."
									},
									{
										name = "returns",
										desc = "A string describing the return value of the function, or an array of such strings if the function returns multiple values",
										oneOf = { 
											{ type = "string" },
											{ type = "array", items = { type = "string" } },
										},
									},
									{
										name = "cases",
										desc = "A collection of use cases that double as test cases and documented examples.",
										allOf = {
											{
												_ref = "#/definitions/casesOptions"
											},
											{
												type = "array",
												items = {
													oneOf = {
														["snippet"] = {
															type = "record",
															properties = {
																{
																	name = "desc",
																	type = "string",
																	desc = "A description of the use case.",
																},
																{
																	name = "snippet",
																	required = true,
																	oneOf = {
																		{ type = "number" },
																		{ type = "string" }
																	},
																	desc = "See [[Module:UtilsTable]] and [[Module:UtilsTable/Documentation/Snippets]] for examples of usage.",
																},
																{
																	name = "expect",
																	type = "any",
																	desc = "The expected return value, which is deep-compared against the actual value to determine pass/fail status. Or, an array of such items if there are multiple return values.",
																},
															}
														},
														["args"] = {
															type = "record",
															properties = {
																{
																	name = "desc",
																	type = "string",
																	desc = "A description of the use case.",
																},
																{
																	name = "args",
																	_ref = "#/definitions/args",
																},
																{
																	name = "expect",
																	type = "any",
																	desc = "The expected return value, which is deep-compared against the actual value to determine pass/fail status. Or, an array of such items if there are multiple return values.",
																},
															},
														},
													}
												},
											}
									},
								},
								},
							},
							{
								_tabName = "Template function",
								type = "record",
								desc = "A function which is to be invoked by templates using the #invoke parser function.",
								properties = {
									{
										name = "wip",
										type = "boolean",
										desc = "Tags the function doc with [[Template:WIP]]."
									},
									{
										name = "desc",
										type = "string",
										desc = "Description of the function - which templates should use it and when.",
									},
									{
										name = "frameParamsFormat",
										type = "string",
										enum = {"singleLine", "multiLine"},
										desc = "Indicates how the #invoke parameters should be laid out.",
									},
									{
										name = "frameParamsOrder",
										desc = "Determines the order that <code>frameParams</code> should appear in.",
										type = "array",
										items = {
											type = "string",
										},
									},
									{
										name = "frameParams",
										desc = "Use this instead of <code>params</code> when documenting a template-based function. See [[Module:Error]] for example.",
										type = "map",
										keys = {
											oneOf = {
												{ type = "number" },
												{ type = "string" }
											},
										},
										values = {
											type = "record",
											properties = {
												{
													name = "name",
													type = "string",
													desc = "Use this to assign names to positional parameters.",
												},
												{
													name = "required",
													type = "boolean",
													desc = "Indicates a required parameter.",
												},
												{
													name = "enum",
													type = "array",
													items = { type = "string" },
													desc = "Indicates which string values are allowed for this parameter.",
												},
												{
													name = "default",
													type = "string",
													desc = "Default value for the parameter."
												},
												{
													name = "desc",
													type = "string",
													desc = "Description of the parameter."
												},
												{
													name = "inline",
													type = "boolean",
													desc = 'If true, then the parameter will be printed on the same line as the previous parameter, even if <code>frameParamsFormat</code> is set to <code>multiLine</code>. See [[Module:Comment]] for example.',
												},
												{
													name = "spaceBefore",
													type = "boolean",
													desc = "If true, adds an extra newline before printing the parameter. See [[Module:Comment]] for a usage example.",
												},
											}
										},
									},
									{
										name = "cases",
										desc = "A collection of use cases that double as test cases and documented examples.",
										allOf = {
											{
												_ref = "#/definitions/casesOptions"
											},
											{
												type = "array",
												items = {
													oneOf = {
														{
															_tabName = "args",
															type = "record",
															properties = {
																{
																	name = "desc",
																	type = "string",
																	desc = "A description of the example/test case.",
																},
																{
																	name = "args",
																	_ref = "#/definitions/args",
																},
															},
														},
														{
															_tabName = "input",
															type = "record",
															properties = {
																{
																	name = "desc",
																	type = "string",
																	desc = "A description of the example/test case.",
																},
																{
																	name = "input",
																	type = "string",
																	desc = "Raw input for the example. See [[Module:Franchise]] for usage.",
																},
															},
														},
													},
												},
											},
										}
									},
								},
							},
						},
					},
				},
				sections = {
					type = "record",
					properties = {
						{
							desc = "Divides the documentation into sections. See [[Module:UtilsTable]] for a usage example.",
							name = "sections",
							type = "array",
							required = true,
							items = {
								type = "record",
								properties = {
									{
										name = "heading",
										type = "string",
										desc = "Section heading"
									},
									{
										name = "section",
										required = true,
										oneOf = {
											{ type = "string" },
											{
												_ref = "#/definitions/functions",
												required = true,
											},
										},
									}
								}
							},
						},
					},
				},
				args = {
					name = "args",
					allOf = {
						{
							type = "array",
							items = { type = "any" },
						},
						{
							type = "map",
							keys = {
								oneOf = {
									{
										type = "string"
									},
									{
										type = "number"
									},
								},
							},
							values = {
								type = "string"
							},
						},
					},
					desc = "An array of arguments to pass to the function.",
				},
				casesOptions = {
					type = "record",
					properties = {
						{
							name = "resultOnly",
							type = "boolean",
							desc = "When <code>true</code>, displays only rendered wikitext as opposed to raw function output. Useful for functions returning strings of complex wikitext.",
						},
						{
							name = "outputOnly",
							type = "boolean",
							desc = "When <code>true</code>, displays only the raw output of the function (opposite of <code>resultOnly</code>). Enabled by default for functions returning data of type other than <code>string</code>."
						},
					},
				},
			}
		},
	}
end

return p