/** * 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 = []; this.assetRoots = { images: '/images/', music: '/music/', sounds: '/sounds/' }; this.bindMethods([ 'parse', 'parseParagraph', 'parseInline', 'parseMusicOptions', 'markdownToHtml', 'markdownToPlainText', 'smartypants', '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 = []; let nextParagraphRole = null; const flushParagraph = () => { if (paragraphBuffer.length === 0) return; const raw = paragraphBuffer.join('\n'); paragraphBuffer = []; const paragraph = this.parseParagraph(raw); if (!paragraph.text) return; const role = nextParagraphRole || 'body'; nextParagraphRole = null; blocks.push(this.buildParagraphBlock(paragraph, role)); }; text.split('\n').forEach((line) => { const trimmed = line.trim(); if (!trimmed) { flushParagraph(); return; } const chapter = trimmed.match(/^::chapter(?:\[(.*?)\]|\s+(.+))$/i); if (chapter) { flushParagraph(); const heading = (chapter[1] || chapter[2] || '').trim(); if (heading) { const normalizedHeading = this.normalizeParagraph(heading); blocks.push({ type: 'heading', text: this.markdownToPlainText(normalizedHeading), layoutText: this.markdownToHtml(normalizedHeading), role: 'chapter-heading' }); } nextParagraphRole = 'chapter-first'; return; } const section = trimmed.match(/^::(?:section|textblock)(?:\[(.*?)\]|\s+(.+))?$/i); if (section) { flushParagraph(); const heading = (section[1] || section[2] || '').trim(); if (heading) { const normalizedHeading = this.normalizeParagraph(heading); blocks.push({ type: 'heading', text: this.markdownToPlainText(normalizedHeading), layoutText: this.markdownToHtml(normalizedHeading), role: 'section-heading' }); } nextParagraphRole = 'textblock-first'; return; } const image = trimmed.match(/^::image\[(widescreen|portrait)\]\(([^)]+)\)$/i); if (image) { flushParagraph(); blocks.push({ type: 'image', size: image[1].toLowerCase(), filename: image[2].trim(), url: this.resolveAssetUrl('images', image[2].trim()) }); return; } const music = trimmed.match(/^::music(?:\[([^\]]*)\])?\(([^)]+)\)$/i); if (music) { flushParagraph(); const options = this.parseMusicOptions(music[1] || 'crossfade'); blocks.push({ type: 'music', ...options, filename: music[2].trim(), url: this.resolveAssetUrl('music', music[2].trim()) }); return; } paragraphBuffer.push(line); }); flushParagraph(); return blocks; } 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; } 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) { const cueMarkers = []; let output = ''; let cursor = 0; const markerPattern = /\{\{\s*(sfx|music)\s*:\s*(?:(queue|crossfade|cut)\s*:\s*)?([^}]+?)\s*\}\}/gi; for (const match of text.matchAll(markerPattern)) { output += text.slice(cursor, match.index); const charIndex = output.length; const wordIndex = this.countWords(output); const type = match[1].toLowerCase(); const mode = type === 'music' ? (match[2] || 'crossfade').toLowerCase() : null; cueMarkers.push({ type, mode, filename: match[3].trim(), url: this.resolveAssetUrl(type === 'sfx' ? 'sounds' : 'music', match[3].trim()), charIndex, wordIndex }); cursor = match.index + match[0].length; } output += text.slice(cursor); return { text: output.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(); } smartypants(text) { return 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'); } 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;