/**
* Markup Parser Module
* Parses author-facing story markup into renderable blocks and timed cue metadata.
*/
import { BaseModule } from './base-module.js';
class MarkupParserModule extends BaseModule {
constructor() {
super('markup-parser', 'Markup Parser');
this.dependencies = ['game-config'];
this.assetRoots = {
images: '/images/',
music: '/music/',
sounds: '/sounds/'
};
this.bindMethods([
'parse',
'parseParagraph',
'parseInline',
'extractGlossaryTags',
'extractTtsInstructionTags',
'normalizeTtsInstructionProvider',
'parseImageOptions',
'parseSfxOptions',
'parseMusicOptions',
'parsePageReserveDirective',
'markdownToHtml',
'markdownToPlainText',
'smartypants',
'applyLocaleTypography',
'getTypographyLocale',
'normalizeDialogueQuotes',
'escapeHtml',
'normalizeParagraph',
'buildParagraphBlock',
'resolveAssetUrl'
]);
}
async initialize() {
this.reportProgress(100, "Markup parser ready");
return true;
}
parse(input) {
const text = String(input || '').replace(/\r\n?/g, '\n');
const blocks = [];
let paragraphBuffer = [];
const flushParagraph = () => {
if (paragraphBuffer.length === 0) return;
const raw = paragraphBuffer.join('\n');
paragraphBuffer = [];
const paragraph = this.parseParagraph(raw);
if (!paragraph.text) return;
const role = 'body';
blocks.push(this.buildParagraphBlock(paragraph, role));
};
text.split('\n').forEach((line) => {
const trimmed = line.trim();
if (!trimmed) {
flushParagraph();
return;
}
paragraphBuffer.push(line);
});
flushParagraph();
return blocks;
}
parseImageOptions(optionText) {
const options = {
size: 'landscape',
leadInSeconds: 0
};
String(optionText || 'landscape')
.split(/[,\s]+/)
.map(token => token.trim())
.filter(Boolean)
.forEach((token, index) => {
const lower = token.toLowerCase();
const [key, value] = lower.split('=');
if (['landscape', 'widescreen', 'portrait', 'square', 'full'].includes(lower)) {
options.size = lower === 'widescreen' ? 'landscape' : lower;
} else if (['lead', 'lead-in', 'leadins', 'lead-in-seconds', 'delay', 'intro', 'pause', 'wait', 'hold'].includes(key)) {
const seconds = Number(value);
options.leadInSeconds = Number.isFinite(seconds) ? Math.max(0, seconds) : 0;
} else if (/^\d+(\.\d+)?s?$/.test(lower)) {
options.leadInSeconds = Number(lower.replace(/s$/, ''));
} else if (index === 0) {
console.warn(`MarkupParser: Unknown image size "${token}", using landscape`);
}
});
return options;
}
parseMusicOptions(optionText) {
const options = {
mode: 'crossfade',
loop: true,
leadInSeconds: 0
};
String(optionText || '')
.split(/[,\s]+/)
.map(token => token.trim())
.filter(Boolean)
.forEach(token => {
const lower = token.toLowerCase();
const [key, value] = lower.split('=');
if (['queue', 'crossfade', 'cut'].includes(lower)) {
options.mode = lower;
} else if (['loop', 'looped', 'repeat'].includes(lower)) {
options.loop = true;
} else if (['once', 'single', 'no-loop', 'noloop'].includes(lower)) {
options.loop = false;
} else if (key === 'loop') {
options.loop = !['false', '0', 'no', 'once'].includes(value);
} else if (['lead', 'lead-in', 'leadins', 'lead-in-seconds', 'delay', 'intro'].includes(key)) {
const seconds = Number(value);
options.leadInSeconds = Number.isFinite(seconds) ? Math.max(0, seconds) : 0;
} else if (/^\d+(\.\d+)?s?$/.test(lower)) {
options.leadInSeconds = Number(lower.replace(/s$/, ''));
}
});
return options;
}
parseSfxOptions(optionText) {
const options = {
maxDurationSeconds: 0,
endMode: 'stop',
fadeDurationSeconds: 2
};
String(optionText || '')
.split(/[,\s]+/)
.map(token => token.trim())
.filter(Boolean)
.forEach(token => {
const lower = token.toLowerCase();
const [key, value] = lower.split('=');
if (['fade', 'fadeout', 'fade-out'].includes(lower)) {
options.endMode = 'fade';
} else if (['stop', 'cut', 'halt'].includes(lower)) {
options.endMode = 'stop';
} else if (['max', 'duration', 'max-duration', 'limit', 'stop-after', 'fade-after'].includes(key)) {
const seconds = Number(value);
options.maxDurationSeconds = Number.isFinite(seconds) ? Math.max(0, seconds) : 0;
if (key === 'fade-after') options.endMode = 'fade';
} else if (/^\d+(\.\d+)?s?$/.test(lower)) {
options.maxDurationSeconds = Number(lower.replace(/s$/, ''));
} else if (key === 'mode' && ['fade', 'fadeout', 'fade-out', 'stop', 'cut'].includes(value)) {
options.endMode = value.startsWith('fade') ? 'fade' : 'stop';
} else if (['fade-duration', 'fade-time', 'fade'].includes(key)) {
const seconds = Number(value);
if (Number.isFinite(seconds)) {
options.fadeDurationSeconds = Math.max(0.1, seconds);
options.endMode = 'fade';
}
}
});
return options;
}
parseParagraph(rawText) {
const normalized = this.normalizeParagraph(rawText);
const reserveDirective = this.parsePageReserveDirective(normalized);
const inline = this.parseInline(reserveDirective.text);
return {
text: this.markdownToPlainText(inline.text),
layoutText: this.markdownToHtml(inline.text),
cueMarkers: inline.cueMarkers,
pageReserve: reserveDirective.directive
};
}
buildParagraphBlock(paragraph, role) {
return {
type: 'paragraph',
text: paragraph.text,
layoutText: paragraph.layoutText,
cueMarkers: paragraph.cueMarkers,
role,
metadata: {
...(paragraph.pageReserve ? { pageReserve: paragraph.pageReserve } : {})
},
isFirstParagraphInChapter: role === 'chapter-first' || role === 'textblock-first',
dropCap: role === 'chapter-first',
addTopSpace: role === 'textblock-first'
};
}
parsePageReserveDirective(text) {
const source = String(text || '');
const match = source.match(/#pagereserve\[\s*([0-9]+(?:\.[0-9]+)?)\s*(%)?\s*\]/i);
if (!match) {
return { text: source, directive: null };
}
const value = Number(match[1]);
const directive = Number.isFinite(value)
? {
value,
unit: match[2] === '%' ? 'percent' : 'pages'
}
: null;
return {
text: source.replace(match[0], '').replace(/\s{2,}/g, ' ').trim(),
directive
};
}
parseInline(text) {
return {
text: String(text || '').replace(/\s{2,}/g, ' ').trim(),
cueMarkers: []
};
}
markdownToHtml(text) {
const escaped = this.smartypants(this.escapeHtml(text));
return escaped
.replace(/\*\*\*([^*]+?)\*\*\*/g, '$1')
.replace(/___([^_]+?)___/g, '$1')
.replace(/\*\*([^*]+?)\*\*/g, '$1')
.replace(/__([^_]+?)__/g, '$1')
.replace(/\*([^*\s][^*]*?)\*/g, '$1')
.replace(/_([^_\s][^_]*?)_/g, '$1');
}
markdownToPlainText(text) {
const plain = String(text || '')
.replace(/\*\*\*([^*]+?)\*\*\*/g, '$1')
.replace(/___([^_]+?)___/g, '$1')
.replace(/\*\*([^*]+?)\*\*/g, '$1')
.replace(/__([^_]+?)__/g, '$1')
.replace(/\*([^*\s][^*]*?)\*/g, '$1')
.replace(/_([^_\s][^_]*?)_/g, '$1');
return this.smartypants(plain).replace(/\s{2,}/g, ' ').trim();
}
extractGlossaryTags(tags = []) {
if (!Array.isArray(tags)) return [];
return tags
.filter(tag => String(tag?.key || '').toLowerCase() === 'gloss')
.map(tag => {
const term = String(tag?.value || '').trim();
const definition = String(tag?.param || '').trim();
if (!term || !definition) return null;
return { term, definition };
})
.filter(Boolean)
.sort((a, b) => b.term.length - a.term.length);
}
extractTtsInstructionTags(tags = []) {
if (!Array.isArray(tags)) return [];
return tags
.map(tag => {
const key = String(tag?.key || '').toLowerCase();
const value = String(tag?.value || '').trim();
const param = String(tag?.param || '').trim();
if (key === 'tts') {
if (param) {
return {
provider: this.normalizeTtsInstructionProvider(value),
instruction: param
};
}
return {
provider: null,
instruction: value
};
}
if (key.startsWith('tts-') && value) {
return {
provider: this.normalizeTtsInstructionProvider(key.slice(4)),
instruction: value
};
}
return null;
})
.filter(entry => entry && entry.instruction);
}
normalizeTtsInstructionProvider(provider) {
const normalized = String(provider || '').trim().toLowerCase();
if (!normalized) return null;
if (normalized === 'openai' || normalized === 'openai-tts') return 'openai-tts';
if (normalized === 'local-openai' || normalized === 'local-openai-tts') return 'local-openai-tts';
if (normalized === 'elevenlabs' || normalized === 'elevenlabs-tts') return 'elevenlabs-tts';
if (normalized === 'kokoro' || normalized === 'kokoro-tts') return 'kokoro-tts';
if (normalized === 'browser' || normalized === 'browser-tts') return 'browser-tts';
return normalized;
}
smartypants(text) {
const result = String(text)
.replace(/---/g, '\u2014')
.replace(/--/g, '\u2013')
.replace(/\.\.\./g, '\u2026')
.replace(/(^|[\s([{\u2014])"([^"]*)"/g, '$1\u201c$2\u201d')
.replace(/(^|[\s([{\u2014])'([^']*)'/g, '$1\u2018$2\u2019');
return this.applyLocaleTypography(result);
}
applyLocaleTypography(text) {
const locale = this.getTypographyLocale();
if (locale.startsWith('de')) {
return this.normalizeDialogueQuotes(text);
}
return text;
}
getTypographyLocale() {
const gameConfig = this.getModule('game-config') || window.GameConfig;
const locale = gameConfig?.getLocale?.()
|| gameConfig?.getConfig?.()?.metadata?.language
|| 'en_US';
return String(locale).trim().toLowerCase().replace('_', '-');
}
normalizeDialogueQuotes(text) {
return String(text || '')
.replace(/&(ldquo|bdquo|laquo|raquo);([^&\n]+?)&(rdquo|ldquo|laquo|raquo);/gi, '»$2«')
.replace(/["\u201c\u201e\u201d\u00ab\u00bb]([^"\u201c\u201e\u201d\u00ab\u00bb\n]+?)["\u201c\u201d\u201e\u00ab\u00bb]/g, '»$1«');
}
escapeHtml(text) {
return String(text)
.replace(/&/g, '&')
.replace(//g, '>');
}
normalizeParagraph(text) {
return String(text).replace(/\s*\n\s*/g, ' ').trim();
}
countWords(text) {
const words = String(text).trim().match(/\S+/g);
return words ? words.length : 0;
}
resolveAssetUrl(kind, filename) {
const root = this.assetRoots[kind];
const safeName = String(filename || '').replace(/\\/g, '/').replace(/^\/+/, '');
if (!root || !safeName || safeName.includes('..') || /^[a-z]+:/i.test(safeName)) {
return '';
}
return root + safeName.split('/').map(encodeURIComponent).join('/');
}
}
const MarkupParser = new MarkupParserModule();
export { MarkupParser };
if (window.moduleRegistry) {
window.moduleRegistry.register(MarkupParser);
}
window.MarkupParser = MarkupParser;