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:
@@ -0,0 +1,22 @@
|
||||
<!--
|
||||
Umami example for the optional provider-neutral rover analytics bridge.
|
||||
|
||||
Copy this file to analytics.html in the same data directory, replace the
|
||||
example URLs and attributes, and restart the server. The server injects the
|
||||
copied file into every web UI entry page; this example filename is not loaded
|
||||
automatically.
|
||||
-->
|
||||
<!-- Replace these example URLs, website IDs, domains, and recorder settings. -->
|
||||
<script defer src="https://analytics.example.com/script.js" data-website-id="replace-with-website-id" data-domains="rover.example.com"></script>
|
||||
<script defer src="https://analytics.example.com/recorder.js" data-website-id="replace-with-website-id" data-domains="rover.example.com" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
|
||||
|
||||
<script>
|
||||
window.roverAnalytics = {
|
||||
track: function (name, data) {
|
||||
window.umami?.track(name, data);
|
||||
},
|
||||
identify: function (data) {
|
||||
window.umami?.identify(data);
|
||||
},
|
||||
};
|
||||
</script>
|
||||
+2
-2
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -4,82 +4,16 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/bitmap.png" />
|
||||
<link rel="apple-touch-icon" href="/bitmap.png" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<!-- The server renders this manifest so installed shortcuts use the local instance's configured branding. -->
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<!-- Mobile driving uses dense press controls, so the viewport opts out of browser zoom gestures that can steal touches from the controls. -->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#020617" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Roomba Rover" />
|
||||
<!-- place analytics tags here and they will be injected into <head> of index.html at build time of the web UI. -->
|
||||
<!-- these tags are loaded PAGE-WIDE, this means /, /spectate, /mini, etc. -->
|
||||
|
||||
<script>
|
||||
/*
|
||||
Build-time analytics adapter for the rover UI.
|
||||
|
||||
React only calls window.roverAnalytics.track/identify. Keeping the Umami
|
||||
adapter here means analytics can still be removed, replaced, or configured
|
||||
by changing this injected file instead of rebuilding app logic around a
|
||||
specific analytics provider.
|
||||
*/
|
||||
(function () {
|
||||
var pendingCalls = [];
|
||||
var flushTimer = null;
|
||||
|
||||
function callUmami(method, args) {
|
||||
if (!window.umami || typeof window.umami[method] !== 'function') return false;
|
||||
window.umami[method].apply(window.umami, args);
|
||||
return true;
|
||||
}
|
||||
|
||||
function flushPendingCalls() {
|
||||
if (!pendingCalls.length) return;
|
||||
if (!window.umami) return;
|
||||
|
||||
pendingCalls = pendingCalls.filter(function (call) {
|
||||
return !callUmami(call.method, call.args);
|
||||
});
|
||||
|
||||
if (!pendingCalls.length && flushTimer) {
|
||||
window.clearInterval(flushTimer);
|
||||
flushTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function enqueue(method, args) {
|
||||
if (callUmami(method, args)) return;
|
||||
pendingCalls.push({ method: method, args: args });
|
||||
|
||||
/*
|
||||
The React app may fire route/session events before Umami's deferred
|
||||
script has executed. Queueing preserves those early events while still
|
||||
letting the whole adapter no-op harmlessly if the script is blocked.
|
||||
*/
|
||||
if (!flushTimer) {
|
||||
flushTimer = window.setInterval(flushPendingCalls, 500);
|
||||
}
|
||||
}
|
||||
|
||||
window.roverAnalytics = {
|
||||
track: function (name, data) {
|
||||
enqueue('track', typeof data === 'undefined' ? [name] : [name, data]);
|
||||
},
|
||||
identify: function (data) {
|
||||
enqueue('identify', [data || {}]);
|
||||
},
|
||||
};
|
||||
|
||||
window.addEventListener('load', flushPendingCalls);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- otterlytics testing for blocking local -->
|
||||
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
|
||||
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
|
||||
<title>Roomba Rover</title>
|
||||
<script type="module" crossorigin src="/assets/index-B8HPO9Qz.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-C3R_zn0v.css">
|
||||
<!-- site-metadata:inject -->
|
||||
<!-- analytics:inject -->
|
||||
<script type="module" crossorigin src="/assets/index-B0pLdqo3.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CoK6vO1j.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"name": "Multi Roomba Rover",
|
||||
"short_name": "MRR",
|
||||
"description": "Remote driving interface for the MultiRoomba Rover fleet.",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#000000",
|
||||
"theme_color": "#020617",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/bitmap.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
// Site Metadata Helper
|
||||
// Purpose: Resolves the public name, description, and colors used before the web UI starts.
|
||||
// Scope: Keeps document/PWA branding server-rendered and independent of Socket.IO session state.
|
||||
const { loadConfig } = require('./configLoader');
|
||||
|
||||
const DEFAULT_SITE_METADATA = Object.freeze({
|
||||
name: 'Multi Roomba Rover',
|
||||
shortName: 'Multi Roomba Rover',
|
||||
description: 'Drive and watch remote rovers from your browser.',
|
||||
accentColor: '#38bdf8',
|
||||
backgroundColor: '#020617',
|
||||
publicUrl: null,
|
||||
});
|
||||
|
||||
const BACKGROUND_BLEND_AMOUNT = 0.15;
|
||||
|
||||
function asTrimmedString(value) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function normalizeHexColor(value) {
|
||||
const color = asTrimmedString(value).toLowerCase();
|
||||
|
||||
/*
|
||||
Supporting both common CSS hex forms keeps the operator-facing setting
|
||||
forgiving while still preventing arbitrary CSS from being injected into
|
||||
generated HTML and SVG attributes.
|
||||
*/
|
||||
if (/^#[0-9a-f]{6}$/.test(color)) return color;
|
||||
if (/^#[0-9a-f]{3}$/.test(color)) {
|
||||
return `#${color.slice(1).split('').map((character) => character.repeat(2)).join('')}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function blendHexColors(baseColor, accentColor, accentAmount) {
|
||||
const base = baseColor.slice(1).match(/.{2}/g).map((channel) => Number.parseInt(channel, 16));
|
||||
const accent = accentColor.slice(1).match(/.{2}/g).map((channel) => Number.parseInt(channel, 16));
|
||||
|
||||
/*
|
||||
The profile color is deliberately only a tint. A full-strength profile
|
||||
color could produce a glaring PWA launch screen, while this blend preserves
|
||||
the application's established dark appearance and still makes each server
|
||||
visually recognizable.
|
||||
*/
|
||||
const channels = base.map((channel, index) =>
|
||||
Math.round(channel * (1 - accentAmount) + accent[index] * accentAmount),
|
||||
);
|
||||
return `#${channels.map((channel) => channel.toString(16).padStart(2, '0')).join('')}`;
|
||||
}
|
||||
|
||||
function normalizePublicUrl(value) {
|
||||
const candidate = asTrimmedString(value);
|
||||
if (!candidate) return null;
|
||||
|
||||
/*
|
||||
URL() helpfully repairs strings such as `http:192.168.0.1`, but preserving
|
||||
that typo in public metadata would conceal a configuration mistake. Require
|
||||
the conventional absolute URL form so the published address is explicit.
|
||||
*/
|
||||
if (!/^https?:\/\//i.test(candidate)) return null;
|
||||
|
||||
try {
|
||||
const url = new URL(candidate);
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
|
||||
|
||||
/*
|
||||
Removing a trailing slash gives callers one stable base URL to combine
|
||||
with paths. Invalid values are ignored instead of producing broken
|
||||
canonical and social metadata on every page.
|
||||
*/
|
||||
return url.toString().replace(/\/$/, '');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getReadableAccentText(accentColor) {
|
||||
const channels = accentColor.slice(1).match(/.{2}/g).map((channel) => Number.parseInt(channel, 16));
|
||||
const luminance = (channels[0] * 299 + channels[1] * 587 + channels[2] * 114) / 1000;
|
||||
|
||||
// A simple luminance split keeps the generated preview badge legible for both dark and light profile colors.
|
||||
return luminance > 150 ? '#020617' : '#ffffff';
|
||||
}
|
||||
|
||||
function resolveSiteMetadata(config = loadConfig()) {
|
||||
const interInstance = config?.interInstance;
|
||||
const profile = interInstance?.profile;
|
||||
const profileName = asTrimmedString(profile?.name);
|
||||
|
||||
/*
|
||||
A partially filled profile must not unexpectedly rename the site. The
|
||||
inter-instance feature must be explicitly enabled and have a usable name
|
||||
before any profile branding is applied; otherwise every value comes from
|
||||
the coherent default set above.
|
||||
*/
|
||||
if (interInstance?.enabled !== true || !profileName) {
|
||||
return { ...DEFAULT_SITE_METADATA, accentTextColor: getReadableAccentText(DEFAULT_SITE_METADATA.accentColor) };
|
||||
}
|
||||
|
||||
const accentColor = normalizeHexColor(profile.color) || DEFAULT_SITE_METADATA.accentColor;
|
||||
return {
|
||||
name: profileName,
|
||||
shortName: profileName,
|
||||
description: asTrimmedString(profile.description) || DEFAULT_SITE_METADATA.description,
|
||||
accentColor,
|
||||
backgroundColor: blendHexColors(
|
||||
DEFAULT_SITE_METADATA.backgroundColor,
|
||||
accentColor,
|
||||
BACKGROUND_BLEND_AMOUNT,
|
||||
),
|
||||
accentTextColor: getReadableAccentText(accentColor),
|
||||
publicUrl: normalizePublicUrl(profile.publicUrl),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_SITE_METADATA,
|
||||
resolveSiteMetadata,
|
||||
};
|
||||
@@ -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