openvibers/openvibe.tools Commits
Browser tools get server engines: the pure transforms behind the dev and text pages move out of the pages into engine files the pages load and Node can require, so a tool API can run the page's own code (ADR-027). public/js/dev-engine.js holds the dev tools' transforms (JSON minify/validate/parse/stringify, YAML, XML format/minify, CSV, SQL, HTML, Markdown to HTML, Base64, URL, JWT decode, UUID, hash, hex, timestamps, cron, beautify/minify, CSS minify/format, HTML minify, lorem, curl conversion, colour); dev.html loads it and only adds presentation (DOMParser validation, entity decoding, terser, the regex tester, HTML to Markdown and the webhook and Open Graph calls stay in the page). text-engine.js gains the transforms that were inline in pages (hex/decimal/octal code points, line shuffle and reverse, full Braille both ways, the twelve escapes, the character inspector, the cleaner's steps in order) and the new format-engine.js holds JSON helpers, the Markdown renderer and the line diff for json., markdown. and compare. (which do not load the font tables). Each page was run old and new in a vm with the same inputs: dev.html's 99 sample/mode cases, eight text pages' 103 writes and the cleaner's 512 option combinations answer identically. server/dev/engines.js and apps/text/server/engines.js wrap them for the API ({ text } or { data } per call; compare refuses more than 2000 lines a side); the pages stay browser-only
Changes
@@@ -147,6 +147,7 @@ a{color:inherit;text-decoration:none}<script src="https://openvibe.network/shared/navbar.js"></script><script src="https://openvibe.network/shared/footer.js"></script><script src="https://openvibe.network/shared/theme-loader.js" defer></script>++<script src="/js/dev-engine.js"></script><script>'use strict';const API='https://openvibe.tools/api/dev',$=s=>document.getElementById(s),@@@ -267,80 +268,35 @@ async function run(){}const P={};--P.jsonfmt=(s,m)=>{const o=JSON.parse(s);if(m==='Minify')return JSON.stringify(o);if(m==='Validate')return'\u2713 Valid JSON \u2014 '+(Array.isArray(o)?o.length+' items':typeof o==='object'?Object.keys(o).length+' keys':typeof o);return JSON.stringify(o,null,2)};----P.yaml=(s,m)=>{-- if(m.includes('JSON\u2192')){const o=JSON.parse(s);return j2y(o,'')}-- const ln=s.split('\n').filter(l=>l.trim()&&!l.trim().startsWith('#')),r={};-- for(const l of ln){const mt=l.match(/^(\s*)([^:]+):\s*(.*)$/);if(mt){let v=mt[3].trim();v=v===''||v==='~'||v==='null'?null:v==='true'?true:v==='false'?false:/^-?\d+(\.\d+)?$/.test(v)?Number(v):v.replace(/^["']|["']$/g,'');r[mt[2].trim()]=v}}-- return JSON.stringify(r,null,2);--};--function j2y(o,ind){let r='';if(Array.isArray(o))o.forEach(v=>{r+=ind+'- '+(typeof v==='object'&&v?'\n'+j2y(v,ind+' '):v)+'\n'});--else if(typeof o==='object'&&o)for(const[k,v]of Object.entries(o))r+=typeof v==='object'&&v?ind+k+':\n'+j2y(v,ind+' '):ind+k+': '+v+'\n';--else r+=ind+o+'\n';return r}--++// The transforms live in /js/dev-engine.js (shared with the tools API); these add the page's presentation.++P.jsonfmt=(s,m)=>DevEngine.jsonfmt(s,m);++P.yaml=(s,m)=>DevEngine.yaml(s,m);P.xml=(s,m)=>{if(m==='Validate'){const d=new DOMParser().parseFromString(s,'text/xml');if(d.querySelector('parsererror'))throw new Error(d.querySelector('parsererror').textContent);return'\u2713 Valid XML'}-- if(m==='Minify')return s.replace(/>\s+</g,'><').replace(/\s{2,}/g,' ').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();++ return DevEngine.xml(s,m);};--P.csv=(s,m)=>{-- const rows=s.trim().split('\n').map(r=>{const c=[];let cur='',q=false;for(let i=0;i<r.length;i++){if(r[i]==='"')q=!q;else if(r[i]===','&&!q){c.push(cur.trim());cur=''}else cur+=r[i]}c.push(cur.trim());return c});-- if(m==='To JSON'){const hd=rows[0];return JSON.stringify(rows.slice(1).map(r=>{const o={};hd.forEach((h,i)=>o[h]=r[i]||'');return o}),null,2)}++ if(m==='To JSON')return JSON.stringify(DevEngine.csvObjects(s),null,2);++ const rows=DevEngine.csvRows(s);return{html:'<div style="overflow-x:auto"><table style="border-collapse:collapse;width:100%;font-size:.82rem">'+rows.map((r,ri)=>'<tr>'+r.map(c=>'<'+(ri?'td':'th')+' style="border:1px solid var(--brd);padding:6px 10px;text-align:left;'+(ri?'':'background:var(--bg3);font-weight:700')+'">'+H(c)+'</'+(ri?'td':'th')+'>').join('')+'</tr>').join('')+'</table></div>'};};----P.sql=(s,m)=>{-- if(m==='Compact')return s.replace(/\s+/g,' ').trim();-- const kw=['SELECT','DISTINCT','FROM','WHERE','JOIN','LEFT JOIN','RIGHT JOIN','INNER JOIN','ON','AND','OR','ORDER BY','GROUP BY','HAVING','LIMIT','OFFSET','INSERT INTO','VALUES','UPDATE','SET','DELETE FROM','CREATE TABLE','ALTER TABLE','UNION','CASE','WHEN','THEN','ELSE','END'];-- let r=s;if(m==='Uppercase')kw.forEach(k=>{r=r.replace(new RegExp('\\b'+k.replace(/ /g,'\\s+')+'\\b','gi'),k)});-- kw.slice(0,20).forEach(k=>{r=r.replace(new RegExp('(\\b)('+k.replace(/ /g,'\\s+')+')(\\b)','gi'),'\n$1$2$3')});-- return r.replace(/^\n+/,'').replace(/\n{2,}/g,'\n');--};----P.md=(s,m)=>{-- let h=s.replace(/^######\s(.+)/gm,'<h6>$1</h6>').replace(/^#####\s(.+)/gm,'<h5>$1</h5>').replace(/^####\s(.+)/gm,'<h4>$1</h4>')-- .replace(/^###\s(.+)/gm,'<h3>$1</h3>').replace(/^##\s(.+)/gm,'<h2>$1</h2>').replace(/^#\s(.+)/gm,'<h1>$1</h1>')-- .replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>').replace(/\*(.+?)\*/g,'<em>$1</em>')-- .replace(/`([^`]+)`/g,'<code style="background:var(--bg3);padding:2px 5px;border-radius:4px">$1</code>')-- .replace(/^\- (.+)/gm,'<li>$1</li>').replace(/^> (.+)/gm,'<blockquote style="border-left:3px solid var(--ac);padding-left:12px;color:var(--tx2)">$1</blockquote>')-- .replace(/\[([^\]]+)\]\(([^)]+)\)/g,'<a href="$2" style="color:var(--ac)">$1</a>')-- .replace(/^---$/gm,'<hr style="border:none;border-top:1px solid var(--brd);margin:16px 0">');-- if(m==='To HTML')return h;-- return{html:'<div style="line-height:1.8;font-family:Inter,sans-serif;font-size:.92rem">'+h+'</div>'};--};--++P.sql=(s,m)=>DevEngine.sql(s,m);++P.md=(s,m)=>{const h=DevEngine.md(s);if(m==='To HTML')return h;return{html:'<div style="line-height:1.8;font-family:Inter,sans-serif;font-size:.92rem">'+h+'</div>'}};P.html=(s,m)=>{-- if(m==='Entities')return s.replace(/[\u00A0-\u9999<>&'"]/g,c=>'&#'+c.charCodeAt(0)+';');if(m==='Unescape'){const d=document.createElement('div');d.innerHTML=s;return d.textContent}-- if(m==='Minify')return s.replace(/\s+/g,' ').replace(/>\s+</g,'><').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();++ return DevEngine.html(s,m);};--P.base64=(s,m)=>m==='Decode'?decodeURIComponent(escape(atob(s.trim()))):btoa(unescape(encodeURIComponent(s)));----P.url=(s,m)=>{if(m==='Encode')return encodeURIComponent(s);if(m==='Decode')return decodeURIComponent(s);-- try{const u=new URL(s);return'Protocol: '+u.protocol+'\nHost: '+u.host+'\nPath: '+u.pathname+'\nSearch: '+u.search+'\nHash: '+u.hash+'\nOrigin: '+u.origin}catch{return'Invalid URL \u2014 include protocol (https://)'}};--++P.base64=(s,m)=>DevEngine.base64(s,m);++P.url=(s,m)=>DevEngine.url(s,m);P.jwt=(s)=>{-- const p=s.trim().split('.');if(p.length!==3)throw new Error('JWT must have 3 dot-separated parts');-- const d=v=>JSON.parse(decodeURIComponent(escape(atob(v.replace(/-/g,'+').replace(/_/g,'/')))));-- const hdr=d(p[0]),pay=d(p[1]);let ex='';-- if(pay.exp){const dt=new Date(pay.exp*1000),left=dt-Date.now();ex=left>0?'\n\u23f1 Expires: '+dt.toISOString()+' ('+Math.floor(left/36e5)+'h '+Math.floor(left%36e5/6e4)+'m left)':'\n\u26a0 EXPIRED: '+dt.toISOString()}-- return{html:'<div class="js"><div class="jl">Header</div>'+H(JSON.stringify(hdr,null,2))+'</div><div class="js"><div class="jl">Payload</div>'+H(JSON.stringify(pay,null,2))+(ex?'<div style="margin-top:6px;color:'+(ex.includes('EXPIRED')?'#f87171':'#34d399')+'">'+H(ex)+'</div>':'')+'</div><div class="js"><div class="jl">Signature</div>'+H(p[2])+'</div>'};++ const j=DevEngine.jwt(s);let ex='';++ if(j.exp){const left=j.exp.msLeft;ex=left>0?'\n\u23f1 Expires: '+j.exp.at+' ('+Math.floor(left/36e5)+'h '+Math.floor(left%36e5/6e4)+'m left)':'\n\u26a0 EXPIRED: '+j.exp.at}++ return{html:'<div class="js"><div class="jl">Header</div>'+H(JSON.stringify(j.header,null,2))+'</div><div class="js"><div class="jl">Payload</div>'+H(JSON.stringify(j.payload,null,2))+(ex?'<div style="margin-top:6px;color:'+(ex.includes('EXPIRED')?'#f87171':'#34d399')+'">'+H(ex)+'</div>':'')+'</div><div class="js"><div class="jl">Signature</div>'+H(j.signature)+'</div>'};};----P.uuid=(s,m)=>{-- if(m==='Validate')return/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(s.trim())?'\u2713 Valid UUID v4':'\u2717 Invalid UUID v4';-- const n=m.includes('100')?100:m.includes('10')?10:1;return Array.from({length:n},()=>crypto.randomUUID()).join('\n');--};----P.hash=async(s,m)=>{const a=m==='SHA-1'?'SHA-1':m==='SHA-512'?'SHA-512':'SHA-256';-- const buf=await crypto.subtle.digest(a,new TextEncoder().encode(s));return a+': '+Array.from(new Uint8Array(buf)).map(b=>b.toString(16).padStart(2,'0')).join('')};----P.hex=(s,m)=>{if(m==='From Hex')return s.trim().replace(/\s+/g,'').match(/.{2}/g).map(h=>String.fromCharCode(parseInt(h,16))).join('');-- return[...s].map(c=>c.charCodeAt(0).toString(16).padStart(2,'0')).join(' ')};++P.uuid=(s,m)=>DevEngine.uuid(s,m);++P.hash=async(s,m)=>{const a=m==='SHA-1'?'SHA-1':m==='SHA-512'?'SHA-512':'SHA-256';return a+': '+await DevEngine.hash(s,a)};++P.hex=(s,m)=>DevEngine.hex(s,m);P.entities=(s,m)=>{if(m==='HTML Enc')return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');@@@ -350,29 +306,9 @@ P.entities=(s,m)=>{if(m==='URL Enc')return encodeURIComponent(s);return decodeURIComponent(s);};--P.timestamp=(s,m)=>{-- if(m==='Now'){const n=Date.now();return'Unix (s): '+Math.floor(n/1000)+'\nUnix (ms): '+n+'\nISO 8601: '+new Date(n).toISOString()+'\nLocal: '+new Date(n).toLocaleString()+'\nUTC: '+new Date(n).toUTCString()}-- if(m==='To Date'){const v=Number(s.trim()),d=new Date(v<1e12?v*1000:v);if(isNaN(d.getTime()))throw new Error('Invalid timestamp');return'ISO 8601: '+d.toISOString()+'\nLocal: '+d.toLocaleString()+'\nUTC: '+d.toUTCString()+'\nUnix (s): '+Math.floor(d/1000)+'\nUnix (ms): '+Number(d)}-- const d=new Date(s.trim());if(isNaN(d.getTime()))throw new Error('Invalid date string');return'Unix (s): '+Math.floor(d/1000)+'\nUnix (ms): '+Number(d)+'\nISO 8601: '+d.toISOString()+'\nUTC: '+d.toUTCString();--};----P.cron=(s)=>{-- const p=s.trim().split(/\s+/);if(p.length<5)throw new Error('Need 5 fields: minute hour day month weekday');-- const N=[['minute',0,59],['hour',0,23],['day',1,31],['month',1,12],['weekday',0,6]];-- function desc(v,i){if(v==='*')return'every '+N[i][0];if(v.includes('/'))return'every '+v.split('/')[1]+' '+N[i][0]+'s';return N[i][0]+' '+v}-- function expand(v,mn,mx){if(v==='*')return Array.from({length:mx-mn+1},(_,i)=>i+mn);if(v.includes('/')){const[b,st]=v.split('/');const start=b==='*'?mn:+b;const r=[];for(let i=start;i<=mx;i+=+st)r.push(i);return r}-- if(v.includes(','))return v.split(',').map(Number);if(v.includes('-')){const[a,b]=v.split('-').map(Number);return Array.from({length:b-a+1},(_,i)=>i+a)}return[+v]}-- const mins=expand(p[0],0,59),hrs=expand(p[1],0,23),doms=expand(p[2],1,31),mos=expand(p[3],1,12),dws=expand(p[4],0,6);-- const nx=[];const d=new Date();d.setSeconds(0,0);d.setMinutes(d.getMinutes()+1);-- for(let i=0;i<525600&&nx.length<5;i++){if(mins.includes(d.getMinutes())&&hrs.includes(d.getHours())&&doms.includes(d.getDate())&&mos.includes(d.getMonth()+1)&&dws.includes(d.getDay()))nx.push(new Date(d));d.setMinutes(d.getMinutes()+1)}-- return'Schedule: '+p.map((v,i)=>desc(v,i)).join(', ')+'\n\nNext 5 runs:\n'+nx.map((d,i)=>' '+(i+1)+'. '+d.toLocaleString()).join('\n');--};----P.beautify=(s,m)=>{-- if(m==='JSON')return JSON.stringify(JSON.parse(s),null,2);-- if(m==='CSS'){let r='',ind=0;for(const c of s){if(c==='{'){r+=' {\n';ind++;r+=' '.repeat(ind)}else if(c==='}'){ind--;r+='\n'+' '.repeat(ind)+'}\n'+' '.repeat(ind)}else if(c===';')r+=';\n'+' '.repeat(ind);else r+=c}return r.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();--};++P.timestamp=(s,m)=>DevEngine.timestamp(s,m);++P.cron=(s)=>DevEngine.cron(s);++P.beautify=(s,m)=>DevEngine.beautify(s,m);// ── 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)});@@@ -381,36 +317,16 @@ const saved=(a,b)=>{const x=new Blob([a]).size,y=new Blob([b]).size;return x?`${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.cssminify=(s)=>{const o=DevEngine.cssMinify(s);return withNote(o,saved(s,o))};++P.cssformat=(s)=>DevEngine.cssFormat(s);++P.htmlminify=(s)=>{const o=DevEngine.htmlMinify(s);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.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};++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');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');@@@ -430,11 +346,8 @@ P.html2md=(s)=>{const doc=new DOMParser().parseFromString(s,'text/html');const eif(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()};++P.md2html=(s,m)=>{const h=DevEngine.md2html(s);return m==='Preview'?{html:'<div style="line-height:1.8">'+h+'</div>'}:h};++P.minify=(s,m)=>DevEngine.minify(s,m);P.codediff=(a,m,b)=>{const al=a.split('\n'),bl=b.split('\n'),r=[];let ai=0,bi=0;@@@ -459,29 +372,9 @@ P.regex=(pat,m,test)=>{P.slugify=s=>s.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g,'').replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,'');--P.lorem=(s,m)=>{-- const W='lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore magna aliqua enim ad minim veniam quis nostrud exercitation ullamco laboris nisi aliquip ex ea commodo consequat duis aute irure in reprehenderit voluptate velit esse cillum fugiat nulla pariatur excepteur sint occaecat cupidatat non proident sunt culpa qui officia deserunt mollit anim id est laborum'.split(' ');-- const rw=()=>W[Math.random()*W.length|0];-- const sent=()=>{const n=8+(Math.random()*10|0);let s=Array.from({length:n},rw).join(' ');return s[0].toUpperCase()+s.slice(1)+'.'};-- const para=()=>Array.from({length:4+(Math.random()*4|0)},sent).join(' ');-- if(m.includes('Words'))return Array.from({length:50},rw).join(' ');-- if(m.includes('Sent'))return Array.from({length:10},sent).join(' ');-- return Array.from({length:parseInt(m)||3},para).join('\n\n');--};++P.lorem=(s,m)=>DevEngine.lorem(m.includes('Words')?'words':m.includes('Sent')?'sentences':'paragraphs',m.includes('Words')?50:m.includes('Sent')?10:parseInt(m)||3);--P.curlconvert=(s,m)=>{-- const cmd=s.trim().replace(/\\\n/g,' ');let method='GET',url='',hd={},body='';-- const tok=[];let cur='',inQ=false,qc='';-- for(let i=0;i<cmd.length;i++){const c=cmd[i];if(!inQ&&(c==="'"||c==='"')){inQ=true;qc=c}else if(inQ&&c===qc){inQ=false}else if(!inQ&&c===' '){if(cur){tok.push(cur);cur=''}continue}else cur+=c}-- if(cur)tok.push(cur);-- for(let i=0;i<tok.length;i++){const t=tok[i];if(t==='-X'||t==='--request')method=tok[++i];else if(t==='-H'||t==='--header'){const h=tok[++i];const ci=h.indexOf(':');if(ci>0)hd[h.slice(0,ci).trim()]=h.slice(ci+1).trim()}-- else if(t==='-d'||t==='--data'||t==='--data-raw')body=tok[++i];else if(!t.startsWith('-')&&t!=='curl')url=t}-- if(body&&method==='GET')method='POST';-- if(m==='Parsed')return'Method: '+method+'\nURL: '+url+'\nHeaders:\n'+Object.entries(hd).map(([k,v])=>' '+k+': '+v).join('\n')+'\nBody: '+(body||'(none)');-- if(m==='To fetch')return"fetch('"+url+"', {\n method: '"+method+"',\n headers: "+JSON.stringify(hd,null,4).replace(/\n/g,'\n ')+','+(body?"\n body: '"+body+"',":'')+'\n});';-- if(m==='To Python')return"import requests\n\nresponse = requests."+method.toLowerCase()+"(\n '"+url+"',\n headers="+JSON.stringify(hd)+","+(body?"\n data='"+body+"',":'')+"\n)\nprint(response.json())";-- return"const https = require('https');\n\nconst req = https.request('"+url+"', {\n method: '"+method+"',\n headers: "+JSON.stringify(hd,null,4).replace(/\n/g,'\n ')+"\n}, res => {\n let data = '';\n res.on('data', c => data += c);\n res.on('end', () => console.log(data));\n});\n"+(body?"req.write('"+body+"');\n":"")+"req.end();";--};++P.curlconvert=(s,m)=>DevEngine.curlconvert(s,m);P.webhook=async()=>{const o=$('out');@@@ -494,17 +387,8 @@ P.webhook=async()=>{};P.color=(s)=>{-- let r,g,b;const v=s.trim();-- if(v.startsWith('#')){const hx=v.slice(1);r=parseInt(hx.slice(0,2),16);g=parseInt(hx.slice(2,4),16);b=parseInt(hx.slice(4,6),16)}-- else if(v.startsWith('rgb')){[r,g,b]=v.match(/\d+/g).map(Number)}-- else throw new Error('Enter HEX (#ff0000) or RGB (rgb(255,0,0))');-- if([r,g,b].some(x=>isNaN(x)))throw new Error('Invalid color value');-- const mx=Math.max(r,g,b)/255,mn=Math.min(r,g,b)/255,d=mx-mn;let h=0,s2=0,l=(mx+mn)/2;-- if(d){s2=l>.5?d/(2-mx-mn):d/(mx+mn);if(mx===r/255)h=((g/255-b/255)/d+(g<b?6:0))*60;else if(mx===g/255)h=((b/255-r/255)/d+2)*60;else h=((r/255-g/255)/d+4)*60}-- const hex='#'+[r,g,b].map(c=>c.toString(16).padStart(2,'0')).join('');-- const hsl='hsl('+Math.round(h)+', '+Math.round(s2*100)+'%, '+Math.round(l*100)+'%)';-- const comp='#'+[255-r,255-g,255-b].map(c=>c.toString(16).padStart(2,'0')).join('');-- return{html:'<div><div class="sw" style="background:'+hex+'" title="'+hex+'"></div><div class="sw" style="background:'+comp+'" title="Complement"></div></div><div style="line-height:2.2;margin-top:8px">HEX: <strong>'+hex+'</strong>\nRGB: <strong>rgb('+r+', '+g+', '+b+')</strong>\nHSL: <strong>'+hsl+'</strong>\nComplement: <strong>'+comp+'</strong></div><div style="margin-top:12px;color:var(--tx2);font-size:.82rem">CSS:\n --color: '+hex+';\n --color-rgb: '+r+', '+g+', '+b+';</div>'};++ const c=DevEngine.color(s);++ return{html:'<div><div class="sw" style="background:'+c.hex+'" title="'+c.hex+'"></div><div class="sw" style="background:'+c.complement+'" title="Complement"></div></div><div style="line-height:2.2;margin-top:8px">HEX: <strong>'+c.hex+'</strong>\nRGB: <strong>'+c.rgb+'</strong>\nHSL: <strong>'+c.hsl+'</strong>\nComplement: <strong>'+c.complement+'</strong></div><div style="margin-top:12px;color:var(--tx2);font-size:.82rem">CSS:\n --color: '+c.hex+';\n --color-rgb: '+c.r+', '+c.g+', '+c.b+';</div>'};};P.opengraph=async(s)=>{nnew file mode 100644@@@ -0,0 +1,278 @@++// ═══════════════════════════════════════════════════════════════++// Dev.OpenVibe — the transforms behind the developer tools.++//++// One engine for two callers: the tool pages (dev.html loads this file and only adds presentation)++// and the server (server/dev/engines.js requires it for the tools API, ADR-027). Everything here is++// pure: no DOM, no network, no storage. The browser globals it uses exist in Node 22 as well++// (atob, btoa, escape, unescape, TextEncoder, URL, crypto.subtle, crypto.randomUUID).++//++// What is NOT here needs a browser: XML validation, XML minify and HTML to Markdown (DOMParser),++// HTML entity decoding (a DOM element), the terser-based JavaScript minifier and beautifier (loaded++// from a CDN on demand), and the regex tester (a visitor's pattern must not run on the server++// without isolation). Those stay in dev.html.++// ═══════════════════════════════════════════════════════════════++(function (root) {++'use strict';++++const E = {};++++// ── Data & formats ───────────────────────────────────────────++E.jsonfmt = (s, m) => { const o = JSON.parse(s); if (m === 'Minify') return JSON.stringify(o); if (m === 'Validate') return '\u2713 Valid JSON \u2014 ' + (Array.isArray(o) ? o.length + ' items' : typeof o === 'object' ? Object.keys(o).length + ' keys' : typeof o); return JSON.stringify(o, null, 2); };++++function j2y(o, ind) {++ let r = '';++ if (Array.isArray(o)) o.forEach(v => { r += ind + '- ' + (typeof v === 'object' && v ? '\n' + j2y(v, ind + ' ') : v) + '\n'; });++ else if (typeof o === 'object' && o) for (const [k, v] of Object.entries(o)) r += typeof v === 'object' && v ? ind + k + ':\n' + j2y(v, ind + ' ') : ind + k + ': ' + v + '\n';++ else r += ind + o + '\n';++ return r;++}++/** mode 'YAML→JSON' (default) or 'JSON→YAML'. */++E.yaml = (s, m) => {++ if (String(m || '').includes('JSON\u2192')) { const o = JSON.parse(s); return j2y(o, ''); }++ const ln = s.split('\n').filter(l => l.trim() && !l.trim().startsWith('#')), r = {};++ for (const l of ln) { const mt = l.match(/^(\s*)([^:]+):\s*(.*)$/); if (mt) { let v = mt[3].trim(); v = v === '' || v === '~' || v === 'null' ? null : v === 'true' ? true : v === 'false' ? false : /^-?\d+(\.\d+)?$/.test(v) ? Number(v) : v.replace(/^["']|["']$/g, ''); r[mt[2].trim()] = v; } }++ return JSON.stringify(r, null, 2);++};++++/** Indent markup one tag per line (the XML, HTML and beautify formatters). */++function indentTags(s) {++ 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();++}++/** mode 'Format' (default) or 'Minify'. Validation needs a DOM parser and stays on the page. */++E.xml = (s, m) => (m === 'Minify' ? s.replace(/>\s+</g, '><').replace(/\s{2,}/g, ' ').trim() : indentTags(s));++++/** CSV → rows of cells; quoted fields keep their commas. */++E.csvRows = (s) => s.trim().split('\n').map(r => { const c = []; let cur = '', q = false; for (let i = 0; i < r.length; i++) { if (r[i] === '"') q = !q; else if (r[i] === ',' && !q) { c.push(cur.trim()); cur = ''; } else cur += r[i]; } c.push(cur.trim()); return c; });++/** CSV → one object per row, keyed by the header row. */++E.csvObjects = (s) => { const rows = E.csvRows(s); const hd = rows[0]; return rows.slice(1).map(r => { const o = {}; hd.forEach((h, i) => { o[h] = r[i] || ''; }); return o; }); };++++/** mode 'Format' (default), 'Uppercase' or 'Compact'. */++E.sql = (s, m) => {++ if (m === 'Compact') return s.replace(/\s+/g, ' ').trim();++ const kw = ['SELECT', 'DISTINCT', 'FROM', 'WHERE', 'JOIN', 'LEFT JOIN', 'RIGHT JOIN', 'INNER JOIN', 'ON', 'AND', 'OR', 'ORDER BY', 'GROUP BY', 'HAVING', 'LIMIT', 'OFFSET', 'INSERT INTO', 'VALUES', 'UPDATE', 'SET', 'DELETE FROM', 'CREATE TABLE', 'ALTER TABLE', 'UNION', 'CASE', 'WHEN', 'THEN', 'ELSE', 'END'];++ let r = s; if (m === 'Uppercase') kw.forEach(k => { r = r.replace(new RegExp('\\b' + k.replace(/ /g, '\\s+') + '\\b', 'gi'), k); });++ kw.slice(0, 20).forEach(k => { r = r.replace(new RegExp('(\\b)(' + k.replace(/ /g, '\\s+') + ')(\\b)', 'gi'), '\n$1$2$3'); });++ return r.replace(/^\n+/, '').replace(/\n{2,}/g, '\n');++};++++/** Markdown → HTML as the Markdown editor renders it (inline styles use the page's theme variables). */++E.md = (s) => s.replace(/^######\s(.+)/gm, '<h6>$1</h6>').replace(/^#####\s(.+)/gm, '<h5>$1</h5>').replace(/^####\s(.+)/gm, '<h4>$1</h4>')++ .replace(/^###\s(.+)/gm, '<h3>$1</h3>').replace(/^##\s(.+)/gm, '<h2>$1</h2>').replace(/^#\s(.+)/gm, '<h1>$1</h1>')++ .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>').replace(/\*(.+?)\*/g, '<em>$1</em>')++ .replace(/`([^`]+)`/g, '<code style="background:var(--bg3);padding:2px 5px;border-radius:4px">$1</code>')++ .replace(/^\- (.+)/gm, '<li>$1</li>').replace(/^> (.+)/gm, '<blockquote style="border-left:3px solid var(--ac);padding-left:12px;color:var(--tx2)">$1</blockquote>')++ .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" style="color:var(--ac)">$1</a>')++ .replace(/^---$/gm, '<hr style="border:none;border-top:1px solid var(--brd);margin:16px 0">');++/** Markdown → clean HTML (no inline styles, lists wrapped, paragraphs), as Markdown to HTML copies it. */++E.md2html = (s) => {++ let h = E.md(s).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 h;++};++++/** mode 'Format' (default), 'Minify' or 'Entities'. Unescaping entities needs a DOM and stays on the page. */++E.html = (s, m) => {++ if (m === 'Entities') return s.replace(/[\u00A0-\u9999<>&'"]/g, c => '&#' + c.charCodeAt(0) + ';');++ if (m === 'Minify') return s.replace(/\s+/g, ' ').replace(/>\s+</g, '><').trim();++ return indentTags(s);++};++++// ── Encoding & crypto ────────────────────────────────────────++/** mode 'Encode' (default) or 'Decode'; UTF-8 safe. */++E.base64 = (s, m) => (m === 'Decode' ? decodeURIComponent(escape(atob(s.trim()))) : btoa(unescape(encodeURIComponent(s))));++++/** mode 'Encode', 'Decode' or 'Parse' (default). */++E.url = (s, m) => {++ if (m === 'Encode') return encodeURIComponent(s);++ if (m === 'Decode') return decodeURIComponent(s);++ try { const u = new URL(s); return 'Protocol: ' + u.protocol + '\nHost: ' + u.host + '\nPath: ' + u.pathname + '\nSearch: ' + u.search + '\nHash: ' + u.hash + '\nOrigin: ' + u.origin; } catch { return 'Invalid URL \u2014 include protocol (https://)'; }++};++++/** A JWT's parts, decoded (never verified). exp: { at: ISO time, msLeft } when the payload has one. */++E.jwt = (s, now = Date.now()) => {++ const p = s.trim().split('.'); if (p.length !== 3) throw new Error('JWT must have 3 dot-separated parts');++ const d = v => JSON.parse(decodeURIComponent(escape(atob(v.replace(/-/g, '+').replace(/_/g, '/')))));++ const header = d(p[0]), payload = d(p[1]);++ const exp = payload.exp ? { at: new Date(payload.exp * 1000).toISOString(), msLeft: payload.exp * 1000 - now } : null;++ return { header, payload, signature: p[2], exp };++};++++const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;++/** n random v4 UUIDs, one per line. */++E.uuids = (n) => Array.from({ length: n }, () => crypto.randomUUID()).join('\n');++E.isUuid = (s) => UUID_V4.test(String(s).trim());++/** mode 'Generate' (default), 'Bulk 10', 'Bulk 100' or 'Validate'. */++E.uuid = (s, m) => {++ if (m === 'Validate') return E.isUuid(s) ? '\u2713 Valid UUID v4' : '\u2717 Invalid UUID v4';++ return E.uuids(String(m || '').includes('100') ? 100 : String(m || '').includes('10') ? 10 : 1);++};++++/** Hex digest of the UTF-8 text: algorithm 'SHA-256' (default), 'SHA-1' or 'SHA-512'. */++E.hash = async (s, algorithm) => {++ const a = algorithm === 'SHA-1' ? 'SHA-1' : algorithm === 'SHA-512' ? 'SHA-512' : 'SHA-256';++ const buf = await crypto.subtle.digest(a, new TextEncoder().encode(s));++ return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');++};++++/** mode 'To Hex' (default) or 'From Hex'. */++E.hex = (s, m) => {++ if (m === 'From Hex') return s.trim().replace(/\s+/g, '').match(/.{2}/g).map(h => String.fromCharCode(parseInt(h, 16))).join('');++ return [...s].map(c => c.charCodeAt(0).toString(16).padStart(2, '0')).join(' ');++};++++// ── Time ─────────────────────────────────────────────────────++/** mode 'Now', 'To Date' or 'To Unix' (default). "Local" is the local time of whoever runs it. */++E.timestamp = (s, m, now = Date.now()) => {++ if (m === 'Now') { const n = now; return 'Unix (s): ' + Math.floor(n / 1000) + '\nUnix (ms): ' + n + '\nISO 8601: ' + new Date(n).toISOString() + '\nLocal: ' + new Date(n).toLocaleString() + '\nUTC: ' + new Date(n).toUTCString(); }++ if (m === 'To Date') { const v = Number(s.trim()), d = new Date(v < 1e12 ? v * 1000 : v); if (isNaN(d.getTime())) throw new Error('Invalid timestamp'); return 'ISO 8601: ' + d.toISOString() + '\nLocal: ' + d.toLocaleString() + '\nUTC: ' + d.toUTCString() + '\nUnix (s): ' + Math.floor(d / 1000) + '\nUnix (ms): ' + Number(d); }++ const d = new Date(s.trim()); if (isNaN(d.getTime())) throw new Error('Invalid date string'); return 'Unix (s): ' + Math.floor(d / 1000) + '\nUnix (ms): ' + Number(d) + '\nISO 8601: ' + d.toISOString() + '\nUTC: ' + d.toUTCString();++};++++/** A cron expression in words and its next five runs (local time of whoever runs it). */++E.cron = (s, now = Date.now()) => {++ const p = s.trim().split(/\s+/); if (p.length < 5) throw new Error('Need 5 fields: minute hour day month weekday');++ const N = [['minute', 0, 59], ['hour', 0, 23], ['day', 1, 31], ['month', 1, 12], ['weekday', 0, 6]];++ function desc(v, i) { if (v === '*') return 'every ' + N[i][0]; if (v.includes('/')) return 'every ' + v.split('/')[1] + ' ' + N[i][0] + 's'; return N[i][0] + ' ' + v; }++ function expand(v, mn, mx) {++ if (v === '*') return Array.from({ length: mx - mn + 1 }, (_, i) => i + mn);++ if (v.includes('/')) { const [b, st] = v.split('/'); const start = b === '*' ? mn : +b; const r = []; for (let i = start; i <= mx; i += +st) r.push(i); return r; }++ if (v.includes(',')) return v.split(',').map(Number);++ if (v.includes('-')) { const [a, b] = v.split('-').map(Number); return Array.from({ length: b - a + 1 }, (_, i) => i + a); }++ return [+v];++ }++ const mins = expand(p[0], 0, 59), hrs = expand(p[1], 0, 23), doms = expand(p[2], 1, 31), mos = expand(p[3], 1, 12), dws = expand(p[4], 0, 6);++ const nx = []; const d = new Date(now); d.setSeconds(0, 0); d.setMinutes(d.getMinutes() + 1);++ for (let i = 0; i < 525600 && nx.length < 5; i++) { if (mins.includes(d.getMinutes()) && hrs.includes(d.getHours()) && doms.includes(d.getDate()) && mos.includes(d.getMonth() + 1) && dws.includes(d.getDay())) nx.push(new Date(d)); d.setMinutes(d.getMinutes() + 1); }++ return 'Schedule: ' + p.map((v, i) => desc(v, i)).join(', ') + '\n\nNext 5 runs:\n' + nx.map((x, i) => ' ' + (i + 1) + '. ' + x.toLocaleString()).join('\n');++};++++// ── Code quality ─────────────────────────────────────────────++/** mode 'JSON', 'CSS' or 'HTML' (default). */++E.beautify = (s, m) => {++ if (m === 'JSON') return JSON.stringify(JSON.parse(s), null, 2);++ if (m === 'CSS') { let r = '', ind = 0; for (const c of s) { if (c === '{') { r += ' {\n'; ind++; r += ' '.repeat(ind); } else if (c === '}') { ind--; r += '\n' + ' '.repeat(ind) + '}\n' + ' '.repeat(ind); } else if (c === ';') r += ';\n' + ' '.repeat(ind); else r += c; } return r.trim(); }++ return indentTags(s);++};++++/** mode 'JSON', 'CSS', 'HTML' or anything else (JavaScript comments and whitespace). */++E.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();++};++++const LOREM = 'lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore magna aliqua enim ad minim veniam quis nostrud exercitation ullamco laboris nisi aliquip ex ea commodo consequat duis aute irure in reprehenderit voluptate velit esse cillum fugiat nulla pariatur excepteur sint occaecat cupidatat non proident sunt culpa qui officia deserunt mollit anim id est laborum'.split(' ');++/** unit 'paragraphs' (default), 'sentences' or 'words'; count of that unit. */++E.lorem = (unit, count) => {++ const rw = () => LOREM[Math.random() * LOREM.length | 0];++ const sent = () => { const n = 8 + (Math.random() * 10 | 0); const s = Array.from({ length: n }, rw).join(' '); return s[0].toUpperCase() + s.slice(1) + '.'; };++ const para = () => Array.from({ length: 4 + (Math.random() * 4 | 0) }, sent).join(' ');++ if (unit === 'words') return Array.from({ length: count || 50 }, rw).join(' ');++ if (unit === 'sentences') return Array.from({ length: count || 10 }, sent).join(' ');++ return Array.from({ length: count || 3 }, para).join('\n\n');++};++++/** A curl command → mode 'Parsed', 'To fetch', 'To Python' or Node.js (default). */++E.curlconvert = (s, m) => {++ const cmd = s.trim().replace(/\\\n/g, ' '); let method = 'GET', url = '', body = ''; const hd = {};++ const tok = []; let cur = '', inQ = false, qc = '';++ for (let i = 0; i < cmd.length; i++) { const c = cmd[i]; if (!inQ && (c === "'" || c === '"')) { inQ = true; qc = c; } else if (inQ && c === qc) { inQ = false; } else if (!inQ && c === ' ') { if (cur) { tok.push(cur); cur = ''; } continue; } else cur += c; }++ if (cur) tok.push(cur);++ for (let i = 0; i < tok.length; i++) {++ const t = tok[i];++ if (t === '-X' || t === '--request') method = tok[++i];++ else if (t === '-H' || t === '--header') { const h = tok[++i]; const ci = h.indexOf(':'); if (ci > 0) hd[h.slice(0, ci).trim()] = h.slice(ci + 1).trim(); }++ else if (t === '-d' || t === '--data' || t === '--data-raw') body = tok[++i];++ else if (!t.startsWith('-') && t !== 'curl') url = t;++ }++ if (body && method === 'GET') method = 'POST';++ if (m === 'Parsed') return 'Method: ' + method + '\nURL: ' + url + '\nHeaders:\n' + Object.entries(hd).map(([k, v]) => ' ' + k + ': ' + v).join('\n') + '\nBody: ' + (body || '(none)');++ if (m === 'To fetch') return "fetch('" + url + "', {\n method: '" + method + "',\n headers: " + JSON.stringify(hd, null, 4).replace(/\n/g, '\n ') + ',' + (body ? "\n body: '" + body + "'," : '') + '\n});';++ if (m === 'To Python') return "import requests\n\nresponse = requests." + method.toLowerCase() + "(\n '" + url + "',\n headers=" + JSON.stringify(hd) + ',' + (body ? "\n data='" + body + "'," : '') + '\n)\nprint(response.json())';++ return "const https = require('https');\n\nconst req = https.request('" + url + "', {\n method: '" + method + "',\n headers: " + JSON.stringify(hd, null, 4).replace(/\n/g, '\n ') + "\n}, res => {\n let data = '';\n res.on('data', c => data += c);\n res.on('end', () => console.log(data));\n});\n" + (body ? "req.write('" + body + "');\n" : '') + 'req.end();';++};++++// ── Frontend ─────────────────────────────────────────────────++/** '#rrggbb' or 'rgb(r, g, b)' → { hex, r, g, b, rgb, hsl, complement }. */++E.color = (s) => {++ let r, g, b; const v = s.trim();++ if (v.startsWith('#')) { const hx = v.slice(1); r = parseInt(hx.slice(0, 2), 16); g = parseInt(hx.slice(2, 4), 16); b = parseInt(hx.slice(4, 6), 16); }++ else if (v.startsWith('rgb')) { [r, g, b] = v.match(/\d+/g).map(Number); }++ else throw new Error('Enter HEX (#ff0000) or RGB (rgb(255,0,0))');++ if ([r, g, b].some(x => isNaN(x))) throw new Error('Invalid color value');++ const mx = Math.max(r, g, b) / 255, mn = Math.min(r, g, b) / 255, d = mx - mn; let h = 0, s2 = 0; const l = (mx + mn) / 2;++ if (d) { s2 = l > 0.5 ? d / (2 - mx - mn) : d / (mx + mn); if (mx === r / 255) h = ((g / 255 - b / 255) / d + (g < b ? 6 : 0)) * 60; else if (mx === g / 255) h = ((b / 255 - r / 255) / d + 2) * 60; else h = ((r / 255 - g / 255) / d + 4) * 60; }++ const hex = '#' + [r, g, b].map(c => c.toString(16).padStart(2, '0')).join('');++ return { hex, r, g, b, rgb: 'rgb(' + r + ', ' + g + ', ' + b + ')', hsl: 'hsl(' + Math.round(h) + ', ' + Math.round(s2 * 100) + '%, ' + Math.round(l * 100) + '%)', complement: '#' + [255 - r, 255 - g, 255 - b].map(c => c.toString(16).padStart(2, '0')).join('') };++};++++// ── Single-purpose tools ─────────────────────────────────────++/** Stylesheet minifier: strings and url() kept, comments and whitespace out, 0px → 0, #aabbcc → #abc. */++E.cssMinify = (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]);++};++E.cssFormat = (s) => {++ let out = '', depth = 0; const pad = () => ' '.repeat(depth); const min = E.cssMinify(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();++};++/** HTML minifier: pre, textarea, script and style kept as they are; comments (not IE conditionals) out. */++E.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();++ return o.replace(/\u0000(\d+)\u0000/g, (_, i) => keep[+i]);++};++++/** JSON.parse with the line, column and a hint for the usual mistakes. */++function 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}`;++}++E.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);++E.jsonMinify = (s) => JSON.stringify(E.jsonIn(s));++/** { kind, size (items or keys; null for a scalar), values, depth } of valid JSON; throws with the position otherwise. */++E.jsonStats = (s) => {++ const v = E.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 { kind: kind(v), size: Array.isArray(v) ? v.length : v && typeof v === 'object' ? Object.keys(v).length : null, values: n, depth };++};++/** view 'Tree', 'Paths' (default) or 'Types'. */++E.jsonParse = (s, m) => {++ const v = E.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');++};++/** mode 'Stringify' (default) or 'Parse string'. */++E.jsonStringify = (s, m) => {++ if (m === 'Parse string') { const t = s.trim(); const inner = JSON.parse(/^"/.test(t) ? t : '"' + t.replace(/"/g, '\\"') + '"'); return JSON.stringify(E.jsonIn(inner), null, 2); }++ return JSON.stringify(JSON.stringify(E.jsonIn(s)));++};++++if (typeof module !== 'undefined' && module.exports) module.exports = E;++else root.DevEngine = E;++})(typeof window !== 'undefined' ? window : this);nnew file mode 100644@@@ -0,0 +1,52 @@++'use strict';++// ═══════════════════════════════════════════════════════════════++// Dev.OpenVibe — server engines for the tools API (ADR-027).++//++// The developer tools run in the browser, and their pages keep doing so. These are the same++// transforms, public/js/dev-engine.js, required in Node, so a dev tool whose descriptor says++// api: true can be run by POST /api/v1/tools/:id/run. Each engine takes the run's input (already++// checked against the tool's input schema; the API's lower-case modes are the page's buttons) and++// answers { text } (output kind text) or { data } (output kind json). An input the transform cannot++// read (JSON that does not parse, a malformed JWT) throws, and the run fails with that message.++// ═══════════════════════════════════════════════════════════════++++const D = require('../../public/js/dev-engine');++++const label = (map, value, dflt) => map[value] || map[dflt];++++const ENGINES = {++ yaml: ({ text, to = 'json' }) => ({ text: D.yaml(text, to === 'yaml' ? 'JSON→YAML' : 'YAML→JSON') }),++ xml: ({ text, mode = 'format' }) => ({ text: D.xml(text, mode === 'minify' ? 'Minify' : 'Format') }),++ csv: ({ text }) => ({ data: { rows: D.csvObjects(text) } }),++ sql: ({ text, mode = 'format' }) => ({ text: D.sql(text, label({ format: 'Format', uppercase: 'Uppercase', compact: 'Compact' }, mode, 'format')) }),++ html: ({ text, mode = 'format' }) => ({ text: D.html(text, label({ format: 'Format', minify: 'Minify', entities: 'Entities' }, mode, 'format')) }),++ base64: ({ text, mode = 'encode' }) => ({ text: D.base64(text, mode === 'decode' ? 'Decode' : 'Encode') }),++ url: ({ text, mode = 'encode' }) => ({ text: D.url(text, label({ encode: 'Encode', decode: 'Decode', parse: 'Parse' }, mode, 'encode')) }),++ jwt: ({ token }) => {++ const j = D.jwt(token);++ return { data: { header: j.header, payload: j.payload, signature: j.signature, expires_at: j.exp ? j.exp.at : null, expired: j.exp ? j.exp.msLeft <= 0 : null } };++ },++ uuid: ({ mode = 'generate', count = 1, text = '' }) => (mode === 'validate' ? { data: { valid: D.isUuid(text) } } : { data: { uuids: D.uuids(count).split('\n') } }),++ hash: async ({ text, algorithm = 'SHA-256' }) => ({ data: { algorithm, hex: await D.hash(text, algorithm) } }),++ hex: ({ text, mode = 'to-hex' }) => ({ text: D.hex(text, mode === 'from-hex' ? 'From Hex' : 'To Hex') }),++ timestamp: ({ mode = 'now', text = '' }) => ({ text: D.timestamp(text, label({ now: 'Now', 'to-date': 'To Date', 'to-unix': 'To Unix' }, mode, 'now')) }),++ cron: ({ expression }) => ({ text: D.cron(expression) }),++ beautify: ({ text, language = 'html' }) => ({ text: D.beautify(text, language.toUpperCase()) }),++ minify: ({ text, language }) => ({ text: D.minify(text, language.toUpperCase()) }),++ lorem: ({ unit = 'paragraphs', count }) => ({ text: D.lorem(unit, count) }),++ curlconvert: ({ command, to = 'fetch' }) => ({ text: D.curlconvert(command, label({ fetch: 'To fetch', python: 'To Python', node: 'To Node', parsed: 'Parsed' }, to, 'fetch')) }),++ color: ({ color }) => ({ data: D.color(color) }),++ cssminify: ({ text }) => ({ text: D.cssMinify(text) }),++ cssformat: ({ text }) => ({ text: D.cssFormat(text) }),++ htmlminify: ({ text }) => ({ text: D.htmlMinify(text) }),++ jsonminify: ({ text }) => ({ text: D.jsonMinify(text) }),++ // A validator's "no" is its answer, not a failure: invalid JSON is { valid: false, error }.++ jsonvalidate: ({ text }) => {++ try { return { data: { valid: true, ...D.jsonStats(text) } }; } catch (err) { return { data: { valid: false, error: err.message } }; }++ },++ jsonparse: ({ text, view = 'paths' }) => ({ text: D.jsonParse(text, label({ tree: 'Tree', paths: 'Paths', types: 'Types' }, view, 'paths')) }),++ jsonstringify: ({ text, mode = 'stringify' }) => ({ text: D.jsonStringify(text, mode === 'parse' ? 'Parse string' : 'Stringify') }),++ md2html: ({ text }) => ({ text: D.md2html(text) }),++};++++module.exports = { ENGINES };@@@ -63,16 +63,13 @@OpenVibeUI.initPage();const output = document.getElementById('output');-- function toHex(text) { return [...text].map(c => c.codePointAt(0).toString(16).toUpperCase().padStart(2, '0')).join(' '); }-- function toDec(text) { return [...text].map(c => c.codePointAt(0)).join(' '); }-- function toOct(text) { return [...text].map(c => c.codePointAt(0).toString(8).padStart(3, '0')).join(' '); }const cards = {};const formats = [{ key: 'binary', name: 'Binary', fn: TextOpenVibeEngine.toBinary },-- { key: 'hex', name: 'Hexadecimal', fn: toHex },-- { key: 'decimal', name: 'Decimal', fn: toDec },-- { key: 'octal', name: 'Octal', fn: toOct },++ { key: 'hex', name: 'Hexadecimal', fn: TextOpenVibeEngine.toHex },++ { key: 'decimal', name: 'Decimal', fn: TextOpenVibeEngine.toDecimal },++ { key: 'octal', name: 'Octal', fn: TextOpenVibeEngine.toOctal },];function update() {@@@ -63,18 +63,6 @@<script src="/js/shared.js"></script><script>OpenVibeUI.initPage();-- const BRAILLE_MAP = {-- 'a':'⠁','b':'⠃','c':'⠉','d':'⠙','e':'⠑','f':'⠋','g':'⠛','h':'⠓','i':'⠊','j':'⠚',-- 'k':'⠅','l':'⠇','m':'⠍','n':'⠝','o':'⠕','p':'⠏','q':'⠟','r':'⠗','s':'⠎','t':'⠞',-- 'u':'⠥','v':'⠧','w':'⠺','x':'⠭','y':'⠽','z':'⠵',-- '1':'⠼⠁','2':'⠼⠃','3':'⠼⠉','4':'⠼⠙','5':'⠼⠑',-- '6':'⠼⠋','7':'⠼⠛','8':'⠼⠓','9':'⠼⠊','0':'⠼⠚',-- ' ':'⠀','.':'⠲',',':'⠂','?':'⠦','!':'⠖',';':'⠆',':':'⠒',-- '-':'⠤','(':'⠶',')':'⠶','\'':'⠄','"':'⠐⠂','/':'⠌'-- };-- const REVERSE_MAP = {};-- Object.entries(BRAILLE_MAP).forEach(([k, v]) => { if (!REVERSE_MAP[v]) REVERSE_MAP[v] = k; });--let mode = 'encode';function setMode(m) {mode = m;@@@ -84,23 +72,9 @@document.getElementById('input').placeholder = m === 'encode' ? 'Type your text...' : 'Paste Braille dots here...';update();}-- function encode(text) {-- return [...text.toLowerCase()].map(c => BRAILLE_MAP[c] || c).join('');-- }-- function decode(braille) {-- let out = '', i = 0;-- const chars = [...braille];-- while (i < chars.length) {-- const two = chars[i] + (chars[i+1] || '');-- if (REVERSE_MAP[two]) { out += REVERSE_MAP[two]; i += 2; }-- else if (REVERSE_MAP[chars[i]]) { out += REVERSE_MAP[chars[i]]; i++; }-- else { out += chars[i]; i++; }-- }-- return out;-- }function update() {const text = document.getElementById('input').value;-- document.getElementById('output').textContent = mode === 'encode' ? encode(text) : decode(text);++ document.getElementById('output').textContent = mode === 'encode' ? TextOpenVibeEngine.toBraille(text) : TextOpenVibeEngine.fromBraille(text);}document.getElementById('input').addEventListener('input', update);update();@@@ -76,17 +76,9 @@function update() {let text = document.getElementById('input').value;if (!text) { document.getElementById('output').textContent = ''; return; }-- const c = TextOpenVibeEngine.clean;-- if (document.getElementById('optInvisible').checked) text = c.removeInvisible(text);-- if (document.getElementById('optZalgo').checked) text = c.removeZalgo(text);-- if (document.getElementById('optQuotes').checked) text = c.normalizeQuotes(text);-- if (document.getElementById('optHyphens').checked) text = c.normalizeHyphens(text);-- if (document.getElementById('optHtml').checked) text = c.stripHtml(text);-- if (document.getElementById('optSpaces').checked) text = c.collapseSpaces(text);-- if (document.getElementById('optTrim').checked) text = c.trimLines(text);-- if (document.getElementById('optBlank').checked) text = c.removeBlankLines(text);-- if (document.getElementById('optLines').checked) text = c.collapseLines(text);-- document.getElementById('output').textContent = text;++ const steps = {};++ for (const { key } of TextOpenVibeEngine.cleanSteps) steps[key] = document.getElementById('opt' + key[0].toUpperCase() + key.slice(1)).checked;++ document.getElementById('output').textContent = TextOpenVibeEngine.cleanText(text, steps);}document.getElementById('input').addEventListener('input', update);@@@ -80,38 +80,13 @@ A brand new line was added.</textarea><script src="https://openvibe.network/shared/notification-ui.js"></script><script src="https://openvibe.network/shared/account-switcher.js"></script><script src="/js/shared.js"></script>++ <script src="/js/format-engine.js"></script><script>OpenVibeUI.initPage();function escHtml(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }-- // Simple line-based diff (LCS)-- function diffLines(a, b) {-- const linesA = a.split('\n'), linesB = b.split('\n');-- const m = linesA.length, n = linesB.length;-- // Build LCS table-- const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));-- for (let i = 1; i <= m; i++)-- for (let j = 1; j <= n; j++)-- dp[i][j] = linesA[i-1] === linesB[j-1] ? dp[i-1][j-1] + 1 : Math.max(dp[i-1][j], dp[i][j-1]);-- // Backtrack-- const result = [];-- let i = m, j = n;-- while (i > 0 || j > 0) {-- if (i > 0 && j > 0 && linesA[i-1] === linesB[j-1]) {-- result.unshift({ type: 'same', text: linesA[i-1] });-- i--; j--;-- } else if (j > 0 && (i === 0 || dp[i][j-1] >= dp[i-1][j])) {-- result.unshift({ type: 'add', text: linesB[j-1] });-- j--;-- } else {-- result.unshift({ type: 'del', text: linesA[i-1] });-- i--;-- }-- }-- return result;-- }--++ const diffLines = FormatOpenVibeEngine.diffLines;function compare() {const a = document.getElementById('textA').value;const b = document.getElementById('textB').value;@@@ -62,20 +62,8 @@<script>OpenVibeUI.initPage();-- const TRANSFORMS = [-- { id: 'htmlEscape', label: 'HTML Escaped', icon: 'fa-code', fn: t => TextOpenVibeEngine.escapeHtml(t) },-- { id: 'htmlUnescape', label: 'HTML Unescaped', icon: 'fa-code', fn: t => TextOpenVibeEngine.unescapeHtml(t) },-- { id: 'urlEncode', label: 'URL Encoded', icon: 'fa-link', fn: t => encodeURIComponent(t) },-- { id: 'urlDecode', label: 'URL Decoded', icon: 'fa-link', fn: t => { try { return decodeURIComponent(t); } catch { return '[Invalid encoded string]'; } } },-- { id: 'base64Encode', label: 'Base64 Encoded', icon: 'fa-lock', fn: t => { try { return btoa(unescape(encodeURIComponent(t))); } catch { return '[Error]'; } } },-- { id: 'base64Decode', label: 'Base64 Decoded', icon: 'fa-unlock', fn: t => { try { return decodeURIComponent(escape(atob(t))); } catch { return '[Invalid Base64]'; } } },-- { id: 'jsonStringify', label: 'JSON Stringified', icon: 'fa-brackets-curly', fn: t => JSON.stringify(t) },-- { id: 'jsonParse', label: 'JSON Parsed', icon: 'fa-brackets-curly', fn: t => { try { return typeof JSON.parse(t) === 'string' ? JSON.parse(t) : JSON.stringify(JSON.parse(t), null, 2); } catch { return '[Invalid JSON string]'; } } },-- { id: 'cssEscape', label: 'CSS Escaped', icon: 'fa-paintbrush', fn: t => t.replace(/([^\w-])/g, '\\$1') },-- { id: 'regexEscape', label: 'Regex Escaped', icon: 'fa-asterisk', fn: t => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') },-- { id: 'unicodeEscape', label: 'Unicode Escape', icon: 'fa-globe', fn: t => [...t].map(c => { const cp = c.codePointAt(0); return cp > 127 ? (cp > 0xFFFF ? '\\u{' + cp.toString(16) + '}' : '\\u' + cp.toString(16).padStart(4, '0')) : c; }).join('') },-- { id: 'hexEncode', label: 'Hex Encoded', icon: 'fa-hashtag', fn: t => [...new TextEncoder().encode(t)].map(b => b.toString(16).padStart(2, '0')).join(' ') },-- ];++ const ICONS = { htmlEscape: 'fa-code', htmlUnescape: 'fa-code', urlEncode: 'fa-link', urlDecode: 'fa-link', base64Encode: 'fa-lock', base64Decode: 'fa-unlock', jsonStringify: 'fa-brackets-curly', jsonParse: 'fa-brackets-curly', cssEscape: 'fa-paintbrush', regexEscape: 'fa-asterisk', unicodeEscape: 'fa-globe', hexEncode: 'fa-hashtag' };++ const TRANSFORMS = Object.entries(TextOpenVibeEngine.escapes).map(([id, e]) => ({ id, label: e.label, icon: ICONS[id], fn: e.fn }));const grid = document.getElementById('grid');TRANSFORMS.forEach(tr => {nnew file mode 100644@@@ -0,0 +1,108 @@++// ═══════════════════════════════════════════════════════════════++// Text.OpenVibe — JSON formatting, Markdown rendering and text comparison.++//++// The engine behind json., markdown. and compare.openvibe.tools, kept apart from text-engine.js so++// those three pages do not load the font tables. Like text-engine.js it is pure (no DOM, no network)++// and runs unchanged in Node: server/engines.js requires it for the tools API (ADR-027).++// ═══════════════════════════════════════════════════════════════++(function (root) {++'use strict';++++// ── JSON ─────────────────────────────────────────────────────++/** Keys at every depth (array items are not keys; their own keys are). */++function countKeys(obj) {++ let count = 0;++ if (typeof obj === 'object' && obj !== null) {++ if (Array.isArray(obj)) { obj.forEach(v => count += countKeys(v)); }++ else { for (const k of Object.keys(obj)) { count++; count += countKeys(obj[k]); } }++ }++ return count;++}++++/** Nesting depth: a scalar is 0, {} or [] is 1. */++function maxDepth(obj, d) {++ d = d || 0;++ if (typeof obj !== 'object' || obj === null) return d;++ if (Array.isArray(obj)) return obj.length ? Math.max(...obj.map(v => maxDepth(v, d + 1))) : d + 1;++ const vals = Object.values(obj);++ return vals.length ? Math.max(...vals.map(v => maxDepth(v, d + 1))) : d + 1;++}++++/** The same value with every object's keys in sorted order. */++function sortKeysDeep(obj) {++ if (Array.isArray(obj)) return obj.map(sortKeysDeep);++ if (typeof obj === 'object' && obj !== null) {++ const sorted = {};++ Object.keys(obj).sort().forEach(k => sorted[k] = sortKeysDeep(obj[k]));++ return sorted;++ }++ return obj;++}++++// ── Markdown ─────────────────────────────────────────────────++/** A small Markdown renderer (no dependencies): code, headings, emphasis, links, images, quotes, lists, rules. */++function markdownToHtml(md) {++ let html = md;++ // Code blocks++ html = html.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code>$2</code></pre>');++ // Inline code++ html = html.replace(/`([^`]+)`/g, '<code>$1</code>');++ // Headers++ html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');++ html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');++ html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');++ // Bold++ html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');++ // Italic++ html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');++ // Strikethrough++ html = html.replace(/~~(.+?)~~/g, '<del>$1</del>');++ // Images++ html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1">');++ // Links++ html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>');++ // Blockquotes++ html = html.replace(/^> (.+)$/gm, '<blockquote>$1</blockquote>');++ // Unordered lists++ html = html.replace(/^- (.+)$/gm, '<li>$1</li>');++ html = html.replace(/(<li>.*<\/li>\n?)+/g, '<ul>$&</ul>');++ // HR++ html = html.replace(/^---$/gm, '<hr>');++ // Paragraphs++ html = html.replace(/^(?!<[hupblo]|<li|<hr|<img|<pre|<code|<del|<strong|<em|<a)(.+)$/gm, '<p>$1</p>');++ return html;++}++++// ── Compare ──────────────────────────────────────────────────++/** Line diff by longest common subsequence → [{ type: 'same'|'add'|'del', text }]. O(lines(a) × lines(b)). */++function diffLines(a, b) {++ const linesA = a.split('\n'), linesB = b.split('\n');++ const m = linesA.length, n = linesB.length;++ // Build LCS table++ const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));++ for (let i = 1; i <= m; i++)++ for (let j = 1; j <= n; j++)++ dp[i][j] = linesA[i-1] === linesB[j-1] ? dp[i-1][j-1] + 1 : Math.max(dp[i-1][j], dp[i][j-1]);++ // Backtrack++ const result = [];++ let i = m, j = n;++ while (i > 0 || j > 0) {++ if (i > 0 && j > 0 && linesA[i-1] === linesB[j-1]) {++ result.unshift({ type: 'same', text: linesA[i-1] });++ i--; j--;++ } else if (j > 0 && (i === 0 || dp[i][j-1] >= dp[i-1][j])) {++ result.unshift({ type: 'add', text: linesB[j-1] });++ j--;++ } else {++ result.unshift({ type: 'del', text: linesA[i-1] });++ i--;++ }++ }++ return result;++}++++const FormatOpenVibeEngine = { countKeys, maxDepth, sortKeysDeep, markdownToHtml, diffLines };++++if (typeof module !== 'undefined' && module.exports) module.exports = FormatOpenVibeEngine;++else root.FormatOpenVibeEngine = FormatOpenVibeEngine;++})(typeof window !== 'undefined' ? window : this);@@@ -295,6 +295,19 @@ const cleanFns = {};// ── Text analysis ────────────────────────────────────────────++// The Text Cleaner's steps in the order it applies them, and whether each is on by default.++const CLEAN_STEPS = [++ ['invisible', 'removeInvisible', true], ['zalgo', 'removeZalgo', false], ['quotes', 'normalizeQuotes', true],++ ['hyphens', 'normalizeHyphens', true], ['html', 'stripHtml', false], ['spaces', 'collapseSpaces', true],++ ['trim', 'trimLines', true], ['blank', 'removeBlankLines', false], ['lines', 'collapseLines', true],++];++++/** Run the cleaner's steps; opts.<step> true/false overrides its default. */++function cleanText(text, opts = {}) {++ for (const [key, fn, on] of CLEAN_STEPS) if (opts[key] === undefined ? on : opts[key]) text = cleanFns[fn](text);++ return text;++}++function analyze(text) {const chars = [...text];const words = text.trim().split(/\s+/).filter(Boolean);@@@ -430,17 +443,80 @@ function generateBio(opts = {}) {return templates[Math.floor(Math.random() * templates.length)];}--// ── Braille art conversion ───────────────────────────────────++// ── Braille (Grade 1: letters, digits with the number sign, common punctuation) ──++const BRAILLE_MAP = {++ 'a':'⠁','b':'⠃','c':'⠉','d':'⠙','e':'⠑','f':'⠋','g':'⠛','h':'⠓','i':'⠊','j':'⠚',++ 'k':'⠅','l':'⠇','m':'⠍','n':'⠝','o':'⠕','p':'⠏','q':'⠟','r':'⠗','s':'⠎','t':'⠞',++ 'u':'⠥','v':'⠧','w':'⠺','x':'⠭','y':'⠽','z':'⠵',++ '1':'⠼⠁','2':'⠼⠃','3':'⠼⠉','4':'⠼⠙','5':'⠼⠑',++ '6':'⠼⠋','7':'⠼⠛','8':'⠼⠓','9':'⠼⠊','0':'⠼⠚',++ ' ':'⠀','.':'⠲',',':'⠂','?':'⠦','!':'⠖',';':'⠆',':':'⠒',++ '-':'⠤','(':'⠶',')':'⠶','\'':'⠄','"':'⠐⠂','/':'⠌'++};++const BRAILLE_REVERSE = {};++Object.entries(BRAILLE_MAP).forEach(([k, v]) => { if (!BRAILLE_REVERSE[v]) BRAILLE_REVERSE[v] = k; });++function toBraille(text) {-- const BRAILLE_MAP = {-- a:'\u2801',b:'\u2803',c:'\u2809',d:'\u2819',e:'\u2811',f:'\u280B',g:'\u281B',-- h:'\u2813',i:'\u280A',j:'\u281A',k:'\u2805',l:'\u2807',m:'\u280D',n:'\u281D',-- o:'\u2815',p:'\u280F',q:'\u281F',r:'\u2817',s:'\u280E',t:'\u281E',u:'\u2825',-- v:'\u2827',w:'\u283A',x:'\u282D',y:'\u283D',z:'\u2835',' ':'\u2800',-- };return [...text.toLowerCase()].map(c => BRAILLE_MAP[c] || c).join('');}++function fromBraille(braille) {++ let out = '', i = 0;++ const chars = [...braille];++ while (i < chars.length) {++ const two = chars[i] + (chars[i+1] || '');++ if (BRAILLE_REVERSE[two]) { out += BRAILLE_REVERSE[two]; i += 2; }++ else if (BRAILLE_REVERSE[chars[i]]) { out += BRAILLE_REVERSE[chars[i]]; i++; }++ else { out += chars[i]; i++; }++ }++ return out;++}++++// ── Code points as hex, decimal and octal ────────────────────++function toHex(text) { return [...text].map(c => c.codePointAt(0).toString(16).toUpperCase().padStart(2, '0')).join(' '); }++function toDecimal(text) { return [...text].map(c => c.codePointAt(0)).join(' '); }++function toOctal(text) { return [...text].map(c => c.codePointAt(0).toString(8).padStart(3, '0')).join(' '); }++++// ── Character inspector ──────────────────────────────────────++const INVISIBLE_CHARS = new Set([0x200B,0x200C,0x200D,0xFEFF,0x00AD,0x034F,0x180E,0x2060,0x2061,0x2062,0x2063,0x2064,0x200E,0x200F,0x202A,0x202B,0x202C,0x202D,0x202E]);++++function utf8Bytes(cp) {++ if (cp <= 0x7F) return 1;++ if (cp <= 0x7FF) return 2;++ if (cp <= 0xFFFF) return 3;++ return 4;++}++++function charCategory(cp) {++ if (cp <= 0x1F || (cp >= 0x7F && cp <= 0x9F)) return 'Control';++ if (INVISIBLE_CHARS.has(cp)) return 'Invisible';++ if (cp >= 0x0300 && cp <= 0x036F) return 'Combining';++ if (cp >= 0x1F600 && cp <= 0x1F64F) return 'Emoji';++ if (cp >= 0x1F300 && cp <= 0x1F5FF) return 'Emoji Symbol';++ if (cp >= 0x1F680 && cp <= 0x1F6FF) return 'Emoji Transport';++ if (cp >= 0x1F900 && cp <= 0x1F9FF) return 'Emoji Supplement';++ if (cp >= 0x2600 && cp <= 0x26FF) return 'Misc Symbol';++ if (cp >= 0x2700 && cp <= 0x27BF) return 'Dingbat';++ if (cp >= 65 && cp <= 90) return 'Latin Upper';++ if (cp >= 97 && cp <= 122) return 'Latin Lower';++ if (cp >= 48 && cp <= 57) return 'Digit';++ if (cp === 32) return 'Space';++ if (cp === 10) return 'Newline';++ return 'Other';++}++++/** One row per character: { char, codePoint: 'U+0041', hex: '0x41', utf8Bytes, category, invisible }. */++function inspectChars(text) {++ return [...text].map(ch => {++ const cp = ch.codePointAt(0);++ const hex = cp.toString(16).toUpperCase();++ return {++ char: ch, codePoint: 'U+' + hex.padStart(4, '0'), hex: '0x' + hex, utf8Bytes: utf8Bytes(cp), category: charCategory(cp),++ invisible: INVISIBLE_CHARS.has(cp) || (cp <= 0x1F && cp !== 10) || (cp >= 0x7F && cp <= 0x9F),++ };++ });++}++// ── Slug generation ──────────────────────────────────────────function toSlug(text) {return text.toLowerCase()@@@ -459,6 +535,16 @@ function dedupeLines(text) {return [...new Set(text.split('\n'))].join('\n');}++function shuffleLines(text) {++ const lines = text.split('\n');++ for (let i = lines.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [lines[i], lines[j]] = [lines[j], lines[i]]; }++ return lines.join('\n');++}++++function reverseLines(text) {++ return text.split('\n').reverse().join('\n');++}++// ── Escape / unescape ───────────────────────────────────────function escapeHtml(text) {return text.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');@@@ -467,6 +553,23 @@ function unescapeHtml(text) {return text.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,"'");}++// Every escape the Escape & Unescape tool shows, in its order. A decoder that cannot read its input++// answers a bracketed note instead of throwing, as the page always has.++const ESCAPES = {++ htmlEscape: { label: 'HTML Escaped', fn: t => escapeHtml(t) },++ htmlUnescape: { label: 'HTML Unescaped', fn: t => unescapeHtml(t) },++ urlEncode: { label: 'URL Encoded', fn: t => encodeURIComponent(t) },++ urlDecode: { label: 'URL Decoded', fn: t => { try { return decodeURIComponent(t); } catch { return '[Invalid encoded string]'; } } },++ base64Encode: { label: 'Base64 Encoded', fn: t => { try { return btoa(unescape(encodeURIComponent(t))); } catch { return '[Error]'; } } },++ base64Decode: { label: 'Base64 Decoded', fn: t => { try { return decodeURIComponent(escape(atob(t))); } catch { return '[Invalid Base64]'; } } },++ jsonStringify: { label: 'JSON Stringified', fn: t => JSON.stringify(t) },++ jsonParse: { label: 'JSON Parsed', fn: t => { try { return typeof JSON.parse(t) === 'string' ? JSON.parse(t) : JSON.stringify(JSON.parse(t), null, 2); } catch { return '[Invalid JSON string]'; } } },++ cssEscape: { label: 'CSS Escaped', fn: t => t.replace(/([^\w-])/g, '\\$1') },++ regexEscape: { label: 'Regex Escaped', fn: t => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') },++ unicodeEscape: { label: 'Unicode Escape', fn: t => [...t].map(c => { const cp = c.codePointAt(0); return cp > 127 ? (cp > 0xFFFF ? '\\u{' + cp.toString(16) + '}' : '\\u' + cp.toString(16).padStart(4, '0')) : c; }).join('') },++ hexEncode: { label: 'Hex Encoded', fn: t => [...new TextEncoder().encode(t)].map(b => b.toString(16).padStart(2, '0')).join(' ') },++};++// ── Public API ───────────────────────────────────────────────const TextOpenVibeEngine = {// Fancy text@@@ -490,7 +593,7 @@ const TextOpenVibeEngine = {case: caseFns,// Cleanup-- clean: cleanFns,++ clean: cleanFns, cleanText, cleanSteps: CLEAN_STEPS.map(([key, , on]) => ({ key, on })),// Analysisanalyze,@@@ -502,16 +605,22 @@ const TextOpenVibeEngine = {toBinary, fromBinary,// Braille-- toBraille,++ toBraille, fromBraille,++++ // Code points++ toHex, toDecimal, toOctal,++++ // Character inspector++ inspectChars,// SlugtoSlug,// Sort / dedupe-- sortLines, dedupeLines,++ sortLines, dedupeLines, shuffleLines, reverseLines,// Escape-- escapeHtml, unescapeHtml,++ escapeHtml, unescapeHtml, escapes: ESCAPES,// Kaomojikaomoji: KAOMOJI,@@@ -77,6 +77,7 @@<script src="https://openvibe.network/shared/notification-ui.js"></script><script src="https://openvibe.network/shared/account-switcher.js"></script><script src="/js/shared.js"></script>++ <script src="/js/format-engine.js"></script><script>OpenVibeUI.initPage();let lastFormatted = '';@@@ -96,33 +97,7 @@.replace(/(?<=[\[,]\s*)(null)(?=\s*[,\]])/g, '<span class="json-null">$1</span>');}-- function countKeys(obj) {-- let count = 0;-- if (typeof obj === 'object' && obj !== null) {-- if (Array.isArray(obj)) { obj.forEach(v => count += countKeys(v)); }-- else { for (const k of Object.keys(obj)) { count++; count += countKeys(obj[k]); } }-- }-- return count;-- }---- function maxDepth(obj, d) {-- d = d || 0;-- if (typeof obj !== 'object' || obj === null) return d;-- if (Array.isArray(obj)) return obj.length ? Math.max(...obj.map(v => maxDepth(v, d + 1))) : d + 1;-- const vals = Object.values(obj);-- return vals.length ? Math.max(...vals.map(v => maxDepth(v, d + 1))) : d + 1;-- }---- function sortKeysDeep(obj) {-- if (Array.isArray(obj)) return obj.map(sortKeysDeep);-- if (typeof obj === 'object' && obj !== null) {-- const sorted = {};-- Object.keys(obj).sort().forEach(k => sorted[k] = sortKeysDeep(obj[k]));-- return sorted;-- }-- return obj;-- }--++ const { countKeys, maxDepth, sortKeysDeep } = FormatOpenVibeEngine;function format(indent) {const raw = document.getElementById('input').value.trim();const out = document.getElementById('output');@@@ -98,42 +98,11 @@ Made with ♥ by the OpenVibe Network</textarea><script src="https://openvibe.network/shared/notification-ui.js"></script><script src="https://openvibe.network/shared/account-switcher.js"></script><script src="/js/shared.js"></script>++ <script src="/js/format-engine.js"></script><script>OpenVibeUI.initPage();-- // Simple Markdown parser (no external deps)-- function parseMd(md) {-- let html = md;-- // Code blocks-- html = html.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code>$2</code></pre>');-- // Inline code-- html = html.replace(/`([^`]+)`/g, '<code>$1</code>');-- // Headers-- html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');-- html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');-- html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');-- // Bold-- html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');-- // Italic-- html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');-- // Strikethrough-- html = html.replace(/~~(.+?)~~/g, '<del>$1</del>');-- // Images-- html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1">');-- // Links-- html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>');-- // Blockquotes-- html = html.replace(/^> (.+)$/gm, '<blockquote>$1</blockquote>');-- // Unordered lists-- html = html.replace(/^- (.+)$/gm, '<li>$1</li>');-- html = html.replace(/(<li>.*<\/li>\n?)+/g, '<ul>$&</ul>');-- // HR-- html = html.replace(/^---$/gm, '<hr>');-- // Paragraphs-- html = html.replace(/^(?!<[hupblo]|<li|<hr|<img|<pre|<code|<del|<strong|<em|<a)(.+)$/gm, '<p>$1</p>');-- return html;-- }--++ const parseMd = FormatOpenVibeEngine.markdownToHtml;function update() {document.getElementById('preview').innerHTML = parseMd(document.getElementById('input').value);}@@@ -74,13 +74,9 @@ elderberry</textarea>function sortAsc() { output.textContent = TextOpenVibeEngine.sortLines(input.value); }function sortDesc() { output.textContent = TextOpenVibeEngine.sortLines(input.value, { reverse: true }); }-- function shuffle() {-- const lines = input.value.split('\n');-- for (let i = lines.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [lines[i], lines[j]] = [lines[j], lines[i]]; }-- output.textContent = lines.join('\n');-- }++ function shuffle() { output.textContent = TextOpenVibeEngine.shuffleLines(input.value); }function dedupe() { output.textContent = TextOpenVibeEngine.dedupeLines(input.value); }-- function reverseLines() { output.textContent = input.value.split('\n').reverse().join('\n'); }++ function reverseLines() { output.textContent = TextOpenVibeEngine.reverseLines(input.value); }function applyResult() { input.value = output.textContent; }</script></body>@@@ -71,49 +71,19 @@<script>OpenVibeUI.initPage();-- const INVISIBLE_CHARS = new Set([0x200B,0x200C,0x200D,0xFEFF,0x00AD,0x034F,0x180E,0x2060,0x2061,0x2062,0x2063,0x2064,0x200E,0x200F,0x202A,0x202B,0x202C,0x202D,0x202E]);---- function utf8Bytes(cp) {-- if (cp <= 0x7F) return 1;-- if (cp <= 0x7FF) return 2;-- if (cp <= 0xFFFF) return 3;-- return 4;-- }---- function charCategory(cp) {-- if (cp <= 0x1F || (cp >= 0x7F && cp <= 0x9F)) return 'Control';-- if (INVISIBLE_CHARS.has(cp)) return 'Invisible';-- if (cp >= 0x0300 && cp <= 0x036F) return 'Combining';-- if (cp >= 0x1F600 && cp <= 0x1F64F) return 'Emoji';-- if (cp >= 0x1F300 && cp <= 0x1F5FF) return 'Emoji Symbol';-- if (cp >= 0x1F680 && cp <= 0x1F6FF) return 'Emoji Transport';-- if (cp >= 0x1F900 && cp <= 0x1F9FF) return 'Emoji Supplement';-- if (cp >= 0x2600 && cp <= 0x26FF) return 'Misc Symbol';-- if (cp >= 0x2700 && cp <= 0x27BF) return 'Dingbat';-- if (cp >= 65 && cp <= 90) return 'Latin Upper';-- if (cp >= 97 && cp <= 122) return 'Latin Lower';-- if (cp >= 48 && cp <= 57) return 'Digit';-- if (cp === 32) return 'Space';-- if (cp === 10) return 'Newline';-- return 'Other';-- }--function update() {-- const chars = [...document.getElementById('input').value];const tbody = document.querySelector('#output tbody');tbody.innerHTML = '';-- chars.forEach((ch, i) => {-- const cp = ch.codePointAt(0);-- const isInvis = INVISIBLE_CHARS.has(cp) || (cp <= 0x1F && cp !== 10) || (cp >= 0x7F && cp <= 0x9F);++ TextOpenVibeEngine.inspectChars(document.getElementById('input').value).forEach((c, i) => {const tr = document.createElement('tr');-- if (isInvis) tr.className = 'char-invisible';++ if (c.invisible) tr.className = 'char-invisible';tr.innerHTML = `<td>${i + 1}</td>-- <td class="char-display">${isInvis ? '⬚' : OpenVibeUI.escapeHtml(ch)}</td>-- <td class="char-mono">U+${cp.toString(16).toUpperCase().padStart(4, '0')}</td>-- <td class="char-mono">0x${cp.toString(16).toUpperCase()}</td>-- <td>${utf8Bytes(cp)}</td>-- <td>${charCategory(cp)}</td>++ <td class="char-display">${c.invisible ? '⬚' : OpenVibeUI.escapeHtml(c.char)}</td>++ <td class="char-mono">${c.codePoint}</td>++ <td class="char-mono">${c.hex}</td>++ <td>${c.utf8Bytes}</td>++ <td>${c.category}</td>`;tbody.appendChild(tr);});nnew file mode 100644@@@ -0,0 +1,71 @@++'use strict';++// ═══════════════════════════════════════════════════════════════++// Text.OpenVibe — server engines for the tools API (ADR-027).++//++// The text pages run everything in the browser, and they keep doing so. These are the same engines,++// public/js/text-engine.js and public/js/format-engine.js, required in Node, so a text tool whose++// descriptor says api: true can be run by POST /api/v1/tools/:id/run (served by the gateway). Each++// engine takes the run's input (already checked against the tool's input schema) and answers++// { text } (output kind text) or { data } (output kind json). Nothing here touches the network or disk.++// ═══════════════════════════════════════════════════════════════++++const T = require('../public/js/text-engine');++const F = require('../public/js/format-engine');++++// The style sets each page shows (fancy.html: all of them; bubble.html; reverse.html).++const STYLE_SETS = {++ fancy: Object.keys(T.styles),++ bubble: ['circled', 'squared', 'negSquared', 'parenthesized', 'regional'],++ reversetext: ['upsideDown', 'mirrored', 'strikethrough', 'underline', 'doubleUnderline', 'overline', 'slashed', 'dotted', 'spaced'],++};++// compare: the diff table is lines(a) × lines(b); beyond this many lines a side it is refused.++const MAX_DIFF_LINES = 2000;++++const styled = (set) => ({ text, styles }) => {++ const ids = styles && styles.length ? STYLE_SETS[set].filter(id => styles.includes(id)) : STYLE_SETS[set];++ return { data: { styles: ids.map(id => ({ id, name: T.styles[id].name, text: T.styles[id].fn(text) })) } };++};++++class EngineError extends Error {++ constructor(message) { super(message); this.status = 422; this.code = 'tools.input.invalid'; }++}++++const ENGINES = {++ fancy: styled('fancy'),++ bubble: styled('bubble'),++ reversetext: styled('reversetext'),++ zalgo: ({ text, mode = 'add', intensity = 'medium', up, mid, down }) => ({ text: mode === 'remove' ? T.clean.removeZalgo(text) : T.zalgo(text, { intensity, up, mid, down }) }),++ ascii: ({ text, blockChar = '█', spaceChar = ' ' }) => ({ text: T.asciiArt(text, { blockChar, spaceChar }) }),++ unicode: ({ text }) => ({ data: { characters: T.inspectChars(text) } }),++ braille: ({ text, mode = 'encode' }) => ({ text: mode === 'decode' ? T.fromBraille(text) : T.toBraille(text) }),++ morse: ({ text, mode = 'encode' }) => ({ text: mode === 'decode' ? T.fromMorse(text) : T.toMorse(text) }),++ binary: ({ text, to = 'binary' }) => ({ text: { binary: T.toBinary, hex: T.toHex, decimal: T.toDecimal, octal: T.toOctal, text: T.fromBinary }[to](text) }),++ case: ({ text, to }) => ({ text: T.case[to](text) }),++ count: ({ text }) => ({ data: T.analyze(text) }),++ clean: ({ text, ...steps }) => ({ text: T.cleanText(text, steps) }),++ sort: ({ text, mode = 'asc' }) => ({++ text: mode === 'desc' ? T.sortLines(text, { reverse: true }) : mode === 'shuffle' ? T.shuffleLines(text) : mode === 'dedupe' ? T.dedupeLines(text) : mode === 'reverse' ? T.reverseLines(text) : T.sortLines(text),++ }),++ compare: ({ a, b }) => {++ const n = Math.max(a.split('\n').length, b.split('\n').length);++ if (n > MAX_DIFF_LINES) throw new EngineError(`Each text may have at most ${MAX_DIFF_LINES} lines (this one has ${n}).`);++ const lines = F.diffLines(a, b);++ const count = (t) => lines.filter(l => l.type === t).length;++ return { data: { lines, added: count('add'), removed: count('del'), unchanged: count('same') } };++ },++ json: ({ text, mode = 'format', indent = 2 }) => {++ let value;++ try { value = JSON.parse(text.trim()); } catch (err) { throw new EngineError(err.message); }++ if (mode === 'minify') return { text: JSON.stringify(value) };++ return { text: JSON.stringify(mode === 'sort-keys' ? F.sortKeysDeep(value) : value, null, indent) };++ },++ markdown: ({ text }) => ({ text: F.markdownToHtml(text) }),++ escape: ({ text, transform }) => ({ text: T.escapes[transform].fn(text) }),++ slug: ({ text }) => ({ text: T.toSlug(text) }),++ bio: () => ({ text: T.generateBio() }),++ nickname: () => ({ text: T.generateNickname() }),++ symbols: ({ category } = {}) => ({ data: { categories: category ? { [category]: T.symbols[category] } : T.symbols } }),++ kaomoji: ({ mood } = {}) => ({ data: { moods: mood ? { [mood]: T.kaomoji[mood] } : T.kaomoji } }),++};++++module.exports = { ENGINES, STYLE_SETS, MAX_DIFF_LINES, EngineError, styleNames: () => Object.fromEntries(Object.entries(T.styles).map(([k, v]) => [k, v.name])), CASES: Object.keys(T.case), ESCAPES: Object.keys(T.escapes), SYMBOL_CATEGORIES: Object.keys(T.symbols), MOODS: Object.keys(T.kaomoji) };
@@ -147,6 +147,7 @@ a{color:inherit;text-decoration:none}
@@ -147,6 +147,7 @@ a{color:inherit;text-decoration:none}
<script src="https://openvibe.network/shared/navbar.js"></script>
<script src="https://openvibe.network/shared/navbar.js"></script>
<script src="https://openvibe.network/shared/footer.js"></script>
<script src="https://openvibe.network/shared/footer.js"></script>
<script src="https://openvibe.network/shared/theme-loader.js" defer></script>
<script src="https://openvibe.network/shared/theme-loader.js" defer></script>
+<script src="/js/dev-engine.js"></script>
<script>
<script>
'use strict';
'use strict';
const API='https://openvibe.tools/api/dev',$=s=>document.getElementById(s),
const API='https://openvibe.tools/api/dev',$=s=>document.getElementById(s),
@@ -267,80 +268,35 @@ async function run(){
@@ -267,80 +268,35 @@ async function run(){
}
}
const P={};
const P={};
-P.jsonfmt=(s,m)=>{const o=JSON.parse(s);if(m==='Minify')return JSON.stringify(o);if(m==='Validate')return'\u2713 Valid JSON \u2014 '+(Array.isArray(o)?o.length+' items':typeof o==='object'?Object.keys(o).length+' keys':typeof o);return JSON.stringify(o,null,2)};
-
-P.yaml=(s,m)=>{
- if(m.includes('JSON\u2192')){const o=JSON.parse(s);return j2y(o,'')}
- const ln=s.split('\n').filter(l=>l.trim()&&!l.trim().startsWith('#')),r={};
- for(const l of ln){const mt=l.match(/^(\s*)([^:]+):\s*(.*)$/);if(mt){let v=mt[3].trim();v=v===''||v==='~'||v==='null'?null:v==='true'?true:v==='false'?false:/^-?\d+(\.\d+)?$/.test(v)?Number(v):v.replace(/^["']|["']$/g,'');r[mt[2].trim()]=v}}
- return JSON.stringify(r,null,2);
-};
-function j2y(o,ind){let r='';if(Array.isArray(o))o.forEach(v=>{r+=ind+'- '+(typeof v==='object'&&v?'\n'+j2y(v,ind+' '):v)+'\n'});
-else if(typeof o==='object'&&o)for(const[k,v]of Object.entries(o))r+=typeof v==='object'&&v?ind+k+':\n'+j2y(v,ind+' '):ind+k+': '+v+'\n';
-else r+=ind+o+'\n';return r}
-
+// The transforms live in /js/dev-engine.js (shared with the tools API); these add the page's presentation.
+P.jsonfmt=(s,m)=>DevEngine.jsonfmt(s,m);
+P.yaml=(s,m)=>DevEngine.yaml(s,m);
P.xml=(s,m)=>{
P.xml=(s,m)=>{
if(m==='Validate'){const d=new DOMParser().parseFromString(s,'text/xml');if(d.querySelector('parsererror'))throw new Error(d.querySelector('parsererror').textContent);return'\u2713 Valid XML'}
if(m==='Validate'){const d=new DOMParser().parseFromString(s,'text/xml');if(d.querySelector('parsererror'))throw new Error(d.querySelector('parsererror').textContent);return'\u2713 Valid XML'}
- if(m==='Minify')return s.replace(/>\s+</g,'><').replace(/\s{2,}/g,' ').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();
+ return DevEngine.xml(s,m);
};
};
-
P.csv=(s,m)=>{
P.csv=(s,m)=>{
- const rows=s.trim().split('\n').map(r=>{const c=[];let cur='',q=false;for(let i=0;i<r.length;i++){if(r[i]==='"')q=!q;else if(r[i]===','&&!q){c.push(cur.trim());cur=''}else cur+=r[i]}c.push(cur.trim());return c});
- if(m==='To JSON'){const hd=rows[0];return JSON.stringify(rows.slice(1).map(r=>{const o={};hd.forEach((h,i)=>o[h]=r[i]||'');return o}),null,2)}
+ if(m==='To JSON')return JSON.stringify(DevEngine.csvObjects(s),null,2);
+ const rows=DevEngine.csvRows(s);
return{html:'<div style="overflow-x:auto"><table style="border-collapse:collapse;width:100%;font-size:.82rem">'+rows.map((r,ri)=>'<tr>'+r.map(c=>'<'+(ri?'td':'th')+' style="border:1px solid var(--brd);padding:6px 10px;text-align:left;'+(ri?'':'background:var(--bg3);font-weight:700')+'">'+H(c)+'</'+(ri?'td':'th')+'>').join('')+'</tr>').join('')+'</table></div>'};
return{html:'<div style="overflow-x:auto"><table style="border-collapse:collapse;width:100%;font-size:.82rem">'+rows.map((r,ri)=>'<tr>'+r.map(c=>'<'+(ri?'td':'th')+' style="border:1px solid var(--brd);padding:6px 10px;text-align:left;'+(ri?'':'background:var(--bg3);font-weight:700')+'">'+H(c)+'</'+(ri?'td':'th')+'>').join('')+'</tr>').join('')+'</table></div>'};
};
};
-
-P.sql=(s,m)=>{
- if(m==='Compact')return s.replace(/\s+/g,' ').trim();
- const kw=['SELECT','DISTINCT','FROM','WHERE','JOIN','LEFT JOIN','RIGHT JOIN','INNER JOIN','ON','AND','OR','ORDER BY','GROUP BY','HAVING','LIMIT','OFFSET','INSERT INTO','VALUES','UPDATE','SET','DELETE FROM','CREATE TABLE','ALTER TABLE','UNION','CASE','WHEN','THEN','ELSE','END'];
- let r=s;if(m==='Uppercase')kw.forEach(k=>{r=r.replace(new RegExp('\\b'+k.replace(/ /g,'\\s+')+'\\b','gi'),k)});
- kw.slice(0,20).forEach(k=>{r=r.replace(new RegExp('(\\b)('+k.replace(/ /g,'\\s+')+')(\\b)','gi'),'\n$1$2$3')});
- return r.replace(/^\n+/,'').replace(/\n{2,}/g,'\n');
-};
-
-P.md=(s,m)=>{
- let h=s.replace(/^######\s(.+)/gm,'<h6>$1</h6>').replace(/^#####\s(.+)/gm,'<h5>$1</h5>').replace(/^####\s(.+)/gm,'<h4>$1</h4>')
- .replace(/^###\s(.+)/gm,'<h3>$1</h3>').replace(/^##\s(.+)/gm,'<h2>$1</h2>').replace(/^#\s(.+)/gm,'<h1>$1</h1>')
- .replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>').replace(/\*(.+?)\*/g,'<em>$1</em>')
- .replace(/`([^`]+)`/g,'<code style="background:var(--bg3);padding:2px 5px;border-radius:4px">$1</code>')
- .replace(/^\- (.+)/gm,'<li>$1</li>').replace(/^> (.+)/gm,'<blockquote style="border-left:3px solid var(--ac);padding-left:12px;color:var(--tx2)">$1</blockquote>')
- .replace(/\[([^\]]+)\]\(([^)]+)\)/g,'<a href="$2" style="color:var(--ac)">$1</a>')
- .replace(/^---$/gm,'<hr style="border:none;border-top:1px solid var(--brd);margin:16px 0">');
- if(m==='To HTML')return h;
- return{html:'<div style="line-height:1.8;font-family:Inter,sans-serif;font-size:.92rem">'+h+'</div>'};
-};
-
+P.sql=(s,m)=>DevEngine.sql(s,m);
+P.md=(s,m)=>{const h=DevEngine.md(s);if(m==='To HTML')return h;return{html:'<div style="line-height:1.8;font-family:Inter,sans-serif;font-size:.92rem">'+h+'</div>'}};
P.html=(s,m)=>{
P.html=(s,m)=>{
- if(m==='Entities')return s.replace(/[\u00A0-\u9999<>&'"]/g,c=>'&#'+c.charCodeAt(0)+';');
if(m==='Unescape'){const d=document.createElement('div');d.innerHTML=s;return d.textContent}
if(m==='Unescape'){const d=document.createElement('div');d.innerHTML=s;return d.textContent}
- if(m==='Minify')return s.replace(/\s+/g,' ').replace(/>\s+</g,'><').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();
+ return DevEngine.html(s,m);
};
};
-P.base64=(s,m)=>m==='Decode'?decodeURIComponent(escape(atob(s.trim()))):btoa(unescape(encodeURIComponent(s)));
-
-P.url=(s,m)=>{if(m==='Encode')return encodeURIComponent(s);if(m==='Decode')return decodeURIComponent(s);
- try{const u=new URL(s);return'Protocol: '+u.protocol+'\nHost: '+u.host+'\nPath: '+u.pathname+'\nSearch: '+u.search+'\nHash: '+u.hash+'\nOrigin: '+u.origin}catch{return'Invalid URL \u2014 include protocol (https://)'}};
-
+P.base64=(s,m)=>DevEngine.base64(s,m);
+P.url=(s,m)=>DevEngine.url(s,m);
P.jwt=(s)=>{
P.jwt=(s)=>{
- const p=s.trim().split('.');if(p.length!==3)throw new Error('JWT must have 3 dot-separated parts');
- const d=v=>JSON.parse(decodeURIComponent(escape(atob(v.replace(/-/g,'+').replace(/_/g,'/')))));
- const hdr=d(p[0]),pay=d(p[1]);let ex='';
- if(pay.exp){const dt=new Date(pay.exp*1000),left=dt-Date.now();ex=left>0?'\n\u23f1 Expires: '+dt.toISOString()+' ('+Math.floor(left/36e5)+'h '+Math.floor(left%36e5/6e4)+'m left)':'\n\u26a0 EXPIRED: '+dt.toISOString()}
- return{html:'<div class="js"><div class="jl">Header</div>'+H(JSON.stringify(hdr,null,2))+'</div><div class="js"><div class="jl">Payload</div>'+H(JSON.stringify(pay,null,2))+(ex?'<div style="margin-top:6px;color:'+(ex.includes('EXPIRED')?'#f87171':'#34d399')+'">'+H(ex)+'</div>':'')+'</div><div class="js"><div class="jl">Signature</div>'+H(p[2])+'</div>'};
+ const j=DevEngine.jwt(s);let ex='';
+ if(j.exp){const left=j.exp.msLeft;ex=left>0?'\n\u23f1 Expires: '+j.exp.at+' ('+Math.floor(left/36e5)+'h '+Math.floor(left%36e5/6e4)+'m left)':'\n\u26a0 EXPIRED: '+j.exp.at}
+ return{html:'<div class="js"><div class="jl">Header</div>'+H(JSON.stringify(j.header,null,2))+'</div><div class="js"><div class="jl">Payload</div>'+H(JSON.stringify(j.payload,null,2))+(ex?'<div style="margin-top:6px;color:'+(ex.includes('EXPIRED')?'#f87171':'#34d399')+'">'+H(ex)+'</div>':'')+'</div><div class="js"><div class="jl">Signature</div>'+H(j.signature)+'</div>'};
};
};
-
-P.uuid=(s,m)=>{
- if(m==='Validate')return/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(s.trim())?'\u2713 Valid UUID v4':'\u2717 Invalid UUID v4';
- const n=m.includes('100')?100:m.includes('10')?10:1;return Array.from({length:n},()=>crypto.randomUUID()).join('\n');
-};
-
-P.hash=async(s,m)=>{const a=m==='SHA-1'?'SHA-1':m==='SHA-512'?'SHA-512':'SHA-256';
- const buf=await crypto.subtle.digest(a,new TextEncoder().encode(s));return a+': '+Array.from(new Uint8Array(buf)).map(b=>b.toString(16).padStart(2,'0')).join('')};
-
-P.hex=(s,m)=>{if(m==='From Hex')return s.trim().replace(/\s+/g,'').match(/.{2}/g).map(h=>String.fromCharCode(parseInt(h,16))).join('');
- return[...s].map(c=>c.charCodeAt(0).toString(16).padStart(2,'0')).join(' ')};
+P.uuid=(s,m)=>DevEngine.uuid(s,m);
+P.hash=async(s,m)=>{const a=m==='SHA-1'?'SHA-1':m==='SHA-512'?'SHA-512':'SHA-256';return a+': '+await DevEngine.hash(s,a)};
+P.hex=(s,m)=>DevEngine.hex(s,m);
P.entities=(s,m)=>{
P.entities=(s,m)=>{
if(m==='HTML Enc')return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
if(m==='HTML Enc')return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
@@ -350,29 +306,9 @@ P.entities=(s,m)=>{
@@ -350,29 +306,9 @@ P.entities=(s,m)=>{
if(m==='URL Enc')return encodeURIComponent(s);return decodeURIComponent(s);
if(m==='URL Enc')return encodeURIComponent(s);return decodeURIComponent(s);
};
};
-P.timestamp=(s,m)=>{
- if(m==='Now'){const n=Date.now();return'Unix (s): '+Math.floor(n/1000)+'\nUnix (ms): '+n+'\nISO 8601: '+new Date(n).toISOString()+'\nLocal: '+new Date(n).toLocaleString()+'\nUTC: '+new Date(n).toUTCString()}
- if(m==='To Date'){const v=Number(s.trim()),d=new Date(v<1e12?v*1000:v);if(isNaN(d.getTime()))throw new Error('Invalid timestamp');return'ISO 8601: '+d.toISOString()+'\nLocal: '+d.toLocaleString()+'\nUTC: '+d.toUTCString()+'\nUnix (s): '+Math.floor(d/1000)+'\nUnix (ms): '+Number(d)}
- const d=new Date(s.trim());if(isNaN(d.getTime()))throw new Error('Invalid date string');return'Unix (s): '+Math.floor(d/1000)+'\nUnix (ms): '+Number(d)+'\nISO 8601: '+d.toISOString()+'\nUTC: '+d.toUTCString();
-};
-
-P.cron=(s)=>{
- const p=s.trim().split(/\s+/);if(p.length<5)throw new Error('Need 5 fields: minute hour day month weekday');
- const N=[['minute',0,59],['hour',0,23],['day',1,31],['month',1,12],['weekday',0,6]];
- function desc(v,i){if(v==='*')return'every '+N[i][0];if(v.includes('/'))return'every '+v.split('/')[1]+' '+N[i][0]+'s';return N[i][0]+' '+v}
- function expand(v,mn,mx){if(v==='*')return Array.from({length:mx-mn+1},(_,i)=>i+mn);if(v.includes('/')){const[b,st]=v.split('/');const start=b==='*'?mn:+b;const r=[];for(let i=start;i<=mx;i+=+st)r.push(i);return r}
- if(v.includes(','))return v.split(',').map(Number);if(v.includes('-')){const[a,b]=v.split('-').map(Number);return Array.from({length:b-a+1},(_,i)=>i+a)}return[+v]}
- const mins=expand(p[0],0,59),hrs=expand(p[1],0,23),doms=expand(p[2],1,31),mos=expand(p[3],1,12),dws=expand(p[4],0,6);
- const nx=[];const d=new Date();d.setSeconds(0,0);d.setMinutes(d.getMinutes()+1);
- for(let i=0;i<525600&&nx.length<5;i++){if(mins.includes(d.getMinutes())&&hrs.includes(d.getHours())&&doms.includes(d.getDate())&&mos.includes(d.getMonth()+1)&&dws.includes(d.getDay()))nx.push(new Date(d));d.setMinutes(d.getMinutes()+1)}
- return'Schedule: '+p.map((v,i)=>desc(v,i)).join(', ')+'\n\nNext 5 runs:\n'+nx.map((d,i)=>' '+(i+1)+'. '+d.toLocaleString()).join('\n');
-};
-
-P.beautify=(s,m)=>{
- if(m==='JSON')return JSON.stringify(JSON.parse(s),null,2);
- if(m==='CSS'){let r='',ind=0;for(const c of s){if(c==='{'){r+=' {\n';ind++;r+=' '.repeat(ind)}else if(c==='}'){ind--;r+='\n'+' '.repeat(ind)+'}\n'+' '.repeat(ind)}else if(c===';')r+=';\n'+' '.repeat(ind);else r+=c}return r.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();
-};
+P.timestamp=(s,m)=>DevEngine.timestamp(s,m);
+P.cron=(s)=>DevEngine.cron(s);
+P.beautify=(s,m)=>DevEngine.beautify(s,m);
// ── Single-purpose tools ─────────────────────────────────────
// ── 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 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)});
@@ -381,36 +317,16 @@ const saved=(a,b)=>{const x=new Blob([a]).size,y=new Blob([b]).size;return x?`${
@@ -381,36 +317,16 @@ const saved=(a,b)=>{const x=new Blob([a]).size,y=new Blob([b]).size;return x?`${
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>'});
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.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||''};
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.cssminify=(s)=>{const o=DevEngine.cssMinify(s);return withNote(o,saved(s,o))};
+P.cssformat=(s)=>DevEngine.cssFormat(s);
+P.htmlminify=(s)=>{const o=DevEngine.htmlMinify(s);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]);
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 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.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};
+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');
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 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');
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');
@@ -430,11 +346,8 @@ P.html2md=(s)=>{const doc=new DOMParser().parseFromString(s,'text/html');const e
@@ -430,11 +346,8 @@ P.html2md=(s)=>{const doc=new DOMParser().parseFromString(s,'text/html');const e
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'}
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 inline(n)}
return node(doc.body).replace(/[ \t]+\n/g,(m)=>m.startsWith(' ')?' \n':'\n').replace(/\n{3,}/g,'\n\n').trim()};
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()};
+P.md2html=(s,m)=>{const h=DevEngine.md2html(s);return m==='Preview'?{html:'<div style="line-height:1.8">'+h+'</div>'}:h};
+P.minify=(s,m)=>DevEngine.minify(s,m);
P.codediff=(a,m,b)=>{
P.codediff=(a,m,b)=>{
const al=a.split('\n'),bl=b.split('\n'),r=[];let ai=0,bi=0;
const al=a.split('\n'),bl=b.split('\n'),r=[];let ai=0,bi=0;
@@ -459,29 +372,9 @@ P.regex=(pat,m,test)=>{
@@ -459,29 +372,9 @@ P.regex=(pat,m,test)=>{
P.slugify=s=>s.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g,'').replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,'');
P.slugify=s=>s.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g,'').replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,'');
-P.lorem=(s,m)=>{
- const W='lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore magna aliqua enim ad minim veniam quis nostrud exercitation ullamco laboris nisi aliquip ex ea commodo consequat duis aute irure in reprehenderit voluptate velit esse cillum fugiat nulla pariatur excepteur sint occaecat cupidatat non proident sunt culpa qui officia deserunt mollit anim id est laborum'.split(' ');
- const rw=()=>W[Math.random()*W.length|0];
- const sent=()=>{const n=8+(Math.random()*10|0);let s=Array.from({length:n},rw).join(' ');return s[0].toUpperCase()+s.slice(1)+'.'};
- const para=()=>Array.from({length:4+(Math.random()*4|0)},sent).join(' ');
- if(m.includes('Words'))return Array.from({length:50},rw).join(' ');
- if(m.includes('Sent'))return Array.from({length:10},sent).join(' ');
- return Array.from({length:parseInt(m)||3},para).join('\n\n');
-};
+P.lorem=(s,m)=>DevEngine.lorem(m.includes('Words')?'words':m.includes('Sent')?'sentences':'paragraphs',m.includes('Words')?50:m.includes('Sent')?10:parseInt(m)||3);
-P.curlconvert=(s,m)=>{
- const cmd=s.trim().replace(/\\\n/g,' ');let method='GET',url='',hd={},body='';
- const tok=[];let cur='',inQ=false,qc='';
- for(let i=0;i<cmd.length;i++){const c=cmd[i];if(!inQ&&(c==="'"||c==='"')){inQ=true;qc=c}else if(inQ&&c===qc){inQ=false}else if(!inQ&&c===' '){if(cur){tok.push(cur);cur=''}continue}else cur+=c}
- if(cur)tok.push(cur);
- for(let i=0;i<tok.length;i++){const t=tok[i];if(t==='-X'||t==='--request')method=tok[++i];else if(t==='-H'||t==='--header'){const h=tok[++i];const ci=h.indexOf(':');if(ci>0)hd[h.slice(0,ci).trim()]=h.slice(ci+1).trim()}
- else if(t==='-d'||t==='--data'||t==='--data-raw')body=tok[++i];else if(!t.startsWith('-')&&t!=='curl')url=t}
- if(body&&method==='GET')method='POST';
- if(m==='Parsed')return'Method: '+method+'\nURL: '+url+'\nHeaders:\n'+Object.entries(hd).map(([k,v])=>' '+k+': '+v).join('\n')+'\nBody: '+(body||'(none)');
- if(m==='To fetch')return"fetch('"+url+"', {\n method: '"+method+"',\n headers: "+JSON.stringify(hd,null,4).replace(/\n/g,'\n ')+','+(body?"\n body: '"+body+"',":'')+'\n});';
- if(m==='To Python')return"import requests\n\nresponse = requests."+method.toLowerCase()+"(\n '"+url+"',\n headers="+JSON.stringify(hd)+","+(body?"\n data='"+body+"',":'')+"\n)\nprint(response.json())";
- return"const https = require('https');\n\nconst req = https.request('"+url+"', {\n method: '"+method+"',\n headers: "+JSON.stringify(hd,null,4).replace(/\n/g,'\n ')+"\n}, res => {\n let data = '';\n res.on('data', c => data += c);\n res.on('end', () => console.log(data));\n});\n"+(body?"req.write('"+body+"');\n":"")+"req.end();";
-};
+P.curlconvert=(s,m)=>DevEngine.curlconvert(s,m);
P.webhook=async()=>{
P.webhook=async()=>{
const o=$('out');
const o=$('out');
@@ -494,17 +387,8 @@ P.webhook=async()=>{
@@ -494,17 +387,8 @@ P.webhook=async()=>{
};
};
P.color=(s)=>{
P.color=(s)=>{
- let r,g,b;const v=s.trim();
- if(v.startsWith('#')){const hx=v.slice(1);r=parseInt(hx.slice(0,2),16);g=parseInt(hx.slice(2,4),16);b=parseInt(hx.slice(4,6),16)}
- else if(v.startsWith('rgb')){[r,g,b]=v.match(/\d+/g).map(Number)}
- else throw new Error('Enter HEX (#ff0000) or RGB (rgb(255,0,0))');
- if([r,g,b].some(x=>isNaN(x)))throw new Error('Invalid color value');
- const mx=Math.max(r,g,b)/255,mn=Math.min(r,g,b)/255,d=mx-mn;let h=0,s2=0,l=(mx+mn)/2;
- if(d){s2=l>.5?d/(2-mx-mn):d/(mx+mn);if(mx===r/255)h=((g/255-b/255)/d+(g<b?6:0))*60;else if(mx===g/255)h=((b/255-r/255)/d+2)*60;else h=((r/255-g/255)/d+4)*60}
- const hex='#'+[r,g,b].map(c=>c.toString(16).padStart(2,'0')).join('');
- const hsl='hsl('+Math.round(h)+', '+Math.round(s2*100)+'%, '+Math.round(l*100)+'%)';
- const comp='#'+[255-r,255-g,255-b].map(c=>c.toString(16).padStart(2,'0')).join('');
- return{html:'<div><div class="sw" style="background:'+hex+'" title="'+hex+'"></div><div class="sw" style="background:'+comp+'" title="Complement"></div></div><div style="line-height:2.2;margin-top:8px">HEX: <strong>'+hex+'</strong>\nRGB: <strong>rgb('+r+', '+g+', '+b+')</strong>\nHSL: <strong>'+hsl+'</strong>\nComplement: <strong>'+comp+'</strong></div><div style="margin-top:12px;color:var(--tx2);font-size:.82rem">CSS:\n --color: '+hex+';\n --color-rgb: '+r+', '+g+', '+b+';</div>'};
+ const c=DevEngine.color(s);
+ return{html:'<div><div class="sw" style="background:'+c.hex+'" title="'+c.hex+'"></div><div class="sw" style="background:'+c.complement+'" title="Complement"></div></div><div style="line-height:2.2;margin-top:8px">HEX: <strong>'+c.hex+'</strong>\nRGB: <strong>'+c.rgb+'</strong>\nHSL: <strong>'+c.hsl+'</strong>\nComplement: <strong>'+c.complement+'</strong></div><div style="margin-top:12px;color:var(--tx2);font-size:.82rem">CSS:\n --color: '+c.hex+';\n --color-rgb: '+c.r+', '+c.g+', '+c.b+';</div>'};
};
};
P.opengraph=async(s)=>{
P.opengraph=async(s)=>{
new file mode 100644
new file mode 100644
@@ -0,0 +1,278 @@
@@ -0,0 +1,278 @@
+// ═══════════════════════════════════════════════════════════════
+// Dev.OpenVibe — the transforms behind the developer tools.
+//
+// One engine for two callers: the tool pages (dev.html loads this file and only adds presentation)
+// and the server (server/dev/engines.js requires it for the tools API, ADR-027). Everything here is
+// pure: no DOM, no network, no storage. The browser globals it uses exist in Node 22 as well
+// (atob, btoa, escape, unescape, TextEncoder, URL, crypto.subtle, crypto.randomUUID).
+//
+// What is NOT here needs a browser: XML validation, XML minify and HTML to Markdown (DOMParser),
+// HTML entity decoding (a DOM element), the terser-based JavaScript minifier and beautifier (loaded
+// from a CDN on demand), and the regex tester (a visitor's pattern must not run on the server
+// without isolation). Those stay in dev.html.
+// ═══════════════════════════════════════════════════════════════
+(function (root) {
+'use strict';
+
+const E = {};
+
+// ── Data & formats ───────────────────────────────────────────
+E.jsonfmt = (s, m) => { const o = JSON.parse(s); if (m === 'Minify') return JSON.stringify(o); if (m === 'Validate') return '\u2713 Valid JSON \u2014 ' + (Array.isArray(o) ? o.length + ' items' : typeof o === 'object' ? Object.keys(o).length + ' keys' : typeof o); return JSON.stringify(o, null, 2); };
+
+function j2y(o, ind) {
+ let r = '';
+ if (Array.isArray(o)) o.forEach(v => { r += ind + '- ' + (typeof v === 'object' && v ? '\n' + j2y(v, ind + ' ') : v) + '\n'; });
+ else if (typeof o === 'object' && o) for (const [k, v] of Object.entries(o)) r += typeof v === 'object' && v ? ind + k + ':\n' + j2y(v, ind + ' ') : ind + k + ': ' + v + '\n';
+ else r += ind + o + '\n';
+ return r;
+}
+/** mode 'YAML→JSON' (default) or 'JSON→YAML'. */
+E.yaml = (s, m) => {
+ if (String(m || '').includes('JSON\u2192')) { const o = JSON.parse(s); return j2y(o, ''); }
+ const ln = s.split('\n').filter(l => l.trim() && !l.trim().startsWith('#')), r = {};
+ for (const l of ln) { const mt = l.match(/^(\s*)([^:]+):\s*(.*)$/); if (mt) { let v = mt[3].trim(); v = v === '' || v === '~' || v === 'null' ? null : v === 'true' ? true : v === 'false' ? false : /^-?\d+(\.\d+)?$/.test(v) ? Number(v) : v.replace(/^["']|["']$/g, ''); r[mt[2].trim()] = v; } }
+ return JSON.stringify(r, null, 2);
+};
+
+/** Indent markup one tag per line (the XML, HTML and beautify formatters). */
+function indentTags(s) {
+ 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();
+}
+/** mode 'Format' (default) or 'Minify'. Validation needs a DOM parser and stays on the page. */
+E.xml = (s, m) => (m === 'Minify' ? s.replace(/>\s+</g, '><').replace(/\s{2,}/g, ' ').trim() : indentTags(s));
+
+/** CSV → rows of cells; quoted fields keep their commas. */
+E.csvRows = (s) => s.trim().split('\n').map(r => { const c = []; let cur = '', q = false; for (let i = 0; i < r.length; i++) { if (r[i] === '"') q = !q; else if (r[i] === ',' && !q) { c.push(cur.trim()); cur = ''; } else cur += r[i]; } c.push(cur.trim()); return c; });
+/** CSV → one object per row, keyed by the header row. */
+E.csvObjects = (s) => { const rows = E.csvRows(s); const hd = rows[0]; return rows.slice(1).map(r => { const o = {}; hd.forEach((h, i) => { o[h] = r[i] || ''; }); return o; }); };
+
+/** mode 'Format' (default), 'Uppercase' or 'Compact'. */
+E.sql = (s, m) => {
+ if (m === 'Compact') return s.replace(/\s+/g, ' ').trim();
+ const kw = ['SELECT', 'DISTINCT', 'FROM', 'WHERE', 'JOIN', 'LEFT JOIN', 'RIGHT JOIN', 'INNER JOIN', 'ON', 'AND', 'OR', 'ORDER BY', 'GROUP BY', 'HAVING', 'LIMIT', 'OFFSET', 'INSERT INTO', 'VALUES', 'UPDATE', 'SET', 'DELETE FROM', 'CREATE TABLE', 'ALTER TABLE', 'UNION', 'CASE', 'WHEN', 'THEN', 'ELSE', 'END'];
+ let r = s; if (m === 'Uppercase') kw.forEach(k => { r = r.replace(new RegExp('\\b' + k.replace(/ /g, '\\s+') + '\\b', 'gi'), k); });
+ kw.slice(0, 20).forEach(k => { r = r.replace(new RegExp('(\\b)(' + k.replace(/ /g, '\\s+') + ')(\\b)', 'gi'), '\n$1$2$3'); });
+ return r.replace(/^\n+/, '').replace(/\n{2,}/g, '\n');
+};
+
+/** Markdown → HTML as the Markdown editor renders it (inline styles use the page's theme variables). */
+E.md = (s) => s.replace(/^######\s(.+)/gm, '<h6>$1</h6>').replace(/^#####\s(.+)/gm, '<h5>$1</h5>').replace(/^####\s(.+)/gm, '<h4>$1</h4>')
+ .replace(/^###\s(.+)/gm, '<h3>$1</h3>').replace(/^##\s(.+)/gm, '<h2>$1</h2>').replace(/^#\s(.+)/gm, '<h1>$1</h1>')
+ .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>').replace(/\*(.+?)\*/g, '<em>$1</em>')
+ .replace(/`([^`]+)`/g, '<code style="background:var(--bg3);padding:2px 5px;border-radius:4px">$1</code>')
+ .replace(/^\- (.+)/gm, '<li>$1</li>').replace(/^> (.+)/gm, '<blockquote style="border-left:3px solid var(--ac);padding-left:12px;color:var(--tx2)">$1</blockquote>')
+ .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" style="color:var(--ac)">$1</a>')
+ .replace(/^---$/gm, '<hr style="border:none;border-top:1px solid var(--brd);margin:16px 0">');
+/** Markdown → clean HTML (no inline styles, lists wrapped, paragraphs), as Markdown to HTML copies it. */
+E.md2html = (s) => {
+ let h = E.md(s).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 h;
+};
+
+/** mode 'Format' (default), 'Minify' or 'Entities'. Unescaping entities needs a DOM and stays on the page. */
+E.html = (s, m) => {
+ if (m === 'Entities') return s.replace(/[\u00A0-\u9999<>&'"]/g, c => '&#' + c.charCodeAt(0) + ';');
+ if (m === 'Minify') return s.replace(/\s+/g, ' ').replace(/>\s+</g, '><').trim();
+ return indentTags(s);
+};
+
+// ── Encoding & crypto ────────────────────────────────────────
+/** mode 'Encode' (default) or 'Decode'; UTF-8 safe. */
+E.base64 = (s, m) => (m === 'Decode' ? decodeURIComponent(escape(atob(s.trim()))) : btoa(unescape(encodeURIComponent(s))));
+
+/** mode 'Encode', 'Decode' or 'Parse' (default). */
+E.url = (s, m) => {
+ if (m === 'Encode') return encodeURIComponent(s);
+ if (m === 'Decode') return decodeURIComponent(s);
+ try { const u = new URL(s); return 'Protocol: ' + u.protocol + '\nHost: ' + u.host + '\nPath: ' + u.pathname + '\nSearch: ' + u.search + '\nHash: ' + u.hash + '\nOrigin: ' + u.origin; } catch { return 'Invalid URL \u2014 include protocol (https://)'; }
+};
+
+/** A JWT's parts, decoded (never verified). exp: { at: ISO time, msLeft } when the payload has one. */
+E.jwt = (s, now = Date.now()) => {
+ const p = s.trim().split('.'); if (p.length !== 3) throw new Error('JWT must have 3 dot-separated parts');
+ const d = v => JSON.parse(decodeURIComponent(escape(atob(v.replace(/-/g, '+').replace(/_/g, '/')))));
+ const header = d(p[0]), payload = d(p[1]);
+ const exp = payload.exp ? { at: new Date(payload.exp * 1000).toISOString(), msLeft: payload.exp * 1000 - now } : null;
+ return { header, payload, signature: p[2], exp };
+};
+
+const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
+/** n random v4 UUIDs, one per line. */
+E.uuids = (n) => Array.from({ length: n }, () => crypto.randomUUID()).join('\n');
+E.isUuid = (s) => UUID_V4.test(String(s).trim());
+/** mode 'Generate' (default), 'Bulk 10', 'Bulk 100' or 'Validate'. */
+E.uuid = (s, m) => {
+ if (m === 'Validate') return E.isUuid(s) ? '\u2713 Valid UUID v4' : '\u2717 Invalid UUID v4';
+ return E.uuids(String(m || '').includes('100') ? 100 : String(m || '').includes('10') ? 10 : 1);
+};
+
+/** Hex digest of the UTF-8 text: algorithm 'SHA-256' (default), 'SHA-1' or 'SHA-512'. */
+E.hash = async (s, algorithm) => {
+ const a = algorithm === 'SHA-1' ? 'SHA-1' : algorithm === 'SHA-512' ? 'SHA-512' : 'SHA-256';
+ const buf = await crypto.subtle.digest(a, new TextEncoder().encode(s));
+ return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
+};
+
+/** mode 'To Hex' (default) or 'From Hex'. */
+E.hex = (s, m) => {
+ if (m === 'From Hex') return s.trim().replace(/\s+/g, '').match(/.{2}/g).map(h => String.fromCharCode(parseInt(h, 16))).join('');
+ return [...s].map(c => c.charCodeAt(0).toString(16).padStart(2, '0')).join(' ');
+};
+
+// ── Time ─────────────────────────────────────────────────────
+/** mode 'Now', 'To Date' or 'To Unix' (default). "Local" is the local time of whoever runs it. */
+E.timestamp = (s, m, now = Date.now()) => {
+ if (m === 'Now') { const n = now; return 'Unix (s): ' + Math.floor(n / 1000) + '\nUnix (ms): ' + n + '\nISO 8601: ' + new Date(n).toISOString() + '\nLocal: ' + new Date(n).toLocaleString() + '\nUTC: ' + new Date(n).toUTCString(); }
+ if (m === 'To Date') { const v = Number(s.trim()), d = new Date(v < 1e12 ? v * 1000 : v); if (isNaN(d.getTime())) throw new Error('Invalid timestamp'); return 'ISO 8601: ' + d.toISOString() + '\nLocal: ' + d.toLocaleString() + '\nUTC: ' + d.toUTCString() + '\nUnix (s): ' + Math.floor(d / 1000) + '\nUnix (ms): ' + Number(d); }
+ const d = new Date(s.trim()); if (isNaN(d.getTime())) throw new Error('Invalid date string'); return 'Unix (s): ' + Math.floor(d / 1000) + '\nUnix (ms): ' + Number(d) + '\nISO 8601: ' + d.toISOString() + '\nUTC: ' + d.toUTCString();
+};
+
+/** A cron expression in words and its next five runs (local time of whoever runs it). */
+E.cron = (s, now = Date.now()) => {
+ const p = s.trim().split(/\s+/); if (p.length < 5) throw new Error('Need 5 fields: minute hour day month weekday');
+ const N = [['minute', 0, 59], ['hour', 0, 23], ['day', 1, 31], ['month', 1, 12], ['weekday', 0, 6]];
+ function desc(v, i) { if (v === '*') return 'every ' + N[i][0]; if (v.includes('/')) return 'every ' + v.split('/')[1] + ' ' + N[i][0] + 's'; return N[i][0] + ' ' + v; }
+ function expand(v, mn, mx) {
+ if (v === '*') return Array.from({ length: mx - mn + 1 }, (_, i) => i + mn);
+ if (v.includes('/')) { const [b, st] = v.split('/'); const start = b === '*' ? mn : +b; const r = []; for (let i = start; i <= mx; i += +st) r.push(i); return r; }
+ if (v.includes(',')) return v.split(',').map(Number);
+ if (v.includes('-')) { const [a, b] = v.split('-').map(Number); return Array.from({ length: b - a + 1 }, (_, i) => i + a); }
+ return [+v];
+ }
+ const mins = expand(p[0], 0, 59), hrs = expand(p[1], 0, 23), doms = expand(p[2], 1, 31), mos = expand(p[3], 1, 12), dws = expand(p[4], 0, 6);
+ const nx = []; const d = new Date(now); d.setSeconds(0, 0); d.setMinutes(d.getMinutes() + 1);
+ for (let i = 0; i < 525600 && nx.length < 5; i++) { if (mins.includes(d.getMinutes()) && hrs.includes(d.getHours()) && doms.includes(d.getDate()) && mos.includes(d.getMonth() + 1) && dws.includes(d.getDay())) nx.push(new Date(d)); d.setMinutes(d.getMinutes() + 1); }
+ return 'Schedule: ' + p.map((v, i) => desc(v, i)).join(', ') + '\n\nNext 5 runs:\n' + nx.map((x, i) => ' ' + (i + 1) + '. ' + x.toLocaleString()).join('\n');
+};
+
+// ── Code quality ─────────────────────────────────────────────
+/** mode 'JSON', 'CSS' or 'HTML' (default). */
+E.beautify = (s, m) => {
+ if (m === 'JSON') return JSON.stringify(JSON.parse(s), null, 2);
+ if (m === 'CSS') { let r = '', ind = 0; for (const c of s) { if (c === '{') { r += ' {\n'; ind++; r += ' '.repeat(ind); } else if (c === '}') { ind--; r += '\n' + ' '.repeat(ind) + '}\n' + ' '.repeat(ind); } else if (c === ';') r += ';\n' + ' '.repeat(ind); else r += c; } return r.trim(); }
+ return indentTags(s);
+};
+
+/** mode 'JSON', 'CSS', 'HTML' or anything else (JavaScript comments and whitespace). */
+E.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();
+};
+
+const LOREM = 'lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore magna aliqua enim ad minim veniam quis nostrud exercitation ullamco laboris nisi aliquip ex ea commodo consequat duis aute irure in reprehenderit voluptate velit esse cillum fugiat nulla pariatur excepteur sint occaecat cupidatat non proident sunt culpa qui officia deserunt mollit anim id est laborum'.split(' ');
+/** unit 'paragraphs' (default), 'sentences' or 'words'; count of that unit. */
+E.lorem = (unit, count) => {
+ const rw = () => LOREM[Math.random() * LOREM.length | 0];
+ const sent = () => { const n = 8 + (Math.random() * 10 | 0); const s = Array.from({ length: n }, rw).join(' '); return s[0].toUpperCase() + s.slice(1) + '.'; };
+ const para = () => Array.from({ length: 4 + (Math.random() * 4 | 0) }, sent).join(' ');
+ if (unit === 'words') return Array.from({ length: count || 50 }, rw).join(' ');
+ if (unit === 'sentences') return Array.from({ length: count || 10 }, sent).join(' ');
+ return Array.from({ length: count || 3 }, para).join('\n\n');
+};
+
+/** A curl command → mode 'Parsed', 'To fetch', 'To Python' or Node.js (default). */
+E.curlconvert = (s, m) => {
+ const cmd = s.trim().replace(/\\\n/g, ' '); let method = 'GET', url = '', body = ''; const hd = {};
+ const tok = []; let cur = '', inQ = false, qc = '';
+ for (let i = 0; i < cmd.length; i++) { const c = cmd[i]; if (!inQ && (c === "'" || c === '"')) { inQ = true; qc = c; } else if (inQ && c === qc) { inQ = false; } else if (!inQ && c === ' ') { if (cur) { tok.push(cur); cur = ''; } continue; } else cur += c; }
+ if (cur) tok.push(cur);
+ for (let i = 0; i < tok.length; i++) {
+ const t = tok[i];
+ if (t === '-X' || t === '--request') method = tok[++i];
+ else if (t === '-H' || t === '--header') { const h = tok[++i]; const ci = h.indexOf(':'); if (ci > 0) hd[h.slice(0, ci).trim()] = h.slice(ci + 1).trim(); }
+ else if (t === '-d' || t === '--data' || t === '--data-raw') body = tok[++i];
+ else if (!t.startsWith('-') && t !== 'curl') url = t;
+ }
+ if (body && method === 'GET') method = 'POST';
+ if (m === 'Parsed') return 'Method: ' + method + '\nURL: ' + url + '\nHeaders:\n' + Object.entries(hd).map(([k, v]) => ' ' + k + ': ' + v).join('\n') + '\nBody: ' + (body || '(none)');
+ if (m === 'To fetch') return "fetch('" + url + "', {\n method: '" + method + "',\n headers: " + JSON.stringify(hd, null, 4).replace(/\n/g, '\n ') + ',' + (body ? "\n body: '" + body + "'," : '') + '\n});';
+ if (m === 'To Python') return "import requests\n\nresponse = requests." + method.toLowerCase() + "(\n '" + url + "',\n headers=" + JSON.stringify(hd) + ',' + (body ? "\n data='" + body + "'," : '') + '\n)\nprint(response.json())';
+ return "const https = require('https');\n\nconst req = https.request('" + url + "', {\n method: '" + method + "',\n headers: " + JSON.stringify(hd, null, 4).replace(/\n/g, '\n ') + "\n}, res => {\n let data = '';\n res.on('data', c => data += c);\n res.on('end', () => console.log(data));\n});\n" + (body ? "req.write('" + body + "');\n" : '') + 'req.end();';
+};
+
+// ── Frontend ─────────────────────────────────────────────────
+/** '#rrggbb' or 'rgb(r, g, b)' → { hex, r, g, b, rgb, hsl, complement }. */
+E.color = (s) => {
+ let r, g, b; const v = s.trim();
+ if (v.startsWith('#')) { const hx = v.slice(1); r = parseInt(hx.slice(0, 2), 16); g = parseInt(hx.slice(2, 4), 16); b = parseInt(hx.slice(4, 6), 16); }
+ else if (v.startsWith('rgb')) { [r, g, b] = v.match(/\d+/g).map(Number); }
+ else throw new Error('Enter HEX (#ff0000) or RGB (rgb(255,0,0))');
+ if ([r, g, b].some(x => isNaN(x))) throw new Error('Invalid color value');
+ const mx = Math.max(r, g, b) / 255, mn = Math.min(r, g, b) / 255, d = mx - mn; let h = 0, s2 = 0; const l = (mx + mn) / 2;
+ if (d) { s2 = l > 0.5 ? d / (2 - mx - mn) : d / (mx + mn); if (mx === r / 255) h = ((g / 255 - b / 255) / d + (g < b ? 6 : 0)) * 60; else if (mx === g / 255) h = ((b / 255 - r / 255) / d + 2) * 60; else h = ((r / 255 - g / 255) / d + 4) * 60; }
+ const hex = '#' + [r, g, b].map(c => c.toString(16).padStart(2, '0')).join('');
+ return { hex, r, g, b, rgb: 'rgb(' + r + ', ' + g + ', ' + b + ')', hsl: 'hsl(' + Math.round(h) + ', ' + Math.round(s2 * 100) + '%, ' + Math.round(l * 100) + '%)', complement: '#' + [255 - r, 255 - g, 255 - b].map(c => c.toString(16).padStart(2, '0')).join('') };
+};
+
+// ── Single-purpose tools ─────────────────────────────────────
+/** Stylesheet minifier: strings and url() kept, comments and whitespace out, 0px → 0, #aabbcc → #abc. */
+E.cssMinify = (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]);
+};
+E.cssFormat = (s) => {
+ let out = '', depth = 0; const pad = () => ' '.repeat(depth); const min = E.cssMinify(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();
+};
+/** HTML minifier: pre, textarea, script and style kept as they are; comments (not IE conditionals) out. */
+E.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();
+ return o.replace(/\u0000(\d+)\u0000/g, (_, i) => keep[+i]);
+};
+
+/** JSON.parse with the line, column and a hint for the usual mistakes. */
+function 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}`;
+}
+E.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);
+E.jsonMinify = (s) => JSON.stringify(E.jsonIn(s));
+/** { kind, size (items or keys; null for a scalar), values, depth } of valid JSON; throws with the position otherwise. */
+E.jsonStats = (s) => {
+ const v = E.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 { kind: kind(v), size: Array.isArray(v) ? v.length : v && typeof v === 'object' ? Object.keys(v).length : null, values: n, depth };
+};
+/** view 'Tree', 'Paths' (default) or 'Types'. */
+E.jsonParse = (s, m) => {
+ const v = E.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');
+};
+/** mode 'Stringify' (default) or 'Parse string'. */
+E.jsonStringify = (s, m) => {
+ if (m === 'Parse string') { const t = s.trim(); const inner = JSON.parse(/^"/.test(t) ? t : '"' + t.replace(/"/g, '\\"') + '"'); return JSON.stringify(E.jsonIn(inner), null, 2); }
+ return JSON.stringify(JSON.stringify(E.jsonIn(s)));
+};
+
+if (typeof module !== 'undefined' && module.exports) module.exports = E;
+else root.DevEngine = E;
+})(typeof window !== 'undefined' ? window : this);
new file mode 100644
new file mode 100644
@@ -0,0 +1,52 @@
@@ -0,0 +1,52 @@
+'use strict';
+// ═══════════════════════════════════════════════════════════════
+// Dev.OpenVibe — server engines for the tools API (ADR-027).
+//
+// The developer tools run in the browser, and their pages keep doing so. These are the same
+// transforms, public/js/dev-engine.js, required in Node, so a dev tool whose descriptor says
+// api: true can be run by POST /api/v1/tools/:id/run. Each engine takes the run's input (already
+// checked against the tool's input schema; the API's lower-case modes are the page's buttons) and
+// answers { text } (output kind text) or { data } (output kind json). An input the transform cannot
+// read (JSON that does not parse, a malformed JWT) throws, and the run fails with that message.
+// ═══════════════════════════════════════════════════════════════
+
+const D = require('../../public/js/dev-engine');
+
+const label = (map, value, dflt) => map[value] || map[dflt];
+
+const ENGINES = {
+ yaml: ({ text, to = 'json' }) => ({ text: D.yaml(text, to === 'yaml' ? 'JSON→YAML' : 'YAML→JSON') }),
+ xml: ({ text, mode = 'format' }) => ({ text: D.xml(text, mode === 'minify' ? 'Minify' : 'Format') }),
+ csv: ({ text }) => ({ data: { rows: D.csvObjects(text) } }),
+ sql: ({ text, mode = 'format' }) => ({ text: D.sql(text, label({ format: 'Format', uppercase: 'Uppercase', compact: 'Compact' }, mode, 'format')) }),
+ html: ({ text, mode = 'format' }) => ({ text: D.html(text, label({ format: 'Format', minify: 'Minify', entities: 'Entities' }, mode, 'format')) }),
+ base64: ({ text, mode = 'encode' }) => ({ text: D.base64(text, mode === 'decode' ? 'Decode' : 'Encode') }),
+ url: ({ text, mode = 'encode' }) => ({ text: D.url(text, label({ encode: 'Encode', decode: 'Decode', parse: 'Parse' }, mode, 'encode')) }),
+ jwt: ({ token }) => {
+ const j = D.jwt(token);
+ return { data: { header: j.header, payload: j.payload, signature: j.signature, expires_at: j.exp ? j.exp.at : null, expired: j.exp ? j.exp.msLeft <= 0 : null } };
+ },
+ uuid: ({ mode = 'generate', count = 1, text = '' }) => (mode === 'validate' ? { data: { valid: D.isUuid(text) } } : { data: { uuids: D.uuids(count).split('\n') } }),
+ hash: async ({ text, algorithm = 'SHA-256' }) => ({ data: { algorithm, hex: await D.hash(text, algorithm) } }),
+ hex: ({ text, mode = 'to-hex' }) => ({ text: D.hex(text, mode === 'from-hex' ? 'From Hex' : 'To Hex') }),
+ timestamp: ({ mode = 'now', text = '' }) => ({ text: D.timestamp(text, label({ now: 'Now', 'to-date': 'To Date', 'to-unix': 'To Unix' }, mode, 'now')) }),
+ cron: ({ expression }) => ({ text: D.cron(expression) }),
+ beautify: ({ text, language = 'html' }) => ({ text: D.beautify(text, language.toUpperCase()) }),
+ minify: ({ text, language }) => ({ text: D.minify(text, language.toUpperCase()) }),
+ lorem: ({ unit = 'paragraphs', count }) => ({ text: D.lorem(unit, count) }),
+ curlconvert: ({ command, to = 'fetch' }) => ({ text: D.curlconvert(command, label({ fetch: 'To fetch', python: 'To Python', node: 'To Node', parsed: 'Parsed' }, to, 'fetch')) }),
+ color: ({ color }) => ({ data: D.color(color) }),
+ cssminify: ({ text }) => ({ text: D.cssMinify(text) }),
+ cssformat: ({ text }) => ({ text: D.cssFormat(text) }),
+ htmlminify: ({ text }) => ({ text: D.htmlMinify(text) }),
+ jsonminify: ({ text }) => ({ text: D.jsonMinify(text) }),
+ // A validator's "no" is its answer, not a failure: invalid JSON is { valid: false, error }.
+ jsonvalidate: ({ text }) => {
+ try { return { data: { valid: true, ...D.jsonStats(text) } }; } catch (err) { return { data: { valid: false, error: err.message } }; }
+ },
+ jsonparse: ({ text, view = 'paths' }) => ({ text: D.jsonParse(text, label({ tree: 'Tree', paths: 'Paths', types: 'Types' }, view, 'paths')) }),
+ jsonstringify: ({ text, mode = 'stringify' }) => ({ text: D.jsonStringify(text, mode === 'parse' ? 'Parse string' : 'Stringify') }),
+ md2html: ({ text }) => ({ text: D.md2html(text) }),
+};
+
+module.exports = { ENGINES };
@@ -63,16 +63,13 @@
@@ -63,16 +63,13 @@
OpenVibeUI.initPage();
OpenVibeUI.initPage();
const output = document.getElementById('output');
const output = document.getElementById('output');
- function toHex(text) { return [...text].map(c => c.codePointAt(0).toString(16).toUpperCase().padStart(2, '0')).join(' '); }
- function toDec(text) { return [...text].map(c => c.codePointAt(0)).join(' '); }
- function toOct(text) { return [...text].map(c => c.codePointAt(0).toString(8).padStart(3, '0')).join(' '); }
const cards = {};
const cards = {};
const formats = [
const formats = [
{ key: 'binary', name: 'Binary', fn: TextOpenVibeEngine.toBinary },
{ key: 'binary', name: 'Binary', fn: TextOpenVibeEngine.toBinary },
- { key: 'hex', name: 'Hexadecimal', fn: toHex },
- { key: 'decimal', name: 'Decimal', fn: toDec },
- { key: 'octal', name: 'Octal', fn: toOct },
+ { key: 'hex', name: 'Hexadecimal', fn: TextOpenVibeEngine.toHex },
+ { key: 'decimal', name: 'Decimal', fn: TextOpenVibeEngine.toDecimal },
+ { key: 'octal', name: 'Octal', fn: TextOpenVibeEngine.toOctal },
];
];
function update() {
function update() {
@@ -63,18 +63,6 @@
@@ -63,18 +63,6 @@
<script src="/js/shared.js"></script>
<script src="/js/shared.js"></script>
<script>
<script>
OpenVibeUI.initPage();
OpenVibeUI.initPage();
- const BRAILLE_MAP = {
- 'a':'⠁','b':'⠃','c':'⠉','d':'⠙','e':'⠑','f':'⠋','g':'⠛','h':'⠓','i':'⠊','j':'⠚',
- 'k':'⠅','l':'⠇','m':'⠍','n':'⠝','o':'⠕','p':'⠏','q':'⠟','r':'⠗','s':'⠎','t':'⠞',
- 'u':'⠥','v':'⠧','w':'⠺','x':'⠭','y':'⠽','z':'⠵',
- '1':'⠼⠁','2':'⠼⠃','3':'⠼⠉','4':'⠼⠙','5':'⠼⠑',
- '6':'⠼⠋','7':'⠼⠛','8':'⠼⠓','9':'⠼⠊','0':'⠼⠚',
- ' ':'⠀','.':'⠲',',':'⠂','?':'⠦','!':'⠖',';':'⠆',':':'⠒',
- '-':'⠤','(':'⠶',')':'⠶','\'':'⠄','"':'⠐⠂','/':'⠌'
- };
- const REVERSE_MAP = {};
- Object.entries(BRAILLE_MAP).forEach(([k, v]) => { if (!REVERSE_MAP[v]) REVERSE_MAP[v] = k; });
-
let mode = 'encode';
let mode = 'encode';
function setMode(m) {
function setMode(m) {
mode = m;
mode = m;
@@ -84,23 +72,9 @@
@@ -84,23 +72,9 @@
document.getElementById('input').placeholder = m === 'encode' ? 'Type your text...' : 'Paste Braille dots here...';
document.getElementById('input').placeholder = m === 'encode' ? 'Type your text...' : 'Paste Braille dots here...';
update();
update();
}
}
- function encode(text) {
- return [...text.toLowerCase()].map(c => BRAILLE_MAP[c] || c).join('');
- }
- function decode(braille) {
- let out = '', i = 0;
- const chars = [...braille];
- while (i < chars.length) {
- const two = chars[i] + (chars[i+1] || '');
- if (REVERSE_MAP[two]) { out += REVERSE_MAP[two]; i += 2; }
- else if (REVERSE_MAP[chars[i]]) { out += REVERSE_MAP[chars[i]]; i++; }
- else { out += chars[i]; i++; }
- }
- return out;
- }
function update() {
function update() {
const text = document.getElementById('input').value;
const text = document.getElementById('input').value;
- document.getElementById('output').textContent = mode === 'encode' ? encode(text) : decode(text);
+ document.getElementById('output').textContent = mode === 'encode' ? TextOpenVibeEngine.toBraille(text) : TextOpenVibeEngine.fromBraille(text);
}
}
document.getElementById('input').addEventListener('input', update);
document.getElementById('input').addEventListener('input', update);
update();
update();
@@ -76,17 +76,9 @@
@@ -76,17 +76,9 @@
function update() {
function update() {
let text = document.getElementById('input').value;
let text = document.getElementById('input').value;
if (!text) { document.getElementById('output').textContent = ''; return; }
if (!text) { document.getElementById('output').textContent = ''; return; }
- const c = TextOpenVibeEngine.clean;
- if (document.getElementById('optInvisible').checked) text = c.removeInvisible(text);
- if (document.getElementById('optZalgo').checked) text = c.removeZalgo(text);
- if (document.getElementById('optQuotes').checked) text = c.normalizeQuotes(text);
- if (document.getElementById('optHyphens').checked) text = c.normalizeHyphens(text);
- if (document.getElementById('optHtml').checked) text = c.stripHtml(text);
- if (document.getElementById('optSpaces').checked) text = c.collapseSpaces(text);
- if (document.getElementById('optTrim').checked) text = c.trimLines(text);
- if (document.getElementById('optBlank').checked) text = c.removeBlankLines(text);
- if (document.getElementById('optLines').checked) text = c.collapseLines(text);
- document.getElementById('output').textContent = text;
+ const steps = {};
+ for (const { key } of TextOpenVibeEngine.cleanSteps) steps[key] = document.getElementById('opt' + key[0].toUpperCase() + key.slice(1)).checked;
+ document.getElementById('output').textContent = TextOpenVibeEngine.cleanText(text, steps);
}
}
document.getElementById('input').addEventListener('input', update);
document.getElementById('input').addEventListener('input', update);
@@ -80,38 +80,13 @@ A brand new line was added.</textarea>
@@ -80,38 +80,13 @@ A brand new line was added.</textarea>
<script src="https://openvibe.network/shared/notification-ui.js"></script>
<script src="https://openvibe.network/shared/notification-ui.js"></script>
<script src="https://openvibe.network/shared/account-switcher.js"></script>
<script src="https://openvibe.network/shared/account-switcher.js"></script>
<script src="/js/shared.js"></script>
<script src="/js/shared.js"></script>
+ <script src="/js/format-engine.js"></script>
<script>
<script>
OpenVibeUI.initPage();
OpenVibeUI.initPage();
function escHtml(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
function escHtml(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
- // Simple line-based diff (LCS)
- function diffLines(a, b) {
- const linesA = a.split('\n'), linesB = b.split('\n');
- const m = linesA.length, n = linesB.length;
- // Build LCS table
- const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
- for (let i = 1; i <= m; i++)
- for (let j = 1; j <= n; j++)
- dp[i][j] = linesA[i-1] === linesB[j-1] ? dp[i-1][j-1] + 1 : Math.max(dp[i-1][j], dp[i][j-1]);
- // Backtrack
- const result = [];
- let i = m, j = n;
- while (i > 0 || j > 0) {
- if (i > 0 && j > 0 && linesA[i-1] === linesB[j-1]) {
- result.unshift({ type: 'same', text: linesA[i-1] });
- i--; j--;
- } else if (j > 0 && (i === 0 || dp[i][j-1] >= dp[i-1][j])) {
- result.unshift({ type: 'add', text: linesB[j-1] });
- j--;
- } else {
- result.unshift({ type: 'del', text: linesA[i-1] });
- i--;
- }
- }
- return result;
- }
-
+ const diffLines = FormatOpenVibeEngine.diffLines;
function compare() {
function compare() {
const a = document.getElementById('textA').value;
const a = document.getElementById('textA').value;
const b = document.getElementById('textB').value;
const b = document.getElementById('textB').value;
@@ -62,20 +62,8 @@
@@ -62,20 +62,8 @@
<script>
<script>
OpenVibeUI.initPage();
OpenVibeUI.initPage();
- const TRANSFORMS = [
- { id: 'htmlEscape', label: 'HTML Escaped', icon: 'fa-code', fn: t => TextOpenVibeEngine.escapeHtml(t) },
- { id: 'htmlUnescape', label: 'HTML Unescaped', icon: 'fa-code', fn: t => TextOpenVibeEngine.unescapeHtml(t) },
- { id: 'urlEncode', label: 'URL Encoded', icon: 'fa-link', fn: t => encodeURIComponent(t) },
- { id: 'urlDecode', label: 'URL Decoded', icon: 'fa-link', fn: t => { try { return decodeURIComponent(t); } catch { return '[Invalid encoded string]'; } } },
- { id: 'base64Encode', label: 'Base64 Encoded', icon: 'fa-lock', fn: t => { try { return btoa(unescape(encodeURIComponent(t))); } catch { return '[Error]'; } } },
- { id: 'base64Decode', label: 'Base64 Decoded', icon: 'fa-unlock', fn: t => { try { return decodeURIComponent(escape(atob(t))); } catch { return '[Invalid Base64]'; } } },
- { id: 'jsonStringify', label: 'JSON Stringified', icon: 'fa-brackets-curly', fn: t => JSON.stringify(t) },
- { id: 'jsonParse', label: 'JSON Parsed', icon: 'fa-brackets-curly', fn: t => { try { return typeof JSON.parse(t) === 'string' ? JSON.parse(t) : JSON.stringify(JSON.parse(t), null, 2); } catch { return '[Invalid JSON string]'; } } },
- { id: 'cssEscape', label: 'CSS Escaped', icon: 'fa-paintbrush', fn: t => t.replace(/([^\w-])/g, '\\$1') },
- { id: 'regexEscape', label: 'Regex Escaped', icon: 'fa-asterisk', fn: t => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') },
- { id: 'unicodeEscape', label: 'Unicode Escape', icon: 'fa-globe', fn: t => [...t].map(c => { const cp = c.codePointAt(0); return cp > 127 ? (cp > 0xFFFF ? '\\u{' + cp.toString(16) + '}' : '\\u' + cp.toString(16).padStart(4, '0')) : c; }).join('') },
- { id: 'hexEncode', label: 'Hex Encoded', icon: 'fa-hashtag', fn: t => [...new TextEncoder().encode(t)].map(b => b.toString(16).padStart(2, '0')).join(' ') },
- ];
+ const ICONS = { htmlEscape: 'fa-code', htmlUnescape: 'fa-code', urlEncode: 'fa-link', urlDecode: 'fa-link', base64Encode: 'fa-lock', base64Decode: 'fa-unlock', jsonStringify: 'fa-brackets-curly', jsonParse: 'fa-brackets-curly', cssEscape: 'fa-paintbrush', regexEscape: 'fa-asterisk', unicodeEscape: 'fa-globe', hexEncode: 'fa-hashtag' };
+ const TRANSFORMS = Object.entries(TextOpenVibeEngine.escapes).map(([id, e]) => ({ id, label: e.label, icon: ICONS[id], fn: e.fn }));
const grid = document.getElementById('grid');
const grid = document.getElementById('grid');
TRANSFORMS.forEach(tr => {
TRANSFORMS.forEach(tr => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,108 @@
@@ -0,0 +1,108 @@
+// ═══════════════════════════════════════════════════════════════
+// Text.OpenVibe — JSON formatting, Markdown rendering and text comparison.
+//
+// The engine behind json., markdown. and compare.openvibe.tools, kept apart from text-engine.js so
+// those three pages do not load the font tables. Like text-engine.js it is pure (no DOM, no network)
+// and runs unchanged in Node: server/engines.js requires it for the tools API (ADR-027).
+// ═══════════════════════════════════════════════════════════════
+(function (root) {
+'use strict';
+
+// ── JSON ─────────────────────────────────────────────────────
+/** Keys at every depth (array items are not keys; their own keys are). */
+function countKeys(obj) {
+ let count = 0;
+ if (typeof obj === 'object' && obj !== null) {
+ if (Array.isArray(obj)) { obj.forEach(v => count += countKeys(v)); }
+ else { for (const k of Object.keys(obj)) { count++; count += countKeys(obj[k]); } }
+ }
+ return count;
+}
+
+/** Nesting depth: a scalar is 0, {} or [] is 1. */
+function maxDepth(obj, d) {
+ d = d || 0;
+ if (typeof obj !== 'object' || obj === null) return d;
+ if (Array.isArray(obj)) return obj.length ? Math.max(...obj.map(v => maxDepth(v, d + 1))) : d + 1;
+ const vals = Object.values(obj);
+ return vals.length ? Math.max(...vals.map(v => maxDepth(v, d + 1))) : d + 1;
+}
+
+/** The same value with every object's keys in sorted order. */
+function sortKeysDeep(obj) {
+ if (Array.isArray(obj)) return obj.map(sortKeysDeep);
+ if (typeof obj === 'object' && obj !== null) {
+ const sorted = {};
+ Object.keys(obj).sort().forEach(k => sorted[k] = sortKeysDeep(obj[k]));
+ return sorted;
+ }
+ return obj;
+}
+
+// ── Markdown ─────────────────────────────────────────────────
+/** A small Markdown renderer (no dependencies): code, headings, emphasis, links, images, quotes, lists, rules. */
+function markdownToHtml(md) {
+ let html = md;
+ // Code blocks
+ html = html.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code>$2</code></pre>');
+ // Inline code
+ html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
+ // Headers
+ html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
+ html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
+ html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
+ // Bold
+ html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
+ // Italic
+ html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
+ // Strikethrough
+ html = html.replace(/~~(.+?)~~/g, '<del>$1</del>');
+ // Images
+ html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1">');
+ // Links
+ html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>');
+ // Blockquotes
+ html = html.replace(/^> (.+)$/gm, '<blockquote>$1</blockquote>');
+ // Unordered lists
+ html = html.replace(/^- (.+)$/gm, '<li>$1</li>');
+ html = html.replace(/(<li>.*<\/li>\n?)+/g, '<ul>$&</ul>');
+ // HR
+ html = html.replace(/^---$/gm, '<hr>');
+ // Paragraphs
+ html = html.replace(/^(?!<[hupblo]|<li|<hr|<img|<pre|<code|<del|<strong|<em|<a)(.+)$/gm, '<p>$1</p>');
+ return html;
+}
+
+// ── Compare ──────────────────────────────────────────────────
+/** Line diff by longest common subsequence → [{ type: 'same'|'add'|'del', text }]. O(lines(a) × lines(b)). */
+function diffLines(a, b) {
+ const linesA = a.split('\n'), linesB = b.split('\n');
+ const m = linesA.length, n = linesB.length;
+ // Build LCS table
+ const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
+ for (let i = 1; i <= m; i++)
+ for (let j = 1; j <= n; j++)
+ dp[i][j] = linesA[i-1] === linesB[j-1] ? dp[i-1][j-1] + 1 : Math.max(dp[i-1][j], dp[i][j-1]);
+ // Backtrack
+ const result = [];
+ let i = m, j = n;
+ while (i > 0 || j > 0) {
+ if (i > 0 && j > 0 && linesA[i-1] === linesB[j-1]) {
+ result.unshift({ type: 'same', text: linesA[i-1] });
+ i--; j--;
+ } else if (j > 0 && (i === 0 || dp[i][j-1] >= dp[i-1][j])) {
+ result.unshift({ type: 'add', text: linesB[j-1] });
+ j--;
+ } else {
+ result.unshift({ type: 'del', text: linesA[i-1] });
+ i--;
+ }
+ }
+ return result;
+}
+
+const FormatOpenVibeEngine = { countKeys, maxDepth, sortKeysDeep, markdownToHtml, diffLines };
+
+if (typeof module !== 'undefined' && module.exports) module.exports = FormatOpenVibeEngine;
+else root.FormatOpenVibeEngine = FormatOpenVibeEngine;
+})(typeof window !== 'undefined' ? window : this);
@@ -295,6 +295,19 @@ const cleanFns = {
@@ -295,6 +295,19 @@ const cleanFns = {
};
};
// ── Text analysis ────────────────────────────────────────────
// ── Text analysis ────────────────────────────────────────────
+// The Text Cleaner's steps in the order it applies them, and whether each is on by default.
+const CLEAN_STEPS = [
+ ['invisible', 'removeInvisible', true], ['zalgo', 'removeZalgo', false], ['quotes', 'normalizeQuotes', true],
+ ['hyphens', 'normalizeHyphens', true], ['html', 'stripHtml', false], ['spaces', 'collapseSpaces', true],
+ ['trim', 'trimLines', true], ['blank', 'removeBlankLines', false], ['lines', 'collapseLines', true],
+];
+
+/** Run the cleaner's steps; opts.<step> true/false overrides its default. */
+function cleanText(text, opts = {}) {
+ for (const [key, fn, on] of CLEAN_STEPS) if (opts[key] === undefined ? on : opts[key]) text = cleanFns[fn](text);
+ return text;
+}
+
function analyze(text) {
function analyze(text) {
const chars = [...text];
const chars = [...text];
const words = text.trim().split(/\s+/).filter(Boolean);
const words = text.trim().split(/\s+/).filter(Boolean);
@@ -430,17 +443,80 @@ function generateBio(opts = {}) {
@@ -430,17 +443,80 @@ function generateBio(opts = {}) {
return templates[Math.floor(Math.random() * templates.length)];
return templates[Math.floor(Math.random() * templates.length)];
}
}
-// ── Braille art conversion ───────────────────────────────────
+// ── Braille (Grade 1: letters, digits with the number sign, common punctuation) ──
+const BRAILLE_MAP = {
+ 'a':'⠁','b':'⠃','c':'⠉','d':'⠙','e':'⠑','f':'⠋','g':'⠛','h':'⠓','i':'⠊','j':'⠚',
+ 'k':'⠅','l':'⠇','m':'⠍','n':'⠝','o':'⠕','p':'⠏','q':'⠟','r':'⠗','s':'⠎','t':'⠞',
+ 'u':'⠥','v':'⠧','w':'⠺','x':'⠭','y':'⠽','z':'⠵',
+ '1':'⠼⠁','2':'⠼⠃','3':'⠼⠉','4':'⠼⠙','5':'⠼⠑',
+ '6':'⠼⠋','7':'⠼⠛','8':'⠼⠓','9':'⠼⠊','0':'⠼⠚',
+ ' ':'⠀','.':'⠲',',':'⠂','?':'⠦','!':'⠖',';':'⠆',':':'⠒',
+ '-':'⠤','(':'⠶',')':'⠶','\'':'⠄','"':'⠐⠂','/':'⠌'
+};
+const BRAILLE_REVERSE = {};
+Object.entries(BRAILLE_MAP).forEach(([k, v]) => { if (!BRAILLE_REVERSE[v]) BRAILLE_REVERSE[v] = k; });
+
function toBraille(text) {
function toBraille(text) {
- const BRAILLE_MAP = {
- a:'\u2801',b:'\u2803',c:'\u2809',d:'\u2819',e:'\u2811',f:'\u280B',g:'\u281B',
- h:'\u2813',i:'\u280A',j:'\u281A',k:'\u2805',l:'\u2807',m:'\u280D',n:'\u281D',
- o:'\u2815',p:'\u280F',q:'\u281F',r:'\u2817',s:'\u280E',t:'\u281E',u:'\u2825',
- v:'\u2827',w:'\u283A',x:'\u282D',y:'\u283D',z:'\u2835',' ':'\u2800',
- };
return [...text.toLowerCase()].map(c => BRAILLE_MAP[c] || c).join('');
return [...text.toLowerCase()].map(c => BRAILLE_MAP[c] || c).join('');
}
}
+function fromBraille(braille) {
+ let out = '', i = 0;
+ const chars = [...braille];
+ while (i < chars.length) {
+ const two = chars[i] + (chars[i+1] || '');
+ if (BRAILLE_REVERSE[two]) { out += BRAILLE_REVERSE[two]; i += 2; }
+ else if (BRAILLE_REVERSE[chars[i]]) { out += BRAILLE_REVERSE[chars[i]]; i++; }
+ else { out += chars[i]; i++; }
+ }
+ return out;
+}
+
+// ── Code points as hex, decimal and octal ────────────────────
+function toHex(text) { return [...text].map(c => c.codePointAt(0).toString(16).toUpperCase().padStart(2, '0')).join(' '); }
+function toDecimal(text) { return [...text].map(c => c.codePointAt(0)).join(' '); }
+function toOctal(text) { return [...text].map(c => c.codePointAt(0).toString(8).padStart(3, '0')).join(' '); }
+
+// ── Character inspector ──────────────────────────────────────
+const INVISIBLE_CHARS = new Set([0x200B,0x200C,0x200D,0xFEFF,0x00AD,0x034F,0x180E,0x2060,0x2061,0x2062,0x2063,0x2064,0x200E,0x200F,0x202A,0x202B,0x202C,0x202D,0x202E]);
+
+function utf8Bytes(cp) {
+ if (cp <= 0x7F) return 1;
+ if (cp <= 0x7FF) return 2;
+ if (cp <= 0xFFFF) return 3;
+ return 4;
+}
+
+function charCategory(cp) {
+ if (cp <= 0x1F || (cp >= 0x7F && cp <= 0x9F)) return 'Control';
+ if (INVISIBLE_CHARS.has(cp)) return 'Invisible';
+ if (cp >= 0x0300 && cp <= 0x036F) return 'Combining';
+ if (cp >= 0x1F600 && cp <= 0x1F64F) return 'Emoji';
+ if (cp >= 0x1F300 && cp <= 0x1F5FF) return 'Emoji Symbol';
+ if (cp >= 0x1F680 && cp <= 0x1F6FF) return 'Emoji Transport';
+ if (cp >= 0x1F900 && cp <= 0x1F9FF) return 'Emoji Supplement';
+ if (cp >= 0x2600 && cp <= 0x26FF) return 'Misc Symbol';
+ if (cp >= 0x2700 && cp <= 0x27BF) return 'Dingbat';
+ if (cp >= 65 && cp <= 90) return 'Latin Upper';
+ if (cp >= 97 && cp <= 122) return 'Latin Lower';
+ if (cp >= 48 && cp <= 57) return 'Digit';
+ if (cp === 32) return 'Space';
+ if (cp === 10) return 'Newline';
+ return 'Other';
+}
+
+/** One row per character: { char, codePoint: 'U+0041', hex: '0x41', utf8Bytes, category, invisible }. */
+function inspectChars(text) {
+ return [...text].map(ch => {
+ const cp = ch.codePointAt(0);
+ const hex = cp.toString(16).toUpperCase();
+ return {
+ char: ch, codePoint: 'U+' + hex.padStart(4, '0'), hex: '0x' + hex, utf8Bytes: utf8Bytes(cp), category: charCategory(cp),
+ invisible: INVISIBLE_CHARS.has(cp) || (cp <= 0x1F && cp !== 10) || (cp >= 0x7F && cp <= 0x9F),
+ };
+ });
+}
+
// ── Slug generation ──────────────────────────────────────────
// ── Slug generation ──────────────────────────────────────────
function toSlug(text) {
function toSlug(text) {
return text.toLowerCase()
return text.toLowerCase()
@@ -459,6 +535,16 @@ function dedupeLines(text) {
@@ -459,6 +535,16 @@ function dedupeLines(text) {
return [...new Set(text.split('\n'))].join('\n');
return [...new Set(text.split('\n'))].join('\n');
}
}
+function shuffleLines(text) {
+ const lines = text.split('\n');
+ for (let i = lines.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [lines[i], lines[j]] = [lines[j], lines[i]]; }
+ return lines.join('\n');
+}
+
+function reverseLines(text) {
+ return text.split('\n').reverse().join('\n');
+}
+
// ── Escape / unescape ───────────────────────────────────────
// ── Escape / unescape ───────────────────────────────────────
function escapeHtml(text) {
function escapeHtml(text) {
return text.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
return text.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
@@ -467,6 +553,23 @@ function unescapeHtml(text) {
@@ -467,6 +553,23 @@ function unescapeHtml(text) {
return text.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,"'");
return text.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,"'");
}
}
+// Every escape the Escape & Unescape tool shows, in its order. A decoder that cannot read its input
+// answers a bracketed note instead of throwing, as the page always has.
+const ESCAPES = {
+ htmlEscape: { label: 'HTML Escaped', fn: t => escapeHtml(t) },
+ htmlUnescape: { label: 'HTML Unescaped', fn: t => unescapeHtml(t) },
+ urlEncode: { label: 'URL Encoded', fn: t => encodeURIComponent(t) },
+ urlDecode: { label: 'URL Decoded', fn: t => { try { return decodeURIComponent(t); } catch { return '[Invalid encoded string]'; } } },
+ base64Encode: { label: 'Base64 Encoded', fn: t => { try { return btoa(unescape(encodeURIComponent(t))); } catch { return '[Error]'; } } },
+ base64Decode: { label: 'Base64 Decoded', fn: t => { try { return decodeURIComponent(escape(atob(t))); } catch { return '[Invalid Base64]'; } } },
+ jsonStringify: { label: 'JSON Stringified', fn: t => JSON.stringify(t) },
+ jsonParse: { label: 'JSON Parsed', fn: t => { try { return typeof JSON.parse(t) === 'string' ? JSON.parse(t) : JSON.stringify(JSON.parse(t), null, 2); } catch { return '[Invalid JSON string]'; } } },
+ cssEscape: { label: 'CSS Escaped', fn: t => t.replace(/([^\w-])/g, '\\$1') },
+ regexEscape: { label: 'Regex Escaped', fn: t => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') },
+ unicodeEscape: { label: 'Unicode Escape', fn: t => [...t].map(c => { const cp = c.codePointAt(0); return cp > 127 ? (cp > 0xFFFF ? '\\u{' + cp.toString(16) + '}' : '\\u' + cp.toString(16).padStart(4, '0')) : c; }).join('') },
+ hexEncode: { label: 'Hex Encoded', fn: t => [...new TextEncoder().encode(t)].map(b => b.toString(16).padStart(2, '0')).join(' ') },
+};
+
// ── Public API ───────────────────────────────────────────────
// ── Public API ───────────────────────────────────────────────
const TextOpenVibeEngine = {
const TextOpenVibeEngine = {
// Fancy text
// Fancy text
@@ -490,7 +593,7 @@ const TextOpenVibeEngine = {
@@ -490,7 +593,7 @@ const TextOpenVibeEngine = {
case: caseFns,
case: caseFns,
// Cleanup
// Cleanup
- clean: cleanFns,
+ clean: cleanFns, cleanText, cleanSteps: CLEAN_STEPS.map(([key, , on]) => ({ key, on })),
// Analysis
// Analysis
analyze,
analyze,
@@ -502,16 +605,22 @@ const TextOpenVibeEngine = {
@@ -502,16 +605,22 @@ const TextOpenVibeEngine = {
toBinary, fromBinary,
toBinary, fromBinary,
// Braille
// Braille
- toBraille,
+ toBraille, fromBraille,
+
+ // Code points
+ toHex, toDecimal, toOctal,
+
+ // Character inspector
+ inspectChars,
// Slug
// Slug
toSlug,
toSlug,
// Sort / dedupe
// Sort / dedupe
- sortLines, dedupeLines,
+ sortLines, dedupeLines, shuffleLines, reverseLines,
// Escape
// Escape
- escapeHtml, unescapeHtml,
+ escapeHtml, unescapeHtml, escapes: ESCAPES,
// Kaomoji
// Kaomoji
kaomoji: KAOMOJI,
kaomoji: KAOMOJI,
@@ -77,6 +77,7 @@
@@ -77,6 +77,7 @@
<script src="https://openvibe.network/shared/notification-ui.js"></script>
<script src="https://openvibe.network/shared/notification-ui.js"></script>
<script src="https://openvibe.network/shared/account-switcher.js"></script>
<script src="https://openvibe.network/shared/account-switcher.js"></script>
<script src="/js/shared.js"></script>
<script src="/js/shared.js"></script>
+ <script src="/js/format-engine.js"></script>
<script>
<script>
OpenVibeUI.initPage();
OpenVibeUI.initPage();
let lastFormatted = '';
let lastFormatted = '';
@@ -96,33 +97,7 @@
@@ -96,33 +97,7 @@
.replace(/(?<=[\[,]\s*)(null)(?=\s*[,\]])/g, '<span class="json-null">$1</span>');
.replace(/(?<=[\[,]\s*)(null)(?=\s*[,\]])/g, '<span class="json-null">$1</span>');
}
}
- function countKeys(obj) {
- let count = 0;
- if (typeof obj === 'object' && obj !== null) {
- if (Array.isArray(obj)) { obj.forEach(v => count += countKeys(v)); }
- else { for (const k of Object.keys(obj)) { count++; count += countKeys(obj[k]); } }
- }
- return count;
- }
-
- function maxDepth(obj, d) {
- d = d || 0;
- if (typeof obj !== 'object' || obj === null) return d;
- if (Array.isArray(obj)) return obj.length ? Math.max(...obj.map(v => maxDepth(v, d + 1))) : d + 1;
- const vals = Object.values(obj);
- return vals.length ? Math.max(...vals.map(v => maxDepth(v, d + 1))) : d + 1;
- }
-
- function sortKeysDeep(obj) {
- if (Array.isArray(obj)) return obj.map(sortKeysDeep);
- if (typeof obj === 'object' && obj !== null) {
- const sorted = {};
- Object.keys(obj).sort().forEach(k => sorted[k] = sortKeysDeep(obj[k]));
- return sorted;
- }
- return obj;
- }
-
+ const { countKeys, maxDepth, sortKeysDeep } = FormatOpenVibeEngine;
function format(indent) {
function format(indent) {
const raw = document.getElementById('input').value.trim();
const raw = document.getElementById('input').value.trim();
const out = document.getElementById('output');
const out = document.getElementById('output');
@@ -98,42 +98,11 @@ Made with ♥ by the OpenVibe Network</textarea>
@@ -98,42 +98,11 @@ Made with ♥ by the OpenVibe Network</textarea>
<script src="https://openvibe.network/shared/notification-ui.js"></script>
<script src="https://openvibe.network/shared/notification-ui.js"></script>
<script src="https://openvibe.network/shared/account-switcher.js"></script>
<script src="https://openvibe.network/shared/account-switcher.js"></script>
<script src="/js/shared.js"></script>
<script src="/js/shared.js"></script>
+ <script src="/js/format-engine.js"></script>
<script>
<script>
OpenVibeUI.initPage();
OpenVibeUI.initPage();
- // Simple Markdown parser (no external deps)
- function parseMd(md) {
- let html = md;
- // Code blocks
- html = html.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code>$2</code></pre>');
- // Inline code
- html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
- // Headers
- html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
- html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
- html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
- // Bold
- html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
- // Italic
- html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
- // Strikethrough
- html = html.replace(/~~(.+?)~~/g, '<del>$1</del>');
- // Images
- html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1">');
- // Links
- html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>');
- // Blockquotes
- html = html.replace(/^> (.+)$/gm, '<blockquote>$1</blockquote>');
- // Unordered lists
- html = html.replace(/^- (.+)$/gm, '<li>$1</li>');
- html = html.replace(/(<li>.*<\/li>\n?)+/g, '<ul>$&</ul>');
- // HR
- html = html.replace(/^---$/gm, '<hr>');
- // Paragraphs
- html = html.replace(/^(?!<[hupblo]|<li|<hr|<img|<pre|<code|<del|<strong|<em|<a)(.+)$/gm, '<p>$1</p>');
- return html;
- }
-
+ const parseMd = FormatOpenVibeEngine.markdownToHtml;
function update() {
function update() {
document.getElementById('preview').innerHTML = parseMd(document.getElementById('input').value);
document.getElementById('preview').innerHTML = parseMd(document.getElementById('input').value);
}
}
@@ -74,13 +74,9 @@ elderberry</textarea>
@@ -74,13 +74,9 @@ elderberry</textarea>
function sortAsc() { output.textContent = TextOpenVibeEngine.sortLines(input.value); }
function sortAsc() { output.textContent = TextOpenVibeEngine.sortLines(input.value); }
function sortDesc() { output.textContent = TextOpenVibeEngine.sortLines(input.value, { reverse: true }); }
function sortDesc() { output.textContent = TextOpenVibeEngine.sortLines(input.value, { reverse: true }); }
- function shuffle() {
- const lines = input.value.split('\n');
- for (let i = lines.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [lines[i], lines[j]] = [lines[j], lines[i]]; }
- output.textContent = lines.join('\n');
- }
+ function shuffle() { output.textContent = TextOpenVibeEngine.shuffleLines(input.value); }
function dedupe() { output.textContent = TextOpenVibeEngine.dedupeLines(input.value); }
function dedupe() { output.textContent = TextOpenVibeEngine.dedupeLines(input.value); }
- function reverseLines() { output.textContent = input.value.split('\n').reverse().join('\n'); }
+ function reverseLines() { output.textContent = TextOpenVibeEngine.reverseLines(input.value); }
function applyResult() { input.value = output.textContent; }
function applyResult() { input.value = output.textContent; }
</script>
</script>
</body>
</body>
@@ -71,49 +71,19 @@
@@ -71,49 +71,19 @@
<script>
<script>
OpenVibeUI.initPage();
OpenVibeUI.initPage();
- const INVISIBLE_CHARS = new Set([0x200B,0x200C,0x200D,0xFEFF,0x00AD,0x034F,0x180E,0x2060,0x2061,0x2062,0x2063,0x2064,0x200E,0x200F,0x202A,0x202B,0x202C,0x202D,0x202E]);
-
- function utf8Bytes(cp) {
- if (cp <= 0x7F) return 1;
- if (cp <= 0x7FF) return 2;
- if (cp <= 0xFFFF) return 3;
- return 4;
- }
-
- function charCategory(cp) {
- if (cp <= 0x1F || (cp >= 0x7F && cp <= 0x9F)) return 'Control';
- if (INVISIBLE_CHARS.has(cp)) return 'Invisible';
- if (cp >= 0x0300 && cp <= 0x036F) return 'Combining';
- if (cp >= 0x1F600 && cp <= 0x1F64F) return 'Emoji';
- if (cp >= 0x1F300 && cp <= 0x1F5FF) return 'Emoji Symbol';
- if (cp >= 0x1F680 && cp <= 0x1F6FF) return 'Emoji Transport';
- if (cp >= 0x1F900 && cp <= 0x1F9FF) return 'Emoji Supplement';
- if (cp >= 0x2600 && cp <= 0x26FF) return 'Misc Symbol';
- if (cp >= 0x2700 && cp <= 0x27BF) return 'Dingbat';
- if (cp >= 65 && cp <= 90) return 'Latin Upper';
- if (cp >= 97 && cp <= 122) return 'Latin Lower';
- if (cp >= 48 && cp <= 57) return 'Digit';
- if (cp === 32) return 'Space';
- if (cp === 10) return 'Newline';
- return 'Other';
- }
-
function update() {
function update() {
- const chars = [...document.getElementById('input').value];
const tbody = document.querySelector('#output tbody');
const tbody = document.querySelector('#output tbody');
tbody.innerHTML = '';
tbody.innerHTML = '';
- chars.forEach((ch, i) => {
- const cp = ch.codePointAt(0);
- const isInvis = INVISIBLE_CHARS.has(cp) || (cp <= 0x1F && cp !== 10) || (cp >= 0x7F && cp <= 0x9F);
+ TextOpenVibeEngine.inspectChars(document.getElementById('input').value).forEach((c, i) => {
const tr = document.createElement('tr');
const tr = document.createElement('tr');
- if (isInvis) tr.className = 'char-invisible';
+ if (c.invisible) tr.className = 'char-invisible';
tr.innerHTML = `
tr.innerHTML = `
<td>${i + 1}</td>
<td>${i + 1}</td>
- <td class="char-display">${isInvis ? '⬚' : OpenVibeUI.escapeHtml(ch)}</td>
- <td class="char-mono">U+${cp.toString(16).toUpperCase().padStart(4, '0')}</td>
- <td class="char-mono">0x${cp.toString(16).toUpperCase()}</td>
- <td>${utf8Bytes(cp)}</td>
- <td>${charCategory(cp)}</td>
+ <td class="char-display">${c.invisible ? '⬚' : OpenVibeUI.escapeHtml(c.char)}</td>
+ <td class="char-mono">${c.codePoint}</td>
+ <td class="char-mono">${c.hex}</td>
+ <td>${c.utf8Bytes}</td>
+ <td>${c.category}</td>
`;
`;
tbody.appendChild(tr);
tbody.appendChild(tr);
});
});
new file mode 100644
new file mode 100644
@@ -0,0 +1,71 @@
@@ -0,0 +1,71 @@
+'use strict';
+// ═══════════════════════════════════════════════════════════════
+// Text.OpenVibe — server engines for the tools API (ADR-027).
+//
+// The text pages run everything in the browser, and they keep doing so. These are the same engines,
+// public/js/text-engine.js and public/js/format-engine.js, required in Node, so a text tool whose
+// descriptor says api: true can be run by POST /api/v1/tools/:id/run (served by the gateway). Each
+// engine takes the run's input (already checked against the tool's input schema) and answers
+// { text } (output kind text) or { data } (output kind json). Nothing here touches the network or disk.
+// ═══════════════════════════════════════════════════════════════
+
+const T = require('../public/js/text-engine');
+const F = require('../public/js/format-engine');
+
+// The style sets each page shows (fancy.html: all of them; bubble.html; reverse.html).
+const STYLE_SETS = {
+ fancy: Object.keys(T.styles),
+ bubble: ['circled', 'squared', 'negSquared', 'parenthesized', 'regional'],
+ reversetext: ['upsideDown', 'mirrored', 'strikethrough', 'underline', 'doubleUnderline', 'overline', 'slashed', 'dotted', 'spaced'],
+};
+// compare: the diff table is lines(a) × lines(b); beyond this many lines a side it is refused.
+const MAX_DIFF_LINES = 2000;
+
+const styled = (set) => ({ text, styles }) => {
+ const ids = styles && styles.length ? STYLE_SETS[set].filter(id => styles.includes(id)) : STYLE_SETS[set];
+ return { data: { styles: ids.map(id => ({ id, name: T.styles[id].name, text: T.styles[id].fn(text) })) } };
+};
+
+class EngineError extends Error {
+ constructor(message) { super(message); this.status = 422; this.code = 'tools.input.invalid'; }
+}
+
+const ENGINES = {
+ fancy: styled('fancy'),
+ bubble: styled('bubble'),
+ reversetext: styled('reversetext'),
+ zalgo: ({ text, mode = 'add', intensity = 'medium', up, mid, down }) => ({ text: mode === 'remove' ? T.clean.removeZalgo(text) : T.zalgo(text, { intensity, up, mid, down }) }),
+ ascii: ({ text, blockChar = '█', spaceChar = ' ' }) => ({ text: T.asciiArt(text, { blockChar, spaceChar }) }),
+ unicode: ({ text }) => ({ data: { characters: T.inspectChars(text) } }),
+ braille: ({ text, mode = 'encode' }) => ({ text: mode === 'decode' ? T.fromBraille(text) : T.toBraille(text) }),
+ morse: ({ text, mode = 'encode' }) => ({ text: mode === 'decode' ? T.fromMorse(text) : T.toMorse(text) }),
+ binary: ({ text, to = 'binary' }) => ({ text: { binary: T.toBinary, hex: T.toHex, decimal: T.toDecimal, octal: T.toOctal, text: T.fromBinary }[to](text) }),
+ case: ({ text, to }) => ({ text: T.case[to](text) }),
+ count: ({ text }) => ({ data: T.analyze(text) }),
+ clean: ({ text, ...steps }) => ({ text: T.cleanText(text, steps) }),
+ sort: ({ text, mode = 'asc' }) => ({
+ text: mode === 'desc' ? T.sortLines(text, { reverse: true }) : mode === 'shuffle' ? T.shuffleLines(text) : mode === 'dedupe' ? T.dedupeLines(text) : mode === 'reverse' ? T.reverseLines(text) : T.sortLines(text),
+ }),
+ compare: ({ a, b }) => {
+ const n = Math.max(a.split('\n').length, b.split('\n').length);
+ if (n > MAX_DIFF_LINES) throw new EngineError(`Each text may have at most ${MAX_DIFF_LINES} lines (this one has ${n}).`);
+ const lines = F.diffLines(a, b);
+ const count = (t) => lines.filter(l => l.type === t).length;
+ return { data: { lines, added: count('add'), removed: count('del'), unchanged: count('same') } };
+ },
+ json: ({ text, mode = 'format', indent = 2 }) => {
+ let value;
+ try { value = JSON.parse(text.trim()); } catch (err) { throw new EngineError(err.message); }
+ if (mode === 'minify') return { text: JSON.stringify(value) };
+ return { text: JSON.stringify(mode === 'sort-keys' ? F.sortKeysDeep(value) : value, null, indent) };
+ },
+ markdown: ({ text }) => ({ text: F.markdownToHtml(text) }),
+ escape: ({ text, transform }) => ({ text: T.escapes[transform].fn(text) }),
+ slug: ({ text }) => ({ text: T.toSlug(text) }),
+ bio: () => ({ text: T.generateBio() }),
+ nickname: () => ({ text: T.generateNickname() }),
+ symbols: ({ category } = {}) => ({ data: { categories: category ? { [category]: T.symbols[category] } : T.symbols } }),
+ kaomoji: ({ mood } = {}) => ({ data: { moods: mood ? { [mood]: T.kaomoji[mood] } : T.kaomoji } }),
+};
+
+module.exports = { ENGINES, STYLE_SETS, MAX_DIFF_LINES, EngineError, styleNames: () => Object.fromEntries(Object.entries(T.styles).map(([k, v]) => [k, v.name])), CASES: Object.keys(T.case), ESCAPES: Object.keys(T.escapes), SYMBOL_CATEGORIES: Object.keys(T.symbols), MOODS: Object.keys(T.kaomoji) };