openvibers/openvibe.tools Commits
Twelve single-purpose tools (JS/CSS/HTML/XML/JSON minifiers, JS/CSS beautifiers, JSON parser/validator/stringify, HTML↔Markdown) on descriptive + short hosts; tool id resolved server-side for any host
Changes
@@@ -171,6 +171,18 @@ entities:{n:'String Escape Tool',i:'fa-shield-halved',c:'enc',d:'HTML, JS & URLtimestamp:{n:'Unix Timestamp Converter',i:'fa-clock',c:'time',d:'Unix timestamp \u2194 date converter',p:'',m:['Now','To Date','To Unix']},cron:{n:'Cron Expression Parser',i:'fa-calendar-check',c:'time',d:'Cron expression parser & next runs',p:'*/15 9-17 * * 1-5',m:['Parse']},beautify:{n:'Code Beautifier',i:'fa-wand-magic-sparkles',c:'qual',d:'Beautify JS, CSS, HTML & JSON',p:'{"a":1,"b":[2,3],"c":{"d":true}}',m:['JSON','HTML','CSS']},++jsminify:{n:"JavaScript Minifier",i:'fa-compress',c:'qual',d:"Shrink JavaScript with a real parser, not regex",p:"function greet(name) {\n // say hello\n const message = \"Hello, \" + name + \"!\";\n console.log(message);\n return message;\n}\ngreet(\"OpenVibe\");",m:["Minify", "Minify + mangle", "Whitespace only"]},++jsformat:{n:"JavaScript Beautifier",i:'fa-wand-magic-sparkles',c:'qual',d:"Turn minified or messy JavaScript into readable code",p:"function greet(n){const m=\"Hello, \"+n+\"!\";console.log(m);return m}greet(\"OpenVibe\");",m:["Beautify"]},++cssminify:{n:"CSS Minifier",i:'fa-compress',c:'qual',d:"Strip comments and whitespace from stylesheets",p:"/* card */\n.card {\n color: #ffffff;\n margin: 0px 0px 10px 0px;\n padding: 12px;\n}\n\n.card:hover {\n color: #3b82f6;\n}",m:["Minify"]},++cssformat:{n:"CSS Beautifier",i:'fa-wand-magic-sparkles',c:'qual',d:"Re-indent minified or messy CSS",p:".card{color:#fff;margin:0 0 10px;padding:12px}.card:hover{color:#3b82f6}@media (max-width:600px){.card{padding:8px}}",m:["Beautify"]},++htmlminify:{n:"HTML Minifier",i:'fa-compress',c:'qual',d:"Remove comments and whitespace from HTML",p:"<!-- header -->\n<div class=\"card\">\n <p>Hello <strong>World</strong></p>\n</div>",m:["Minify"]},++xmlminify:{n:"XML Minifier",i:'fa-compress',c:'qual',d:"Collapse XML to a single compact line",p:"<?xml version=\"1.0\"?>\n<!-- catalog -->\n<catalog>\n <item id=\"1\">\n <name>OpenVibe</name>\n </item>\n</catalog>",m:["Minify"]},++jsonminify:{n:"JSON Minifier",i:'fa-compress',c:'data',d:"Compact JSON to the smallest valid form",p:"{\n \"name\": \"openvibe\",\n \"version\": 1,\n \"tags\": [\"open\", \"source\"]\n}",m:["Minify"]},++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"]},++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']},regex:{n:'Regex Tester',i:'fa-magnifying-glass',c:'qual',d:'Regex tester with match highlighting',p:'\\b[A-Z][a-z]+\\b',p2:'Hello World from OpenVibe.Tools today',dual:1,m:['Test']},@@@ -184,7 +196,8 @@ opengraph:{n:'Open Graph Preview',i:'fa-share-nodes',c:'fe',d:'Open Graph & Twitconst CATS={data:{l:'Data & Formats',i:'fa-database'},enc:{l:'Encoding & Crypto',i:'fa-lock'},time:{l:'Time & Scheduling',i:'fa-clock'},qual:{l:'Code Quality',i:'fa-wand-magic-sparkles'},http:{l:'HTTP & API',i:'fa-terminal'},fe:{l:'Frontend & SEO',i:'fa-palette'}};const host=location.hostname,sub=host.replace(/\.openvibe\.tools$/,'');--let ACT=T[sub]?sub:'dev',MODE='';++const META=(document.querySelector('meta[name="ov-tool"]')||{}).content||'';++let ACT=T[META]?META:(T[sub]?sub:'dev'),MODE='';document.addEventListener('DOMContentLoaded',()=>{const tok=localStorage.getItem('ov_token');@@@ -361,6 +374,65 @@ P.beautify=(s,m)=>{let f='',ind=0;s.replace(/>\s*</g,'><').split(/(<[^>]+>)/g).filter(Boolean).forEach(n=>{if(n.match(/^<\//))ind--;f+=' '.repeat(Math.max(0,ind))+n.trim()+'\n';if(n.match(/^<[^\/!?]/)&&!n.match(/\/>/))ind++});return f.trim();};++// ── Single-purpose tools ─────────────────────────────────────++const loadScript=(src)=>new Promise((ok,no)=>{const e=document.createElement('script');e.src=src;e.onload=ok;e.onerror=()=>no(new Error('Could not load the minifier. Check your connection and try again.'));document.head.appendChild(e)});++const terser=async()=>{if(!window.Terser)await loadScript('https://cdn.jsdelivr.net/npm/terser@5/dist/bundle.min.js');return window.Terser};++const saved=(a,b)=>{const x=new Blob([a]).size,y=new Blob([b]).size;return x?`${x.toLocaleString()} → ${y.toLocaleString()} bytes (${Math.max(0,Math.round((1-y/x)*100))}% smaller)`:''};++const withNote=(code,note)=>({html:(note?'<div style="color:var(--tx2);font-size:.8rem;margin-bottom:8px">'+H(note)+'</div>':'')+'<pre style="margin:0;white-space:pre-wrap;word-break:break-word">'+H(code)+'</pre>'});++P.jsminify=async(s,m)=>{const T=await terser();const r=await T.minify(s,m==='Whitespace only'?{compress:false,mangle:false}:{compress:true,mangle:m==='Minify + mangle'});return withNote(r.code||'',saved(s,r.code||''))};++P.jsformat=async(s)=>{const T=await terser();const r=await T.minify(s,{compress:false,mangle:false,format:{beautify:true,comments:'all',indent_level:2}});return r.code||''};++const cssMin=(s)=>{const keep=[];s=s.replace(/("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|url\([^)]*\))/g,(m)=>{keep.push(m);return'\u0000'+(keep.length-1)+'\u0000'});++ s=s.replace(/\/\*[\s\S]*?\*\//g,'').replace(/\s+/g,' ').replace(/\s*([{}:;,>+~])\s*/g,'$1').replace(/;}/g,'}').replace(/(^|[\s:,(])0(?:px|em|rem|%|pt|vh|vw)(?=[\s;,)}]|$)/g,'$10').replace(/#([0-9a-f])\1([0-9a-f])\2([0-9a-f])\3\b/gi,'#$1$2$3').trim();++ return s.replace(/\u0000(\d+)\u0000/g,(_,i)=>keep[+i])};++P.cssminify=(s)=>{const o=cssMin(s);return withNote(o,saved(s,o))};++P.cssformat=(s)=>{let out='',depth=0;const pad=()=>' '.repeat(depth);const min=cssMin(s);let buf='';++ for(let i=0;i<min.length;i++){const c=min[i];++ if(c==='{'){out+=pad()+buf.trim()+' {\n';buf='';depth++}++ else if(c==='}'){if(buf.trim())out+=pad()+buf.trim().replace(/:(?!\/\/)/,': ')+';\n';buf='';depth=Math.max(0,depth-1);out+=pad()+'}\n'+(depth?'':'\n')}++ else if(c===';'){out+=pad()+buf.trim().replace(/:(?!\/\/)/,': ')+';\n';buf=''}++ else buf+=c}++ return out.trim()};++P.htmlminify=(s)=>{const keep=[];let o=s.replace(/<(pre|textarea|script|style)\b[\s\S]*?<\/\1>/gi,(m)=>{keep.push(m);return'\u0000'+(keep.length-1)+'\u0000'});++ o=o.replace(/<!--(?!\[if)[\s\S]*?-->/g,'').replace(/\s+/g,' ').replace(/>\s+</g,'> <').replace(/>\s<(\/?(?:html|head|body|div|p|ul|ol|li|table|tr|td|th|thead|tbody|section|article|header|footer|nav|main|h[1-6]|meta|link|title|br|hr)\b)/gi,'><$1').trim();++ o=o.replace(/\u0000(\d+)\u0000/g,(_,i)=>keep[+i]);return withNote(o,saved(s,o))};++P.xmlminify=(s)=>{const d=new DOMParser().parseFromString(s,'application/xml');const er=d.querySelector('parsererror');if(er)throw new Error(er.textContent.split('\n')[0]);++ 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))};++const jsonWhere=(s,e)=>{const m=/position (\d+)/.exec(e.message);if(!m)return e.message;const pos=+m[1],pre=s.slice(0,pos),line=pre.split('\n').length,col=pos-pre.lastIndexOf('\n');++ const near=s.slice(Math.max(0,pos-20),pos+1);let hint='';if(/,\s*[}\]]$/.test(s.slice(0,pos+1).replace(/\s+$/,''))||/,\s*$/.test(pre))hint=' A trailing comma is not allowed in JSON.';else if(/'/.test(near))hint=' JSON strings and keys need double quotes.';else if(/\/\/|\/\*/.test(near))hint=' JSON has no comments.';else if(/[{,]\s*[A-Za-z_]\w*\s*:?$/.test(pre+s[pos]))hint=' Keys must be in double quotes.';++ return `Line ${line}, column ${col}: ${e.message.replace(/ in JSON at position \d+.*/,'')}.${hint}`};++const jsonIn=(s)=>{try{return JSON.parse(s)}catch(e){throw new Error(jsonWhere(s,e))}};++const kind=(v)=>v===null?'null':Array.isArray(v)?'array':typeof v;++P.jsonminify=(s)=>{const o=JSON.stringify(jsonIn(s));return withNote(o,saved(s,o))};++P.jsonvalidate=(s)=>{const v=jsonIn(s);let n=0,depth=0;(function walk(x,d){n++;depth=Math.max(depth,d);if(x&&typeof x==='object')for(const k in x)walk(x[k],d+1)})(v,0);++ return'\u2713 Valid JSON\n\nTop level: '+kind(v)+(Array.isArray(v)?' with '+v.length+' items':v&&typeof v==='object'?' with '+Object.keys(v).length+' keys':'')+'\nValues: '+n.toLocaleString()+'\nDeepest nesting: '+depth};++P.jsonparse=(s,m)=>{const v=jsonIn(s);if(m==='Tree')return JSON.stringify(v,null,2);const rows=[];++ (function walk(x,p){if(x&&typeof x==='object'){if(m==='Types')rows.push((p||'$')+' '+kind(x));const ks=Array.isArray(x)?x.map((_,i)=>i):Object.keys(x);if(!ks.length&&m!=='Types')rows.push((p||'$')+' = '+JSON.stringify(x));++ for(const k of ks)walk(x[k],Array.isArray(x)?p+'['+k+']':(/^[A-Za-z_$][\w$]*$/.test(k)?(p?p+'.':'')+k:p+'['+JSON.stringify(k)+']'))}++ else rows.push((p||'$')+(m==='Types'?' '+kind(x):' = '+JSON.stringify(x)))})(v,'');++ return rows.join('\n')};++P.jsonstringify=(s,m)=>{if(m==='Parse string'){let t=s.trim();const inner=JSON.parse(/^"/.test(t)?t:'"'+t.replace(/"/g,'\\"')+'"');return JSON.stringify(jsonIn(inner),null,2)}return JSON.stringify(JSON.stringify(jsonIn(s)))};++P.html2md=(s)=>{const doc=new DOMParser().parseFromString(s,'text/html');const esc=(t)=>t.replace(/([\\`*_{}\[\]#|])/g,'\\$1');++ const inline=(n)=>[...n.childNodes].map(node).join('');++ const list=(n,ord,depth)=>[...n.children].filter(c=>c.tagName==='LI').map((li,i)=>' '.repeat(depth)+(ord?(i+1)+'. ':'- ')+[...li.childNodes].map(c=>c.nodeType===1&&/^(UL|OL)$/.test(c.tagName)?'\n'+list(c,c.tagName==='OL',depth+1):node(c)).join('').trim()).join('\n');++ function node(n){if(n.nodeType===3)return esc(n.nodeValue.replace(/\s+/g,' '));if(n.nodeType!==1)return'';const t=n.tagName;++ if(/^(SCRIPT|STYLE|NOSCRIPT|TEMPLATE|IFRAME|FORM|INPUT|BUTTON|SELECT|TEXTAREA|SVG|HEAD)$/.test(t))return'';++ if(/^H[1-6]$/.test(t))return'\n\n'+'#'.repeat(+t[1])+' '+inline(n).trim()+'\n\n';++ if(t==='P'||t==='DIV'||t==='SECTION'||t==='ARTICLE'||t==='HEADER'||t==='FOOTER'||t==='MAIN')return'\n\n'+inline(n).trim()+'\n\n';++ if(t==='BR')return' \n';if(t==='HR')return'\n\n---\n\n';++ if(t==='STRONG'||t==='B'){const x=inline(n).trim();return x?'**'+x+'**':''}if(t==='EM'||t==='I'){const x=inline(n).trim();return x?'*'+x+'*':''}++ if(t==='DEL'||t==='S')return'~~'+inline(n).trim()+'~~';++ if(t==='CODE'&&n.parentNode.tagName!=='PRE')return'`'+n.textContent+'`';++ if(t==='PRE'){const c=n.querySelector('code');const lang=c&&(/language-([\w-]+)/.exec(c.className)||[])[1]||'';return'\n\n```'+lang+'\n'+n.textContent.replace(/\n$/,'')+'\n```\n\n'}++ if(t==='A'){const h=n.getAttribute('href')||'';const x=inline(n).trim();return h&&!/^javascript:/i.test(h)?'['+(x||h)+']('+h+')':x}++ if(t==='IMG'){const src=n.getAttribute('src')||'';return src?'':''}++ if(t==='UL'||t==='OL')return'\n\n'+list(n,t==='OL',0)+'\n\n';++ if(t==='BLOCKQUOTE')return'\n\n'+inline(n).trim().split('\n').map(l=>'> '+l).join('\n')+'\n\n';++ if(t==='TABLE'){const rows=[...n.querySelectorAll('tr')].map(r=>[...r.children].map(c=>inline(c).trim().replace(/\|/g,'\\|').replace(/\n+/g,' ')));if(!rows.length)return'';const w=Math.max(...rows.map(r=>r.length));const line=(r)=>'| '+Array.from({length:w},(_,i)=>r[i]||'').join(' | ')+' |';return'\n\n'+line(rows[0])+'\n|'+' --- |'.repeat(w)+'\n'+rows.slice(1).map(line).join('\n')+'\n\n'}++ return inline(n)}++ return node(doc.body).replace(/[ \t]+\n/g,(m)=>m.startsWith(' ')?' \n':'\n').replace(/\n{3,}/g,'\n\n').trim()};++P.md2html=(s,m)=>{let h=P.md(s,'To HTML').replace(/ style="[^"]*"/g,'').replace(/((?:<li>.*<\/li>\n?)+)/g,'<ul>\n$1</ul>\n');++ h=h.split(/\n{2,}/).map(b=>/^\s*<(h\d|ul|ol|li|blockquote|hr|pre|table)/.test(b)?b:'<p>'+b.trim().replace(/\n/g,'<br>\n')+'</p>').join('\n');++ return m==='Preview'?{html:'<div style="line-height:1.8">'+h+'</div>'}:h};P.minify=(s,m)=>{if(m==='JSON')return JSON.stringify(JSON.parse(s));if(m==='CSS')return s.replace(/\/\*[\s\S]*?\*\//g,'').replace(/\s+/g,' ').replace(/\s*([{}:;,])\s*/g,'$1').trim();if(m==='HTML')return s.replace(/\s+/g,' ').replace(/>\s+</g,'><').trim();return s.replace(/\/\/.*$/gm,'').replace(/\/\*[\s\S]*?\*\//g,'').replace(/\s+/g,' ').trim()};@@@ -55,6 +55,20 @@ const DEV_TOOLS = [// Frontend & SEO{ id: 'color', subdomain: 'color', name: 'OpenVibeColor', icon: 'fa-palette', desc: 'Color picker, HEX/RGB/HSL converter & palette generator', category: 'frontend' },{ id: 'opengraph', subdomain: 'opengraph', name: 'OpenVibeOpenGraph', icon: 'fa-share-nodes', desc: 'Open Graph & Twitter Card preview & validator', category: 'frontend' },++++ // Single-purpose tools: one job per address (search engines and people both look for the exact job).++ { id: 'jsminify', subdomain: 'jsminify', name: "JavaScript Minifier", icon: 'fa-compress', desc: "Shrink JavaScript with a real parser, not regex", category: 'quality' },++ { id: 'jsformat', subdomain: 'jsformat', name: "JavaScript Beautifier", icon: 'fa-wand-magic-sparkles', desc: "Turn minified or messy JavaScript into readable code", category: 'quality' },++ { id: 'cssminify', subdomain: 'cssminify', name: "CSS Minifier", icon: 'fa-compress', desc: "Strip comments and whitespace from stylesheets", category: 'quality' },++ { id: 'cssformat', subdomain: 'cssformat', name: "CSS Beautifier", icon: 'fa-wand-magic-sparkles', desc: "Re-indent minified or messy CSS", category: 'quality' },++ { id: 'htmlminify', subdomain: 'htmlminify', name: "HTML Minifier", icon: 'fa-compress', desc: "Remove comments and whitespace from HTML", category: 'quality' },++ { id: 'xmlminify', subdomain: 'xmlminify', name: "XML Minifier", icon: 'fa-compress', desc: "Collapse XML to a single compact line", category: 'quality' },++ { id: 'jsonminify', subdomain: 'jsonminify', name: "JSON Minifier", icon: 'fa-compress', desc: "Compact JSON to the smallest valid form", category: 'data' },++ { id: 'jsonparse', subdomain: 'jsonparse', name: "JSON Parser", icon: 'fa-sitemap', desc: "Explore JSON as a tree, a list of paths or a type map", category: 'data' },++ { id: 'jsonvalidate', subdomain: 'jsonvalidate', name: "JSON Validator", icon: 'fa-circle-check', desc: "Check JSON and find the exact error", category: 'data' },++ { id: 'jsonstringify', subdomain: 'jsonstringify', name: "JSON Stringify", icon: 'fa-quote-right', desc: "Turn JSON into an escaped string literal, and back", category: 'data' },++ { id: 'html2md', subdomain: 'html2md', name: "HTML to Markdown", icon: 'fa-right-left', desc: "Convert HTML into clean Markdown", category: 'data' },++ { id: 'md2html', subdomain: 'md2html', name: "Markdown to HTML", icon: 'fa-right-left', desc: "Convert Markdown into HTML you can paste anywhere", category: 'data' },];for (const t of DEV_TOOLS) { const seo = seoFor(t.subdomain); if (seo) { t.name = seo.name; t.desc = seo.desc; t.seo = seo; } }@@@ -178,7 +178,7 @@ app.use('/api/dev', rateLimit({ windowMs: 60_000, max: 60 }), createDevRoutes(nu// ── Host-header subdomain routing ────────────────────────────function subdomainOf(req) {// A mirror or custom domain is the tool it points at.-- if (req.ovHost && req.ovHost.tool && !getRequestHost(req).endsWith('.openvibe.tools')) return req.ovHost.tool;++ if (req.ovHost && req.ovHost.tool) return req.ovHost.tool; // descriptive hosts, mirrors and custom domains all resolve to a tool idreturn getRequestHost(req).replace(/\.openvibe\.tools$/, '');}@@@ -21,6 +21,18 @@ const HOST_RE = /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63// Human-first defaults: the descriptive host is canonical, the one people already type is short.const HOST_DEFAULTS = {++ jsminify: { canonical: 'js-minifier', short: 'jsminify', aliases: ["minifyjs", "javascript-minifier", "jsmin"] },++ jsformat: { canonical: 'js-beautifier', short: 'jsformat', aliases: ["jsbeautify", "javascript-beautifier", "unminify"] },++ cssminify: { canonical: 'css-minifier', short: 'cssminify', aliases: ["minifycss", "cssmin", "css-compressor"] },++ cssformat: { canonical: 'css-beautifier', short: 'cssformat', aliases: ["cssbeautify", "css-formatter", "unminifycss"] },++ htmlminify: { canonical: 'html-minifier', short: 'htmlminify', aliases: ["minifyhtml", "htmlmin", "html-compressor"] },++ xmlminify: { canonical: 'xml-minifier', short: 'xmlminify', aliases: ["minifyxml", "xmlmin"] },++ jsonminify: { canonical: 'json-minifier', short: 'jsonminify', aliases: ["minifyjson", "jsonmin", "json-compressor"] },++ jsonparse: { canonical: 'json-parser', short: 'jsonparse', aliases: ["jsonviewer", "json-viewer", "jsontree", "parsejson"] },++ jsonvalidate: { canonical: 'json-validator', short: 'jsonvalidate', aliases: ["jsonlint", "validatejson", "json-lint", "jsoncheck"] },++ jsonstringify: { canonical: 'json-stringify', short: 'jsonstringify', aliases: ["jsonescape", "json-escape", "jsonunescape", "stringifyjson"] },++ html2md: { canonical: 'html-to-markdown', short: 'html2md', aliases: ["htmltomarkdown", "html2markdown", "htmltomd"] },++ md2html: { canonical: 'markdown-to-html', short: 'md2html', aliases: ["markdowntohtml", "md2htm", "mdtohtml"] },yt: { canonical: 'youtube-downloader', short: 'yt', aliases: ['youtube', 'ytdl', 'youtubedownloader', 'youtube-download'] },};// The same tool built twice (once in Text, once in Developer Tools). People see ONE entry, under the@@@ -238,6 +238,102 @@ const DEV = {bullets: ['Live preview of the social card for any public URL', 'Lists Open Graph, Twitter Card and standard meta tags', 'Flags missing titles, descriptions and images'],faq: [['Why does my link preview show no image?', 'Usually a missing og:image tag, or an image URL that is relative rather than absolute. Social platforms need a full https URL.'], ['Why has my preview not updated after I fixed the tags?', 'Platforms cache previews aggressively. Most offer a debugger that forces a re-scrape; this tool always fetches live so you can confirm the tags themselves are correct.']],},++ jsminify: {++ name: "JavaScript Minifier", title: "JavaScript Minifier \u2014 Minify JS Online",++ desc: "Shrink JavaScript with a real parser, not regex. Paste JavaScript and get a smaller file that behaves the same.",++ kw: "js minifier, minify javascript, javascript minifier online, js compressor, uglify js, terser online, minify js code",++ about: "Paste JavaScript and get a smaller file that behaves the same. The minifier parses your code properly (it uses Terser), so it can remove dead code, shorten expressions and rename local variables safely, which regex-based minifiers cannot do. Everything runs in your browser; your code is not uploaded.",++ bullets: ["Three levels: whitespace only, full minify, or minify with variable mangling", "Understands modern syntax: modules, classes, async, optional chaining", "Runs locally in your browser, so private code stays private"],++ faq: [["Will minified code still work?", "Yes. Minifying removes whitespace and comments and rewrites expressions into shorter equivalents. Mangling renames local variables only, never globals or property names, so the behaviour stays the same."], ["Is my code uploaded?", "No. The minifier is loaded into your browser and runs there."]],++ },++ jsformat: {++ name: "JavaScript Beautifier", title: "JavaScript Beautifier \u2014 Format & Unminify JS",++ desc: "Turn minified or messy JavaScript into readable code. Paste minified, bundled or badly indented JavaScript and get it back with consistent indentation and line breaks, so you can read a production bundle or clean up a snippet before sharing it.",++ kw: "js beautifier, javascript formatter, unminify js, format javascript online, pretty print js, js prettifier",++ about: "Paste minified, bundled or badly indented JavaScript and get it back with consistent indentation and line breaks, so you can read a production bundle or clean up a snippet before sharing it. It parses the code rather than guessing, and runs in your browser.",++ bullets: ["Re-indents and re-breaks code from a real syntax tree", "Good for reading minified bundles and third-party scripts", "Nothing is uploaded"],++ faq: [["Can it restore original variable names?", "No. Names shortened by a minifier are gone unless you have a source map. Beautifying restores layout, not names."]],++ },++ cssminify: {++ name: "CSS Minifier", title: "CSS Minifier \u2014 Minify CSS Online",++ desc: "Strip comments and whitespace from stylesheets. Paste a stylesheet and get a compact version for production: comments and whitespace removed, trailing semicolons dropped, zero units and long hex colours shortened.",++ kw: "css minifier, minify css, css compressor online, compress css, minify stylesheet",++ about: "Paste a stylesheet and get a compact version for production: comments and whitespace removed, trailing semicolons dropped, zero units and long hex colours shortened. Strings and url() values are left untouched.",++ bullets: ["Removes comments, whitespace and redundant semicolons", "Shortens #ffffff to #fff and 0px to 0", "Leaves strings and url() values alone"],++ faq: [["Does it change how my CSS works?", "No. It only removes characters the browser ignores and rewrites values into shorter forms that mean the same thing."]],++ },++ cssformat: {++ name: "CSS Beautifier", title: "CSS Beautifier \u2014 Format & Unminify CSS",++ desc: "Re-indent minified or messy CSS. Turn a one-line or inconsistently formatted stylesheet into readable CSS with one declaration per line and nested indentation for media queries and other at-rules..",++ kw: "css beautifier, css formatter, unminify css, format css online, pretty print css",++ about: "Turn a one-line or inconsistently formatted stylesheet into readable CSS with one declaration per line and nested indentation for media queries and other at-rules.",++ bullets: ["One declaration per line, two-space indentation", "Handles nested at-rules such as @media and @supports", "Runs in your browser"],++ faq: [["Does it reorder my rules?", "No. Order matters in CSS, so rules and declarations stay exactly where they were."]],++ },++ htmlminify: {++ name: "HTML Minifier", title: "HTML Minifier \u2014 Minify HTML Online",++ desc: "Remove comments and whitespace from HTML. Make an HTML document smaller by removing comments and the whitespace between tags.",++ kw: "html minifier, minify html, html compressor online, compress html, minify web page",++ about: "Make an HTML document smaller by removing comments and the whitespace between tags. Useful for email templates, embedded snippets and static pages where every kilobyte is sent on each request.",++ bullets: ["Removes comments and inter-tag whitespace", "Keeps the content of pre, textarea, script and style intact", "Runs in your browser"],++ faq: [["Can minifying break my layout?", "Whitespace between inline elements can be meaningful. The minifier collapses it to a single space rather than removing it, which keeps inline text spacing correct."]],++ },++ xmlminify: {++ name: "XML Minifier", title: "XML Minifier \u2014 Minify XML Online",++ desc: "Collapse XML to a single compact line. Strip comments and the whitespace between elements from XML, SVG, RSS and sitemap files.",++ kw: "xml minifier, minify xml, compress xml online, xml compressor, minify svg",++ about: "Strip comments and the whitespace between elements from XML, SVG, RSS and sitemap files. Text inside elements and CDATA sections is preserved.",++ bullets: ["Removes comments and whitespace between tags", "Keeps text content and CDATA untouched", "Works for SVG, RSS, Atom and sitemaps"],++ faq: [["Is the result still valid XML?", "Yes. Only insignificant whitespace and comments are removed, and the document is checked for well-formedness first."]],++ },++ jsonminify: {++ name: "JSON Minifier", title: "JSON Minifier \u2014 Minify & Compact JSON Online",++ desc: "Compact JSON to the smallest valid form. Remove every unnecessary space and line break from a JSON document.",++ kw: "json minifier, minify json, compact json, json compressor online, json minify",++ about: "Remove every unnecessary space and line break from a JSON document. The input is parsed first, so you also find out if it is invalid, and the output is guaranteed to be valid JSON.",++ bullets: ["Validates while it minifies", "Reports how many bytes were saved", "Runs in your browser; the data is not uploaded"],++ faq: [["Does minifying change the data?", "No. Only whitespace outside strings is removed. Keys, values and their order stay the same."]],++ },++ jsonparse: {++ name: "JSON Parser", title: "JSON Parser & Viewer \u2014 Explore JSON Online",++ desc: "Explore JSON as a tree, a list of paths or a type map. Paste JSON and see what is actually in it: an indented tree, a flat list of every path with its value (handy for writing jq filters or accessing nested fields in code), or a map of the types at each path..",++ kw: "json parser, json viewer, json tree viewer, parse json online, json path finder, json explorer",++ about: "Paste JSON and see what is actually in it: an indented tree, a flat list of every path with its value (handy for writing jq filters or accessing nested fields in code), or a map of the types at each path.",++ bullets: ["Tree, flat path list and type map views", "Paths are written the way you would access them in code", "Pinpoints the position of a syntax error"],++ faq: [["What is the path list for?", "It shows every leaf as a path such as user.roles[0], which is what you need when writing a jq filter, a JSONPath query or plain property access."]],++ },++ jsonvalidate: {++ name: "JSON Validator", title: "JSON Validator \u2014 JSON Lint Online",++ desc: "Check JSON and find the exact error. Find out whether a document is valid JSON and, if it is not, where it breaks: the line and column of the first error, with the common causes (trailing commas, single quotes, unquoted keys, comments) called out..",++ kw: "json validator, json lint, validate json online, json checker, json syntax error, jsonlint",++ about: "Find out whether a document is valid JSON and, if it is not, where it breaks: the line and column of the first error, with the common causes (trailing commas, single quotes, unquoted keys, comments) called out.",++ bullets: ["Line and column of the first syntax error", "Names the usual culprits: trailing commas, single quotes, comments", "Summarises the structure when the document is valid"],++ faq: [["Why is a trailing comma an error?", "JSON is stricter than JavaScript. A comma after the last item in an object or array is not allowed by the JSON specification, even though most JavaScript engines accept it in code."]],++ },++ jsonstringify: {++ name: "JSON Stringify", title: "JSON Stringify & Unescape Online",++ desc: "Turn JSON into an escaped string literal, and back. Convert a JSON document into a single escaped string, the form you need when embedding JSON inside another JSON value, an environment variable or a source file.",++ kw: "json stringify online, json escape, json unescape, escape json string, json to string, string to json",++ about: "Convert a JSON document into a single escaped string, the form you need when embedding JSON inside another JSON value, an environment variable or a source file. The reverse mode takes an escaped string and gives the JSON back.",++ bullets: ["Escapes quotes, backslashes and newlines correctly", "Reverse mode parses a quoted, escaped string back into JSON", "Runs in your browser"],++ faq: [["When do I need this?", "Whenever JSON has to travel inside a string: a JSON field that holds JSON, a config value, a test fixture or a shell variable."]],++ },++ html2md: {++ name: "HTML to Markdown", title: "HTML to Markdown Converter Online",++ desc: "Convert HTML into clean Markdown. Paste HTML from a web page, an email or a CMS and get Markdown you can put in a README, a wiki or a static site.",++ kw: "html to markdown, convert html to markdown, html2md, html to md converter, web page to markdown",++ about: "Paste HTML from a web page, an email or a CMS and get Markdown you can put in a README, a wiki or a static site. Headings, emphasis, links, images, lists, quotes, code blocks and tables are converted; scripts and styling are dropped.",++ bullets: ["Headings, lists, links, images, quotes, code and tables", "Drops scripts, styles and presentational markup", "Parsed by your browser; nothing is uploaded"],++ faq: [["What happens to things Markdown cannot express?", "Layout markup such as div and span is unwrapped so its text is kept. Scripts, styles and form controls are removed."]],++ },++ md2html: {++ name: "Markdown to HTML", title: "Markdown to HTML Converter Online",++ desc: "Convert Markdown into HTML you can paste anywhere. Write or paste Markdown and copy the HTML it produces, ready for a CMS, an email template or a page that does not render Markdown itself.",++ kw: "markdown to html, convert markdown to html, md to html, markdown converter online, markdown html generator",++ about: "Write or paste Markdown and copy the HTML it produces, ready for a CMS, an email template or a page that does not render Markdown itself. A preview mode shows the rendered result.",++ bullets: ["Copy-ready HTML output, or a rendered preview", "Headings, emphasis, lists, links, quotes and code", "Runs in your browser"],++ faq: [["Is this the same as the Markdown editor?", "The editor is for writing with a live preview. This tool is for one job: getting the HTML out."]],++ },};// ── Network & diagnostics tools (net.openvibe.tools) ─────────@@@ -119,6 +119,7 @@ function renderTool(file, sub, canonicalHost) {html = html.replace(/<link\s+rel=["']canonical["'][^>]*>/i, `<link rel="canonical" href="${url}">`);const extraHead = [++ `<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">','<meta name="twitter:card" content="summary_large_image">',@@@ -107,6 +107,10 @@.ovnav-launcher .ovl-seg button[aria-pressed="true"] { background: var(--accent, #3b82f6); color: var(--on-accent, #fff); }.ovnav-launcher .ovl-seg button:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: -2px; }.ovnav-launcher .ovl-display a { margin-left: auto; color: var(--accent-light, var(--accent, #60a5fa)); font-weight: 600; }++ .ovnav-launcher .ovl-addr a { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11.5px; }++ .ovnav-launcher .ovl-addr a.is-here { border-color: var(--accent, #3b82f6); color: var(--text-primary, #e6edf7); }++ .ovnav-launcher .ovl-all { grid-column: 1 / -1; color: var(--accent-light, var(--accent, #60a5fa)); }++ .ovnav-launcher .ovl-h span { text-transform: none; letter-spacing: 0; font-weight: 500; }.ovnav-launcher .ovl-empty { padding: 18px 8px; color: var(--text-secondary, #a8b3c4); font-size: 13px; }@media (max-width: 480px) { .ovnav-launcher { left: 8px; } .ovnav-launcher .ovl-fams { grid-template-columns: 1fr; } }@media (prefers-reduced-motion: reduce) { .ovnav-launcher { transition: none; } }@@@ -613,17 +617,19 @@{ name: 'Developer Tools', icon: 'code', url: 'https://dev.openvibe.tools/' },{ name: 'Network Tools', icon: 'dns', url: 'https://net.openvibe.tools/' },];-- const CATALOG_URL = 'https://openvibe.tools/api/catalog.json';++ const CATALOG_URL = 'https://openvibe.network/api/catalog.json'; // allowed by every site's CSP++ const TOOLS_SEARCH = 'https://openvibe.tools/search?q=';const okUrl = (u) => { try { const x = new URL(u); return x.protocol === 'https:' ? x.href : null; } catch { return null; } };async function launcherCatalog() {-- try { const c = JSON.parse(sessionStorage.getItem('ov_catalog') || 'null'); if (c && Date.now() - c.at < 30 * 60000) return c.data; } catch { /* */ }++ try { const c = JSON.parse(sessionStorage.getItem('ov_catalog2') || 'null'); if (c && Date.now() - c.at < 30 * 60000) return c.data; } catch { /* */ }try {const r = await fetch(CATALOG_URL, { credentials: 'omit' }); if (!r.ok) return null;const j = await r.json();const data = { families: (j.families || []).slice(0, 12).map(f => ({ name: String(f.name || ''), icon: String(f.icon || 'tools'), url: okUrl(f.url) })).filter(f => f.name && f.url),-- tools: (j.tools || []).slice(0, 400).map(t => ({ name: String(t.name || ''), icon: String(t.icon || 'tools'), url: okUrl(t.url), k: [t.name, t.tagline].concat(t.keywords || []).join(' ').toLowerCase().slice(0, 400) })).filter(t => t.name && t.url) };-- try { sessionStorage.setItem('ov_catalog', JSON.stringify({ at: Date.now(), data })); } catch { /* */ }++ tools: (j.tools || []).slice(0, 400).map(t => ({ name: String(t.name || ''), icon: String(t.icon || 'tools'), url: okUrl(t.url), go: t.hosts && t.hosts.short ? okUrl('https://' + t.hosts.short + '/') : null,++ hosts: [t.hosts && t.hosts.short, t.hosts && t.hosts.canonical].concat((t.hosts && t.hosts.mirrors) || [], (t.hosts && t.hosts.aliases) || []).filter(h => typeof h === 'string' && h), id: String(t.id || ''), k: [t.name, t.tagline].concat(t.keywords || []).join(' ').toLowerCase().slice(0, 400) })).filter(t => t.name && t.url) };++ try { sessionStorage.setItem('ov_catalog2', JSON.stringify({ at: Date.now(), data })); } catch { /* */ }return data;} catch { return null; }}@@@ -631,20 +637,33 @@function bindLauncher(nav) {const btn = nav.querySelector('#openvibe-launcher-btn'); if (!btn) return;let panel = null;-- const tile = (it, cls) => `<a class="${cls}" href="${escapeAttr(it.url)}"><span class="ov-icon" data-icon="${escapeAttr(it.icon)}" data-size="${cls === 'ovl-site' ? 34 : 24}" data-fx="none"></span><span><b>${escapeAttr(it.name)}</b>${it.desc ? `<small>${escapeAttr(it.desc)}</small>` : ''}</span></a>`;++ const tile = (it, cls) => `<a class="${cls}" href="${escapeAttr(it.go || it.url)}"><span class="ov-icon" data-icon="${escapeAttr(it.icon)}" data-size="${cls === 'ovl-site' ? 34 : 24}" data-fx="none"></span><span><b>${escapeAttr(it.name)}</b>${it.desc ? `<small>${escapeAttr(it.desc)}</small>` : ''}</span></a>`;const close = () => { if (panel) panel.classList.remove('open'); btn.setAttribute('aria-expanded', 'false'); };function paint(cat, q) {const fams = (cat && cat.families.length ? cat.families : LAUNCHER_FAMILIES);const body = panel.querySelector('.ovl-body');-- if (q && cat) {-- const hits = cat.tools.filter(t => t.k.includes(q)).slice(0, 12);-- body.innerHTML = hits.length ? `<div class="ovl-h">Tools</div><div class="ovl-fams">${hits.map(t => tile(t, 'ovl-fam')).join('')}</div>` : '<div class="ovl-empty">No tool matches. <a href="https://openvibe.tools/">Browse them all</a></div>';++ if (q) {++ // Sites and families always filter locally; tools need the catalog. Without it (offline, blocked),++ // the last row hands the query to the Tools search page, so typing never does nothing.++ const hit = (x) => (x.name + ' ' + (x.desc || '')).toLowerCase().includes(q);++ const siteList = (OVChrome.get() && OVChrome.get().nav.length ? OVChrome.get().nav.map(n => ({ name: n.name, desc: n.tagline, icon: n.icon || n.id, url: n.url })) : LAUNCHER_SITES).filter(hit);++ const famList = fams.filter(hit);++ const tools = cat ? cat.tools.filter(t => t.k.includes(q)).slice(0, 12) : [];++ const more = `<a class="ovl-fam ovl-all" href="${escapeAttr(TOOLS_SEARCH + encodeURIComponent(q))}"><span class="ov-icon" data-icon="search" data-size="24" data-fx="none"></span><span><b>Search all tools for “${escapeAttr(q)}”</b></span></a>`;++ body.innerHTML = (siteList.length ? `<div class="ovl-h">Sites</div><div class="ovl-fams">${siteList.map(x => tile(x, 'ovl-fam')).join('')}</div>` : '')++ + (famList.length ? `<div class="ovl-h">Tool families</div><div class="ovl-fams">${famList.map(x => tile(x, 'ovl-fam')).join('')}</div>` : '')++ + `<div class="ovl-h">Tools${cat ? '' : ' <span>loading…</span>'}</div><div class="ovl-fams">${tools.map(t => tile(t, 'ovl-fam')).join('')}${more}</div>`;return;}const chrome = OVChrome.get();const sites = chrome && chrome.nav.length ? chrome.nav.map(n => ({ name: n.name, desc: n.tagline, icon: n.icon || n.id, url: n.url })) : LAUNCHER_SITES;const soon = chrome ? chrome.soon : [];-- body.innerHTML = `<div class="ovl-h">Sites</div><div class="ovl-sites">${sites.map(x => tile(x, 'ovl-site')).join('')}</div>++ // On a tool: every address it answers to (short, search-friendly, mirrors, custom domains), so people++ // can pick the one they will remember.++ const cur = currentHost().toLowerCase();++ const here = cat ? cat.tools.find(t => t.hosts.indexOf(cur) >= 0) : null;++ const addresses = here && here.hosts.length > 1 ? `<div class="ovl-h">${escapeAttr(here.name)} lives at <a href="https://openvibe.tools/tool/${escapeAttr(here.id)}">About</a></div><div class="ovl-soon ovl-addr">${[...new Set(here.hosts)].filter(h => /^[a-z0-9.-]+$/.test(h)).map(h => `<a href="https://${h}/"${h === cur ? ' class="is-here" aria-current="page"' : ''}>${h}</a>`).join('')}</div>` : '';++ body.innerHTML = addresses + `<div class="ovl-h">Sites</div><div class="ovl-sites">${sites.map(x => tile(x, 'ovl-site')).join('')}</div><div class="ovl-h">Tools <a href="https://openvibe.tools/">See all</a></div><div class="ovl-fams">${fams.map(x => tile(x, 'ovl-fam')).join('')}</div>${soon.length ? `<div class="ovl-h">Opening soon</div><div class="ovl-soon">${soon.map(x => `<a href="${escapeAttr(x.url)}">${escapeAttr(x.name)}</a>`).join('')}</div>` : ''}`;}@@@ -660,7 +679,7 @@let cat = null; paint(null, '');const input = panel.querySelector('.ovl-q');input.addEventListener('input', () => paint(cat, input.value.trim().toLowerCase()));-- input.addEventListener('keydown', (ev) => { if (ev.key === 'Enter') { const first = panel.querySelector('.ovl-body a'); if (first) location.href = first.href; } if (ev.key === 'ArrowDown') { const first = panel.querySelector('.ovl-body a'); if (first) { ev.preventDefault(); first.focus(); } } });++ input.addEventListener('keydown', (ev) => { if (ev.key === 'Enter') { const first = panel.querySelector('.ovl-body a'); location.href = first ? first.href : TOOLS_SEARCH + encodeURIComponent(input.value.trim()); } if (ev.key === 'ArrowDown') { const first = panel.querySelector('.ovl-body a'); if (first) { ev.preventDefault(); first.focus(); } } });panel.addEventListener('keydown', (ev) => {if (ev.key === 'Escape') { close(); btn.focus(); return; }if (ev.key !== 'ArrowDown' && ev.key !== 'ArrowUp') return;
@@ -171,6 +171,18 @@ entities:{n:'String Escape Tool',i:'fa-shield-halved',c:'enc',d:'HTML, JS & URL
@@ -171,6 +171,18 @@ entities:{n:'String Escape Tool',i:'fa-shield-halved',c:'enc',d:'HTML, JS & URL
timestamp:{n:'Unix Timestamp Converter',i:'fa-clock',c:'time',d:'Unix timestamp \u2194 date converter',p:'',m:['Now','To Date','To Unix']},
timestamp:{n:'Unix Timestamp Converter',i:'fa-clock',c:'time',d:'Unix timestamp \u2194 date converter',p:'',m:['Now','To Date','To Unix']},
cron:{n:'Cron Expression Parser',i:'fa-calendar-check',c:'time',d:'Cron expression parser & next runs',p:'*/15 9-17 * * 1-5',m:['Parse']},
cron:{n:'Cron Expression Parser',i:'fa-calendar-check',c:'time',d:'Cron expression parser & next runs',p:'*/15 9-17 * * 1-5',m:['Parse']},
beautify:{n:'Code Beautifier',i:'fa-wand-magic-sparkles',c:'qual',d:'Beautify JS, CSS, HTML & JSON',p:'{"a":1,"b":[2,3],"c":{"d":true}}',m:['JSON','HTML','CSS']},
beautify:{n:'Code Beautifier',i:'fa-wand-magic-sparkles',c:'qual',d:'Beautify JS, CSS, HTML & JSON',p:'{"a":1,"b":[2,3],"c":{"d":true}}',m:['JSON','HTML','CSS']},
+jsminify:{n:"JavaScript Minifier",i:'fa-compress',c:'qual',d:"Shrink JavaScript with a real parser, not regex",p:"function greet(name) {\n // say hello\n const message = \"Hello, \" + name + \"!\";\n console.log(message);\n return message;\n}\ngreet(\"OpenVibe\");",m:["Minify", "Minify + mangle", "Whitespace only"]},
+jsformat:{n:"JavaScript Beautifier",i:'fa-wand-magic-sparkles',c:'qual',d:"Turn minified or messy JavaScript into readable code",p:"function greet(n){const m=\"Hello, \"+n+\"!\";console.log(m);return m}greet(\"OpenVibe\");",m:["Beautify"]},
+cssminify:{n:"CSS Minifier",i:'fa-compress',c:'qual',d:"Strip comments and whitespace from stylesheets",p:"/* card */\n.card {\n color: #ffffff;\n margin: 0px 0px 10px 0px;\n padding: 12px;\n}\n\n.card:hover {\n color: #3b82f6;\n}",m:["Minify"]},
+cssformat:{n:"CSS Beautifier",i:'fa-wand-magic-sparkles',c:'qual',d:"Re-indent minified or messy CSS",p:".card{color:#fff;margin:0 0 10px;padding:12px}.card:hover{color:#3b82f6}@media (max-width:600px){.card{padding:8px}}",m:["Beautify"]},
+htmlminify:{n:"HTML Minifier",i:'fa-compress',c:'qual',d:"Remove comments and whitespace from HTML",p:"<!-- header -->\n<div class=\"card\">\n <p>Hello <strong>World</strong></p>\n</div>",m:["Minify"]},
+xmlminify:{n:"XML Minifier",i:'fa-compress',c:'qual',d:"Collapse XML to a single compact line",p:"<?xml version=\"1.0\"?>\n<!-- catalog -->\n<catalog>\n <item id=\"1\">\n <name>OpenVibe</name>\n </item>\n</catalog>",m:["Minify"]},
+jsonminify:{n:"JSON Minifier",i:'fa-compress',c:'data',d:"Compact JSON to the smallest valid form",p:"{\n \"name\": \"openvibe\",\n \"version\": 1,\n \"tags\": [\"open\", \"source\"]\n}",m:["Minify"]},
+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"]},
+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']},
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']},
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']},
regex:{n:'Regex Tester',i:'fa-magnifying-glass',c:'qual',d:'Regex tester with match highlighting',p:'\\b[A-Z][a-z]+\\b',p2:'Hello World from OpenVibe.Tools today',dual:1,m:['Test']},
regex:{n:'Regex Tester',i:'fa-magnifying-glass',c:'qual',d:'Regex tester with match highlighting',p:'\\b[A-Z][a-z]+\\b',p2:'Hello World from OpenVibe.Tools today',dual:1,m:['Test']},
@@ -184,7 +196,8 @@ opengraph:{n:'Open Graph Preview',i:'fa-share-nodes',c:'fe',d:'Open Graph & Twit
@@ -184,7 +196,8 @@ opengraph:{n:'Open Graph Preview',i:'fa-share-nodes',c:'fe',d:'Open Graph & Twit
const CATS={data:{l:'Data & Formats',i:'fa-database'},enc:{l:'Encoding & Crypto',i:'fa-lock'},time:{l:'Time & Scheduling',i:'fa-clock'},qual:{l:'Code Quality',i:'fa-wand-magic-sparkles'},http:{l:'HTTP & API',i:'fa-terminal'},fe:{l:'Frontend & SEO',i:'fa-palette'}};
const CATS={data:{l:'Data & Formats',i:'fa-database'},enc:{l:'Encoding & Crypto',i:'fa-lock'},time:{l:'Time & Scheduling',i:'fa-clock'},qual:{l:'Code Quality',i:'fa-wand-magic-sparkles'},http:{l:'HTTP & API',i:'fa-terminal'},fe:{l:'Frontend & SEO',i:'fa-palette'}};
const host=location.hostname,sub=host.replace(/\.openvibe\.tools$/,'');
const host=location.hostname,sub=host.replace(/\.openvibe\.tools$/,'');
-let ACT=T[sub]?sub:'dev',MODE='';
+const META=(document.querySelector('meta[name="ov-tool"]')||{}).content||'';
+let ACT=T[META]?META:(T[sub]?sub:'dev'),MODE='';
document.addEventListener('DOMContentLoaded',()=>{
document.addEventListener('DOMContentLoaded',()=>{
const tok=localStorage.getItem('ov_token');
const tok=localStorage.getItem('ov_token');
@@ -361,6 +374,65 @@ P.beautify=(s,m)=>{
@@ -361,6 +374,65 @@ P.beautify=(s,m)=>{
let f='',ind=0;s.replace(/>\s*</g,'><').split(/(<[^>]+>)/g).filter(Boolean).forEach(n=>{if(n.match(/^<\//))ind--;f+=' '.repeat(Math.max(0,ind))+n.trim()+'\n';if(n.match(/^<[^\/!?]/)&&!n.match(/\/>/))ind++});return f.trim();
let f='',ind=0;s.replace(/>\s*</g,'><').split(/(<[^>]+>)/g).filter(Boolean).forEach(n=>{if(n.match(/^<\//))ind--;f+=' '.repeat(Math.max(0,ind))+n.trim()+'\n';if(n.match(/^<[^\/!?]/)&&!n.match(/\/>/))ind++});return f.trim();
};
};
+// ── Single-purpose tools ─────────────────────────────────────
+const loadScript=(src)=>new Promise((ok,no)=>{const e=document.createElement('script');e.src=src;e.onload=ok;e.onerror=()=>no(new Error('Could not load the minifier. Check your connection and try again.'));document.head.appendChild(e)});
+const terser=async()=>{if(!window.Terser)await loadScript('https://cdn.jsdelivr.net/npm/terser@5/dist/bundle.min.js');return window.Terser};
+const saved=(a,b)=>{const x=new Blob([a]).size,y=new Blob([b]).size;return x?`${x.toLocaleString()} → ${y.toLocaleString()} bytes (${Math.max(0,Math.round((1-y/x)*100))}% smaller)`:''};
+const withNote=(code,note)=>({html:(note?'<div style="color:var(--tx2);font-size:.8rem;margin-bottom:8px">'+H(note)+'</div>':'')+'<pre style="margin:0;white-space:pre-wrap;word-break:break-word">'+H(code)+'</pre>'});
+P.jsminify=async(s,m)=>{const T=await terser();const r=await T.minify(s,m==='Whitespace only'?{compress:false,mangle:false}:{compress:true,mangle:m==='Minify + mangle'});return withNote(r.code||'',saved(s,r.code||''))};
+P.jsformat=async(s)=>{const T=await terser();const r=await T.minify(s,{compress:false,mangle:false,format:{beautify:true,comments:'all',indent_level:2}});return r.code||''};
+const cssMin=(s)=>{const keep=[];s=s.replace(/("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|url\([^)]*\))/g,(m)=>{keep.push(m);return'\u0000'+(keep.length-1)+'\u0000'});
+ s=s.replace(/\/\*[\s\S]*?\*\//g,'').replace(/\s+/g,' ').replace(/\s*([{}:;,>+~])\s*/g,'$1').replace(/;}/g,'}').replace(/(^|[\s:,(])0(?:px|em|rem|%|pt|vh|vw)(?=[\s;,)}]|$)/g,'$10').replace(/#([0-9a-f])\1([0-9a-f])\2([0-9a-f])\3\b/gi,'#$1$2$3').trim();
+ return s.replace(/\u0000(\d+)\u0000/g,(_,i)=>keep[+i])};
+P.cssminify=(s)=>{const o=cssMin(s);return withNote(o,saved(s,o))};
+P.cssformat=(s)=>{let out='',depth=0;const pad=()=>' '.repeat(depth);const min=cssMin(s);let buf='';
+ for(let i=0;i<min.length;i++){const c=min[i];
+ if(c==='{'){out+=pad()+buf.trim()+' {\n';buf='';depth++}
+ else if(c==='}'){if(buf.trim())out+=pad()+buf.trim().replace(/:(?!\/\/)/,': ')+';\n';buf='';depth=Math.max(0,depth-1);out+=pad()+'}\n'+(depth?'':'\n')}
+ else if(c===';'){out+=pad()+buf.trim().replace(/:(?!\/\/)/,': ')+';\n';buf=''}
+ else buf+=c}
+ return out.trim()};
+P.htmlminify=(s)=>{const keep=[];let o=s.replace(/<(pre|textarea|script|style)\b[\s\S]*?<\/\1>/gi,(m)=>{keep.push(m);return'\u0000'+(keep.length-1)+'\u0000'});
+ o=o.replace(/<!--(?!\[if)[\s\S]*?-->/g,'').replace(/\s+/g,' ').replace(/>\s+</g,'> <').replace(/>\s<(\/?(?:html|head|body|div|p|ul|ol|li|table|tr|td|th|thead|tbody|section|article|header|footer|nav|main|h[1-6]|meta|link|title|br|hr)\b)/gi,'><$1').trim();
+ o=o.replace(/\u0000(\d+)\u0000/g,(_,i)=>keep[+i]);return withNote(o,saved(s,o))};
+P.xmlminify=(s)=>{const d=new DOMParser().parseFromString(s,'application/xml');const er=d.querySelector('parsererror');if(er)throw new Error(er.textContent.split('\n')[0]);
+ 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))};
+const jsonWhere=(s,e)=>{const m=/position (\d+)/.exec(e.message);if(!m)return e.message;const pos=+m[1],pre=s.slice(0,pos),line=pre.split('\n').length,col=pos-pre.lastIndexOf('\n');
+ const near=s.slice(Math.max(0,pos-20),pos+1);let hint='';if(/,\s*[}\]]$/.test(s.slice(0,pos+1).replace(/\s+$/,''))||/,\s*$/.test(pre))hint=' A trailing comma is not allowed in JSON.';else if(/'/.test(near))hint=' JSON strings and keys need double quotes.';else if(/\/\/|\/\*/.test(near))hint=' JSON has no comments.';else if(/[{,]\s*[A-Za-z_]\w*\s*:?$/.test(pre+s[pos]))hint=' Keys must be in double quotes.';
+ return `Line ${line}, column ${col}: ${e.message.replace(/ in JSON at position \d+.*/,'')}.${hint}`};
+const jsonIn=(s)=>{try{return JSON.parse(s)}catch(e){throw new Error(jsonWhere(s,e))}};
+const kind=(v)=>v===null?'null':Array.isArray(v)?'array':typeof v;
+P.jsonminify=(s)=>{const o=JSON.stringify(jsonIn(s));return withNote(o,saved(s,o))};
+P.jsonvalidate=(s)=>{const v=jsonIn(s);let n=0,depth=0;(function walk(x,d){n++;depth=Math.max(depth,d);if(x&&typeof x==='object')for(const k in x)walk(x[k],d+1)})(v,0);
+ return'\u2713 Valid JSON\n\nTop level: '+kind(v)+(Array.isArray(v)?' with '+v.length+' items':v&&typeof v==='object'?' with '+Object.keys(v).length+' keys':'')+'\nValues: '+n.toLocaleString()+'\nDeepest nesting: '+depth};
+P.jsonparse=(s,m)=>{const v=jsonIn(s);if(m==='Tree')return JSON.stringify(v,null,2);const rows=[];
+ (function walk(x,p){if(x&&typeof x==='object'){if(m==='Types')rows.push((p||'$')+' '+kind(x));const ks=Array.isArray(x)?x.map((_,i)=>i):Object.keys(x);if(!ks.length&&m!=='Types')rows.push((p||'$')+' = '+JSON.stringify(x));
+ for(const k of ks)walk(x[k],Array.isArray(x)?p+'['+k+']':(/^[A-Za-z_$][\w$]*$/.test(k)?(p?p+'.':'')+k:p+'['+JSON.stringify(k)+']'))}
+ else rows.push((p||'$')+(m==='Types'?' '+kind(x):' = '+JSON.stringify(x)))})(v,'');
+ return rows.join('\n')};
+P.jsonstringify=(s,m)=>{if(m==='Parse string'){let t=s.trim();const inner=JSON.parse(/^"/.test(t)?t:'"'+t.replace(/"/g,'\\"')+'"');return JSON.stringify(jsonIn(inner),null,2)}return JSON.stringify(JSON.stringify(jsonIn(s)))};
+P.html2md=(s)=>{const doc=new DOMParser().parseFromString(s,'text/html');const esc=(t)=>t.replace(/([\\`*_{}\[\]#|])/g,'\\$1');
+ const inline=(n)=>[...n.childNodes].map(node).join('');
+ const list=(n,ord,depth)=>[...n.children].filter(c=>c.tagName==='LI').map((li,i)=>' '.repeat(depth)+(ord?(i+1)+'. ':'- ')+[...li.childNodes].map(c=>c.nodeType===1&&/^(UL|OL)$/.test(c.tagName)?'\n'+list(c,c.tagName==='OL',depth+1):node(c)).join('').trim()).join('\n');
+ function node(n){if(n.nodeType===3)return esc(n.nodeValue.replace(/\s+/g,' '));if(n.nodeType!==1)return'';const t=n.tagName;
+ if(/^(SCRIPT|STYLE|NOSCRIPT|TEMPLATE|IFRAME|FORM|INPUT|BUTTON|SELECT|TEXTAREA|SVG|HEAD)$/.test(t))return'';
+ if(/^H[1-6]$/.test(t))return'\n\n'+'#'.repeat(+t[1])+' '+inline(n).trim()+'\n\n';
+ if(t==='P'||t==='DIV'||t==='SECTION'||t==='ARTICLE'||t==='HEADER'||t==='FOOTER'||t==='MAIN')return'\n\n'+inline(n).trim()+'\n\n';
+ if(t==='BR')return' \n';if(t==='HR')return'\n\n---\n\n';
+ if(t==='STRONG'||t==='B'){const x=inline(n).trim();return x?'**'+x+'**':''}if(t==='EM'||t==='I'){const x=inline(n).trim();return x?'*'+x+'*':''}
+ if(t==='DEL'||t==='S')return'~~'+inline(n).trim()+'~~';
+ if(t==='CODE'&&n.parentNode.tagName!=='PRE')return'`'+n.textContent+'`';
+ if(t==='PRE'){const c=n.querySelector('code');const lang=c&&(/language-([\w-]+)/.exec(c.className)||[])[1]||'';return'\n\n```'+lang+'\n'+n.textContent.replace(/\n$/,'')+'\n```\n\n'}
+ if(t==='A'){const h=n.getAttribute('href')||'';const x=inline(n).trim();return h&&!/^javascript:/i.test(h)?'['+(x||h)+']('+h+')':x}
+ if(t==='IMG'){const src=n.getAttribute('src')||'';return src?'':''}
+ if(t==='UL'||t==='OL')return'\n\n'+list(n,t==='OL',0)+'\n\n';
+ if(t==='BLOCKQUOTE')return'\n\n'+inline(n).trim().split('\n').map(l=>'> '+l).join('\n')+'\n\n';
+ if(t==='TABLE'){const rows=[...n.querySelectorAll('tr')].map(r=>[...r.children].map(c=>inline(c).trim().replace(/\|/g,'\\|').replace(/\n+/g,' ')));if(!rows.length)return'';const w=Math.max(...rows.map(r=>r.length));const line=(r)=>'| '+Array.from({length:w},(_,i)=>r[i]||'').join(' | ')+' |';return'\n\n'+line(rows[0])+'\n|'+' --- |'.repeat(w)+'\n'+rows.slice(1).map(line).join('\n')+'\n\n'}
+ return inline(n)}
+ return node(doc.body).replace(/[ \t]+\n/g,(m)=>m.startsWith(' ')?' \n':'\n').replace(/\n{3,}/g,'\n\n').trim()};
+P.md2html=(s,m)=>{let h=P.md(s,'To HTML').replace(/ style="[^"]*"/g,'').replace(/((?:<li>.*<\/li>\n?)+)/g,'<ul>\n$1</ul>\n');
+ h=h.split(/\n{2,}/).map(b=>/^\s*<(h\d|ul|ol|li|blockquote|hr|pre|table)/.test(b)?b:'<p>'+b.trim().replace(/\n/g,'<br>\n')+'</p>').join('\n');
+ return m==='Preview'?{html:'<div style="line-height:1.8">'+h+'</div>'}:h};
P.minify=(s,m)=>{if(m==='JSON')return JSON.stringify(JSON.parse(s));if(m==='CSS')return s.replace(/\/\*[\s\S]*?\*\//g,'').replace(/\s+/g,' ').replace(/\s*([{}:;,])\s*/g,'$1').trim();
P.minify=(s,m)=>{if(m==='JSON')return JSON.stringify(JSON.parse(s));if(m==='CSS')return s.replace(/\/\*[\s\S]*?\*\//g,'').replace(/\s+/g,' ').replace(/\s*([{}:;,])\s*/g,'$1').trim();
if(m==='HTML')return s.replace(/\s+/g,' ').replace(/>\s+</g,'><').trim();return s.replace(/\/\/.*$/gm,'').replace(/\/\*[\s\S]*?\*\//g,'').replace(/\s+/g,' ').trim()};
if(m==='HTML')return s.replace(/\s+/g,' ').replace(/>\s+</g,'><').trim();return s.replace(/\/\/.*$/gm,'').replace(/\/\*[\s\S]*?\*\//g,'').replace(/\s+/g,' ').trim()};
@@ -55,6 +55,20 @@ const DEV_TOOLS = [
@@ -55,6 +55,20 @@ const DEV_TOOLS = [
// Frontend & SEO
// Frontend & SEO
{ id: 'color', subdomain: 'color', name: 'OpenVibeColor', icon: 'fa-palette', desc: 'Color picker, HEX/RGB/HSL converter & palette generator', category: 'frontend' },
{ id: 'color', subdomain: 'color', name: 'OpenVibeColor', icon: 'fa-palette', desc: 'Color picker, HEX/RGB/HSL converter & palette generator', category: 'frontend' },
{ id: 'opengraph', subdomain: 'opengraph', name: 'OpenVibeOpenGraph', icon: 'fa-share-nodes', desc: 'Open Graph & Twitter Card preview & validator', category: 'frontend' },
{ id: 'opengraph', subdomain: 'opengraph', name: 'OpenVibeOpenGraph', icon: 'fa-share-nodes', desc: 'Open Graph & Twitter Card preview & validator', category: 'frontend' },
+
+ // Single-purpose tools: one job per address (search engines and people both look for the exact job).
+ { id: 'jsminify', subdomain: 'jsminify', name: "JavaScript Minifier", icon: 'fa-compress', desc: "Shrink JavaScript with a real parser, not regex", category: 'quality' },
+ { id: 'jsformat', subdomain: 'jsformat', name: "JavaScript Beautifier", icon: 'fa-wand-magic-sparkles', desc: "Turn minified or messy JavaScript into readable code", category: 'quality' },
+ { id: 'cssminify', subdomain: 'cssminify', name: "CSS Minifier", icon: 'fa-compress', desc: "Strip comments and whitespace from stylesheets", category: 'quality' },
+ { id: 'cssformat', subdomain: 'cssformat', name: "CSS Beautifier", icon: 'fa-wand-magic-sparkles', desc: "Re-indent minified or messy CSS", category: 'quality' },
+ { id: 'htmlminify', subdomain: 'htmlminify', name: "HTML Minifier", icon: 'fa-compress', desc: "Remove comments and whitespace from HTML", category: 'quality' },
+ { id: 'xmlminify', subdomain: 'xmlminify', name: "XML Minifier", icon: 'fa-compress', desc: "Collapse XML to a single compact line", category: 'quality' },
+ { id: 'jsonminify', subdomain: 'jsonminify', name: "JSON Minifier", icon: 'fa-compress', desc: "Compact JSON to the smallest valid form", category: 'data' },
+ { id: 'jsonparse', subdomain: 'jsonparse', name: "JSON Parser", icon: 'fa-sitemap', desc: "Explore JSON as a tree, a list of paths or a type map", category: 'data' },
+ { id: 'jsonvalidate', subdomain: 'jsonvalidate', name: "JSON Validator", icon: 'fa-circle-check', desc: "Check JSON and find the exact error", category: 'data' },
+ { id: 'jsonstringify', subdomain: 'jsonstringify', name: "JSON Stringify", icon: 'fa-quote-right', desc: "Turn JSON into an escaped string literal, and back", category: 'data' },
+ { id: 'html2md', subdomain: 'html2md', name: "HTML to Markdown", icon: 'fa-right-left', desc: "Convert HTML into clean Markdown", category: 'data' },
+ { id: 'md2html', subdomain: 'md2html', name: "Markdown to HTML", icon: 'fa-right-left', desc: "Convert Markdown into HTML you can paste anywhere", category: 'data' },
];
];
for (const t of DEV_TOOLS) { const seo = seoFor(t.subdomain); if (seo) { t.name = seo.name; t.desc = seo.desc; t.seo = seo; } }
for (const t of DEV_TOOLS) { const seo = seoFor(t.subdomain); if (seo) { t.name = seo.name; t.desc = seo.desc; t.seo = seo; } }
@@ -178,7 +178,7 @@ app.use('/api/dev', rateLimit({ windowMs: 60_000, max: 60 }), createDevRoutes(nu
@@ -178,7 +178,7 @@ app.use('/api/dev', rateLimit({ windowMs: 60_000, max: 60 }), createDevRoutes(nu
// ── Host-header subdomain routing ────────────────────────────
// ── Host-header subdomain routing ────────────────────────────
function subdomainOf(req) {
function subdomainOf(req) {
// A mirror or custom domain is the tool it points at.
// A mirror or custom domain is the tool it points at.
- if (req.ovHost && req.ovHost.tool && !getRequestHost(req).endsWith('.openvibe.tools')) return req.ovHost.tool;
+ if (req.ovHost && req.ovHost.tool) return req.ovHost.tool; // descriptive hosts, mirrors and custom domains all resolve to a tool id
return getRequestHost(req).replace(/\.openvibe\.tools$/, '');
return getRequestHost(req).replace(/\.openvibe\.tools$/, '');
}
}
@@ -21,6 +21,18 @@ const HOST_RE = /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63
@@ -21,6 +21,18 @@ const HOST_RE = /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63
// Human-first defaults: the descriptive host is canonical, the one people already type is short.
// Human-first defaults: the descriptive host is canonical, the one people already type is short.
const HOST_DEFAULTS = {
const HOST_DEFAULTS = {
+ jsminify: { canonical: 'js-minifier', short: 'jsminify', aliases: ["minifyjs", "javascript-minifier", "jsmin"] },
+ jsformat: { canonical: 'js-beautifier', short: 'jsformat', aliases: ["jsbeautify", "javascript-beautifier", "unminify"] },
+ cssminify: { canonical: 'css-minifier', short: 'cssminify', aliases: ["minifycss", "cssmin", "css-compressor"] },
+ cssformat: { canonical: 'css-beautifier', short: 'cssformat', aliases: ["cssbeautify", "css-formatter", "unminifycss"] },
+ htmlminify: { canonical: 'html-minifier', short: 'htmlminify', aliases: ["minifyhtml", "htmlmin", "html-compressor"] },
+ xmlminify: { canonical: 'xml-minifier', short: 'xmlminify', aliases: ["minifyxml", "xmlmin"] },
+ jsonminify: { canonical: 'json-minifier', short: 'jsonminify', aliases: ["minifyjson", "jsonmin", "json-compressor"] },
+ jsonparse: { canonical: 'json-parser', short: 'jsonparse', aliases: ["jsonviewer", "json-viewer", "jsontree", "parsejson"] },
+ jsonvalidate: { canonical: 'json-validator', short: 'jsonvalidate', aliases: ["jsonlint", "validatejson", "json-lint", "jsoncheck"] },
+ jsonstringify: { canonical: 'json-stringify', short: 'jsonstringify', aliases: ["jsonescape", "json-escape", "jsonunescape", "stringifyjson"] },
+ html2md: { canonical: 'html-to-markdown', short: 'html2md', aliases: ["htmltomarkdown", "html2markdown", "htmltomd"] },
+ md2html: { canonical: 'markdown-to-html', short: 'md2html', aliases: ["markdowntohtml", "md2htm", "mdtohtml"] },
yt: { canonical: 'youtube-downloader', short: 'yt', aliases: ['youtube', 'ytdl', 'youtubedownloader', 'youtube-download'] },
yt: { canonical: 'youtube-downloader', short: 'yt', aliases: ['youtube', 'ytdl', 'youtubedownloader', 'youtube-download'] },
};
};
// The same tool built twice (once in Text, once in Developer Tools). People see ONE entry, under the
// The same tool built twice (once in Text, once in Developer Tools). People see ONE entry, under the
@@ -238,6 +238,102 @@ const DEV = {
@@ -238,6 +238,102 @@ const DEV = {
bullets: ['Live preview of the social card for any public URL', 'Lists Open Graph, Twitter Card and standard meta tags', 'Flags missing titles, descriptions and images'],
bullets: ['Live preview of the social card for any public URL', 'Lists Open Graph, Twitter Card and standard meta tags', 'Flags missing titles, descriptions and images'],
faq: [['Why does my link preview show no image?', 'Usually a missing og:image tag, or an image URL that is relative rather than absolute. Social platforms need a full https URL.'], ['Why has my preview not updated after I fixed the tags?', 'Platforms cache previews aggressively. Most offer a debugger that forces a re-scrape; this tool always fetches live so you can confirm the tags themselves are correct.']],
faq: [['Why does my link preview show no image?', 'Usually a missing og:image tag, or an image URL that is relative rather than absolute. Social platforms need a full https URL.'], ['Why has my preview not updated after I fixed the tags?', 'Platforms cache previews aggressively. Most offer a debugger that forces a re-scrape; this tool always fetches live so you can confirm the tags themselves are correct.']],
},
},
+ jsminify: {
+ name: "JavaScript Minifier", title: "JavaScript Minifier \u2014 Minify JS Online",
+ desc: "Shrink JavaScript with a real parser, not regex. Paste JavaScript and get a smaller file that behaves the same.",
+ kw: "js minifier, minify javascript, javascript minifier online, js compressor, uglify js, terser online, minify js code",
+ about: "Paste JavaScript and get a smaller file that behaves the same. The minifier parses your code properly (it uses Terser), so it can remove dead code, shorten expressions and rename local variables safely, which regex-based minifiers cannot do. Everything runs in your browser; your code is not uploaded.",
+ bullets: ["Three levels: whitespace only, full minify, or minify with variable mangling", "Understands modern syntax: modules, classes, async, optional chaining", "Runs locally in your browser, so private code stays private"],
+ faq: [["Will minified code still work?", "Yes. Minifying removes whitespace and comments and rewrites expressions into shorter equivalents. Mangling renames local variables only, never globals or property names, so the behaviour stays the same."], ["Is my code uploaded?", "No. The minifier is loaded into your browser and runs there."]],
+ },
+ jsformat: {
+ name: "JavaScript Beautifier", title: "JavaScript Beautifier \u2014 Format & Unminify JS",
+ desc: "Turn minified or messy JavaScript into readable code. Paste minified, bundled or badly indented JavaScript and get it back with consistent indentation and line breaks, so you can read a production bundle or clean up a snippet before sharing it.",
+ kw: "js beautifier, javascript formatter, unminify js, format javascript online, pretty print js, js prettifier",
+ about: "Paste minified, bundled or badly indented JavaScript and get it back with consistent indentation and line breaks, so you can read a production bundle or clean up a snippet before sharing it. It parses the code rather than guessing, and runs in your browser.",
+ bullets: ["Re-indents and re-breaks code from a real syntax tree", "Good for reading minified bundles and third-party scripts", "Nothing is uploaded"],
+ faq: [["Can it restore original variable names?", "No. Names shortened by a minifier are gone unless you have a source map. Beautifying restores layout, not names."]],
+ },
+ cssminify: {
+ name: "CSS Minifier", title: "CSS Minifier \u2014 Minify CSS Online",
+ desc: "Strip comments and whitespace from stylesheets. Paste a stylesheet and get a compact version for production: comments and whitespace removed, trailing semicolons dropped, zero units and long hex colours shortened.",
+ kw: "css minifier, minify css, css compressor online, compress css, minify stylesheet",
+ about: "Paste a stylesheet and get a compact version for production: comments and whitespace removed, trailing semicolons dropped, zero units and long hex colours shortened. Strings and url() values are left untouched.",
+ bullets: ["Removes comments, whitespace and redundant semicolons", "Shortens #ffffff to #fff and 0px to 0", "Leaves strings and url() values alone"],
+ faq: [["Does it change how my CSS works?", "No. It only removes characters the browser ignores and rewrites values into shorter forms that mean the same thing."]],
+ },
+ cssformat: {
+ name: "CSS Beautifier", title: "CSS Beautifier \u2014 Format & Unminify CSS",
+ desc: "Re-indent minified or messy CSS. Turn a one-line or inconsistently formatted stylesheet into readable CSS with one declaration per line and nested indentation for media queries and other at-rules..",
+ kw: "css beautifier, css formatter, unminify css, format css online, pretty print css",
+ about: "Turn a one-line or inconsistently formatted stylesheet into readable CSS with one declaration per line and nested indentation for media queries and other at-rules.",
+ bullets: ["One declaration per line, two-space indentation", "Handles nested at-rules such as @media and @supports", "Runs in your browser"],
+ faq: [["Does it reorder my rules?", "No. Order matters in CSS, so rules and declarations stay exactly where they were."]],
+ },
+ htmlminify: {
+ name: "HTML Minifier", title: "HTML Minifier \u2014 Minify HTML Online",
+ desc: "Remove comments and whitespace from HTML. Make an HTML document smaller by removing comments and the whitespace between tags.",
+ kw: "html minifier, minify html, html compressor online, compress html, minify web page",
+ about: "Make an HTML document smaller by removing comments and the whitespace between tags. Useful for email templates, embedded snippets and static pages where every kilobyte is sent on each request.",
+ bullets: ["Removes comments and inter-tag whitespace", "Keeps the content of pre, textarea, script and style intact", "Runs in your browser"],
+ faq: [["Can minifying break my layout?", "Whitespace between inline elements can be meaningful. The minifier collapses it to a single space rather than removing it, which keeps inline text spacing correct."]],
+ },
+ xmlminify: {
+ name: "XML Minifier", title: "XML Minifier \u2014 Minify XML Online",
+ desc: "Collapse XML to a single compact line. Strip comments and the whitespace between elements from XML, SVG, RSS and sitemap files.",
+ kw: "xml minifier, minify xml, compress xml online, xml compressor, minify svg",
+ about: "Strip comments and the whitespace between elements from XML, SVG, RSS and sitemap files. Text inside elements and CDATA sections is preserved.",
+ bullets: ["Removes comments and whitespace between tags", "Keeps text content and CDATA untouched", "Works for SVG, RSS, Atom and sitemaps"],
+ faq: [["Is the result still valid XML?", "Yes. Only insignificant whitespace and comments are removed, and the document is checked for well-formedness first."]],
+ },
+ jsonminify: {
+ name: "JSON Minifier", title: "JSON Minifier \u2014 Minify & Compact JSON Online",
+ desc: "Compact JSON to the smallest valid form. Remove every unnecessary space and line break from a JSON document.",
+ kw: "json minifier, minify json, compact json, json compressor online, json minify",
+ about: "Remove every unnecessary space and line break from a JSON document. The input is parsed first, so you also find out if it is invalid, and the output is guaranteed to be valid JSON.",
+ bullets: ["Validates while it minifies", "Reports how many bytes were saved", "Runs in your browser; the data is not uploaded"],
+ faq: [["Does minifying change the data?", "No. Only whitespace outside strings is removed. Keys, values and their order stay the same."]],
+ },
+ jsonparse: {
+ name: "JSON Parser", title: "JSON Parser & Viewer \u2014 Explore JSON Online",
+ desc: "Explore JSON as a tree, a list of paths or a type map. Paste JSON and see what is actually in it: an indented tree, a flat list of every path with its value (handy for writing jq filters or accessing nested fields in code), or a map of the types at each path..",
+ kw: "json parser, json viewer, json tree viewer, parse json online, json path finder, json explorer",
+ about: "Paste JSON and see what is actually in it: an indented tree, a flat list of every path with its value (handy for writing jq filters or accessing nested fields in code), or a map of the types at each path.",
+ bullets: ["Tree, flat path list and type map views", "Paths are written the way you would access them in code", "Pinpoints the position of a syntax error"],
+ faq: [["What is the path list for?", "It shows every leaf as a path such as user.roles[0], which is what you need when writing a jq filter, a JSONPath query or plain property access."]],
+ },
+ jsonvalidate: {
+ name: "JSON Validator", title: "JSON Validator \u2014 JSON Lint Online",
+ desc: "Check JSON and find the exact error. Find out whether a document is valid JSON and, if it is not, where it breaks: the line and column of the first error, with the common causes (trailing commas, single quotes, unquoted keys, comments) called out..",
+ kw: "json validator, json lint, validate json online, json checker, json syntax error, jsonlint",
+ about: "Find out whether a document is valid JSON and, if it is not, where it breaks: the line and column of the first error, with the common causes (trailing commas, single quotes, unquoted keys, comments) called out.",
+ bullets: ["Line and column of the first syntax error", "Names the usual culprits: trailing commas, single quotes, comments", "Summarises the structure when the document is valid"],
+ faq: [["Why is a trailing comma an error?", "JSON is stricter than JavaScript. A comma after the last item in an object or array is not allowed by the JSON specification, even though most JavaScript engines accept it in code."]],
+ },
+ jsonstringify: {
+ name: "JSON Stringify", title: "JSON Stringify & Unescape Online",
+ desc: "Turn JSON into an escaped string literal, and back. Convert a JSON document into a single escaped string, the form you need when embedding JSON inside another JSON value, an environment variable or a source file.",
+ kw: "json stringify online, json escape, json unescape, escape json string, json to string, string to json",
+ about: "Convert a JSON document into a single escaped string, the form you need when embedding JSON inside another JSON value, an environment variable or a source file. The reverse mode takes an escaped string and gives the JSON back.",
+ bullets: ["Escapes quotes, backslashes and newlines correctly", "Reverse mode parses a quoted, escaped string back into JSON", "Runs in your browser"],
+ faq: [["When do I need this?", "Whenever JSON has to travel inside a string: a JSON field that holds JSON, a config value, a test fixture or a shell variable."]],
+ },
+ html2md: {
+ name: "HTML to Markdown", title: "HTML to Markdown Converter Online",
+ desc: "Convert HTML into clean Markdown. Paste HTML from a web page, an email or a CMS and get Markdown you can put in a README, a wiki or a static site.",
+ kw: "html to markdown, convert html to markdown, html2md, html to md converter, web page to markdown",
+ about: "Paste HTML from a web page, an email or a CMS and get Markdown you can put in a README, a wiki or a static site. Headings, emphasis, links, images, lists, quotes, code blocks and tables are converted; scripts and styling are dropped.",
+ bullets: ["Headings, lists, links, images, quotes, code and tables", "Drops scripts, styles and presentational markup", "Parsed by your browser; nothing is uploaded"],
+ faq: [["What happens to things Markdown cannot express?", "Layout markup such as div and span is unwrapped so its text is kept. Scripts, styles and form controls are removed."]],
+ },
+ md2html: {
+ name: "Markdown to HTML", title: "Markdown to HTML Converter Online",
+ desc: "Convert Markdown into HTML you can paste anywhere. Write or paste Markdown and copy the HTML it produces, ready for a CMS, an email template or a page that does not render Markdown itself.",
+ kw: "markdown to html, convert markdown to html, md to html, markdown converter online, markdown html generator",
+ about: "Write or paste Markdown and copy the HTML it produces, ready for a CMS, an email template or a page that does not render Markdown itself. A preview mode shows the rendered result.",
+ bullets: ["Copy-ready HTML output, or a rendered preview", "Headings, emphasis, lists, links, quotes and code", "Runs in your browser"],
+ faq: [["Is this the same as the Markdown editor?", "The editor is for writing with a live preview. This tool is for one job: getting the HTML out."]],
+ },
};
};
// ── Network & diagnostics tools (net.openvibe.tools) ─────────
// ── Network & diagnostics tools (net.openvibe.tools) ─────────
@@ -119,6 +119,7 @@ function renderTool(file, sub, canonicalHost) {
@@ -119,6 +119,7 @@ function renderTool(file, sub, canonicalHost) {
html = html.replace(/<link\s+rel=["']canonical["'][^>]*>/i, `<link rel="canonical" href="${url}">`);
html = html.replace(/<link\s+rel=["']canonical["'][^>]*>/i, `<link rel="canonical" href="${url}">`);
const extraHead = [
const extraHead = [
+ `<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="keywords" content="${esc(rec.kw || '')}">`,
'<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1">',
'<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1">',
'<meta name="twitter:card" content="summary_large_image">',
'<meta name="twitter:card" content="summary_large_image">',
@@ -107,6 +107,10 @@
@@ -107,6 +107,10 @@
.ovnav-launcher .ovl-seg button[aria-pressed="true"] { background: var(--accent, #3b82f6); color: var(--on-accent, #fff); }
.ovnav-launcher .ovl-seg button[aria-pressed="true"] { background: var(--accent, #3b82f6); color: var(--on-accent, #fff); }
.ovnav-launcher .ovl-seg button:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: -2px; }
.ovnav-launcher .ovl-seg button:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: -2px; }
.ovnav-launcher .ovl-display a { margin-left: auto; color: var(--accent-light, var(--accent, #60a5fa)); font-weight: 600; }
.ovnav-launcher .ovl-display a { margin-left: auto; color: var(--accent-light, var(--accent, #60a5fa)); font-weight: 600; }
+ .ovnav-launcher .ovl-addr a { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11.5px; }
+ .ovnav-launcher .ovl-addr a.is-here { border-color: var(--accent, #3b82f6); color: var(--text-primary, #e6edf7); }
+ .ovnav-launcher .ovl-all { grid-column: 1 / -1; color: var(--accent-light, var(--accent, #60a5fa)); }
+ .ovnav-launcher .ovl-h span { text-transform: none; letter-spacing: 0; font-weight: 500; }
.ovnav-launcher .ovl-empty { padding: 18px 8px; color: var(--text-secondary, #a8b3c4); font-size: 13px; }
.ovnav-launcher .ovl-empty { padding: 18px 8px; color: var(--text-secondary, #a8b3c4); font-size: 13px; }
@media (max-width: 480px) { .ovnav-launcher { left: 8px; } .ovnav-launcher .ovl-fams { grid-template-columns: 1fr; } }
@media (max-width: 480px) { .ovnav-launcher { left: 8px; } .ovnav-launcher .ovl-fams { grid-template-columns: 1fr; } }
@media (prefers-reduced-motion: reduce) { .ovnav-launcher { transition: none; } }
@media (prefers-reduced-motion: reduce) { .ovnav-launcher { transition: none; } }
@@ -613,17 +617,19 @@
@@ -613,17 +617,19 @@
{ name: 'Developer Tools', icon: 'code', url: 'https://dev.openvibe.tools/' },
{ name: 'Developer Tools', icon: 'code', url: 'https://dev.openvibe.tools/' },
{ name: 'Network Tools', icon: 'dns', url: 'https://net.openvibe.tools/' },
{ name: 'Network Tools', icon: 'dns', url: 'https://net.openvibe.tools/' },
];
];
- const CATALOG_URL = 'https://openvibe.tools/api/catalog.json';
+ const CATALOG_URL = 'https://openvibe.network/api/catalog.json'; // allowed by every site's CSP
+ const TOOLS_SEARCH = 'https://openvibe.tools/search?q=';
const okUrl = (u) => { try { const x = new URL(u); return x.protocol === 'https:' ? x.href : null; } catch { return null; } };
const okUrl = (u) => { try { const x = new URL(u); return x.protocol === 'https:' ? x.href : null; } catch { return null; } };
async function launcherCatalog() {
async function launcherCatalog() {
- try { const c = JSON.parse(sessionStorage.getItem('ov_catalog') || 'null'); if (c && Date.now() - c.at < 30 * 60000) return c.data; } catch { /* */ }
+ try { const c = JSON.parse(sessionStorage.getItem('ov_catalog2') || 'null'); if (c && Date.now() - c.at < 30 * 60000) return c.data; } catch { /* */ }
try {
try {
const r = await fetch(CATALOG_URL, { credentials: 'omit' }); if (!r.ok) return null;
const r = await fetch(CATALOG_URL, { credentials: 'omit' }); if (!r.ok) return null;
const j = await r.json();
const j = await r.json();
const data = { families: (j.families || []).slice(0, 12).map(f => ({ name: String(f.name || ''), icon: String(f.icon || 'tools'), url: okUrl(f.url) })).filter(f => f.name && f.url),
const data = { families: (j.families || []).slice(0, 12).map(f => ({ name: String(f.name || ''), icon: String(f.icon || 'tools'), url: okUrl(f.url) })).filter(f => f.name && f.url),
- tools: (j.tools || []).slice(0, 400).map(t => ({ name: String(t.name || ''), icon: String(t.icon || 'tools'), url: okUrl(t.url), k: [t.name, t.tagline].concat(t.keywords || []).join(' ').toLowerCase().slice(0, 400) })).filter(t => t.name && t.url) };
- try { sessionStorage.setItem('ov_catalog', JSON.stringify({ at: Date.now(), data })); } catch { /* */ }
+ tools: (j.tools || []).slice(0, 400).map(t => ({ name: String(t.name || ''), icon: String(t.icon || 'tools'), url: okUrl(t.url), go: t.hosts && t.hosts.short ? okUrl('https://' + t.hosts.short + '/') : null,
+ hosts: [t.hosts && t.hosts.short, t.hosts && t.hosts.canonical].concat((t.hosts && t.hosts.mirrors) || [], (t.hosts && t.hosts.aliases) || []).filter(h => typeof h === 'string' && h), id: String(t.id || ''), k: [t.name, t.tagline].concat(t.keywords || []).join(' ').toLowerCase().slice(0, 400) })).filter(t => t.name && t.url) };
+ try { sessionStorage.setItem('ov_catalog2', JSON.stringify({ at: Date.now(), data })); } catch { /* */ }
return data;
return data;
} catch { return null; }
} catch { return null; }
}
}
@@ -631,20 +637,33 @@
@@ -631,20 +637,33 @@
function bindLauncher(nav) {
function bindLauncher(nav) {
const btn = nav.querySelector('#openvibe-launcher-btn'); if (!btn) return;
const btn = nav.querySelector('#openvibe-launcher-btn'); if (!btn) return;
let panel = null;
let panel = null;
- const tile = (it, cls) => `<a class="${cls}" href="${escapeAttr(it.url)}"><span class="ov-icon" data-icon="${escapeAttr(it.icon)}" data-size="${cls === 'ovl-site' ? 34 : 24}" data-fx="none"></span><span><b>${escapeAttr(it.name)}</b>${it.desc ? `<small>${escapeAttr(it.desc)}</small>` : ''}</span></a>`;
+ const tile = (it, cls) => `<a class="${cls}" href="${escapeAttr(it.go || it.url)}"><span class="ov-icon" data-icon="${escapeAttr(it.icon)}" data-size="${cls === 'ovl-site' ? 34 : 24}" data-fx="none"></span><span><b>${escapeAttr(it.name)}</b>${it.desc ? `<small>${escapeAttr(it.desc)}</small>` : ''}</span></a>`;
const close = () => { if (panel) panel.classList.remove('open'); btn.setAttribute('aria-expanded', 'false'); };
const close = () => { if (panel) panel.classList.remove('open'); btn.setAttribute('aria-expanded', 'false'); };
function paint(cat, q) {
function paint(cat, q) {
const fams = (cat && cat.families.length ? cat.families : LAUNCHER_FAMILIES);
const fams = (cat && cat.families.length ? cat.families : LAUNCHER_FAMILIES);
const body = panel.querySelector('.ovl-body');
const body = panel.querySelector('.ovl-body');
- if (q && cat) {
- const hits = cat.tools.filter(t => t.k.includes(q)).slice(0, 12);
- body.innerHTML = hits.length ? `<div class="ovl-h">Tools</div><div class="ovl-fams">${hits.map(t => tile(t, 'ovl-fam')).join('')}</div>` : '<div class="ovl-empty">No tool matches. <a href="https://openvibe.tools/">Browse them all</a></div>';
+ if (q) {
+ // Sites and families always filter locally; tools need the catalog. Without it (offline, blocked),
+ // the last row hands the query to the Tools search page, so typing never does nothing.
+ const hit = (x) => (x.name + ' ' + (x.desc || '')).toLowerCase().includes(q);
+ const siteList = (OVChrome.get() && OVChrome.get().nav.length ? OVChrome.get().nav.map(n => ({ name: n.name, desc: n.tagline, icon: n.icon || n.id, url: n.url })) : LAUNCHER_SITES).filter(hit);
+ const famList = fams.filter(hit);
+ const tools = cat ? cat.tools.filter(t => t.k.includes(q)).slice(0, 12) : [];
+ const more = `<a class="ovl-fam ovl-all" href="${escapeAttr(TOOLS_SEARCH + encodeURIComponent(q))}"><span class="ov-icon" data-icon="search" data-size="24" data-fx="none"></span><span><b>Search all tools for “${escapeAttr(q)}”</b></span></a>`;
+ body.innerHTML = (siteList.length ? `<div class="ovl-h">Sites</div><div class="ovl-fams">${siteList.map(x => tile(x, 'ovl-fam')).join('')}</div>` : '')
+ + (famList.length ? `<div class="ovl-h">Tool families</div><div class="ovl-fams">${famList.map(x => tile(x, 'ovl-fam')).join('')}</div>` : '')
+ + `<div class="ovl-h">Tools${cat ? '' : ' <span>loading…</span>'}</div><div class="ovl-fams">${tools.map(t => tile(t, 'ovl-fam')).join('')}${more}</div>`;
return;
return;
}
}
const chrome = OVChrome.get();
const chrome = OVChrome.get();
const sites = chrome && chrome.nav.length ? chrome.nav.map(n => ({ name: n.name, desc: n.tagline, icon: n.icon || n.id, url: n.url })) : LAUNCHER_SITES;
const sites = chrome && chrome.nav.length ? chrome.nav.map(n => ({ name: n.name, desc: n.tagline, icon: n.icon || n.id, url: n.url })) : LAUNCHER_SITES;
const soon = chrome ? chrome.soon : [];
const soon = chrome ? chrome.soon : [];
- body.innerHTML = `<div class="ovl-h">Sites</div><div class="ovl-sites">${sites.map(x => tile(x, 'ovl-site')).join('')}</div>
+ // On a tool: every address it answers to (short, search-friendly, mirrors, custom domains), so people
+ // can pick the one they will remember.
+ const cur = currentHost().toLowerCase();
+ const here = cat ? cat.tools.find(t => t.hosts.indexOf(cur) >= 0) : null;
+ const addresses = here && here.hosts.length > 1 ? `<div class="ovl-h">${escapeAttr(here.name)} lives at <a href="https://openvibe.tools/tool/${escapeAttr(here.id)}">About</a></div><div class="ovl-soon ovl-addr">${[...new Set(here.hosts)].filter(h => /^[a-z0-9.-]+$/.test(h)).map(h => `<a href="https://${h}/"${h === cur ? ' class="is-here" aria-current="page"' : ''}>${h}</a>`).join('')}</div>` : '';
+ body.innerHTML = addresses + `<div class="ovl-h">Sites</div><div class="ovl-sites">${sites.map(x => tile(x, 'ovl-site')).join('')}</div>
<div class="ovl-h">Tools <a href="https://openvibe.tools/">See all</a></div><div class="ovl-fams">${fams.map(x => tile(x, 'ovl-fam')).join('')}</div>
<div class="ovl-h">Tools <a href="https://openvibe.tools/">See all</a></div><div class="ovl-fams">${fams.map(x => tile(x, 'ovl-fam')).join('')}</div>
${soon.length ? `<div class="ovl-h">Opening soon</div><div class="ovl-soon">${soon.map(x => `<a href="${escapeAttr(x.url)}">${escapeAttr(x.name)}</a>`).join('')}</div>` : ''}`;
${soon.length ? `<div class="ovl-h">Opening soon</div><div class="ovl-soon">${soon.map(x => `<a href="${escapeAttr(x.url)}">${escapeAttr(x.name)}</a>`).join('')}</div>` : ''}`;
}
}
@@ -660,7 +679,7 @@
@@ -660,7 +679,7 @@
let cat = null; paint(null, '');
let cat = null; paint(null, '');
const input = panel.querySelector('.ovl-q');
const input = panel.querySelector('.ovl-q');
input.addEventListener('input', () => paint(cat, input.value.trim().toLowerCase()));
input.addEventListener('input', () => paint(cat, input.value.trim().toLowerCase()));
- input.addEventListener('keydown', (ev) => { if (ev.key === 'Enter') { const first = panel.querySelector('.ovl-body a'); if (first) location.href = first.href; } if (ev.key === 'ArrowDown') { const first = panel.querySelector('.ovl-body a'); if (first) { ev.preventDefault(); first.focus(); } } });
+ input.addEventListener('keydown', (ev) => { if (ev.key === 'Enter') { const first = panel.querySelector('.ovl-body a'); location.href = first ? first.href : TOOLS_SEARCH + encodeURIComponent(input.value.trim()); } if (ev.key === 'ArrowDown') { const first = panel.querySelector('.ovl-body a'); if (first) { ev.preventDefault(); first.focus(); } } });
panel.addEventListener('keydown', (ev) => {
panel.addEventListener('keydown', (ev) => {
if (ev.key === 'Escape') { close(); btn.focus(); return; }
if (ev.key === 'Escape') { close(); btn.focus(); return; }
if (ev.key !== 'ArrowDown' && ev.key !== 'ArrowUp') return;
if (ev.key !== 'ArrowDown' && ev.key !== 'ArrowUp') return;