openvibers/openvibe.tools Commits

Tools: the shared tool frame on every network and code tool page (#155)

OP OpenVibers committed 13 days ago · 93804d3443 · 29 files, +1437 −823

* Tools: the shared tool frame on every network and code tool page

Every net.* and dev.* tool page now uses openvibe-shared 4.55.0's tool frame: head, box, answer, family, folds and related list. The catalogue gains 2-3 public Try examples per input tool; the pages draw answers through OpenVibeKit.answer, with rows, a Raw fold and validator errors in tone bad. The old hero and chip cloud are gone from tool pages, the navbar brand reads Tools, and the SEO title/canonical/JSON-LD and a single h1 stay.

Also fixes the flaky yt limits test fixture: the app's 5-second upstream probe wrote the download marker args.json, so the 'no download was started' check raced it.

* Tools: the tool frame's terms, verbs and field labels

Every net.* and dev.* tool page now states its terms from the real configuration through openvibe-shared 4.56.0's toolHead facts: free, where the work happens, the anonymous limit from the guard's quota table (or no page limit for a local tool without an API), and whether an account is needed. A dev tool with several modes draws kit.seg and its button says the active verb; a single mode is the button. Field labels come from the tool's descriptor, and the footer reads OpenVibe.Tools on tool subdomains. Pin openvibe-shared v4.56.0 in all eight apps.

Changes

ddiff --git a/README.md b/README.md
iindex 19c524c..816f54e 100644
---- a/README.md
++++ b/README.md
@@@ -31,7 +31,7 @@ offline against the Network's public key (JWKS).
OpenVibe.Search (tool documents), OpenVibe.Community (the paste hand-over), OpenVibe.Billing (job usage
readings)
- host programs some tools need (ffmpeg, yt-dlp, qpdf, poppler, libheif; [Host packages](#host-packages-some-tools-need))
--- in each app: `openvibe-contracts` v0.92.0 (img, audio, docs, gateway), `openvibe-sdk` v0.12.0 and `openvibe-shared` v4.54.0, pinned
++- in each app: `openvibe-contracts` v0.92.0 (img, audio, docs, gateway), `openvibe-sdk` v0.12.0 and `openvibe-shared` v4.56.0, pinned
by release tarball, and `apps/_shared` (openvibe-tools-shared) by relative path
## Capabilities
@@@ -642,5 +642,5 @@ counters and salt use Valkey through `VALKEY_URL`, with process-local state when
<!-- versions:start -->
- openvibe-contracts: v0.222.0
- openvibe-sdk: v0.74.0
--- openvibe-shared: v4.54.0
++- openvibe-shared: v4.56.0
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 19d60d5..59dcf39 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -18,7 +18,7 @@
"domain": "openvibe.tools and *.openvibe.tools",
"contracts": "openvibe-contracts v0.222.0 (the gateway, img, audio and docs apps' package.json; the root package.json pins none)",
"sdk": "openvibe-sdk v0.74.0 (the gateway's package.json; the other apps are still on v0.67.0)",
-- "shared": "openvibe-shared v4.54.0 (each app's package.json; the root package.json pins none)",
++ "shared": "openvibe-shared v4.56.0 (each app's package.json; the root package.json pins none)",
"capabilities": [
"tools.job.create",
"tools.job.read",
ddiff --git a/apps/audio/package-lock.json b/apps/audio/package-lock.json
iindex 76f4428..9137134 100644
---- a/apps/audio/package-lock.json
++++ b/apps/audio/package-lock.json
@@@ -22,7 +22,7 @@
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.43.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.56.0",
"pg": "^8.23.0",
"uuid": "^11.1.0"
}
@@@ -1330,9 +1330,9 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "4.43.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.43.0",
-- "integrity": "sha512-OANEzCEB2QPgbkb9Yt0O67k6DxcK2pHMO1nhKYCjTI7OhUF8LW8EpLf6FJmpHmDTmpFTUq4pJ3lr3u2hPXMDYQ==",
++ "version": "4.56.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.56.0",
++ "integrity": "sha512-6Whnuat9QQmKLt7ncQ+xtmwAAZfRUUIouLb/cFlSm6SvL5DfXkpsb44nscAqkx2vUIpDFLtT0nnD37jNCP+Cxg==",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0",
ddiff --git a/apps/audio/package.json b/apps/audio/package.json
iindex 4da0d05..adee39e 100644
---- a/apps/audio/package.json
++++ b/apps/audio/package.json
@@@ -25,7 +25,7 @@
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.54.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.56.0",
"uuid": "^11.1.0"
}
}
ddiff --git a/apps/docs/package-lock.json b/apps/docs/package-lock.json
iindex 731f3dd..be1aec2 100644
---- a/apps/docs/package-lock.json
++++ b/apps/docs/package-lock.json
@@@ -21,7 +21,7 @@
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.43.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.56.0",
"pdf-lib": "^1.17.1",
"pg": "^8.23.0",
"sharp": "^0.33.5",
@@@ -1758,9 +1758,9 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "4.43.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.43.0",
-- "integrity": "sha512-OANEzCEB2QPgbkb9Yt0O67k6DxcK2pHMO1nhKYCjTI7OhUF8LW8EpLf6FJmpHmDTmpFTUq4pJ3lr3u2hPXMDYQ==",
++ "version": "4.56.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.56.0",
++ "integrity": "sha512-6Whnuat9QQmKLt7ncQ+xtmwAAZfRUUIouLb/cFlSm6SvL5DfXkpsb44nscAqkx2vUIpDFLtT0nnD37jNCP+Cxg==",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0",
ddiff --git a/apps/docs/package.json b/apps/docs/package.json
iindex 4b8cfdd..382fdc6 100644
---- a/apps/docs/package.json
++++ b/apps/docs/package.json
@@@ -24,7 +24,7 @@
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.54.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.56.0",
"pdf-lib": "^1.17.1",
"sharp": "^0.33.5",
"uuid": "^11.1.0"
ddiff --git a/apps/food/package-lock.json b/apps/food/package-lock.json
iindex 99cac80..026775a 100644
---- a/apps/food/package-lock.json
++++ b/apps/food/package-lock.json
@@@ -17,7 +17,7 @@
"helmet": "^7.1.0",
"iovalkey": "^0.4.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.43.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.56.0",
"pg": "^8.23.0"
}
},
@@@ -1129,9 +1129,9 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "4.43.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.43.0",
-- "integrity": "sha512-OANEzCEB2QPgbkb9Yt0O67k6DxcK2pHMO1nhKYCjTI7OhUF8LW8EpLf6FJmpHmDTmpFTUq4pJ3lr3u2hPXMDYQ==",
++ "version": "4.56.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.56.0",
++ "integrity": "sha512-6Whnuat9QQmKLt7ncQ+xtmwAAZfRUUIouLb/cFlSm6SvL5DfXkpsb44nscAqkx2vUIpDFLtT0nnD37jNCP+Cxg==",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0",
ddiff --git a/apps/food/package.json b/apps/food/package.json
iindex 1b63988..0cec95e 100644
---- a/apps/food/package.json
++++ b/apps/food/package.json
@@@ -20,6 +20,6 @@
"express-rate-limit": "^7.1.5",
"helmet": "^7.1.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.54.0"
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.56.0"
}
}
ddiff --git a/apps/gateway/package-lock.json b/apps/gateway/package-lock.json
iindex 64144d9..845f616 100644
---- a/apps/gateway/package-lock.json
++++ b/apps/gateway/package-lock.json
@@@ -21,7 +21,7 @@
"jsonwebtoken": "^9.0.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.48.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.56.0",
"pg": "^8.23.0",
"zxing-wasm": "3.1.5"
}
@@@ -1229,9 +1229,9 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "4.48.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.48.0",
-- "integrity": "sha512-JzASdCIvFTnJz3Xeng1e/j9WGZAVPpa6AvF60tgNf4TGFXm+PXQXyFp2o2JyHDkqF3L8HlY6CDVcIRGTQegjJQ==",
++ "version": "4.56.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.56.0",
++ "integrity": "sha512-6Whnuat9QQmKLt7ncQ+xtmwAAZfRUUIouLb/cFlSm6SvL5DfXkpsb44nscAqkx2vUIpDFLtT0nnD37jNCP+Cxg==",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0",
ddiff --git a/apps/gateway/package.json b/apps/gateway/package.json
iindex a25ce76..4d38dc6 100644
---- a/apps/gateway/package.json
++++ b/apps/gateway/package.json
@@@ -23,7 +23,7 @@
"jsonwebtoken": "^9.0.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.54.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.56.0",
"bwip-js": "4.11.4",
"zxing-wasm": "3.1.5"
}
ddiff --git a/apps/gateway/public/dev.html b/apps/gateway/public/dev.html
iindex c0a7359..b32e1f1 100644
---- a/apps/gateway/public/dev.html
++++ b/apps/gateway/public/dev.html
@@@ -27,7 +27,7 @@
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<style>
--:root{--bg:#0b0c12;--bg2:#12131c;--bg3:#1a1b28;--card:rgba(22,24,36,.85);--tx:#f1f4fb;--tx2:#a0a8c4;--tx3:#5e6580;--ac:#a78bfa;--acl:#c4b5fd;--brd:rgba(255,255,255,.07);--r:16px}
++:root{--bg:var(--bg-primary,#0b0c12);--bg2:var(--bg-secondary,#12131c);--bg3:var(--bg-tertiary,#1a1b28);--card:var(--bg-card,rgba(22,24,36,.85));--tx:var(--text-primary,#f1f4fb);--tx2:var(--text-secondary,#a0a8c4);--tx3:var(--text-muted,#5e6580);--ac:var(--accent,#a78bfa);--acl:var(--accent-light,#c4b5fd);--brd:var(--border,rgba(255,255,255,.07));--r:16px}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
html{background:var(--bg)}
body{min-height:100vh;font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--tx);-webkit-font-smoothing:antialiased}
@@@ -76,6 +76,14 @@ a{color:inherit;text-decoration:none}
.toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%) translateY(80px);background:var(--bg3);color:var(--tx);padding:10px 24px;border-radius:10px;font-size:.85rem;font-weight:600;border:1px solid var(--brd);opacity:0;transition:.3s;z-index:9999;pointer-events:none}
.toast.show{transform:translateX(-50%) translateY(0);opacity:1}
@media(max-width:640px){.io{flex-direction:column}.hg{grid-template-columns:1fr}}
++/* The tool frame: the brand is the site ("Tools"), not the subdomain's name. A framed page's box comes from the server. */
++.ov-tool-page .openvibe-navbar .openvibe-navbar-brand .b-core,.ov-tool-page .openvibe-navbar .openvibe-navbar-brand .b-dot,.ov-tool-page .openvibe-navbar .openvibe-navbar-brand .b-tld{display:none}
++.ovk-tool-modes{display:block;min-width:0}
++.ovk-tool-modes .ovk-seg{flex-wrap:wrap;max-width:100%}
++.ovk-tool-buttons{display:flex;gap:8px;flex-wrap:wrap}
++.ovk-dev-out{margin:0;white-space:pre-wrap;overflow-wrap:anywhere;font:.86rem/1.65 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color:var(--text-primary,#f1f4fb)}
++.ovk-dev-out table{border-collapse:collapse;width:100%;font-size:.82rem}.ovk-dev-out td,.ovk-dev-out th{border:1px solid var(--border,rgba(255,255,255,.07));padding:6px 10px;text-align:left}
++.ov-tool-page .page{padding:0}
</style>
<script type="application/ld+json">
{
@@@ -131,7 +139,9 @@ a{color:inherit;text-decoration:none}
<body>
<div class="bg-glow"></div>
<div id="navbar-mount"></div>
--<div class="page"><div class="shell">
++<div class="page">
++<!-- ov-frame:start -->
++<div class="shell">
<div class="hero"><div class="hero-icon" id="hi"><i class="fa-solid fa-code"></i></div><h1 id="ht">Dev.OpenVibe</h1><p id="hd">Developer & SEO Tools Hub</p></div>
<div id="tool" style="display:none">
<div class="modes" id="modes"></div>
@@@ -141,7 +151,9 @@ a{color:inherit;text-decoration:none}
</div>
<div id="hub"></div>
<div class="rel" id="rel"></div>
--</div></div>
++</div>
++<!-- ov-frame:end -->
++</div>
<div id="ov-footer"></div>
<div class="toast" id="toast"></div>
<script src="/shared/navbar.js"></script>
@@@ -183,7 +195,7 @@ jsonminify:{n:"JSON Minifier",i:'fa-compress',c:'data',d:"Compact JSON to the sm
jsonparse:{n:"JSON Parser",i:'fa-sitemap',c:'data',d:"Explore JSON as a tree, a list of paths or a type map",p:"{\"user\":{\"id\":7,\"name\":\"Ada\",\"roles\":[\"admin\",\"dev\"],\"active\":true},\"count\":2}",m:["Tree", "Paths", "Types"]},
jsonvalidate:{n:"JSON Validator",i:'fa-circle-check',c:'data',d:"Check JSON and find the exact error",p:"{\n \"name\": \"openvibe\",\n \"version\": 1,\n}",m:["Validate"]},
jsonstringify:{n:"JSON Stringify",i:'fa-quote-right',c:'data',d:"Turn JSON into an escaped string literal, and back",p:"{\"name\":\"openvibe\",\"note\":\"line one\\nline two\"}",m:["Stringify", "Parse string"]},
--html2md:{n:"HTML to Markdown",i:'fa-right-left',c:'data',d:"Convert HTML into clean Markdown",p:"<h1>Hello World</h1>\n<p>This is <strong>bold</strong>, <em>italic</em> and a <a href=\"https://openvibe.network\">link</a>.</p>\n<ul><li>Item one</li><li>Item two</li></ul>\n<pre><code>code block</code></pre>",m:["Convert"]},
++html2md:{n:"HTML to Markdown",i:'fa-right-left',c:'data',d:"Convert HTML into clean Markdown",p:"\u003ch1>Hello World\u003c/h1>\n<p>This is <strong>bold</strong>, <em>italic</em> and a <a href=\"https://openvibe.network\">link</a>.</p>\n<ul><li>Item one</li><li>Item two</li></ul>\n<pre><code>code block</code></pre>",m:["Convert"]},
md2html:{n:"Markdown to HTML",i:'fa-right-left',c:'data',d:"Convert Markdown into HTML you can paste anywhere",p:"# Hello World\n\nThis is **bold** and *italic* with a [link](https://openvibe.network).\n\n- Item one\n- Item two\n\n> A quote\n\n`inline code`",m:["To HTML", "Preview"]},
minify:{n:'Code Minifier',i:'fa-compress',c:'qual',d:'Minify JS, CSS, HTML & JSON',p:'{\n "name": "openvibe",\n "version": 1\n}',m:['JSON','HTML','CSS']},
codediff:{n:'Diff Checker',i:'fa-code-compare',c:'qual',d:'Side-by-side text diff checker',p:'Line one\nLine two\nLine three\nLine four',p2:'Line one\nLine 2 modified\nLine three\nLine five\nLine six',dual:1,m:['Compare']},
@@@ -205,12 +217,21 @@ let ACT=T[META]?META:(T[sub]?sub:'dev'),MODE='';
document.addEventListener('DOMContentLoaded',()=>{
const tok=localStorage.getItem('ov_token');
const cur=T[ACT]||T.dev;
-- if(window.OpenVibeNavbar)try{OpenVibeNavbar.init({service:'dev',apiBase:'https://openvibe.network',token:tok,history:{type:'tool',title:ACT==='dev'?'Dev.OpenVibe':cur.n},silentLogin:'https://openvibe.tools/auth/login?silent=1&next={url}'})}catch(e){}
++ if(window.OpenVibeNavbar)try{OpenVibeNavbar.init({service:'dev',apiBase:'https://openvibe.network',token:tok,history:{type:'tool',title:ACT==='dev'?'Dev.OpenVibe':cur.n},silentLogin:'https://openvibe.tools/auth/login?silent=1&next={url}',...(document.body.classList.contains('ov-tool-page')?{brand:{subLabel:'Tools',name:'OpenVibe.Tools'}}:{})})}catch(e){}
if(window.OpenVibeFooter)try{
// Footer columns come from the same catalog as the hub, one column per category.
const links=Object.entries(CATS).map(([cid,cat])=>({heading:cat.l,items:Object.entries(T).filter(([,t])=>t.c===cid).map(([id,t])=>({name:t.n,url:'https://'+id+'.openvibe.tools'}))})).filter(g=>g.items.length);
-- OpenVibeFooter.init({service:'dev',variant:ACT==='dev'?'full':'compact',mount:'#ov-footer',apiBase:'https://openvibe.network',links});
++ OpenVibeFooter.init({service:'dev',variant:ACT==='dev'?'full':'compact',mount:'#ov-footer',apiBase:'https://openvibe.network',brandName:document.body.classList.contains('ov-tool-page')?'OpenVibe.Tools':undefined,links});
}catch(e){}
++ // The frame's box is a form (GET without JavaScript); with it, submit runs in place.
++ const form=document.querySelector('[data-ov-tool-page] form[data-ov-tool-form]');
++ if(form)form.addEventListener('submit',e=>{e.preventDefault();run()});
++ const clear=document.getElementById('tool-clear');
++ if(clear)clear.addEventListener('click',()=>clr());
++ document.querySelectorAll('[data-ov-try-two]').forEach(a=>a.addEventListener('click',e=>{
++ e.preventDefault();
++ try{const [x,y]=JSON.parse(a.getAttribute('data-ov-try-two'));if($('in1'))$('in1').value=x;if($('in2'))$('in2').value=y;run()}catch(_){}
++ }));
ACT==='dev'?showHub():showTool();
});
@@@ -229,31 +250,66 @@ function showHub(){
}
function showTool(){
-- $('hub').style.display='none';$('tool').style.display='';
++ const framed=!!document.querySelector('[data-ov-tool-page]');
const t=T[ACT];if(!t)return showHub();
-- $('hi').innerHTML='<i class="fa-solid '+t.i+'"></i>';
-- $('ht').textContent=t.n;$('hd').textContent=t.d;
-- // Title comes from the server (server/seo) so crawlers and the tab agree — don't clobber it.
-- if(!document.querySelector('link[rel=canonical][href*="'+ACT+'.openvibe"]'))document.title=t.n+' \u2014 '+t.d;
-- let mh='';
-- if(t.m)t.m.forEach((m,i)=>{mh+='<button class="mb'+(i===0?' on':'')+'" data-m="'+m+'" onclick="setMode(this)">'+m+'</button>'});
-- $('modes').innerHTML=mh;MODE=t.m?t.m[0]:'';
-- let ioh='<textarea id="in1" spellcheck="false" placeholder="'+H(t.p||'Paste here...')+'"></textarea>';
-- if(t.dual)ioh+='<textarea id="in2" spellcheck="false" placeholder="'+H(t.p2||'')+'"></textarea>';
-- $('io').innerHTML=ioh;
-- let ah='<button class="ab ab-go" onclick="run()"><i class="fa-solid fa-play"></i> Run</button>';
-- ah+='<button class="ab ab-s" onclick="copyOut()"><i class="fa-solid fa-copy"></i> Copy</button>';
-- ah+='<button class="ab ab-s" onclick="saveAsPaste(this)" title="Save the output as an unlisted paste on OpenVibe.Community"><i class="fa-solid fa-note-sticky"></i> Save as paste</button>';
-- ah+='<button class="ab ab-s" onclick="clr()"><i class="fa-solid fa-trash"></i> Clear</button>';
-- ah+='<button class="ab ab-s" onclick="sample()"><i class="fa-solid fa-flask"></i> Sample</button>';
-- $('acts').innerHTML=ah;
-- buildRel();
-- const q=new URLSearchParams(location.search).get('q');
-- if(q){$('in1').value=q;run()}
-- else if(['uuid','lorem','timestamp','color'].includes(ACT)){if(t.p)$('in1').value=t.p;run()}
++ if(!framed){
++ $('hub').style.display='none';$('tool').style.display='';
++ $('hi').innerHTML='<i class="fa-solid '+t.i+'"></i>';
++ $('ht').textContent=t.n;$('hd').textContent=t.d;
++ // Title comes from the server (server/seo) so crawlers and the tab agree — don't clobber it.
++ if(!document.querySelector('link[rel=canonical][href*="'+ACT+'.openvibe"]')&&!document.querySelector('[data-ov-tool-page]'))document.title=t.n+' \u2014 '+t.d;
++ let ioh='<textarea id="in1" spellcheck="false" placeholder="'+H(t.p||'Paste here...')+'"></textarea>';
++ if(t.dual)ioh+='<textarea id="in2" spellcheck="false" placeholder="'+H(t.p2||'')+'"></textarea>';
++ $('io').innerHTML=ioh;
++ }
++ // A framed page's modes are drawn by the server: one mode is the button's own verb, several are the kit's
++ // segmented control and the button says the active one. A page served without the frame keeps the old buttons.
++ if(framed){
++ const runBtn=document.getElementById('tool-run');
++ const checked=document.querySelector('#modes input[name="mode"]:checked');
++ MODE=(checked&&checked.value)||(runBtn&&runBtn.dataset.mode)||MODE||'';
++ const modes=document.getElementById('modes');
++ if(modes&&!modes.dataset.ovWired){
++ modes.dataset.ovWired='1';
++ modes.addEventListener('change',(e)=>{
++ if(e.target.name!=='mode')return;
++ MODE=e.target.value;
++ const span=runBtn&&runBtn.querySelector('span');
++ if(span)span.textContent=MODE;
++ run();
++ });
++ }
++ } else if(t.m){
++ const modes=document.getElementById('modes');
++ MODE=t.m[0];
++ if(modes)modes.innerHTML=t.m.map((m,i)=>'<button type="button" class="ovk-btn ovk-btn--'+(i===0?'ghost':'quiet')+' ovk-btn--small mb" data-m="'+H(m)+'" aria-pressed="'+(i===0?'true':'false')+'" onclick="setMode(this)">'+H(m)+'</button>').join('');
++ }
++ if(!framed){
++ let ah='<button class="ab ab-go" onclick="run()"><i class="fa-solid fa-play"></i> Run</button>';
++ ah+='<button class="ab ab-s" onclick="copyOut()"><i class="fa-solid fa-copy"></i> Copy</button>';
++ ah+='<button class="ab ab-s" onclick="saveAsPaste(this)" title="Save the output as an unlisted paste on OpenVibe.Community"><i class="fa-solid fa-note-sticky"></i> Save as paste</button>';
++ ah+='<button class="ab ab-s" onclick="clr()"><i class="fa-solid fa-trash"></i> Clear</button>';
++ ah+='<button class="ab ab-s" onclick="sample()"><i class="fa-solid fa-flask"></i> Sample</button>';
++ $('acts').innerHTML=ah;
++ }
++ if(!framed)buildRel();
++ // A Try or a shared link (?q=, and the second box for diff/regex) fills the box and runs once.
++ if(devRunFromQuery())return;
++ if(['uuid','lorem','timestamp','color'].includes(ACT)&&t.p){$('in1').value=t.p;run()}
}
--function setMode(el){document.querySelectorAll('.mb').forEach(b=>b.classList.remove('on'));el.classList.add('on');MODE=el.dataset.m;run()}
++/** Fill the box from ?q= (and ?q2=) and run once; true when it did. */
++function devRunFromQuery(){
++ const params=new URLSearchParams(location.search);
++ const q=params.get('q'),q2=params.get('q2');
++ if(!q||!$('in1'))return false;
++ $('in1').value=q;
++ if(q2&&$('in2'))$('in2').value=q2;
++ run();
++ return true;
++}
++
++function setMode(el){document.querySelectorAll('#modes .mb').forEach(b=>{b.classList.toggle('ovk-btn--ghost',b===el);b.classList.toggle('ovk-btn--quiet',b!==el);b.setAttribute('aria-pressed',b===el?'true':'false')});MODE=el.dataset.m;run()}
function buildRel(){
const cat=T[ACT]?.c;let h='';
@@@ -269,9 +325,59 @@ function buildRel(){
const RT=OVWebRuntime.create({});
async function run(){
const g=RT.nextRoute(),sc=RT.scope();
-- const i1=$('in1')?.value||'',i2=$('in2')?.value||'',o=$('out');
-- try{const r=await P[ACT](i1,MODE,i2,sc);if(r==null||!RT.isCurrent(g))return;o.innerHTML=typeof r==='object'?r.html:H(String(r))}
-- catch(e){if(RT.isCurrent(g)&&e.name!=='AbortError')o.innerHTML='<span class="err">\u2717 '+H(e.message)+'</span>'}
++ const i1=$('in1')?.value||'',i2=$('in2')?.value||'';
++ try{
++ const r=await P[ACT](i1,MODE,i2,sc);
++ if(r==null||!RT.isCurrent(g))return;
++ renderDevAnswer(r);
++ }catch(e){
++ if(RT.isCurrent(g)&&e.name!=='AbortError')renderDevError(e);
++ }
++}
++
++// ── The answer, in the kit's tool frame (.ovk-tool-out) ──
++// A transform's result is the output itself; a validator's error names the line and the cause (tone 'bad'). Every
++// answer keeps Copy and Save as paste as its actions, and the untouched output in a closed Raw fold.
++let ANSWER_RAW='';
++function answerOut(){return document.querySelector('.ovk-tool-out')||$('out')}
++function answerActions(){
++ const kit=window.OpenVibeKit;if(!kit)return[];
++ return [
++ kit.iconButton({type:'button',icon:'copy',label:'Copy result',attrs:{'data-ovk-copy':'#ov-tool-answer'}}),
++ kit.iconButton({type:'button',icon:'file',label:'Save as paste',attrs:{'data-ov-save-paste':''}}),
++ ];
++}
++function wireAnswer(root){
++ if(window.OpenVibeKitWeb)OpenVibeKitWeb.enhance(root);
++ if(window.OpenVibeKitData)OpenVibeKitData.enhance(root);
++ const save=root.querySelector('[data-ov-save-paste]');
++ if(save)save.addEventListener('click',()=>saveAsPaste(save));
++}
++function renderDevAnswer(r){
++ const out=answerOut();if(!out)return;
++ const kit=window.OpenVibeKit;if(!kit){out.innerHTML=typeof r==='object'&&r.html?r.html:'<pre>'+H(String(r))+'</pre>';return}
++ ANSWER_RAW=typeof r==='string'?r:(r&&r.html!=null?r.html:JSON.stringify(r,null,2));
++ const body=(r&&typeof r==='object'&&r.html!=null?'<div class="ovk-dev-out">'+r.html+'</div>':'<pre class="ovk-dev-out">'+H(String(r))+'</pre>')
++ +kit.fold({title:'Raw',body:kit.snippet({code:ANSWER_RAW})});
++ out.innerHTML=kit.answer({
++ id:'ov-tool-answer',title:(r&&typeof r==='object'&&r.title)||((MODE||'Tool')+' result'),badge:(r&&typeof r==='object'&&r.badge)||{text:MODE||'Done',tone:'ok'},
++ rows:(r&&typeof r==='object'&&r.rows)||[],body,actions:answerActions(),next:(r&&typeof r==='object'&&r.next)||[],
++ });
++ wireAnswer(out);
++}
++function renderDevError(e){
++ const out=answerOut();if(!out)return;
++ const msg=String(e&&e.message||e||'The tool failed');
++ const line=/Line (\d+)/.exec(msg);
++ ANSWER_RAW=msg;
++ const kit=window.OpenVibeKit;
++ if(!kit){out.innerHTML='<span class="err">\u2717 '+H(msg)+'</span>';return}
++ out.innerHTML=kit.answer({
++ id:'ov-tool-answer',tone:'bad',title:line?'Not valid JSON':'Run failed',badge:line?{text:'Line '+line[1],tone:'bad'}:{text:'Error',tone:'bad'},
++ rows:[['Problem',msg]],body:'<p class="ovk-muted">'+(line?'The line and column above are where the input stops being valid.':'')+'</p>',
++ actions:answerActions(),
++ });
++ wireAnswer(out);
}
const P={};
@@@ -331,7 +437,8 @@ P.xmlminify=(s)=>{const d=new DOMParser().parseFromString(s,'application/xml');c
const keep=[];let o=s.replace(/<!\[CDATA\[[\s\S]*?\]\]>/g,(m)=>{keep.push(m);return'\u0000'+(keep.length-1)+'\u0000'}).replace(/<!--[\s\S]*?-->/g,'').replace(/>\s+</g,'><').trim().replace(/\u0000(\d+)\u0000/g,(_,i)=>keep[+i]);return withNote(o,saved(s,o))};
P.jsonminify=(s)=>{const o=DevEngine.jsonMinify(s);return withNote(o,saved(s,o))};
P.jsonvalidate=(s)=>{const j=DevEngine.jsonStats(s);
-- return'\u2713 Valid JSON\n\nTop level: '+j.kind+(j.kind==='array'?' with '+j.size+' items':j.kind==='object'?' with '+j.size+' keys':'')+'\nValues: '+j.values.toLocaleString()+'\nDeepest nesting: '+j.depth};
++ const size=j.kind==='array'?j.size+' items':j.kind==='object'?j.size+' keys':null;
++ return{title:'Valid JSON',badge:{text:'Valid',tone:'ok'},rows:[['Top level',j.kind],['Size',size],['Values',j.values.toLocaleString()],['Deepest nesting',String(j.depth)]],html:'<p>No syntax error. The document parses as JSON.</p>'}};
P.jsonparse=(s,m)=>DevEngine.jsonParse(s,m);
P.jsonstringify=(s,m)=>DevEngine.jsonStringify(s,m);
P.html2md=(s)=>{const doc=new DOMParser().parseFromString(s,'text/html');const esc=(t)=>t.replace(/([\\`*_{}\[\]#|])/g,'\\$1');
@@@ -384,7 +491,7 @@ P.lorem=(s,m)=>DevEngine.lorem(m.includes('Words')?'words':m.includes('Sent')?'s
P.curlconvert=(s,m)=>DevEngine.curlconvert(s,m);
P.webhook=async(s,m,s2,sc)=>{
-- const o=$('out');
++ const o=answerOut();
const r=await sc.fetch(API+'/webhook/bins',{method:'POST',credentials:'include'});const d=await r.json();if(!d.ok)throw new Error(d.error||'Failed');
const binUrl=API+'/webhook/bins/'+d.binId+'/in';
o.innerHTML='<div style="margin-bottom:12px"><div class="jl">Your Webhook URL</div><div style="font-size:1rem;word-break:break-all;color:var(--ac);cursor:pointer" onclick="navigator.clipboard.writeText(\''+binUrl+"');toast('Copied!')\">"+binUrl+'</div><div style="color:var(--tx3);font-size:.78rem;margin-top:4px">Click to copy \u00b7 Polling for requests...</div></div><div id="whr">Waiting for requests...</div>';
@@@ -418,11 +525,12 @@ P.read=async(s,m)=>{
};
// ═══════════════ UTILITY ═══════════════
--function copyOut(){navigator.clipboard.writeText(($('out').innerText||'')).then(()=>toast('Copied!')).catch(()=>{})}
++function copyOut(){const a=document.querySelector('#ov-tool-answer');navigator.clipboard.writeText((a&&a.innerText)||ANSWER_RAW||'').then(()=>toast('Copied!')).catch(()=>{})}
// The output becomes an unlisted OpenVibe.Community paste (through this gateway's /api/pastes proxy, as
// the signed-in person when there is one); the link appears under the buttons.
async function saveAsPaste(btn){
-- const text=($('out').innerText||'').trim();
++ const ans=document.querySelector('#ov-tool-answer');
++ const text=((ans&&ans.innerText)||ANSWER_RAW||'').trim();
if(!text)return toast('Run the tool first');
btn.disabled=true;
try{
@@@ -431,13 +539,13 @@ async function saveAsPaste(btn){
const d=await r.json().catch(()=>({}));
if(!r.ok||!d.slug)throw new Error(d.error||d.detail||'Could not save the paste');
const url='https://openvibe.community/p/'+encodeURIComponent(d.slug);
-- let n=$('pasted');if(!n){n=document.createElement('div');n.id='pasted';n.style.cssText='margin:8px 0;font-size:.85rem';$('acts').after(n)}
++ let n=$('pasted');if(!n){n=document.createElement('div');n.id='pasted';n.style.cssText='margin:8px 0;font-size:.85rem';const out=document.querySelector('.ovk-tool-out');if(out)out.append(n);else if($('acts'))$('acts').after(n)}
n.textContent='Saved as an unlisted paste: ';
const a=document.createElement('a');a.href=url;a.target='_blank';a.rel='noopener';a.textContent=url.replace('https://','');n.appendChild(a);
toast('Saved as a paste');
}catch(e){toast(e.message)}finally{btn.disabled=false}
}
--function clr(){RT.nextRoute();const p=$('pasted');if(p)p.remove();const a=$('in1');if(a)a.value='';const b=$('in2');if(b)b.value='';$('out').innerHTML=''}
++function clr(){RT.nextRoute();const p=$('pasted');if(p)p.remove();const a=$('in1');if(a)a.value='';const b=$('in2');if(b)b.value='';const o=answerOut();if(o)o.innerHTML='';ANSWER_RAW=''}
function sample(){const t=T[ACT];if(t?.p){$('in1').value=t.p;if(t.p2&&$('in2'))$('in2').value=t.p2}run()}
function toast(msg){const t=$('toast');t.textContent=msg;t.classList.add('show');setTimeout(()=>t.classList.remove('show'),2000)}
</script>
ddiff --git a/apps/gateway/public/net.html b/apps/gateway/public/net.html
iindex 87b9ca0..7249ad1 100644
---- a/apps/gateway/public/net.html
++++ b/apps/gateway/public/net.html
@@@ -504,6 +504,12 @@
from { opacity: 0; transform: scale(0.9); }
to { opacity: 1; transform: scale(1); }
}
++ /* The tool frame: the brand is the site ("Tools"), not the subdomain's name; the kit's buttons carry the accent. */
++ .ov-tool-page .openvibe-navbar .openvibe-navbar-brand .b-core,
++ .ov-tool-page .openvibe-navbar .openvibe-navbar-brand .b-dot,
++ .ov-tool-page .openvibe-navbar .openvibe-navbar-brand .b-tld { display: none; }
++ #options-area .options-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; font-size: .85rem; color: var(--text-secondary); }
++ #options-area select, #options-area input { min-height: 40px; padding: 4px 10px; border-radius: 10px; border: 1px solid var(--border-light); background: var(--bg-input); color: var(--text-primary); font: inherit; }
</style>
<script type="application/ld+json">
{
@@@ -545,6 +551,7 @@
<!-- Navbar injected by OpenVibeNavbar.init() -->
<div class="nav-placeholder" id="navbar-mount"></div>
++ <!-- ov-frame:start -->
<!-- Hero -->
<div class="hero shell" id="hero">
<div class="hero-icon"><i class="fa-solid fa-network-wired"></i></div>
@@@ -604,6 +611,7 @@
<!-- Results -->
<div class="shell results" id="results"></div>
++ <!-- ov-frame:end -->
<!-- Hub: tool directory (shown on net.openvibe.tools) -->
<div class="shell" id="hub-directory" style="display:none"></div>
@@@ -730,8 +738,13 @@
const isLookup = activeTool === 'lookup';
// ── Set page title + meta ────────────────────────────────
-- document.title = isHub ? 'Net.OpenVibe — Network & Internet Diagnostics' : `${tool.name} — ${tool.placeholder || 'Network Tool'}`;
-- document.querySelector('meta[property="og:title"]').content = document.title;
++ // The server wrote this page's own title, description and canonical (seo/render). A framed page keeps them;
++ // the old script rewrote them, which made every tool subdomain share the hub's title.
++ if (!document.querySelector('[data-ov-tool-page]')) {
++ document.title = isHub ? 'Net.OpenVibe — Network & Internet Diagnostics' : `${tool.name} — ${tool.placeholder || 'Network Tool'}`;
++ const ogTitle = document.querySelector('meta[property="og:title"]');
++ if (ogTitle) ogTitle.content = document.title;
++ }
// ── Init ─────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
@@@ -742,11 +755,13 @@
service: 'net', apiBase: 'https://openvibe.network', token,
history: { type: 'tool', title: isHub ? 'Net.OpenVibe' : tool.name },
silentLogin: 'https://openvibe.tools/auth/login?silent=1&next={url}',
++ ...(document.body.classList.contains('ov-tool-page') ? { brand: { subLabel: 'Tools', name: 'OpenVibe.Tools' } } : {}),
});
} catch {}
try {
OpenVibeFooter.init({
service: 'net', variant: isHub ? 'full' : 'compact', mount: '#ov-footer', apiBase: 'https://openvibe.network',
++ brandName: document.body.classList.contains('ov-tool-page') ? 'OpenVibe.Tools' : undefined,
links: [
{ heading: 'Network tools', items: [
{ name: 'Domain & IP lookup', url: 'https://lookup.openvibe.tools' },
@@@ -777,18 +792,34 @@
document.getElementById('stat-tools').textContent = String(n);
}
++ // The box is a GET form; its submit runs the tool without reloading the page.
++ document.querySelectorAll('[data-ov-tool-page] form.ovk-tool-form, form[data-ovk-tool-form]').forEach((form) => {
++ if (form.dataset.ovWired) return;
++ form.dataset.ovWired = '1';
++ form.addEventListener('submit', (e) => { e.preventDefault(); runTool(); });
++ });
++
// If tool auto-runs (myip), do it immediately
if (tool.auto) runTool();
++ // A Try link or a shared link (?q=, or an older target/domain/ip) fills the box and runs once.
++ runFromQuery();
++ });
-- // Enter key
-- const input = document.getElementById('search-input');
-- if (input) input.addEventListener('keydown', e => { if (e.key === 'Enter') runTool(); });
++ /** The first value the address names for this tool, from its own input name or an older alias. */
++ function queryValue(search) {
++ const params = new URLSearchParams(search);
++ return params.get('q') || params.get('target') || params.get('domain') || params.get('ip') || '';
++ }
-- // Check for ?q= parameter
-- const params = new URLSearchParams(location.search);
-- const q = params.get('q') || params.get('target') || params.get('domain') || params.get('ip');
-- if (q && input) { input.value = q; runTool(); }
-- });
++ /** Fill the box from ?q= and run it once (a shared link or a Try link opened without JavaScript's form submit). */
++ function runFromQuery() {
++ const q = queryValue(location.search);
++ const input = document.getElementById('search-input');
++ if (!q || !input) return false;
++ input.value = q;
++ runTool();
++ return true;
++ }
function getCookie(name) {
const v = document.cookie.match('(^|;)\\s*' + name + '\\s*=\\s*([^;]+)');
@@@ -797,103 +828,92 @@
// ── Setup UI based on active tool ────────────────────────
function setupUI() {
-- const hero = document.getElementById('hero');
-- const heroIcon = hero.querySelector('.hero-icon');
-- const heroH1 = hero.querySelector('h1');
-- const heroSub = hero.querySelector('.sub');
++ const framed = !!document.querySelector('[data-ov-tool-page]');
const searchInput = document.getElementById('search-input');
const btnGo = document.getElementById('btn-go');
const chipsArea = document.getElementById('tool-chips-area');
-- // Update hero
-- heroIcon.innerHTML = `<i class="fa-solid ${tool.icon}"></i>`;
-- if (isHub) {
-- heroH1.innerHTML = '<span class="accent">OpenVibe</span>Net';
-- heroSub.textContent = 'Online network diagnostics — DNS, GeoIP, ping, SSL, Whois, HTTP headers, and more. No login required.';
-- } else {
-- heroH1.innerHTML = tool.name.replace('OpenVibe', '<span class="accent">OpenVibe</span>');
-- heroSub.textContent = tool.placeholder || '';
-- }
--
-- // Update search
-- if (tool.placeholder) {
-- searchInput.placeholder = tool.placeholder;
-- }
-- if (tool.auto || tool.unavailable) {
-- document.getElementById('search-bar').style.display = 'none';
-- }
-- // A tool this server cannot run: say so where the results would be, and point at what works.
-- if (tool.unavailable) {
-- const box = document.createElement('div');
-- box.className = 'card tool-unavailable';
-- box.setAttribute('role', 'status');
-- const head = document.createElement('div');
-- head.className = 'card-head';
-- head.textContent = 'Not available';
-- const p = document.createElement('p');
-- p.textContent = tool.unavailable;
-- box.append(head, p);
-- if (tool.instead && TOOLS[tool.instead]) {
-- const a = document.createElement('a');
-- a.className = 'pivot';
-- a.href = `https://${tool.instead}.openvibe.tools`;
-- a.textContent = `Open the ${TOOLS[tool.instead].name}`;
-- box.append(a);
++ // A framed page has its head and box from the server (seo/frame.js): no hero, no chip cloud.
++ if (!framed) {
++ const hero = document.getElementById('hero');
++ if (hero) {
++ const heroIcon = hero.querySelector('.hero-icon');
++ const heroH1 = hero.querySelector('h1');
++ const heroSub = hero.querySelector('.sub');
++ if (heroIcon) heroIcon.innerHTML = `<i class="fa-solid ${tool.icon}"></i>`;
++ if (isHub) {
++ if (heroH1) heroH1.innerHTML = '<span class="accent">OpenVibe</span>Net';
++ if (heroSub) heroSub.textContent = 'Online network diagnostics — DNS, GeoIP, ping, SSL, Whois, HTTP headers, and more. No login required.';
++ } else {
++ if (heroH1) heroH1.innerHTML = tool.name.replace('OpenVibe', '<span class="accent">OpenVibe</span>');
++ if (heroSub) heroSub.textContent = tool.placeholder || '';
++ }
}
-- document.getElementById('results').append(box);
-- }
-- if (tool.btnText) {
-- btnGo.innerHTML = `<i class="fa-solid fa-magnifying-glass"></i> ${tool.btnText}`;
-- }
--
-- // Build tool chips or tool selector
-- if (isHub) {
-- // Hub: show tool directory
-- document.getElementById('hub-directory').style.display = '';
-- buildHubDirectory();
-- // Quick-pick chips
-- const popular = ['lookup', 'myip', 'dns', 'ping', 'ssl', 'geoip', 'whois', 'headers', 'rdns', 'port'];
-- chipsArea.innerHTML = `<div class="tool-chips">${popular.map(id => {
-- const t = TOOLS[id];
-- return `<span class="tool-chip" onclick="goTool('${id}')"><i class="fa-solid ${t.icon}"></i> ${t.name.replace('OpenVibe', '')}</span>`;
-- }).join('')}</div>`;
-- } else {
-- // Tool page: show related tools as mini-nav
-- const related = Object.entries(TOOLS).filter(([,t]) => t.cat === tool.cat && !t.auto && !t.unavailable);
-- if (related.length > 1) {
-- chipsArea.innerHTML = `<div class="tool-selector">${related.map(([id, t]) => {
-- const active = id === activeTool ? ' active' : '';
-- return `<button class="ts-btn${active}" onclick="goTool('${id}')"><i class="fa-solid ${t.icon}"></i> ${t.name.replace('OpenVibe', '')}</button>`;
++ if (tool.placeholder && searchInput) searchInput.placeholder = tool.placeholder;
++ if ((tool.auto || tool.unavailable) && document.getElementById('search-bar')) document.getElementById('search-bar').style.display = 'none';
++ if (tool.unavailable) {
++ const results = document.getElementById('results');
++ if (results) {
++ const box = document.createElement('div');
++ box.className = 'card tool-unavailable';
++ box.setAttribute('role', 'status');
++ const head = document.createElement('div');
++ head.className = 'card-head';
++ head.textContent = 'Not available';
++ const p = document.createElement('p');
++ p.textContent = tool.unavailable;
++ box.append(head, p);
++ if (tool.instead && TOOLS[tool.instead]) {
++ const a = document.createElement('a');
++ a.className = 'pivot';
++ a.href = `https://${tool.instead}.openvibe.tools`;
++ a.textContent = `Open the ${TOOLS[tool.instead].name}`;
++ box.append(a);
++ }
++ results.append(box);
++ }
++ }
++ if (tool.btnText && btnGo) btnGo.innerHTML = `<i class="fa-solid fa-magnifying-glass"></i> ${tool.btnText}`;
++ if (isHub) {
++ document.getElementById('hub-directory').style.display = '';
++ buildHubDirectory();
++ const popular = ['lookup', 'myip', 'dns', 'ping', 'ssl', 'geoip', 'whois', 'headers', 'rdns', 'port'];
++ if (chipsArea) chipsArea.innerHTML = `<div class="tool-chips">${popular.map(id => {
++ const t = TOOLS[id];
++ return `<span class="tool-chip" onclick="goTool('${id}')"><i class="fa-solid ${t.icon}"></i> ${t.name.replace('OpenVibe', '')}</span>`;
}).join('')}</div>`;
++ } else if (chipsArea) {
++ const related = Object.entries(TOOLS).filter(([, t]) => t.cat === tool.cat && !t.auto && !t.unavailable);
++ if (related.length > 1) chipsArea.innerHTML = `<div class="tool-selector">${related.map(([id, t]) => `<button class="ts-btn${id === activeTool ? ' active' : ''}" onclick="goTool('${id}')"><i class="fa-solid ${t.icon}"></i> ${t.name.replace('OpenVibe', '')}</button>`).join('')}</div>`;
}
}
-- // Tool options
++ // Tool options (still inside the framed card, under the box): a record type, a port, a robots path, DNS types.
const opts = document.getElementById('options-area');
-- if (tool.propTypes) {
-- opts.innerHTML = `<div class="options-row"><label for="prop-type">Record type:</label>
-- <select id="prop-type">${['A', 'AAAA', 'CNAME', 'MX', 'NS', 'TXT'].map(t => `<option value="${t}">${t}</option>`).join('')}</select></div>`;
-- }
-- if (tool.smtpPorts) {
-- opts.innerHTML = `<div class="options-row"><label for="smtp-port">Port:</label>
-- <select id="smtp-port"><option value="25">25 (server to server)</option><option value="587">587 (submission)</option></select></div>`;
-- }
-- if (tool.robotsTest) {
-- opts.innerHTML = `<div class="options-row"><label for="robots-path">Test a path (optional):</label>
-- <input id="robots-path" type="text" placeholder="/private/page.html" spellcheck="false" style="min-width:200px">
-- <label for="robots-ua">as</label>
-- <select id="robots-ua"><option value="*">any crawler (*)</option><option>Googlebot</option><option>Bingbot</option><option>GPTBot</option></select></div>`;
-- }
--
-- // DNS type options
-- if (tool.dnsTypes) {
-- const types = ['A', 'AAAA', 'MX', 'TXT', 'CNAME', 'NS', 'SOA', 'PTR', 'CAA', 'SRV'];
-- document.getElementById('options-area').innerHTML = `<div class="options-row">
-- <label>Record types:</label>
-- <select id="dns-types" multiple style="min-width:200px;min-height:28px">
-- ${types.map(t => `<option value="${t}" ${['A','AAAA','MX','TXT','NS'].includes(t) ? 'selected' : ''}>${t}</option>`).join('')}
-- </select>
-- </div>`;
++ if (opts) {
++ if (tool.propTypes) {
++ opts.innerHTML = `<div class="options-row"><label for="prop-type">Record type:</label>
++ <select id="prop-type">${['A', 'AAAA', 'CNAME', 'MX', 'NS', 'TXT'].map(t => `<option value="${t}">${t}</option>`).join('')}</select></div>`;
++ }
++ if (tool.smtpPorts) {
++ opts.innerHTML = `<div class="options-row"><label for="smtp-port">Port:</label>
++ <select id="smtp-port"><option value="25">25 (server to server)</option><option value="587">587 (submission)</option></select></div>`;
++ }
++ if (tool.robotsTest) {
++ opts.innerHTML = `<div class="options-row"><label for="robots-path">Test a path (optional):</label>
++ <input id="robots-path" type="text" placeholder="/private/page.html" spellcheck="false" style="min-width:200px">
++ <label for="robots-ua">as</label>
++ <select id="robots-ua"><option value="*">any crawler (*)</option><option>Googlebot</option><option>Bingbot</option><option>GPTBot</option></select></div>`;
++ }
++ if (tool.dnsTypes) {
++ const types = ['A', 'AAAA', 'MX', 'TXT', 'CNAME', 'NS', 'SOA', 'PTR', 'CAA', 'SRV'];
++ opts.innerHTML = `<div class="options-row">
++ <label>Record types:</label>
++ <select id="dns-types" multiple style="min-width:200px;min-height:28px">
++ ${types.map(t => `<option value="${t}" ${['A','AAAA','MX','TXT','NS'].includes(t) ? 'selected' : ''}>${t}</option>`).join('')}
++ </select>
++ </div>`;
++ }
}
}
@@@ -973,7 +993,8 @@
async function runTool() {
const input = document.getElementById('search-input');
const target = input ? input.value.trim() : '';
-- const resultsEl = document.getElementById('results');
++ const resultsEl = document.querySelector('.ovk-tool-out');
++ if (!resultsEl) return;
// Hub: if user types something, redirect to lookup
if (isHub && target) {
@@@ -984,8 +1005,8 @@
if (!tool.endpoint || tool.unavailable) return;
if (!target && !tool.auto) { toast('Please enter a domain, IP, or URL', 'error'); return; }
-- // Show loading
-- resultsEl.innerHTML = `<div class="loading"><i class="fa-solid fa-circle-notch fa-spin"></i><p>Running ${tool.name}…${tool.wait ? ` (${tool.wait})` : ''}</p></div>`;
++ // Show loading (in the frame's answer slot)
++ resultsEl.innerHTML = `<section class="ovk-answer" aria-live="polite"><div class="ovk-answer-head"><h2>Running ${esc(tool.name)}…</h2></div><div class="ovk-answer-body"><p class="ovk-muted">${esc(tool.wait ? `This takes ${tool.wait}.` : 'Asking OpenVibe\'s server.')}</p></div></section>`;
try {
// The tool's run API (plan T8, Phase 1): the fields the page collected, kept only where the
@@@ -1017,690 +1038,379 @@
renderResults(data);
} catch (err) {
-- resultsEl.innerHTML = `<div class="error-msg"><i class="fa-solid fa-circle-exclamation"></i> ${esc(err.message)}</div>`;
++ showAnswer({ title: 'Lookup failed', badge: { text: 'Error', tone: 'bad' }, tone: 'bad', rows: [], body: `<p>${esc(err.message)}</p>` });
}
}
// A probe (port, ping, latency, traceroute, mtr) sends traffic to the target, so the run API asks for an account
// (401); an expired sign-in answers 401 too, and the same card signs the visitor back in.
-- function renderSignIn(el) {
++ function renderSignIn() {
const next = encodeURIComponent(location.href);
-- el.innerHTML = `<div class="card signin-card"><i class="fa-solid fa-lock" aria-hidden="true"></i><div class="signin-text"><b>Sign in to run ${esc(tool.name)}</b><p>Probes like ping, port checks and traceroute send traffic to the target itself, so they run under an OpenVibe account. It is free, and lookups like DNS still work without one.</p></div><a class="btn-go" href="https://openvibe.tools/auth/login?next=${next}">Sign in</a></div>`;
++ const kit = window.OpenVibeKit;
++ showAnswer({
++ title: 'Sign in to run this probe', badge: { text: 'Account needed', tone: 'warn' },
++ rows: [], body: `<p>Probes like ping, port checks and traceroute send traffic to the target itself, so they run under an OpenVibe account. It is free, and lookups like DNS still work without one.</p>`,
++ next: kit ? [kit.button({ href: `https://openvibe.tools/auth/login?next=${next}`, label: 'Sign in', icon: 'user' })] : [],
++ });
}
// ── Render results based on tool type ────────────────────
function renderResults(data) {
-- const el = document.getElementById('results');
--
-- // Route to specialized renderers
-- if (activeTool === 'myip') return renderMyIP(el, data);
-- if (activeTool === 'lookup' || isLookup) return renderLookup(el, data);
-- if (tool.endpoint === '/ip') return renderIP(el, data);
-- if (tool.endpoint === '/ipv4') return renderIPv4(el, data);
-- if (tool.endpoint === '/ipv6') return renderIPv6(el, data);
-- if (tool.endpoint === '/dns') return renderDNS(el, data);
-- if (tool.endpoint === '/rdns') return renderRDNS(el, data);
-- if (tool.endpoint === '/rdap') return renderRDAP(el, data);
-- if (tool.endpoint === '/ssl') return renderSSL(el, data);
-- if (tool.endpoint === '/headers') return renderHeaders(el, data);
-- if (tool.endpoint === '/redirects') return renderRedirects(el, data);
-- if (tool.endpoint === '/port') return renderPort(el, data);
-- if (tool.endpoint === '/ping') return renderPing(el, data);
-- if (tool.endpoint === '/trace') return renderTrace(el, data, false);
-- if (tool.endpoint === '/mtr') return renderTrace(el, data, true);
-- if (tool.endpoint === '/dnsprop') return renderDnsProp(el, data);
-- if (tool.endpoint === '/blacklist') return renderBlacklist(el, data);
-- if (tool.endpoint === '/reputation') return renderReputation(el, data);
-- if (tool.endpoint === '/smtp') return renderSMTP(el, data);
-- if (tool.endpoint === '/uptime') return renderUptime(el, data);
-- if (tool.endpoint === '/robots') return renderRobots(el, data);
-- if (tool.endpoint === '/sitemap') return renderSitemap(el, data);
--
++ if (activeTool === 'myip') return answerMyIP(data);
++ if (activeTool === 'lookup' || isLookup) return answerLookup(data);
++ if (tool.endpoint === '/ip') return answerIP(data);
++ if (tool.endpoint === '/ipv4') return answerIPv4(data);
++ if (tool.endpoint === '/ipv6') return answerIPv6(data);
++ if (tool.endpoint === '/dns') return answerDNS(data);
++ if (tool.endpoint === '/rdns') return answerRDNS(data);
++ if (tool.endpoint === '/rdap') return answerRDAP(data);
++ if (tool.endpoint === '/ssl') return answerSSL(data);
++ if (tool.endpoint === '/headers') return answerHeaders(data);
++ if (tool.endpoint === '/redirects') return answerRedirects(data);
++ if (tool.endpoint === '/port') return answerPort(data);
++ if (tool.endpoint === '/ping') return answerPing(data);
++ if (tool.endpoint === '/trace') return answerTrace(data, false);
++ if (tool.endpoint === '/mtr') return answerTrace(data, true);
++ if (tool.endpoint === '/dnsprop') return answerDnsProp(data);
++ if (tool.endpoint === '/blacklist') return answerBlacklist(data);
++ if (tool.endpoint === '/reputation') return answerReputation(data);
++ if (tool.endpoint === '/smtp') return answerSMTP(data);
++ if (tool.endpoint === '/uptime') return answerUptime(data);
++ if (tool.endpoint === '/robots') return answerRobots(data);
++ if (tool.endpoint === '/sitemap') return answerSitemap(data);
// Fallback: raw JSON
-- el.innerHTML = `<div class="card"><div class="raw-block">${esc(JSON.stringify(data, null, 2))}</div></div>`;
++ return answerRaw(data);
}
-- // ── Renderers ────────────────────────────────────────────
--
-- function renderMyIP(el, data) {
-- el.innerHTML = `<div class="myip-card">
-- <div style="font-size:13px;color:var(--text-muted)">Your Public IP Address</div>
-- <div class="ip-display">${esc(data.ip)}</div>
-- <button class="copy-btn" onclick="copyText('${esc(data.ip)}')"><i class="fa-solid fa-copy"></i> Copy</button>
-- <div style="margin-top:12px">
-- <span class="pivot" onclick="goLookup('${esc(data.ip)}')"><i class="fa-solid fa-magnifying-glass"></i> Full lookup</span>
-- &nbsp;
-- <span class="pivot" onclick="goToolWithTarget('geoip','${esc(data.ip)}')"><i class="fa-solid fa-earth-americas"></i> GeoIP</span>
-- &nbsp;
-- <span class="pivot" onclick="goToolWithTarget('rdns','${esc(data.ip)}')"><i class="fa-solid fa-rotate-left"></i> Reverse DNS</span>
-- </div>
-- </div>`;
-- // Also fetch full IP info
-- fetchAndAppendIPInfo(el, data.ip);
++ // ── The answer, in the kit's tool frame (.ovk-tool-out) ──
++ // Every lookup now answers as labelled rows with the fact that matters marked, the detail in a kit.table
++ // and the untouched JSON in a closed "Raw" fold. The next step links to a sibling for the same input.
++ const ANSWER_ID = 'ov-tool-answer';
++ const K = () => window.OpenVibeKit;
++ const fmtDate = (v) => {
++ if (!v) return '';
++ const d = new Date(v);
++ return Number.isFinite(d.getTime()) ? d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }) : String(v);
++ };
++ // "in 10 months", "1 year ago": the note on a date row.
++ function relTime(v) {
++ const t = Date.parse(v);
++ if (!Number.isFinite(t)) return '';
++ const days = Math.round((t - Date.now()) / 86400000);
++ const n = Math.abs(days);
++ const word = (unit, one) => `${n} ${one || unit}${n === 1 ? '' : 's'}`;
++ if (days >= 0) {
++ if (n < 1) return 'today';
++ if (n < 60) return `in ${word('day')}`;
++ const months = Math.round(n / 30.44);
++ return `in ${months} month${months === 1 ? '' : 's'}`;
++ }
++ if (n < 2) return 'yesterday';
++ if (n < 60) return `${word('day')} ago`;
++ const months = Math.round(n / 30.44);
++ return `${months} month${months === 1 ? '' : 's'} ago`;
}
--
-- async function fetchAndAppendIPInfo(el, ip) {
-- try {
-- const token = localStorage.getItem('ov_token') || getCookie('ov_token');
-- const headers = { 'Content-Type': 'application/json' };
-- if (token) headers['Authorization'] = `Bearer ${token}`;
-- const r = await fetch(`${API_BASE}/ip/run`, { method: 'POST', headers, credentials: 'same-origin', body: JSON.stringify({ input: { target: ip } }) });
-- const body = await r.json();
-- if (body.state === 'succeeded' && body.result) {
-- const data = { ok: true, ...body.result.data };
-- if (data.ok) el.innerHTML += renderIPCard(data);
-- }
-- } catch {}
++ const lineValue = (arr) => ({ html: (arr || []).filter((x) => x != null && x !== '').map((x) => esc(String(x))).join('<br>') });
++ function rawFold(data) {
++ const kit = K();
++ return kit.fold({ title: 'Raw', body: kit.snippet({ code: JSON.stringify(data, null, 2) }) });
}
--
-- function renderIP(el, data) {
-- el.innerHTML = renderIPCard(data);
++ function nextTool(id, label, value) {
++ return K().button({ href: `https://${id}.openvibe.tools/?q=${encodeURIComponent(value)}`, label, icon: 'chevron', kind: 'quiet', small: true });
}
--
-- function renderIPCard(data) {
-- const geo = data.geo || {};
-- const net = data.network || {};
-- return `
-- <div class="badges">
-- ${geo.city ? `<span class="badge badge-cyan">📍 ${esc(geo.city)}, ${esc(geo.country)}</span>` : ''}
-- ${net.isp ? `<span class="badge badge-blue">🏢 ${esc(net.isp)}</span>` : ''}
-- ${net.as ? `<span class="badge">🔗 ${esc(net.as)}</span>` : ''}
-- ${data.reverse ? `<span class="badge">🔄 ${esc(data.reverse)}</span>` : ''}
-- </div>
-- <div class="card">
-- <div class="card-head"><i class="fa-solid fa-earth-americas"></i> GeoIP & Network</div>
-- <table class="res-table">
-- <tr><td class="key">IP</td><td class="val">${esc(data.ip)}</td></tr>
-- ${data.hostname ? `<tr><td class="key">Hostname</td><td class="val">${esc(data.hostname)}</td></tr>` : ''}
-- <tr><td class="key">Country</td><td class="val">${esc(geo.country || '—')} (${esc(geo.countryCode || '')})</td></tr>
-- <tr><td class="key">Region</td><td class="val">${esc(geo.region || '—')}</td></tr>
-- <tr><td class="key">City</td><td class="val">${esc(geo.city || '—')}</td></tr>
-- <tr><td class="key">Timezone</td><td class="val">${esc(geo.timezone || '—')}</td></tr>
-- <tr><td class="key">Coordinates</td><td class="val">${geo.lat != null ? `${geo.lat}, ${geo.lon}` : '—'}</td></tr>
-- <tr><td class="key">ISP</td><td class="val">${esc(net.isp || '—')}</td></tr>
-- <tr><td class="key">Organization</td><td class="val">${esc(net.org || '—')}</td></tr>
-- <tr><td class="key">AS</td><td class="val">${esc(net.as || '—')}</td></tr>
-- <tr><td class="key">AS Name</td><td class="val">${esc(net.asname || '—')}</td></tr>
-- ${data.reverse ? `<tr><td class="key">Reverse DNS</td><td class="val">${esc(data.reverse)}</td></tr>` : ''}
-- </table>
-- </div>
-- <div style="text-align:center;margin-top:8px">
-- <span class="pivot" onclick="goLookup('${esc(data.ip)}')"><i class="fa-solid fa-magnifying-glass"></i> Full lookup</span>
-- &nbsp;
-- <span class="pivot" onclick="goToolWithTarget('rdns','${esc(data.ip)}')"><i class="fa-solid fa-rotate-left"></i> Reverse DNS</span>
-- &nbsp;
-- <span class="pivot" onclick="goToolWithTarget('whois','${esc(data.ip)}')"><i class="fa-solid fa-address-book"></i> Whois</span>
-- </div>`;
++ function watchNext(value) {
++ if (!/^https?:\/\//i.test(String(value || ''))) return '';
++ return K().button({ href: `https://openvibe.watch/watches/new?template=page&url=${encodeURIComponent(value)}`, label: 'Watch for changes', icon: 'bell', kind: 'quiet', small: true });
}
--
-- function renderDNS(el, data) {
-- const records = data.records || {};
-- let html = `<div class="badges"><span class="badge badge-cyan">🔍 ${esc(data.target)}</span><span class="badge">📡 Server: ${esc(data.server)}</span></div>`;
-- for (const [type, recs] of Object.entries(records)) {
-- if (recs.error) {
-- html += `<div class="card"><div class="card-head"><i class="fa-solid fa-sitemap"></i> ${type}</div><p style="color:var(--text-muted);font-size:13px">No ${type} records (${recs.error})</p></div>`;
-- continue;
-- }
-- html += `<div class="card"><div class="card-head"><i class="fa-solid fa-sitemap"></i> ${type} Records</div>`;
-- if (Array.isArray(recs) && recs.length > 0) {
-- if (type === 'MX') {
-- html += `<table class="res-table"><tr><th>Priority</th><th>Exchange</th></tr>${recs.map(r => `<tr><td class="val">${r.priority}</td><td class="val">${esc(typeof r === 'object' ? r.exchange : r)}</td></tr>`).join('')}</table>`;
-- } else if (type === 'SOA') {
-- const soa = recs;
-- html += `<table class="res-table">
-- <tr><td class="key">Primary NS</td><td class="val">${esc(soa.nsname || soa[0]?.nsname || '—')}</td></tr>
-- <tr><td class="key">Hostmaster</td><td class="val">${esc(soa.hostmaster || soa[0]?.hostmaster || '—')}</td></tr>
-- <tr><td class="key">Serial</td><td class="val">${soa.serial || soa[0]?.serial || '—'}</td></tr>
-- <tr><td class="key">Refresh</td><td class="val">${soa.refresh || soa[0]?.refresh || '—'}s</td></tr>
-- <tr><td class="key">Retry</td><td class="val">${soa.retry || soa[0]?.retry || '—'}s</td></tr>
-- <tr><td class="key">Expire</td><td class="val">${soa.expire || soa[0]?.expire || '—'}s</td></tr>
-- <tr><td class="key">Min TTL</td><td class="val">${soa.minttl || soa[0]?.minttl || '—'}s</td></tr>
-- </table>`;
-- } else if (type === 'TXT') {
-- html += `<div style="display:flex;flex-direction:column;gap:6px">${recs.map(r => {
-- const txt = Array.isArray(r) ? r.join('') : (typeof r === 'object' ? (r.data || JSON.stringify(r)) : r);
-- const isSPF = txt.startsWith('v=spf1');
-- const isDMARC = txt.startsWith('v=DMARC1');
-- const isDKIM = txt.startsWith('v=DKIM1');
-- const label = isSPF ? '🛡️ SPF' : isDMARC ? '🛡️ DMARC' : isDKIM ? '🔑 DKIM' : '';
-- return `<div class="raw-block">${label ? `<strong>${label}</strong> ` : ''}${esc(txt)}</div>`;
-- }).join('')}</div>`;
-- } else {
-- html += `<table class="res-table"><tr><th>Value</th></tr>${recs.map(r => {
-- const val = typeof r === 'object' ? (r.data || JSON.stringify(r)) : r;
-- return `<tr><td class="val">${esc(String(val))}</td></tr>`;
-- }).join('')}</table>`;
-- }
-- } else {
-- html += `<p style="color:var(--text-muted);font-size:13px">No records found</p>`;
-- }
-- html += `</div>`;
++ function answerTable(columns, rows, label) { return K().table({ columns, rows, label }); }
++ function showAnswer(o) {
++ const out = document.querySelector('.ovk-tool-out');
++ if (!out) return;
++ const kit = K();
++ if (!kit) {
++ out.innerHTML = `<div class="card"><pre class="raw-block">${esc(JSON.stringify(o, null, 2))}</pre></div>`;
++ return;
}
--
-- // Pivot links
-- html += `<div style="text-align:center;margin-top:8px">
-- <span class="pivot" onclick="goLookup('${esc(data.target)}')"><i class="fa-solid fa-magnifying-glass"></i> Full lookup</span>
-- &nbsp;
-- <span class="pivot" onclick="goToolWithTarget('ssl','${esc(data.target)}')"><i class="fa-solid fa-lock"></i> SSL</span>
-- &nbsp;
-- <span class="pivot" onclick="goToolWithTarget('whois','${esc(data.target)}')"><i class="fa-solid fa-address-book"></i> Whois</span>
-- </div>`;
--
-- el.innerHTML = html;
++ const actions = [
++ kit.iconButton({ type: 'button', icon: 'copy', label: 'Copy result', attrs: { 'data-ovk-copy': `#${ANSWER_ID}` } }),
++ kit.iconButton({ href: location.href, icon: 'link', label: 'Copy link', attrs: { 'data-ovk-share': 'Copy link' } }),
++ ...(o.actions || []),
++ ];
++ out.innerHTML = kit.answer({ id: ANSWER_ID, title: o.title || '', badge: o.badge || null, rows: o.rows || [], body: o.body || '', next: o.next || [], tone: o.tone || '', actions });
++ if (window.OpenVibeKitWeb) OpenVibeKitWeb.enhance(out);
++ if (window.OpenVibeKitData) OpenVibeKitData.enhance(out);
}
--
-- function renderRDNS(el, data) {
-- el.innerHTML = `<div class="card">
-- <div class="card-head"><i class="fa-solid fa-rotate-left"></i> Reverse DNS</div>
-- <table class="res-table">
-- <tr><td class="key">IP</td><td class="val">${esc(data.ip)}</td></tr>
-- <tr><td class="key">PTR / Hostname</td><td class="val">${data.hostnames?.length ? data.hostnames.map(h => esc(h)).join('<br>') : '<span style="color:var(--text-muted)">No PTR record</span>'}</td></tr>
-- </table>
-- </div>`;
++ function answerRaw(data) {
++ showAnswer({ title: 'Lookup result', badge: { text: 'Raw', tone: 'ok' }, rows: [], body: rawFold(data) });
}
-- function renderRDAP(el, data) {
-- const s = data.summary || {};
-- let html = `<div class="badges">
-- <span class="badge badge-cyan">${esc(data.target)}</span>
-- <span class="badge">${esc(data.type)}</span>
-- ${(s.status || []).map(st => `<span class="badge ${st.includes('active') ? 'badge-green' : ''}">${esc(st)}</span>`).join('')}
-- </div>`;
-- html += `<div class="card"><div class="card-head"><i class="fa-solid fa-id-card"></i> Registration Data (RDAP)</div>
-- <table class="res-table">
-- <tr><td class="key">Name</td><td class="val">${esc(s.name || data.target)}</td></tr>
-- <tr><td class="key">Handle</td><td class="val">${esc(s.handle || '—')}</td></tr>
-- ${(s.events || []).map(e => `<tr><td class="key">${esc(e.action)}</td><td class="val">${esc(e.date)}</td></tr>`).join('')}
-- ${s.nameservers?.length ? `<tr><td class="key">Nameservers</td><td class="val">${s.nameservers.map(n => esc(n)).join('<br>')}</td></tr>` : ''}
-- ${s.port43 ? `<tr><td class="key">Whois Server</td><td class="val">${esc(s.port43)}</td></tr>` : ''}
-- </table>
-- </div>`;
-- if (s.entities?.length) {
-- html += `<div class="card"><div class="card-head"><i class="fa-solid fa-users"></i> Entities</div>
-- <table class="res-table"><tr><th>Handle</th><th>Roles</th><th>Name</th></tr>
-- ${s.entities.map(e => `<tr><td class="val">${esc(e.handle || '—')}</td><td>${(e.roles||[]).join(', ')}</td><td>${esc(e.name || '—')}</td></tr>`).join('')}
-- </table></div>`;
-- }
-- // History: what OpenVibe Tools saw of this domain over time (GET /api/v1/domains/<domain>, filled in below)
-- if (data.type === 'domain') html += `<div class="card" id="rdap-history"><div class="card-head"><i class="fa-solid fa-clock-rotate-left"></i> History</div><p class="hist-note">Loading what OpenVibe Tools saw of this domain over time…</p></div>`;
-- // Raw toggle
-- html += `<details style="margin-top:12px"><summary style="cursor:pointer;color:var(--text-muted);font-size:13px">Raw RDAP response</summary>
-- <div class="raw-block" style="margin-top:8px">${esc(JSON.stringify(data.raw, null, 2))}</div></details>`;
-- el.innerHTML = html;
-- if (data.type === 'domain') loadDomainHistory(el.querySelector('#rdap-history'), data.target);
++ function answerMyIP(data) {
++ showAnswer({
++ title: data.ip, badge: { text: 'Your public IP', tone: 'ok' },
++ rows: [['IP address', data.ip], ['Version', data.version ? `IPv${data.version}` : '']],
++ next: [nextTool('geoip', 'Where is it?', data.ip), nextTool('rdns', 'Reverse DNS', data.ip), nextTool('lookup', 'Full lookup', data.ip)],
++ body: rawFold(data),
++ });
}
-- // The domain's history (apps/gateway/server/net/history.js): every lookup here keeps its public registration facts
-- // (registrar, dates, status, name servers, DNSSEC; never contact details), and a change becomes a new snapshot.
-- const HIST_LABEL = { registrar: 'Registrar', created: 'Registered', expires: 'Expires', status: 'Status', nameservers: 'Name servers', dnssec: 'DNSSEC' };
-- function histValue(field, v) {
-- if (v == null || (Array.isArray(v) && !v.length)) return '—';
-- if (field === 'dnssec') return v ? 'on' : 'off';
-- if (field === 'created' || field === 'expires') return new Date(v).toISOString().slice(0, 10);
-- return Array.isArray(v) ? v.join(', ') : String(v);
-- }
-- async function loadDomainHistory(box, domain) {
-- if (!box) return;
-- let d = null;
-- try { const r = await fetch('/api/v1/domains/' + encodeURIComponent(domain), { credentials: 'omit' }); d = r.ok ? await r.json() : null; } catch (e) { d = null; }
-- const day = (iso) => (iso ? new Date(iso).toISOString().slice(0, 10) : '');
-- if (!d || !d.ok || !d.snapshots || !d.snapshots.length) {
-- box.querySelector('.hist-note').textContent = 'This is the first time OpenVibe Tools has seen this domain. Its facts are kept from now on, so the next lookup can show what changed.';
-- return;
-- }
-- let html = `<p class="hist-note">First seen here on <b>${esc(day(d.first_seen))}</b> · looked up <b>${esc(String(d.lookups))}</b> time${d.lookups === 1 ? '' : 's'} · ${esc(String(d.snapshots.length))} version${d.snapshots.length === 1 ? '' : 's'} kept</p>`;
-- if (!d.changes.length) html += `<p class="hist-note">No changes seen since ${esc(day(d.first_seen))}.</p>`;
-- else html += `<ol class="hist-list">${d.changes.map((c) => `<li><span class="hist-day">${esc(day(c.at))}</span><ul>${c.changes.map((x) => `<li><b>${esc(HIST_LABEL[x.field] || x.field)}</b> <span class="hist-was">${esc(histValue(x.field, x.before))}</span> → ${esc(histValue(x.field, x.after))}</li>`).join('')}</ul></li>`).join('')}</ol>`;
-- html += `<p class="hist-small">Registration facts only, never contact details. <a href="/api/v1/domains/${encodeURIComponent(domain)}" rel="nofollow">As JSON</a></p>`;
-- box.querySelector('.hist-note').outerHTML = html;
++ function answerIP(data) {
++ const g = data.geo || {}, n = data.network || {};
++ showAnswer({
++ title: data.ip, badge: g.country ? { text: g.countryCode || g.country, tone: 'ok' } : null,
++ rows: [
++ ['IP address', data.ip], ['Hostname', data.hostname], ['Reverse DNS', data.reverse],
++ ['Country', g.country ? `${g.country}${g.countryCode ? ` (${g.countryCode})` : ''}` : ''],
++ ['Region', g.region], ['City', g.city], ['Timezone', g.timezone],
++ ['Coordinates', g.lat != null ? `${g.lat}, ${g.lon}` : ''],
++ ['ISP', n.isp], ['Organisation', n.org], ['AS', n.as], ['AS name', n.asname],
++ ],
++ next: [nextTool('rdns', 'Reverse DNS', data.ip), nextTool('whois', 'Whois', data.ip), nextTool('lookup', 'Full lookup', data.ip)],
++ body: rawFold(data),
++ });
}
-- function renderSSL(el, data) {
-- const c = data.certificate || {};
-- const statusClass = c.isExpired ? 'badge-red' : c.isExpiringSoon ? 'badge-yellow' : 'badge-green';
-- const statusText = c.isExpired ? '🔴 EXPIRED' : c.isExpiringSoon ? `🟡 Expires in ${c.daysLeft}d` : `🟢 Valid (${c.daysLeft}d left)`;
-- let html = `<div class="badges">
-- <span class="badge ${statusClass}">${statusText}</span>
-- <span class="badge badge-cyan">${esc(data.protocol || '')}</span>
-- ${data.cipher ? `<span class="badge">${esc(data.cipher.name)}</span>` : ''}
-- </div>`;
-- html += `<div class="card"><div class="card-head"><i class="fa-solid fa-lock"></i> SSL Certificate</div>
-- <table class="res-table">
-- <tr><td class="key">Subject</td><td class="val">${esc(c.subject?.CN || '—')}</td></tr>
-- <tr><td class="key">Issuer</td><td class="val">${esc(c.issuer?.CN || c.issuer?.O || '—')}</td></tr>
-- <tr><td class="key">Serial</td><td class="val" style="font-size:11px">${esc(c.serialNumber || '—')}</td></tr>
-- <tr><td class="key">Valid From</td><td class="val">${esc(c.validFrom || '—')}</td></tr>
-- <tr><td class="key">Valid To</td><td class="val">${esc(c.validTo || '—')}</td></tr>
-- <tr><td class="key">Days Left</td><td class="val ${c.isExpired ? 'style="color:var(--red)"' : ''}">${c.daysLeft}</td></tr>
-- <tr><td class="key">SANs</td><td class="val">${c.sans?.length ? c.sans.map(s => esc(s)).join(', ') : '—'}</td></tr>
-- <tr><td class="key">Fingerprint</td><td class="val" style="font-size:10px">${esc(c.fingerprint || '—')}</td></tr>
-- </table>
-- </div>`;
-- if (data.chain?.length) {
-- html += `<div class="card"><div class="card-head"><i class="fa-solid fa-link"></i> Certificate Chain</div>
-- <table class="res-table"><tr><th>Subject</th><th>Issuer</th><th>Valid To</th></tr>
-- ${data.chain.map(c => `<tr><td class="val">${esc(c.subject)}</td><td>${esc(c.issuer)}</td><td>${esc(c.validTo)}</td></tr>`).join('')}
-- </table></div>`;
-- }
-- el.innerHTML = html;
++ function answerIPv4(data) {
++ const v = data.ipv4 || {}, c = data.cidr;
++ const rows = [['Address', data.ip], ['Class', v.class], ['Type', v.type], ['Public', v.isPublic == null ? '' : (v.isPublic ? 'Yes' : 'No')]];
++ if (c && c.prefix < 32) rows.push(['Block', c.cidr], ['Network', c.network], ['Broadcast', c.broadcast], ['Netmask', c.netmask], ['Wildcard', c.wildcard], ['Usable hosts', `${c.firstUsable} – ${c.lastUsable}`], ['Addresses', `${Number(c.total).toLocaleString('en-US')} total, ${Number(c.usable).toLocaleString('en-US')} usable`]);
++ if (data.geo) rows.push(['Location', [data.geo.city, data.geo.country].filter(Boolean).join(', ')], ['Organisation', (data.network || {}).org || (data.network || {}).isp]);
++ showAnswer({ title: data.ip, badge: { text: v.isPublic ? 'Public' : 'Reserved', tone: v.isPublic ? 'ok' : 'warn' }, rows, next: [nextTool('ip', 'IP lookup', data.ip), nextTool('lookup', 'Full lookup', data.ip)], body: (data.note ? `<p class="ovk-muted">${esc(data.note)}</p>` : '') + rawFold(data) });
}
-- function renderHeaders(el, data) {
-- const h = data.headers || {};
-- let html = `<div class="badges">
-- <span class="badge ${data.status < 400 ? 'badge-green' : 'badge-red'}">HTTP ${data.status} ${esc(data.statusText || '')}</span>
-- ${data.server ? `<span class="badge">Server: ${esc(data.server)}</span>` : ''}
-- ${data.timing ? `<span class="badge badge-cyan">⚡ ${data.timing.ms}ms</span>` : ''}
-- <span class="badge">Security: ${data.securityScore || '?/6'}</span>
-- </div>`;
-- // Security analysis
-- const sec = data.security || {};
-- html += `<div class="card"><div class="card-head"><i class="fa-solid fa-shield-halved"></i> Security Headers</div>
-- <table class="res-table">
-- <tr><td class="key">HSTS</td><td class="val">${sec.hasHSTS ? '✅ Present' : '❌ Missing'}</td></tr>
-- <tr><td class="key">CSP</td><td class="val">${sec.hasCSP ? '✅ Present' : '❌ Missing'}</td></tr>
-- <tr><td class="key">X-Content-Type-Options</td><td class="val">${sec.hasXCTO ? '✅ nosniff' : '❌ Missing'}</td></tr>
-- <tr><td class="key">X-Frame-Options</td><td class="val">${sec.hasXFO ? '✅ Present' : '❌ Missing'}</td></tr>
-- <tr><td class="key">Referrer-Policy</td><td class="val">${sec.hasRP ? '✅ Present' : '❌ Missing'}</td></tr>
-- <tr><td class="key">Permissions-Policy</td><td class="val">${sec.hasPermissions ? '✅ Present' : '❌ Missing'}</td></tr>
-- </table>
-- </div>`;
-- // All headers
-- html += `<div class="card"><div class="card-head"><i class="fa-solid fa-list"></i> Response Headers</div>
-- <table class="res-table">${Object.entries(h).map(([k, v]) => `<tr><td class="key">${esc(k)}</td><td class="val">${esc(v)}</td></tr>`).join('')}</table>
-- </div>`;
-- el.innerHTML = html;
++ function answerIPv6(data) {
++ const v = data.ipv6 || {}, nw = data.network || {};
++ showAnswer({
++ title: data.ip, badge: v.type ? { text: v.type, tone: v.type === 'Global unicast' ? 'ok' : 'warn' } : null,
++ rows: [['Compressed', v.compressed], ['Expanded', v.expanded], ['Scope', v.scope], ['Range', v.prefix], ['Reverse DNS', v.ptr || 'none found'], ['Network', nw.org || nw.isp], ['AS', nw.as], ['Location', data.geo ? [data.geo.city, data.geo.country].filter(Boolean).join(', ') : '']],
++ next: [nextTool('rdns', 'Reverse DNS', data.ip), nextTool('ip', 'IPv4 lookup', data.ip)],
++ body: rawFold(data),
++ });
}
-- function renderRedirects(el, data) {
-- const chain = data.chain || [];
-- let html = `<div class="badges">
-- <span class="badge badge-cyan">${chain.length} hop${chain.length !== 1 ? 's' : ''}</span>
-- <span class="badge badge-green">Final: ${esc(data.finalUrl || '')}</span>
-- </div>`;
-- html += `<div class="card"><div class="card-head"><i class="fa-solid fa-share"></i> Redirect Chain</div>
-- <table class="res-table"><tr><th>#</th><th>URL</th><th>Status</th><th>Time</th></tr>
-- ${chain.map((h, i) => `<tr>
-- <td class="val">${i + 1}</td>
-- <td class="val" style="max-width:400px;overflow:hidden;text-overflow:ellipsis">${esc(h.url)}</td>
-- <td>${h.status == null ? `<span class="badge badge-red" title="${esc(h.error || '')}">${h.refused ? 'refused' : 'error'}</span>` : `<span class="badge ${h.status < 400 ? (h.status >= 300 ? 'badge-yellow' : 'badge-green') : 'badge-red'}">${h.status}</span>`}</td>
-- <td class="val">${h.ms}ms</td>
-- </tr>`).join('')}
-- </table>${data.stopped ? `<p style="color:var(--text-muted);font-size:13px">Stopped: ${esc(data.stopped)}</p>` : ''}</div>`;
-- el.innerHTML = html;
++ function answerRDNS(data) {
++ showAnswer({
++ title: data.ip, badge: data.ptr ? { text: 'PTR found', tone: 'ok' } : { text: 'No PTR', tone: 'warn' },
++ rows: [['IP address', data.ip], ['PTR / hostname', (data.hostnames || []).length ? lineValue(data.hostnames) : 'none found']],
++ next: [nextTool('ip', 'IP lookup', data.ip), nextTool('whois', 'Whois', data.ip)],
++ body: rawFold(data),
++ });
}
-- function renderPort(el, data) {
-- const ports = data.ports || [];
-- let html = `<div class="badges">
-- <span class="badge badge-cyan">${esc(data.target)} (${esc(data.ip)})</span>
-- <span class="badge badge-green">${ports.filter(p => p.status === 'open').length} open</span>
-- <span class="badge badge-red">${ports.filter(p => p.status !== 'open').length} closed/filtered</span>
-- </div>`;
-- html += `<div class="card"><div class="card-head"><i class="fa-solid fa-door-open"></i> Port Scan Results</div>
-- <table class="res-table"><tr><th>Port</th><th>Status</th><th>Latency</th></tr>
-- ${ports.map(p => `<tr>
-- <td class="val">${p.port}</td>
-- <td><span class="badge ${p.status === 'open' ? 'badge-green' : p.status === 'filtered' ? 'badge-yellow' : 'badge-red'}">${p.status}</span></td>
-- <td class="val">${p.ms}ms</td>
-- </tr>`).join('')}
-- </table></div>`;
-- el.innerHTML = html;
++ function answerLookup(data) {
++ const ip = data.ip || {}, dns = data.dns || {}, rdap = data.rdap || {}, ssl = data.ssl || {}, hd = data.headers || {};
++ const dnsCount = Object.values(dns.records || {}).reduce((n, r) => n + (Array.isArray(r) ? r.length : 0), 0);
++ const s = rdap.summary || {};
++ showAnswer({
++ title: 'Domain & IP lookup', badge: { text: 'All-in-one', tone: 'ok' },
++ rows: [
++ ['Target', data.target],
++ ['IP address', ip.ip], ['Location', ip.geo ? [ip.geo.city, ip.geo.country].filter(Boolean).join(', ') : ''], ['Organisation', (ip.network || {}).org || (ip.network || {}).isp],
++ ['DNS', dnsCount ? `${dnsCount} records` : 'none found'],
++ ['Registration', (s.status || []).join(', ') || rdap.error || ''],
++ ['Certificate', ssl.certificate ? `${fmtDate(ssl.certificate.validTo)}${ssl.certificate.daysLeft != null ? ` · ${ssl.certificate.daysLeft} days left` : ''}` : ''],
++ ['HTTP', hd.status ? `${hd.status} ${hd.statusText || ''}` : ''],
++ ],
++ next: [nextTool('dns', 'DNS records', data.target), nextTool('whois', 'Whois', data.target), nextTool('ssl', 'SSL certificate', data.target)],
++ body: rawFold(data),
++ });
}
-- function renderPing(el, data) {
-- const results = data.results || [];
-- const stats = data.stats || {};
-- let html = `<div class="badges">
-- <span class="badge badge-cyan">${esc(data.target)} (${esc(data.ip)})</span>
-- <span class="badge badge-green">Avg: ${stats.avg}ms</span>
-- <span class="badge">Min: ${stats.min}ms / Max: ${stats.max}ms</span>
-- <span class="badge ${stats.loss > 0 ? 'badge-red' : 'badge-green'}">Loss: ${stats.loss}%</span>
-- </div>`;
-- html += `<div class="card"><div class="card-head"><i class="fa-solid fa-satellite-dish"></i> Ping Results (TCP)</div>
-- <table class="res-table"><tr><th>Seq</th><th>Status</th><th>Time</th></tr>
-- ${results.map(r => `<tr>
-- <td class="val">${r.seq}</td>
-- <td><span class="badge ${r.status === 'ok' ? 'badge-green' : 'badge-red'}">${r.status}</span></td>
-- <td class="val">${r.ms}ms</td>
-- </tr>`).join('')}
-- </table>
-- <div style="margin-top:12px;padding-top:12px;border-top:1px solid var(--border)">
-- <table class="res-table">
-- <tr><td class="key">Min</td><td class="val">${stats.min}ms</td><td class="key">Max</td><td class="val">${stats.max}ms</td></tr>
-- <tr><td class="key">Avg</td><td class="val">${stats.avg}ms</td><td class="key">Loss</td><td class="val">${stats.loss}%</td></tr>
-- </table>
-- </div>
-- </div>`;
-- el.innerHTML = html;
++ function answerRDAP(data) {
++ const s = data.summary || {}, ev = {};
++ for (const e of s.events || []) ev[e.action] = e.date;
++ const registrar = (s.entities || []).find((e) => (e.roles || []).includes('registrar'));
++ const created = ev.registration, expires = ev.expiration;
++ const rows = [
++ ['Registrar', registrar ? registrar.name : ''], ['Created', fmtDate(created), created ? relTime(created) : ''],
++ ['Expires', fmtDate(expires), expires ? { text: relTime(expires), tone: 'warn' } : ''],
++ ['Updated', fmtDate(ev['last changed'])], ['Name servers', s.nameservers && s.nameservers.length ? lineValue(s.nameservers) : ''],
++ ['Status', (s.status || []).join(', ')], ['Whois server', s.port43],
++ ];
++ showAnswer({
++ title: data.type === 'ip' ? 'IP registration' : 'Registration record', badge: { text: data.type === 'ip' ? 'IP' : 'Registered', tone: 'ok' },
++ rows: [[data.type === 'ip' ? 'IP address' : 'Domain', data.target], ...rows],
++ next: [nextTool('dns', 'DNS records', data.target), nextTool('ssl', 'SSL certificate', data.target), nextTool('lookup', 'Full lookup', data.target)],
++ body: rawFold(data),
++ });
}
-- // ── Traceroute and MTR: the path from our server, hop by hop (mtr's report; no reply = a router that stays quiet) ──
-- function renderTrace(el, data, full) {
-- const hops = data.hops || [];
-- const ms = (v) => v === null || v === undefined ? '—' : `${v}ms`;
-- const lossBadge = (l) => l === null || l === undefined ? '' : `<span class="badge ${l === 0 ? 'badge-green' : l < 100 ? 'badge-yellow' : 'badge-red'}">${l}%</span>`;
-- let html = `<div class="badges">
-- <span class="badge badge-cyan">${esc(data.target)} (${esc(data.ip)})</span>
-- <span class="badge">${hops.length} ${hops.length === 1 ? 'hop' : 'hops'}</span>
-- <span class="badge ${data.reached ? 'badge-green' : 'badge-yellow'}">${data.reached ? 'Reached' : 'Not reached'}</span>
-- <span class="badge">${data.probes} probes a hop</span>
-- </div>`;
-- html += `<div class="card"><div class="card-head"><i class="fa-solid ${full ? 'fa-chart-line' : 'fa-route'}"></i> ${full ? 'MTR report' : 'Route'} from OpenVibe's server</div>
-- <div style="overflow-x:auto"><table class="res-table"><tr><th>#</th><th>Host</th><th>Network</th><th>Loss</th>${full ? '<th>Sent</th><th>Last</th>' : ''}<th>Best</th><th>Avg</th><th>Worst</th>${full ? '<th>StDev</th>' : ''}</tr>
-- ${hops.map(h => `<tr>
-- <td class="val">${h.hop}</td>
-- <td class="val">${h.ip ? `${h.host ? `${esc(h.host)}<br><span style="color:var(--text-muted);font-size:11px">${esc(h.ip)}</span>` : esc(h.ip)}` : '<span style="color:var(--text-muted)">no reply</span>'}</td>
-- <td class="val">${h.asn ? esc(h.asn) : '—'}</td>
-- <td>${h.ip ? lossBadge(h.loss) : ''}</td>
-- ${full ? `<td class="val">${h.sent}</td><td class="val">${ms(h.last)}</td>` : ''}
-- <td class="val">${ms(h.best)}</td><td class="val">${ms(h.avg)}</td><td class="val">${ms(h.worst)}</td>
-- ${full ? `<td class="val">${h.stdev === null || h.stdev === undefined ? '—' : h.stdev}</td>` : ''}
-- </tr>`).join('')}
-- </table></div>
-- <p style="color:var(--text-muted);font-size:12px;margin:10px 0 0">Probes are ICMP echo from OpenVibe's server (mtr). A router that does not answer is shown as no reply; loss at one hop that does not carry on to the next is usually that router rate-limiting replies, not lost traffic.</p>
-- </div>`;
-- el.innerHTML = html;
++ function answerDNS(data) {
++ const records = data.records || {};
++ const badgeText = ({ mx: 'MX', txt: 'TXT', ns: 'NS', spf: 'SPF', dkim: 'DKIM', dmarc: 'DMARC' })[activeTool] || 'DNS';
++ const one = (r) => {
++ if (r == null) return '';
++ if (typeof r === 'string') return r;
++ if (Array.isArray(r)) return r.join('');
++ if (r.exchange) return `${r.priority} ${r.exchange}`;
++ if (r.data) return r.data;
++ if (r.nsname) return `${r.nsname} serial ${r.serial}`;
++ return JSON.stringify(r);
++ };
++ const rows = [];
++ for (const [type, recs] of Object.entries(records)) {
++ if (recs && recs.error) { rows.push([`${type} records`, 'none found', { text: recs.error, tone: 'warn' }]); continue; }
++ const list = Array.isArray(recs) ? recs : (recs && typeof recs === 'object' ? [recs] : []);
++ if (!list.length) { rows.push([`${type} records`, 'none found', { text: 'no answer', tone: 'warn' }]); continue; }
++ const vals = list.map(one).filter(Boolean);
++ rows.push([`${type} records`, vals.length === 1 ? vals[0] : lineValue(vals)]);
++ }
++ if (!rows.length) rows.push(['Records', 'none found', { text: 'no answer', tone: 'warn' }]);
++ showAnswer({
++ title: `${badgeText} records`, badge: { text: badgeText, tone: 'ok' },
++ rows: [['Domain', data.target], ...rows], next: [nextTool('whois', 'Whois', data.target), nextTool('ssl', 'SSL certificate', data.target), nextTool('lookup', 'Full lookup', data.target)],
++ body: `<p class="ovk-muted">${data.server && data.server !== 'system' ? `Asked ${esc(data.server)}.` : 'Asked the system resolver.'}</p>` + rawFold(data),
++ });
}
-- // ── Reputation: the public signals, each with what it saw and what it cost ──
-- function renderReputation(el, data) {
-- const findings = data.findings || [];
-- const tone = { good: 'badge-green', warn: 'badge-yellow', bad: 'badge-red', info: '' };
-- const word = { good: 'Good', warn: 'Check', bad: 'Problem', info: 'Note' };
-- let html = `<div class="badges">
-- <span class="badge badge-cyan">${esc(data.domain || data.target)}</span>
-- <span class="badge ${data.grade === 'good' ? 'badge-green' : data.grade === 'fair' ? 'badge-yellow' : 'badge-red'}">Score ${data.score} / 100 (${esc(data.grade)})</span>
-- </div>`;
-- html += `<div class="card"><div class="card-head"><i class="fa-solid fa-star"></i> What the score is made of</div>
-- <table class="res-table"><tr><th>Check</th><th>Result</th><th>Finding</th><th>Cost</th></tr>
-- ${findings.map(f => `<tr>
-- <td class="key">${esc(f.check)}</td>
-- <td><span class="badge ${tone[f.status] || ''}">${word[f.status] || esc(f.status)}</span></td>
-- <td class="val" style="white-space:normal">${esc(f.detail)}</td>
-- <td class="val">${f.cost ? `−${f.cost}` : '—'}</td>
-- </tr>`).join('')}
-- </table>
-- <p style="color:var(--text-muted);font-size:12px;margin:10px 0 0">Public signals only: mail providers keep their own private scores. The blacklist checker shows every list, and the DKIM lookup checks a selector.</p>${from(data)}</div>`;
-- el.innerHTML = html;
++ function answerDnsProp(data) {
++ const list = data.resolvers || [];
++ const rows = [['Answered', `${data.answered} of ${list.length}`], ['Resolvers agree', data.agree ? 'Yes' : 'No']];
++ if (data.majority && data.majority.answers) rows.push(['Most say', lineValue(data.majority.answers)]);
++ const table = answerTable([{ key: 'name', label: 'Resolver', primary: true }, { key: 'answers', label: 'Answer' }, { key: 'status', label: 'Status' }, { key: 'ms', label: 'Time', num: true }],
++ list.map((r) => ({ name: r.name || r.ip, answers: r.status === 'ok' ? (r.answers || []).join(', ') : '', status: r.status === 'ok' ? (r.differs ? 'differs' : 'ok') : r.status, ms: r.ms })), 'Public resolvers');
++ showAnswer({ title: `${data.target} ${data.type || ''}`.trim(), badge: { text: data.agree ? 'All agree' : 'Resolvers disagree', tone: data.agree ? 'ok' : 'warn' }, rows, body: table + rawFold(data) });
}
-- // ── IPv4 (address or CIDR block) and IPv6 ────────────────
-- function renderIPv4(el, data) {
-- const v = data.ipv4 || {};
-- const c = data.cidr;
-- let html = `<div class="badges">
-- <span class="badge badge-cyan">${esc(c && c.prefix < 32 ? c.cidr : data.ip)}</span>
-- <span class="badge">Class ${esc(v.class)}</span>
-- <span class="badge ${v.isPublic ? 'badge-green' : 'badge-yellow'}">${esc(v.type)}</span>
-- </div>`;
-- if (c && c.prefix < 32) {
-- const n = (x) => Number(x).toLocaleString('en-US');
-- html += `<div class="card"><div class="card-head"><i class="fa-solid fa-hashtag"></i> Subnet ${esc(c.cidr)}</div><table class="res-table">
-- ${row('Network', esc(c.network))}${c.broadcast ? row('Broadcast', esc(c.broadcast)) : ''}
-- ${row('Netmask', esc(c.netmask))}${row('Wildcard', esc(c.wildcard))}
-- ${row('Usable hosts', `${esc(c.firstUsable)} – ${esc(c.lastUsable)}`)}
-- ${row('Addresses', `${n(c.total)} total, ${n(c.usable)} usable`)}
-- </table></div>`;
-- }
-- if (data.geo) html += renderIPCard(data);
-- else if (data.note) html += `<p style="color:var(--text-muted);font-size:13px">${esc(data.note)}</p>`;
-- el.innerHTML = html;
++ function answerSSL(data) {
++ const c = data.certificate || {};
++ const days = c.daysLeft;
++ showAnswer({
++ title: 'Certificate', badge: { text: c.isExpired ? 'Expired' : c.isExpiringSoon ? 'Expires soon' : 'Valid', tone: c.isExpired ? 'bad' : c.isExpiringSoon ? 'warn' : 'ok' },
++ rows: [['Domain', data.target], ['Subject', c.subject && (c.subject.CN || c.subject.O)], ['Issuer', c.issuer && (c.issuer.CN || c.issuer.O)], ['Valid until', fmtDate(c.validTo), c.validTo ? { text: relTime(c.validTo), tone: 'warn' } : ''], ['Days left', days != null ? String(days) : ''], ['Protocol', data.protocol], ['Port', data.port], ['Chain', (data.chain || []).length ? `${data.chain.length} certificates` : '']],
++ next: [nextTool('whois', 'Whois', data.target), nextTool('dns', 'DNS records', data.target), watchNext(`https://${data.target}`)],
++ body: ((data.chain || []).length ? answerTable([{ key: 'subject', label: 'Subject', primary: true }, { key: 'issuer', label: 'Issuer' }, { key: 'validTo', label: 'Valid to' }], data.chain.map((x) => ({ subject: x.subject, issuer: x.issuer, validTo: fmtDate(x.validTo) })), 'Certificate chain') : '') + rawFold(data),
++ });
}
-- function renderIPv6(el, data) {
-- const v = data.ipv6 || {};
-- const nw = data.network;
-- let html = `<div class="badges">
-- <span class="badge badge-cyan">${esc(data.ip)}</span>
-- <span class="badge ${v.type === 'Global unicast' ? 'badge-green' : 'badge-yellow'}">${esc(v.type)}</span>
-- ${v.scope ? `<span class="badge">scope: ${esc(v.scope)}</span>` : ''}
-- </div>`;
-- html += `<div class="card"><div class="card-head"><i class="fa-solid fa-code"></i> IPv6 address</div><table class="res-table">
-- ${row('Compressed', esc(v.compressed))}${row('Expanded', esc(v.expanded))}
-- ${v.prefix ? row('Range', esc(v.prefix)) : ''}
-- ${row('Reverse DNS', v.ptr ? esc(v.ptr) : '<span style="color:var(--text-muted)">No PTR record</span>')}
-- ${nw ? row('Network', `${esc(nw.org || nw.isp || '—')}${nw.as ? ` (${esc(nw.as)})` : ''}`) : ''}
-- ${data.geo && data.geo.country ? row('Location', esc([data.geo.city, data.geo.country].filter(Boolean).join(', '))) : ''}
-- </table></div>`;
-- el.innerHTML = html;
++ function answerHeaders(data) {
++ const sec = data.security || {};
++ const hd = data.headers || {};
++ const yes = (v) => v ? 'present' : 'missing';
++ const secTable = answerTable([{ key: 'name', label: 'Security header', primary: true }, { key: 'state', label: 'State' }], [
++ { name: 'Strict-Transport-Security', state: yes(sec.hasHSTS) }, { name: 'Content-Security-Policy', state: yes(sec.hasCSP) },
++ { name: 'X-Content-Type-Options', state: yes(sec.hasXCTO) }, { name: 'X-Frame-Options', state: yes(sec.hasXFO) },
++ { name: 'Referrer-Policy', state: yes(sec.hasRP) }, { name: 'Permissions-Policy', state: yes(sec.hasPermissions) },
++ ], 'Security headers');
++ const hdTable = answerTable([{ key: 'name', label: 'Header', primary: true }, { key: 'value', label: 'Value' }], Object.entries(hd).map(([k, v]) => ({ name: k, value: String(v) })), 'Response headers');
++ showAnswer({
++ title: 'HTTP response', badge: { text: `HTTP ${data.status}`, tone: data.status < 400 ? 'ok' : 'bad' },
++ rows: [['URL', data.url || data.target], ['Status', `${data.status} ${data.statusText || ''}`.trim()], ['Server', data.server], ['Content type', data.contentType], ['Security score', data.securityScore], ['Time', data.timing ? `${data.timing.ms} ms` : '']],
++ next: [watchNext(data.url || data.target), nextTool('ssl', 'SSL certificate', data.url || data.target), nextTool('redirects', 'Redirects', data.url || data.target)],
++ body: secTable + hdTable + rawFold(data),
++ });
}
-- // ── Website, mail and DNS checks ─────────────────────────
-- const row = (k, v) => `<tr><td class="key">${esc(k)}</td><td class="val">${v}</td></tr>`;
-- const from = (data) => data.checkedFrom ? `<p style="color:var(--text-muted);font-size:12px;margin-top:8px">Checked from ${esc(data.checkedFrom)}.</p>` : '';
--
-- function renderDnsProp(el, data) {
-- const list = data.resolvers || [];
-- let html = `<div class="badges">
-- <span class="badge badge-cyan">${esc(data.target)} ${esc(data.type)}</span>
-- <span class="badge ${data.agree ? 'badge-green' : 'badge-yellow'}">${data.agree ? 'All resolvers agree' : 'Resolvers disagree'}</span>
-- <span class="badge">${data.answered}/${list.length} answered</span>
-- </div>`;
-- html += `<div class="card"><div class="card-head"><i class="fa-solid fa-globe"></i> Public resolvers</div>
-- <table class="res-table"><tr><th>Resolver</th><th>Answer</th><th>Time</th></tr>
-- ${list.map(r => `<tr>
-- <td class="val">${esc(r.name)}<br><span style="color:var(--text-muted);font-size:11px">${esc(r.ip)}</span></td>
-- <td class="val">${r.status === 'ok' ? r.answers.map(a => esc(a)).join('<br>') : `<span class="badge ${r.status === 'error' ? 'badge-red' : ''}">${esc(r.status === 'error' ? (r.error || 'error') : r.status === 'nxdomain' ? 'NXDOMAIN (no such name)' : 'no record')}</span>`}
-- ${r.differs ? ' <span class="badge badge-yellow">differs</span>' : ''}</td>
-- <td class="val">${r.ms}ms</td></tr>`).join('')}
-- </table>${from(data)}</div>`;
-- el.innerHTML = html;
++ function answerRedirects(data) {
++ const chain = data.chain || [];
++ const table = answerTable([{ key: 'n', label: '#', primary: true }, { key: 'url', label: 'URL' }, { key: 'status', label: 'Status' }, { key: 'ms', label: 'Time', num: true }], chain.map((h, i) => ({ n: i + 1, url: h.url || h.error || '', status: h.status == null ? (h.refused ? 'refused' : 'error') : h.status, ms: h.ms })), 'Redirect chain');
++ showAnswer({
++ title: 'Redirect chain', badge: { text: `${chain.length} hop${chain.length === 1 ? '' : 's'}`, tone: 'ok' },
++ rows: [['Original URL', data.originalUrl || data.target], ['Final URL', data.finalUrl], ['Hops', String(chain.length)], ['Stopped', data.stopped]],
++ next: [watchNext(data.originalUrl || data.target), nextTool('headers', 'HTTP headers', data.finalUrl || data.originalUrl || data.target)],
++ body: table + rawFold(data),
++ });
}
-- function renderBlacklist(el, data) {
-- const lists = data.lists || [];
-- let html = `<div class="badges">
-- <span class="badge badge-cyan">${esc(data.domain || data.ip || data.target)}${data.domain && data.ip ? ` (${esc(data.ip)})` : ''}</span>
-- <span class="badge ${data.listedCount ? 'badge-red' : 'badge-green'}">${data.listedCount ? `Listed on ${data.listedCount}` : 'Not listed'}</span>
-- ${data.unknownCount ? `<span class="badge badge-yellow">${data.unknownCount} could not be checked</span>` : ''}
-- </div>`;
-- if (data.domain && !data.ip) html += `<p style="color:var(--text-muted);font-size:13px">${esc(data.domain)} has no IPv4 address, so only the domain lists were checked.</p>`;
-- html += `<div class="card"><div class="card-head"><i class="fa-solid fa-ban"></i> Blacklists</div>
-- <table class="res-table"><tr><th>List</th><th>Checked</th><th>Result</th></tr>
-- ${lists.map(l => `<tr>
-- <td class="val">${esc(l.name)}<br><span style="color:var(--text-muted);font-size:11px">${esc(l.zone)}</span></td>
-- <td class="val">${esc(l.query)}</td>
-- <td>${l.listed === true ? `<span class="badge badge-red">Listed</span> ${l.reason ? `<div style="font-size:12px;margin-top:4px">${esc(l.reason)}</div>` : ''} <a class="pivot" href="${esc(l.delist)}" target="_blank" rel="noopener">Delisting</a>`
-- : l.listed === false ? '<span class="badge badge-green">Not listed</span>'
-- : `<span class="badge badge-yellow" title="${esc(l.error || '')}">Unknown</span><div style="font-size:11px;color:var(--text-muted);margin-top:4px">${esc(l.error || '')}</div>`}</td>
-- </tr>`).join('')}
-- </table>${from(data)}</div>`;
-- el.innerHTML = html;
++ function answerPort(data) {
++ const ports = data.ports || [];
++ const open = ports.filter((p) => p.status === 'open').length;
++ const table = answerTable([{ key: 'port', label: 'Port', primary: true, num: true }, { key: 'status', label: 'Status' }, { key: 'ms', label: 'Time', num: true }], ports.map((p) => ({ port: p.port, status: p.status, ms: p.ms })), 'Ports');
++ showAnswer({
++ title: 'Port check', badge: { text: `${open} open`, tone: open ? 'ok' : 'warn' },
++ rows: [['Target', `${data.target} (${data.ip})`], ['Open ports', open ? ports.filter((p) => p.status === 'open').map((p) => p.port).join(', ') : 'none found'], ['Closed or filtered', String(ports.length - open)]],
++ next: [nextTool('ping', 'Ping', data.target), nextTool('headers', 'HTTP headers', data.target)],
++ body: table + rawFold(data),
++ });
}
-- function renderSMTP(el, data) {
-- let html = `<div class="badges">
-- <span class="badge badge-cyan">${esc(data.host)}:${data.port} (${esc(data.ip)})</span>
-- <span class="badge ${data.connected ? 'badge-green' : 'badge-red'}">${data.connected ? 'Connected' : 'No connection'}</span>
-- ${data.starttls ? `<span class="badge ${data.starttls.ok ? 'badge-green' : data.starttls.offered ? 'badge-yellow' : 'badge-red'}">${data.starttls.ok ? 'STARTTLS ' + esc(data.starttls.protocol || '') : data.starttls.offered ? 'STARTTLS failed' : 'No STARTTLS'}</span>` : ''}
-- </div>`;
-- if (data.mx && data.mx.length) html += `<p style="color:var(--text-muted);font-size:13px">${esc(data.target)} receives mail at ${data.mx.map(m => `${esc(m.exchange)} (${m.priority})`).join(', ')}; tested the first.</p>`;
-- if (data.error) html += `<div class="error-msg" style="margin-bottom:12px">${esc(data.error)}</div>`;
-- html += `<div class="card"><div class="card-head"><i class="fa-solid fa-paper-plane"></i> Conversation</div><table class="res-table">
-- ${data.connectMs != null ? row('Connect time', `${data.connectMs}ms`) : ''}
-- ${data.banner ? row('Greeting', `${data.banner.code} ${esc(data.banner.text)}`) : ''}
-- ${data.ehlo ? row('EHLO', `${data.ehlo.code} ${esc(data.ehlo.greeting)}`) : ''}
-- ${data.ehlo ? row('Extensions', (data.starttls && data.starttls.extensions ? data.starttls.extensions : data.ehlo.extensions).map(e => esc(e)).join('<br>') || '—') : ''}
-- ${data.auth ? row('Login methods', data.auth.length ? data.auth.map(esc).join(', ') : 'none offered') : ''}
-- </table></div>`;
-- const c = data.starttls && data.starttls.certificate;
-- if (c) {
-- html += `<div class="card"><div class="card-head"><i class="fa-solid fa-lock"></i> STARTTLS certificate</div><table class="res-table">
-- ${row('Protocol', esc(data.starttls.protocol || '—'))}${row('Cipher', esc(data.starttls.cipher || '—'))}
-- ${row('Subject', esc(c.subject || '—'))}${row('Issuer', esc(c.issuer || '—'))}
-- ${row('Valid to', `${esc(c.validTo || '—')}${c.daysLeft != null ? ` (${c.daysLeft} days)` : ''}`)}
-- ${row('Trusted chain', c.trusted ? '✅ yes' : `❌ no${c.error ? ' — ' + esc(c.error) : ''}`)}
-- ${c.hostnameMatch != null ? row('Name matches', c.hostnameMatch ? '✅ yes' : '❌ no') : ''}
-- </table></div>`;
-- }
-- el.innerHTML = html + from(data);
++ function answerPing(data) {
++ const s = data.stats || {};
++ const table = answerTable([{ key: 'seq', label: 'Attempt', primary: true, num: true }, { key: 'status', label: 'Status' }, { key: 'ms', label: 'Time', num: true }], (data.results || []).map((r) => ({ seq: r.seq, status: r.status, ms: r.ms })), 'Attempts');
++ showAnswer({
++ title: 'Ping test', badge: { text: s.loss ? `${s.loss}% loss` : 'Reachable', tone: s.loss ? 'warn' : 'ok' },
++ rows: [['Target', `${data.target} (${data.ip})`], ['Average', s.avg != null ? `${s.avg} ms` : ''], ['Best', s.min != null ? `${s.min} ms` : ''], ['Worst', s.max != null ? `${s.max} ms` : ''], ['Loss', s.loss != null ? `${s.loss}%` : '']],
++ next: [nextTool('traceroute', 'Traceroute', data.target), nextTool('port', 'Port checker', data.target)],
++ body: table + rawFold(data),
++ });
}
-- function renderUptime(el, data) {
-- let html = `<div class="badges">
-- <span class="badge ${data.state === 'up' ? 'badge-green' : 'badge-red'}">${data.state === 'up' ? '🟢 Up' : '🔴 Down'}</span>
-- ${data.status ? `<span class="badge">HTTP ${data.status} ${esc(data.statusText || '')}</span>` : ''}
-- <span class="badge badge-cyan">⚡ ${data.ms}ms</span>
-- </div>`;
-- html += `<div class="card"><div class="card-head"><i class="fa-solid fa-heart-pulse"></i> ${esc(data.url)}</div><table class="res-table">
-- ${data.error ? row('Problem', esc(data.error)) : ''}
-- ${data.finalUrl && data.finalUrl !== data.url ? row('Ended at', esc(data.finalUrl)) : ''}
-- ${data.redirects ? row('Redirects', (data.chain || []).map(h => `${h.status} ${esc(h.url)}`).join('<br>')) : ''}
-- ${data.server ? row('Server', esc(data.server)) : ''}
-- ${row('Checked at', esc(data.checkedAt || ''))}
-- </table>${from(data)}</div>`;
-- el.innerHTML = html;
++ function answerTrace(data, full) {
++ const hops = data.hops || [];
++ const cols = [{ key: 'hop', label: '#', primary: true, num: true }, { key: 'host', label: 'Host' }, { key: 'asn', label: 'Network' }, { key: 'loss', label: 'Loss', num: true }, { key: 'best', label: 'Best', num: true }, { key: 'avg', label: 'Avg', num: true }, { key: 'worst', label: 'Worst', num: true }];
++ const table = answerTable(cols, hops.map((h) => ({ hop: h.hop, host: h.host ? `${h.host}${h.ip ? ` (${h.ip})` : ''}` : (h.ip || 'no reply'), asn: h.asn || '', loss: h.loss == null ? '' : `${h.loss}%`, best: h.best == null ? '' : `${h.best}ms`, avg: h.avg == null ? '' : `${h.avg}ms`, worst: h.worst == null ? '' : `${h.worst}ms` })), full ? 'MTR report' : 'Route');
++ showAnswer({
++ title: full ? 'MTR report' : 'Route', badge: { text: data.reached ? 'Reached' : 'Not reached', tone: data.reached ? 'ok' : 'warn' },
++ rows: [['Target', `${data.target} (${data.ip})`], ['Hops', String(hops.length)], ['Probes a hop', data.probes != null ? String(data.probes) : ''], ['Family', data.family ? `IPv${data.family}` : '']],
++ next: [nextTool('ping', 'Ping', data.target), nextTool('mtr', 'MTR report', data.target)],
++ body: table + `<p class="ovk-muted">Probes are ICMP echo from OpenVibe's server. A router that stays quiet shows as no reply; loss at one hop that does not carry on is usually that router rate-limiting replies.</p>` + rawFold(data),
++ });
}
-- function renderRobots(el, data) {
-- let html = `<div class="badges">
-- <span class="badge ${data.found ? 'badge-green' : 'badge-yellow'}">HTTP ${data.status}</span>
-- <span class="badge badge-cyan">${esc(data.verdict || '')}</span>
-- ${data.found ? `<span class="badge">${data.size} bytes</span>` : ''}
-- </div>`;
-- if (data.test) {
-- const t = data.test;
-- html += `<div class="card"><div class="card-head"><i class="fa-solid fa-vial"></i> ${esc(t.path)} as ${esc(t.ua)}</div>
-- <p><span class="badge ${t.allowed ? 'badge-green' : 'badge-red'}">${t.allowed ? 'Allowed' : 'Blocked'}</span>
-- ${t.rule ? ` by <code>${esc(t.rule.type)}: ${esc(t.rule.path)}</code> (line ${t.rule.line})` : ' (no rule matches)'}</p></div>`;
-- }
-- for (const w of data.warnings || []) html += `<div class="error-msg" style="margin-bottom:8px;text-align:left">${w.line ? `Line ${w.line}: ` : ''}${esc(w.text)}</div>`;
-- for (const g of data.groups || []) {
-- html += `<div class="card"><div class="card-head"><i class="fa-solid fa-robot"></i> User-agent: ${g.agents.map(esc).join(', ')}</div>
-- <table class="res-table">${g.rules.map(r => `<tr><td class="key">${esc(r.type)}</td><td class="val">${esc(r.path) || '<span style="color:var(--text-muted)">(empty: allows everything)</span>'}</td></tr>`).join('') || '<tr><td class="val">No rules</td></tr>'}
-- ${g.crawlDelay ? row('crawl-delay', esc(g.crawlDelay)) : ''}</table></div>`;
-- }
-- if ((data.sitemaps || []).length) html += `<div class="card"><div class="card-head"><i class="fa-solid fa-sitemap"></i> Sitemaps</div>${data.sitemaps.map(u => `<div class="raw-block">${esc(u)}</div>`).join('')}</div>`;
-- el.innerHTML = html + from(data);
++ function answerBlacklist(data) {
++ const lists = data.lists || [];
++ const table = answerTable([{ key: 'name', label: 'List', primary: true }, { key: 'zone', label: 'Zone' }, { key: 'result', label: 'Result' }], lists.map((l) => ({ name: l.name, zone: l.zone, result: l.listed === true ? 'listed' : l.listed === false ? 'not listed' : 'unknown' })), 'Blacklists');
++ showAnswer({
++ title: 'Blacklist check', badge: { text: data.listedCount ? `Listed on ${data.listedCount}` : 'Not listed', tone: data.listedCount ? 'bad' : 'ok' },
++ rows: [['Target', data.domain || data.ip || data.target], ['Listed', String(data.listedCount || 0)], ['Clean', String(data.cleanCount || 0)], ['Could not check', String(data.unknownCount || 0)]],
++ next: [nextTool('reputation', 'Reputation', data.domain || data.target), nextTool('dns', 'DNS records', data.domain || data.target)],
++ body: table + rawFold(data),
++ });
}
-- function renderSitemap(el, data) {
-- const issues = data.issues || [];
-- const errors = issues.filter(i => i.level === 'error');
-- let html = `<div class="badges">
-- <span class="badge ${data.valid ? 'badge-green' : 'badge-red'}">${data.valid ? 'Valid' : `${errors.length} problem${errors.length === 1 ? '' : 's'}`}</span>
-- ${data.kind ? `<span class="badge badge-cyan">${data.kind === 'index' ? `Sitemap index: ${data.count} sitemaps` : `${Number(data.count).toLocaleString('en-US')} URLs`}</span>` : ''}
-- <span class="badge">HTTP ${data.status}${data.gzip ? ' · gzip' : ''}</span>
-- </div>`;
-- html += `<p style="color:var(--text-muted);font-size:13px">${esc(data.finalUrl || data.url)}${data.source === 'robots.txt' ? ' (from robots.txt)' : data.source === 'default' ? ' (the default location)' : ''}</p>`;
-- for (const i of issues) {
-- html += `<div class="card"><div class="card-head"><span class="badge ${i.level === 'error' ? 'badge-red' : 'badge-yellow'}">${i.level}</span> ${esc(i.message)}${i.count > 1 ? ` (${i.count}×)` : ''}</div>
-- ${i.examples && i.examples.length ? i.examples.map(x => `<div class="raw-block">${esc(x)}</div>`).join('') : ''}</div>`;
-- }
-- if ((data.sample || []).length) {
-- html += `<div class="card"><div class="card-head"><i class="fa-solid fa-list"></i> First entries</div><table class="res-table">
-- ${data.sample.map(u => `<tr><td class="val">${esc(u.loc)}</td><td class="val">${esc(u.lastmod || '')}</td></tr>`).join('')}</table></div>`;
-- }
-- if ((data.childResults || []).length) {
-- html += `<div class="card"><div class="card-head"><i class="fa-solid fa-sitemap"></i> First ${data.childResults.length} of ${data.childrenTotal} sitemaps</div><table class="res-table">
-- ${data.childResults.map(c => `<tr><td class="val">${esc(c.url)}</td><td>${c.error ? `<span class="badge badge-red">${esc(c.error)}</span>` : `<span class="badge ${c.valid ? 'badge-green' : 'badge-red'}">${c.valid ? 'valid' : 'problems'}</span> ${Number(c.count || 0).toLocaleString('en-US')} URLs`}</td></tr>`).join('')}</table></div>`;
-- }
-- el.innerHTML = html + from(data);
++ function answerReputation(data) {
++ const findings = data.findings || [];
++ const table = answerTable([{ key: 'check', label: 'Check', primary: true }, { key: 'status', label: 'Result' }, { key: 'detail', label: 'Finding' }, { key: 'cost', label: 'Cost', num: true }], findings.map((f) => ({ check: f.check, status: f.status, detail: f.detail, cost: f.cost ? `−${f.cost}` : '' })), 'Signals');
++ showAnswer({
++ title: 'Reputation', badge: { text: `${data.score} / 100 · ${data.grade}`, tone: data.grade === 'good' ? 'ok' : data.grade === 'fair' ? 'warn' : 'bad' },
++ rows: [['Target', data.domain || data.target], ['Score', `${data.score} / 100`], ['Grade', data.grade]],
++ next: [nextTool('blacklist', 'Blacklist check', data.domain || data.target), nextTool('spf', 'SPF record', data.domain || data.target)],
++ body: table + rawFold(data),
++ });
}
-- function renderLookup(el, data) {
-- // Supertool: tabbed view
-- const sections = [];
-- if (data.ip) sections.push({ id: 'ip', label: 'IP / Geo', icon: 'fa-earth-americas' });
-- if (data.dns) sections.push({ id: 'dns', label: 'DNS', icon: 'fa-sitemap' });
-- if (data.rdap) sections.push({ id: 'rdap', label: 'RDAP / Whois', icon: 'fa-id-card' });
-- if (data.ssl) sections.push({ id: 'ssl', label: 'SSL', icon: 'fa-lock' });
-- if (data.headers) sections.push({ id: 'headers', label: 'Headers', icon: 'fa-list' });
-- if (data.reverseDns) sections.push({ id: 'rdns', label: 'Reverse DNS', icon: 'fa-rotate-left' });
--
-- // Badges
-- let html = `<div class="badges">${(data.badges || []).map(b => `<span class="badge badge-cyan">${esc(b)}</span>`).join('')}</div>`;
--
-- // Tabs
-- html += `<div class="tabs">${sections.map((s, i) => `<button class="tab-btn ${i === 0 ? 'active' : ''}" onclick="switchTab('${s.id}', this)"><i class="fa-solid ${s.icon}"></i> ${s.label}</button>`).join('')}</div>`;
--
-- // Tab contents
-- if (data.ip) {
-- const ip = data.ip;
-- html += `<div class="tab-content ${sections[0]?.id === 'ip' ? 'active' : ''}" id="tab-ip">
-- <div class="card"><div class="card-head"><i class="fa-solid fa-earth-americas"></i> IP / Geolocation</div>
-- <table class="res-table">
-- <tr><td class="key">IP</td><td class="val">${esc(ip.address || '—')}</td></tr>
-- <tr><td class="key">Country</td><td class="val">${esc(ip.country || '—')} (${esc(ip.countryCode || '')})</td></tr>
-- <tr><td class="key">Region</td><td class="val">${esc(ip.region || '—')}</td></tr>
-- <tr><td class="key">City</td><td class="val">${esc(ip.city || '—')}</td></tr>
-- <tr><td class="key">Timezone</td><td class="val">${esc(ip.timezone || '—')}</td></tr>
-- <tr><td class="key">Coordinates</td><td class="val">${ip.lat != null ? `${ip.lat}, ${ip.lon}` : '—'}</td></tr>
-- <tr><td class="key">ISP</td><td class="val">${esc(ip.isp || '—')}</td></tr>
-- <tr><td class="key">Organization</td><td class="val">${esc(ip.org || '—')}</td></tr>
-- <tr><td class="key">AS</td><td class="val">${esc(ip.as || '—')}</td></tr>
-- <tr><td class="key">AS Name</td><td class="val">${esc(ip.asname || '—')}</td></tr>
-- </table></div></div>`;
-- }
-- if (data.dns) {
-- html += `<div class="tab-content" id="tab-dns">`;
-- for (const [type, recs] of Object.entries(data.dns)) {
-- if (!recs?.length) continue;
-- html += `<div class="card"><div class="card-head"><i class="fa-solid fa-sitemap"></i> ${type}</div>`;
-- if (type === 'MX') {
-- html += `<table class="res-table"><tr><th>Priority</th><th>Exchange</th></tr>${recs.map(r => `<tr><td class="val">${r.priority}</td><td class="val">${esc(r.exchange)}</td></tr>`).join('')}</table>`;
-- } else if (type === 'TXT') {
-- html += `<div style="display:flex;flex-direction:column;gap:6px">${recs.map(r => `<div class="raw-block">${esc(Array.isArray(r) ? r.join('') : r)}</div>`).join('')}</div>`;
-- } else {
-- html += `<div class="raw-block">${recs.map(r => esc(typeof r === 'object' ? JSON.stringify(r) : r)).join('\n')}</div>`;
-- }
-- html += `</div>`;
-- }
-- html += `</div>`;
-- }
-- if (data.rdap) {
-- const r = data.rdap;
-- html += `<div class="tab-content" id="tab-rdap"><div class="card"><div class="card-head"><i class="fa-solid fa-id-card"></i> RDAP / Whois</div>
-- <table class="res-table">
-- <tr><td class="key">Name</td><td class="val">${esc(r.name || '—')}</td></tr>
-- ${(r.status || []).map(s => `<tr><td class="key">Status</td><td class="val">${esc(s)}</td></tr>`).join('')}
-- ${(r.events || []).map(e => `<tr><td class="key">${esc(e.action)}</td><td class="val">${esc(e.date)}</td></tr>`).join('')}
-- ${r.nameservers?.length ? `<tr><td class="key">Nameservers</td><td class="val">${r.nameservers.map(n => esc(n)).join('<br>')}</td></tr>` : ''}
-- </table></div></div>`;
-- }
-- if (data.ssl) {
-- const s = data.ssl;
-- html += `<div class="tab-content" id="tab-ssl"><div class="card"><div class="card-head"><i class="fa-solid fa-lock"></i> SSL Certificate</div>
-- <table class="res-table">
-- <tr><td class="key">Subject</td><td class="val">${esc(s.subject || '—')}</td></tr>
-- <tr><td class="key">Issuer</td><td class="val">${esc(s.issuer || '—')}</td></tr>
-- <tr><td class="key">Valid From</td><td class="val">${esc(s.validFrom || '—')}</td></tr>
-- <tr><td class="key">Valid To</td><td class="val">${esc(s.validTo || '—')}</td></tr>
-- <tr><td class="key">Days Left</td><td class="val">${s.daysLeft}</td></tr>
-- <tr><td class="key">Protocol</td><td class="val">${esc(s.protocol || '—')}</td></tr>
-- <tr><td class="key">SANs</td><td class="val">${s.sans?.length ? s.sans.map(x => esc(x)).join(', ') : '—'}</td></tr>
-- </table></div></div>`;
-- }
-- if (data.headers) {
-- const h = data.headers;
-- html += `<div class="tab-content" id="tab-headers"><div class="card"><div class="card-head"><i class="fa-solid fa-list"></i> HTTP Response</div>
-- <table class="res-table">
-- <tr><td class="key">Status</td><td class="val">${h.status} — ${h.ms}ms</td></tr>
-- ${h.server ? `<tr><td class="key">Server</td><td class="val">${esc(h.server)}</td></tr>` : ''}
-- ${Object.entries(h.headers || {}).map(([k, v]) => `<tr><td class="key">${esc(k)}</td><td class="val">${esc(v)}</td></tr>`).join('')}
-- </table></div></div>`;
-- }
-- if (data.reverseDns) {
-- const r = data.reverseDns;
-- html += `<div class="tab-content" id="tab-rdns"><div class="card"><div class="card-head"><i class="fa-solid fa-rotate-left"></i> Reverse DNS</div>
-- <table class="res-table">
-- <tr><td class="key">IP</td><td class="val">${esc(r.ip || '—')}</td></tr>
-- <tr><td class="key">Hostnames</td><td class="val">${r.hostnames?.length ? r.hostnames.map(h => esc(h)).join('<br>') : 'No PTR record'}</td></tr>
-- </table></div></div>`;
-- }
++ function answerSMTP(data) {
++ const st = data.starttls || {}, c = st.certificate || {};
++ const ext = st.extensions || (data.ehlo && data.ehlo.extensions) || [];
++ showAnswer({
++ title: `${data.host || data.target}:${data.port}`, badge: { text: data.connected ? 'Connected' : 'No connection', tone: data.connected ? 'ok' : 'bad' },
++ rows: [['Host', data.host], ['IP', data.ip], ['Port', data.port], ['Greeting', data.banner ? `${data.banner.code} ${data.banner.text}` : ''], ['STARTTLS', st.ok ? `${st.protocol || 'yes'}` : st.offered ? 'offered but failed' : 'not offered'], ['Certificate', c.subject || ''], ['Trusted chain', c.trusted == null ? '' : c.trusted ? 'Yes' : 'No'], ['Name matches', c.hostnameMatch == null ? '' : c.hostnameMatch ? 'Yes' : 'No'], ['Error', data.error]],
++ next: [nextTool('mx', 'MX records', data.target), nextTool('spf', 'SPF record', data.target)],
++ body: (ext.length ? `<p class="ovk-muted">Extensions: ${ext.map(esc).join(', ')}</p>` : '') + rawFold(data),
++ });
++ }
-- el.innerHTML = html;
++ function answerUptime(data) {
++ showAnswer({
++ title: 'Uptime check', badge: { text: data.state === 'up' ? 'Up' : 'Down', tone: data.state === 'up' ? 'ok' : 'bad' },
++ rows: [['URL', data.url], ['State', data.state], ['HTTP status', data.status != null ? String(data.status) : ''], ['Answer time', data.ms != null ? `${data.ms} ms` : ''], ['Final URL', data.finalUrl && data.finalUrl !== data.url ? data.finalUrl : ''], ['Redirects', data.redirects != null ? String(data.redirects) : ''], ['Error', data.error], ['Checked at', data.checkedAt ? fmtDate(data.checkedAt) : '']],
++ next: [watchNext(data.url), nextTool('headers', 'HTTP headers', data.url), nextTool('ssl', 'SSL certificate', data.url)],
++ body: rawFold(data),
++ });
}
-- function switchTab(id, btn) {
-- document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
-- document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
-- btn.classList.add('active');
-- const tab = document.getElementById(`tab-${id}`);
-- if (tab) tab.classList.add('active');
++ function answerRobots(data) {
++ const groups = data.groups || [];
++ const table = groups.length ? answerTable([{ key: 'agents', label: 'User-agent', primary: true }, { key: 'rules', label: 'Rules' }], groups.map((g) => ({ agents: (g.agents || []).join(', '), rules: (g.rules || []).map((r) => `${r.type}: ${r.path || ''}`).join(' · ') || 'none' })), 'Rules') : '';
++ const warn = (data.warnings || []).map((w) => `<p><span class="ovk-pill ovk-pill--warn">${w.line ? `line ${w.line}` : 'warning'}</span> ${esc(w.text)}</p>`).join('');
++ showAnswer({
++ title: 'robots.txt', badge: { text: data.found ? `HTTP ${data.status}` : 'Not found', tone: data.found ? 'ok' : 'warn' },
++ rows: [['URL', data.url], ['Verdict', data.verdict], ['Found', data.found ? 'Yes' : 'No'], ['Final URL', data.finalUrl && data.finalUrl !== data.url ? data.finalUrl : ''], ['Sitemaps', (data.sitemaps || []).length ? lineValue(data.sitemaps) : '']],
++ next: [nextTool('sitemap', 'Sitemap validator', data.url), watchNext(data.url)],
++ body: table + warn + rawFold(data),
++ });
}
++ function answerSitemap(data) {
++ const issues = data.issues || [];
++ const table = issues.length ? answerTable([{ key: 'level', label: 'Level', primary: true }, { key: 'message', label: 'Problem' }], issues.map((i) => ({ level: i.level, message: `${i.message}${i.count > 1 ? ` (${i.count}×)` : ''}` })), 'Problems') : '';
++ const sample = (data.sample || []).length ? answerTable([{ key: 'loc', label: 'URL', primary: true }, { key: 'lastmod', label: 'Last modified' }], (data.sample || []).map((u) => ({ loc: u.loc, lastmod: u.lastmod || '' })), 'First entries') : '';
++ showAnswer({
++ title: 'Sitemap', badge: { text: data.valid ? 'Valid' : 'Problems', tone: data.valid ? 'ok' : 'bad' },
++ rows: [['URL', data.url], ['Kind', data.kind === 'index' ? 'Sitemap index' : data.kind], ['URLs', data.count != null ? String(data.count) : ''], ['HTTP status', data.status != null ? String(data.status) : ''], ['Final URL', data.finalUrl && data.finalUrl !== data.url ? data.finalUrl : '']],
++ next: [nextTool('robots', 'robots.txt', data.url), watchNext(data.url)],
++ body: table + sample + rawFold(data),
++ });
++ }
// ── Utility functions ────────────────────────────────────
function esc(s) {
if (s == null) return '';
@@@ -1709,18 +1419,6 @@
return d.innerHTML;
}
-- function copyText(text) {
-- navigator.clipboard.writeText(text).then(() => toast('Copied!', 'info')).catch(() => {});
-- }
--
-- function goLookup(target) {
-- location.href = `https://lookup.openvibe.tools?q=${encodeURIComponent(target)}`;
-- }
--
-- function goToolWithTarget(toolId, target) {
-- location.href = `https://${toolId}.openvibe.tools?q=${encodeURIComponent(target)}`;
-- }
--
function toast(msg, type = 'info') {
const container = document.getElementById('toasts');
const t = document.createElement('div');
ddiff --git a/apps/gateway/server/index.js b/apps/gateway/server/index.js
iindex eaaac08..b9add1e 100644
---- a/apps/gateway/server/index.js
++++ b/apps/gateway/server/index.js
@@@ -406,7 +406,7 @@ const CODES_HTML = path.join(__dirname, '..', 'public', 'codes.html');
function sendTool(req, res, file) {
try {
res.set('Content-Type', 'text/html; charset=utf-8');
-- return res.send(renderTool(file, subdomainOf(req), req.ovHost && req.ovHost.canonicalHost));
++ return res.send(renderTool(file, subdomainOf(req), req.ovHost && req.ovHost.canonicalHost, req.query));
} catch (err) {
console.error('[SEO] render failed:', err.message);
return res.sendFile(file);
ddiff --git a/apps/gateway/server/seo/catalog.js b/apps/gateway/server/seo/catalog.js
iindex 3712837..2f04dc5 100644
---- a/apps/gateway/server/seo/catalog.js
++++ b/apps/gateway/server/seo/catalog.js
@@@ -520,6 +520,138 @@ const CODES = {
},
};
++// ── Try examples (the tool frame, 4.55.0) ─────────────────────
++// Two or three public, harmless values per input tool, under the box: a tap fills it and runs the tool. A tool
++// with no input (my.ip, the generators, the webhook bin) has none. A two-box tool (diff, regex) gets [a, b] pairs.
++const EXAMPLES = {
++ // Network & diagnostics
++ lookup: ['openvibe.network', 'example.com', '1.1.1.1'],
++ ip: ['1.1.1.1', '8.8.8.8', 'openvibe.network'],
++ ipv4: ['192.168.1.0/24', '8.8.8.8', '203.0.113.0/24'],
++ ipv6: ['2001:db8::1', '2606:4700:4700::1111', 'openvibe.network'],
++ geoip: ['1.1.1.1', '8.8.8.8', '193.0.0.1'],
++ hostname: ['1.1.1.1', '8.8.8.8', '93.184.216.34'],
++ isp: ['1.1.1.1', '8.8.8.8', '193.0.0.1'],
++ asn: ['1.1.1.1', '8.8.8.8', '193.0.0.1'],
++ rdns: ['1.1.1.1', '8.8.8.8', '93.184.216.34'],
++ whois: ['openvibe.network', 'wikipedia.org', 'example.com'],
++ rdap: ['openvibe.network', 'wikipedia.org', '1.1.1.1'],
++ dns: ['openvibe.network', 'wikipedia.org', 'example.com'],
++ dig: ['openvibe.network', 'wikipedia.org', 'example.com'],
++ nslookup: ['openvibe.network', 'wikipedia.org', 'example.com'],
++ dnspropagation: ['openvibe.network', 'example.com', 'wikipedia.org'],
++ mx: ['openvibe.network', 'gmail.com', 'wikipedia.org'],
++ txt: ['openvibe.network', 'example.com', 'wikipedia.org'],
++ ns: ['openvibe.network', 'wikipedia.org', 'example.com'],
++ spf: ['openvibe.network', 'example.com', 'wikipedia.org'],
++ dkim: ['openvibe.network', 'example.com', 'wikipedia.org'],
++ dmarc: ['openvibe.network', 'example.com', 'wikipedia.org'],
++ ping: ['1.1.1.1', 'openvibe.network', 'example.com'],
++ traceroute: ['1.1.1.1', 'openvibe.network', 'example.com'],
++ mtr: ['1.1.1.1', 'openvibe.network', 'example.com'],
++ port: ['openvibe.network', '1.1.1.1', 'example.com'],
++ headers: ['https://openvibe.network', 'https://example.com', 'https://wikipedia.org'],
++ redirects: ['https://openvibe.network', 'https://example.com', 'http://wikipedia.org'],
++ ssl: ['openvibe.network', 'wikipedia.org', 'example.com'],
++ curl: ['https://openvibe.network', 'https://example.com', 'https://wikipedia.org'],
++ httpstatus: ['https://openvibe.network', 'https://example.com', 'https://wikipedia.org'],
++ latency: ['1.1.1.1', 'openvibe.network', 'example.com'],
++ blacklist: ['1.1.1.1', '8.8.8.8', 'example.com'],
++ reputation: ['openvibe.network', 'example.com', '1.1.1.1'],
++ smtp: ['openvibe.network', 'example.com', 'gmail.com'],
++ uptime: ['https://openvibe.network', 'https://example.com', 'https://wikipedia.org'],
++ robots: ['openvibe.network', 'wikipedia.org', 'example.com'],
++ sitemap: ['openvibe.network', 'wikipedia.org', 'example.com'],
++ // Developer tools
++ jsonfmt: ['{"name":"openvibe","version":1,"active":true}', '{"a":1,"b":[2,3]}', '{"name": }'],
++ yaml: ['name: openvibe\nversion: 1\nactive: true', 'services:\n web:\n image: nginx', 'a: 1\n b: 2'],
++ xml: ['<catalog><item id="1"><name>OpenVibe</name></item></catalog>', '<?xml version="1.0"?><root><a>1</a></root>'],
++ csv: ['name,age,city\nAda,36,London\nLinus,55,Portland', 'a,b\n1,2\n3,4'],
++ sql: ['select u.id, u.name from users u join orders o on u.id = o.user_id where o.total > 100 order by o.total desc', 'select * from users where active = true'],
++ md: ['# Hello\n\nThis is **bold** and *italic*.\n\n- one\n- two', '## Notes\n\nA [link](https://openvibe.network).'],
++ html: ['<div><p>Hello <strong>World</strong></p></div>', '<section><h1>OpenVibe</h1></section>'],
++ base64: ['Hello, World!', 'openvibe.network', 'The quick brown fox'],
++ url: ['https://openvibe.tools/search?q=hello world&lang=en#results', 'a b&c', 'hello/world?x=1'],
++ // Public example tokens made for the Try links (no key, no account, not a secret).
++ jwt: ['eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6Ik9wZW5WaWJlIiwiaWF0IjoxNTE2MjM5MDIyfQ.c2lnbmF0dXJl', 'eyJhbGciOiJub25lIn0.eyJzdWIiOiJleGFtcGxlIiwicm9sZSI6InJlYWRlciJ9.', 'not.a.jwt'], // gitleaks:allow (public examples)
++ hash: ['Hello, World!', 'openvibe.network', 'The quick brown fox'],
++ hex: ['Hello', '4f70656e56696265', 'openvibe'],
++ entities: ['<script>alert("hello")</script>', 'Tom & Jerry', 'a "quote" and \'single\''],
++ cron: ['*/15 9-17 * * 1-5', '0 3 * * *', '*/5 * * * *'],
++ beautify: ['{"a":1,"b":[2,3],"c":{"d":true}}', '<div><p>Hi</p></div>', '.a{color:red}'],
++ minify: ['{\n "name": "openvibe",\n "version": 1\n}', '<!-- c -->\n<div>\n <p>Hi</p>\n</div>'],
++ codediff: [['Line one\nLine two\nLine three\nLine four', 'Line one\nLine 2 modified\nLine three\nLine five\nLine six'], ['a\nb\nc', 'a\nB\nc']],
++ regex: [['\\b[A-Z][a-z]+\\b', 'Hello World from OpenVibe.Tools today'], ['\\d{4}-\\d{2}-\\d{2}', 'Released 2026-10-30 on openvibe.tools']],
++ slugify: ['Hello World! This is a Test -- 2024', 'OpenVibe.Tools — every tool', 'Café au lait'],
++ curlconvert: ["curl -X POST https://api.example.com/users -H 'Content-Type: application/json' -d '{\"name\":\"openvibe\"}'", 'curl https://openvibe.network', 'curl -I https://example.com'],
++ color: ['#a78bfa', '#ff7a1a', 'rgb(255,122,26)'],
++ opengraph: ['https://openvibe.network', 'https://wikipedia.org', 'https://example.com'],
++ read: ['https://openvibe.network', 'https://wikipedia.org', 'https://example.com'],
++ jsminify: ['function greet(name) {\n // say hello\n const message = "Hello, " + name + "!";\n console.log(message);\n return message;\n}', 'const a = 1; // comment\nconsole.log(a);'],
++ jsformat: ['function greet(n){const m="Hello, "+n+"!";console.log(m);return m}greet("OpenVibe");', 'const add=(a,b)=>a+b;export default add;'],
++ cssminify: ['/* card */\n.card {\n color: #ffffff;\n margin: 0px;\n padding: 12px;\n}', '.row { display: flex; gap: 0px; }\n/* end */'],
++ cssformat: ['.card{color:#fff;margin:0 0 10px;padding:12px}.card:hover{color:#3b82f6}', '@media (max-width:600px){.card{padding:8px}}'],
++ htmlminify: ['<!-- header -->\n<div class="card">\n <p>Hello <strong>World</strong></p>\n</div>', '<section>\n <h1>OpenVibe</h1>\n</section>'],
++ xmlminify: ['<?xml version="1.0"?>\n<!-- catalog -->\n<catalog>\n <item id="1">\n <name>OpenVibe</name>\n </item>\n</catalog>', '<rss><channel><title>OpenVibe</title></channel></rss>'],
++ jsonminify: ['{\n "name": "openvibe",\n "version": 1,\n "tags": ["open", "source"]\n}', '{"a": [1, 2, 3], "b": {"c": true}}'],
++ jsonparse: ['{"user":{"id":7,"name":"Ada","roles":["admin","dev"],"active":true},"count":2}', '{"items":[{"id":1},{"id":2}],"next":null}'],
++ jsonvalidate: ['{"name":"openvibe","version":1,"active":true}', '{\n "name": "openvibe",\n "version": 1,\n}'],
++ jsonstringify: ['{"name":"openvibe","note":"line one\\nline two"}', '"{\\"a\\":1}"'],
++ html2md: ['<h1>Hello World</h1>\n<p>This is <strong>bold</strong> and <a href="https://openvibe.network">a link</a>.</p>\n<ul><li>One</li><li>Two</li></ul>', '<blockquote>A quote</blockquote><pre><code>openvibe</code></pre>'],
++ md2html: ['# Hello World\n\nThis is **bold** and *italic* with a [link](https://openvibe.network).\n\n- one\n- two', '> A quote\n\n`inline code`'],
++};
++
++// The examples live on the record too, so the catalogue, the page and any future caller read the same values.
++for (const [sub, examples] of Object.entries(EXAMPLES)) { const rec = DEV[sub] || NET[sub]; if (rec) rec.examples = examples; }
++
++// ── The tools' modes ─────────────────────────────────────────
++// A dev tool's modes are the verbs in its box: one mode is the button itself ("Validate"), several are the kit's
++// segmented control with the button saying the active one ("Encode"). Mirrors dev.html's T[...].m; a test keeps them
++// equal. Network tools have no modes (their options are record types and ports).
++const MODES = {
++ jsonfmt: ['Format', 'Validate', 'Minify'],
++ yaml: ['YAML→JSON', 'JSON→YAML'],
++ xml: ['Format', 'Validate', 'Minify'],
++ csv: ['Table', 'To JSON'],
++ sql: ['Format', 'Uppercase', 'Compact'],
++ md: ['Preview', 'To HTML'],
++ html: ['Format', 'Minify', 'Entities', 'Unescape'],
++ base64: ['Encode', 'Decode'],
++ url: ['Encode', 'Decode', 'Parse'],
++ jwt: ['Decode'],
++ uuid: ['Generate', 'Bulk 10', 'Bulk 100', 'Validate'],
++ hash: ['SHA-256', 'SHA-1', 'SHA-512'],
++ hex: ['To Hex', 'From Hex'],
++ entities: ['HTML Enc', 'HTML Dec', 'JS Esc', 'JS Unesc', 'URL Enc', 'URL Dec'],
++ timestamp: ['Now', 'To Date', 'To Unix'],
++ cron: ['Parse'],
++ beautify: ['JSON', 'HTML', 'CSS'],
++ minify: ['JSON', 'HTML', 'CSS'],
++ codediff: ['Compare'],
++ regex: ['Test'],
++ slugify: ['Generate'],
++ lorem: ['1 Para', '3 Para', '5 Para', '10 Sent', '50 Words'],
++ curlconvert: ['To fetch', 'To Python', 'To Node', 'Parsed'],
++ webhook: ['Create Bin'],
++ color: ['Convert'],
++ opengraph: ['Preview'],
++ read: ['Markdown', 'Text'],
++ jsminify: ['Minify', 'Minify + mangle', 'Whitespace only'],
++ jsformat: ['Beautify'],
++ cssminify: ['Minify'],
++ cssformat: ['Beautify'],
++ htmlminify: ['Minify'],
++ xmlminify: ['Minify'],
++ jsonminify: ['Minify'],
++ jsonparse: ['Tree', 'Paths', 'Types'],
++ jsonvalidate: ['Validate'],
++ jsonstringify: ['Stringify', 'Parse string'],
++ html2md: ['Convert'],
++ md2html: ['To HTML', 'Preview'],
++};
++
++for (const [sub, modes] of Object.entries(MODES)) { const rec = DEV[sub]; if (rec) rec.modes = modes; }
++
const CATALOG = new Map();
for (const [sub, rec] of Object.entries(DEV)) CATALOG.set(sub, { ...rec, sub, family: 'dev', hub: sub === 'dev' });
for (const [sub, rec] of Object.entries(NET)) CATALOG.set(sub, { ...rec, sub, family: 'net', hub: sub === 'net' });
ddiff --git a/apps/gateway/server/seo/frame.js b/apps/gateway/server/seo/frame.js
nnew file mode 100644
iindex 0000000..3204170
---- /dev/null
++++ b/apps/gateway/server/seo/frame.js
@@@ -0,0 +1,338 @@
++'use strict';
++
++// ═══════════════════════════════════════════════════════════════
++// The tool frame for net.* and dev.* tool pages (openvibe-shared 4.55.0, owner ask 11).
++//
++// One order for every tool page: the tool's head, the box you type in, the answer (.ovk-tool-out, drawn by the
++// page's own run code), the family, the folds (About, Questions, Use it from code) and "People also use". The
++// markup is openvibe-shared/kit's; this module only chooses the copy and the links, from the catalogue, the
++// families map and the tool descriptors. Nothing here restyles the kit.
++//
++// A page's answer is drawn in the browser (a net lookup calls the run API; a dev tool transforms text), so the
++// server renders the frame empty and the page fills `.ovk-tool-out` with the same kit.answer builder
++// (window.OpenVibeKit.answer). The catalogue's `examples` become the Try links under the box.
++// ═══════════════════════════════════════════════════════════════
++
++const kit = require('openvibe-shared/kit');
++const serve = require('openvibe-shared/serve');
++const { CATALOG, relatedTo } = require('./catalog');
++const registry = require('../registry');
++const { FAMILY_MAP } = require('../registry/families');
++const { quotas } = require('../../../_shared/guard/limits');
++
++const SITE = 'https://openvibe.tools';
++const esc = kit.esc;
++
++// The frame's family: the two families this part covers, with the owner's fixed tints (kit.toolHead hue). The
++// hub link and the family membership come from the registry's families map; the display names are the frame's.
++const hubOf = (id) => { const f = FAMILY_MAP.get(id); return f && f.hub ? `https://${f.hub}.openvibe.tools/` : 'https://openvibe.tools/'; };
++const FAMILY = {
++ net: { id: 'net', label: 'Network tools', more: 'More network tools', hue: 190, hub: hubOf('net'), icon: 'globe' },
++ dev: { id: 'dev', label: 'Code and data', more: 'More code and data tools', hue: 265, hub: hubOf('dev'), icon: 'code' },
++};
++
++const HOST = new Map(); // tool id → page host, from the registry (owner overrides included)
++function hostOf(id) {
++ if (!HOST.size) for (const t of registry.get().tools) HOST.set(t.id, t.hosts.canonical);
++ return HOST.get(id) || `${id}.openvibe.tools`;
++}
++
++// Short names for the family row on a phone; the full name shows from 1024 px (kit.family).
++const SHORT = {
++ lookup: 'All-in-one', myip: 'My IP', ip: 'IP', ipv4: 'IPv4/CIDR', ipv6: 'IPv6', geoip: 'GeoIP', hostname: 'Hostname',
++ isp: 'ISP', asn: 'ASN', rdns: 'Reverse DNS', whois: 'Whois', rdap: 'RDAP', dns: 'DNS', dig: 'dig', nslookup: 'NSLookup',
++ dnspropagation: 'Propagation', mx: 'MX', txt: 'TXT', ns: 'NS', spf: 'SPF', dkim: 'DKIM', dmarc: 'DMARC', ping: 'Ping',
++ traceroute: 'Traceroute', mtr: 'MTR', port: 'Ports', headers: 'Headers', redirects: 'Redirects', ssl: 'SSL', curl: 'curl',
++ httpstatus: 'Status', latency: 'Latency', blacklist: 'Blacklist', reputation: 'Reputation', smtp: 'SMTP', uptime: 'Uptime',
++ robots: 'robots.txt', sitemap: 'Sitemap',
++ jsonfmt: 'Formatter', yaml: 'YAML', xml: 'XML', csv: 'CSV', sql: 'SQL', md: 'Markdown', html: 'HTML', base64: 'Base64',
++ url: 'URL', jwt: 'JWT', uuid: 'UUID', hash: 'Hash', hex: 'Hex', entities: 'Escape', timestamp: 'Timestamp', cron: 'Cron',
++ beautify: 'Beautify', minify: 'Minify', codediff: 'Diff', regex: 'Regex', slugify: 'Slug', lorem: 'Lorem',
++ curlconvert: 'curl', webhook: 'Webhook', color: 'Colour', opengraph: 'Open Graph', read: 'Reader', jsminify: 'JS minify',
++ jsformat: 'JS format', cssminify: 'CSS minify', cssformat: 'CSS format', htmlminify: 'HTML minify', xmlminify: 'XML minify',
++ jsonminify: 'JSON minify', jsonparse: 'JSON parser', jsonvalidate: 'Validator', jsonstringify: 'Stringify',
++ html2md: 'HTML→MD', md2html: 'MD→HTML',
++};
++
++// The nearest kit icon (kit.ICONS); the family icon is the fallback.
++const ICON = {
++ lookup: 'search', myip: 'user', ip: 'globe', ipv4: 'grid', ipv6: 'code', geoip: 'map', hostname: 'server', isp: 'cloud',
++ asn: 'list', rdns: 'swap', whois: 'book', rdap: 'file', dns: 'list', dig: 'terminal', nslookup: 'terminal',
++ dnspropagation: 'globe', mx: 'mail', txt: 'file', ns: 'server', spf: 'shield', dkim: 'key', dmarc: 'shield', ping: 'zap',
++ traceroute: 'map', mtr: 'chart', port: 'grid', headers: 'list', redirects: 'swap', ssl: 'lock', curl: 'terminal',
++ httpstatus: 'check', latency: 'clock', blacklist: 'ban', reputation: 'star', smtp: 'mail', uptime: 'heart',
++ robots: 'robot', sitemap: 'map',
++ jsonfmt: 'code', yaml: 'file', xml: 'file', csv: 'grid', sql: 'grid', md: 'book', html: 'code', base64: 'lock',
++ url: 'link', jwt: 'key', uuid: 'tag', hash: 'shield', hex: 'terminal', entities: 'code', timestamp: 'clock',
++ cron: 'calendar', beautify: 'spark', minify: 'filter', codediff: 'swap', regex: 'search', slugify: 'link', lorem: 'book',
++ curlconvert: 'terminal', webhook: 'send', color: 'palette', opengraph: 'eye', read: 'book', jsminify: 'filter',
++ jsformat: 'spark', cssminify: 'filter', cssformat: 'spark', htmlminify: 'filter', xmlminify: 'filter',
++ jsonminify: 'filter', jsonparse: 'list', jsonvalidate: 'check', jsonstringify: 'code', html2md: 'file', md2html: 'file',
++};
++
++const PLACEHOLDER = {
++ net: 'example.com or 1.1.1.1',
++ dev: 'Paste your input',
++};
++const DEV_PLACEHOLDER = {
++ jsonfmt: 'Paste JSON', jsonvalidate: 'Paste JSON to check', jsonparse: 'Paste JSON', jsonminify: 'Paste JSON',
++ jsonstringify: 'Paste JSON, or an escaped string', yaml: 'Paste YAML', xml: 'Paste XML', xmlminify: 'Paste XML',
++ csv: 'Paste CSV', sql: 'Paste a SQL query', md: 'Type or paste Markdown', md2html: 'Type or paste Markdown',
++ html: 'Paste HTML', html2md: 'Paste HTML', htmlminify: 'Paste HTML', base64: 'Text, or Base64 to decode',
++ url: 'A URL or a value to encode', jwt: 'Paste a JSON Web Token', hash: 'Text to hash', hex: 'Text or hex bytes',
++ entities: 'A string to escape', cron: 'A cron expression', beautify: 'Paste code', minify: 'Paste code',
++ codediff: 'The original text', regex: 'A regular expression', slugify: 'A title', curlconvert: 'Paste a curl command',
++ color: '#a78bfa or rgb(167, 139, 250)', opengraph: 'https://example.com', read: 'https://example.com',
++ jsminify: 'Paste JavaScript', jsformat: 'Paste JavaScript', cssminify: 'Paste CSS', cssformat: 'Paste CSS',
++ uuid: 'A UUID to check (optional)', lorem: 'Words, sentences or paragraphs (optional)', timestamp: 'A Unix time or a date',
++};
++const URL_TOOLS = new Set(['headers', 'curl', 'httpstatus', 'redirects', 'uptime', 'robots', 'sitemap', 'opengraph', 'read']);
++// Where the work happens: every net. tool is a server lookup; in dev., only these three call the server (dev.html's
++// P.opengraph, P.read and P.webhook). The rest compute in the page, so they must not claim the server.
++const SERVER_DEV = new Set(['opengraph', 'read', 'webhook']);
++const serverWork = (rec) => rec.family === 'net' || SERVER_DEV.has(rec.sub);
++
++/** A page that gets the frame: a catalogued net./dev. tool, not the family hub. */
++const isFramed = (rec) => !!rec && !rec.hub && (rec.family === 'net' || rec.family === 'dev');
++
++const firstSentence = (s) => {
++ const t = String(s || '').trim();
++ const i = t.search(/[.!?](\s|$)/);
++ return i > 0 ? t.slice(0, i + 1) : t;
++};
++const sentence = (s) => { const t = firstSentence(s).trim(); return t && !/[.!?]$/.test(t) ? `${t}.` : t; };
++
++/** The one-line task for the head: the catalogue's first sentence. */
++const taskOf = (rec) => sentence(rec.desc);
++
++function shortLabel(value) {
++ const s = String(value == null ? '' : value).replace(/\s+/g, ' ').trim();
++ return s.length <= 28 ? s : `${s.slice(0, 27).trimEnd()}…`;
++}
++
++function placeholderFor(rec) {
++ if (rec.family === 'dev') {
++ if (DEV_PLACEHOLDER[rec.sub]) return DEV_PLACEHOLDER[rec.sub];
++ const ex = (rec.examples || []).find((e) => typeof e === 'string');
++ return ex ? shortLabel(ex).replace(/…$/, '') : PLACEHOLDER.dev;
++ }
++ if (rec.sub === 'whois') return 'example.com or 1.1.1.1';
++ if (URL_TOOLS.has(rec.sub)) return 'https://example.com';
++ if (['dns', 'dig', 'nslookup', 'mx', 'txt', 'ns', 'spf', 'dkim', 'dmarc', 'dnspropagation'].includes(rec.sub)) return 'example.com';
++ if (['ip', 'ipv4', 'ipv6', 'geoip', 'hostname', 'isp', 'asn', 'rdns', 'blacklist', 'reputation'].includes(rec.sub)) return '1.1.1.1 or example.com';
++ return PLACEHOLDER.net;
++}
++
++// Short labels for values too long to read as a pill (a paste of JSON reads as "One object", not as its first 28 characters).
++const TRY_LABELS = {
++ jsonfmt: ['One object', 'Nested', 'Broken JSON'], jsonvalidate: ['Valid JSON', 'Trailing comma'],
++ jsonparse: ['A user object', 'A list'], jsonminify: ['Pretty JSON', 'One line'], jsonstringify: ['A JSON value', 'An escaped string'],
++ base64: ['Text', 'Domain', 'Sentence'], url: ['A long URL', 'A query value', 'A URL'],
++ whois: ['openvibe.network', 'wikipedia.org', 'example.com'], dns: ['openvibe.network', 'wikipedia.org', 'example.com'],
++ mx: ['openvibe.network', 'gmail.com', 'wikipedia.org'], txt: ['openvibe.network', 'example.com', 'wikipedia.org'],
++ headers: ['openvibe.network', 'example.com', 'wikipedia.org'], ssl: ['openvibe.network', 'wikipedia.org', 'example.com'],
++};
++
++/** The Try items the kit can draw: one-box tools only (a two-box tool gets its own pair links in the page). */
++function tryItems(rec) {
++ const labels = TRY_LABELS[rec.sub] || [];
++ return (rec.examples || []).filter((e) => typeof e === 'string').slice(0, 3).map((value, i) => ({ value, label: labels[i] || shortLabel(value) }));
++}
++
++/** The field's label, from the tool's own descriptor: "A domain" is Domain, "A domain or an IP address" is
++ * Domain or IP, and a URL tool says so. The label must match what the input accepts. */
++function fieldLabel(rec) {
++ const d = descriptorOf(rec.sub);
++ const target = d && d.input && d.input.properties && d.input.properties.target;
++ const desc = String((target && target.description) || '');
++ if (/^A domain or an IP address, or a URL/i.test(desc)) return 'Domain, IP or URL';
++ if (/^An IPv4 address, a CIDR block/i.test(desc)) return 'IPv4, CIDR or domain';
++ if (/^An IPv6 address or a domain/i.test(desc)) return 'IPv6 or domain';
++ if (/^A domain or a public IP address/i.test(desc)) return 'Domain or IP';
++ if (/^A domain or an IP address/i.test(desc)) return 'Domain or IP';
++ if (/^An IPv4 address or a domain/i.test(desc)) return 'Domain or IP';
++ if (/^A mail server/i.test(desc)) return 'Mail server or domain';
++ if (/^A sitemap URL/i.test(desc)) return 'Sitemap URL or domain';
++ if (/^A URL,/i.test(desc)) return 'URL or domain';
++ if (/^A domain$/i.test(desc)) return 'Domain';
++ return 'Domain, IP address or URL';
++}
++
++/** The tool's terms, from its real configuration: cost, where the work happens, the gateway's anonymous limit
++ * for its quota class (the same table the API docs show) and whether an account is needed. A page-side tool
++ * says so and, when it has an API, names the API's limit; it never pretends to be a server run. */
++function factsFor(rec) {
++ const d = descriptorOf(rec.sub);
++ const onServer = serverWork(rec);
++ const q = d && d.quotaClass && quotas()[d.quotaClass];
++ const anonymous = !(d && d.auth && d.auth.anonymous === false);
++ const facts = [
++ { text: 'Free', icon: 'gift' },
++ { text: onServer ? 'Runs on our server' : 'Runs in your browser: nothing is sent', icon: onServer ? 'server' : 'computer' },
++ ];
++ if (onServer) {
++ if (!anonymous) facts.push({ text: 'Needs a free account', icon: 'user' }, { text: 'No sign-up for lookups', icon: 'globe' });
++ else {
++ if (q) facts.push({ text: `${q.anonymous.perMinute} ${rec.family === 'net' ? 'lookups' : 'runs'} a minute without an account`, icon: 'clock' });
++ facts.push({ text: 'No sign-up', icon: 'user' });
++ }
++ } else {
++ if (d && d.api && q) facts.push({ text: `API: ${q.anonymous.perMinute} ${rec.family === 'net' ? 'lookups' : 'runs'} a minute without an account`, icon: 'clock' });
++ else facts.push({ text: 'No limit: it runs on your device', icon: 'zap' });
++ facts.push({ text: 'No sign-up', icon: 'user' });
++ }
++ return facts;
++}
++
++/** The box: kit.toolInput for a lookup, a kit editor for a text tool. */
++function inputFor(rec, value = '') {
++ if (rec.family === 'net') {
++ if (rec.sub === 'myip') {
++ return `<form class="ovk-tool-form" data-ovk-tool-form>`
++ + `<button type="submit" class="ovk-btn ovk-btn--primary ovk-tool-go">${kit.icon('globe')}<span>Show my IP</span></button></form>`;
++ }
++ const input = kit.toolInput({
++ name: 'q', id: 'search-input', label: fieldLabel(rec), placeholder: placeholderFor(rec), value,
++ button: rec.sub === 'lookup' ? 'Look up all' : 'Look up', icon: 'search',
++ inputmode: URL_TOOLS.has(rec.sub) ? 'url' : 'text',
++ tries: tryItems(rec).length ? { items: tryItems(rec) } : null,
++ });
++ return `${input}<div id="options-area" class="ovk-tool-options"></div>`;
++ }
++
++ const ex = rec.examples || [];
++ const second = typeof ex[1] === 'string' ? ex[1] : '';
++ const dual = rec.sub === 'codediff' || rec.sub === 'regex';
++ const modes = rec.modes || [];
++ const active = modes[0] || 'Go';
++ const seg = modes.length > 1 ? `<div id="modes" class="ovk-tool-modes">${kit.seg({ name: 'mode', options: modes.map((m) => [m, m]), value: active })}</div>` : '';
++ const tries = tryItems(rec).length
++ ? kit.tries({ name: 'q', target: '#in1', items: tryItems(rec) })
++ : (dual ? pairTries(rec) : '');
++ return `<form class="ovk-tool-form" data-ovk-tool-form data-ov-tool="${esc(rec.sub)}">
++<textarea class="ovk-tool-editor" id="in1" name="q" spellcheck="false" placeholder="${esc(placeholderFor(rec))}" aria-label="${esc(rec.name)} input">${esc(value || '')}</textarea>
++${dual ? `<textarea class="ovk-tool-editor" id="in2" name="q2" spellcheck="false" aria-label="${esc(rec.name)} second input" placeholder="${esc(second ? shortLabel(second) : 'The second value')}"></textarea>` : ''}
++${seg}
++<div class="ovk-tool-buttons"><button type="submit" id="tool-run" data-mode="${esc(active)}" class="ovk-btn ovk-btn--primary ovk-tool-go"><span>${esc(active)}</span></button><button type="button" class="ovk-btn ovk-btn--quiet ovk-btn--small" id="tool-clear">${kit.icon('close')}<span>Clear</span></button></div>
++${tries}</form>`;
++}
++
++/** Two-box tools: a pair fills both boxes and runs (the kit's Try link fills only one). */
++function pairTries(rec) {
++ const pairs = (rec.examples || []).filter(Array.isArray).slice(0, 3);
++ if (!pairs.length) return '';
++ return `<p class="ovk-try"><span>Try</span>${pairs.map(([a, b], i) => {
++ const href = `?q=${encodeURIComponent(a)}&q2=${encodeURIComponent(b)}`;
++ return `<a href="${esc(href)}" data-ov-try-two="${esc(JSON.stringify([a, b]))}">${esc(i === 0 ? 'Example' : `Example ${i + 1}`)}</a>`;
++ }).join('')}</p>`;
++}
++
++function aboutFold(rec) {
++ const bullets = (rec.bullets || []).length ? `<ul>${rec.bullets.map((b) => `<li>${esc(b)}</li>`).join('')}</ul>` : '';
++ return kit.fold({ id: 'about-this-tool', title: `About ${rec.name}`, body: `<p>${esc(rec.about || rec.desc || '')}</p>${bullets}` });
++}
++
++function questionsFold(rec) {
++ const faq = rec.faq || [];
++ if (!faq.length) return '';
++ const body = faq.map(([q, a]) => `<p><strong>${esc(q)}</strong></p><p>${esc(a)}</p>`).join('');
++ return kit.fold({ title: 'Questions', meta: String(faq.length), body });
++}
++
++// curl quoting for a value with single quotes (site/pages/site.js's shq).
++const shq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
++
++function descriptorOf(id) {
++ try { return require('../registry/descriptors').snapshot().tools.find((d) => d.id === id) || null; } catch { return null; }
++}
++
++/** An input sample for a tool whose descriptor has no example: its real field names and a public value for each. */
++function sampleInput(rec, d) {
++ const ex = (d.examples || [])[0];
++ if (ex && ex.input) return ex.input;
++ const props = (d.input && d.input.properties) || {};
++ const out = {};
++ for (const [k, s] of Object.entries(props)) {
++ if (k === 'text') out[k] = typeof rec.examples?.[0] === 'string' ? rec.examples[0] : 'Hello, World!';
++ else if (k === 'target') out[k] = typeof rec.examples?.[0] === 'string' ? rec.examples[0] : 'example.com';
++ else if (k === 'url') out[k] = 'https://openvibe.network';
++ else if (k === 'token') out[k] = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ4In0.sig';
++ else if (k === 'command') out[k] = 'curl https://openvibe.network';
++ else if (k === 'expression') out[k] = '*/5 * * * *';
++ else if (k === 'color') out[k] = '#a78bfa';
++ else if (k === 'types') out[k] = ['A'];
++ else if (s && s.default !== undefined) out[k] = s.default;
++ }
++ return out;
++}
++
++/** "Use it from code": the real POST /api/v1/tools/<id>/run, its real input fields and the docs. */
++function apiFold(rec) {
++ const d = descriptorOf(rec.sub);
++ const docs = '<a href="https://openvibe.tools/developers">API docs</a>';
++ if (!d || !d.api) {
++ return kit.fold({ title: 'Use it from code', meta: 'API', body: `<p>${esc(rec.name)} runs in your browser, so there is no server call for it. ${docs} covers the tools you can call from code.</p>` });
++ }
++ const input = sampleInput(rec, d);
++ const fields = Object.keys((d.input && d.input.properties) || {}).join(', ') || 'none';
++ const token = !(d.auth && d.auth.anonymous);
++ const probe = d.auth && d.auth.capability === 'tools.net.probe';
++ const lines = [`curl -X POST ${SITE}${d.run.path}`];
++ if (token) lines.push(`-H 'Authorization: Bearer $OPENVIBE_TOKEN'`);
++ lines.push(`-H 'Content-Type: application/json'`, `-d ${shq(JSON.stringify({ input }))}`);
++ const who = probe ? 'Needs a token with <code>tools.net.probe</code>, granted to partners only; people use the page.'
++ : token ? 'Needs a signed-in browser session or a token with <code>tools.tool.run</code>.'
++ : 'No account needed within the free limit; a token with <code>tools.tool.run</code> raises it.';
++ return kit.fold({
++ title: 'Use it from code', meta: 'API',
++ body: `<p>Input: <code>${esc(fields)}</code>. ${who}</p>${kit.snippet({ code: lines.join(' \\\n ') })}<p><a href="${SITE}/api/v1/tools/${esc(rec.sub)}/schema">JSON Schema</a> · <a href="${SITE}/api/v1/openapi.json">OpenAPI</a> · ${docs}</p>`,
++ });
++}
++
++function familyItems(rec) {
++ const items = [];
++ for (const [sub, other] of CATALOG) {
++ if (other.family !== rec.family || other.hub) continue;
++ items.push({ href: `https://${hostOf(sub)}/`, label: other.name, short: SHORT[sub] || other.name, current: sub === rec.sub });
++ }
++ return items;
++}
++
++function relatedRows(rec) {
++ return relatedTo(rec.sub, 3).map((r) => kit.row({ href: `https://${hostOf(r.sub)}/`, title: r.name, sub: r.desc }));
++}
++
++/**
++ * The frame's markup for one catalogue record: kit.tool with its head, box, family, folds and related list.
++ * @param {object} rec a catalogue record (seoFor(sub))
++ * @param {object} [opts] { q } the ?q= already in the address, filled into the box
++ * @returns {string} HTML
++ */
++function frameFor(rec, { q = '' } = {}) {
++ const fam = FAMILY[rec.family];
++ const count = [...CATALOG.values()].filter((r) => r.family === rec.family && !r.hub).length;
++ const items = familyItems(rec);
++ const related = relatedRows(rec);
++ const tool = kit.tool({
++ head: kit.toolHead({
++ icon: ICON[rec.sub] || fam.icon, hue: fam.hue, family: rec.family,
++ crumb: { href: fam.hub, label: fam.label }, title: rec.name, task: taskOf(rec), facts: factsFor(rec),
++ }),
++ input: inputFor(rec, q),
++ answer: '',
++ family: kit.family({ title: fam.more, more: { href: fam.hub, label: `All ${count}` }, items }),
++ folds: [aboutFold(rec), questionsFold(rec), apiFold(rec)].filter(Boolean),
++ related: related.length ? kit.list(related, { title: 'People also use' }) : '',
++ });
++ return `<div class="ovk-page ovk-page--wide" data-ov-tool-page="${esc(rec.sub)}">${tool}</div>`;
++}
++
++/** The browser files a framed page loads (the kit and its behaviour, from this site's own pin). */
++function assets() {
++ return {
++ css: [serve.url('kit.css'), serve.url('kit-data.css')],
++ js: [serve.url('kit.js'), serve.url('kit-web.js'), serve.url('kit-data-web.js')],
++ };
++}
++
++module.exports = { frameFor, isFramed, assets };
ddiff --git a/apps/gateway/server/seo/render.js b/apps/gateway/server/seo/render.js
iindex f12626a..5b424f4 100644
---- a/apps/gateway/server/seo/render.js
++++ b/apps/gateway/server/seo/render.js
@@@ -18,6 +18,7 @@ const fs = require('fs');
const path = require('path');
const { seoFor, relatedTo, BRAND } = require('./catalog');
const { FAMILY_MAP } = require('../registry/families');
++const { frameFor, isFramed, assets } = require('./frame');
// A family's breadcrumb name and hub page (net.openvibe.tools, barcodes.openvibe.tools…).
const familyOf = (rec) => { const f = FAMILY_MAP.get(rec.family) || {}; return { name: f.name || 'Tools', url: f.hub ? `https://${f.hub}.openvibe.tools` : SITE, plural: String(f.name || 'tools').toLowerCase() }; };
@@@ -104,15 +105,18 @@ function contentBlock(rec, related) {
* Render the SPA file for one subdomain with its own metadata and content.
* Returns the original HTML untouched when the subdomain is not catalogued.
*/
--function renderTool(file, sub, canonicalHost) {
++function renderTool(file, sub, canonicalHost, query = {}) {
const rec = seoFor(sub);
if (!rec) return baseHtml(file);
-- const key = `${file}::${sub}::${canonicalHost || ''}`;
++ const framed = isFramed(rec);
++ // The tool's own input name (q, and the older aliases) fills the box, so a Try or a shared link runs on open.
++ const q = framed ? String(query.q || query.target || query.domain || query.ip || '').slice(0, 2048) : '';
++ const key = `${file}::${sub}::${canonicalHost || ''}::${framed ? q : ''}`;
if (_rendered.has(key)) return _rendered.get(key);
// The registry decides the canonical host (a custom domain may own the tool).
const url = `https://${canonicalHost || sub + '.openvibe.tools'}`;
-- const related = relatedTo(sub, 6);
++ const related = relatedTo(sub, framed ? 3 : 6); // the frame shows three; a family/hub block shows six
const title = `${rec.title} | ${BRAND}`;
let html = baseHtml(file);
@@@ -132,8 +136,10 @@ function renderTool(file, sub, canonicalHost) {
`<meta property="og:image" content="${card}">`, '<meta property="og:image:width" content="1200">', '<meta property="og:image:height" content="630">',
`<meta name="twitter:image" content="${card}">`,
];
++ const frameAssets = framed ? assets() : null;
const extraHead = [
...image,
++ ...(frameAssets ? frameAssets.css.map((href) => `<link rel="stylesheet" href="${href}">`) : []),
`<meta name="ov-tool" content="${esc(sub)}">`, // the page script reads this: the host may be descriptive, a mirror or a custom domain
`<meta name="keywords" content="${esc(rec.kw || '')}">`,
'<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1">',
@@@ -146,11 +152,22 @@ function renderTool(file, sub, canonicalHost) {
].join('\n');
html = html.replace(/<\/head>/i, `${extraHead}\n</head>`);
++ // The tool frame (openvibe-shared 4.55.0): replace the old hero, chips and search bar in one go, and keep the
++ // page's own run code (it fills .ovk-tool-out). The ?q= value is already in the box.
++ if (framed) {
++ const frame = frameFor(rec, { q });
++ if (/<!-- ov-frame:start -->/.test(html)) html = html.replace(/<!-- ov-frame:start -->[\s\S]*?<!-- ov-frame:end -->/, frame);
++ html = html.replace(/<body(\s[^>]*)?>/i, (m, attrs = '') => { const cls = /\sclass="([^"]*)"/.exec(attrs || ''); const next = cls ? attrs.replace(cls[0], ` class="${cls[1]} ov-tool-page"`) : `${attrs || ''} class="ov-tool-page"`; return `<body${next}>`; });
++ if (frameAssets) html = html.replace(/<\/body>/i, `${frameAssets.js.map((src) => `<script src="${src}"></script>`).join('\n')}\n</body>`);
++ }
++
// Visible content + internal links, under the tool and above the footer. It used to go before </body>, after the
// footer (the owner, 2026-10-28: "is it intentional to have content after the bottom nav bar like this?"); a page
// without a footer mount still gets it before </body>.
-- const block = contentBlock(rec, related);
-- html = /<div id="ov-footer"/i.test(html) ? html.replace(/<div id="ov-footer"/i, `${block}\n<div id="ov-footer"`) : html.replace(/<\/body>/i, `${block}\n</body>`);
++ if (!framed) {
++ const block = contentBlock(rec, related);
++ html = /<div id="ov-footer"/i.test(html) ? html.replace(/<div id="ov-footer"/i, `${block}\n<div id="ov-footer"`) : html.replace(/<\/body>/i, `${block}\n</body>`);
++ }
if (_rendered.size > 200) _rendered.clear();
_rendered.set(key, html);
ddiff --git a/apps/gateway/test/registry-consistency.test.js b/apps/gateway/test/registry-consistency.test.js
iindex 4266d44..a1d6c26 100644
---- a/apps/gateway/test/registry-consistency.test.js
++++ b/apps/gateway/test/registry-consistency.test.js
@@@ -69,7 +69,7 @@ function netPage() {
out.set(m[1], { endpoint: ep ? ep[1] || null : undefined, unavailable: /unavailable: '/.test(m[0]) });
}
const dispatch = html.slice(html.indexOf('function renderResults('), html.indexOf('// Fallback: raw JSON'));
-- out.rendered = new Set([...dispatch.matchAll(/tool\.endpoint === '([^']+)'\) return render/g)].map(m => m[1]));
++ out.rendered = new Set([...dispatch.matchAll(/tool\.endpoint === '([^']+)'\)\s*return (?:render|answer)/g)].map(m => m[1]));
out.renderedById = new Set([...dispatch.matchAll(/activeTool === '([a-z0-9]+)'/g)].map(m => m[1]));
return out;
}
ddiff --git a/apps/gateway/test/tool-frame-seo.test.js b/apps/gateway/test/tool-frame-seo.test.js
nnew file mode 100644
iindex 0000000..09d9c4b
---- /dev/null
++++ b/apps/gateway/test/tool-frame-seo.test.js
@@@ -0,0 +1,51 @@
++'use strict';
++// Every tool subdomain in the sitemap still answers 200 with its own title and exactly one h1 (owner ask 11:
++// the tool frame replaced the page's hero, and the hero used to carry the only h1 and its own title).
++// Runs the gateway as it runs in production (its own process, Host-header routing) and asks each net./dev.
++// host from the sitemap. The catalogue is the same list the sitemap is built from.
++const assert = require('assert');
++const fs = require('fs');
++const http = require('http');
++const os = require('os');
++const path = require('path');
++const { startApp } = require('../../_shared/test/spawn');
++const site = require('../server/pages/site');
++const { CATALOG } = require('../server/seo/catalog');
++
++const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
++const get = (port, host) => new Promise((resolve) => {
++ const req = http.get({ host: '127.0.0.1', port, path: '/', headers: { Host: host, Connection: 'close' }, timeout: 15000 }, (res) => {
++ let body = '';
++ res.setEncoding('utf8');
++ res.on('data', (d) => { body += d; });
++ res.on('end', () => resolve({ status: res.statusCode, body }));
++ });
++ req.on('timeout', () => { req.destroy(); resolve({ status: 0, body: '' }); });
++ req.on('error', () => resolve({ status: 0, body: '' }));
++});
++
++(async () => {
++ const tools = [...CATALOG.values()].filter((r) => !r.hub && (r.family === 'net' || r.family === 'dev'));
++ const byHost = new Map(tools.map((r) => [`${r.sub}.openvibe.tools`, r]));
++ const inSitemap = site.sitemapEntries().map((e) => String(e.loc).replace(/^https?:\/\//, '').replace(/\/$/, '')).filter((h) => byHost.has(h));
++ assert.ok(inSitemap.length > 50, `the sitemap lists ${inSitemap.length} net./dev. tools`);
++
++ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-tools-sitemap-'));
++ const gw = await startApp('gateway', { DATA_DIR: tmp, OV_DOMAINS_URL: 'http://127.0.0.1:9/api/domains', OV_REGISTRY_URL: 'http://127.0.0.1:9/registry' });
++ try {
++ for (const host of inSitemap) {
++ const rec = byHost.get(host);
++ const r = await get(gw.port, host);
++ assert.strictEqual(r.status, 200, `${host} answers 200`);
++ assert.ok(r.body.includes(`<title>${esc(rec.title)} | OpenVibe</title>`), `${host} has its own title`);
++ const h1 = r.body.match(/<h1[\s>]/g) || [];
++ assert.strictEqual(h1.length, 1, `${host} has one h1`);
++ assert.ok(r.body.includes(`<h1 class="ovk-tool-title">${esc(rec.name)}</h1>`), `${host} h1 is the tool's name`);
++ assert.ok(r.body.includes(`<link rel="canonical" href="https://${host}">`), `${host} canonical`);
++ }
++ } finally {
++ await gw.kill();
++ fs.rmSync(tmp, { recursive: true, force: true });
++ }
++ console.log(`tool frame sitemap: ${inSitemap.length} net./dev. subdomains answer 200 with their own title, canonical and one h1`);
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/apps/gateway/test/tool-frame-terms.test.js b/apps/gateway/test/tool-frame-terms.test.js
nnew file mode 100644
iindex 0000000..53e8b88
---- /dev/null
++++ b/apps/gateway/test/tool-frame-terms.test.js
@@@ -0,0 +1,148 @@
++'use strict';
++// The tool frame's terms, verb buttons and field labels (owner review of #155):
++// • one mode is the button's verb, several are the kit's segmented control and the button says the active one;
++// "Run" is never a label;
++// • the field label comes from the tool's descriptor, so MX asks for a Domain and a URL tool for a URL;
++// • facts: Free, where the work happens, the gateway's anonymous limit for the tool's quota class and whether
++// an account is needed. A page-side tool never claims the server, and a server tool never claims the browser.
++// • the catalogue's modes and the page's own processors (dev.html T[..].m) stay equal.
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const { CATALOG } = require('../server/seo/catalog');
++const { frameFor } = require('../server/seo/frame');
++const { snapshot } = require('../server/registry/descriptors');
++
++const PUB = path.join(__dirname, '..', 'public');
++const framed = [...CATALOG.values()].filter((r) => !r.hub && (r.family === 'net' || r.family === 'dev'));
++const descriptors = new Map(snapshot().tools.map((d) => [d.id, d]));
++const labelOf = (html) => { const m = /<label class="ovk-tool-label"[^>]*>(.*?)<\/label>/.exec(html); return m ? m[1] : null; };
++const factsOf = (html) => {
++ const m = /<ul class="ovk-tool-facts">([\s\S]*?)<\/ul>/.exec(html);
++ if (!m) return [];
++ return m[1].replace(/<svg[\s\S]*?<\/svg>/g, '').replace(/<\/li>\s*<li>/g, '\n').replace(/<[^>]+>/g, '').split('\n').map((s) => s.trim()).filter(Boolean);
++};
++const buttonOf = (html) => {
++ const m = /<button[^>]*id="tool-run"[^>]*data-mode="([^"]+)"[^>]*>\s*<span>([^<]+)<\/span>/.exec(html);
++ return m ? { mode: m[1], label: m[2] } : null;
++};
++
++// ── The field label matches the descriptor ─────────────────
++{
++ for (const rec of framed.filter((r) => r.family === 'net' && r.sub !== 'myip')) {
++ const d = descriptors.get(rec.sub);
++ assert.ok(d, `${rec.sub}: descriptor`);
++ const desc = String((d.input.properties.target && d.input.properties.target.description) || '');
++ const label = labelOf(frameFor(rec));
++ assert.ok(label, `${rec.sub}: a field label`);
++ assert.notStrictEqual(label, 'Domain, IP address or URL', `${rec.sub}: no generic label`);
++ if (/^A domain$/i.test(desc)) assert.strictEqual(label, 'Domain', `${rec.sub}: ${desc} → Domain`);
++ else if (/^A domain or an IP address, or a URL/i.test(desc)) assert.strictEqual(label, 'Domain, IP or URL', `${rec.sub}: ${desc}`);
++ else if (/^A domain or an IP address/i.test(desc) || /^A domain or a public IP address/i.test(desc) || /^An IPv4 address or a domain/i.test(desc)) assert.strictEqual(label, 'Domain or IP', `${rec.sub}: ${desc}`);
++ else if (/^An IPv4 address, a CIDR block/i.test(desc)) assert.strictEqual(label, 'IPv4, CIDR or domain', `${rec.sub}: ${desc}`);
++ else if (/^An IPv6 address or a domain/i.test(desc)) assert.strictEqual(label, 'IPv6 or domain', `${rec.sub}: ${desc}`);
++ else if (/^A sitemap URL/i.test(desc)) assert.strictEqual(label, 'Sitemap URL or domain', `${rec.sub}: ${desc}`);
++ else if (/^A URL,/i.test(desc)) assert.strictEqual(label, 'URL or domain', `${rec.sub}: ${desc}`);
++ else if (/^A mail server/i.test(desc)) assert.strictEqual(label, 'Mail server or domain', `${rec.sub}: ${desc}`);
++ if (/ip/i.test(desc)) assert.match(label, /IP/i, `${rec.sub}: the label names IP when the input takes one`);
++ if (/url/i.test(desc)) assert.match(label, /URL/i, `${rec.sub}: the label names URL when the input takes one`);
++ }
++ for (const id of ['mx', 'txt', 'spf', 'dkim', 'dmarc', 'dns', 'ns', 'dig', 'nslookup', 'dnspropagation']) {
++ assert.strictEqual(labelOf(frameFor(CATALOG.get(id))), 'Domain', `${id}: a domain field`);
++ }
++ assert.strictEqual(labelOf(frameFor(CATALOG.get('whois'))), 'Domain or IP');
++ assert.strictEqual(labelOf(frameFor(CATALOG.get('headers'))), 'URL or domain');
++}
++
++// ── One mode is the button; several are the kit segmented control ──
++{
++ for (const rec of framed.filter((r) => r.family === 'dev')) {
++ const html = frameFor(rec);
++ const modes = rec.modes || [];
++ assert.ok(modes.length >= 1, `${rec.sub}: has a mode`);
++ const btn = buttonOf(html);
++ assert.ok(btn, `${rec.sub}: a verb button`);
++ assert.strictEqual(btn.mode, modes[0], `${rec.sub}: the button's data-mode is the first mode`);
++ assert.strictEqual(btn.label, modes[0], `${rec.sub}: the button says the verb`);
++ assert.ok(!/<span>Run<\/span>/.test(html), `${rec.sub}: never Run`);
++ if (modes.length === 1) {
++ assert.ok(!html.includes('class="ovk-seg"'), `${rec.sub}: a single mode has no mode row`);
++ } else {
++ assert.ok(html.includes('class="ovk-seg"'), `${rec.sub}: the modes use kit.seg`);
++ assert.ok(html.includes('<div id="modes" class="ovk-tool-modes">'), `${rec.sub}: the modes sit in the frame row`);
++ const radios = [...html.matchAll(/<input type="radio" name="mode" value="([^"]+)"( checked)?>/g)];
++ assert.deepStrictEqual(radios.map((r) => r[1]), modes, `${rec.sub}: every mode is an option`);
++ assert.ok(radios[0][2], `${rec.sub}: the first mode is checked`);
++ }
++ }
++ for (const rec of framed.filter((r) => r.family === 'net')) {
++ const html = frameFor(rec);
++ assert.ok(!html.includes('class="ovk-seg"'), `${rec.sub}: network tools have no mode row`);
++ assert.ok(!/<span>Run<\/span>/.test(html), `${rec.sub}: never Run`);
++ }
++ assert.strictEqual(buttonOf(frameFor(CATALOG.get('jsonvalidate'))).label, 'Validate');
++ assert.strictEqual(buttonOf(frameFor(CATALOG.get('codediff'))).label, 'Compare');
++ assert.strictEqual(buttonOf(frameFor(CATALOG.get('base64'))).label, 'Encode');
++ assert.strictEqual(buttonOf(frameFor(CATALOG.get('webhook'))).label, 'Create Bin');
++}
++
++// ── The catalogue's modes and dev.html's processors agree ──
++{
++ const html = fs.readFileSync(path.join(PUB, 'dev.html'), 'utf8');
++ const block = html.slice(html.indexOf('const T={'), html.indexOf('const CATS='));
++ const T = new Function(`${block}; return T;`)();
++ for (const rec of framed.filter((r) => r.family === 'dev')) {
++ assert.deepStrictEqual(rec.modes, T[rec.sub] && T[rec.sub].m, `${rec.sub}: dev.html's modes`);
++ }
++}
++
++// ── Facts: Free, the work's place, the anonymous limit, an account or not ──
++{
++ const whois = factsOf(frameFor(CATALOG.get('whois')));
++ assert.deepStrictEqual(whois.slice(0, 2), ['Free', 'Runs on our server'], 'whois: free, on our server');
++ assert.ok(whois.includes('60 lookups a minute without an account'), 'whois: the tools-fetch limit from the guard');
++ assert.ok(whois.includes('No sign-up'), 'whois: anonymous');
++
++ const fmt = factsOf(frameFor(CATALOG.get('jsonfmt')));
++ assert.deepStrictEqual(fmt.slice(0, 2), ['Free', 'Runs in your browser: nothing is sent'], 'jsonfmt: in the page');
++ assert.ok(fmt.includes('No limit: it runs on your device'), 'jsonfmt: no gateway limit on a page-side tool without an API');
++ assert.ok(fmt.includes('No sign-up'));
++
++ const validate = factsOf(frameFor(CATALOG.get('jsonvalidate')));
++ assert.deepStrictEqual(validate.slice(0, 2), ['Free', 'Runs in your browser: nothing is sent']);
++ assert.ok(validate.includes('API: 120 runs a minute without an account'), 'jsonvalidate: its API class limit is named');
++
++ const og = factsOf(frameFor(CATALOG.get('opengraph')));
++ assert.ok(og.includes('Runs on our server'), 'opengraph: the page calls the run API');
++ assert.ok(!og.includes('Runs in your browser: nothing is sent'));
++
++ for (const id of ['ping', 'port', 'traceroute', 'mtr', 'latency', 'smtp']) {
++ const facts = factsOf(frameFor(CATALOG.get(id)));
++ assert.ok(facts.includes('Needs a free account'), `${id}: probes and SMTP need an account`);
++ assert.ok(!facts.some((f) => /without an account$/.test(f)), `${id}: no anonymous limit claim`);
++ assert.ok(!facts.includes('No sign-up'), `${id}: no anonymous claim`);
++ }
++
++ for (const rec of framed) {
++ const facts = factsOf(frameFor(rec));
++ assert.ok(facts.includes('Free'), `${rec.sub}: Free`);
++ const browser = facts.includes('Runs in your browser: nothing is sent');
++ const server = facts.includes('Runs on our server');
++ assert.notStrictEqual(browser, server, `${rec.sub}: exactly one place for the work`);
++ const expectedServer = rec.family === 'net' || ['opengraph', 'read', 'webhook'].includes(rec.sub);
++ assert.strictEqual(server, expectedServer, `${rec.sub}: the work's place matches the page`);
++ }
++
++ const saved = process.env.TOOLS_GUARD_LIMITS;
++ try {
++ process.env.TOOLS_GUARD_LIMITS = JSON.stringify({ 'tools-fetch': { anonymous: { perMinute: 7 } } });
++ assert.ok(factsOf(frameFor(CATALOG.get('whois'))).includes('7 lookups a minute without an account'), 'the limit follows the config');
++ process.env.TOOLS_GUARD_LIMITS = JSON.stringify({ 'tools-run': { anonymous: { perMinute: 9 } } });
++ assert.ok(factsOf(frameFor(CATALOG.get('base64'))).includes('API: 9 runs a minute without an account'), 'the API limit follows the config');
++ } finally {
++ if (saved === undefined) delete process.env.TOOLS_GUARD_LIMITS;
++ else process.env.TOOLS_GUARD_LIMITS = saved;
++ }
++}
++
++console.log(`tool frame terms: ${framed.length} pages, labels from descriptors, verb buttons and kit.seg, facts from the guard config: all checks passed`);
ddiff --git a/apps/gateway/test/tool-frame.test.js b/apps/gateway/test/tool-frame.test.js
nnew file mode 100644
iindex 0000000..f37eeb3
---- /dev/null
++++ b/apps/gateway/test/tool-frame.test.js
@@@ -0,0 +1,119 @@
++'use strict';
++// The tool frame (openvibe-shared 4.55.0, owner ask 11): every net./dev. tool page has one order — head, box,
++// answer slot, family, folds (About, Questions, Use it from code), people also use — with the catalogue's Try
++// examples, the current tool marked in the family, and the real run API in the API fold. A ?q= link fills the box
++// and runs once. The old centred hero and chip cloud are gone; the title, canonical and structured data stay.
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const { renderTool } = require('../server/seo/render');
++const { CATALOG } = require('../server/seo/catalog');
++const { frameFor } = require('../server/seo/frame');
++
++const PUB = path.join(__dirname, '..', 'public');
++const net = (sub, q) => renderTool(path.join(PUB, 'net.html'), sub, null, q ? { q } : {});
++const dev = (sub, q) => renderTool(path.join(PUB, 'dev.html'), sub, null, q ? { q } : {});
++
++/** The frame's order: each marker appears once, and in this order. */
++function inOrder(html, parts) {
++ let at = -1;
++ for (const p of parts) {
++ const i = html.indexOf(p, at + 1);
++ assert.ok(i > at, `${p} after ${parts[parts.indexOf(p) - 1] || 'the top'} (${i} > ${at})`);
++ at = i;
++ }
++}
++
++// ── One network tool: whois ─────────────────────────────────
++{
++ const html = net('whois', 'openvibe.network');
++ inOrder(html, ['<header class="ovk-tool-head', '<h1 class="ovk-tool-title">Whois Lookup</h1>', 'class="ovk-tool-card"', 'class="ovk-tool-out"', 'class="ovk-family"', 'class="ovk-tool-folds"', 'People also use']);
++ assert.ok(html.includes('<a class="ovk-tool-crumb" href="https://net.openvibe.tools/">Network tools'), 'crumb is the family and its hub');
++ assert.ok(html.includes('Look up domain registration details: registrar, creation and expiry dates, nameservers and status codes.'), 'the task is the catalogue\'s one sentence');
++ assert.ok(html.includes('value="openvibe.network"'), 'the ?q= value is in the box');
++ assert.ok(/<a href="\?q=openvibe\.network"[^>]*data-ovk-try="openvibe\.network"/.test(html), 'an example is a ?q= Try link');
++ assert.ok(html.includes('data-ovk-try="wikipedia.org"') && html.includes('data-ovk-try="example.com"'), 'two or three examples');
++ assert.ok(html.includes('aria-current="page"') && /aria-current="page"><span class="ovk-family-short">Whois<\/span>/.test(html), 'the family marks the current tool');
++ assert.ok(html.includes('All 38'), 'the family links all 38 network tools');
++ assert.ok(html.includes('Use it from code') && html.includes('https://openvibe.tools/api/v1/tools/whois/run') && html.includes('<code>target</code>'), 'the API fold has the real call and field');
++ assert.ok(html.includes('About Whois Lookup') && html.includes('Questions') && html.includes('Why is the registrant hidden?'), 'the folds carry the about and FAQ copy');
++ assert.ok(html.includes('<title>Whois Lookup — Domain Registration &amp; Ownership Checker | OpenVibe</title>'), 'the page title');
++ assert.ok(html.includes('<link rel="canonical" href="https://whois.openvibe.tools">'), 'the canonical');
++ assert.ok(html.includes('"@type":"SoftwareApplication"') && html.includes('"@type":"BreadcrumbList"') && html.includes('"@type":"FAQPage"'), 'the structured data');
++ assert.strictEqual((html.match(/<h1/g) || []).length, 1, 'one h1');
++ assert.ok(!html.includes('id="hero"') && !html.includes('id="tool-chips-area"'), 'the old hero and chip cloud are gone');
++ assert.ok(html.includes('/shared/kit.css?') && html.includes('/shared/kit-data.css?') && html.includes('/shared/kit-data-web.js?'), 'the kit look and behaviour are loaded');
++ assert.ok(html.includes('class="ov-tool-page"') && html.includes('brand'), 'the page is marked for the Tools brand');
++}
++
++// ── One code tool: jsonvalidate ─────────────────────────────
++{
++ const html = dev('jsonvalidate', '{"a":1}');
++ inOrder(html, ['<header class="ovk-tool-head', '<h1 class="ovk-tool-title">JSON Validator</h1>', 'textarea class="ovk-tool-editor"', 'class="ovk-tool-out"', 'class="ovk-family"', 'class="ovk-tool-folds"', 'People also use']);
++ assert.ok(html.includes('href="https://dev.openvibe.tools/">Code and data'), 'crumb is the code and data family');
++ assert.ok(html.includes('data-ovk-try='), 'the code tool has Try examples');
++ assert.ok(html.includes('>{&quot;a&quot;:1}</textarea>'), '?q= fills the editor');
++ assert.ok(html.includes('aria-current="page"') && /aria-current="page"><span class="ovk-family-short">Validator<\/span>/.test(html), 'the family marks the current tool');
++ assert.ok(html.includes('All 39'), 'the family links all 39 code and data tools');
++ assert.ok(html.includes('https://openvibe.tools/api/v1/tools/jsonvalidate/run') && html.includes('<code>text</code>'), 'the API fold has the real call and field');
++ assert.ok(html.includes('About JSON Validator') && html.includes('Questions'), 'the folds carry the copy');
++ assert.ok(html.includes('<title>JSON Validator — JSON Lint Online | OpenVibe</title>'), 'the page title');
++ assert.ok(html.includes('<link rel="canonical" href="https://jsonvalidate.openvibe.tools">'), 'the canonical');
++ assert.strictEqual((html.match(/<h1/g) || []).length, 1, 'one h1');
++ assert.ok(!html.includes('id="hero"'), 'the old hero is gone');
++}
++
++// ── ?q= fills the box and runs exactly once (both pages' own functions) ──
++{
++ const html = fs.readFileSync(path.join(PUB, 'net.html'), 'utf8');
++ const from = html.indexOf('function queryValue(');
++ const to = html.indexOf('function setupUI()', from);
++ assert.ok(from > 0 && to > from, 'net.html keeps queryValue/runFromQuery together');
++ const scope = new Function('location', 'document', 'runTool', `'use strict';\n${html.slice(from, to)}\nreturn { queryValue, runFromQuery };`);
++ const input = { value: '' };
++ let calls = 0;
++ const doc = { getElementById: (id) => (id === 'search-input' ? input : null) };
++ for (const search of ['?q=openvibe.network', '?target=dns.google', '?domain=example.com', '?ip=1.1.1.1']) {
++ input.value = '';
++ calls = 0;
++ const api = scope({ search }, doc, () => { calls++; });
++ assert.strictEqual(api.runFromQuery(), true, `runs for ${search}`);
++ assert.strictEqual(calls, 1, `runs once for ${search}`);
++ assert.ok(input.value.length > 0, `fills the box for ${search}`);
++ }
++ assert.strictEqual(scope({ search: '' }, doc, () => { calls++; }).runFromQuery(), false, 'no ?q=, no run');
++}
++{
++ const html = fs.readFileSync(path.join(PUB, 'dev.html'), 'utf8');
++ const from = html.indexOf('function devRunFromQuery(');
++ const to = html.indexOf('\n}', from) + 2;
++ assert.ok(from > 0 && to > from, 'dev.html has devRunFromQuery');
++ const input = { value: '' }, second = { value: '' };
++ const run = () => { calls++; };
++ let calls = 0;
++ const scope = new Function('location', '$', 'run', `'use strict';\n${html.slice(from, to)}\nreturn devRunFromQuery;`);
++ const got = scope({ search: '?q=%7B%22a%22%3A1%7D&q2=b' }, (id) => (id === 'in1' ? input : id === 'in2' ? second : null), run);
++ assert.strictEqual(got(), true, 'dev ?q= runs');
++ assert.strictEqual(calls, 1, 'dev ?q= runs once');
++ assert.strictEqual(input.value, '{"a":1}', 'dev fills the first box');
++ assert.strictEqual(second.value, 'b', 'dev fills the second box');
++}
++
++// ── Every framed tool has the pieces; examples exist for every input tool ──
++{
++ let counted = 0;
++ for (const rec of CATALOG.values()) {
++ if (rec.hub || (rec.family !== 'net' && rec.family !== 'dev')) continue;
++ const html = frameFor(rec);
++ for (const needle of ['class="ovk-tool-head"', 'class="ovk-tool-card"', 'class="ovk-tool-out"', 'class="ovk-family"', 'class="ovk-tool-folds"', 'Use it from code']) {
++ assert.ok(html.includes(needle), `${rec.sub}: ${needle}`);
++ }
++ assert.match(html, /aria-current="page"/, `${rec.sub}: the current tool is marked`);
++ counted++;
++ const inputs = rec.sub !== 'myip' && !['uuid', 'lorem', 'timestamp', 'webhook'].includes(rec.sub);
++ if (inputs) assert.ok((rec.examples || []).length >= 2, `${rec.sub}: 2–3 Try examples`);
++ }
++ assert.strictEqual(counted, 77, 'every network and code and data tool page is framed');
++}
++
++console.log('tool frame: whois and jsonvalidate in order, Try links, ?q= once, current family tool, real API call: all checks passed');
ddiff --git a/apps/img/package-lock.json b/apps/img/package-lock.json
iindex ee82cd3..16267d9 100644
---- a/apps/img/package-lock.json
++++ b/apps/img/package-lock.json
@@@ -21,7 +21,7 @@
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.43.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.56.0",
"pg": "^8.23.0",
"sharp": "^0.33.5",
"to-ico": "^1.1.5",
@@@ -2264,9 +2264,9 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "4.43.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.43.0",
-- "integrity": "sha512-OANEzCEB2QPgbkb9Yt0O67k6DxcK2pHMO1nhKYCjTI7OhUF8LW8EpLf6FJmpHmDTmpFTUq4pJ3lr3u2hPXMDYQ==",
++ "version": "4.56.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.56.0",
++ "integrity": "sha512-6Whnuat9QQmKLt7ncQ+xtmwAAZfRUUIouLb/cFlSm6SvL5DfXkpsb44nscAqkx2vUIpDFLtT0nnD37jNCP+Cxg==",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0",
ddiff --git a/apps/img/package.json b/apps/img/package.json
iindex f331e80..baf1c8f 100644
---- a/apps/img/package.json
++++ b/apps/img/package.json
@@@ -24,7 +24,7 @@
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.54.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.56.0",
"sharp": "^0.33.5",
"to-ico": "^1.1.5",
"uuid": "^11.1.0"
ddiff --git a/apps/maps/package-lock.json b/apps/maps/package-lock.json
iindex 4db650f..aef9599 100644
---- a/apps/maps/package-lock.json
++++ b/apps/maps/package-lock.json
@@@ -21,7 +21,7 @@
"iovalkey": "^0.4.0",
"node-cache": "^5.1.2",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.43.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.56.0",
"pg": "^8.23.0"
}
},
@@@ -1446,9 +1446,9 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "4.43.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.43.0",
-- "integrity": "sha512-OANEzCEB2QPgbkb9Yt0O67k6DxcK2pHMO1nhKYCjTI7OhUF8LW8EpLf6FJmpHmDTmpFTUq4pJ3lr3u2hPXMDYQ==",
++ "version": "4.56.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.56.0",
++ "integrity": "sha512-6Whnuat9QQmKLt7ncQ+xtmwAAZfRUUIouLb/cFlSm6SvL5DfXkpsb44nscAqkx2vUIpDFLtT0nnD37jNCP+Cxg==",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0",
ddiff --git a/apps/maps/package.json b/apps/maps/package.json
iindex e7a93f5..0ede415 100644
---- a/apps/maps/package.json
++++ b/apps/maps/package.json
@@@ -15,7 +15,7 @@
"pg": "^8.23.0",
"@electric-sql/pglite": "^0.5.8",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.54.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.56.0",
"cheerio": "^1.0.0-rc.12",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
ddiff --git a/apps/text/package-lock.json b/apps/text/package-lock.json
iindex 35b9768..b663396 100644
---- a/apps/text/package-lock.json
++++ b/apps/text/package-lock.json
@@@ -18,7 +18,7 @@
"helmet": "^7.1.0",
"iovalkey": "^0.4.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.43.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.56.0",
"pg": "^8.23.0"
}
},
@@@ -1130,9 +1130,9 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "4.43.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.43.0",
-- "integrity": "sha512-OANEzCEB2QPgbkb9Yt0O67k6DxcK2pHMO1nhKYCjTI7OhUF8LW8EpLf6FJmpHmDTmpFTUq4pJ3lr3u2hPXMDYQ==",
++ "version": "4.56.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.56.0",
++ "integrity": "sha512-6Whnuat9QQmKLt7ncQ+xtmwAAZfRUUIouLb/cFlSm6SvL5DfXkpsb44nscAqkx2vUIpDFLtT0nnD37jNCP+Cxg==",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0",
ddiff --git a/apps/text/package.json b/apps/text/package.json
iindex 3065e39..1c20bf9 100644
---- a/apps/text/package.json
++++ b/apps/text/package.json
@@@ -20,7 +20,7 @@
"express-rate-limit": "^7.1.5",
"dotenv": "^16.3.1",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.54.0"
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.56.0"
},
"license": "MIT"
}
ddiff --git a/apps/yt/package-lock.json b/apps/yt/package-lock.json
iindex eed31bc..949d26c 100644
---- a/apps/yt/package-lock.json
++++ b/apps/yt/package-lock.json
@@@ -19,7 +19,7 @@
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.2",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.43.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.56.0",
"pg": "^8.23.0",
"uuid": "^11.1.0"
}
@@@ -1005,9 +1005,9 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "4.43.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.43.0",
-- "integrity": "sha512-OANEzCEB2QPgbkb9Yt0O67k6DxcK2pHMO1nhKYCjTI7OhUF8LW8EpLf6FJmpHmDTmpFTUq4pJ3lr3u2hPXMDYQ==",
++ "version": "4.56.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.56.0",
++ "integrity": "sha512-6Whnuat9QQmKLt7ncQ+xtmwAAZfRUUIouLb/cFlSm6SvL5DfXkpsb44nscAqkx2vUIpDFLtT0nnD37jNCP+Cxg==",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0",
ddiff --git a/apps/yt/package.json b/apps/yt/package.json
iindex c1c4ba0..f13b688 100644
---- a/apps/yt/package.json
++++ b/apps/yt/package.json
@@@ -21,7 +21,7 @@
"pg": "^8.23.0",
"@electric-sql/pglite": "^0.5.8",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.54.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.56.0",
"jsonwebtoken": "^9.0.2",
"uuid": "^11.1.0"
}
ddiff --git a/apps/yt/test/limits.test.js b/apps/yt/test/limits.test.js
iindex 9a10dd2..58a8e5f 100644
---- a/apps/yt/test/limits.test.js
++++ b/apps/yt/test/limits.test.js
@@@ -69,6 +69,9 @@ if (args.includes('--dump-json')) {
process.exit(0);
}
const i = args.indexOf('--match-filter');
++// The app's upstream probe (--simulate) is not a download and must not write args.json. It runs five
++// seconds after boot, so a probe landing before the 'no download was started' check made this flaky.
++if (i < 0) { process.stdout.write('jNQXAC9IVRw\\n'); process.exit(0); }
require('fs').writeFileSync(${JSON.stringify(path.join(data, 'args.json'))}, JSON.stringify(args));
process.stdout.write('OVD|' + (long ? 4000 : 30) + '|False\\n');
process.exit(0); // the match filter skipped it: no file