recital / exporters / ink / src / lib / paragraph.ts
paragraph.ts
Raw
/**
 * Copyright (c) 2022 Amorphous
 *
 * This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/.
 */

import { smartypantsu } from "smartypants"
import { parseWikiLink } from "@a-morphous/recital-ext-common-commands"
import { FlatObject, ParagraphToken } from "@a-morphous/recital/dist/types/types"
import { getNextScene } from "../utils/next-scene"
import { inkSlugify } from "../utils/slugify"

const parseChoice = (line: string) => {
	const choiceRegex = /^(?<nesting>>*)(?<modifier>[!+.]*) ?(?<logic>{.+})? ?\[\[(?<link>.+)\]\]/g
	const matches = choiceRegex.exec(line)
	if (!matches || !matches.groups) {
		return undefined
	}

	const link = parseWikiLink(matches.groups.link)

	return {
		nestedCount: matches.groups.nesting ? matches.groups.nesting.length : 1,
		modifiers: matches.groups.modifier ?? "",
		logic: matches.groups.logic ?? "",
		...link,
	}
}

const parseGather = (line: string) => {
	// gathers can be on a line all by themselves, so we need to make sure the regex
	// accommodates that
	const singleRegex = /^(?<nesting><+)$/g
	const singleMatch = singleRegex.exec(line)
	if (singleMatch && singleMatch.groups) {
		return {
			nestedCount: singleMatch.groups.nesting.length,
			text: "",
		}
	}
	const regex = /^(?<nesting><+) +(?<text>.*)/g
	const matches = regex.exec(line)
	if (!matches || !matches.groups) {
		return undefined
	}
	return {
		nestedCount: matches.groups.nesting.length,
		text: matches.groups.text.trim(),
	}
}

// glue is a straight replacement, since we can't have <> at the beginning of a line
// without it turning into a fragment.
const parseGlue = (line: string) => {
	return line.replace(/\$\<\>/g, "<>")
}

const parseRedirect = (line: string) => {
	// -> redirect can happen at the beginning of a line, or after an equal sign
	// if we are setting a variable to a redirect.
	const regex = /(^|= *|- *)(?<redirect>->) *(?<link>.+)/g
	const matches = regex.exec(line)
	if (!matches || !matches.groups) {
		return undefined
	}

	return {
		beforeLink: line.split("->")[0] ?? "",
		link: matches.groups.link,
	}
}

export type ParagraphState = {
	lastIndent: number
	withinLogicBlock?: boolean // are we inside one of those { } conditionals?
}

export const parseParagraph = (
	token: ParagraphToken,
	flats: FlatObject[],
	state: ParagraphState = { lastIndent: 0, withinLogicBlock: false }
) => {
	if (token.text.trim() === "END") {
		return `${"\t".repeat(state.lastIndent)}->END\n`
	}

	let paragraphString = ""

	// TODO: we should split the text and smart text individual pieces
	// to deal with inter-paragraph {logic}, but that's too hard for now.

	// check if we should be in or out of a logic block
	if (token.text.trim().startsWith("{")) {
		state.withinLogicBlock = true
	}
	if (token.text.trim().startsWith("}") || token.text.trim().endsWith("}")) {
		state.withinLogicBlock = false
	}

	// VAR and { } tags should not be put into smartypants
	let shouldSmartText = true
	if (/^(VAR |~).*/.test(token.text.trim())) {
		shouldSmartText = false
	}
	if (state.withinLogicBlock) {
		if (token.text.trim().startsWith("-")) {
			shouldSmartText = false
		}
		// the first line, for those clauses that aren't settled.
		if (token.text.trim().startsWith("{")) {
			shouldSmartText = false
		}
	}

	const smartText = shouldSmartText ? smartypantsu(token.text, "qie") : token.text

	// choices
	const choice = parseChoice(smartText)
	if (choice) {
		state.lastIndent = choice.nestedCount

		let bulletPoint = choice.modifiers.includes("+") ? "+ " : "* "

		paragraphString += `${"\t".repeat(choice.nestedCount - 1)}${bulletPoint.repeat(
			choice.nestedCount
		)}`

		if (choice.modifiers.includes(".")) {
			// fallback choice
			paragraphString += `->\n${"\t".repeat(state.lastIndent)}${choice.label}\n`
			return paragraphString
		}

		// add the logic if we have it
		if (choice.logic) {
			paragraphString += `${choice.logic} `
		}

		// add the label, wrapping it with [] if we're not loud.
		if (choice.modifiers.includes("!")) {
			paragraphString += choice.label
		} else {
			paragraphString += `[${choice.label}]`
		}

		// add the redirect if we have one
		if (choice.target !== choice.label) {
			paragraphString += ` -> ${inkSlugify(choice.target)}\n`
		} else {
			paragraphString += "\n"
		}
		return paragraphString
	}

	let workingText = smartText

	// glue
	workingText = parseGlue(workingText)

	// gathers
	const gather = parseGather(workingText)
	if (gather) {
		state.lastIndent = gather.nestedCount - 1
		workingText = `${"- ".repeat(gather.nestedCount)}${gather.text}\n`
	}

	// redirects
	const redirect = parseRedirect(workingText)
	if (redirect) {
		workingText = `${redirect.beforeLink.trim()} -> ${inkSlugify(redirect.link)}\n`
	}

	// auto-advance
	if (workingText.trim().endsWith("->")) {
		const i = flats.indexOf(token)
		const targetToken = i > -1 ? getNextScene(i, flats, true) : undefined
		if (!targetToken) {
			workingText = workingText.trimEnd().replace("->", "-> END")
		} else {
			workingText = workingText
				.trimEnd()
				.replace("->", `-> ${inkSlugify(targetToken.title ?? targetToken.primary ?? "ERROR")}\n`)
		}
	}

	// get the @ shorthand working.
	let shorthand = ""
	if (token.primary) {
		shorthand = "@"
		if (token.id) {
			shorthand += "#" + token.id
		}
		if (token.classes && token.classes.length) {
			if (token.id) {
				shorthand += "."
			}
			shorthand += `${token.classes.join(".")}`
		}
		shorthand += " "
	}

	// everything else
	paragraphString += "\t".repeat(state.lastIndent) + shorthand + workingText + "\n"

	return paragraphString
}