openvibers/openvibe.tools Commits

One entry per tool: duplicate builds folded into mirrors (200, canonical → primary, unlisted); owner-settable mirror role; cards link the SEO host and take people straight to the short one

OP OpenVibers committed 18 Sept 2026 · 20c01d1a6c · 4 files, +64 −12


Changes

ddiff --git a/apps/gateway/public/js/site.js b/apps/gateway/public/js/site.js
iindex 721256e..d381659 100644
---- a/apps/gateway/public/js/site.js
++++ b/apps/gateway/public/js/site.js
@@@ -9,6 +9,13 @@
try { if (window.OpenVibeNavbar) OpenVibeNavbar.init({ service: 'tools', apiBase: 'https://openvibe.network', token: token, history: { type: 'tool', title: document.title }, silentLogin: 'https://openvibe.tools/auth/login?silent=1&next={url}' }); } catch (e) { /* non-critical */ }
try { if (window.OpenVibeFooter) OpenVibeFooter.init({ service: 'tools', variant: 'full', mount: '#ov-footer', apiBase: 'https://openvibe.network' }); } catch (e) { /* */ }
++ // Cards link to the search-friendly host; people go straight to the short one (no redirect hop).
++ document.addEventListener('click', function (e) {
++ var a = e.target.closest && e.target.closest('a[data-go]');
++ if (!a || e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
++ e.preventDefault(); location.href = a.getAttribute('data-go');
++ });
++
var input = document.getElementById('q'), results = document.getElementById('results');
if (!input || !results) return;
var grid = document.getElementById('results-grid'), empty = document.getElementById('results-empty');
@@@ -32,7 +39,7 @@
}).filter(Boolean).sort(function (a, b) { return b.s - a.s || a.t.name.localeCompare(b.t.name); }).slice(0, 24).map(function (x) { return x.t; });
}
function card(t) {
-- var a = document.createElement('a'); a.className = 'tool'; a.href = t.url;
++ var a = document.createElement('a'); a.className = 'tool'; a.href = t.url; if (t.hosts.short) a.setAttribute('data-go', 'https://' + t.hosts.short + '/');
var ic = document.createElement('span'); ic.className = 'ov-icon'; ic.dataset.icon = t.icon; ic.dataset.fx = 'none'; ic.style.setProperty('--ovi-size', '36px');
var box = document.createElement('span'); box.className = 'tool-t';
var b = document.createElement('b'); b.textContent = t.name; var s = document.createElement('small'); s.textContent = t.tagline; var i = document.createElement('i'); i.textContent = t.hosts.short || t.hosts.canonical;
ddiff --git a/apps/gateway/server/pages/site.js b/apps/gateway/server/pages/site.js
iindex 7320d70..ab56239 100644
---- a/apps/gateway/server/pages/site.js
++++ b/apps/gateway/server/pages/site.js
@@@ -25,8 +25,12 @@ const POPULAR = ['yt', 'convert', 'compress', 'mergepdf', 'jsonfmt', 'mp3', 'dns
const icon = (name, size) => `<span class="ov-icon" data-icon="${esc(name)}" data-ovi="${esc(icons.resolve(name))}" data-fx="none" style="--ovi-size:${size}px" aria-hidden="true">${icons.svg(name)}</span>`;
const hostLabel = (h) => h.replace(/\.openvibe\.tools$/, '') === h ? h : h;
++// Links name the search-friendly host (what crawlers follow and index). A person's click goes straight to
++// the easy address instead of bouncing through a redirect: site.js reads data-go.
++const go = (t) => (t.hosts && t.hosts.short && !t.external ? ` data-go="https://${esc(t.hosts.short)}/"` : '');
++
function toolCard(t) {
-- return `<a class="tool" href="${esc(t.url)}" data-k="${esc([t.name, t.tagline, ...(t.keywords || [])].join(' ').toLowerCase())}">
++ return `<a class="tool" href="${esc(t.url)}"${go(t)} data-k="${esc([t.name, t.tagline, ...(t.keywords || [])].join(' ').toLowerCase())}">
${icon(t.icon, 36)}<span class="tool-t"><b>${esc(t.name)}</b><small>${esc(t.tagline)}</small><i>${esc(hostLabel(t.hosts.short || t.hosts.canonical))}</i></span></a>`;
}
@@@ -137,7 +141,7 @@ function pageIndex() {
const byId = new Map(tools.map(t => [t.id, t]));
const popular = POPULAR.map(id => byId.get(id)).filter(Boolean);
const fams = families.filter(f => f.path);
-- const tile = (t) => `<a class="tile" href="${esc(t.url)}" title="${esc(t.tagline)}">${icon(t.icon, 28)}<span><b>${esc(t.name)}</b><small>${esc(t.tagline)}</small></span></a>`;
++ const tile = (t) => `<a class="tile" href="${esc(t.url)}"${go(t)} title="${esc(t.tagline)}">${icon(t.icon, 28)}<span><b>${esc(t.name)}</b><small>${esc(t.tagline)}</small></span></a>`;
const soon = (t) => `<span class="tile is-soon" title="Planned">${icon(t.icon, 28)}<span><b>${esc(t.name)}</b><small>${esc(t.tagline)}</small></span><em>Soon</em></span>`;
const body = `<div class="hero"><p class="kicker">Open source · community-run · no account needed to start</p><h1>The toolbox for <span>everything you do online</span></h1>
<p>Convert a video, squeeze an image, merge a PDF, debug DNS, format JSON. ${tools.length} tools that open instantly, each on an address you can remember, like <a href="https://yt.openvibe.tools/">yt.openvibe.tools</a>.</p>
@@@ -160,7 +164,7 @@ ${fams.map(f => { const list = tools.filter(t => t.family === f.id); const next
function pageFamily(f) {
const { tools, families } = registry.get();
-- const list = tools.filter(t => t.family === f.id);
++ const list = tools.filter(t => t.family === f.id || (t.alsoIn || []).includes(f.id));
const url = SITE + f.path;
const body = `<nav class="crumbs" aria-label="Breadcrumb"><a href="/">Tools</a> › ${esc(f.name)}</nav>
<div class="page-h">${icon(f.icon, 56)}<h1>${esc(f.name)}</h1></div><p class="lead">${esc(f.description)}</p>
@@@ -177,11 +181,11 @@ function pageTool(t) {
const f = families.find(x => x.id === t.family);
const url = `${SITE}/tool/${t.id}`;
const related = tools.filter(x => x.family === t.family && x.id !== t.id).slice(0, 8);
-- const hosts = [[t.hosts.canonical, 'Primary'], ...(t.hosts.short ? [[t.hosts.short, 'Short link']] : []), ...t.hosts.aliases.map(a => [a, 'Mirror'])];
++ const hosts = [...(t.hosts.short ? [[t.hosts.short, 'Easiest to remember']] : []), [t.hosts.canonical, t.hosts.short ? 'Search-friendly address' : 'Primary'], ...(t.hosts.mirrors || []).map(a => [a, 'Mirror']), ...t.hosts.aliases.map(a => [a, 'Redirects here'])];
const body = `<nav class="crumbs" aria-label="Breadcrumb"><a href="/">Tools</a> › ${f && f.path ? `<a href="${esc(f.path)}">${esc(f.name)}</a> › ` : ''}${esc(t.name)}</nav>
<div class="page-h">${icon(t.icon, 56)}<h1>${esc(t.name)}</h1></div><p class="lead">${esc(t.description)}</p>
<a class="cta" href="${esc(t.url)}">Open ${esc(t.name)}</a>
--<section><div class="sec-h"><h2>Where to find it</h2></div><p class="sec-p">Every address below opens the same tool. The primary address is the one to bookmark and share.</p>
++<section><div class="sec-h"><h2>Where to find it</h2></div><p class="sec-p">Every address below opens the same tool. The first address is the one to bookmark and share.</p>
<ul class="hosts">${hosts.map(([h, tag], i) => `<li${i === 0 ? ' class="primary"' : ''}><a href="https://${esc(h)}/"${i === 0 ? '' : ' rel="nofollow"'}>${esc(h)}</a><span class="tag">${tag}</span></li>`).join('')}</ul></section>
${t.keywords && t.keywords.length ? `<section><div class="sec-h"><h2>People also call this</h2></div><ul class="kw">${t.keywords.map(k => `<li>${esc(k)}</li>`).join('')}</ul></section>` : ''}
${related.length ? `<section><div class="sec-h"><h2>Related tools</h2>${f && f.path ? `<a class="more" href="${esc(f.path)}">All ${esc(f.name)}</a>` : ''}</div><div class="grid">${related.map(toolCard).join('')}</div></section>` : ''}`;
ddiff --git a/apps/gateway/server/registry/index.js b/apps/gateway/server/registry/index.js
iindex a783308..15b0ce7 100644
---- a/apps/gateway/server/registry/index.js
++++ b/apps/gateway/server/registry/index.js
@@@ -23,6 +23,15 @@ const HOST_RE = /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63
const HOST_DEFAULTS = {
yt: { canonical: 'youtube-downloader', short: 'yt', aliases: ['youtube', 'ytdl', 'youtubedownloader', 'youtube-download'] },
};
++// The same tool built twice (once in Text, once in Developer Tools). People see ONE entry, under the
++// friendliest address; the other build keeps serving as a **mirror**: 200, rel=canonical → the primary,
++// never listed, never in a sitemap. `alsoIn` lets the primary show up on the second family's page.
++const MERGED = {
++ jsonfmt: { into: 'json', alsoIn: 'dev' }, md: { into: 'markdown', alsoIn: 'dev' }, codediff: { into: 'compare', alsoIn: 'dev' },
++ slugify: { into: 'slug', alsoIn: 'dev' }, entities: { into: 'escape', alsoIn: 'dev' },
++};
++// Two different tools must never share a display name.
++const RENAME = { curl: 'HTTP Request Tester', curlconvert: 'cURL Converter' };
const FAMILY_ICON = { net: 'dns', dev: 'code' };
const NET_ICONS = { ip: 'ip', myip: 'ip', dns: 'dns', whois: 'whois', ssl: 'ssl', ping: 'ping', traceroute: 'ping' };
@@@ -68,7 +77,7 @@ function build() {
const ov = new Map(); // tool_id → { canonical, short, aliases[] }
for (const o of overrides) {
const e = ov.get(o.tool_id) || { aliases: [] }; ov.set(o.tool_id, e);
-- if (o.role === 'canonical') e.canonical = o.host; else if (o.role === 'short') e.short = o.host; else e.aliases.push(o.host);
++ if (o.role === 'canonical') e.canonical = o.host; else if (o.role === 'short') e.short = o.host; else if (o.role === 'mirror') (e.mirrors = e.mirrors || []).push(o.host); else e.aliases.push(o.host);
}
const hostsFor = (id, def) => {
const d = def || {}; const o = ov.get(id) || { aliases: [] };
@@@ -81,14 +90,25 @@ function build() {
if (o.short && d.short && toHost(d.short) !== short) aliases.add(toHost(d.short));
if (short === canonical) short = null;
aliases.delete(canonical); if (short) aliases.delete(short);
-- return { canonical, short, aliases: [...aliases] };
++ return { canonical, short, aliases: [...aliases], ownerMirrors: (o.mirrors || []).filter(h => h !== canonical && h !== short) };
};
-- const tools = baseTools().map(t => {
++ const all = baseTools();
++ const mirrorsOf = new Map(); // primary id → [{ id, host, port }]
++ const tools = all.filter(t => !MERGED[t.id] || !all.some(x => x.id === MERGED[t.id].into)).map(t => {
const fam = familyById.get(t.family);
const hosts = hostsFor(t.id, HOST_DEFAULTS[t.id]);
const external = t.id === 'pastes' ? 'https://openvibe.community/pastes' : null;
-- return { ...t, hosts, url: external || `https://${hosts.canonical}/`, port: fam ? fam.port : null, external };
++ const folded = all.filter(x => MERGED[x.id] && MERGED[x.id].into === t.id);
++ const alsoIn = [...new Set(folded.map(x => MERGED[x.id].alsoIn).filter(f => f && f !== t.family))];
++ // Keywords from the folded build come along, so search finds the tool under either vocabulary.
++ const keywords = [...new Set([...(t.keywords || []), ...folded.flatMap(x => x.keywords || [])])];
++ // Mirrors: the folded second build, plus any host the owner marked 'mirror' (served by this tool itself).
++ const own = (hosts.ownerMirrors || []).map(h => ({ id: t.id, host: h, port: fam ? fam.port : null })); delete hosts.ownerMirrors;
++ const built = folded.map(x => ({ id: x.id, host: toHost(x.id), port: (familyById.get(x.family) || {}).port || null }));
++ hosts.mirrors = [...built, ...own].map(m => m.host);
++ mirrorsOf.set(t.id, [...built, ...own]);
++ return { ...t, name: RENAME[t.id] || t.name, keywords, alsoIn, hosts, url: external || `https://${hosts.canonical}/`, port: fam ? fam.port : null, external };
});
const families = FAMILIES.map(f => {
const hosts = f.hub ? hostsFor(f.id, { canonical: f.hub, aliases: f.hubAliases }) : null;
@@@ -100,6 +120,7 @@ function build() {
claim(x.hosts.canonical, { kind, role: 'canonical', item: x });
claim(x.hosts.short, { kind, role: 'short', item: x });
x.hosts.aliases.forEach(a => claim(a, { kind, role: 'alias', item: x }));
++ (mirrorsOf.get(x.id) || []).forEach(m => claim(m.host, { kind, role: 'mirror', item: x, mirror: m }));
}
const live = new Set(tools.map(t => t.id));
const planned = PLANNED.filter(t => !live.has(t.id));
@@@ -118,6 +139,8 @@ function resolveHost(hostname) {
const inZone = host.endsWith('.' + ZONE);
if (!hit) return { kind: 'unknown', host, inZone };
const h = hit.item.hosts;
++ // A mirror is served by its own build (its id and port), but answers for the primary's canonical host.
++ if (hit.role === 'mirror') return { kind: hit.kind, role: 'mirror', item: hit.item, tool: hit.mirror.id, primary: hit.item.id, host, inZone, canonicalHost: h.canonical, shortHost: h.short || h.canonical, port: hit.mirror.port };
return { kind: hit.kind, role: hit.role, item: hit.item, tool: hit.item.id, host, inZone, canonicalHost: h.canonical, shortHost: h.short || h.canonical, port: hit.item.port || null };
}
@@@ -128,14 +151,14 @@ function catalog() {
updated: r.updated,
families: r.families.map(f => ({ id: f.id, name: f.name, tagline: f.tagline, description: f.description, icon: f.icon, url: f.url, path: f.path, page: f.page, count: r.tools.filter(t => t.family === f.id).length })),
planned: r.planned.map(t => ({ id: t.id, family: t.family, name: t.name, tagline: t.tagline, icon: t.icon, status: 'planned' })),
-- tools: r.tools.map(t => ({ id: t.id, family: t.family, name: t.name, tagline: t.tagline, description: t.description, keywords: t.keywords, icon: t.icon, hosts: t.hosts, url: t.url, page: `https://${APEX}/tool/${t.id}` })),
++ tools: r.tools.map(t => ({ id: t.id, family: t.family, alsoIn: t.alsoIn, name: t.name, tagline: t.tagline, description: t.description, keywords: t.keywords, icon: t.icon, hosts: t.hosts, url: t.url, page: `https://${APEX}/tool/${t.id}` })),
};
}
function setOverrides(rows) {
const known = new Set([...baseTools().map(t => t.id), ...FAMILIES.map(f => f.id)]);
const seen = new Set();
-- overrides = (Array.isArray(rows) ? rows : []).filter(o => o && known.has(o.tool_id) && HOST_RE.test(String(o.host || '')) && ['canonical', 'short', 'alias'].includes(o.role) && o.host !== APEX && !seen.has(o.host) && seen.add(o.host))
++ overrides = (Array.isArray(rows) ? rows : []).filter(o => o && known.has(o.tool_id) && HOST_RE.test(String(o.host || '')) && ['canonical', 'short', 'alias', 'mirror'].includes(o.role) && o.host !== APEX && !seen.has(o.host) && seen.add(o.host))
.map(o => ({ tool_id: o.tool_id, host: String(o.host).toLowerCase(), role: o.role }));
build();
}
ddiff --git a/apps/gateway/test/registry.test.js b/apps/gateway/test/registry.test.js
iindex 548df05..0abe1fb 100644
---- a/apps/gateway/test/registry.test.js
++++ b/apps/gateway/test/registry.test.js
@@@ -47,3 +47,21 @@ console.log('registry + site: all checks passed');
assert.equal(isPersonNavigating(req(chrome, 'text/html', { 'sec-fetch-dest': 'iframe' })), false);
console.log('short-host redirect rule: ok');
}
++
++// One entry per tool: builds that exist twice are folded, the second host serves as a mirror.
++{
++ const cat = registry.catalog();
++ assert.ok(!cat.tools.some(t => ['jsonfmt', 'md', 'codediff', 'slugify', 'entities'].includes(t.id)), 'folded builds are not listed');
++ const names = cat.tools.map(t => t.name); assert.equal(new Set(names).size, names.length, 'no two tools share a name');
++ const json = cat.tools.find(t => t.id === 'json');
++ assert.deepEqual(json.hosts.mirrors, ['jsonfmt.openvibe.tools']); assert.deepEqual(json.alsoIn, ['dev']);
++ const m = registry.resolveHost('jsonfmt.openvibe.tools');
++ assert.deepEqual([m.role, m.tool, m.canonicalHost], ['mirror', 'jsonfmt', 'json.openvibe.tools'], 'mirror serves its own build, canonical is the primary');
++ assert.ok(!site.sitemapEntries().some(u => u.loc.includes('jsonfmt.')), 'mirrors stay out of the sitemap');
++ assert.equal(site.search('json formatter').filter(t => /json formatter/i.test(t.name)).length, 1, 'search shows it once');
++ registry.setOverrides([{ tool_id: 'yt', host: 'youtubedownloader.example.com', role: 'mirror' }]);
++ const om = registry.resolveHost('youtubedownloader.example.com');
++ assert.deepEqual([om.role, om.port, om.canonicalHost], ['mirror', 4013, 'youtube-downloader.openvibe.tools'], 'owner mirrors are served by the tool and canonicalised');
++ registry.setOverrides([]);
++ console.log('no duplicates, mirrors canonicalised: ok');
++}