Module:Dialogue: Difference between revisions
Jump to navigation
Jump to search
Erin Umbreon (talk | contribs) don't emit apostrophe syntax for bold/italic unless it's part of the input, use CSS for other stuff |
set "Has related NPC" and "Has cutscene NPC" |
||
| Line 70: | Line 70: | ||
local function processInlineText(text) | local function processInlineText(text) | ||
-- Preprocess template expansion/etc | -- Preprocess template expansion/etc | ||
text = preprocess(text) | text = preprocess(text) or "" | ||
-- Handle placeholders | -- Handle placeholders | ||
| Line 138: | Line 138: | ||
end | end | ||
--- Parses a single line of input and returns all its information | local function updateSpeakerData (speakerLink, data) | ||
local currentData = SpeakerData[speakerLink] | |||
if not currentData then | |||
currentData = {} | |||
SpeakerData[speakerLink] = currentData | |||
end | |||
for k, v in pairs(data) do | |||
currentData[k] = v | |||
end | |||
end | |||
--- Parses a single line of input and returns all its information. | |||
local function parseLine(line) | local function parseLine(line) | ||
line = mw.text.trim(line) | line = mw.text.trim(line) | ||
| Line 194: | Line 205: | ||
local lines = mw.text.split(text, "\n") | local lines = mw.text.split(text, "\n") | ||
local i = 0 | local i = 0 | ||
local inCutscene = false | |||
return function() | return function() | ||
local line | local line | ||
| Line 210: | Line 222: | ||
until line ~= "" -- continue advancing past any empty line | until line ~= "" -- continue advancing past any empty line | ||
return parseLine(line) | return parseLine(line) | ||
end | end | ||
| Line 218: | Line 229: | ||
-- #region Rendering - Converting parsed data into markup/wikitext | -- #region Rendering - Converting parsed data into markup/wikitext | ||
-- functions for handling rendering context | |||
local function contains(array, val) | |||
for _, v in ipairs(array) do | |||
if v == val then | |||
return true | |||
end | |||
end | |||
return false | |||
end | |||
local function extend(array, val) | |||
local newArray = {} | |||
for _, v in ipairs(array) do | |||
table.insert(newArray, v) | |||
end | |||
table.insert(newArray, val) | |||
return newArray | |||
end | |||
-- declare the renderLine function early since we need to recurse back to it | -- declare the renderLine function early since we need to recurse back to it | ||
| Line 278: | Line 308: | ||
--- Consumes lines from the input until it finds a different speaker or a | --- Consumes lines from the input until it finds a different speaker or a | ||
--- non-dialogue line. | --- non-dialogue line. | ||
local function renderSpeakerDialogueGroup(line, next) | local function renderSpeakerDialogueGroup(line, context, next) | ||
-- Keep track of the speaker information we're starting with | -- Keep track of the speaker information we're starting with | ||
local speakerName = line.name | local speakerName = line.name | ||
local speakerLink = line.link | local speakerLink = line.link | ||
-- Update speaker data for this speaker | |||
if contains(context, "voiced cutscene") or contains(context, "cutscene") then | |||
updateSpeakerData(speakerLink, {speaksInCutscene = true}) | |||
else | |||
updateSpeakerData(speakerLink, {speaksNormally = true}) | |||
end | |||
-- Assemble the container for this speaker's dialogue | -- Assemble the container for this speaker's dialogue | ||
| Line 316: | Line 353: | ||
--- Renders a group of optional dialogue. Consumes lines from the input until | --- Renders a group of optional dialogue. Consumes lines from the input until | ||
--- it finds the "optional end" directive or end of input. | --- it finds the "optional end" directive or end of input. | ||
local function renderOptionalDialogue(line, next) | local function renderOptionalDialogue(line, context, next) | ||
local childContainer = mw.html.create("div"):addClass("dialogue-container mw-collapsible-content") | 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") | local wrapper = mw.html.create("div"):addClass("dialogue-box dialogue-box--optional mw-collapsible") | ||
| Line 328: | Line 365: | ||
wrapper:addClass("mw-collapsed") | wrapper:addClass("mw-collapsed") | ||
end | end | ||
-- assemble child context | |||
local innerContext = extend(context, "optional dialogue") | |||
line = next() -- move past the "optional start" marker | line = next() -- move past the "optional start" marker | ||
| Line 341: | Line 381: | ||
-- otherwise, render the line into the child container and move to the next | -- otherwise, render the line into the child container and move to the next | ||
local rendered | local rendered | ||
rendered, line = renderLine(line, next) | rendered, line = renderLine(line, innerContext, next) | ||
childContainer:node(rendered) | childContainer:node(rendered) | ||
end | end | ||
| Line 349: | Line 389: | ||
--- each choice. Consumes lines from the input until it sees the "section end" | --- each choice. Consumes lines from the input until it sees the "section end" | ||
--- marker or end of input. | --- marker or end of input. | ||
local function renderChoices(line, next) | local function renderChoices(line, context, next) | ||
local currentLevel = line.level | local currentLevel = line.level | ||
-- TODO: rename all these classes from "option" to "choice" and make them make sense | -- TODO: rename all these classes from "option" to "choice" and make them make sense | ||
| Line 388: | Line 428: | ||
-- otherwise, render the line into the choice container and advance | -- otherwise, render the line into the choice container and advance | ||
local renderedLine | local renderedLine | ||
renderedLine, line = renderLine(line, next) | renderedLine, line = renderLine(line, context, next) -- TODO: add choice to context before passing to inner renders | ||
childContainer:node(renderedLine) | childContainer:node(renderedLine) | ||
end | end | ||
| Line 395: | Line 435: | ||
--- Renders dialogue that occurs in a cutscene. Consumes lines from the input | --- Renders dialogue that occurs in a cutscene. Consumes lines from the input | ||
-- until it sees the "cutscene end" marker or end of input. | -- until it sees the "cutscene end" marker or end of input. | ||
local function renderCutscene(line, next) | local function renderCutscene(line, context, next) | ||
local childContainer = mw.html.create("div"):addClass("dialogue-container") | local childContainer = mw.html.create("div"):addClass("dialogue-container") | ||
| Line 414: | Line 454: | ||
:wikitext("End of cutscene.") | :wikitext("End of cutscene.") | ||
) | ) | ||
-- construct inner context to pass to inner render functions | |||
local innerContext = extend( | |||
context, | |||
(line.value == "voiced cutscene start") and "voiced cutscene" or "cutscene" | |||
) | |||
line = next() | line = next() | ||
| Line 424: | Line 470: | ||
local renderedLine | local renderedLine | ||
renderedLine, line = renderLine(line, next) | renderedLine, line = renderLine(line, innerContext, next) | ||
childContainer:node(renderedLine) | childContainer:node(renderedLine) | ||
end | end | ||
| Line 430: | Line 476: | ||
--- Renders special directives, such as cutscene start/end and the end of a quest. | --- Renders special directives, such as cutscene start/end and the end of a quest. | ||
local function renderDirective(line, next) | local function renderDirective(line, context, next) | ||
if line.value == "cutscene start" or line.value == "voiced cutscene start" then | if line.value == "cutscene start" or line.value == "voiced cutscene start" then | ||
return renderCutscene(line, next) | return renderCutscene(line, context, next) | ||
elseif line.value == "cutscene end" then | elseif line.value == "cutscene end" then | ||
-- this is handled by renderCutscene; if we encounter this | -- this is handled by renderCutscene; if we encounter this | ||
| Line 438: | Line 484: | ||
return renderError("Extraneous <code>-cutscene end</code>"), next() | return renderError("Extraneous <code>-cutscene end</code>"), next() | ||
elseif line.value == "optional start" then | elseif line.value == "optional start" then | ||
return renderOptionalDialogue(line, next) | return renderOptionalDialogue(line, context, next) | ||
elseif line.value == "optional end" then | elseif line.value == "optional end" then | ||
-- this is handled by renderOptionalDialogue; if we encounter this | -- this is handled by renderOptionalDialogue; if we encounter this | ||
| Line 462: | Line 508: | ||
-- NOTE: this function is already declared as local at the top of this section | -- NOTE: this function is already declared as local at the top of this section | ||
-- because we need to recurse into it from earlier functions | -- because we need to recurse into it from earlier functions | ||
function renderLine(line, next) | function renderLine(line, context, next) | ||
context = context or {} | |||
if line.type == "dialogue" then | if line.type == "dialogue" then | ||
-- The renderer for spoken dialogue will advance through multiple | -- The renderer for spoken dialogue will advance through multiple | ||
-- lines and group dialogue lines from the same speaker together, | -- lines and group dialogue lines from the same speaker together, | ||
-- returning both the rendered result *and* the line that comes next | -- returning both the rendered result *and* the line that comes next | ||
return renderSpeakerDialogueGroup(line, next) | return renderSpeakerDialogueGroup(line, context, next) | ||
elseif line.type == "system" then | elseif line.type == "system" then | ||
| Line 477: | Line 525: | ||
elseif line.type == "directive" then | elseif line.type == "directive" then | ||
return renderDirective(line, next) | return renderDirective(line, context, next) | ||
elseif line.type == "choice" then | elseif line.type == "choice" then | ||
return renderChoices(line, next) | return renderChoices(line, context, next) | ||
elseif line.type == "section-end" then | elseif line.type == "section-end" then | ||
| Line 507: | Line 555: | ||
-- initialize a global variable to store maintenance categories added during parsing | -- initialize a global variable to store maintenance categories added during parsing | ||
ExtraCategories = {} | ExtraCategories = {} | ||
-- initialize another global variable to keep track of which speakers appear in what contexts | |||
SpeakerData = {} | |||
local next = parsedLinesIter(text) | local next = parsedLinesIter(text) | ||
| Line 515: | Line 565: | ||
while line do | while line do | ||
local renderedLine | local renderedLine | ||
renderedLine, line = renderLine(line, next) | renderedLine, line = renderLine(line, {}, next) | ||
outputContainer:node(renderedLine) | outputContainer:node(renderedLine) | ||
end | end | ||
local result = tostring(outputContainer) | |||
-- tack extra categories onto the end of the output | -- tack extra categories onto the end of the output | ||
for category in pairs(ExtraCategories) do | for category in pairs(ExtraCategories) do | ||
result = result .. "[[Category:" .. category .. "]]" | |||
end | |||
local questGiver = userContentFrame:callParserFunction("#show", { | |||
mw.title.getCurrentTitle().prefixedText, | |||
"?Has quest giver", | |||
link = "none", | |||
}) | |||
-- process SpeakerData and add the relevant NPCs (excluding the quest giver) to semantic data | |||
for speakerLink, data in pairs(SpeakerData) do | |||
if speakerLink == questGiver then | |||
-- do nothing, because the quest giver has their own property (Has quest giver) | |||
elseif data.speaksNormally then | |||
userContentFrame:callParserFunction("#set", {"Has related NPC", speakerLink}) | |||
elseif data.speaksInCutscene then | |||
userContentFrame:callParserFunction("#set", {"Has cutscene NPC", speakerLink}) | |||
end | |||
end | end | ||
return | return result | ||
end | end | ||
Revision as of 22:26, 12 March 2026
This module implements {{Dialogue}}.
-- #region Constants
-- Placeholders that can appear between square brackets and their title text
local placeholders = {
["Forename"] = "Player's forename",
["Surname"] = "Player's surname",
["GC Rank"] = "Player's grand company rank (e.g. \"Private\")",
["Companion"] = "Name of player's chocobo companion",
["Job"] = "Player's current job",
}
-- #endregion
-- this is used as a global variable; `main` reassigns this to be whatever frame is invoking main. `mw.getCurrentFrame()` is just a fallback
local userContentFrame = mw.getCurrentFrame()
--- Expands templates/magic words/etc in user input.
local function preprocess(text)
return userContentFrame:preprocess(text)
end
-- Returns whether this frame is part of an unsaved edit preview (i.e. has no
-- {{REVISIONID}}) - lets us do the equivalent of {{#if:{{REVISIONID}}|no|yes}}
local function inEditPreview()
-- TODO: :preprocess() seems to be discouraged, but I can't find another way
-- of calling individual non-parserfunction magic words
return userContentFrame:preprocess("{{REVISIONID}}") == ""
end
-- converts a string into a pattern that matches the string case-insensitively
-- from https://www.lua.org/pil/20.4.html
local function nocase (str)
return str:gsub("%a", function (char)
return ("[%s%s]"):format(string.lower(char), string.upper(char))
end)
end
-- Function to process Forename and Surname placeholders. Deprecated - this
-- handles the old formats that won't be supported soon. everything should be
-- using square brackets from now on
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)(%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
-- Parses/renders inline text formatting like placeholders and alternate phrases.
local function processInlineText(text)
-- Preprocess template expansion/etc
text = preprocess(text) or ""
-- Handle placeholders
for placeholder, description in pairs(placeholders) do
text = text:gsub(
"%[" .. nocase(placeholder) .. "%]", -- case-insensitive match between brackets
tostring(mw.html.create("abbr")
:attr("title", description)
:wikitext(placeholder)
)
)
end
-- Handle inline options
-- TODO
-- Handle legacy placeholder formats
-- TODO: remove
text = processNames(text)
return text
end
-- #region Parsing - Converting the input line format into data
-- Generates a parse error that can be rendered
local function parseError(text)
return {type = "error", text = text}
end
--- Parses dialogue speaker strings
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
-- Handle special-cased speakers for different kinds of system messages
if speakerName == "System" then
return {type = "system"}
elseif speakerName == "Question" then
return {type = "system", subtype = "question"}
elseif speakerName == "Popup" then
return {type = "system", subtype = "popup"}
end
-- Link override of "x" disables linking and let the name have wikitext
if speakerLink == "x" then
speakerLink = nil
speakerName = processInlineText(speakerName)
end
return {type = "character", name = speakerName, link = speakerLink}
end
local function updateSpeakerData (speakerLink, data)
local currentData = SpeakerData[speakerLink]
if not currentData then
currentData = {}
SpeakerData[speakerLink] = currentData
end
for k, v in pairs(data) do
currentData[k] = v
end
end
--- Parses a single line of input and returns all its information.
local function parseLine(line)
line = mw.text.trim(line)
-- Raw wikitext lines
if line:sub(1, 1) == ":" then
return {type = "raw", text = preprocess(line:sub(2))}
end
-- End-of-option delimiters
if line == "---" or line == "–––" or line == "—" then
return {type = "section-end"}
end
-- Choices
local choiceLevel, choiceText = line:match("^(>+)%s*(.+)$")
if choiceText then
return {type = "choice", level = #choiceLevel, text = processInlineText(choiceText)}
end
-- Other special directives
if line:sub(1, 1) == "-" then
-- Line is a special directive
return {type = "directive", value = line:sub(2)}
end
-- Dialogue lines
-- Determine who's speaking
local speaker
local speakerMatch, text = line:match("^(.-):%s*(.+)$")
if speakerMatch and text then
speaker = parseSpeaker(speakerMatch)
-- Remove bold wikicode from start of text (temporary fix for old speaker format that had bold around speaker+colon)
-- TODO: handle this some other way so we can actually have bold markup in the template
text = text:gsub("^'''", "")
-- Process forename/surname/etc placeholders in dialogue text
text = processInlineText(text)
if speaker.type == "character" then
return {type = "dialogue", name = speaker.name, link = speaker.link, text = text}
elseif speaker.type == "system" then
return {type = "system", subtype = speaker.subtype, text = text}
end
return parseError(("Unknown speaker type \"%s\""):format(speaker.type))
end
-- Anything else is handled as non-dialogue wikicode
return {type = "raw", text = preprocess(line)}
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
local inCutscene = false
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
return parseLine(line)
end
end
-- #endregion
-- #region Rendering - Converting parsed data into markup/wikitext
-- functions for handling rendering context
local function contains(array, val)
for _, v in ipairs(array) do
if v == val then
return true
end
end
return false
end
local function extend(array, val)
local newArray = {}
for _, v in ipairs(array) do
table.insert(newArray, v)
end
table.insert(newArray, val)
return newArray
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 single line of dialogue.
local function renderSingleLine(line)
local div = mw.html.create("div"):addClass("dialogue-line")
:wikitext(line.text)
-- add CSS classes for specific types of dialogue
if line.type == "system" then
if line.subtype then
div:addClass("dialogue-line--" .. line.subtype)
else
div:addClass("dialogue-line--system")
end
end
return div
end
--- Renders multiple system messages back-to-back. Consumes lines from
-- the input until it finds another type of line.
local function renderSystemMessageGroup(line, next)
local childContainer = mw.html.create("div"):addClass("dialogue-container")
local wrapper = mw.html.create("div")
:addClass(("dialogue-box dialogue-box--%s"):format(line.subtype or "system"))
:node(childContainer)
while true do
-- Add the current line of dialogue to the container
childContainer:node(renderSingleLine(line))
line = next()
if
not line -- if there's no more lines left,
or line.type ~= "system" -- or the next line isn't a system message,
or line.subtype -- or the message has some other sybtype,
then -- then we're done with this container
return wrapper, line
end
-- loop and continue processing lines from the same speaker
end
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, context, next)
-- Keep track of the speaker information we're starting with
local speakerName = line.name
local speakerLink = line.link
-- Update speaker data for this speaker
if contains(context, "voiced cutscene") or contains(context, "cutscene") then
updateSpeakerData(speakerLink, {speaksInCutscene = true})
else
updateSpeakerData(speakerLink, {speaksNormally = true})
end
-- 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(renderSingleLine(line))
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, context, 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")
:node(mw.html.create("div"):addClass("dialogue-box-header mw-collapsible-toggle")
:wikitext("Optional dialogue")
)
:node(childContainer)
-- collapse optional dialogue by default, unless editing the page
if not inEditPreview() then
wrapper:addClass("mw-collapsed")
end
-- assemble child context
local innerContext = extend(context, "optional dialogue")
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, innerContext, 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, context, next)
local currentLevel = line.level
-- 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(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" and line.level <= currentLevel then
-- end of this choice and beginning of another at the same or
-- lower level - return what we have, and resume the parent context
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 dialogue-choice-has-response")
-- collapse choice by default, unless editing the page
if not inEditPreview() then
wrapper:addClass("mw-collapsed")
end
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, context, next) -- TODO: add choice to context before passing to inner renders
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, context, 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.")
)
-- construct inner context to pass to inner render functions
local innerContext = extend(
context,
(line.value == "voiced cutscene start") and "voiced cutscene" or "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, innerContext, next)
childContainer:node(renderedLine)
end
end
--- Renders special directives, such as cutscene start/end and the end of a quest.
local function renderDirective(line, context, next)
if line.value == "cutscene start" or line.value == "voiced cutscene start" then
return renderCutscene(line, context, 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, context, 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 accepted" then
return
mw.html.create("div"):addClass("dialogue-quest-banner")
:wikitext("[[File:Quest Accepted.png|500px|link=]]"),
next()
elseif line.value == "quest complete" then
return
mw.html.create("div"):addClass("dialogue-quest-banner")
: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, context, next)
context = context or {}
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, context, next)
elseif line.type == "system" then
if line.subtype == "question" then
return renderSingleLine(line), next()
else -- other types of system messages
return renderSystemMessageGroup(line, next)
end
elseif line.type == "directive" then
return renderDirective(line, context, next)
elseif line.type == "choice" then
return renderChoices(line, context, 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 == "error" then
return renderError("Parse: " .. line.text), next()
elseif line.type == "raw" then
return mw.html.create("div"):wikitext("\n" .. line.text .. "\n"), 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)
-- initialize a global variable to store maintenance categories added during parsing
ExtraCategories = {}
-- initialize another global variable to keep track of which speakers appear in what contexts
SpeakerData = {}
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
local result = tostring(outputContainer)
-- tack extra categories onto the end of the output
for category in pairs(ExtraCategories) do
result = result .. "[[Category:" .. category .. "]]"
end
local questGiver = userContentFrame:callParserFunction("#show", {
mw.title.getCurrentTitle().prefixedText,
"?Has quest giver",
link = "none",
})
-- process SpeakerData and add the relevant NPCs (excluding the quest giver) to semantic data
for speakerLink, data in pairs(SpeakerData) do
if speakerLink == questGiver then
-- do nothing, because the quest giver has their own property (Has quest giver)
elseif data.speaksNormally then
userContentFrame:callParserFunction("#set", {"Has related NPC", speakerLink})
elseif data.speaksInCutscene then
userContentFrame:callParserFunction("#set", {"Has cutscene NPC", speakerLink})
end
end
return result
end
-- pattern matches a single <nowiki> strip marker with nothing else
local nowikiPattern = "^\127'\"`UNIQ%-%-nowiki%-%x+%-QINU`\"'\127$"
-- Template/{{#invoke}} entrypoint
function p.main(frame)
userContentFrame = frame
local text = require("Module:Arguments").getArgs(frame)[1]
local preResultContent = "" -- for warnings and automatically-applied categories and stuff
-- warn if input isn't wrapped in <nowiki>
if not text:match(nowikiPattern) then
-- edit-preview-only message
if inEditPreview() then
preResultContent = preResultContent .. tostring(renderError("Wrap template input in a nowiki tag. See [[Template:Dialogue|the documentation]] for information. This message only shows in the edit preview."))
end
preResultContent = preResultContent .. "[[Category:Dialogue input not using nowiki tag]]"
end
-- 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 preResultContent .. tostring(p.render(text))
end
return p