WebPile Pro Enterprise

Multi-file Monaco studio → isolated preview, console capture, validation, version history, ZIP export.

Project: —
Auto
Safe Full
Editor Files · Ctrl/+Enter render · Ctrl/+S snapshot
Loading editor…
Enterprise notes: this tool prefers local vendor assets for Monaco/JSZip, but can fall back to CDN. In locked networks, export a pack and vendorize dependencies.
Monaco couldn’t load (usually a network block). You’re in fallback mode (plain textarea). Preview, export, history, validation still work.
Ready
0 chars · 0 lines Session:
Preview Safe Mode (scripts blocked) · isolated sandbox
Isolated
Capture
Safe is for layout/CSS. Full runs scripts. “Isolated” keeps preview from reading your tool storage (recommended). Export ZIP for client handoff.
Single-file studio · enterprise defaults: isolated preview + network block + version snapshots
`, 'styles.css': `:root{ color-scheme: dark; } body{ margin:0; padding:32px; font-family:system-ui, -apple-system, Segoe UI, Roboto, Arial; background:#0b0b12; color:#eef; } .card{ max-width:900px; margin:0 auto; padding:24px; border-radius:18px; background:rgba(255,255,255,.06); border:1px solid rgba(255,255,255,.12) } h1{ margin:0 0 10px; letter-spacing:.06em; text-transform:uppercase } p{ opacity:.85; line-height:1.5 } button{ margin-top:14px; padding:10px 14px; border-radius:999px; border:1px solid rgba(255,255,255,.18); background:#7c3cff; color:white; font-weight:800; cursor:pointer }`, 'app.js': `document.getElementById('btn')?.addEventListener('click', () => { console.log('Hello from app.js ✅', { at: new Date().toISOString() }); });` }; const defaultProject = (name='New Project') => ({ id: uid(), name: sanitizeName(name), createdAt: now(), updatedAt: now(), files: [ { path: 'index.html', language: 'html', content: TEMPLATES['index.html'] }, { path: 'styles.css', language: 'css', content: TEMPLATES['styles.css'] }, { path: 'app.js', language: 'javascript', content: TEMPLATES['app.js'] }, ], settings: { auto: true, fullMode: false, isolated: true, instrument: true, network: 'block', debounce: 350, device: 'responsive', scale: '1', } }); const inferLang = (path) => { const p = String(path).toLowerCase(); if (p.endsWith('.html') || p.endsWith('.htm')) return 'html'; if (p.endsWith('.css')) return 'css'; if (p.endsWith('.js') || p.endsWith('.mjs') || p.endsWith('.cjs')) return 'javascript'; if (p.endsWith('.json')) return 'json'; if (p.endsWith('.md')) return 'markdown'; return 'plaintext'; }; // ---------- Runtime state ---------- const state = { usingCdn: false, monacoBase: null, project: null, activeFile: 'index.html', dirtyFiles: new Set(), editor: null, modelByPath: new Map(), fallbackMode: false, renderTimer: null, lastRenderHash: '', sessionToken: '', console: [], lastValidateReport: null, snapshots: [], projects: [] }; // ---------- Migration from older single-file version (best-effort) ---------- const migrateLegacy = async () => { // Legacy keys from your previous file: HTML_PREVIEW_STUDIO__CODE_V2_MONACO + WEBPILE_PRO_FILES_V1 let legacy = null; try { legacy = localStorage.getItem('HTML_PREVIEW_STUDIO__CODE_V2_MONACO'); } catch(_) {} if (legacy && legacy.trim()) { const p = defaultProject('Migrated — Single HTML'); p.files = [ { path:'index.html', language:'html', content: legacy }, { path:'styles.css', language:'css', content: '/* migrated: split your CSS out if you want */\n' }, { path:'app.js', language:'javascript', content: '// migrated: split your JS out if you want\n' } ]; p.updatedAt = now(); await idbPut('projects', p); try { localStorage.removeItem('HTML_PREVIEW_STUDIO__CODE_V2_MONACO'); } catch(_) {} toast('Migrated legacy editor state into a project', 'ok', 1800); return; } // Legacy WebPile list let legacyList = null; try { legacyList = JSON.parse(localStorage.getItem('WEBPILE_PRO_FILES_V1') || 'null'); } catch(_) {} if (Array.isArray(legacyList) && legacyList.length) { for (const entry of legacyList.slice(0, 50)) { const p = defaultProject('Migrated — ' + (entry.name || 'Site')); p.files = [ { path:'index.html', language:'html', content: String(entry.content || '') }, { path:'styles.css', language:'css', content: '/* migrated */\n' }, { path:'app.js', language:'javascript', content: '// migrated\n' } ]; p.updatedAt = now(); await idbPut('projects', p); } try { localStorage.removeItem('WEBPILE_PRO_FILES_V1'); } catch(_) {} toast('Migrated legacy WebPile sites into projects', 'ok', 1800); } }; // ---------- Monaco / editor layer ---------- const createMonacoTheme = (monaco) => { try { monaco.editor.defineTheme('kaixuNebula', { base: 'vs-dark', inherit: true, rules: [ { token: '', foreground: 'EAF0FF' }, { token: 'tag', foreground: '27D6FF' }, { token: 'attribute.name', foreground: 'FFCF5B' }, { token: 'attribute.value', foreground: 'FF2BD6' }, { token: 'string', foreground: 'FF2BD6' }, { token: 'comment', foreground: 'A8B2D6' } ], colors: { 'editor.background': '#07021a', 'editor.lineHighlightBackground': '#0b0320', 'editorCursor.foreground': '#ffcf5b', 'editor.selectionBackground': '#2a145a', 'editor.inactiveSelectionBackground': '#1b0f3a', 'editorIndentGuide.background': '#1b0f3a', 'editorIndentGuide.activeBackground': '#7c3cff', 'editorLineNumber.foreground': '#6f78a2', 'editorLineNumber.activeForeground': '#ffcf5b', 'editorWidget.background': '#0b0320', 'editorSuggestWidget.background': '#0b0320', 'editorSuggestWidget.border': '#1b0f3a', 'editorSuggestWidget.selectedBackground': '#1b0f3a' } }); } catch (_) {} }; const setActiveModel = (path) => { state.activeFile = path; if (state.fallbackMode) { els.fallbackTextarea.value = getFile(path).content || ''; renderFileTabs(); updateStats(); return; } const m = state.modelByPath.get(path); if (!m || !state.editor) return; state.editor.setModel(m); renderFileTabs(); updateStats(); }; const getFile = (path) => { const files = state.project?.files || []; return files.find(f => f.path === path) || files[0] || null; }; const setFileContent = (path, content) => { const f = getFile(path); if (!f) return; f.content = String(content ?? ''); state.project.updatedAt = now(); state.dirtyFiles.add(path); renderFileTabs(); }; const escapeHtml = (s) => String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); const renderFileTabs = () => { const files = state.project?.files || []; els.fileTabs.innerHTML = ''; for (const f of files) { const btn = document.createElement('div'); btn.className = 'tab' + (f.path === state.activeFile ? ' active' : '') + (state.dirtyFiles.has(f.path) ? ' dirty' : ''); btn.title = f.path; btn.innerHTML = `${escapeHtml(f.path)}`; btn.addEventListener('click', () => setActiveModel(f.path)); els.fileTabs.appendChild(btn); } }; const updateStats = () => { const v = getActiveContent() || ''; const lines = v.length ? (v.match(/\n/g)?.length ?? 0) + 1 : 0; els.stats.textContent = `${v.length.toLocaleString()} chars · ${lines.toLocaleString()} lines`; }; const getActiveContent = () => { if (!state.project) return ''; if (state.fallbackMode) return els.fallbackTextarea.value || ''; const model = state.modelByPath.get(state.activeFile); return model ? model.getValue() : (getFile(state.activeFile)?.content || ''); }; const applyEditorToProject = () => { if (!state.project) return; if (state.fallbackMode) { setFileContent(state.activeFile, els.fallbackTextarea.value || ''); return; } for (const f of state.project.files) { const m = state.modelByPath.get(f.path); if (m) f.content = m.getValue(); } state.project.updatedAt = now(); }; const persistProject = async (msg='Saved') => { if (!state.project) return; applyEditorToProject(); try { await idbPut('projects', state.project); state.dirtyFiles.clear(); setSave('ok', msg); renderFileTabs(); } catch (e) { setSave('warn', 'Save failed (storage blocked)'); } }; const schedulePersist = () => { clearTimeout(schedulePersist._t); schedulePersist._t = setTimeout(() => persistProject('Autosaved'), 450); }; const scheduleRender = () => { if (!state.project?.settings?.auto) return; const ms = clamp(Number(els.debounceMs.value || 0), 0, 5000); state.project.settings.debounce = ms; clearTimeout(state.renderTimer); state.renderTimer = setTimeout(() => renderNow(false), ms); }; // ---------- Compose / preview ---------- const hashStr = (s) => { let h = 2166136261; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h += (h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24); } return (h >>> 0).toString(16); }; const applyViewport = () => { const s = state.project?.settings || {}; const vw = s.device === 'responsive' ? null : Number(s.device); const scale = Number(s.scale || '1'); els.viewport.style.width = vw ? `${vw}px` : '100%'; els.viewport.style.transformOrigin = 'top center'; els.viewport.style.transform = scale === 1 ? 'none' : `scale(${scale})`; els.viewport.style.height = '100%'; }; const setSandbox = () => { const s = state.project?.settings || {}; // IMPORTANT: For security, Full mode does NOT automatically enable allow-same-origin. // "Isolated" means: no allow-same-origin. Compatibility means: allow-same-origin (riskier). const allowSameOrigin = !s.isolated; if (s.fullMode) { const parts = [ 'allow-scripts', 'allow-forms', 'allow-modals', 'allow-popups', 'allow-pointer-lock' ]; if (allowSameOrigin) parts.push('allow-same-origin'); els.iframe.setAttribute('sandbox', parts.join(' ')); } else { els.iframe.setAttribute('sandbox', 'allow-forms allow-modals allow-popups'); } }; const buildInstrumentation = (token, networkPolicy) => { // Token gates messages so other frames can't spam our console UI. return ` . const cssTag = `\n\n`; const jsTag = `\n ')) html = html.replace(' ', jsTag + ' '); else html = html + jsTag; } // Preview safety affordances: set base target _blank to avoid inside-iframe navigation surprises. const baseTag = ``; if (html.includes('')) { html = html.replace('', baseTag + '\n'); } // Instrumentation injection (optional; only meaningful in Full mode) const s = state.project.settings; if (s.fullMode && s.instrument) { const inj = buildInstrumentation(state.sessionToken, s.network); if (html.includes('')) html = html.replace('', inj + '\n'); else html = inj + html; } return html; }; const clearConsole = () => { state.console = []; els.consoleBody.textContent = ''; els.consoleCount.textContent = '0 entries'; }; const appendConsole = (kind, payload) => { const level = payload?.level || kind; const ts = payload?.ts || now(); const args = payload?.args || (payload?.reason ? [payload.reason] : []); const msg = (args || []).map(a => { if (a && a.__error) return `${a.name}: ${a.message}\n${a.stack || ''}`; if (typeof a === 'object') return JSON.stringify(a, null, 2); return String(a); }).join(' '); const line = { level, ts, msg }; state.console.push(line); if (state.console.length > 400) state.console.shift(); const p = document.createElement('div'); const lvl = (level || 'log').toLowerCase(); p.className = 'logLine ' + (lvl.includes('warn') ? 'warn' : lvl.includes('error') ? 'error' : lvl.includes('info') ? 'info' : ''); p.innerHTML = `${escapeHtml(lvl)} ${escapeHtml(new Date(ts).toLocaleTimeString())} ${escapeHtml(msg)}`; els.consoleBody.appendChild(p); els.consoleBody.scrollTop = els.consoleBody.scrollHeight; els.consoleCount.textContent = `${state.console.length} entries`; }; window.addEventListener('message', (e) => { const d = e.data; if (!d || d.__webpile !== true) return; if (d.token !== state.sessionToken) return; if (!state.project?.settings?.instrument) return; if (d.type === 'console') appendConsole('console', d.payload); if (d.type === 'error') appendConsole('error', { level:'error', args:[{__error:true,name:'Error',message:d.payload?.message || 'Error',stack:d.payload?.stack || ''}], ts:d.payload?.ts }); if (d.type === 'rejection') appendConsole('rejection', { level:'error', args:[d.payload?.reason || 'unhandledrejection'], ts:d.payload?.ts }); if (d.type === 'netblock') appendConsole('netblock', { level:'warn', args:[`NETWORK BLOCKED: ${d.payload?.kind} → ${d.payload?.url}`], ts:d.payload?.ts }); }); const renderNow = (force=false) => { if (!state.project) return; applyEditorToProject(); setSandbox(); state.sessionToken = uid(); els.sessionToken.textContent = state.sessionToken.slice(0, 8); const composed = composeHtml(); const h = hashStr(composed + '|' + JSON.stringify(state.project.settings)); if (!force && h === state.lastRenderHash) return; state.lastRenderHash = h; // Clear console on each render to avoid cross-run confusion. clearConsole(); try { els.iframe.srcdoc = composed; els.previewSub.textContent = state.project.settings.fullMode ? `Full Mode (scripts allowed) · ${state.project.settings.isolated ? 'isolated sandbox' : 'compatibility sandbox (same-origin)'} · network: ${state.project.settings.network}` : 'Safe Mode (scripts blocked) · isolated sandbox'; setSave('ok', state.project.settings.auto ? 'Rendered (auto)' : 'Rendered'); } catch (e) { setSave('warn', 'Render used blob fallback'); const blob = new Blob([composed], { type: 'text/html;charset=utf-8' }); const url = URL.createObjectURL(blob); els.iframe.src = url; setTimeout(() => URL.revokeObjectURL(url), 60000); } }; const openPreviewTab = () => { const composed = composeHtml(); const blob = new Blob([composed], { type: 'text/html;charset=utf-8' }); const url = URL.createObjectURL(blob); window.open(url, '_blank', 'noopener,noreferrer'); setTimeout(() => URL.revokeObjectURL(url), 60000); }; // ---------- Validation (local static scan) ---------- const validateProject = async () => { applyEditorToProject(); const files = state.project.files; const report = { project: { id: state.project.id, name: state.project.name, updatedAt: state.project.updatedAt }, settings: { ...state.project.settings }, totals: { files: files.length, bytes: files.reduce((a,f)=>a + bytes(f.content).length, 0) }, issues: [], hashes: {} }; // Hashes (for evidence pack) for (const f of files) { report.hashes[f.path] = await sha256Hex(f.content || ''); } const html = String((files.find(f=>inferLang(f.path)==='html')?.content) || ''); let doc = null; try { doc = new DOMParser().parseFromString(html, 'text/html'); } catch(_) {} // HTML parse error (best-effort) if (doc) { const pe = doc.querySelector('parsererror'); if (pe) report.issues.push({ severity:'error', code:'HTML_PARSE', message:'HTML parser error detected (browser parsererror node).', detail: pe.textContent?.slice(0, 240) || '' }); } else { report.issues.push({ severity:'warn', code:'HTML_PARSE', message:'Could not parse HTML via DOMParser.', detail:'' }); } // Basic metadata const hasTitle = doc && doc.querySelector('title'); if (!hasTitle) report.issues.push({ severity:'warn', code:'META_TITLE', message:'Missing .', detail:'Add a title for accessibility and professional polish.' }); const hasCharset = /<meta\s+charset=/i.test(html); if (!hasCharset) report.issues.push({ severity:'warn', code:'META_CHARSET', message:'Missing <meta charset>.', detail:'Add <meta charset="utf-8"> early in <head>.' }); const hasViewport = /<meta\s+name=["']viewport["']/i.test(html); if (!hasViewport) report.issues.push({ severity:'warn', code:'META_VIEWPORT', message:'Missing viewport meta.', detail:'Add <meta name="viewport" content="width=device-width,initial-scale=1">.' }); // Duplicate IDs if (doc) { const ids = [...doc.querySelectorAll('[id]')].map(n=>n.getAttribute('id')).filter(Boolean); const seen = new Map(); for (const id of ids) seen.set(id, (seen.get(id) || 0) + 1); const dups = [...seen.entries()].filter(([_,c])=>c>1).slice(0, 20); if (dups.length) report.issues.push({ severity:'warn', code:'DUP_IDS', message:`Duplicate IDs found (${dups.length}).`, detail: dups.map(([i,c])=>`${i}×${c}`).join(', ') }); } // Script/network red flags (heuristics) const js = String(getFile('app.js')?.content || ''); const combinedJs = html + '\n' + js; const looksLikeEval = /\beval\s*\(|new\s+Function\s*\(/.test(combinedJs); if (looksLikeEval) report.issues.push({ severity:'warn', code:'JS_EVAL', message:'Possible eval/new Function usage detected.', detail:'Avoid eval in production; it complicates CSP and security reviews.' }); const looksLikeInlineRemote = /<script[^>]+src=/i.test(html) || /<link[^>]+href=/i.test(html); if (looksLikeInlineRemote && state.project.settings.network === 'block') { report.issues.push({ severity:'info', code:'NET_BLOCK_MAY_BREAK', message:'Remote resources detected while Network policy is Block.', detail:'If your preview relies on remote fetch/XHR/WS it will be blocked. Script/link/image tags may still load depending on browser.' }); } // Sandbox risk check if (state.project.settings.fullMode && !state.project.settings.isolated) { report.issues.push({ severity:'warn', code:'SAME_ORIGIN', message:'Compatibility mode (allow-same-origin) enabled in Full Mode.', detail:'This is riskier: preview code can access same-origin storage if not otherwise isolated. Keep Isolated on by default.' }); } // Tool file size advisory if (report.totals.bytes > 3_000_000) { report.issues.push({ severity:'warn', code:'SIZE_BIG', message:'Project size is large (>3MB).', detail:'Very large files can slow the browser and editor.' }); } // Summary counts report.counts = { error: report.issues.filter(i=>i.severity==='error').length, warn: report.issues.filter(i=>i.severity==='warn').length, info: report.issues.filter(i=>i.severity==='info').length }; return report; }; const renderValidateUI = (report) => { els.validateKpis.innerHTML = ''; const k = (label, value) => { const d = document.createElement('div'); d.className = 'kpi'; d.innerHTML = `<b>${escapeHtml(label)}</b><span>${escapeHtml(String(value))}</span>`; els.validateKpis.appendChild(d); }; k('Project', report.project.name); k('Files', report.totals.files); k('Bytes', report.totals.bytes.toLocaleString()); k('Issues', `E:${report.counts.error} W:${report.counts.warn} I:${report.counts.info}`); els.validateList.innerHTML = ''; if (!report.issues.length) { const r = document.createElement('div'); r.className = 'row'; r.innerHTML = `<div class="meta"><b>No issues found</b><span>Nice. This is a clean baseline.</span></div>`; els.validateList.appendChild(r); } else { for (const it of report.issues) { const r = document.createElement('div'); r.className = 'row'; const sev = it.severity === 'error' ? 'bad' : it.severity === 'warn' ? 'warn' : 'ok'; r.innerHTML = `<div class="meta"><b><span class="dot ${sev}" style="margin-right:8px"></span>${escapeHtml(it.code)} · ${escapeHtml(it.message)}</b><span>${escapeHtml(it.detail || '')}</span></div>`; els.validateList.appendChild(r); } } els.validateFooter.textContent = `Report built ${new Date().toLocaleString()} · SHA-256 checksums included`; }; const reportToText = (report) => { const lines = []; lines.push(`${BUILD.name} Validation Report`); lines.push(`Project: ${report.project.name} (${report.project.id})`); lines.push(`Updated: ${fmtTime(report.project.updatedAt)}`); lines.push(`Files: ${report.totals.files} · Bytes: ${report.totals.bytes}`); lines.push(`Issues: error=${report.counts.error}, warn=${report.counts.warn}, info=${report.counts.info}`); lines.push(''); for (const it of report.issues) { lines.push(`[${it.severity.toUpperCase()}] ${it.code} — ${it.message}`); if (it.detail) lines.push(` ${it.detail}`); } lines.push(''); lines.push('Checksums (SHA-256):'); for (const [k,v] of Object.entries(report.hashes || {})) lines.push(` ${k} ${v}`); return lines.join('\n'); }; // ---------- Snapshots ---------- const listSnapshots = async (projectId) => { const all = await idbGetAll('snapshots', 'projectId', projectId); all.sort((a,b) => b.createdAt - a.createdAt); return all; }; const enforceSnapshotLimit = async (projectId, limit=30) => { const snaps = await listSnapshots(projectId); if (snaps.length <= limit) return; const toDel = snaps.slice(limit); for (const s of toDel) await idbDelete('snapshots', s.id); }; const createSnapshot = async (label='') => { if (!state.project) return; await persistProject('Saved before snapshot'); const snap = { id: uid(), projectId: state.project.id, createdAt: now(), label: String(label || '').trim().slice(0, 120), project: JSON.parse(JSON.stringify(state.project)) }; await idbPut('snapshots', snap); await enforceSnapshotLimit(state.project.id, 30); toast('Snapshot created', 'ok'); }; const restoreSnapshot = async (snap) => { if (!snap?.project) return; state.project = snap.project; await idbPut('projects', state.project); await loadProjectIntoEditor(state.project.id, { keepActiveFile: true }); toast('Snapshot restored', 'ok', 1600); }; const renderHistoryList = async () => { const snaps = await listSnapshots(state.project.id); state.snapshots = snaps; els.historyList.innerHTML = ''; if (!snaps.length) { const r = document.createElement('div'); r.className = 'row'; r.innerHTML = `<div class="meta"><b>No snapshots yet</b><span>Create one with Ctrl/⌘+S or the Snapshot button.</span></div>`; els.historyList.appendChild(r); } else { for (const s of snaps) { const r = document.createElement('div'); r.className = 'row'; const label = s.label ? ` · ${s.label}` : ''; r.innerHTML = ` <div class="meta"> <b>${escapeHtml(new Date(s.createdAt).toLocaleString())}${escapeHtml(label)}</b> <span>${escapeHtml(s.project?.files?.length || 0)} files · Updated ${escapeHtml(new Date(s.project?.updatedAt || s.createdAt).toLocaleString())}</span> </div> <div class="actions"> <button class="btn" data-act="restore">Restore</button> <button class="btn" data-act="export">Export ZIP</button> <button class="btn danger" data-act="delete">Delete</button> </div> `; r.querySelector('[data-act="restore"]').addEventListener('click', () => restoreSnapshot(s)); r.querySelector('[data-act="export"]').addEventListener('click', () => exportProjectZip(s.project, `${state.project.name} — snapshot`)); r.querySelector('[data-act="delete"]').addEventListener('click', async () => { if (!confirm('Delete this snapshot?')) return; await idbDelete('snapshots', s.id); renderHistoryList(); toast('Snapshot deleted', 'ok'); }); els.historyList.appendChild(r); } } els.historyFooter.textContent = `${snaps.length} snapshots (kept to 30 max)`; }; // Diff (simple textual diff summary; Monaco diff editor would be heavier UI) const diffTwoSnapshots = async () => { const snaps = state.snapshots || []; if (snaps.length < 2) { toast('Need 2 snapshots to diff', 'warn'); return; } // Pick latest two by default. const a = snaps[0].project; const b = snaps[1].project; const aMap = new Map(a.files.map(f=>[f.path, f.content])); const bMap = new Map(b.files.map(f=>[f.path, f.content])); const paths = Array.from(new Set([...aMap.keys(), ...bMap.keys()])).sort(); const out = []; out.push(`Diff (latest vs previous)`); out.push(`A: ${new Date(snaps[0].createdAt).toLocaleString()} ${snaps[0].label || ''}`); out.push(`B: ${new Date(snaps[1].createdAt).toLocaleString()} ${snaps[1].label || ''}`); out.push(''); for (const p of paths) { const aa = aMap.get(p); const bb = bMap.get(p); if (aa == null) out.push(`+ ${p} (added)`); else if (bb == null) out.push(`- ${p} (removed)`); else if (aa !== bb) out.push(`~ ${p} (changed)`); } out.push(''); out.push('Tip: for detailed diffs, export both snapshots and use a real diff tool.'); await navigator.clipboard.writeText(out.join('\n')).catch(()=>{}); toast('Diff summary copied to clipboard', 'ok', 1600); }; // ---------- ZIP export/import ---------- const ensureJSZip = async () => { if (window.JSZip) return true; try { const src = await tryLoad([DEP.jszipLocal, DEP.jszipCdn]); if (src === DEP.jszipCdn) { state.usingCdn = true; els.cdnBanner.classList.add('show'); } return !!window.JSZip; } catch (e) { toast('JSZip unavailable (export/import disabled)', 'warn', 1800); return false; } }; const exportProjectZip = async (project, nameOverride=null) => { const ok = await ensureJSZip(); if (!ok) return; const zip = new JSZip(); const root = sanitizeName(nameOverride || project.name).replace(/\s+/g,'_') || 'project'; const folder = zip.folder(root); // Write files for (const f of project.files) folder.file(f.path, f.content || ''); // webpile.json manifest const manifest = { tool: BUILD, project: { id: project.id, name: project.name, createdAt: project.createdAt, updatedAt: project.updatedAt }, files: project.files.map(f => ({ path:f.path, language:f.language, bytes: bytes(f.content).length })), settings: project.settings }; folder.file('webpile.json', JSON.stringify(manifest, null, 2)); // checksums const checks = []; for (const f of project.files) { const h = await sha256Hex(f.content || ''); checks.push(`${h} ${f.path}`); } folder.file('checksums.sha256', checks.join('\n') + '\n'); const blob = await zip.generateAsync({ type: 'blob' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${root}.zip`; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 60000); toast('Exported ZIP', 'ok'); }; const exportEvidencePack = async () => { if (!state.lastValidateReport) { toast('Run Validate first', 'warn'); return; } const ok = await ensureJSZip(); if (!ok) return; const zip = new JSZip(); const root = sanitizeName(state.project.name).replace(/\s+/g,'_') + '__evidence'; const folder = zip.folder(root); folder.file('validation_report.txt', reportToText(state.lastValidateReport)); folder.file('validation_report.json', JSON.stringify(state.lastValidateReport, null, 2)); const checks = []; for (const [p,h] of Object.entries(state.lastValidateReport.hashes || {})) checks.push(`${h} ${p}`); folder.file('checksums.sha256', checks.join('\n') + '\n'); const blob = await zip.generateAsync({ type: 'blob' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${root}.zip`; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 60000); toast('Evidence Pack exported', 'ok'); }; const exportToolPack = async () => { const ok = await ensureJSZip(); if (!ok) return; // Tool pack: index.html + tiny README + placeholder folders. const zip = new JSZip(); const root = 'webpile-pro-enterprise'; const folder = zip.folder(root); folder.file('index.html', document.documentElement.outerHTML); folder.file('README.txt', [ 'WebPile Pro Enterprise — Tool Pack', '', 'Deploy (Netlify Drop):', '1) Unzip this folder.', '2) Drag the folder into Netlify Drop (or upload via Netlify UI).', '', 'Vendorizing Monaco/JSZip (recommended for locked-down networks):', '- Place Monaco under: vendor/monaco/min/vs/... (Monaco "min" build)', '- Place JSZip at: vendor/jszip.min.js', '', 'Once vendor files exist, the tool will prefer them and avoid CDN fallbacks.', '', 'Optional PWA files:', '- manifest.webmanifest, icons/, sw.js', '- You can create these if you want install/offline caching for the tool itself.', '' ].join('\n')); // Placeholder folders folder.folder('vendor').folder('monaco').folder('min'); folder.folder('icons'); const blob = await zip.generateAsync({ type: 'blob' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${root}.zip`; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 60000); toast('Tool Pack exported', 'ok'); }; const importZipAsProject = async (file) => { const ok = await ensureJSZip(); if (!ok) return null; const buf = await file.arrayBuffer(); const zip = await JSZip.loadAsync(buf); const entries = []; zip.forEach((relPath, entry) => entries.push({ relPath, entry })); // Attempt to find webpile.json let manifestEntry = entries.find(e => e.relPath.toLowerCase().endsWith('webpile.json')); let rootPrefix = ''; if (manifestEntry) { rootPrefix = manifestEntry.relPath.slice(0, -'webpile.json'.length); } else { // Guess: use top folder const top = entries.map(e => e.relPath.split('/')[0]).filter(Boolean); rootPrefix = (top[0] ? top[0] + '/' : ''); } // Read files (skip folders) const files = []; for (const e of entries) { if (e.entry.dir) continue; const rp = e.relPath; if (!rp.startsWith(rootPrefix)) continue; const sub = rp.slice(rootPrefix.length); if (!sub || sub.toLowerCase() === 'webpile.json' || sub.toLowerCase() === 'checksums.sha256') continue; // Avoid importing vendor/tool files as project content. if (sub.startsWith('vendor/') || sub.startsWith('icons/')) continue; const content = await e.entry.async('string'); files.push({ path: sanitizePath(sub), language: inferLang(sub), content }); } if (!files.length) { toast('ZIP contained no importable files', 'warn'); return null; } const p = defaultProject('Imported — ' + (file.name || 'ZIP')); p.files = files; p.updatedAt = now(); await idbPut('projects', p); toast('Imported ZIP as new project', 'ok', 1600); return p; }; const importHtmlToActive = async (file) => { const text = await file.text(); if (!text.trim()) return; if (!state.project) return; const htmlFile = state.project.files.find(f => inferLang(f.path) === 'html') || getFile('index.html'); htmlFile.path = htmlFile.path || 'index.html'; htmlFile.language = 'html'; htmlFile.content = text; state.project.updatedAt = now(); await persistProject('Imported HTML'); await loadProjectIntoEditor(state.project.id); renderNow(true); toast('Imported HTML into project', 'ok'); }; // ---------- Projects UI ---------- const listProjects = async () => { const projects = await idbGetAll('projects'); projects.sort((a,b)=> b.updatedAt - a.updatedAt); state.projects = projects; els.projectsList.innerHTML = ''; for (const p of projects) { const row = document.createElement('div'); row.className = 'row'; row.innerHTML = ` <div class="meta"> <b>${escapeHtml(p.name)}</b> <span>${escapeHtml(p.files?.length || 0)} files · Updated ${escapeHtml(fmtTime(p.updatedAt || p.createdAt || now()))}</span> </div> <div class="actions"> <button class="btn primary" data-act="open">Open</button> <button class="btn" data-act="dup">Duplicate</button> <button class="btn" data-act="export">Export</button> <button class="btn danger" data-act="del">Delete</button> </div> `; row.querySelector('[data-act="open"]').addEventListener('click', async () => { await loadProjectIntoEditor(p.id); closeModal(els.projectsModal); }); row.querySelector('[data-act="export"]').addEventListener('click', () => exportProjectZip(p)); row.querySelector('[data-act="dup"]').addEventListener('click', async () => { const d = JSON.parse(JSON.stringify(p)); d.id = uid(); d.name = sanitizeName(p.name + ' — Copy'); d.createdAt = now(); d.updatedAt = now(); await idbPut('projects', d); toast('Duplicated project', 'ok'); listProjects(); }); row.querySelector('[data-act="del"]').addEventListener('click', async () => { if (!confirm('Delete project "' + p.name + '"? This does not affect exported ZIPs.')) return; // Delete snapshots for project const snaps = await listSnapshots(p.id); for (const s of snaps) await idbDelete('snapshots', s.id); await idbDelete('projects', p.id); toast('Deleted project', 'ok'); if (state.project?.id === p.id) { // Load another project or create a new one await bootstrapProject(); } else { listProjects(); } }); els.projectsList.appendChild(row); } els.projectsFooter.textContent = `${projects.length} local projects`; }; const bootstrapProject = async () => { const lastId = await kvGet('lastProjectId'); let p = lastId ? await idbGet('projects', lastId) : null; if (!p) { // Create first project if none exist const existing = await idbGetAll('projects'); if (existing.length) { existing.sort((a,b)=>b.updatedAt-a.updatedAt); p = existing[0]; } else { p = defaultProject('WebPile Starter'); await idbPut('projects', p); } } await loadProjectIntoEditor(p.id); }; const loadProjectIntoEditor = async (projectId, opts={}) => { const p = await idbGet('projects', projectId); if (!p) return; state.project = p; // Persist "last opened" await kvSet('lastProjectId', p.id); // Sync UI controls const s = state.project.settings || {}; els.autoToggle.dataset.on = String(!!s.auto); els.modeToggle.dataset.on = String(!!s.fullMode); els.modeLabel.textContent = s.fullMode ? 'Full' : 'Safe'; els.debounceMs.value = clamp(Number(s.debounce ?? 350), 0, 5000); els.isolationToggle.dataset.on = String(!!s.isolated); els.instrumentToggle.dataset.on = String(!!s.instrument); els.networkSel.value = s.network || 'block'; els.deviceSel.value = s.device || 'responsive'; els.scaleSel.value = s.scale || '1'; applyViewport(); els.projectPill.textContent = 'Project: ' + state.project.name; // Ensure active file exists const files = state.project.files || []; if (!files.length) state.project.files = defaultProject(state.project.name).files; if (!opts.keepActiveFile) state.activeFile = (files[0]?.path || 'index.html'); // Render file tabs renderFileTabs(); // Load into Monaco or fallback editor await initEditorIfNeeded(); // Set up models if (!state.fallbackMode) { const monaco = window.monaco; state.modelByPath.forEach(m => { try { m.dispose(); } catch(_) {} }); state.modelByPath.clear(); for (const f of state.project.files) { const lang = f.language || inferLang(f.path); f.language = lang; const model = monaco.editor.createModel(String(f.content || ''), lang); state.modelByPath.set(f.path, model); model.onDidChangeContent(() => { state.dirtyFiles.add(f.path); schedulePersist(); scheduleRender(); updateStats(); renderFileTabs(); }); } if (!state.editor) throw new Error('Editor missing'); const active = state.modelByPath.get(state.activeFile) ? state.activeFile : state.project.files[0].path; setActiveModel(active); state.editor.focus(); // Keybindings: render + snapshot + console state.editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter, () => renderNow(true)); state.editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => createSnapshot('Quick save')); state.editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.Backquote, () => toggleConsole(true)); } else { els.fallbackTextarea.value = getFile(state.activeFile).content || ''; updateStats(); els.fallbackTextarea.oninput = () => { setFileContent(state.activeFile, els.fallbackTextarea.value || ''); schedulePersist(); scheduleRender(); updateStats(); }; } // Initial render for project renderNow(true); }; // ---------- Editor init (Monaco preferred, fallback textarea) ---------- const initEditorIfNeeded = async () => { if (state.editor || state.fallbackMode) return; // If Monaco loader isn't present, try to load it (local first). els.loadLine.textContent = 'Loading Monaco/JSZip…'; // Load JSZip opportunistically (used for export/import; ok if it fails) try { const jszipSrc = await tryLoad([DEP.jszipLocal, DEP.jszipCdn]); if (jszipSrc === DEP.jszipCdn) { state.usingCdn = true; els.cdnBanner.classList.add('show'); } } catch (_) { /* ok */ } // Load Monaco AMD loader let loaderSrc = null; try { // Try local loader first (vendor/monaco/min/vs/loader.js) loaderSrc = await tryLoad([ DEP.monacoLocalBase + '/vs/loader.js', DEP.monacoLocalBase + '/vs/loader.min.js', DEP.monacoCdnBase + '/vs/loader.min.js' ]); } catch (e) { // Fallback mode state.fallbackMode = true; els.monacoLoading.style.display = 'none'; els.fallbackEditor.style.display = 'flex'; toast('Monaco blocked → fallback editor enabled', 'warn', 2200); return; } // Determine base for worker and require config state.monacoBase = loaderSrc.includes(DEP.monacoLocalBase) ? DEP.monacoLocalBase : DEP.monacoCdnBase; if (!loaderSrc.includes(DEP.monacoLocalBase)) { state.usingCdn = true; els.cdnBanner.classList.add('show'); } // Worker loader: data: worker imports workerMain from the chosen base window.MonacoEnvironment = { getWorkerUrl: function(workerId, label) { const src = ` self.MonacoEnvironment = { baseUrl: "${state.monacoBase}/" }; importScripts("${state.monacoBase}/vs/base/worker/workerMain.min.js"); `; return "data:text/javascript;charset=utf-8," + encodeURIComponent(src); } }; // Configure require if (typeof require === 'undefined') { state.fallbackMode = true; els.monacoLoading.style.display = 'none'; els.fallbackEditor.style.display = 'flex'; toast('Monaco loader missing require() → fallback editor', 'warn', 2200); return; } els.loadLine.textContent = 'Initializing Monaco…'; require.config({ paths: { 'vs': state.monacoBase + '/vs' } }); await new Promise((resolve, reject) => { require(['vs/editor/editor.main'], () => resolve(), reject); }); const monaco = window.monaco; createMonacoTheme(monaco); state.editor = monaco.editor.create(els.monacoEl, { theme: 'kaixuNebula', fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace', fontSize: 12.5, lineNumbers: 'on', minimap: { enabled: false }, scrollBeyondLastLine: false, smoothScrolling: true, cursorBlinking: 'smooth', cursorSmoothCaretAnimation: 'on', renderWhitespace: 'selection', wordWrap: 'off', automaticLayout: true, padding: { top: 10, bottom: 10 } }); els.monacoLoading.style.display = 'none'; toast('Monaco ready', 'ok', 1200); }; // ---------- Modals ---------- const openModal = (m) => { m.classList.add('show'); m.setAttribute('aria-hidden','false'); }; const closeModal = (m) => { m.classList.remove('show'); m.setAttribute('aria-hidden','true'); }; // ---------- Console toggle ---------- const toggleConsole = (on=null) => { const show = on == null ? (els.consolePane.style.display === 'none') : !!on; els.consolePane.style.display = show ? '' : 'none'; if (show) els.consoleBody.scrollTop = els.consoleBody.scrollHeight; }; // ---------- File operations ---------- const addFile = async () => { const name = prompt('New file path (e.g., extra.js, components/ui.css):', 'extra.js'); if (!name) return; const path = sanitizePath(name); if (state.project.files.some(f => f.path === path)) { toast('File already exists', 'warn'); return; } const f = { path, language: inferLang(path), content: '' }; state.project.files.push(f); state.project.updatedAt = now(); await persistProject('File added'); await loadProjectIntoEditor(state.project.id, { keepActiveFile: true }); setActiveModel(path); toast('File added', 'ok'); }; const renameActiveFile = async () => { const current = state.activeFile; const f = getFile(current); if (!f) return; const name = prompt('Rename file', f.path); if (!name) return; const path = sanitizePath(name); if (path === f.path) return; if (state.project.files.some(x => x.path === path)) { toast('That name already exists', 'warn'); return; } // Update file record f.path = path; f.language = inferLang(path); // Update Monaco model mapping if (!state.fallbackMode) { const m = state.modelByPath.get(current); state.modelByPath.delete(current); if (m) state.modelByPath.set(path, m); } state.activeFile = path; state.project.updatedAt = now(); await persistProject('File renamed'); renderFileTabs(); toast('File renamed', 'ok'); }; const deleteActiveFile = async () => { const current = state.activeFile; const files = state.project.files || []; if (files.length <= 1) { toast('Cannot delete the last file', 'warn'); return; } if (!confirm('Delete file "' + current + '"?')) return; // Remove file const idx = files.findIndex(f => f.path === current); if (idx < 0) return; files.splice(idx, 1); if (!state.fallbackMode) { const m = state.modelByPath.get(current); if (m) { try { m.dispose(); } catch(_) {} } state.modelByPath.delete(current); } state.activeFile = files[Math.max(0, idx-1)]?.path || files[0].path; state.project.updatedAt = now(); await persistProject('File deleted'); await loadProjectIntoEditor(state.project.id, { keepActiveFile: true }); toast('File deleted', 'ok'); }; const resetProject = async () => { if (!confirm('Reset this project to templates? This overwrites all current files.')) return; const name = state.project.name; const p = defaultProject(name); p.id = state.project.id; p.createdAt = state.project.createdAt; p.updatedAt = now(); await idbPut('projects', p); await loadProjectIntoEditor(p.id); toast('Project reset', 'ok'); }; const copyActiveFile = async () => { try { await navigator.clipboard.writeText(getActiveContent() || ''); toast('Copied', 'ok', 900); } catch (_) { toast('Clipboard blocked', 'warn', 1400); } }; const formatActive = async () => { if (state.fallbackMode) { toast('Formatting requires Monaco', 'warn'); return; } try { const act = state.editor.getAction('editor.action.formatDocument'); if (act) await act.run(); toast('Formatted', 'ok', 900); } catch (_) { toast('Format unavailable (workers blocked)', 'warn', 1400); } }; // ---------- Global keybindings ---------- window.addEventListener('keydown', async (e) => { const isMac = navigator.platform.toUpperCase().includes('MAC'); const cmdOrCtrl = isMac ? e.metaKey : e.ctrlKey; if (cmdOrCtrl && e.key === 'Enter') { e.preventDefault(); renderNow(true); } if (cmdOrCtrl && (e.key === 's' || e.key === 'S')) { e.preventDefault(); await createSnapshot('Quick save'); } if (cmdOrCtrl && e.key === '`') { e.preventDefault(); toggleConsole(); } if (e.key === 'Escape') { // Close topmost modal if open const modals = [els.validateModal, els.historyModal, els.projectsModal, els.helpModal]; for (const m of modals) { if (m.classList.contains('show')) { closeModal(m); break; } } } }); // ---------- Wire UI events ---------- els.projectsBtn.addEventListener('click', async () => { await listProjects(); openModal(els.projectsModal); els.newProjectName.focus(); }); els.closeProjectsBtn.addEventListener('click', () => closeModal(els.projectsModal)); els.createProjectBtn.addEventListener('click', async () => { const name = sanitizeName(els.newProjectName.value || 'New Project'); const p = defaultProject(name); await idbPut('projects', p); els.newProjectName.value = ''; await listProjects(); await loadProjectIntoEditor(p.id); closeModal(els.projectsModal); toast('Project created', 'ok'); }); els.historyBtn.addEventListener('click', async () => { await renderHistoryList(); openModal(els.historyModal); els.snapshotLabel.focus(); }); els.closeHistoryBtn.addEventListener('click', () => closeModal(els.historyModal)); els.snapshotBtn.addEventListener('click', async () => { await createSnapshot(els.snapshotLabel.value || ''); els.snapshotLabel.value = ''; await renderHistoryList(); }); els.deleteSnapshotsBtn.addEventListener('click', async () => { if (!confirm('Delete all snapshots for this project?')) return; const snaps = await listSnapshots(state.project.id); for (const s of snaps) await idbDelete('snapshots', s.id); await renderHistoryList(); toast('Project history cleared', 'ok'); }); els.diffBtn.addEventListener('click', diffTwoSnapshots); els.consoleBtn.addEventListener('click', () => toggleConsole(true)); els.closeConsoleBtn.addEventListener('click', () => toggleConsole(false)); els.clearConsoleBtn.addEventListener('click', () => { clearConsole(); toast('Console cleared', 'ok', 900); }); els.copyConsoleBtn.addEventListener('click', async () => { const txt = state.console.map(l => `[${new Date(l.ts).toLocaleTimeString()}] ${l.level}: ${l.msg}`).join('\n'); try { await navigator.clipboard.writeText(txt); toast('Console copied', 'ok', 900); } catch(_) { toast('Clipboard blocked', 'warn'); } }); els.validateBtn.addEventListener('click', () => openModal(els.validateModal)); els.closeValidateBtn.addEventListener('click', () => closeModal(els.validateModal)); els.runValidateBtn.addEventListener('click', async () => { setSave('ok', 'Validating…'); const rep = await validateProject(); state.lastValidateReport = rep; renderValidateUI(rep); toast('Validation complete', rep.counts.error ? 'bad' : rep.counts.warn ? 'warn' : 'ok', 1400); }); els.copyValidateBtn.addEventListener('click', async () => { if (!state.lastValidateReport) return; const t = reportToText(state.lastValidateReport); try { await navigator.clipboard.writeText(t); toast('Report copied', 'ok'); } catch(_) { toast('Clipboard blocked', 'warn'); } }); els.exportEvidenceBtn.addEventListener('click', exportEvidencePack); els.helpBtn.addEventListener('click', () => openModal(els.helpModal)); els.closeHelpBtn.addEventListener('click', () => closeModal(els.helpModal)); els.openProjectsFromHelpBtn.addEventListener('click', async () => { closeModal(els.helpModal); await listProjects(); openModal(els.projectsModal); }); els.openValidateFromHelpBtn.addEventListener('click', async () => { closeModal(els.helpModal); openModal(els.validateModal); // run immediately els.runValidateBtn.click(); }); els.exportBtn.addEventListener('click', () => exportProjectZip(state.project)); els.exportToolBtn.addEventListener('click', exportToolPack); els.importBtn.addEventListener('click', () => els.filePicker.click()); els.filePicker.addEventListener('change', async (e) => { const f = e.target.files && e.target.files[0]; els.filePicker.value = ''; if (!f) return; const name = (f.name || '').toLowerCase(); try { if (name.endsWith('.zip')) { const p = await importZipAsProject(f); if (p) await loadProjectIntoEditor(p.id); } else if (name.endsWith('.html') || name.endsWith('.htm') || f.type.includes('text/html')) { await importHtmlToActive(f); } else { toast('Unsupported file type', 'warn'); } } catch (err) { toast('Import failed', 'bad', 1800); } }); els.resetBtn.addEventListener('click', resetProject); els.addFileBtn.addEventListener('click', addFile); els.renameFileBtn.addEventListener('click', renameActiveFile); els.deleteFileBtn.addEventListener('click', deleteActiveFile); els.formatBtn.addEventListener('click', formatActive); els.copyBtn.addEventListener('click', copyActiveFile); els.debounceMs.addEventListener('change', async () => { const ms = clamp(Number(els.debounceMs.value || 0), 0, 5000); state.project.settings.debounce = ms; await persistProject('Settings saved'); }); els.autoToggle.addEventListener('click', async () => { state.project.settings.auto = !state.project.settings.auto; els.autoToggle.dataset.on = String(state.project.settings.auto); await persistProject('Settings saved'); if (state.project.settings.auto) renderNow(true); }); els.modeToggle.addEventListener('click', async () => { state.project.settings.fullMode = !state.project.settings.fullMode; els.modeToggle.dataset.on = String(state.project.settings.fullMode); els.modeLabel.textContent = state.project.settings.fullMode ? 'Full' : 'Safe'; await persistProject('Settings saved'); renderNow(true); }); els.isolationToggle.addEventListener('click', async () => { state.project.settings.isolated = !state.project.settings.isolated; els.isolationToggle.dataset.on = String(state.project.settings.isolated); await persistProject('Settings saved'); renderNow(true); }); els.instrumentToggle.addEventListener('click', async () => { state.project.settings.instrument = !state.project.settings.instrument; els.instrumentToggle.dataset.on = String(state.project.settings.instrument); await persistProject('Settings saved'); renderNow(true); }); els.networkSel.addEventListener('change', async () => { state.project.settings.network = els.networkSel.value; await persistProject('Settings saved'); renderNow(true); }); els.deviceSel.addEventListener('change', async () => { state.project.settings.device = els.deviceSel.value; applyViewport(); await persistProject('Settings saved'); }); els.scaleSel.addEventListener('change', async () => { state.project.settings.scale = els.scaleSel.value; applyViewport(); await persistProject('Settings saved'); }); els.openBtn.addEventListener('click', openPreviewTab); els.reloadBtn.addEventListener('click', () => renderNow(true)); els.renderBtn.addEventListener('click', () => renderNow(true)); // Delete all data els.nukeAllBtn.addEventListener('click', async () => { if (!confirm('Delete ALL local projects + snapshots? This cannot be undone.')) return; await idbClear('projects'); await idbClear('snapshots'); await idbClear('kv'); toast('Local data wiped', 'ok', 1600); await bootstrapProject(); closeModal(els.projectsModal); }); // ---------- App bootstrap ---------- const bootstrap = async () => { setSave('ok', 'Starting…'); // Open DB try { DB.db = await idbOpen(); } catch (e) { // Storage blocked: keep running with in-memory project (no persistence). toast('IndexedDB blocked — running without persistence', 'warn', 2500); DB.db = null; } // Migration (only if DB is available) if (DB.db) { await migrateLegacy(); } // Ensure at least one project if (DB.db) { await bootstrapProject(); } else { // In-memory mode state.project = defaultProject('Ephemeral Project'); els.projectPill.textContent = 'Project: ' + state.project.name + ' (no persistence)'; await initEditorIfNeeded(); renderFileTabs(); if (state.fallbackMode) { els.fallbackTextarea.value = getFile('index.html').content; updateStats(); } else { // Create models const monaco = window.monaco; for (const f of state.project.files) { const model = monaco.editor.createModel(String(f.content || ''), f.language || inferLang(f.path)); state.modelByPath.set(f.path, model); } state.editor.setModel(state.modelByPath.get('index.html')); state.editor.onDidChangeModelContent(() => { applyEditorToProject(); scheduleRender(); updateStats(); }); } applyViewport(); renderNow(true); } setSave('ok', 'Ready'); }; bootstrap().catch((e) => { setSave('bad', 'Startup failed'); toast('Startup failed', 'bad', 2200); // Fallback: show textarea editor anyway state.fallbackMode = true; els.monacoLoading.style.display = 'none'; els.fallbackEditor.style.display = 'flex'; els.fallbackTextarea.value = TEMPLATES['index.html']; els.fallbackTextarea.oninput = () => updateStats(); updateStats(); }); })(); </script> <script src="/sites/skaixu-ide/s0l26/shared-runtime.js"></script> </body> </html>