const fs = require('fs'); const path = require('path'); const announcementIcons = require('#src/data/announcement-icons'); 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, '''); } function normalizeStyleAttributeValue(value) { return String(value || '') .replace(/"/g, '"') .replace(/'/g, "'") .replace(/&quot;/g, '"') .replace(/&#39;/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 = ['a', 'b', 'blockquote', 'br', 'code', 'col', 'colgroup', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'img', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul']; function sanitizeRichTextAttributes(tagName, attrText) { const allowedAttributes = { a: ['href', 'title', 'target', 'rel', 'class', 'style'], blockquote: ['class', 'style'], div: ['class', 'style'], figure: ['class', 'style'], figcaption: ['class', 'style'], h1: ['class', 'style'], h2: ['class', 'style'], h3: ['class', 'style'], h4: ['class', 'style'], h5: ['class', 'style'], h6: ['class', 'style'], img: ['src', 'alt', 'title', 'width', 'height', 'class', 'style', 'loading', 'decoding'], col: ['class', 'style', 'span', 'width'], colgroup: ['class', 'style', 'span'], li: ['class', 'style'], ol: ['class', 'style', 'start'], p: ['class', 'style'], pre: ['class', 'style'], span: ['class', 'style'], table: ['class', 'style'], td: ['class', 'style', 'colspan', 'rowspan'], th: ['class', 'style', 'colspan', 'rowspan', 'scope'], tr: ['class', 'style'], ul: ['class', 'style'] }; const allowed = allowedAttributes[tagName] || []; if (!allowed.length) { return ''; } if (tagName === 'img') { const srcMatch = String(attrText || '').match(/\bsrc\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+))/i); const srcValue = srcMatch ? String(srcMatch[2] !== undefined ? srcMatch[2] : srcMatch[3] !== undefined ? srcMatch[3] : srcMatch[4] !== undefined ? srcMatch[4] : '').trim() : ''; if (!srcValue || !/^(?:https?:\/\/|\/media\/|\/assets\/|\/[^/]|data:image\/)/i.test(srcValue)) { return ''; } } const attrs = []; String(attrText || '').replace(/([a-zA-Z0-9:-]+)(?:\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+)))?/g, (_full, key, _valuePart, doubleQuoted, singleQuoted, bareValue) => { const lowerKey = String(key || '').toLowerCase(); if (!allowed.includes(lowerKey)) { return ''; } const value = doubleQuoted !== undefined ? doubleQuoted : singleQuoted !== undefined ? singleQuoted : bareValue !== undefined ? bareValue : ''; if (lowerKey === 'href' && /^(?:\s*javascript:|\s*data:)/i.test(String(value || ''))) { return ''; } if (lowerKey === 'style' && /(?:expression\s*\(|javascript:|url\s*\()/i.test(String(value || ''))) { return ''; } if (lowerKey === 'target') { const targetValue = String(value || '').trim(); if (targetValue === '_blank') { attrs.push(' target="_blank"'); if (!attrs.includes(' rel="noreferrer noopener"')) { attrs.push(' rel="noreferrer noopener"'); } return ''; } } attrs.push(' ' + lowerKey + '="' + escapeHtml(lowerKey === 'style' ? normalizeStyleAttributeValue(value) : value) + '"'); return ''; }); return attrs.join(''); } function safeJsonForScript(value) { return JSON.stringify(value === undefined ? null : value).replace(//gi, ''); output = output.replace(//gi, ''); return output.replace(/<[^>]+>/g, (tag) => { const match = tag.match(/^<\s*(\/?)\s*([a-z0-9]+)([\s\S]*?)(\/?)>$/i); if (!match) { return ''; } const closing = Boolean(match[1]); const name = String(match[2] || '').toLowerCase(); const attrText = String(match[3] || ''); if (!ALLOWED_RICH_TEXT_TAGS.includes(name)) { return ''; } if (closing) { return ``; } return `<${name}${sanitizeRichTextAttributes(name, attrText)}>`; }); } 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 '' + sanitizeRichText(block.data.text || '') + ''; } 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('') + ''; } else if (block.type === 'delimiter') { return '
'; } else if (block.type === 'code') { return '
' + escapeHtml(block.data.code || '') + '
'; } else if (block.type === 'table') { return renderEditorJsTable(block.data); } else if (block.type === 'paragraph') { return '

' + sanitizeRichText(block.data.text || '') + '

'; } 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('') + '' : ''; return '
  • ' + sanitizeRichText(content || '') + nested + '
  • '; } return '
  • ' + sanitizeRichText(item || '') + '
  • '; } 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 '' + cells.map(function (cell) { return '<' + cellTag + cellAttrs + '>' + sanitizeRichText(cell || '') + ''; }).join('') + ''; }).join(''); return '' + tableRows + '
    '; } function wrapRichTextParagraph(html) { const raw = String(html || '').trim(); if (!raw) { return ''; } if (/^<\s*(?:p|div|h[1-6]|ul|ol|pre|table|blockquote|figure|hr|li)\b/i.test(raw)) { return raw; } return '

    ' + raw + '

    '; } 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 wrapRichTextParagraph(sanitizeRichText(raw)); } function buildHtmlDocument(html) { const raw = String(html || '').trim(); if (!raw) { return ''; } if (/^' + raw + ''; } function renderHtmlRegionContent(value) { const html = normalizeRenderableValue(value).trim(); if (!html) { return '
    HTML
    '; } return ''; } function normalizeRenderableValue(value) { if (value && typeof value === 'object') { if (value.value !== undefined) { return normalizeRenderableValue(value.value); } if (value.text !== undefined) { return normalizeRenderableValue(value.text); } if (value.html !== undefined) { return normalizeRenderableValue(value.html); } if (value.content !== undefined) { return normalizeRenderableValue(value.content); } return ''; } return String(value === undefined || value === null ? '' : value); } 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 playerClientNameScriptPath = path.join(__dirname, 'player-client-name.script.html'); const playerPageOfflineScriptPath = path.join(__dirname, 'public', 'js', 'player-page-offline.js'); const playerPagePlaylistScriptPath = path.join(__dirname, 'public', 'js', 'player-page-playlist.js'); const playerPageCommandsScriptPath = path.join(__dirname, 'public', 'js', 'player-page-commands.js'); const playerPageRenderingScriptPath = path.join(__dirname, 'public', 'js', 'player-page-rendering.js'); const playerPagePlaybackScriptPath = path.join(__dirname, 'public', 'js', 'player-page-playback.js'); const playerAnnouncementTemplatesScriptPath = path.join(__dirname, 'public', 'js', 'player-announcement-templates.js'); const playerAnnouncementLowerThirdTemplatePath = path.join(__dirname, 'announcement-templates', 'lower-third.template.html'); const playerAnnouncementTopBannerTemplatePath = path.join(__dirname, 'announcement-templates', 'top-banner.template.html'); const playerAnnouncementFullscreenTemplatePath = path.join(__dirname, 'announcement-templates', 'fullscreen.template.html'); const playerPageAnnouncementsScriptPath = path.join(__dirname, 'player-page-announcements.js'); const playerPageScriptPath = path.join(__dirname, 'player-page.script.html'); const playerRegionScriptDir = path.join(__dirname, 'regions'); const playerOnboardingLandingScriptPath = path.join(__dirname, 'onboarding', 'player-onboarding-landing.script.html'); const playerOnboardingFormScriptPath = path.join(__dirname, 'onboarding', 'player-onboarding-form.script.html'); let playerPageTemplateCache = null; let playerClientNameScriptCache = null; let playerPageOfflineScriptCache = null; let playerPagePlaylistScriptCache = null; let playerPageCommandsScriptCache = null; let playerPageRenderingScriptCache = null; let playerPagePlaybackScriptCache = null; let playerAnnouncementTemplatesScriptCache = null; let playerAnnouncementTemplatesDataScriptCache = null; let playerPageAnnouncementsScriptCache = null; let playerPageScriptCache = null; let playerRegionScriptsCache = null; let playerOnboardingLandingScriptCache = null; let playerOnboardingFormScriptCache = null; function loadTemplate(filePath, cache) { const stat = fs.statSync(filePath); if (cache.value && cache.mtimeMs === stat.mtimeMs) { return cache.value; } const compiled = require('handlebars').compile(fs.readFileSync(filePath, 'utf8')); cache.value = compiled; cache.mtimeMs = stat.mtimeMs; return compiled; } function getPlayerPageTemplate() { return loadTemplate(playerPageTemplatePath, playerPageTemplateCache || (playerPageTemplateCache = {})); } function getPlayerClientNameScript() { return loadTemplate(playerClientNameScriptPath, playerClientNameScriptCache || (playerClientNameScriptCache = {})); } function getPlayerPageOfflineScript() { return loadTemplate(playerPageOfflineScriptPath, playerPageOfflineScriptCache || (playerPageOfflineScriptCache = {})); } function getPlayerPagePlaylistScript() { return loadTemplate(playerPagePlaylistScriptPath, playerPagePlaylistScriptCache || (playerPagePlaylistScriptCache = {})); } function getPlayerPageCommandsScript() { return loadTemplate(playerPageCommandsScriptPath, playerPageCommandsScriptCache || (playerPageCommandsScriptCache = {})); } function getPlayerPageRenderingScript() { return loadTemplate(playerPageRenderingScriptPath, playerPageRenderingScriptCache || (playerPageRenderingScriptCache = {})); } function getPlayerPagePlaybackScript() { return loadTemplate(playerPagePlaybackScriptPath, playerPagePlaybackScriptCache || (playerPagePlaybackScriptCache = {})); } function getPlayerAnnouncementTemplatesScript() { if (playerAnnouncementTemplatesScriptCache && playerAnnouncementTemplatesScriptCache.path === playerAnnouncementTemplatesScriptPath) { return playerAnnouncementTemplatesScriptCache.value; } const value = fs.readFileSync(playerAnnouncementTemplatesScriptPath, 'utf8').trim(); playerAnnouncementTemplatesScriptCache = { path: playerAnnouncementTemplatesScriptPath, value: value }; return value; } function getAnnouncementIconsDataScript() { return [ '(function () {', ' window.pulseAnnouncementIconKeys = ' + safeJsonForScript(announcementIcons.ANNOUNCEMENT_ICON_KEYS) + ';', ' window.pulseAnnouncementDefaultIconKey = ' + safeJsonForScript(announcementIcons.DEFAULT_ANNOUNCEMENT_ICON) + ';', '}());' ].join('\n'); } function loadAnnouncementTemplate(filePath) { return fs.readFileSync(filePath, 'utf8').trim(); } function getPlayerAnnouncementTemplatesDataScript() { const templatePaths = [ playerAnnouncementLowerThirdTemplatePath, playerAnnouncementTopBannerTemplatePath, playerAnnouncementFullscreenTemplatePath ]; const signature = templatePaths.map(function (filePath) { return fs.statSync(filePath).mtimeMs; }).join('|'); if (playerAnnouncementTemplatesDataScriptCache && playerAnnouncementTemplatesDataScriptCache.signature === signature) { return playerAnnouncementTemplatesDataScriptCache.value; } const value = [ '(function () {', ' window.playerAnnouncementTemplates = {', ' lowerThird: ' + safeJsonForScript(loadAnnouncementTemplate(playerAnnouncementLowerThirdTemplatePath)) + ',', ' topBanner: ' + safeJsonForScript(loadAnnouncementTemplate(playerAnnouncementTopBannerTemplatePath)) + ',', ' fullscreen: ' + safeJsonForScript(loadAnnouncementTemplate(playerAnnouncementFullscreenTemplatePath)), ' };', '}());' ].join('\n'); playerAnnouncementTemplatesDataScriptCache = { signature: signature, value: value }; return value; } function getPlayerPageAnnouncementsScript() { return loadTemplate(playerPageAnnouncementsScriptPath, playerPageAnnouncementsScriptCache || (playerPageAnnouncementsScriptCache = {})); } function getPlayerPageScript() { return loadTemplate(playerPageScriptPath, playerPageScriptCache || (playerPageScriptCache = {})); } function getPlayerRegionScriptPaths() { if (!fs.existsSync(playerRegionScriptDir)) { return []; } return fs.readdirSync(playerRegionScriptDir, { withFileTypes: true }) .filter(function (entry) { return entry.isFile() && entry.name.toLowerCase().endsWith('.js'); }) .map(function (entry) { return path.join(playerRegionScriptDir, entry.name); }) .sort(function (left, right) { return left.localeCompare(right); }); } function getPlayerRegionScripts() { const sharedPlaceholderUtilsPath = path.join(__dirname, '..', 'web', 'public', 'js', 'shared', 'placeholder-utils.js'); const sharedQrSvgUtilsPath = path.join(__dirname, '..', 'web', 'public', 'js', 'shared', 'qr-code-svg.js'); const playerRegionScriptPaths = getPlayerRegionScriptPaths(); const scriptPaths = [sharedPlaceholderUtilsPath, sharedQrSvgUtilsPath].concat(playerRegionScriptPaths); const statSignature = scriptPaths.map(function (filePath) { return fs.statSync(filePath).mtimeMs; }).join('|'); if (playerRegionScriptsCache && playerRegionScriptsCache.signature === statSignature) { return playerRegionScriptsCache.value; } const value = scriptPaths.map(function (filePath) { return fs.readFileSync(filePath, 'utf8').trim(); }).join('\n\n'); playerRegionScriptsCache = { signature: statSignature, value: value }; return value; } function getPlayerOnboardingLandingScript() { return loadTemplate(playerOnboardingLandingScriptPath, playerOnboardingLandingScriptCache || (playerOnboardingLandingScriptCache = {})); } function getPlayerOnboardingFormScript() { return loadTemplate(playerOnboardingFormScriptPath, playerOnboardingFormScriptCache || (playerOnboardingFormScriptCache = {})); } module.exports = { mediaKind: mediaKind, escapeHtml: escapeHtml, sanitizeFontFamily: sanitizeFontFamily, sanitizeFontSize: sanitizeFontSize, sanitizeTextColor: sanitizeTextColor, sanitizeRichTextAttributes: sanitizeRichTextAttributes, safeJsonForScript: safeJsonForScript, parseMaybeJson: parseMaybeJson, normalizeContentValue: normalizeContentValue, normalizeSlide: normalizeSlide, sanitizeRichText: sanitizeRichText, renderEditorJsBlock: renderEditorJsBlock, renderEditorJsListItem: renderEditorJsListItem, renderEditorJsTable: renderEditorJsTable, renderEditorJsContent: renderEditorJsContent, renderHtmlRegionContent: renderHtmlRegionContent, fitCanvasSize: fitCanvasSize, loadTemplate: loadTemplate, getPlayerPageTemplate: getPlayerPageTemplate, getPlayerClientNameScript: getPlayerClientNameScript, getPlayerPageOfflineScript: getPlayerPageOfflineScript, getPlayerPagePlaylistScript: getPlayerPagePlaylistScript, getPlayerPageCommandsScript: getPlayerPageCommandsScript, getPlayerPageRenderingScript: getPlayerPageRenderingScript, getPlayerPagePlaybackScript: getPlayerPagePlaybackScript, getAnnouncementIconsDataScript: getAnnouncementIconsDataScript, getPlayerAnnouncementTemplatesDataScript: getPlayerAnnouncementTemplatesDataScript, getPlayerAnnouncementTemplatesScript: getPlayerAnnouncementTemplatesScript, getPlayerPageAnnouncementsScript: getPlayerPageAnnouncementsScript, getPlayerPageScript: getPlayerPageScript, getPlayerRegionScripts: getPlayerRegionScripts, getPlayerOnboardingLandingScript: getPlayerOnboardingLandingScript, getPlayerOnboardingFormScript: getPlayerOnboardingFormScript };