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

-- 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"}
    end

    -- Link override of "x" disables linking
    if speakerLink == "x" then
        speakerLink = nil
    end

    return {type = "character", name = speakerName, link = 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 choiceLevel, choiceText = line:match("^(>+)%s*(.+)$")
    if choiceText then
        return {type = "choice", level = #choiceLevel, 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 speaker
    local speakerMatch, text = line:match("^(.-):%s*(.+)$")
    if speakerMatch and text then
        speaker = parseSpeaker(speakerMatch)
    else
        -- No speaker specified, so assume it's a system and use entire line as text
        speaker = {type = "system"}
        text = line
        -- this behavior is deprecated, so we add an edit-preview-only warning as maintenance category to the output any time this is used
        ExtraCategories["Uses dialogue template with speakerless dialogue lines"] = true
    end

    -- Remove bold wikicode from text
    text = text:gsub("'''", "")

    -- Process forename/surname/etc placeholders in dialogue text
    text = processNames(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}
    else
        return parseError(("Unknown speaker type \"%s\""):format(speaker.type))
    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 single line of dialogue.
local function renderSingleLine(line)
    local div = mw.html.create("div"):addClass("dialogue-line")
        :wikitext(preprocess(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--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, 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(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, 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)
    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(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" 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 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 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, 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
        if line.subtype == "question" then
            return renderSingleLine(line), next()
        else -- generic system log messages
            return renderSystemMessageGroup(line, next)
        end

    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()

    elseif line.type == "error" then
        return renderError("Parse: " .. line.text), 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 = {}

    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

    -- tack extra categories onto the end of the output
    local categoriesString = ""
    for category in pairs(ExtraCategories) do
        categoriesString = categoriesString .. "[[Category:" .. category .. "]]"
    end

    return tostring(outputContainer) .. categoriesString
end

local nowikiPattern = "^\127'\"`UNIQ%-%-nowiki%-%x+%-QINU`\"'\127$"

-- Template/{{#invoke}} entrypoint
function p.main(frame)
    userContentFrame = frame:newChild({})
    local text = require("Module:Arguments").getArgs(frame)[1]
    mw.logObject(text, "raw input")

    -- warn if input isn't wrapped in <nowiki>
    local usesNowiki = text:match(nowikiPattern)

    -- replace <nowiki> strip markers with their contents
    text = mw.text.unstripNoWiki(text or "")
    mw.logObject(text, "input after unstrip")
    -- decode the HTML entities nowiki leaves behind
    text = mw.text.decode(text)
    mw.logObject(text, "input after unstrip and decode")

    if usesNowiki then
        return p.render(text)
    else
        return preprocess(
            "{{#if:{{REVISIONID}}||"
            .. tostring(renderError("Wrap template input in a nowiki tag. See [[Template:Dialogue|the documentation]] for information. This message only shows in the edit preview."))
            .. "}}"
        ) .. tostring(p.render(text))
    end
end

return p