openvibers/openvibe.tools Commits

Satellite SEO: per-host canonical, Open Graph, Twitter cards and JSON-LD; OG images

OP OpenVibers committed 18 Sept 2026 · 2a9ee9680b · 15 files, +378 −6

img/audio/docs stamp each hostname's own title, description, canonical
(https://<host>/), og:* / twitter:* tags and structured data into index.html
on the way out (server/seo.js), instead of serving the hub's head everywhere.
yt/maps/food are single-host and carry the tags statically. Each app ships a
1200x630 og.png.

Changes

ddiff --git a/apps/audio/public/og.png b/apps/audio/public/og.png
nnew file mode 100644
iindex 0000000..976402d
BBinary files /dev/null and b/apps/audio/public/og.png differ
ddiff --git a/apps/audio/server/index.js b/apps/audio/server/index.js
iindex f42011c..4d3227e 100644
---- a/apps/audio/server/index.js
++++ b/apps/audio/server/index.js
@@@ -254,6 +254,7 @@ app.get('/api/internal/analytics/bots', (req, res) => {
// Public assets
app.use(express.static(path.join(__dirname, '..', 'public'), {
++ index: false, // '/' goes through the per-host SEO renderer below
setHeaders(res, filePath) {
if (filePath.endsWith('.js') || filePath.endsWith('.css')) {
res.setHeader('Cache-Control', 'no-cache');
@@@ -261,12 +262,14 @@ app.use(express.static(path.join(__dirname, '..', 'public'), {
},
}));
--// SPA fallback
++// SPA fallback — every host gets index.html stamped with its own title, canonical, social
++// tags and structured data (server/seo.js); the raw file carries only the hub's.
++const { sendIndex } = require('./seo');
app.get('*', (req, res) => {
if (req.path.startsWith('/api/')) {
return res.status(404).json({ error: 'Not found' });
}
-- res.sendFile(path.join(__dirname, '..', 'public', 'index.html'));
++ return sendIndex(req, res);
});
// ── Start ────────────────────────────────────────────────────
ddiff --git a/apps/audio/server/seo.js b/apps/audio/server/seo.js
nnew file mode 100644
iindex 0000000..eeb67e0
---- /dev/null
++++ b/apps/audio/server/seo.js
@@@ -0,0 +1,107 @@
++'use strict';
++
++// ═══════════════════════════════════════════════════════════════
++// Per-host <head> tags for the single-page app.
++//
++// One index.html is served for every hostname in domain-map.js. Served as-is it carries the
++// hub's title on every subdomain and no canonical at all, so search engines treat the tool
++// hosts as duplicates of each other and social shares have no card. This stamps each host's
++// own title, description, canonical, Open Graph / Twitter tags and structured data into the
++// HTML on the way out. Rendered variants are cached per host; the file itself is read once.
++// ═══════════════════════════════════════════════════════════════
++
++const fs = require('fs');
++const path = require('path');
++
++const PUBLIC = path.join(__dirname, '..', 'public');
++const HUB_HOST = 'audio.openvibe.tools';
++const HUB_NAME = 'Audio.OpenVibe';
++
++const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
++
++let _base = null;
++const _cache = new Map();
++
++function baseHtml() {
++ if (_base === null) _base = fs.readFileSync(path.join(PUBLIC, 'index.html'), 'utf8');
++ return _base;
++}
++
++/**
++ * Render index.html for one host with that host's own metadata.
++ * @param {object} ctx domain-map context (brandName, seoTitle, seoDescription, toolId)
++ * @param {string} host request hostname, e.g. 'png.openvibe.tools'
++ */
++function renderIndex(ctx, host) {
++ const key = host;
++ if (_cache.has(key)) return _cache.get(key);
++
++ const url = `https://${host}/`;
++ const title = ctx.seoTitle || ctx.brandName || HUB_NAME;
++ const desc = ctx.seoDescription || '';
++ const image = `https://${host}/og.png`;
++ let html = baseHtml();
++
++ // Replace what the file already sets, so there is never a second competing value.
++ html = html.replace(/<title>[\s\S]*?<\/title>/i, `<title>${esc(title)}</title>`);
++ html = html.replace(/<meta\s+name=["']description["'][^>]*>/i, `<meta name="description" content="${esc(desc)}">`);
++ html = html.replace(/<meta\s+property=["']og:title["'][^>]*>/i, `<meta property="og:title" content="${esc(title)}">`);
++ html = html.replace(/<meta\s+property=["']og:description["'][^>]*>/i, '');
++ html = html.replace(/<meta\s+property=["']og:url["'][^>]*>/i, '');
++ html = html.replace(/<meta\s+property=["']og:image["'][^>]*>/i, '');
++ html = html.replace(/<link\s+rel=["']canonical["'][^>]*>/i, '');
++
++ const ld = [
++ {
++ '@context': 'https://schema.org', '@type': 'WebApplication',
++ name: title, url, description: desc,
++ applicationCategory: 'UtilitiesApplication', operatingSystem: 'Any (web browser)',
++ image,
++ publisher: { '@type': 'Organization', name: 'OpenVibe', url: 'https://openvibe.tools' },
++ },
++ {
++ '@context': 'https://schema.org', '@type': 'BreadcrumbList',
++ itemListElement: [
++ { '@type': 'ListItem', position: 1, name: 'OpenVibe', item: 'https://openvibe.tools' },
++ { '@type': 'ListItem', position: 2, name: HUB_NAME, item: `https://${HUB_HOST}/` },
++ ...(host === HUB_HOST ? [] : [{ '@type': 'ListItem', position: 3, name: title, item: url }]),
++ ],
++ },
++ ];
++
++ const head = [
++ `<link rel="canonical" href="${url}">`,
++ '<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1">',
++ `<meta property="og:description" content="${esc(desc)}">`,
++ `<meta property="og:url" content="${url}">`,
++ `<meta property="og:image" content="${image}">`,
++ '<meta property="og:image:width" content="1200">',
++ '<meta property="og:image:height" content="630">',
++ '<meta property="og:site_name" content="OpenVibe">',
++ '<meta name="twitter:card" content="summary_large_image">',
++ `<meta name="twitter:title" content="${esc(title)}">`,
++ `<meta name="twitter:description" content="${esc(desc)}">`,
++ `<meta name="twitter:image" content="${image}">`,
++ ...ld.map(b => `<script type="application/ld+json">${JSON.stringify(b).replace(/</g, '\\u003c')}</script>`),
++ ].join('\n');
++
++ const out = html.replace(/<\/head>/i, `${head}\n</head>`);
++ if (_cache.size > 100) _cache.clear();
++ _cache.set(key, out);
++ return out;
++}
++
++/** Express handler: the SPA with this host's SEO tags applied. */
++function sendIndex(req, res) {
++ const host = String(req.headers.host || HUB_HOST).split(':')[0].toLowerCase();
++ try {
++ res.setHeader('Content-Type', 'text/html; charset=utf-8');
++ res.setHeader('Cache-Control', 'no-cache');
++ return res.send(renderIndex(req.ctx || {}, host));
++ } catch (err) {
++ console.error('[SEO] render failed:', err.message);
++ return res.sendFile(path.join(PUBLIC, 'index.html'));
++ }
++}
++
++module.exports = { renderIndex, sendIndex };
ddiff --git a/apps/docs/public/og.png b/apps/docs/public/og.png
nnew file mode 100644
iindex 0000000..8e0b93e
BBinary files /dev/null and b/apps/docs/public/og.png differ
ddiff --git a/apps/docs/server/index.js b/apps/docs/server/index.js
iindex 1e20018..17037d6 100644
---- a/apps/docs/server/index.js
++++ b/apps/docs/server/index.js
@@@ -264,6 +264,7 @@ app.get('/api/internal/analytics/bots', (req, res) => {
// Public assets
app.use(express.static(path.join(__dirname, '..', 'public'), {
++ index: false, // '/' goes through the per-host SEO renderer below
setHeaders(res, filePath) {
if (filePath.endsWith('.js') || filePath.endsWith('.css')) {
res.setHeader('Cache-Control', 'no-cache');
@@@ -271,12 +272,14 @@ app.use(express.static(path.join(__dirname, '..', 'public'), {
},
}));
--// SPA fallback
++// SPA fallback — every host gets index.html stamped with its own title, canonical, social
++// tags and structured data (server/seo.js); the raw file carries only the hub's.
++const { sendIndex } = require('./seo');
app.get('*', (req, res) => {
if (req.path.startsWith('/api/')) {
return res.status(404).json({ error: 'Not found' });
}
-- res.sendFile(path.join(__dirname, '..', 'public', 'index.html'));
++ return sendIndex(req, res);
});
// ── Start ────────────────────────────────────────────────────
ddiff --git a/apps/docs/server/seo.js b/apps/docs/server/seo.js
nnew file mode 100644
iindex 0000000..44ab7d7
---- /dev/null
++++ b/apps/docs/server/seo.js
@@@ -0,0 +1,107 @@
++'use strict';
++
++// ═══════════════════════════════════════════════════════════════
++// Per-host <head> tags for the single-page app.
++//
++// One index.html is served for every hostname in domain-map.js. Served as-is it carries the
++// hub's title on every subdomain and no canonical at all, so search engines treat the tool
++// hosts as duplicates of each other and social shares have no card. This stamps each host's
++// own title, description, canonical, Open Graph / Twitter tags and structured data into the
++// HTML on the way out. Rendered variants are cached per host; the file itself is read once.
++// ═══════════════════════════════════════════════════════════════
++
++const fs = require('fs');
++const path = require('path');
++
++const PUBLIC = path.join(__dirname, '..', 'public');
++const HUB_HOST = 'docs.openvibe.tools';
++const HUB_NAME = 'Docs.OpenVibe';
++
++const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
++
++let _base = null;
++const _cache = new Map();
++
++function baseHtml() {
++ if (_base === null) _base = fs.readFileSync(path.join(PUBLIC, 'index.html'), 'utf8');
++ return _base;
++}
++
++/**
++ * Render index.html for one host with that host's own metadata.
++ * @param {object} ctx domain-map context (brandName, seoTitle, seoDescription, toolId)
++ * @param {string} host request hostname, e.g. 'png.openvibe.tools'
++ */
++function renderIndex(ctx, host) {
++ const key = host;
++ if (_cache.has(key)) return _cache.get(key);
++
++ const url = `https://${host}/`;
++ const title = ctx.seoTitle || ctx.brandName || HUB_NAME;
++ const desc = ctx.seoDescription || '';
++ const image = `https://${host}/og.png`;
++ let html = baseHtml();
++
++ // Replace what the file already sets, so there is never a second competing value.
++ html = html.replace(/<title>[\s\S]*?<\/title>/i, `<title>${esc(title)}</title>`);
++ html = html.replace(/<meta\s+name=["']description["'][^>]*>/i, `<meta name="description" content="${esc(desc)}">`);
++ html = html.replace(/<meta\s+property=["']og:title["'][^>]*>/i, `<meta property="og:title" content="${esc(title)}">`);
++ html = html.replace(/<meta\s+property=["']og:description["'][^>]*>/i, '');
++ html = html.replace(/<meta\s+property=["']og:url["'][^>]*>/i, '');
++ html = html.replace(/<meta\s+property=["']og:image["'][^>]*>/i, '');
++ html = html.replace(/<link\s+rel=["']canonical["'][^>]*>/i, '');
++
++ const ld = [
++ {
++ '@context': 'https://schema.org', '@type': 'WebApplication',
++ name: title, url, description: desc,
++ applicationCategory: 'UtilitiesApplication', operatingSystem: 'Any (web browser)',
++ image,
++ publisher: { '@type': 'Organization', name: 'OpenVibe', url: 'https://openvibe.tools' },
++ },
++ {
++ '@context': 'https://schema.org', '@type': 'BreadcrumbList',
++ itemListElement: [
++ { '@type': 'ListItem', position: 1, name: 'OpenVibe', item: 'https://openvibe.tools' },
++ { '@type': 'ListItem', position: 2, name: HUB_NAME, item: `https://${HUB_HOST}/` },
++ ...(host === HUB_HOST ? [] : [{ '@type': 'ListItem', position: 3, name: title, item: url }]),
++ ],
++ },
++ ];
++
++ const head = [
++ `<link rel="canonical" href="${url}">`,
++ '<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1">',
++ `<meta property="og:description" content="${esc(desc)}">`,
++ `<meta property="og:url" content="${url}">`,
++ `<meta property="og:image" content="${image}">`,
++ '<meta property="og:image:width" content="1200">',
++ '<meta property="og:image:height" content="630">',
++ '<meta property="og:site_name" content="OpenVibe">',
++ '<meta name="twitter:card" content="summary_large_image">',
++ `<meta name="twitter:title" content="${esc(title)}">`,
++ `<meta name="twitter:description" content="${esc(desc)}">`,
++ `<meta name="twitter:image" content="${image}">`,
++ ...ld.map(b => `<script type="application/ld+json">${JSON.stringify(b).replace(/</g, '\\u003c')}</script>`),
++ ].join('\n');
++
++ const out = html.replace(/<\/head>/i, `${head}\n</head>`);
++ if (_cache.size > 100) _cache.clear();
++ _cache.set(key, out);
++ return out;
++}
++
++/** Express handler: the SPA with this host's SEO tags applied. */
++function sendIndex(req, res) {
++ const host = String(req.headers.host || HUB_HOST).split(':')[0].toLowerCase();
++ try {
++ res.setHeader('Content-Type', 'text/html; charset=utf-8');
++ res.setHeader('Cache-Control', 'no-cache');
++ return res.send(renderIndex(req.ctx || {}, host));
++ } catch (err) {
++ console.error('[SEO] render failed:', err.message);
++ return res.sendFile(path.join(PUBLIC, 'index.html'));
++ }
++}
++
++module.exports = { renderIndex, sendIndex };
ddiff --git a/apps/food/public/index.html b/apps/food/public/index.html
iindex 781f302..18e56b0 100644
---- a/apps/food/public/index.html
++++ b/apps/food/public/index.html
@@@ -5,6 +5,20 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Food.OpenVibe — Budget Grocery & Meal Planning</title>
<meta name="description" content="Budget grocery finder, meal planning optimizer, food bank locator, and nutrition database for survival on a budget.">
++ <link rel="canonical" href="https://food.openvibe.tools/">
++ <meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1">
++ <meta property="og:title" content="Food.OpenVibe — Budget Grocery & Meal Planning">
++ <meta property="og:description" content="Budget grocery finder, meal planning optimizer, food bank locator, and nutrition database for survival on a budget.">
++ <meta property="og:type" content="website">
++ <meta property="og:url" content="https://food.openvibe.tools/">
++ <meta property="og:image" content="https://food.openvibe.tools/og.png">
++ <meta property="og:image:width" content="1200">
++ <meta property="og:image:height" content="630">
++ <meta property="og:site_name" content="OpenVibe">
++ <meta name="twitter:card" content="summary_large_image">
++ <meta name="twitter:title" content="Food.OpenVibe — Budget Grocery & Meal Planning">
++ <meta name="twitter:description" content="Budget grocery finder, meal planning optimizer, food bank locator, and nutrition database for survival on a budget.">
++ <meta name="twitter:image" content="https://food.openvibe.tools/og.png">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<!-- Critical theme bootstrap (ov-theme-boot) — MUST stay inline and above the stylesheets.
ddiff --git a/apps/food/public/og.png b/apps/food/public/og.png
nnew file mode 100644
iindex 0000000..3ded206
BBinary files /dev/null and b/apps/food/public/og.png differ
ddiff --git a/apps/img/public/og.png b/apps/img/public/og.png
nnew file mode 100644
iindex 0000000..996b55f
BBinary files /dev/null and b/apps/img/public/og.png differ
ddiff --git a/apps/img/server/index.js b/apps/img/server/index.js
iindex 255d0af..abce0c3 100644
---- a/apps/img/server/index.js
++++ b/apps/img/server/index.js
@@@ -237,6 +237,7 @@ app.get('/api/internal/analytics/bots', (req, res) => {
// Public assets
app.use(express.static(path.join(__dirname, '..', 'public'), {
++ index: false, // '/' goes through the per-host SEO renderer below
setHeaders(res, filePath) {
if (filePath.endsWith('.js') || filePath.endsWith('.css')) {
res.setHeader('Cache-Control', 'no-cache');
@@@ -244,12 +245,14 @@ app.use(express.static(path.join(__dirname, '..', 'public'), {
},
}));
--// SPA fallback
++// SPA fallback — every host gets index.html stamped with its own title, canonical, social
++// tags and structured data (server/seo.js); the raw file carries only the hub's.
++const { sendIndex } = require('./seo');
app.get('*', (req, res) => {
if (req.path.startsWith('/api/')) {
return res.status(404).json({ error: 'Not found' });
}
-- res.sendFile(path.join(__dirname, '..', 'public', 'index.html'));
++ return sendIndex(req, res);
});
// ── Start ────────────────────────────────────────────────────
ddiff --git a/apps/img/server/seo.js b/apps/img/server/seo.js
nnew file mode 100644
iindex 0000000..1ad005c
---- /dev/null
++++ b/apps/img/server/seo.js
@@@ -0,0 +1,107 @@
++'use strict';
++
++// ═══════════════════════════════════════════════════════════════
++// Per-host <head> tags for the single-page app.
++//
++// One index.html is served for every hostname in domain-map.js. Served as-is it carries the
++// hub's title on every subdomain and no canonical at all, so search engines treat the tool
++// hosts as duplicates of each other and social shares have no card. This stamps each host's
++// own title, description, canonical, Open Graph / Twitter tags and structured data into the
++// HTML on the way out. Rendered variants are cached per host; the file itself is read once.
++// ═══════════════════════════════════════════════════════════════
++
++const fs = require('fs');
++const path = require('path');
++
++const PUBLIC = path.join(__dirname, '..', 'public');
++const HUB_HOST = 'img.openvibe.tools';
++const HUB_NAME = 'Img.OpenVibe';
++
++const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
++
++let _base = null;
++const _cache = new Map();
++
++function baseHtml() {
++ if (_base === null) _base = fs.readFileSync(path.join(PUBLIC, 'index.html'), 'utf8');
++ return _base;
++}
++
++/**
++ * Render index.html for one host with that host's own metadata.
++ * @param {object} ctx domain-map context (brandName, seoTitle, seoDescription, toolId)
++ * @param {string} host request hostname, e.g. 'png.openvibe.tools'
++ */
++function renderIndex(ctx, host) {
++ const key = host;
++ if (_cache.has(key)) return _cache.get(key);
++
++ const url = `https://${host}/`;
++ const title = ctx.seoTitle || ctx.brandName || HUB_NAME;
++ const desc = ctx.seoDescription || '';
++ const image = `https://${host}/og.png`;
++ let html = baseHtml();
++
++ // Replace what the file already sets, so there is never a second competing value.
++ html = html.replace(/<title>[\s\S]*?<\/title>/i, `<title>${esc(title)}</title>`);
++ html = html.replace(/<meta\s+name=["']description["'][^>]*>/i, `<meta name="description" content="${esc(desc)}">`);
++ html = html.replace(/<meta\s+property=["']og:title["'][^>]*>/i, `<meta property="og:title" content="${esc(title)}">`);
++ html = html.replace(/<meta\s+property=["']og:description["'][^>]*>/i, '');
++ html = html.replace(/<meta\s+property=["']og:url["'][^>]*>/i, '');
++ html = html.replace(/<meta\s+property=["']og:image["'][^>]*>/i, '');
++ html = html.replace(/<link\s+rel=["']canonical["'][^>]*>/i, '');
++
++ const ld = [
++ {
++ '@context': 'https://schema.org', '@type': 'WebApplication',
++ name: title, url, description: desc,
++ applicationCategory: 'UtilitiesApplication', operatingSystem: 'Any (web browser)',
++ image,
++ publisher: { '@type': 'Organization', name: 'OpenVibe', url: 'https://openvibe.tools' },
++ },
++ {
++ '@context': 'https://schema.org', '@type': 'BreadcrumbList',
++ itemListElement: [
++ { '@type': 'ListItem', position: 1, name: 'OpenVibe', item: 'https://openvibe.tools' },
++ { '@type': 'ListItem', position: 2, name: HUB_NAME, item: `https://${HUB_HOST}/` },
++ ...(host === HUB_HOST ? [] : [{ '@type': 'ListItem', position: 3, name: title, item: url }]),
++ ],
++ },
++ ];
++
++ const head = [
++ `<link rel="canonical" href="${url}">`,
++ '<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1">',
++ `<meta property="og:description" content="${esc(desc)}">`,
++ `<meta property="og:url" content="${url}">`,
++ `<meta property="og:image" content="${image}">`,
++ '<meta property="og:image:width" content="1200">',
++ '<meta property="og:image:height" content="630">',
++ '<meta property="og:site_name" content="OpenVibe">',
++ '<meta name="twitter:card" content="summary_large_image">',
++ `<meta name="twitter:title" content="${esc(title)}">`,
++ `<meta name="twitter:description" content="${esc(desc)}">`,
++ `<meta name="twitter:image" content="${image}">`,
++ ...ld.map(b => `<script type="application/ld+json">${JSON.stringify(b).replace(/</g, '\\u003c')}</script>`),
++ ].join('\n');
++
++ const out = html.replace(/<\/head>/i, `${head}\n</head>`);
++ if (_cache.size > 100) _cache.clear();
++ _cache.set(key, out);
++ return out;
++}
++
++/** Express handler: the SPA with this host's SEO tags applied. */
++function sendIndex(req, res) {
++ const host = String(req.headers.host || HUB_HOST).split(':')[0].toLowerCase();
++ try {
++ res.setHeader('Content-Type', 'text/html; charset=utf-8');
++ res.setHeader('Cache-Control', 'no-cache');
++ return res.send(renderIndex(req.ctx || {}, host));
++ } catch (err) {
++ console.error('[SEO] render failed:', err.message);
++ return res.sendFile(path.join(PUBLIC, 'index.html'));
++ }
++}
++
++module.exports = { renderIndex, sendIndex };
ddiff --git a/apps/maps/public/index.html b/apps/maps/public/index.html
iindex 20e10b2..ae03b15 100644
---- a/apps/maps/public/index.html
++++ b/apps/maps/public/index.html
@@@ -5,6 +5,20 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Maps.OpenVibe — Survival Map for North America</title>
<meta name="description" content="Find free camping, dispersed campsites, shelters, resources, food banks, and survival information across North America.">
++ <link rel="canonical" href="https://maps.openvibe.tools/">
++ <meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1">
++ <meta property="og:title" content="Maps.OpenVibe — Survival Map for North America">
++ <meta property="og:description" content="Find free camping, dispersed campsites, shelters, resources, food banks, and survival information across North America.">
++ <meta property="og:type" content="website">
++ <meta property="og:url" content="https://maps.openvibe.tools/">
++ <meta property="og:image" content="https://maps.openvibe.tools/og.png">
++ <meta property="og:image:width" content="1200">
++ <meta property="og:image:height" content="630">
++ <meta property="og:site_name" content="OpenVibe">
++ <meta name="twitter:card" content="summary_large_image">
++ <meta name="twitter:title" content="Maps.OpenVibe — Survival Map for North America">
++ <meta name="twitter:description" content="Find free camping, dispersed campsites, shelters, resources, food banks, and survival information across North America.">
++ <meta name="twitter:image" content="https://maps.openvibe.tools/og.png">
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
ddiff --git a/apps/maps/public/og.png b/apps/maps/public/og.png
nnew file mode 100644
iindex 0000000..ed8651e
BBinary files /dev/null and b/apps/maps/public/og.png differ
ddiff --git a/apps/yt/public/index.html b/apps/yt/public/index.html
iindex 73b840d..d1e0471 100644
---- a/apps/yt/public/index.html
++++ b/apps/yt/public/index.html
@@@ -5,6 +5,20 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>YT.OpenVibe — YouTube Downloader</title>
<meta name="description" content="Download YouTube videos and audio. MP4, WebM, MP3, M4A, OPUS, FLAC. No sign-up required.">
++ <link rel="canonical" href="https://yt.openvibe.tools/">
++ <meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1">
++ <meta property="og:title" content="YT.OpenVibe — YouTube Downloader">
++ <meta property="og:description" content="Download YouTube videos and audio. MP4, WebM, MP3, M4A, OPUS, FLAC. No sign-up required.">
++ <meta property="og:type" content="website">
++ <meta property="og:url" content="https://yt.openvibe.tools/">
++ <meta property="og:image" content="https://yt.openvibe.tools/og.png">
++ <meta property="og:image:width" content="1200">
++ <meta property="og:image:height" content="630">
++ <meta property="og:site_name" content="OpenVibe">
++ <meta name="twitter:card" content="summary_large_image">
++ <meta name="twitter:title" content="YT.OpenVibe — YouTube Downloader">
++ <meta name="twitter:description" content="Download YouTube videos and audio. MP4, WebM, MP3, M4A, OPUS, FLAC. No sign-up required.">
++ <meta name="twitter:image" content="https://yt.openvibe.tools/og.png">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<!-- Critical theme bootstrap (ov-theme-boot) — MUST stay inline and above the stylesheets.
ddiff --git a/apps/yt/public/og.png b/apps/yt/public/og.png
nnew file mode 100644
iindex 0000000..5b38a71
BBinary files /dev/null and b/apps/yt/public/og.png differ