/** * 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', 'parseImageOptions', 'parseSfxOptions', 'parseMusicOptions', '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'].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 inline = this.parseInline(this.normalizeParagraph(rawText)); return { text: this.markdownToPlainText(inline.text), layoutText: this.markdownToHtml(inline.text), cueMarkers: inline.cueMarkers }; } buildParagraphBlock(paragraph, role) { return { type: 'paragraph', text: paragraph.text, layoutText: paragraph.layoutText, cueMarkers: paragraph.cueMarkers, role, isFirstParagraphInChapter: role === 'chapter-first' || role === 'textblock-first', dropCap: role === 'chapter-first', addTopSpace: role === 'textblock-first' }; } 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); } 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;