273 lines
9.1 KiB
JavaScript
273 lines
9.1 KiB
JavaScript
/**
|
|
* 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',
|
|
'parseImageOptions',
|
|
'parseSfxOptions',
|
|
'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 = [];
|
|
|
|
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, '<strong><em>$1</em></strong>')
|
|
.replace(/___([^_]+?)___/g, '<strong><em>$1</em></strong>')
|
|
.replace(/\*\*([^*]+?)\*\*/g, '<strong>$1</strong>')
|
|
.replace(/__([^_]+?)__/g, '<strong>$1</strong>')
|
|
.replace(/\*([^*\s][^*]*?)\*/g, '<em>$1</em>')
|
|
.replace(/_([^_\s][^_]*?)_/g, '<em>$1</em>');
|
|
}
|
|
|
|
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, '<')
|
|
.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;
|