openvibers/openvibe.tools Commits
Satellite SEO: per-host canonical, Open Graph, Twitter cards and JSON-LD; OG images
Changes
nnew file mode 100644BBinary files /dev/null and b/apps/audio/public/og.png differ@@@ -254,6 +254,7 @@ app.get('/api/internal/analytics/bots', (req, res) => {// Public assetsapp.use(express.static(path.join(__dirname, '..', 'public'), {++ index: false, // '/' goes through the per-host SEO renderer belowsetHeaders(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 ────────────────────────────────────────────────────nnew file mode 100644@@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 };nnew file mode 100644BBinary files /dev/null and b/apps/docs/public/og.png differ@@@ -264,6 +264,7 @@ app.get('/api/internal/analytics/bots', (req, res) => {// Public assetsapp.use(express.static(path.join(__dirname, '..', 'public'), {++ index: false, // '/' goes through the per-host SEO renderer belowsetHeaders(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 ────────────────────────────────────────────────────nnew file mode 100644@@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 };@@@ -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.nnew file mode 100644BBinary files /dev/null and b/apps/food/public/og.png differnnew file mode 100644BBinary files /dev/null and b/apps/img/public/og.png differ@@@ -237,6 +237,7 @@ app.get('/api/internal/analytics/bots', (req, res) => {// Public assetsapp.use(express.static(path.join(__dirname, '..', 'public'), {++ index: false, // '/' goes through the per-host SEO renderer belowsetHeaders(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 ────────────────────────────────────────────────────nnew file mode 100644@@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 };@@@ -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">nnew file mode 100644BBinary files /dev/null and b/apps/maps/public/og.png differ@@@ -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.nnew file mode 100644BBinary files /dev/null and b/apps/yt/public/og.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/apps/audio/public/og.png differ
Binary files /dev/null and b/apps/audio/public/og.png differ
@@ -254,6 +254,7 @@ app.get('/api/internal/analytics/bots', (req, res) => {
@@ -254,6 +254,7 @@ app.get('/api/internal/analytics/bots', (req, res) => {
// Public assets
// Public assets
app.use(express.static(path.join(__dirname, '..', 'public'), {
app.use(express.static(path.join(__dirname, '..', 'public'), {
+ index: false, // '/' goes through the per-host SEO renderer below
setHeaders(res, filePath) {
setHeaders(res, filePath) {
if (filePath.endsWith('.js') || filePath.endsWith('.css')) {
if (filePath.endsWith('.js') || filePath.endsWith('.css')) {
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Cache-Control', 'no-cache');
@@ -261,12 +262,14 @@ app.use(express.static(path.join(__dirname, '..', 'public'), {
@@ -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) => {
app.get('*', (req, res) => {
if (req.path.startsWith('/api/')) {
if (req.path.startsWith('/api/')) {
return res.status(404).json({ error: 'Not found' });
return res.status(404).json({ error: 'Not found' });
}
}
- res.sendFile(path.join(__dirname, '..', 'public', 'index.html'));
+ return sendIndex(req, res);
});
});
// ── Start ────────────────────────────────────────────────────
// ── Start ────────────────────────────────────────────────────
new file mode 100644
new file mode 100644
@@ -0,0 +1,107 @@
@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 };
new file mode 100644
new file mode 100644
Binary files /dev/null and b/apps/docs/public/og.png differ
Binary files /dev/null and b/apps/docs/public/og.png differ
@@ -264,6 +264,7 @@ app.get('/api/internal/analytics/bots', (req, res) => {
@@ -264,6 +264,7 @@ app.get('/api/internal/analytics/bots', (req, res) => {
// Public assets
// Public assets
app.use(express.static(path.join(__dirname, '..', 'public'), {
app.use(express.static(path.join(__dirname, '..', 'public'), {
+ index: false, // '/' goes through the per-host SEO renderer below
setHeaders(res, filePath) {
setHeaders(res, filePath) {
if (filePath.endsWith('.js') || filePath.endsWith('.css')) {
if (filePath.endsWith('.js') || filePath.endsWith('.css')) {
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Cache-Control', 'no-cache');
@@ -271,12 +272,14 @@ app.use(express.static(path.join(__dirname, '..', 'public'), {
@@ -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) => {
app.get('*', (req, res) => {
if (req.path.startsWith('/api/')) {
if (req.path.startsWith('/api/')) {
return res.status(404).json({ error: 'Not found' });
return res.status(404).json({ error: 'Not found' });
}
}
- res.sendFile(path.join(__dirname, '..', 'public', 'index.html'));
+ return sendIndex(req, res);
});
});
// ── Start ────────────────────────────────────────────────────
// ── Start ────────────────────────────────────────────────────
new file mode 100644
new file mode 100644
@@ -0,0 +1,107 @@
@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 };
@@ -5,6 +5,20 @@
@@ -5,6 +5,20 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Food.OpenVibe — Budget Grocery & Meal Planning</title>
<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.">
<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.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<!-- Critical theme bootstrap (ov-theme-boot) — MUST stay inline and above the stylesheets.
<!-- Critical theme bootstrap (ov-theme-boot) — MUST stay inline and above the stylesheets.
new file mode 100644
new file mode 100644
Binary files /dev/null and b/apps/food/public/og.png differ
Binary files /dev/null and b/apps/food/public/og.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/apps/img/public/og.png differ
Binary files /dev/null and b/apps/img/public/og.png differ
@@ -237,6 +237,7 @@ app.get('/api/internal/analytics/bots', (req, res) => {
@@ -237,6 +237,7 @@ app.get('/api/internal/analytics/bots', (req, res) => {
// Public assets
// Public assets
app.use(express.static(path.join(__dirname, '..', 'public'), {
app.use(express.static(path.join(__dirname, '..', 'public'), {
+ index: false, // '/' goes through the per-host SEO renderer below
setHeaders(res, filePath) {
setHeaders(res, filePath) {
if (filePath.endsWith('.js') || filePath.endsWith('.css')) {
if (filePath.endsWith('.js') || filePath.endsWith('.css')) {
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Cache-Control', 'no-cache');
@@ -244,12 +245,14 @@ app.use(express.static(path.join(__dirname, '..', 'public'), {
@@ -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) => {
app.get('*', (req, res) => {
if (req.path.startsWith('/api/')) {
if (req.path.startsWith('/api/')) {
return res.status(404).json({ error: 'Not found' });
return res.status(404).json({ error: 'Not found' });
}
}
- res.sendFile(path.join(__dirname, '..', 'public', 'index.html'));
+ return sendIndex(req, res);
});
});
// ── Start ────────────────────────────────────────────────────
// ── Start ────────────────────────────────────────────────────
new file mode 100644
new file mode 100644
@@ -0,0 +1,107 @@
@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 };
@@ -5,6 +5,20 @@
@@ -5,6 +5,20 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Maps.OpenVibe — Survival Map for North America</title>
<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.">
<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 -->
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.googleapis.com">
new file mode 100644
new file mode 100644
Binary files /dev/null and b/apps/maps/public/og.png differ
Binary files /dev/null and b/apps/maps/public/og.png differ
@@ -5,6 +5,20 @@
@@ -5,6 +5,20 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>YT.OpenVibe — YouTube Downloader</title>
<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.">
<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.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<!-- Critical theme bootstrap (ov-theme-boot) — MUST stay inline and above the stylesheets.
<!-- Critical theme bootstrap (ov-theme-boot) — MUST stay inline and above the stylesheets.
new file mode 100644
new file mode 100644
Binary files /dev/null and b/apps/yt/public/og.png differ
Binary files /dev/null and b/apps/yt/public/og.png differ