Module:Dialogue

From Final Fantasy XIV Online Wiki
Jump to navigation Jump to search

This module implements {{Dialogue}}.


-- Function to process Forename and Surname placeholders
local function processNames(text)
	-- Define name types and their patterns
	local nameTypes = {"Forename", "Surname"}
	local patterns = {
		{"'''(%s)'''", "<u>%1</u>"},
		{"''(%s)''", "<u>%1</u>"},
		{"%[(%s)%]", "<u>%1</u>"},
		{'"(%s)"', "<u>%1</u>"},
		{"(%W)(%s)(%W)", "%1<u>%2</u>%3"},
		{"^(%s)(%W)", "<u>%1</u>%2"},
		{"(%W)(%s)$", "%1<u>%2</u>"}
	}

	-- Process each name type with all patterns
	for _, name in ipairs(nameTypes) do
		for _, pattern in ipairs(patterns) do
			local search = pattern[1]:gsub("%%s", name)
			local replace = pattern[2]
			text = text:gsub(search, replace)
		end

		-- Handle name as entire line
		if text == name then
			text = "<u>" .. name .. "</u>"
		end
	end

	return text
end

-- #region Parsing - Converting the input line format into data

--- Parses dialogue speaker strings into speaker name and link
local function parseSpeaker(str)
	-- Remove surrounding bold markup ('''), if any
	str = mw.text.trim(str:gsub("'''", ""))

	-- Determine speaker name and link
	local speakerName, speakerLink
	if str:match("@") then
		-- Speaker name and link are different (format: "Link@Name" or "x@Name" for no link)
		speakerLink, speakerName = str:match("^(.-)@(.+)$")
		speakerLink = mw.text.trim(speakerLink)
		speakerName = mw.text.trim(speakerName)
	else
		-- Speaker name and link are the same
		-- TODO: if we ever get access to semantic scribunto, maybe do a
		--       "Has canonical name" query for the speaker here?
		speakerName = str
		speakerLink = str
	end

	if speakerName == "System" then
		-- If speaker is "System", treat this as speakerless dialogue
		speakerName = nil
		speakerLink = nil
	elseif speakerLink == "x" then
		-- Link override of "x" disables linking
		speakerLink = nil
	end

	return speakerName, speakerLink
end

--- Parses a single line of input and returns all its information
local function parseLine(line)
	line = mw.text.trim(line)

	-- End-of-option delimiters
	if line == "---" or line == "–––" or line == "—" then
		return {type = "section-end"}
	end

	-- Choices
	local choiceText = line:match("^>%s*(.+)$")
	if choiceText then
		return {type = "choice", text = processNames(choiceText)}
	end

	-- Other special directives
	if line:sub(1, 1) == "-" then
		-- Line is a special directive
		return {type = "directive", value = line:sub(2)}
	end

	-- Mediawiki headings
	local headingMarker, headingTitle = line:match("^(=+)(.+)%1$")
	if headingMarker and headingTitle then
		return {type = "heading", level = #headingMarker, title = headingTitle}
	end

	-- Anything else is an actual dialogue line
	-- Determine who's speaking
	local speakerName, speakerLink
	local speakerMatch, text = line:match("^(.-):%s*(.+)$")
	if speakerMatch and text then
		speakerName, speakerLink = parseSpeaker(speakerMatch)
	else
		-- No speaker specified, so use the entire line as the message text
		text = line
	end

	-- Process forename/surname/etc placeholders in dialogue text
	text = processNames(text)

	if speakerName then
		return {type = "dialogue", name = speakerName, link = speakerLink, text = text}
	else
		return {type = "system", text = text}
	end
end

--- Sequentially parses each line of input text and returns an iterator for the parsed data.
local function parsedLinesIter(text)
	local lines = mw.text.split(text, "\n")
	local i = 0
	return function()
		local line
		repeat
			-- advance the iterator one line
			i = i + 1
			line = lines[i]

			-- if there is no next line, we've reached the end - return nothing
			if not line then
				return nil
			end

			-- trim leading/trailing whitespace
			line = mw.text.trim(line)
		until line ~= "" -- continue advancing past any empty line

		-- parse and return the line
		return parseLine(line)
	end
end

-- #endregion

-- #region Rendering - Converting parsed data into markup/wikitext

local userContentFrame = mw.getCurrentFrame():newChild({})
--- Expands templates/magic words/etc in user input.
local function preprocess(text)
	return userContentFrame:preprocess(text)
end

-- declare the renderLine function early since we need to recurse back to it
-- from other functions - lua doesn't have function declaration hoisting, we
-- have to do it manually
local renderLine

--- Renders an error message.
local function renderError(text)
	return mw.html.create("p")
		:node(mw.html.create("strong"):addClass("error")
			:wikitext("Error: " .. text)
		)
end

--- Renders a line of dialogue from the system rather than a speaker.
local function renderSystemMessage(line)
	return mw.html.create("div"):addClass("dialogue-line dialogue-line--system")
		:wikitext(preprocess(line.text))
end

--- Renders multiple consecutive lines of dialogue from the same speaker.
--- Consumes lines from the input until it finds a different speaker or a
--- non-dialogue line.
local function renderSpeakerDialogueGroup(line, next)
	-- Keep track of the speaker information we're starting with
	local speakerName = line.name
	local speakerLink = line.link

	-- Assemble the container for this speaker's dialogue
	local childContainer = mw.html.create("div"):addClass("dialogue-container")
	local wrapper = mw.html.create("div"):addClass("dialogue-box dialogue-box--speaker")
		:node(mw.html.create("div"):addClass("dialogue-box-header")
			-- Render the speaker's name and link
			:wikitext(
				speakerLink
					and "'''[[" .. speakerLink .. "|" .. speakerName .. "]]'''"
					or "'''" .. speakerName .. "'''"
			)
		)
		:node(childContainer)

	while true do
		-- Add the current line of dialogue to the container
		childContainer:node(mw.html.create("div"):addClass("dialogue-line")
			:wikitext(preprocess(line.text))
		)

		line = next()
		if
			not line                     -- if there's no more lines left,
			or line.type ~= "dialogue"   -- or the next line isn't dialogue,
			or line.name ~= speakerName  -- or the speaker's name is different,
			or line.link ~= speakerLink  -- or the speaker's link is different,
		then                             -- then we're done with this container
			return wrapper, line
		end

		-- loop and continue processing lines from the same speaker
	end
end

--- Renders a group of optional dialogue. Consumes lines from the input until
--- it finds the "optional end" directive or end of input.
local function renderOptionalDialogue(line, next)
	local childContainer = mw.html.create("div"):addClass("dialogue-container mw-collapsible-content")
	local wrapper = mw.html.create("div"):addClass("dialogue-box dialogue-box--optional mw-collapsible mw-collapsed")
		:node(mw.html.create("div"):addClass("dialogue-box-header mw-collapsible-toggle")
			:wikitext("''Optional dialogue''")
		)
		:node(childContainer)

	line = next() -- move past the "optional start" marker
	while true do
		if not line then
			-- no more lines, return what we have
			return wrapper, line
		elseif line.type == "directive" and line.value == "optional end" then
			-- discard end directive, return what we have + resume parent container from the line after the end directive
			return wrapper, next()
		end

		-- otherwise, render the line into the child container and move to the next
		local rendered
		rendered, line = renderLine(line, next)
		childContainer:node(rendered)
	end
end

--- Renders a group of choices the player can make and the dialogue that follows
--- each choice. Consumes lines from the input until it sees the "section end"
--- marker or end of input.
local function renderChoices(line, next)
	-- TODO: rename all these classes from "option" to "choice" and make them make sense
	local childContainer = mw.html.create("div"):addClass("dialogue-container")
	local dialogueChoiceLine = mw.html.create("div"):addClass("dialogue-line dialogue-line--choice")
		:wikitext(preprocess(line.text))
	local wrapper = mw.html.create("div"):addClass("dialogue-container")
		:node(dialogueChoiceLine)
		:node(childContainer)

	line = next()
	local hasResponse = false
	while true do
		if not line then
			-- no more lines, return what we have
			return wrapper, line
		elseif line.type == "choice" then
			-- end of this choice and beginning of another - return what we have
			-- and have the parent context resume, starting with the next choice
			-- TODO: this is hacky
			return wrapper, line
		elseif line.type == "section-end" then
			-- reached end of choice, return what we have and resume parent from
			-- line after section end
			return wrapper, next()
		end

		if not hasResponse then
			hasResponse = true
			wrapper:addClass("mw-collapsible mw-collapsed dialogue-choice-has-response")
			childContainer:addClass("mw-collapsible-content")
			dialogueChoiceLine:addClass("mw-collapsible-toggle")
		end

		-- otherwise, render the line into the choice container and advance
		local renderedLine
		renderedLine, line = renderLine(line, next)
		childContainer:node(renderedLine)
	end
end

--- Renders dialogue that occurs in a cutscene. Consumes lines from the input
--  until it sees the "cutscene end" marker or end of input.
local function renderCutscene(line, next)
	local childContainer = mw.html.create("div"):addClass("dialogue-container")

	-- Check whether this cutscene is voiced or not and show the right message
	local cutsceneStartWikitext
	if line.value == "voiced cutscene start" then
		cutsceneStartWikitext = "[[File:Voiced cutscene icon.png|24px|link=]] ''Start of voiced cutscene.''"
	else
		cutsceneStartWikitext = "[[File:Cutscene icon.png|24px|link=]] ''Start of cutscene.''"
	end

	local wrapper = mw.html.create("div"):addClass("dialogue-cutscene-wrapper")
		:node(mw.html.create("div"):addClass("dialogue-line dialogue-line--cutscene")
			:wikitext(cutsceneStartWikitext)
		)
		:node(childContainer)
		:node(mw.html.create("div"):addClass("dialogue-line dialogue-line--cutscene")
			:wikitext("''End of cutscene.''")
		)

	line = next()
	while true do
		if not line then
			return wrapper, line
		elseif line.type == "directive" and line.value == "cutscene end" then
			return wrapper, next()
		end

		local renderedLine
		renderedLine, line = renderLine(line, next)
		childContainer:node(renderedLine)
	end
end

--- Renders special directives, such as cutscene start/end and the end of a quest.
local function renderDirective(line, next)
	if line.value == "cutscene start" or line.value == "voiced cutscene start" then
		return renderCutscene(line, next)
	elseif line.value == "cutscene end" then
		-- this is handled by renderCutscene; if we encounter this
		-- directive here then it's unmatched
		return renderError("Extraneous <code>-cutscene end</code>"), next()
	elseif line.value == "optional start" then
		return renderOptionalDialogue(line, next)
	elseif line.value == "optional end" then
		-- this is handled by renderOptionalDialogue; if we encounter this
		-- directive here then it's unmatched
		return renderError("Extraneous <code>-optional end</code>"), next()
	elseif line.value == "quest complete" then
		return
			mw.html.create("div"):addClass("dialogue-quest-complete")
				:wikitext("[[File:Quest Complete.png|500px|link=]]"),
			next()
	end

	-- Unknown directive
	return renderError("Unknown directive: " .. line.value)
end

--- Renders a line, branching out into containers as necessary.
-- NOTE: this function is already declared as local at the top of this section
--       because we need to recurse into it from earlier functions
function renderLine(line, next)
	if line.type == "dialogue" then
		-- The renderer for spoken dialogue will advance through multiple
		-- lines and group dialogue lines from the same speaker together,
		-- returning both the rendered result *and* the line that comes next
		return renderSpeakerDialogueGroup(line, next)

	elseif line.type == "system" then
		return renderSystemMessage(line), next()

	elseif line.type == "directive" then
		return renderDirective(line, next)

	elseif line.type == "choice" then
		return renderChoices(line, next)

	elseif line.type == "section-end" then
		-- Section ends are consumed by the relevant container rendering
		-- functions when needed, so any that get picked up here don't
		-- actually close anything
		return renderError("Extraneous <code>---</code> section ending"), next()

	elseif line.type == "heading" then
		return mw.html.create("h" .. line.level):wikitext(preprocess(line.title)), next()
	end

	return renderError("Unknown line type ".. line.type), next()
end

-- #endregion

-- Exports
local p = {}

--- Main parse/render loop (exported for ease of debugging)
function p.render(text)
	local next = parsedLinesIter(text)
	local line = next() -- start with first line

	local outputContainer = mw.html.create("div"):addClass("dialogue-container")

	while line do
		local renderedLine
		renderedLine, line = renderLine(line, next)
		outputContainer:node(renderedLine)
	end

	return outputContainer
end

-- Template/{{#invoke}} entrypoint
function p.main(frame)
	userContentFrame = frame:newChild({})
	local text = require("Module:Arguments").getArgs(frame)[1]
	-- replace <nowiki> strip markers with their contents
	text = mw.text.unstripNoWiki(text or "")
	-- decode the HTML entities nowiki leaves behind
	text = mw.text.decode(text)
	return p.render(text)
end

return p