feat: fun-cli v1.0.0 — gen/run/docs 三命令,fun 框架零改动

- fun gen [ts|go]:AST 扫描 BindService* 调用,临时生成 go test 辅助采集,
  经 fun v1.3.7 公开 API 生成,产物与内置 GenCode 字节级一致(回归测试锁定)
- fun run:热更新开发循环,先编译成功再切换,失败保留旧进程
- fun docs:浅色标准文档站——参数表/State 添加字段/请求 JSON 可编辑/
  响应格式化/NDJSON 流式逐行展示,反向代理 /cell 同源无 CORS
- 元数据:TS 产物两遍解析重建(枚举/可空/流式工厂/嵌套引用全支持)
- 纯标准库实现,零第三方依赖
This commit is contained in:
2026-09-19 06:32:45 +08:00
commit e29767362a
21 changed files with 4148 additions and 0 deletions
+420
View File
@@ -0,0 +1,420 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>fun docs</title>
<style>
:root{
--bg:#ffffff;--bg2:#f6f8fa;--line:#d8dee4;
--fg:#1f2328;--fg2:#636c76;--fg3:#8b949e;
--acc:#0969da;--accbg:#ddf4ff;
--ok:#1a7f37;--okbg:#dafbe1;--err:#cf222e;--errbg:#ffebe9;
--stream:#9a6700;--streambg:#fff8c5;
--code:#24292f;
--mono:ui-monospace,SFMono-Regular,'SF Mono',Menlo,Consolas,monospace;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%}
body{background:var(--bg);color:var(--fg);font:14px/1.65 -apple-system,'Segoe UI',system-ui,'PingFang SC','Microsoft YaHei',sans-serif;display:flex;flex-direction:column;overflow:hidden}
/* 顶栏 */
header{display:flex;align-items:center;gap:10px;padding:9px 20px;background:var(--bg2);border-bottom:1px solid var(--line);flex-shrink:0}
header .logo{font-family:var(--mono);font-weight:700;font-size:15px;color:var(--fg)}
header .logo b{color:var(--acc)}
header .meta{color:var(--fg2);font-size:12.5px}
header .dot{width:7px;height:7px;border-radius:50%;background:var(--ok)}
#layout{flex:1;display:flex;min-height:0}
/* 侧栏 */
aside{width:280px;min-width:280px;border-right:1px solid var(--line);display:flex;flex-direction:column;background:var(--bg)}
#search{margin:12px 14px 6px;padding:6px 10px;border:1px solid var(--line);border-radius:6px;font-size:13px;outline:none;background:var(--bg)}
#search:focus{border-color:var(--acc);box-shadow:0 0 0 3px var(--accbg)}
#tree{flex:1;overflow-y:auto;padding:2px 8px 16px}
.svc{margin:14px 8px 4px;font-size:11.5px;color:var(--fg2);font-weight:600;letter-spacing:.06em;text-transform:uppercase}
.mth{display:flex;align-items:center;gap:7px;padding:4px 9px;border-radius:6px;cursor:pointer;font-size:13.5px;color:var(--fg)}
.mth:hover{background:var(--bg2)}
.mth.on{background:var(--accbg);color:var(--fg)}
.mth.on::before{content:'';display:inline-block;width:3px;height:14px;background:var(--acc);border-radius:2px;margin-right:2px}
.mth .nm{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.mth.on .nm{font-weight:600}
.badge{font-size:10px;padding:1px 6px;border-radius:10px;font-family:var(--mono);border:1px solid}
.b-stream{color:var(--stream);border-color:var(--streambg);background:var(--streambg)}
.b-void{color:var(--fg2);background:var(--bg2);border-color:var(--line)}
/* 正文 */
main{flex:1;overflow-y:auto;padding:26px 34px 60px}
.doc{max-width:860px}
h1{font-size:24px;font-weight:600;letter-spacing:-.01em;font-family:var(--mono);display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.tag{font-size:11px;font-weight:500;padding:2px 9px;border-radius:10px;font-family:var(--mono)}
.t-stream{color:var(--stream);background:var(--streambg)}
.t-void{color:var(--fg2);background:var(--bg2);border:1px solid var(--line)}
.sig{margin:8px 0 4px;color:var(--fg2);font-size:13px;font-family:var(--mono);background:var(--bg2);border:1px solid var(--line);border-radius:6px;padding:8px 12px}
.sig b{color:var(--acc);font-weight:600}
h2{font-size:17px;font-weight:600;margin:26px 0 10px;padding-top:18px;border-top:1px solid var(--line)}
h2 .sub{font-weight:400;color:var(--fg2);font-size:12.5px;margin-left:8px}
/* 参数表 */
table.params{width:100%;border-collapse:collapse;font-size:13px}
table.params th{text-align:left;color:var(--fg2);font-weight:600;font-size:12px;padding:6px 10px;border-bottom:2px solid var(--line);white-space:nowrap}
table.params td{padding:7px 10px;border-bottom:1px solid var(--line);vertical-align:top}
table.params tr:last-child td{border-bottom:none}
table.params td.name{font-family:var(--mono);white-space:nowrap}
table.params td.type{font-family:var(--mono);color:var(--fg2);white-space:nowrap}
table.params td.name .req{color:var(--err);font-weight:700}
table.params td.name .opt{color:var(--fg3);font-size:11px}
input[type=text],textarea,select{width:100%;background:var(--bg);border:1px solid var(--line);border-radius:6px;color:var(--fg);padding:5px 9px;font-size:13px;font-family:var(--mono);outline:none}
input:focus,textarea:focus,select:focus{border-color:var(--acc);box-shadow:0 0 0 3px var(--accbg)}
textarea{min-height:60px;resize:vertical}
input[type=checkbox]{accent-color:var(--acc);width:14px;height:14px}
.nest{border-left:2px solid var(--line);margin:6px 0 2px;padding-left:14px}
.subhdr{font-family:var(--mono);font-size:12px;color:var(--fg2);margin:10px 0 4px}
/* state */
/* 发送 */
#sendBar{display:flex;gap:10px;align-items:center;margin:4px 0 4px}
#send{background:var(--acc);color:#fff;border:none;border-radius:6px;padding:8px 24px;font-size:14px;font-weight:600;cursor:pointer}
#send:hover{background:#0860c4}
#send:disabled{opacity:.55;cursor:wait}
#send.stop{background:var(--err)}
#copyReq{background:var(--bg);border:1px solid var(--line);color:var(--fg2);border-radius:6px;padding:7px 14px;cursor:pointer;font-size:13px}
#copyReq:hover{border-color:var(--acc);color:var(--acc)}
#sendHint{color:var(--fg2);font-size:12.5px}
/* 请求/响应示例块 */
.block{border:1px solid var(--line);border-radius:8px;margin-top:8px;overflow:hidden}
.block .bhead{display:flex;align-items:center;gap:10px;padding:7px 14px;background:var(--bg2);border-bottom:1px solid var(--line);font-family:var(--mono);font-size:12px;color:var(--fg2)}
.chip{padding:1px 9px;border-radius:10px;font-weight:500}
.c-ok{color:var(--ok);background:var(--okbg)}
.c-err{color:var(--err);background:var(--errbg)}
.c-info{color:var(--acc);background:var(--accbg)}
#stateRows .srow{display:grid;grid-template-columns:1fr 1.4fr 30px;gap:8px;margin-bottom:6px;max-width:560px}
#stateRows .srow button{background:none;border:none;color:var(--fg3);cursor:pointer;font-size:15px}
#stateRows .srow button:hover{color:var(--err)}
#addState{background:var(--bg);border:1px dashed var(--line);color:var(--fg2);border-radius:6px;padding:3px 12px;cursor:pointer;font-size:12.5px}
#addState:hover{border-color:var(--acc);color:var(--acc)}
textarea.body{width:100%;border:none;outline:none;background:var(--bg);padding:14px 16px;font-family:var(--mono);font-size:12.5px;line-height:1.6;overflow:auto;max-height:50vh;white-space:pre;resize:vertical;color:var(--code);display:block}
pre.body{padding:14px 16px;font-family:var(--mono);font-size:12.5px;line-height:1.6;overflow:auto;max-height:50vh;white-space:pre-wrap;word-break:break-all;color:var(--code)}
.empty{color:var(--fg3);font-size:12.5px;font-family:var(--mono)}
.note{color:var(--fg2);font-size:12.5px;margin-top:6px}
.note code,td.hint code{font-family:var(--mono);background:var(--bg2);border:1px solid var(--line);border-radius:4px;padding:0 5px;font-size:11.5px}
::-webkit-scrollbar{width:10px;height:10px}
::-webkit-scrollbar-thumb{background:#c9d1d9;border-radius:5px;border:2px solid var(--bg)}
::-webkit-scrollbar-thumb:hover{background:#afb8c1}
#empty{color:var(--fg3);margin-top:20vh;text-align:center;font-size:14px}
</style>
</head>
<body>
<header>
<span class="logo">fun<b>·docs</b></span>
<span class="dot" id="live"></span>
<span class="meta" id="hmeta">加载中…</span>
</header>
<div id="layout">
<aside>
<input id="search" placeholder="搜索服务或方法…" autofocus>
<div id="tree"></div>
</aside>
<main id="main"><div id="empty">在左侧选择一个方法查看文档</div></main>
</div>
<script>
let META=null, CUR=null, ABORT=null;
const $=s=>document.querySelector(s);
const esc=s=>String(s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
fetch('/fun-meta.json').then(r=>r.json()).then(m=>{
META=m;
const nS=m.services.length;let nM=0;m.services.forEach(s=>nM+=s.methods.length);
$('#hmeta').textContent=`${nS} 个服务 · ${nM} 个方法`;
renderTree('');
}).catch(e=>{$('#hmeta').textContent='元数据加载失败: '+e.message;$('#live').style.background='var(--err)'});
$('#search').addEventListener('input',e=>renderTree(e.target.value));
function renderTree(q){
q=(q||'').toLowerCase();
const t=$('#tree');t.innerHTML='';
META.services.forEach(svc=>{
const ms=svc.methods.filter(m=>!q||m.name.toLowerCase().includes(q)||svc.name.toLowerCase().includes(q));
if(!ms.length)return;
const h=document.createElement('div');h.className='svc';h.textContent=svc.name;t.appendChild(h);
ms.forEach(m=>{
const d=document.createElement('div');d.className='mth';
d.innerHTML=`<span class="nm">${esc(m.name)}</span>`+
(m.isStream?'<span class="badge b-stream">stream</span>':'')+
(m.void?'<span class="badge b-void">void</span>':'');
d.onclick=()=>{document.querySelectorAll('.mth').forEach(x=>x.classList.remove('on'));d.classList.add('on');showMethod(svc,m)};
t.appendChild(d);
});
});
}
function typeLabel(t){
if(!t)return '—';
if(t.kind==='struct'||t.kind==='enum')return t.name+(t.optional?'?':'');
if(t.kind==='slice')return typeLabel(t.elem)+'[]';
return (t.optional?t.name+'?':t.name);
}
function showMethod(svc,m){
CUR={svc,m};reqDirty=false;ABORT&&ABORT.abort();ABORT=null;
const badges=(m.isStream?'<span class="tag t-stream">NDJSON 流式</span>':'')+(m.void?'<span class="tag t-void">无返回值</span>':'');
const retLine = m.isStream
? (m.returns?`首条消息 <b>${esc(typeLabel(m.returns))}</b>,其后逐行推送 NDJSON`:`持续推送 NDJSON 行`)
: (m.void?`无数据返回,仅 <b>result.void</b>`:(m.returns?`<b>${esc(typeLabel(m.returns))}</b>`:''));
const dtoName = m.dto?esc(m.dto.name):'无';
$('#main').innerHTML=`<div class="doc">
<h1>${esc(svc.name)}.${esc(m.name)} ${badges}</h1>
<div class="sig"><b>POST</b> /cell · serviceName=<b>${esc(svc.name)}</b> · methodName=<b>${esc(m.name)}</b></div>
<div class="note">返回:${retLine} · 请求 DTO:${dtoName}${m.dto&&m.dto.fields?'':'(该方法不接收参数)'}</div>
${m.dto?`
<h2>请求参数<span class="sub">${esc(m.dto.name)}</span></h2>
${paramsTable(m.dto,[])}
<div class="note">带 <code>*</code> 为必填;指针/切片字段可留空省略;切片按 JSON 数组填写。字段名以小写开头随请求传输。</div>
`:''}
<h2>State<span class="sub">随请求携带 · Guard 可读 · 存于 localStorage</span></h2>
<div id="stateRows"></div>
<button id="addState">+ 添加字段</button>
<h2>试一下<span class="sub">填好参数点发送,请求与响应都会展示在下方</span></h2>
<div id="sendBar">
<button id="send">发送请求</button>
<button id="copyReq">复制请求 JSON</button>
<span id="sendHint"></span>
</div>
<h2>请求<span class="sub">表单填充自动生成,可直接编辑后发送</span></h2>
<div class="note" style="margin:0 0 8px">请求体即发送到 <code>POST /cell</code> 的 JSON,State 值由上方字段注入,也可手动编辑后发送。</div>
<div class="block"><div class="bhead" id="reqHead"><span class="chip c-info">表单生成</span><button id="resetReq" style="margin-left:auto;background:none;border:1px solid var(--line);border-radius:5px;color:var(--fg2);font-size:11px;padding:1px 8px;cursor:pointer;display:none">按表单重置</button></div><textarea class="body edit" id="reqBody" spellcheck="false"></textarea></div>
<h2>响应</h2>
<div class="block"><div class="bhead" id="respHead"><span class="chip c-info">未发送</span></div><pre class="body" id="respBody"><span class="empty">发送后在此展示格式化的响应${m.isStream?'(流式方法逐行追加)':''}</span></pre></div>
</div>`;
$('#addState').onclick=()=>addStateRow('','');
const st=JSON.parse(localStorage.getItem('fun-docs-state')||'{}');
Object.entries(st).forEach(([k,v])=>addStateRow(k,v));
$('#send').onclick=send;
$('#copyReq').onclick=copyReq;
// 表单一变就同步请求体(手动编辑过则不动,可点"按表单重置"恢复)
document.querySelectorAll('#main input,#main select').forEach(el=>{
el.addEventListener('input',previewRequest);el.addEventListener('change',previewRequest);
});
document.querySelectorAll('#main textarea[data-t]').forEach(el=>{
el.addEventListener('input',previewRequest);el.addEventListener('change',previewRequest);
});
$('#reqBody').addEventListener('input',()=>{
reqDirty=true;
$('#reqHead').querySelector('.chip').textContent='手动编辑';
$('#resetReq').style.display='';
});
$('#resetReq').onclick=()=>{reqDirty=false;$('#resetReq').style.display='none';$('#reqHead').querySelector('.chip').textContent='表单生成';previewRequest();};
previewRequest();
}
/* ---------- 参数表 ---------- */
function paramsTable(t,path){
let h=`<table class="params"><tr><th style="width:190px">字段</th><th style="width:130px">类型</th><th style="width:270px">示例值</th><th>说明</th></tr>`;
h+=paramRows(t,path);
return h+'</table>';
}
function paramRows(t,path){
let h='';
t.fields.forEach(f=>{
const p=[...path,f.name];
const id='f-'+p.join('.');
const req=!f.type.optional&&f.type.kind!=='slice';
h+=`<tr><td class="name">${esc(firstLower(f.name))}${f.type.optional?'<span class="opt"> 可空</span>':'<span class="req">*</span>'}</td><td class="type">${esc(typeLabel(f.type))}</td>`;
h+=`<td>${controlHTML(f.type,id)}</td>`;
h+=`<td class="hint" style="color:var(--fg2);font-size:12.5px">${hintFor(f.type)}</td></tr>`;
if(f.type.kind==='struct')h+=`<tr><td colspan="4"><div class="nest"><div class="subhdr">${esc(f.type.name)} 字段:</div>${paramsTable(f.type,p)}</div></td></tr>`;
});
return h;
}
function hintFor(t){
switch(t.kind){
case 'int':return '定宽整数,<code>int64</code> 全链路精度';
case 'enum':return '枚举序号:'+(t.names||[]).map((n,i)=>(t.displayNames&&t.displayNames[i]?t.displayNames[i]:n)+'='+i).join(' · ');
case 'slice':return 'JSON 数组,如 <code>["a","b"]</code>';
case 'struct':return '嵌套对象';
case 'bool':return 'true / false';
default:return '';
}
}
function controlHTML(t,id){
if(t.optional){
return `<div style="display:flex;gap:7px;align-items:center"><input type="checkbox" class="prov" data-for="${id}" title="勾选后提供该字段"><div style="flex:1" data-prov="${id}" data-dis>${innerControl(t,id)}</div></div>`;
}
return innerControl(t,id);
}
function innerControl(t,id){
switch(t.kind){
case 'string':return `<input type="text" id="${id}" data-t="string">`;
case 'int':return `<input type="text" id="${id}" data-t="int" inputmode="numeric" placeholder="0">`;
case 'bool':return `<select id="${id}" data-t="bool"><option value=""></option><option>true</option><option>false</option></select>`;
case 'enum':{
let h=`<select id="${id}" data-t="enum"><option value=""></option>`;
(t.names||[]).forEach((n,i)=>h+=`<option value="${i}">${esc((t.displayNames&&t.displayNames[i])?t.displayNames[i]+' ('+n+')':n)}</option>`);
return h+'</select>';
}
case 'slice':return `<textarea id="${id}" data-t="slice" placeholder='["a","b"] 或 [{...}]'></textarea>`;
default:return `<input type="text" id="${id}" data-t="string">`;
}
}
const firstLower=s=>s?s[0].toLowerCase()+s.slice(1):s;
// 指针字段勾选联动
document.addEventListener('change',e=>{
if(e.target.classList&&e.target.classList.contains('prov')){
const wrap=document.querySelector(`[data-prov="${e.target.dataset.for}"]`);
if(wrap){const el=wrap.querySelector('input,textarea,select');if(el)el.disabled=!e.target.checked}
}
});
/* ---------- 取值 ---------- */
function collectStruct(t,path,obj,errors){
t.fields.forEach(f=>{
const p=[...path,f.name];
const id='f-'+p.join('.');
const key=firstLower(f.name);
let el=document.getElementById(id);
let provided=true;
if(f.type.optional){
const cb=document.querySelector(`.prov[data-for="${id}"]`);
provided=cb&&cb.checked;
}
if(!provided)return;
if(!el)return;
const v=el.value!=null?el.value.trim():'';
const kind=f.type.kind;
if(kind==='struct'){obj[key]=collectStruct(f.type,p,{},errors);return}
if(v===''){
const req=!f.type.optional&&kind!=='slice'&&kind!=='bool'&&kind!=='enum';
if(req)errors.push(`缺少必填字段: ${p.join('.')}`);
return;
}
switch(kind){
case 'int':{
const n=Number(v);
if(!Number.isFinite(n)||!Number.isInteger(n)){errors.push(`${p.join('.')} 不是整数`);return}
obj[key]=n;break;
}
case 'bool':obj[key]=v==='true';break;
case 'enum':obj[key]=Number(v);break;
case 'slice':{
try{obj[key]=JSON.parse(v)}catch{errors.push(`${p.join('.')} 不是合法 JSON 数组`)}
break;
}
default:obj[key]=v;
}
});
return obj;
}
let reqDirty=false;
function buildRequest(){
const errors=[];
const body={serviceName:CUR.svc.name,methodName:CUR.m.name};
if(CUR.m.dto)body.data=collectStruct(CUR.m.dto,[],{},errors);
const state={};
document.querySelectorAll('#stateRows .srow').forEach(r=>{
const k=r.querySelector('[data-sk]').value.trim(),v=r.querySelector('[data-sv]').value;
if(k)state[k]=v;
});
if(Object.keys(state).length)body.state=state;
localStorage.setItem('fun-docs-state',JSON.stringify(state));
return {body,errors};
}
function addStateRow(k,v){
const d=document.createElement('div');d.className='srow';
d.innerHTML=`<input type="text" placeholder="key(如 token)" data-sk value="${esc(k)}"><input type="text" placeholder="value" data-sv value="${esc(v)}"><button title="删除">×</button>`;
d.querySelector('button').onclick=()=>d.remove();
$('#stateRows').appendChild(d);
d.querySelectorAll('input').forEach(i=>i.addEventListener('input',previewRequest));
}
function previewRequest(){
if(!CUR||reqDirty)return;
const {body,errors}=buildRequest();
const pre=$('#reqBody');if(!pre)return;
if(errors.length){pre.value=JSON.stringify({serviceName:CUR.svc.name,methodName:CUR.m.name},null,2);return}
pre.value=pretty(JSON.stringify(body));
}
function requestBody(){
const raw=$('#reqBody').value.trim();
try{return{ok:JSON.parse(raw)}}catch(e){return{ok:null,err:e.message}}
}
function copyReq(){
const raw=$('#reqBody').value.trim();
try{JSON.parse(raw);navigator.clipboard.writeText(JSON.stringify(JSON.parse(raw),null,2))}
catch{navigator.clipboard.writeText(raw)}
$('#sendHint').textContent='已复制';
setTimeout(()=>$('#sendHint').textContent='',1500);
}
/* ---------- 发送 ---------- */
async function send(){
if(ABORT){ABORT.abort();return}
const parsed=requestBody();
if(!parsed.ok){
$('#reqHead').innerHTML='<span class="chip c-err">请求 JSON 无效</span>';
$('#respBody').innerHTML=`<span style="color:var(--err)">${esc(parsed.err)}</span>`;
return;
}
const body=parsed.ok;
try{$('#reqBody').value=pretty(JSON.stringify(body))}catch{}
$('#reqHead').innerHTML='<span class="chip c-info">POST /cell</span>';
const btn=$('#send');btn.disabled=true;
const t0=performance.now();
$('#respHead').innerHTML='<span class="chip c-info">发送中…</span>';
$('#respBody').innerHTML='<span class="empty">等待响应…</span>';
ABORT=new AbortController();
try{
const res=await fetch('/cell',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body),signal:ABORT.signal});
const ms=Math.round(performance.now()-t0);
if(CUR.m.isStream){
btn.textContent='停止';btn.classList.add('stop');btn.disabled=false;
await readStream(res,ms);
}else{
const text=await res.text();
$('#respHead').innerHTML=statusChip(res.status,ms,res.headers.get('content-type'));
$('#respBody').textContent=text?pretty(text):'(空响应体)';
}
}catch(e){
if(e.name==='AbortError')$('#respHead').innerHTML='<span class="chip c-err">已取消</span>';
else $('#respHead').innerHTML='<span class="chip c-err">请求失败</span>';
$('#respBody').textContent=String(e.message||e);
}finally{
ABORT=null;btn.textContent='发送请求';btn.classList.remove('stop');btn.disabled=false;
}
}
async function readStream(res,ms){
$('#respHead').innerHTML=statusChip(res.status,ms,'application/x-ndjson');
const pre=$('#respBody');pre.textContent='';
if(!res.ok||!(res.headers.get('content-type')||'').includes('ndjson')){
pre.textContent=pretty(await res.text());return;
}
const reader=res.body.getReader(),dec=new TextDecoder();
let buf='',lines=0;
while(true){
if(!ABORT)break;
const {done,value}=await reader.read();
if(done)break;
buf+=dec.decode(value,{stream:true});
let i;
while((i=buf.indexOf('\n'))>=0){
const line=buf.slice(0,i).trim();buf=buf.slice(i+1);
if(!line)continue;
lines++;pre.textContent+=pretty(line)+'\n';
if(lines>800)pre.textContent='(超出 800 行,已截断)\n'+pre.textContent.split('\n').slice(-800).join('\n');
pre.scrollTop=pre.scrollHeight;
$('#respHead').lastChild&&$('#respHead').lastChild.remove();
$('#respHead').insertAdjacentHTML('beforeend',`<span class="chip c-ok">流式 · ${lines} 行</span>`);
}
}
$('#respHead').insertAdjacentHTML('beforeend','<span class="chip c-info">流结束</span>');
}
function statusChip(status,ms,ct){
const ok=status>=200&&status<300;
return `<span class="chip ${ok?'c-ok':'c-err'}">HTTP ${status}</span><span class="chip c-info">${ms} ms</span>`+(ct?`<span class="chip c-info">${esc(ct.split(';')[0])}</span>`:'');
}
function pretty(text){
try{return JSON.stringify(JSON.parse(text),null,2)}catch{return text}
}
</script>
</body>
</html>