openvibers/openvibe.tools Commits
Tool pages: limits, where the work runs and the price (#187)
Changes
@@@ -19,6 +19,8 @@ const seo = require('openvibe-shared/seo');const icons = require('openvibe-shared/icons');const appIcon = require('openvibe-shared/app-icon');const registry = require('../registry');++const { seoFor } = require('../seo/catalog');++const guardLimits = require('../../../_shared/guard/limits');const { foldBlock } = require('../short');const SITE = 'https://openvibe.tools';@@@ -79,6 +81,10 @@ section{padding:clamp(24px,4vw,44px) 0 0}.hosts li{display:flex;gap:10px;align-items:center;padding:9px 12px;border:1px solid var(--border,rgba(255,255,255,.08));border-radius:10px;flex-wrap:wrap}.hosts li.primary{border-color:var(--accent,#3b82f6);background:var(--accent-glow,rgba(59,130,246,.1))}.hosts .tag{font-size:11px;font-weight:700;letter-spacing:.5px;text-transform:uppercase;color:var(--text-muted,#7d8aa0);margin-left:auto}.hosts li.primary .tag{color:var(--text-primary,#e6e9ef)}++.facts{list-style:none;padding:0;margin:14px 0 18px;max-width:820px;border:1px solid var(--border,rgba(255,255,255,.08));border-radius:14px;background:var(--bg-secondary,#111826);overflow:hidden}++.facts li{display:flex;gap:10px;flex-wrap:wrap;padding:10px 14px;border-top:1px solid var(--border,rgba(255,255,255,.08));font-size:14px;line-height:1.5;color:var(--text-secondary,#a8b3c4);overflow-wrap:anywhere}++.facts li:first-child{border-top:0}.facts b{flex:none;min-width:104px;color:var(--text-primary,#e6edf7);font-weight:650}++@media (max-width:420px){.facts b{min-width:0;display:block;width:100%}}.kw{display:flex;flex-wrap:wrap;gap:6px;padding:0;list-style:none}.kw li{font-size:12.5px;padding:3px 9px;border-radius:999px;background:var(--bg-secondary,#111826);color:var(--text-secondary,#a8b3c4)}details{border-bottom:1px solid var(--border,rgba(255,255,255,.08));padding:12px 0;max-width:820px}summary{cursor:pointer;font-weight:650}details p{color:var(--text-secondary,#a8b3c4);margin:8px 0 0}.az{columns:4 220px;column-gap:24px;padding:0;list-style:none}.az li{break-inside:avoid;padding:3px 0}.az a{text-decoration:none}.az small{color:var(--text-muted,#7d8aa0)}@@@ -285,9 +291,62 @@ function search(q) {const descriptorOf = (id) => require('../registry/descriptors').snapshot().tools.find((d) => d.id === id) || null;const shq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;const mb = (bytes) => `${Math.round(bytes / 1048576)} MB`;--/** "Use it from code" on a tool's page: the one request that runs it, who may call it, what it takes. */--function apiSection(t) {++const hours = (sec) => { const h = Math.round((sec / 3600) * 10) / 10; return `${h} hour${h === 1 ? '' : 's'}`; };++const period = (s) => (/[.!?]$/.test(String(s)) ? String(s) : `${s}.`);++// The catalogue's FAQ, when a question names a common mistake rather than product information. A tool++// without one leaves the "what trips people up" line out (no invented caveat).++const PITFALL_RE = /(fail|error|wrong|missing|nothing|no effect|no reply|blocked|reject|refus|closed|breaks?|broke|bounc|exceed|\bold\b|\bdiffer\b|different answer|stop working|\bnot\b)/i;++const pitfallOf = (rec) => ((rec && rec.faq) || []).find(([q]) => !/^should\b/i.test(String(q)) && PITFALL_RE.test(String(q))) || null;++++/**++ * The three facts every tool page states before the button: what the limits are (authored in the tool's++ * descriptor and the guard's quota table), where the file goes, and the price. A browser-side tool never++ * reaches the guard, so it has no daily cap; a server run is charged to the session tier on a satellite++ * page (its page mints ov_tools_jobs) or the address tier on an inline gateway route.++ */++function factsSection(t) {const d = descriptorOf(t.id);++ if (!d) return '';++ const limits = [];++ if (d.files) {++ if (d.files.maxBytes) limits.push(`${mb(d.files.maxBytes)} per file`);++ if (d.files.max) limits.push(d.files.min && d.files.min !== d.files.max ? `${d.files.min} to ${d.files.max} files` : `${d.files.max} file${d.files.max === 1 ? '' : 's'} at a time`);++ } else if (t.external || d.execution !== 'client') {++ limits.push('No file upload');++ }++ if (d.limits && d.limits.maxDurationSec) limits.push(`Up to ${hours(d.limits.maxDurationSec)}`);++ if (d.limits && d.limits.maxPages) limits.push(`Up to ${d.limits.maxPages} pages`);++ if (d.execution === 'client') {++ limits.push('No daily limit on the page');++ } else if (!t.external && d.quotaClass) {++ const tier = d.execution === 'job' ? 'session' : 'anonymous';++ const quotas = guardLimits.quotas();++ const q = quotas[d.quotaClass] && quotas[d.quotaClass][tier];++ const perDay = q && q.perDay ? Math.floor(q.perDay / (d.cost || 1)) : 0;++ if (perDay > 0) limits.push(`Up to ${perDay.toLocaleString('en-US')} runs a day`);++ }++ // The file/result retention is the job TTLs in apps/_shared/jobs/http.js; an inline lookup stores++ // nothing, and a browser tool has already kept everything on the device.++ let where;++ if (t.external) {++ let host = ''; try { host = new URL(t.external).hostname; } catch { /* not a URL: say only where it is */ }++ where = host ? `Runs on ${host}, our sister site.` : 'Runs on another OpenVibe site.';++ } else if (d.execution === 'client') {++ where = 'Runs in your browser; nothing is uploaded.';++ } else if (d.execution === 'job') {++ const kept = 'after 1 hour, or after 24 hours when signed in';++ where = d.files ? `Your file goes to our server and is deleted with the result ${kept}.` : `Runs on our server; the result is deleted ${kept}.`;++ } else {++ where = `Runs on our server; no file is uploaded.${t.id === 'webhook' ? ' Request bins are kept in memory for 1 hour.' : ''}`;++ }++ const cost = t.external || !limits.length ? 'Free.' : 'Free, with the limits above. No account needed.';++ return `<ul class="facts" aria-label="Limits, where it runs and cost">++${limits.length ? `<li><b>Limits</b> ${esc(limits.join(' · '))}</li>` : ''}++<li><b>Where it runs</b> ${esc(where)}</li><li><b>Cost</b> ${esc(cost)}</li></ul>`;++}++++/** "Use it from code" on a tool's page: the one request that runs it, who may call it, what it takes. */++function apiSection(t, d = descriptorOf(t.id)) {if (!d) return '';if (!d.api) return `<section aria-labelledby="api-h"><div class="sec-h"><h2 id="api-h">Use it from code</h2></div><p class="sec-p">${esc(t.name)} runs on its page only and has no API. <a href="/developers">The Tools API</a> covers the tools you can call from code.</p></section>`;const ex = (d.examples || [])[0] || {};@@@ -461,17 +520,28 @@ ${f.faq && f.faq.length ? `<section><div class="sec-h"><h2>Questions</h2></div>$function pageTool(t) {const { tools, families } = registry.get();const f = families.find(x => x.id === t.family);++ const d = descriptorOf(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, 6);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'])];++ // The catalogue's own words: its meta description says what the tool is for, and its first FAQ about++ // a failure or confusion says what trips people up. Satellite-only tools have no FAQ, so that line is left out.++ const rec = seoFor(t.id);++ const purpose = rec && rec.desc ? rec.desc : t.tagline;++ const pitfall = pitfallOf(rec);++ const people = t.keywords && t.keywords.length ? `<section><div class="sec-h"><h2>People also call this</h2></div>++<p class="sec-p"><b>What it's for:</b> ${esc(period(purpose))}</p>++${pitfall ? `<p class="sec-p"><b>What trips people up:</b> <strong>${esc(pitfall[0])}</strong> ${esc(pitfall[1])}</p>` : ''}++<ul class="kw">${t.keywords.map(k => `<li>${esc(k)}</li>`).join('')}</ul></section>` : '';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>++${factsSection(t)}${t.status === 'unavailable' ? `<p class="lead"><strong>Not available yet.</strong> ${esc(t.unavailable || '')}</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 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>` : ''}--${apiSection(t)}--${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>` : ''}`;++${people}++${apiSection(t, d)}++${related.length ? `<section><div class="sec-h"><h2>Similar 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>` : ''}`;return shell({ families, agent: true, body, head: head({ title: `${t.name}: ${t.tagline}`, description: t.description, canonical: url, keywords: (t.keywords || []).join(', '),jsonLd: [seo.jsonLd.breadcrumbs([{ name: 'Tools', url: SITE + '/' }, ...(f && f.path ? [{ name: f.name, url: SITE + f.path }] : []), { name: t.name, url }]),seo.jsonLd.softwareApp({ name: t.name, url: t.url, description: t.description, category: 'UtilitiesApplication', keywords: t.keywords })] }) });nnew file mode 100644@@@ -0,0 +1,98 @@++'use strict';++// The tool page's outcome lines (market outcomes 1, 2, 3, 5 and 7):++// • limits, where the work happens and the price are stated before the button++// • "Similar tools" links the same family, at most six++// • "People also call this" says what the tool is for and, when the catalogue's FAQ has one, what trips people up++// The numbers are read from each tool's descriptor and the guard's quota table, never copied into this test:++// the assertions name the measured values so a change in configuration fails here until the page follows it.++const assert = require('assert');++const registry = require('../server/registry');++const site = require('../server/pages/site');++++const tools = registry.get().tools;++const byId = new Map(tools.map((t) => [t.id, t]));++const page = (id) => { const t = byId.get(id); assert.ok(t, `catalogue has ${id}`); return site.pageTool(t); };++const facts = (html) => (html.match(/<ul class="facts"[\s\S]*?<\/ul>/) || [''])[0];++const similar = (html) => (html.match(/<h2>Similar tools<\/h2>[\s\S]*?<\/section>/) || [''])[0];++++// 1. A browser tool: no upload, no daily cap, free; no catalogue FAQ, so no invented "what trips people up".++{++ const html = page('fancy');++ const f = facts(html);++ assert.match(f, /<b>Limits<\/b> No daily limit on the page/, 'browser tools have no daily cap');++ assert.match(f, /<b>Where it runs<\/b> Runs in your browser; nothing is uploaded\./, 'browser work is stated');++ assert.match(f, /<b>Cost<\/b> Free, with the limits above\. No account needed\./, 'cost line');++ assert.doesNotMatch(f, /<details|hidden|display:\s*none/, 'the facts are never hidden');++ assert.match(html, /<p class="sec-p"><b>What it's for:<\/b> Stylish Unicode fonts to copy and paste\.<\/p>/, 'catalogue tagline as the purpose');++ assert.doesNotMatch(html, /What trips people up:/, 'no FAQ means no pitfall line');++ assert.match(html, /<h2>Similar tools<\/h2>/, 'similar tools heading');++ assert.doesNotMatch(html, /<h2>Related tools<\/h2>/, 'the old heading is gone');++}++++// 2. A job tool: the descriptor's file/page limits, the job TTLs and the session-tier daily allowance.++{++ const html = page('mergepdf');++ const f = facts(html);++ assert.match(f, /100 MB per file/, 'descriptor maxBytes');++ assert.match(f, /2 to 50 files/, 'descriptor files.min/max');++ assert.match(f, /Up to 500 pages/, 'descriptor maxPages');++ assert.match(f, /Up to 375 runs a day/, 'session tools-job perDay 3000 / cost 8');++ assert.match(f, /Your file goes to our server and is deleted with the result after 1 hour, or after 24 hours when signed in\./, 'job TTLs');++}++++// 3. An inline lookup: the address-tier allowance, the server statement and the catalogue's pitfall.++{++ const html = page('dns');++ const f = facts(html);++ assert.match(f, /No file upload/, 'no upload for a lookup');++ assert.match(f, /Up to 2,000 runs a day/, 'anonymous tools-fetch perDay 2000 / cost 1');++ assert.match(f, /Runs on our server; no file is uploaded\./, 'server-side lookup is stated');++ assert.match(html, /What it's for:<\/b> Look up DNS records for any domain/, 'catalogue description as the purpose');++ assert.match(html, /What trips people up:<\/b> <strong>Why do I still see the old DNS record\?<\/strong>/, 'catalogue FAQ as the pitfall');++}++++// 4. Limits that come from the environment-backed config, and tools without a file upload.++{++ const video = facts(page('yt'));++ assert.match(video, /No file upload · Up to 3 hours · Up to 50 runs a day/, 'video download duration and cost-50 quota');++ assert.match(video, /Runs on our server; the result is deleted after 1 hour/, 'no file to call "your file"');++ const hook = facts(page('webhook'));++ assert.match(hook, /Up to 5,000 runs a day/, 'anonymous tools-run perDay');++ assert.match(hook, /Request bins are kept in memory for 1 hour\./, 'the descriptor states the bin retention');++ const img = facts(page('convert'));++ assert.match(img, /50 MB per file · 1 file at a time · Up to 600 runs a day/, 'image descriptor limits and session tools-job perDay 3000 / cost 5');++}++++// 5. The leave-out case: a catalogue FAQ that is product information, not a pitfall, keeps the line out.++{++ const csv = page('csv');++ assert.match(csv, /What it's for:<\/b> Convert CSV to JSON/, 'purpose still shown');++ assert.doesNotMatch(csv, /What trips people up:/, 'no invented caveat for CSV');++}++++// 6. Similar tools: only the same family, at most six, and left out when there are no siblings.++{++ for (const t of tools) {++ const s = similar(page(t.id));++ const n = (s.match(/class="tool"/g) || []).length;++ assert.ok(n <= 6, `${t.id}: at most six similar tools (${n})`);++ if (n) {++ const hrefs = [...s.matchAll(/href="([^"]+)"/g)].map((m) => m[1]).filter((h) => h.startsWith('https://'));++ const urls = new Set(tools.filter((x) => x.family === t.family).map((x) => x.url));++ for (const href of hrefs) assert.ok(urls.has(href), `${t.id}: similar card stays in the family (${href})`);++ }++ }++ assert.doesNotMatch(page('yt'), /Similar tools/, 'a family of one tool has no similar block');++ assert.doesNotMatch(page('pastes'), /Similar tools/, 'an external tool with no siblings has no similar block');++}++++// 7. Every page states the facts and the purpose; no page hides either.++for (const t of tools) {++ const html = page(t.id);++ assert.match(html, /<ul class="facts" aria-label="Limits, where it runs and cost">/, `${t.id}: facts block`);++ assert.match(html, /<b>Where it runs<\/b>/, `${t.id}: where line`);++ assert.match(html, /<b>Cost<\/b> Free/, `${t.id}: cost line`);++ assert.match(html, /<p class="sec-p"><b>What it's for:<\/b>/, `${t.id}: purpose line`);++}++++console.log(`tool outcomes: ${tools.length} pages checked, limits + where + cost, similar tools, purpose and pitfall lines`);
@@ -19,6 +19,8 @@ const seo = require('openvibe-shared/seo');
@@ -19,6 +19,8 @@ const seo = require('openvibe-shared/seo');
const icons = require('openvibe-shared/icons');
const icons = require('openvibe-shared/icons');
const appIcon = require('openvibe-shared/app-icon');
const appIcon = require('openvibe-shared/app-icon');
const registry = require('../registry');
const registry = require('../registry');
+const { seoFor } = require('../seo/catalog');
+const guardLimits = require('../../../_shared/guard/limits');
const { foldBlock } = require('../short');
const { foldBlock } = require('../short');
const SITE = 'https://openvibe.tools';
const SITE = 'https://openvibe.tools';
@@ -79,6 +81,10 @@ section{padding:clamp(24px,4vw,44px) 0 0}
@@ -79,6 +81,10 @@ section{padding:clamp(24px,4vw,44px) 0 0}
.hosts li{display:flex;gap:10px;align-items:center;padding:9px 12px;border:1px solid var(--border,rgba(255,255,255,.08));border-radius:10px;flex-wrap:wrap}
.hosts li{display:flex;gap:10px;align-items:center;padding:9px 12px;border:1px solid var(--border,rgba(255,255,255,.08));border-radius:10px;flex-wrap:wrap}
.hosts li.primary{border-color:var(--accent,#3b82f6);background:var(--accent-glow,rgba(59,130,246,.1))}
.hosts li.primary{border-color:var(--accent,#3b82f6);background:var(--accent-glow,rgba(59,130,246,.1))}
.hosts .tag{font-size:11px;font-weight:700;letter-spacing:.5px;text-transform:uppercase;color:var(--text-muted,#7d8aa0);margin-left:auto}.hosts li.primary .tag{color:var(--text-primary,#e6e9ef)}
.hosts .tag{font-size:11px;font-weight:700;letter-spacing:.5px;text-transform:uppercase;color:var(--text-muted,#7d8aa0);margin-left:auto}.hosts li.primary .tag{color:var(--text-primary,#e6e9ef)}
+.facts{list-style:none;padding:0;margin:14px 0 18px;max-width:820px;border:1px solid var(--border,rgba(255,255,255,.08));border-radius:14px;background:var(--bg-secondary,#111826);overflow:hidden}
+.facts li{display:flex;gap:10px;flex-wrap:wrap;padding:10px 14px;border-top:1px solid var(--border,rgba(255,255,255,.08));font-size:14px;line-height:1.5;color:var(--text-secondary,#a8b3c4);overflow-wrap:anywhere}
+.facts li:first-child{border-top:0}.facts b{flex:none;min-width:104px;color:var(--text-primary,#e6edf7);font-weight:650}
+@media (max-width:420px){.facts b{min-width:0;display:block;width:100%}}
.kw{display:flex;flex-wrap:wrap;gap:6px;padding:0;list-style:none}.kw li{font-size:12.5px;padding:3px 9px;border-radius:999px;background:var(--bg-secondary,#111826);color:var(--text-secondary,#a8b3c4)}
.kw{display:flex;flex-wrap:wrap;gap:6px;padding:0;list-style:none}.kw li{font-size:12.5px;padding:3px 9px;border-radius:999px;background:var(--bg-secondary,#111826);color:var(--text-secondary,#a8b3c4)}
details{border-bottom:1px solid var(--border,rgba(255,255,255,.08));padding:12px 0;max-width:820px}summary{cursor:pointer;font-weight:650}details p{color:var(--text-secondary,#a8b3c4);margin:8px 0 0}
details{border-bottom:1px solid var(--border,rgba(255,255,255,.08));padding:12px 0;max-width:820px}summary{cursor:pointer;font-weight:650}details p{color:var(--text-secondary,#a8b3c4);margin:8px 0 0}
.az{columns:4 220px;column-gap:24px;padding:0;list-style:none}.az li{break-inside:avoid;padding:3px 0}.az a{text-decoration:none}.az small{color:var(--text-muted,#7d8aa0)}
.az{columns:4 220px;column-gap:24px;padding:0;list-style:none}.az li{break-inside:avoid;padding:3px 0}.az a{text-decoration:none}.az small{color:var(--text-muted,#7d8aa0)}
@@ -285,9 +291,62 @@ function search(q) {
@@ -285,9 +291,62 @@ function search(q) {
const descriptorOf = (id) => require('../registry/descriptors').snapshot().tools.find((d) => d.id === id) || null;
const descriptorOf = (id) => require('../registry/descriptors').snapshot().tools.find((d) => d.id === id) || null;
const shq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
const shq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
const mb = (bytes) => `${Math.round(bytes / 1048576)} MB`;
const mb = (bytes) => `${Math.round(bytes / 1048576)} MB`;
-/** "Use it from code" on a tool's page: the one request that runs it, who may call it, what it takes. */
-function apiSection(t) {
+const hours = (sec) => { const h = Math.round((sec / 3600) * 10) / 10; return `${h} hour${h === 1 ? '' : 's'}`; };
+const period = (s) => (/[.!?]$/.test(String(s)) ? String(s) : `${s}.`);
+// The catalogue's FAQ, when a question names a common mistake rather than product information. A tool
+// without one leaves the "what trips people up" line out (no invented caveat).
+const PITFALL_RE = /(fail|error|wrong|missing|nothing|no effect|no reply|blocked|reject|refus|closed|breaks?|broke|bounc|exceed|\bold\b|\bdiffer\b|different answer|stop working|\bnot\b)/i;
+const pitfallOf = (rec) => ((rec && rec.faq) || []).find(([q]) => !/^should\b/i.test(String(q)) && PITFALL_RE.test(String(q))) || null;
+
+/**
+ * The three facts every tool page states before the button: what the limits are (authored in the tool's
+ * descriptor and the guard's quota table), where the file goes, and the price. A browser-side tool never
+ * reaches the guard, so it has no daily cap; a server run is charged to the session tier on a satellite
+ * page (its page mints ov_tools_jobs) or the address tier on an inline gateway route.
+ */
+function factsSection(t) {
const d = descriptorOf(t.id);
const d = descriptorOf(t.id);
+ if (!d) return '';
+ const limits = [];
+ if (d.files) {
+ if (d.files.maxBytes) limits.push(`${mb(d.files.maxBytes)} per file`);
+ if (d.files.max) limits.push(d.files.min && d.files.min !== d.files.max ? `${d.files.min} to ${d.files.max} files` : `${d.files.max} file${d.files.max === 1 ? '' : 's'} at a time`);
+ } else if (t.external || d.execution !== 'client') {
+ limits.push('No file upload');
+ }
+ if (d.limits && d.limits.maxDurationSec) limits.push(`Up to ${hours(d.limits.maxDurationSec)}`);
+ if (d.limits && d.limits.maxPages) limits.push(`Up to ${d.limits.maxPages} pages`);
+ if (d.execution === 'client') {
+ limits.push('No daily limit on the page');
+ } else if (!t.external && d.quotaClass) {
+ const tier = d.execution === 'job' ? 'session' : 'anonymous';
+ const quotas = guardLimits.quotas();
+ const q = quotas[d.quotaClass] && quotas[d.quotaClass][tier];
+ const perDay = q && q.perDay ? Math.floor(q.perDay / (d.cost || 1)) : 0;
+ if (perDay > 0) limits.push(`Up to ${perDay.toLocaleString('en-US')} runs a day`);
+ }
+ // The file/result retention is the job TTLs in apps/_shared/jobs/http.js; an inline lookup stores
+ // nothing, and a browser tool has already kept everything on the device.
+ let where;
+ if (t.external) {
+ let host = ''; try { host = new URL(t.external).hostname; } catch { /* not a URL: say only where it is */ }
+ where = host ? `Runs on ${host}, our sister site.` : 'Runs on another OpenVibe site.';
+ } else if (d.execution === 'client') {
+ where = 'Runs in your browser; nothing is uploaded.';
+ } else if (d.execution === 'job') {
+ const kept = 'after 1 hour, or after 24 hours when signed in';
+ where = d.files ? `Your file goes to our server and is deleted with the result ${kept}.` : `Runs on our server; the result is deleted ${kept}.`;
+ } else {
+ where = `Runs on our server; no file is uploaded.${t.id === 'webhook' ? ' Request bins are kept in memory for 1 hour.' : ''}`;
+ }
+ const cost = t.external || !limits.length ? 'Free.' : 'Free, with the limits above. No account needed.';
+ return `<ul class="facts" aria-label="Limits, where it runs and cost">
+${limits.length ? `<li><b>Limits</b> ${esc(limits.join(' · '))}</li>` : ''}
+<li><b>Where it runs</b> ${esc(where)}</li><li><b>Cost</b> ${esc(cost)}</li></ul>`;
+}
+
+/** "Use it from code" on a tool's page: the one request that runs it, who may call it, what it takes. */
+function apiSection(t, d = descriptorOf(t.id)) {
if (!d) return '';
if (!d) return '';
if (!d.api) return `<section aria-labelledby="api-h"><div class="sec-h"><h2 id="api-h">Use it from code</h2></div><p class="sec-p">${esc(t.name)} runs on its page only and has no API. <a href="/developers">The Tools API</a> covers the tools you can call from code.</p></section>`;
if (!d.api) return `<section aria-labelledby="api-h"><div class="sec-h"><h2 id="api-h">Use it from code</h2></div><p class="sec-p">${esc(t.name)} runs on its page only and has no API. <a href="/developers">The Tools API</a> covers the tools you can call from code.</p></section>`;
const ex = (d.examples || [])[0] || {};
const ex = (d.examples || [])[0] || {};
@@ -461,17 +520,28 @@ ${f.faq && f.faq.length ? `<section><div class="sec-h"><h2>Questions</h2></div>$
@@ -461,17 +520,28 @@ ${f.faq && f.faq.length ? `<section><div class="sec-h"><h2>Questions</h2></div>$
function pageTool(t) {
function pageTool(t) {
const { tools, families } = registry.get();
const { tools, families } = registry.get();
const f = families.find(x => x.id === t.family);
const f = families.find(x => x.id === t.family);
+ const d = descriptorOf(t.id);
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, 6);
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 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'])];
+ // The catalogue's own words: its meta description says what the tool is for, and its first FAQ about
+ // a failure or confusion says what trips people up. Satellite-only tools have no FAQ, so that line is left out.
+ const rec = seoFor(t.id);
+ const purpose = rec && rec.desc ? rec.desc : t.tagline;
+ const pitfall = pitfallOf(rec);
+ const people = t.keywords && t.keywords.length ? `<section><div class="sec-h"><h2>People also call this</h2></div>
+<p class="sec-p"><b>What it's for:</b> ${esc(period(purpose))}</p>
+${pitfall ? `<p class="sec-p"><b>What trips people up:</b> <strong>${esc(pitfall[0])}</strong> ${esc(pitfall[1])}</p>` : ''}
+<ul class="kw">${t.keywords.map(k => `<li>${esc(k)}</li>`).join('')}</ul></section>` : '';
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>
+${factsSection(t)}
${t.status === 'unavailable' ? `<p class="lead"><strong>Not available yet.</strong> ${esc(t.unavailable || '')}</p>` : `<a class="cta" href="${esc(t.url)}">Open ${esc(t.name)}</a>`}
${t.status === 'unavailable' ? `<p class="lead"><strong>Not available yet.</strong> ${esc(t.unavailable || '')}</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 first 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>` : ''}
-${apiSection(t)}
-${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>` : ''}`;
+${people}
+${apiSection(t, d)}
+${related.length ? `<section><div class="sec-h"><h2>Similar 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>` : ''}`;
return shell({ families, agent: true, body, head: head({ title: `${t.name}: ${t.tagline}`, description: t.description, canonical: url, keywords: (t.keywords || []).join(', '),
return shell({ families, agent: true, body, head: head({ title: `${t.name}: ${t.tagline}`, description: t.description, canonical: url, keywords: (t.keywords || []).join(', '),
jsonLd: [seo.jsonLd.breadcrumbs([{ name: 'Tools', url: SITE + '/' }, ...(f && f.path ? [{ name: f.name, url: SITE + f.path }] : []), { name: t.name, url }]),
jsonLd: [seo.jsonLd.breadcrumbs([{ name: 'Tools', url: SITE + '/' }, ...(f && f.path ? [{ name: f.name, url: SITE + f.path }] : []), { name: t.name, url }]),
seo.jsonLd.softwareApp({ name: t.name, url: t.url, description: t.description, category: 'UtilitiesApplication', keywords: t.keywords })] }) });
seo.jsonLd.softwareApp({ name: t.name, url: t.url, description: t.description, category: 'UtilitiesApplication', keywords: t.keywords })] }) });
new file mode 100644
new file mode 100644
@@ -0,0 +1,98 @@
@@ -0,0 +1,98 @@
+'use strict';
+// The tool page's outcome lines (market outcomes 1, 2, 3, 5 and 7):
+// • limits, where the work happens and the price are stated before the button
+// • "Similar tools" links the same family, at most six
+// • "People also call this" says what the tool is for and, when the catalogue's FAQ has one, what trips people up
+// The numbers are read from each tool's descriptor and the guard's quota table, never copied into this test:
+// the assertions name the measured values so a change in configuration fails here until the page follows it.
+const assert = require('assert');
+const registry = require('../server/registry');
+const site = require('../server/pages/site');
+
+const tools = registry.get().tools;
+const byId = new Map(tools.map((t) => [t.id, t]));
+const page = (id) => { const t = byId.get(id); assert.ok(t, `catalogue has ${id}`); return site.pageTool(t); };
+const facts = (html) => (html.match(/<ul class="facts"[\s\S]*?<\/ul>/) || [''])[0];
+const similar = (html) => (html.match(/<h2>Similar tools<\/h2>[\s\S]*?<\/section>/) || [''])[0];
+
+// 1. A browser tool: no upload, no daily cap, free; no catalogue FAQ, so no invented "what trips people up".
+{
+ const html = page('fancy');
+ const f = facts(html);
+ assert.match(f, /<b>Limits<\/b> No daily limit on the page/, 'browser tools have no daily cap');
+ assert.match(f, /<b>Where it runs<\/b> Runs in your browser; nothing is uploaded\./, 'browser work is stated');
+ assert.match(f, /<b>Cost<\/b> Free, with the limits above\. No account needed\./, 'cost line');
+ assert.doesNotMatch(f, /<details|hidden|display:\s*none/, 'the facts are never hidden');
+ assert.match(html, /<p class="sec-p"><b>What it's for:<\/b> Stylish Unicode fonts to copy and paste\.<\/p>/, 'catalogue tagline as the purpose');
+ assert.doesNotMatch(html, /What trips people up:/, 'no FAQ means no pitfall line');
+ assert.match(html, /<h2>Similar tools<\/h2>/, 'similar tools heading');
+ assert.doesNotMatch(html, /<h2>Related tools<\/h2>/, 'the old heading is gone');
+}
+
+// 2. A job tool: the descriptor's file/page limits, the job TTLs and the session-tier daily allowance.
+{
+ const html = page('mergepdf');
+ const f = facts(html);
+ assert.match(f, /100 MB per file/, 'descriptor maxBytes');
+ assert.match(f, /2 to 50 files/, 'descriptor files.min/max');
+ assert.match(f, /Up to 500 pages/, 'descriptor maxPages');
+ assert.match(f, /Up to 375 runs a day/, 'session tools-job perDay 3000 / cost 8');
+ assert.match(f, /Your file goes to our server and is deleted with the result after 1 hour, or after 24 hours when signed in\./, 'job TTLs');
+}
+
+// 3. An inline lookup: the address-tier allowance, the server statement and the catalogue's pitfall.
+{
+ const html = page('dns');
+ const f = facts(html);
+ assert.match(f, /No file upload/, 'no upload for a lookup');
+ assert.match(f, /Up to 2,000 runs a day/, 'anonymous tools-fetch perDay 2000 / cost 1');
+ assert.match(f, /Runs on our server; no file is uploaded\./, 'server-side lookup is stated');
+ assert.match(html, /What it's for:<\/b> Look up DNS records for any domain/, 'catalogue description as the purpose');
+ assert.match(html, /What trips people up:<\/b> <strong>Why do I still see the old DNS record\?<\/strong>/, 'catalogue FAQ as the pitfall');
+}
+
+// 4. Limits that come from the environment-backed config, and tools without a file upload.
+{
+ const video = facts(page('yt'));
+ assert.match(video, /No file upload · Up to 3 hours · Up to 50 runs a day/, 'video download duration and cost-50 quota');
+ assert.match(video, /Runs on our server; the result is deleted after 1 hour/, 'no file to call "your file"');
+ const hook = facts(page('webhook'));
+ assert.match(hook, /Up to 5,000 runs a day/, 'anonymous tools-run perDay');
+ assert.match(hook, /Request bins are kept in memory for 1 hour\./, 'the descriptor states the bin retention');
+ const img = facts(page('convert'));
+ assert.match(img, /50 MB per file · 1 file at a time · Up to 600 runs a day/, 'image descriptor limits and session tools-job perDay 3000 / cost 5');
+}
+
+// 5. The leave-out case: a catalogue FAQ that is product information, not a pitfall, keeps the line out.
+{
+ const csv = page('csv');
+ assert.match(csv, /What it's for:<\/b> Convert CSV to JSON/, 'purpose still shown');
+ assert.doesNotMatch(csv, /What trips people up:/, 'no invented caveat for CSV');
+}
+
+// 6. Similar tools: only the same family, at most six, and left out when there are no siblings.
+{
+ for (const t of tools) {
+ const s = similar(page(t.id));
+ const n = (s.match(/class="tool"/g) || []).length;
+ assert.ok(n <= 6, `${t.id}: at most six similar tools (${n})`);
+ if (n) {
+ const hrefs = [...s.matchAll(/href="([^"]+)"/g)].map((m) => m[1]).filter((h) => h.startsWith('https://'));
+ const urls = new Set(tools.filter((x) => x.family === t.family).map((x) => x.url));
+ for (const href of hrefs) assert.ok(urls.has(href), `${t.id}: similar card stays in the family (${href})`);
+ }
+ }
+ assert.doesNotMatch(page('yt'), /Similar tools/, 'a family of one tool has no similar block');
+ assert.doesNotMatch(page('pastes'), /Similar tools/, 'an external tool with no siblings has no similar block');
+}
+
+// 7. Every page states the facts and the purpose; no page hides either.
+for (const t of tools) {
+ const html = page(t.id);
+ assert.match(html, /<ul class="facts" aria-label="Limits, where it runs and cost">/, `${t.id}: facts block`);
+ assert.match(html, /<b>Where it runs<\/b>/, `${t.id}: where line`);
+ assert.match(html, /<b>Cost<\/b> Free/, `${t.id}: cost line`);
+ assert.match(html, /<p class="sec-p"><b>What it's for:<\/b>/, `${t.id}: purpose line`);
+}
+
+console.log(`tool outcomes: ${tools.length} pages checked, limits + where + cost, similar tools, purpose and pitfall lines`);