261 lines
8.6 KiB
JavaScript
261 lines
8.6 KiB
JavaScript
const fs = require('fs');
|
|
const path = require('path');
|
|
const Handlebars = require('handlebars');
|
|
|
|
function mediaKind(mediaPath) {
|
|
const ext = path.extname(mediaPath || '').toLowerCase();
|
|
if (['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg'].includes(ext)) {
|
|
return 'image';
|
|
}
|
|
if (['.mp4', '.webm', '.ogg'].includes(ext)) {
|
|
return 'video';
|
|
}
|
|
if (ext === '.pdf') {
|
|
return 'pdf';
|
|
}
|
|
return 'file';
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
return String(value ?? '')
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
function sanitizeFontFamily(value) {
|
|
return String(value || '').replace(/[^a-zA-Z0-9 ,"-]/g, '').trim();
|
|
}
|
|
|
|
function sanitizeFontSize(value) {
|
|
return Math.max(8, Number(value || 0) || 24);
|
|
}
|
|
|
|
function sanitizeTextColor(value, fallback) {
|
|
const raw = String(value || '').trim();
|
|
if (/^#[0-9a-fA-F]{6}$/.test(raw) || /^#[0-9a-fA-F]{3}$/.test(raw)) {
|
|
return raw;
|
|
}
|
|
return fallback || '#000000';
|
|
}
|
|
|
|
const ALLOWED_RICH_TEXT_TAGS = ['b', 'strong', 'i', 'em', 'u', 'br', 'p', 'div', 'ul', 'ol', 'li'];
|
|
|
|
function safeJsonForScript(value) {
|
|
return JSON.stringify(value === undefined ? null : value).replace(/</g, '\\u003c');
|
|
}
|
|
|
|
function parseMaybeJson(value) {
|
|
if (typeof value !== 'string') {
|
|
return value;
|
|
}
|
|
const raw = value.trim();
|
|
if (!raw) {
|
|
return value;
|
|
}
|
|
if (raw[0] !== '{' && raw[0] !== '[') {
|
|
return value;
|
|
}
|
|
try {
|
|
return JSON.parse(raw);
|
|
} catch (_error) {
|
|
return value;
|
|
}
|
|
}
|
|
|
|
function normalizeContentValue(value) {
|
|
if (!value || typeof value !== 'object') {
|
|
return {
|
|
type: 'text',
|
|
value: parseMaybeJson(value)
|
|
};
|
|
}
|
|
const normalized = {};
|
|
Object.keys(value).forEach(function (key) {
|
|
normalized[key] = value[key];
|
|
});
|
|
if (normalized.value !== undefined) {
|
|
normalized.value = parseMaybeJson(normalized.value);
|
|
} else if (normalized.font_family !== undefined && normalized.font_family !== null) {
|
|
normalized.font_family = sanitizeFontFamily(normalized.font_family);
|
|
} else if (normalized.font_size !== undefined && normalized.font_size !== null) {
|
|
normalized.font_size = sanitizeFontSize(normalized.font_size);
|
|
} else if (normalized.font_color !== undefined && normalized.font_color !== null) {
|
|
normalized.font_color = sanitizeTextColor(normalized.font_color);
|
|
} else if (!normalized.type) {
|
|
normalized.type = 'text';
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
function normalizeSlide(slide) {
|
|
const normalized = {};
|
|
Object.keys(slide || {}).forEach(function (key) {
|
|
normalized[key] = slide[key];
|
|
});
|
|
normalized.content = {};
|
|
const content = slide && slide.content && typeof slide.content === 'object' ? slide.content : {};
|
|
Object.keys(content).forEach(function (regionKey) {
|
|
normalized.content[regionKey] = normalizeContentValue(content[regionKey]);
|
|
});
|
|
return normalized;
|
|
}
|
|
|
|
function sanitizeRichText(html) {
|
|
let output = String(html || '');
|
|
output = output.replace(/<script[\s\S]*?<\/script>/gi, '');
|
|
output = output.replace(/<style[\s\S]*?<\/style>/gi, '');
|
|
return output.replace(/<[^>]+>/g, (tag) => {
|
|
const match = tag.match(/^<\s*(\/?)\s*([a-z0-9]+)(?:\s[^>]*)?>$/i);
|
|
if (!match) {
|
|
return '';
|
|
}
|
|
const closing = Boolean(match[1]);
|
|
const name = String(match[2] || '').toLowerCase();
|
|
if (!ALLOWED_RICH_TEXT_TAGS.includes(name)) {
|
|
return '';
|
|
}
|
|
if (name === 'br') {
|
|
return '<br>';
|
|
}
|
|
return closing ? `</${name}>` : `<${name}>`;
|
|
});
|
|
}
|
|
|
|
function renderEditorJsBlock(block) {
|
|
if (!block || !block.type || !block.data) {
|
|
return '';
|
|
}
|
|
|
|
if (block.type === 'header') {
|
|
const level = Math.max(1, Math.min(6, Number(block.data.level || 2)));
|
|
return '<h' + level + '>' + sanitizeRichText(block.data.text || '') + '</h' + level + '>';
|
|
} else if (block.type === 'list') {
|
|
const tag = block.data.style === 'ordered' ? 'ol' : 'ul';
|
|
const items = Array.isArray(block.data.items) ? block.data.items : [];
|
|
return '<' + tag + ' style="list-style-type:' + (tag === 'ol' ? 'decimal' : 'disc') + ';padding-left:1.4em;">' + items.map((item) => renderEditorJsListItem(item, tag)).join('') + '</' + tag + '>';
|
|
} else if (block.type === 'delimiter') {
|
|
return '<hr />';
|
|
} else if (block.type === 'code') {
|
|
return '<pre><code>' + escapeHtml(block.data.code || '') + '</code></pre>';
|
|
} else if (block.type === 'table') {
|
|
return renderEditorJsTable(block.data);
|
|
} else if (block.type === 'paragraph') {
|
|
return '<p>' + sanitizeRichText(block.data.text || '') + '</p>';
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
function renderEditorJsListItem(item, tag) {
|
|
if (item && typeof item === 'object') {
|
|
const content = item.content !== undefined ? item.content : (item.text !== undefined ? item.text : item.value !== undefined ? item.value : '');
|
|
const children = Array.isArray(item.items) ? item.items : Array.isArray(item.subItems) ? item.subItems : Array.isArray(item.children) ? item.children : [];
|
|
const nested = children.length ? '<' + tag + ' style="list-style-type:' + (tag === 'ol' ? 'decimal' : 'disc') + ';padding-left:1.4em;">' + children.map((child) => renderEditorJsListItem(child, tag)).join('') + '</' + tag + '>' : '';
|
|
return '<li>' + sanitizeRichText(content || '') + nested + '</li>';
|
|
}
|
|
return '<li>' + sanitizeRichText(item || '') + '</li>';
|
|
}
|
|
|
|
function renderEditorJsTable(data) {
|
|
const rows = Array.isArray(data.content) ? data.content : Array.isArray(data.rows) ? data.rows : [];
|
|
if (!rows.length) {
|
|
return '';
|
|
}
|
|
const hasHeadings = Boolean(data.withHeadings);
|
|
const tableRows = rows.map(function (row, rowIndex) {
|
|
const cells = Array.isArray(row) ? row : [];
|
|
const cellTag = hasHeadings && rowIndex === 0 ? 'th' : 'td';
|
|
const cellAttrs = cellTag === 'th' ? ' scope="col"' : '';
|
|
return '<tr>' + cells.map(function (cell) {
|
|
return '<' + cellTag + cellAttrs + '>' + sanitizeRichText(cell || '') + '</' + cellTag + '>';
|
|
}).join('') + '</tr>';
|
|
}).join('');
|
|
return '<table class="editorjs-table">' + tableRows + '</table>';
|
|
}
|
|
|
|
function renderEditorJsContent(value) {
|
|
if (value && typeof value === 'object') {
|
|
if (Array.isArray(value.blocks)) {
|
|
return value.blocks.map(renderEditorJsBlock).join('');
|
|
}
|
|
if (value.value !== undefined) {
|
|
return renderEditorJsContent(value.value);
|
|
}
|
|
}
|
|
const raw = String(value || '');
|
|
try {
|
|
const parsed = JSON.parse(raw);
|
|
if (parsed && Array.isArray(parsed.blocks)) {
|
|
return parsed.blocks.map(renderEditorJsBlock).join('');
|
|
}
|
|
} catch (_error) {
|
|
// fall through to legacy HTML rendering
|
|
}
|
|
return sanitizeRichText(raw);
|
|
}
|
|
|
|
function renderHtmlRegionContent(value) {
|
|
const html = String(value || '').trim();
|
|
if (!html) {
|
|
return '<div class="template-region-placeholder">HTML</div>';
|
|
}
|
|
return '<iframe class="template-region-html-frame" sandbox="" srcdoc="' + escapeHtml(html) + '" title="HTML region" loading="eager"></iframe>';
|
|
}
|
|
|
|
function fitCanvasSize(canvasWidth, canvasHeight, maxWidth, maxHeight) {
|
|
const width = Math.max(1, Number(canvasWidth || 0) || 1920);
|
|
const height = Math.max(1, Number(canvasHeight || 0) || 1080);
|
|
const viewportWidth = Math.max(1, Number(maxWidth || 0) || width);
|
|
const viewportHeight = Math.max(1, Number(maxHeight || 0) || height);
|
|
const scale = Math.min(viewportWidth / width, viewportHeight / height);
|
|
return {
|
|
width: Math.round(width * scale),
|
|
height: Math.round(height * scale)
|
|
};
|
|
}
|
|
|
|
const playerPageTemplatePath = path.join(__dirname, 'player-page.template.html');
|
|
const playerPageScriptPath = path.join(__dirname, 'player-page.script.html');
|
|
let playerPageTemplateCache = null;
|
|
let playerPageScriptCache = null;
|
|
|
|
function loadTemplate(filePath, cache) {
|
|
const stat = fs.statSync(filePath);
|
|
if (cache.value && cache.mtimeMs === stat.mtimeMs) {
|
|
return cache.value;
|
|
}
|
|
const compiled = Handlebars.compile(fs.readFileSync(filePath, 'utf8'));
|
|
cache.value = compiled;
|
|
cache.mtimeMs = stat.mtimeMs;
|
|
return compiled;
|
|
}
|
|
|
|
function getPlayerPageTemplate() {
|
|
return loadTemplate(playerPageTemplatePath, playerPageTemplateCache || (playerPageTemplateCache = {}));
|
|
}
|
|
|
|
function getPlayerPageScript() {
|
|
return loadTemplate(playerPageScriptPath, playerPageScriptCache || (playerPageScriptCache = {}));
|
|
}
|
|
|
|
function renderPlayerPage(slug, initialData) {
|
|
const template = getPlayerPageTemplate();
|
|
const script = getPlayerPageScript()({
|
|
SLUG_JSON: new Handlebars.SafeString(JSON.stringify(slug)),
|
|
INITIAL_DATA_JSON: new Handlebars.SafeString(safeJsonForScript(initialData || null))
|
|
});
|
|
|
|
return template({
|
|
TITLE: 'Screen ' + slug,
|
|
SCRIPT_BLOCK: new Handlebars.SafeString(script)
|
|
});
|
|
}
|
|
|
|
module.exports = {
|
|
mediaKind,
|
|
renderPlayerPage
|
|
};
|