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
Changes
@@@ -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;@@@ -25,8 +25,12 @@ const POPULAR = ['yt', 'convert', 'compress', 'mergepdf', 'jsonfmt', 'mp3', 'dnsconst 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 nextfunction 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>` : ''}`;@@@ -23,6 +23,15 @@ const HOST_RE = /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63const 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();}@@@ -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');++}
@@ -9,6 +9,13 @@
@@ -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.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) { /* */ }
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');
var input = document.getElementById('q'), results = document.getElementById('results');
if (!input || !results) return;
if (!input || !results) return;
var grid = document.getElementById('results-grid'), empty = document.getElementById('results-empty');
var grid = document.getElementById('results-grid'), empty = document.getElementById('results-empty');
@@ -32,7 +39,7 @@
@@ -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; });
}).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) {
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 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 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;
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;
@@ -25,8 +25,12 @@ const POPULAR = ['yt', 'convert', 'compress', 'mergepdf', 'jsonfmt', 'mp3', 'dns
@@ -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 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;
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) {
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>`;
${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() {
@@ -137,7 +141,7 @@ function pageIndex() {
const byId = new Map(tools.map(t => [t.id, t]));
const byId = new Map(tools.map(t => [t.id, t]));
const popular = POPULAR.map(id => byId.get(id)).filter(Boolean);
const popular = POPULAR.map(id => byId.get(id)).filter(Boolean);
const fams = families.filter(f => f.path);
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 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>
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>
<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
@@ -160,7 +164,7 @@ ${fams.map(f => { const list = tools.filter(t => t.family === f.id); const next
function pageFamily(f) {
function pageFamily(f) {
const { tools, families } = registry.get();
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 url = SITE + f.path;
const body = `<nav class="crumbs" aria-label="Breadcrumb"><a href="/">Tools</a> › ${esc(f.name)}</nav>
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>
<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) {
@@ -177,11 +181,11 @@ function pageTool(t) {
const f = families.find(x => x.id === t.family);
const f = families.find(x => x.id === t.family);
const url = `${SITE}/tool/${t.id}`;
const url = `${SITE}/tool/${t.id}`;
const related = tools.filter(x => x.family === t.family && x.id !== t.id).slice(0, 8);
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>
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>
<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>
<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>
<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>` : ''}
${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>` : ''}`;
${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>` : ''}`;
@@ -23,6 +23,15 @@ const HOST_RE = /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63
@@ -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 = {
const HOST_DEFAULTS = {
yt: { canonical: 'youtube-downloader', short: 'yt', aliases: ['youtube', 'ytdl', 'youtubedownloader', 'youtube-download'] },
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 FAMILY_ICON = { net: 'dns', dev: 'code' };
const NET_ICONS = { ip: 'ip', myip: 'ip', dns: 'dns', whois: 'whois', ssl: 'ssl', ping: 'ping', traceroute: 'ping' };
const NET_ICONS = { ip: 'ip', myip: 'ip', dns: 'dns', whois: 'whois', ssl: 'ssl', ping: 'ping', traceroute: 'ping' };
@@ -68,7 +77,7 @@ function build() {
@@ -68,7 +77,7 @@ function build() {
const ov = new Map(); // tool_id → { canonical, short, aliases[] }
const ov = new Map(); // tool_id → { canonical, short, aliases[] }
for (const o of overrides) {
for (const o of overrides) {
const e = ov.get(o.tool_id) || { aliases: [] }; ov.set(o.tool_id, e);
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 hostsFor = (id, def) => {
const d = def || {}; const o = ov.get(id) || { aliases: [] };
const d = def || {}; const o = ov.get(id) || { aliases: [] };
@@ -81,14 +90,25 @@ function build() {
@@ -81,14 +90,25 @@ function build() {
if (o.short && d.short && toHost(d.short) !== short) aliases.add(toHost(d.short));
if (o.short && d.short && toHost(d.short) !== short) aliases.add(toHost(d.short));
if (short === canonical) short = null;
if (short === canonical) short = null;
aliases.delete(canonical); if (short) aliases.delete(short);
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 fam = familyById.get(t.family);
const hosts = hostsFor(t.id, HOST_DEFAULTS[t.id]);
const hosts = hostsFor(t.id, HOST_DEFAULTS[t.id]);
const external = t.id === 'pastes' ? 'https://openvibe.community/pastes' : null;
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 families = FAMILIES.map(f => {
const hosts = f.hub ? hostsFor(f.id, { canonical: f.hub, aliases: f.hubAliases }) : null;
const hosts = f.hub ? hostsFor(f.id, { canonical: f.hub, aliases: f.hubAliases }) : null;
@@ -100,6 +120,7 @@ function build() {
@@ -100,6 +120,7 @@ function build() {
claim(x.hosts.canonical, { kind, role: 'canonical', item: x });
claim(x.hosts.canonical, { kind, role: 'canonical', item: x });
claim(x.hosts.short, { kind, role: 'short', item: x });
claim(x.hosts.short, { kind, role: 'short', item: x });
x.hosts.aliases.forEach(a => claim(a, { kind, role: 'alias', 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 live = new Set(tools.map(t => t.id));
const planned = PLANNED.filter(t => !live.has(t.id));
const planned = PLANNED.filter(t => !live.has(t.id));
@@ -118,6 +139,8 @@ function resolveHost(hostname) {
@@ -118,6 +139,8 @@ function resolveHost(hostname) {
const inZone = host.endsWith('.' + ZONE);
const inZone = host.endsWith('.' + ZONE);
if (!hit) return { kind: 'unknown', host, inZone };
if (!hit) return { kind: 'unknown', host, inZone };
const h = hit.item.hosts;
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 };
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() {
@@ -128,14 +151,14 @@ function catalog() {
updated: r.updated,
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 })),
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' })),
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) {
function setOverrides(rows) {
const known = new Set([...baseTools().map(t => t.id), ...FAMILIES.map(f => f.id)]);
const known = new Set([...baseTools().map(t => t.id), ...FAMILIES.map(f => f.id)]);
const seen = new Set();
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 }));
.map(o => ({ tool_id: o.tool_id, host: String(o.host).toLowerCase(), role: o.role }));
build();
build();
}
}
@@ -47,3 +47,21 @@ console.log('registry + site: all checks passed');
@@ -47,3 +47,21 @@ console.log('registry + site: all checks passed');
assert.equal(isPersonNavigating(req(chrome, 'text/html', { 'sec-fetch-dest': 'iframe' })), false);
assert.equal(isPersonNavigating(req(chrome, 'text/html', { 'sec-fetch-dest': 'iframe' })), false);
console.log('short-host redirect rule: ok');
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');
+}