mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
analytics and embed meta update!!!
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
// Purpose: Defines the embed Http Service module and the helpers/state used by this service unit.
|
||||
// Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary.
|
||||
const { app } = require('../../globals/http');
|
||||
const { renderIndexHtml, renderOgImage } = require('../embedService');
|
||||
const { renderIndexHtml, renderOgImage, renderWebManifest } = require('../embedService');
|
||||
|
||||
/*
|
||||
Every client-side BrowserRouter entry point must also be an explicit HTTP
|
||||
@@ -27,3 +27,13 @@ app.get('/og/preview.png', async (req, res) => {
|
||||
res.status(500).send('Failed to render embed image');
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/manifest.webmanifest', (req, res) => {
|
||||
/*
|
||||
The manifest varies with server configuration, so it is served by the
|
||||
application rather than copied into Vite's static output. Revalidation
|
||||
lets browsers pick up branding changes after the server is restarted.
|
||||
*/
|
||||
res.set('Cache-Control', 'no-cache');
|
||||
res.type('application/manifest+json').send(renderWebManifest());
|
||||
});
|
||||
|
||||
@@ -2,26 +2,60 @@
|
||||
// Purpose: Defines the embed Service module and the helpers/state used by this service unit.
|
||||
// Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary.
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const fsp = require('fs/promises');
|
||||
const sharp = require('sharp');
|
||||
|
||||
const logger = require('../../globals/logger').child('embedService');
|
||||
const { getMode } = require('../modeManager');
|
||||
const roverManager = require('../roverManager');
|
||||
const { getActiveDrivers, getTurnQueues } = require('../turnService');
|
||||
const { getRoomCameras } = require('../roomCameraService');
|
||||
const { getRoomCameraState } = require('../roomCameraService');
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
const { resolveDataPath } = require('../../helpers/dataPaths');
|
||||
const { resolveSiteMetadata } = require('../../helpers/siteMetadata');
|
||||
|
||||
const INDEX_HTML_PATH = path.join(__dirname, '..', '..', '..', 'public', 'index.html');
|
||||
const BITMAP_PATH = path.join(__dirname, '..', '..', '..', 'public', 'bitmap.png');
|
||||
const ANALYTICS_HTML_PATH = resolveDataPath('analytics.html');
|
||||
const ANALYTICS_PLACEHOLDER = '<!-- analytics:inject -->';
|
||||
const SITE_METADATA_PLACEHOLDER = '<!-- site-metadata:inject -->';
|
||||
|
||||
const OG_WIDTH = 1200;
|
||||
const OG_HEIGHT = 630;
|
||||
const BASE_BG = { r: 8, g: 12, b: 22 };
|
||||
|
||||
let cachedIndexHtml = null;
|
||||
let cachedIndexMtimeMs = 0;
|
||||
|
||||
/*
|
||||
Analytics provider markup belongs to the server operator, not to the shared
|
||||
web build. Loading the snippet once at process startup makes deployment
|
||||
behavior predictable: replacing analytics.html takes effect on the next
|
||||
normal server restart, and no analytics configuration needs to travel over
|
||||
Socket.IO or be exposed through a JSON endpoint.
|
||||
|
||||
This file is intentionally trusted as raw HTML. Anyone able to write files in
|
||||
the server data directory already controls the deployment, and allowing a
|
||||
complete head snippet is what keeps this integration compatible with Umami,
|
||||
Plausible, Matomo, or a custom provider without provider-specific server code.
|
||||
*/
|
||||
function loadAnalyticsHeadHtml() {
|
||||
if (!fs.existsSync(ANALYTICS_HTML_PATH)) return '';
|
||||
|
||||
try {
|
||||
return fs.readFileSync(ANALYTICS_HTML_PATH, 'utf8').trim();
|
||||
} catch (err) {
|
||||
/*
|
||||
Analytics is observability-only, so a permissions or read error must not
|
||||
prevent operators and drivers from loading the rover controls.
|
||||
*/
|
||||
logger.warn('Unable to read analytics head HTML; continuing without analytics', err.message);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
const analyticsHeadHtml = loadAnalyticsHeadHtml();
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value || '')
|
||||
.replace(/&/g, '&')
|
||||
@@ -52,6 +86,20 @@ function getBaseUrl(req) {
|
||||
return `${proto}://${host}`;
|
||||
}
|
||||
|
||||
function getPagePath(req) {
|
||||
/*
|
||||
Canonical URLs should describe the page rather than a tracking/query
|
||||
variant of it. Express's path value excludes the query string and is safe
|
||||
to combine with either the configured public URL or the current request.
|
||||
*/
|
||||
return req.path || '/';
|
||||
}
|
||||
|
||||
function joinPublicUrl(baseUrl, pagePath) {
|
||||
const normalizedPath = pagePath.startsWith('/') ? pagePath : `/${pagePath}`;
|
||||
return `${baseUrl}${normalizedPath}`;
|
||||
}
|
||||
|
||||
function getPrimaryRoomCamera() {
|
||||
const cameras = getRoomCameras();
|
||||
if (!cameras.length) return null;
|
||||
@@ -133,12 +181,12 @@ function buildEmbedCopy(state, camera) {
|
||||
};
|
||||
}
|
||||
|
||||
function buildMetaTags({ title, description, imageUrl, pageUrl }) {
|
||||
function buildMetaTags({ title, description, imageUrl, pageUrl, canonicalUrl }) {
|
||||
const safeTitle = escapeHtml(title);
|
||||
const safeDescription = escapeHtml(description);
|
||||
const safeImage = escapeHtml(imageUrl);
|
||||
const safeUrl = escapeHtml(pageUrl);
|
||||
return [
|
||||
const tags = [
|
||||
'<!-- embed meta -->',
|
||||
`<meta name="description" content="${safeDescription}" />`,
|
||||
`<meta property="og:title" content="${safeTitle}" />`,
|
||||
@@ -153,7 +201,27 @@ function buildMetaTags({ title, description, imageUrl, pageUrl }) {
|
||||
`<meta name="twitter:title" content="${safeTitle}" />`,
|
||||
`<meta name="twitter:description" content="${safeDescription}" />`,
|
||||
`<meta name="twitter:image" content="${safeImage}" />`,
|
||||
'<!-- /embed meta -->',
|
||||
];
|
||||
|
||||
/*
|
||||
Only advertise a canonical address when the operator supplied a valid
|
||||
public URL. Guessing from request headers would permanently identify a LAN
|
||||
hostname or reverse-proxy hop as the public home of the instance.
|
||||
*/
|
||||
if (canonicalUrl) {
|
||||
tags.push(`<link rel="canonical" href="${escapeHtml(canonicalUrl)}" />`);
|
||||
}
|
||||
tags.push('<!-- /embed meta -->');
|
||||
return tags.join('\n ');
|
||||
}
|
||||
|
||||
function buildSiteMetadataTags(siteMetadata) {
|
||||
return [
|
||||
'<!-- site metadata -->',
|
||||
`<meta name="theme-color" content="${escapeHtml(siteMetadata.accentColor)}" />`,
|
||||
`<meta name="apple-mobile-web-app-title" content="${escapeHtml(siteMetadata.shortName)}" />`,
|
||||
`<title>${escapeHtml(siteMetadata.name)}</title>`,
|
||||
'<!-- /site metadata -->',
|
||||
].join('\n ');
|
||||
}
|
||||
|
||||
@@ -165,23 +233,47 @@ async function renderIndexHtml(req) {
|
||||
activeDrivers: getActiveDrivers(),
|
||||
turnQueues: getTurnQueues(),
|
||||
};
|
||||
const config = loadConfig();
|
||||
const pageTitle = config?.site?.title || 'Roomba Rover';
|
||||
const siteMetadata = resolveSiteMetadata();
|
||||
const camera = getPrimaryRoomCamera();
|
||||
const copy = buildEmbedCopy(state, camera);
|
||||
const cacheBust = Math.floor(Date.now() / (5 * 60 * 1000));
|
||||
const imageUrl = `${baseUrl}/og/preview.png?t=${cacheBust}`;
|
||||
const pageUrl = `${baseUrl}${req.originalUrl || '/'}`;
|
||||
const pagePath = getPagePath(req);
|
||||
const canonicalUrl = siteMetadata.publicUrl
|
||||
? joinPublicUrl(siteMetadata.publicUrl, pagePath)
|
||||
: null;
|
||||
const pageUrl = canonicalUrl || joinPublicUrl(baseUrl, pagePath);
|
||||
|
||||
const metaBlock = buildMetaTags({
|
||||
title: pageTitle,
|
||||
description: copy.description,
|
||||
title: siteMetadata.name,
|
||||
description: siteMetadata.description,
|
||||
imageUrl,
|
||||
pageUrl,
|
||||
canonicalUrl,
|
||||
});
|
||||
const siteMetadataBlock = buildSiteMetadataTags(siteMetadata);
|
||||
|
||||
let html = await loadIndexHtml();
|
||||
html = html.replace(/<title>.*?<\/title>/i, `<title>${escapeHtml(pageTitle)}</title>`);
|
||||
/*
|
||||
Prefer the explicit marker so the insertion point remains stable across
|
||||
Vite output changes. The closing-head fallback also keeps deployed builds
|
||||
made before the marker was introduced compatible with the runtime loader.
|
||||
*/
|
||||
if (html.includes(ANALYTICS_PLACEHOLDER)) {
|
||||
html = html.replace(ANALYTICS_PLACEHOLDER, analyticsHeadHtml);
|
||||
} else if (analyticsHeadHtml) {
|
||||
html = html.replace('</head>', ` ${analyticsHeadHtml}\n </head>`);
|
||||
}
|
||||
/*
|
||||
Keeping all instance-specific head values behind one marker prevents the
|
||||
built index from carrying a second set of hardcoded titles and colors.
|
||||
The fallback supports an older built index during a rolling deployment.
|
||||
*/
|
||||
if (html.includes(SITE_METADATA_PLACEHOLDER)) {
|
||||
html = html.replace(SITE_METADATA_PLACEHOLDER, siteMetadataBlock);
|
||||
} else {
|
||||
html = html.replace('</head>', ` ${siteMetadataBlock}\n </head>`);
|
||||
}
|
||||
if (html.includes('<!-- embed meta -->')) {
|
||||
html = html.replace(/<!-- embed meta -->[\s\S]*?<!-- \/embed meta -->/i, metaBlock);
|
||||
} else {
|
||||
@@ -190,7 +282,7 @@ async function renderIndexHtml(req) {
|
||||
return html;
|
||||
}
|
||||
|
||||
function buildOverlaySvg({ title, subtitle, stats, cameraLabel, hasFrame }) {
|
||||
function buildOverlaySvg({ title, subtitle, stats, cameraLabel, hasFrame, accentColor, accentTextColor }) {
|
||||
const titleSize = 64;
|
||||
const subtitleSize = 34;
|
||||
const statsSize = 30;
|
||||
@@ -207,8 +299,8 @@ function buildOverlaySvg({ title, subtitle, stats, cameraLabel, hasFrame }) {
|
||||
</defs>
|
||||
<rect width="${OG_WIDTH}" height="${OG_HEIGHT}" fill="url(#fade)" />
|
||||
<rect x="56" y="48" width="210" height="40" rx="20" fill="rgba(0,0,0,0.55)" />
|
||||
<rect x="58" y="50" width="206" height="36" rx="18" fill="#22d3ee" />
|
||||
<text x="160" y="75" font-family="DejaVu Sans, Arial, sans-serif" font-size="20" font-weight="700" text-anchor="middle" fill="#001018">
|
||||
<rect x="58" y="50" width="206" height="36" rx="18" fill="${accentColor}" />
|
||||
<text x="160" y="75" font-family="DejaVu Sans, Arial, sans-serif" font-size="20" font-weight="700" text-anchor="middle" fill="${accentTextColor}">
|
||||
${escapeXml(badgeText)}
|
||||
</text>
|
||||
<text x="64" y="410" font-family="DejaVu Sans, Arial, sans-serif" font-size="${titleSize}" font-weight="700" fill="#ffffff">
|
||||
@@ -235,6 +327,7 @@ async function renderOgImage() {
|
||||
};
|
||||
const camera = getPrimaryRoomCamera();
|
||||
const copy = buildEmbedCopy(state, camera);
|
||||
const siteMetadata = resolveSiteMetadata();
|
||||
const cameraState = state.mode === 'lockdown' || !camera ? null : getRoomCameraState(camera.id);
|
||||
const frame = cameraState?.frame || null;
|
||||
const hasFrame = Boolean(frame);
|
||||
@@ -246,17 +339,19 @@ async function renderOgImage() {
|
||||
width: OG_WIDTH,
|
||||
height: OG_HEIGHT,
|
||||
channels: 3,
|
||||
background: BASE_BG,
|
||||
background: siteMetadata.backgroundColor,
|
||||
},
|
||||
});
|
||||
|
||||
const overlaySvg = Buffer.from(
|
||||
buildOverlaySvg({
|
||||
title: copy.title,
|
||||
title: siteMetadata.name,
|
||||
subtitle: copy.subtitle,
|
||||
stats: copy.stats,
|
||||
cameraLabel: copy.cameraLabel,
|
||||
hasFrame,
|
||||
accentColor: siteMetadata.accentColor,
|
||||
accentTextColor: siteMetadata.accentTextColor,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -272,7 +367,36 @@ async function renderOgImage() {
|
||||
return base.composite(composite).png().toBuffer();
|
||||
}
|
||||
|
||||
function renderWebManifest() {
|
||||
const siteMetadata = resolveSiteMetadata();
|
||||
|
||||
/*
|
||||
The manifest is generated from the same resolved values as the HTML and
|
||||
social image, so browser tabs, installed shortcuts, and launch screens do
|
||||
not drift into three separately configured identities.
|
||||
*/
|
||||
return JSON.stringify({
|
||||
name: siteMetadata.name,
|
||||
short_name: siteMetadata.shortName,
|
||||
description: siteMetadata.description,
|
||||
start_url: '/',
|
||||
scope: '/',
|
||||
display: 'standalone',
|
||||
background_color: siteMetadata.backgroundColor,
|
||||
theme_color: siteMetadata.accentColor,
|
||||
icons: [
|
||||
{
|
||||
src: '/bitmap.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
purpose: 'any',
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
renderIndexHtml,
|
||||
renderOgImage,
|
||||
renderWebManifest,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user