Files
ai.interactive.fiction/public/js/markup-parser-module.js
2026-05-14 23:18:30 +02:00

290 lines
9.5 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',
'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, '<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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
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;