// 公开页面的外壳:转义、版式、样式表、前端脚本、安全响应头。 // // 样式与脚本都走同源的 /app.css、/app.js,CSP 不放行任何内联脚本和内联样式;页面里也就不能写 style="" 属性。 // 配色照 roamai.cloud(暖米白底、黏土橙强调色),跟着系统切深色。不引外部字体与脚本。 import type { Copy, Lang } from "./i18n.js"; const ESC: Record = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }; /** HTML 转义:文本与属性值都用它。 */ export function esc(s: unknown): string { return String(s ?? "").replace(/[&<>"']/g, (c) => ESC[c]!); } export const SECURITY_HEADERS: Record = { "content-security-policy": "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; " + "form-action 'self'; base-uri 'none'; frame-ancestors 'none'", "x-content-type-options": "nosniff", "referrer-policy": "no-referrer", "x-frame-options": "DENY", }; export function htmlResponse(body: string, status = 200, extra: Record = {}): Response { return new Response(body, { status, headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", ...SECURITY_HEADERS, ...extra }, }); } /** 带上 ?lang= 的同站链接(只在英文时带,中文是默认)。 */ export function langHref(path: string, lang: Lang): string { if (lang === "zh") return path; return path + (path.includes("?") ? "&" : "?") + "lang=en"; } export function layout(t: Copy, lang: Lang, opts: { title: string; path: string; body: string }): string { const other = t.switchTo; const switchHref = opts.path + (opts.path.includes("?") ? "&" : "?") + "lang=" + other.lang; return ` ${esc(opts.title)}
${opts.body}
`; } export const APP_CSS = `:root{--bg:#f5f3ee;--surface:#fff;--sunk:#efece4;--ink:#1f1e1d;--muted:#6e6b64;--faint:#a3a099;--line:#e6e3da; --line-strong:#d8d4c8;--accent:#c96442;--accent-deep:#ae4b2b;--run:#4e7d45;--run-soft:#e7eee2;--warn:#b45309;--warn-soft:#f7ecdd; --danger:#b3261e;--danger-soft:#f7e2de;color-scheme:light} @media (prefers-color-scheme:dark){:root{--bg:#1c1b19;--surface:#252421;--sunk:#2c2a27;--ink:#ece9e2;--muted:#b0aca3;--faint:#8a867e; --line:#36342f;--line-strong:#46433d;--accent:#d97757;--accent-deep:#e8957a;--run:#8fbf84;--run-soft:#26331f;--warn:#e0a35c; --warn-soft:#3a2c1a;--danger:#ef8a7f;--danger-soft:#3d2320;color-scheme:dark}} *{box-sizing:border-box} html{-webkit-text-size-adjust:100%} body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.65 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC", "Hiragino Sans GB","Microsoft YaHei","Noto Sans CJK SC",sans-serif} a{color:var(--accent-deep)}a:hover{color:var(--accent)} .wrap{max-width:860px;margin:0 auto;padding:0 20px} .top{border-bottom:1px solid var(--line)} .top-in{display:flex;align-items:center;justify-content:space-between;height:56px} .brand{font-weight:700;color:var(--ink);text-decoration:none;letter-spacing:.01em} .lang{font-size:13px;color:var(--muted);text-decoration:none} main.wrap{padding-top:36px;padding-bottom:48px} h1{font-size:30px;line-height:1.25;margin:0 0 12px;letter-spacing:-.01em} h2{font-size:20px;margin:40px 0 14px} .lead{color:var(--muted);max-width:42em;margin:0} .note{color:var(--muted);font-size:13.5px;margin:10px 0 0} .card{background:var(--surface);border:1px solid var(--line);border-radius:14px;padding:22px} form.card{margin-top:24px;display:grid;gap:18px} label{display:block;font-weight:600;font-size:14px;margin-bottom:6px} input,select{width:100%;font:inherit;color:var(--ink);background:var(--bg);border:1px solid var(--line-strong);border-radius:9px; padding:10px 12px} input:focus,select:focus{outline:2px solid var(--accent);outline-offset:1px;border-color:transparent} .hint{color:var(--faint);font-size:12.5px;margin:6px 0 0} .row{display:grid;grid-template-columns:1fr 1fr;gap:18px} @media (max-width:560px){.row{grid-template-columns:1fr}} button{font:inherit;font-weight:700;color:#fff;background:var(--accent);border:0;border-radius:9px;padding:12px 22px;cursor:pointer; justify-self:start} button:hover{background:var(--accent-deep)}button:disabled{opacity:.6;cursor:default} .status{margin:0;font-size:14px;color:var(--muted)}.status.err{color:var(--danger)} .groups{display:grid;gap:14px} .group h3{font-size:13px;color:var(--muted);margin:0 0 6px;font-weight:600;letter-spacing:.04em} .group ul{margin:0;padding:0;list-style:none;display:grid;gap:6px} .group li{font-size:14px}.group li b{font-weight:600}.group li span{color:var(--muted)} .foot{border-top:1px solid var(--line);color:var(--faint);font-size:12.5px;padding:18px 0 28px} .foot p{margin:4px 0}.foot .links a{margin-right:16px} .score{display:flex;align-items:baseline;gap:10px;flex-wrap:wrap;margin:6px 0 4px} .score .n{font-size:56px;font-weight:700;line-height:1;font-variant-numeric:tabular-nums} .score .of{color:var(--faint)} .pill{display:inline-block;border-radius:999px;padding:2px 12px;font-size:13px;font-weight:700} .pill.passed{background:var(--run-soft);color:var(--run)}.pill.marginal{background:var(--warn-soft);color:var(--warn)} .pill.failed{background:var(--danger-soft);color:var(--danger)} .meta{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:10px 18px;margin:18px 0 0;font-size:14px} .meta div{min-width:0}.meta dt{color:var(--faint);font-size:12.5px}.meta dd{margin:0;overflow-wrap:anywhere} .tbl{width:100%;border-collapse:collapse;font-size:14px;font-variant-numeric:tabular-nums} .tbl th,.tbl td{text-align:left;padding:10px 8px;border-top:1px solid var(--line);vertical-align:top} .tbl thead th{border-top:0;color:var(--faint);font-weight:500;font-size:12.5px} .tbl .num{text-align:right;white-space:nowrap}.tbl .st{white-space:nowrap} .tbl td:first-child{width:100%} .st-pass{color:var(--run)}.st-fail,.st-error{color:var(--danger)}.st-skip{color:var(--faint)} details{margin-top:6px}summary{cursor:pointer;color:var(--accent-deep);font-size:13px} pre{background:var(--sunk);border-radius:8px;padding:10px 12px;overflow-x:auto;font-size:12px;line-height:1.5;margin:8px 0 0; white-space:pre-wrap;overflow-wrap:anywhere} .alert{background:var(--danger-soft);color:var(--danger);border-radius:10px;padding:12px 14px;margin:16px 0 0} .actions{display:flex;gap:18px;flex-wrap:wrap;margin-top:22px;font-size:14px} .files{margin:0;padding:0;list-style:none;font:13px/1.9 ui-monospace,SFMono-Regular,Menlo,monospace} .mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:13px} `; /** * 前端脚本:提交检测、读 NDJSON 流、显示已用时间,完成后跳到报告页。文案从表单的 data-* 里取(页面按语言渲染), * 脚本本身不分语言。API key 提交后立刻从输入框清掉。 */ export const APP_JS = `(function(){ var form=document.getElementById('detect-form');if(!form)return; var btn=form.querySelector('button'),st=document.getElementById('status'),d=form.dataset; function show(t,err){st.textContent=t;st.className='status'+(err?' err':'');st.hidden=false} function fill(t,k,v){return t.split('{'+k+'}').join(v)} form.addEventListener('submit',function(ev){ev.preventDefault(); var body={base_url:form.base_url.value.trim(),api_key:form.api_key.value.trim(),model:form.model.value.trim(),mode:form.mode.value}; form.api_key.value='';btn.disabled=true;var t0=Date.now(); var tick=setInterval(function(){show(fill(d.running,'s',Math.round((Date.now()-t0)/1000)))},1000);show(fill(d.running,'s',0)); function fail(m){clearInterval(tick);btn.disabled=false;show(fill(d.failed,'msg',m),true)} fetch('/api/detect?lang='+encodeURIComponent(d.lang),{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(body)}) .then(function(res){ if(!res.ok||!res.body){return res.json().then(function(j){fail(j&&j.error?j.error:('HTTP '+res.status))},function(){fail('HTTP '+res.status)})} var reader=res.body.getReader(),dec=new TextDecoder(),buf='',done=false; function onLine(line){if(!line)return;var m;try{m=JSON.parse(line)}catch(e){return} if(m.type==='done'&&m.id){done=true;clearInterval(tick);location.href='/r/'+encodeURIComponent(m.id)+(d.lang==='en'?'?lang=en':'')} else if(m.type==='error'){done=true;fail(m.message||'error')}} function pump(){return reader.read().then(function(r){ if(r.done){if(buf)onLine(buf.trim());if(!done)fail(d.network);return} buf+=dec.decode(r.value,{stream:true});var i;while((i=buf.indexOf('\\n'))>=0){onLine(buf.slice(0,i).trim());buf=buf.slice(i+1)} return pump()})} return pump()}) .catch(function(){fail(d.network)})}); })(); `;