Checkpoint current interactive fiction state
This commit is contained in:
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* 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',
|
||||
'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) {
|
||||
blocks.push({
|
||||
type: 'heading',
|
||||
text: this.normalizeParagraph(heading),
|
||||
layoutText: this.markdownToHtml(this.normalizeParagraph(heading)),
|
||||
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) {
|
||||
blocks.push({
|
||||
type: 'heading',
|
||||
text: this.normalizeParagraph(heading),
|
||||
layoutText: this.markdownToHtml(this.normalizeParagraph(heading)),
|
||||
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: 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, '<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>');
|
||||
}
|
||||
|
||||
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;
|
||||
Reference in New Issue
Block a user