Files
ai.interactive.fiction/public/js/markup-parser-module.js
2026-05-19 07:34:52 +02:00

318 lines
11 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 = ['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, '<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();
}
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, '&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;