recital / exporters / ink / src / index.ts
index.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 { parseFlat } from '@a-morphous/recital'
import { enforceIds } from '@a-morphous/recital-ext-common-commands'
import { FlatObject, SceneObject } from '@a-morphous/recital/dist/types/types'
import { convertMetaIntoInkTags } from './lib/meta'
import { ParagraphState, parseParagraph } from './lib/paragraph'
import { StageToInkConfig, StageToInkConfigWithState } from './types'
import { getNextScene } from './utils/next-scene'
import { inkSlugify } from './utils/slugify'

const handleRegularInkToken = (
	token: FlatObject,
	flats: FlatObject[],
	state: ParagraphState,
	config: StageToInkConfigWithState = { _globals: {} }
) => {
	let finalString = ''
	switch (token.type) {
		case 'fragment':
			finalString += `= ${inkSlugify(token.title ?? token.primary ?? 'ERROR')}\n`
			state.lastIndent = 0
			finalString += convertMetaIntoInkTags(token, config)
			break
		case 'command':
			// glue command
			if (token.text.startsWith('<>')) {
				finalString += token.text + '\n'
				break
			}

			// all other commands handled as-is
			finalString += '$' + token.text + '\n'
			break
		case 'paragraph':
			// add variable definitions to global state.
			if (token.text.trimStart().startsWith('VAR')) {
				if (!config._globals) {
					config._globals = {}
				}
				if (!config._globals.variables) {
					config._globals.variables = new Set()
				}

				const variableName = token.text.split('=')[0].replace('VAR', '').trim()
				config._globals.variables.add(variableName)
			}
			finalString += parseParagraph(token, flats, state)
	}

	return finalString
}

const HUB_REPLACEMENT = 'HUB_REPLACEMENT'
const handleStoryletSection = (
	startToken: SceneObject,
	flats: FlatObject[],
	state: ParagraphState,
	config: StageToInkConfigWithState = { _globals: {} }
) => {
	const index = flats.indexOf(startToken)
	let storyletString = ''

	// figure out when the storylet section ends.
	let endIndex
	for (let i = index; i < flats.length; i++) {
		if (flats[i].type === 'endScene') {
			endIndex = i
			break
		}
	}

	let isInHub = false
	let hubText = ''
	let beforeFirstStorylet = true

	for (let i = index; i < endIndex; i++) {
		const token = flats[i]
		switch (token.type) {
			case 'scene':
				// we assume that the default scene creation stuff has happened, including metadata.
				// so we just move on.
				break
			case 'fragment':
				if (token.title && token.title.toLocaleLowerCase() === 'hub') {
					isInHub = true
					break
				}
				if (beforeFirstStorylet) {
					beforeFirstStorylet = false
					storyletString += '-> _hub\n\n' + HUB_REPLACEMENT + '\n'
				}
				isInHub = false
				storyletString += `= ${inkSlugify(token.title ?? token.primary ?? 'ERROR')}\n`
				state.lastIndent = 0
				storyletString += convertMetaIntoInkTags(token, config)

				// we might need to create some variables
				// if they're called for in the enter tag of the storylet, but haven't been defined yet
				// in the story proper.
				if (token.meta?.enter) {
					const operations = token.meta.enter
					for (let operation of operations) {
						const variableName = operation.split(/(\+|\-|\*|\/|%|)?=/g)[0]?.trim()
						if (config._globals.variables && config._globals.variables.has(variableName)) {
							continue
						}
						if (!config._globals.storyletVariables) {
							config._globals.storyletVariables = new Set()
						}
						config._globals.storyletVariables.add(variableName)
					}
				}

				break
			case 'endFragment':
				if (isInHub) {
					isInHub = false
					break
				}

				// redirect to hub
				storyletString += '-> _hub\n\n'
				break
			case 'command':
				if (isInHub) {
					hubText += token.text + '\n'
					break
				}
				storyletString += token.text + '\n'
				break
			case 'paragraph':
				if (isInHub) {
					hubText += parseParagraph(token, flats, state)
					break
				}
				storyletString += parseParagraph(token, flats, state)
		}
	}

	let hubString = ''

	// hub stuff
	const sceneTitle = inkSlugify(startToken.title ?? startToken.primary ?? 'ERROR')
	hubString += '= _hub\n'
	hubString += hubText + '\n'
	hubString += `$HUB ${sceneTitle}\n`

	const i = flats.indexOf(startToken)
	const targetToken = i > -1 ? getNextScene(i, flats) : undefined
	if (!targetToken) {
		hubString += `-> END\n`
	} else {
		hubString += `-> ${inkSlugify(targetToken.title ?? targetToken.primary ?? 'ERROR')}\n`
	}

	let finalString = storyletString.replace(HUB_REPLACEMENT, hubString)

	return {
		inkText: finalString,
		newIndex: endIndex,
	}
}

export const stageToInk = (recitalText: string, config: StageToInkConfig = {}): string => {
	const flats = enforceIds(parseFlat(recitalText))
	let finalString = ''

	const statefulConfig: StageToInkConfigWithState = { ...config, _globals: {} }

	let state: ParagraphState = {
		lastIndent: 0,
	}
	let firstScene = inkSlugify(statefulConfig.sceneToStartOn ?? '')

	let totalNumScenes = 0

	// first pass: collecting metadata
	for (let i = 0; i < flats.length; i++) {
		const token = flats[i]
		if (token.type === 'scene') {
			totalNumScenes += 1
		}
	}

	let currentScene = 0

	for (let i = 0; i < flats.length; i++) {
		const token = flats[i]
		switch (token.type) {
			case 'scene':
				const sceneTitle = inkSlugify(token.title ?? token.primary ?? 'ERROR')
				finalString += `=== ${sceneTitle} ===\n`
				state.lastIndent = 0
				if (!firstScene) {
					firstScene = sceneTitle
				}

				const additionalToken = { ...token }

				if (statefulConfig.addStats) {
					if (!additionalToken.meta) {
						additionalToken.meta = {}
					}
					additionalToken.meta.__sceneNumber = currentScene
					additionalToken.meta.__totalScenes = totalNumScenes

					currentScene += 1
				}

				finalString += convertMetaIntoInkTags(additionalToken, statefulConfig)

				// handle all the storylet info at once
				if (token.meta?.storylet) {
					const storyletData = handleStoryletSection(token, flats, state, statefulConfig)
					i = storyletData.newIndex
					finalString += storyletData.inkText
				}
				break
		}

		finalString += handleRegularInkToken(token, flats, state, statefulConfig)
	}

	// finally process those globals!
	let varString = ''
	// define any variables that need to be defined
	if (statefulConfig._globals.storyletVariables) {
		statefulConfig._globals.storyletVariables.forEach((variableName) => {
			varString += `VAR ${variableName} = false\n`
		})
	}

	// we need to start with the first string.
	finalString = `${varString}\n-> ${firstScene}\n${finalString}`

	return finalString.trim() + '\n'
}