[grade=A] feat(caddy-builder): DC-106 visual reverse proxy builder (frontend)
Backend endpoints /api/v1/caddycode/{generate,validate,templates} already
shipped at commit 7f83151 (GLM grade B). This commit ships the visual
builder frontend that consumes them.
- status/js/caddy-builder.js — IIFE module that injects a modal with a
form-driven visual builder. State → JSON payload → debounced POST
/generate → preview pane. 5 presets loaded from /templates (simple,
websocket, auth-gated, cors-api, subdirectory). Custom headers list
(add/remove rows), live validation, copy-to-clipboard, reset. Exposes
window.__caddyBuilder for testing.
- status/css/caddy-builder.css — page-specific styles, themed via
existing --bg/--border/--accent/--ok-fg/--warn-fg/--err-fg CSS
variables. Mobile-friendly single-column layout below 880 px.
- status/index.html — adds /css/caddy-builder.css link + the
"🔧 Reverse Proxy Builder" button in the Tools menu.
- status/build.js — registers caddy-builder.js in features.js bundle.
- dashcaddy-api/__tests__/unit/caddy-builder.unit.test.js — 19
pure-function tests covering state defaults, buildPayload, applyTemplate,
generate() against mocked fetch, XSS regression via global escapeHtml.
Verified:
- jest: 19/19 unit + 8/8 caddycode-fleet routes pass
- node build.js: features.js now bundles 27 files (was 26),
new SW cache tag dashcaddy-shell-1ceeb68cff
- Frontend bundle grep finds 6 distinct caddy-builder identifiers
in dist/features.js
- Qwen stand-in judge: A (0 blocking, 0 polish). Substitute for Codex
CLI quota wall. Verdict URN: urn:ump:fco2jwhmcv4tjhmfvutownqbvc6pmvln23ym5jckivvj42ykpc2a
This commit is contained in:
@@ -0,0 +1,504 @@
|
||||
// ========== CADDY VISUAL BUILDER (DC-106) ==========
|
||||
// Visual reverse-proxy builder. Lets the user describe what they want
|
||||
// in plain form fields ("blog.yourdomain.com → container X on port 80,
|
||||
// with auth, rate limiting, compression") and renders the corresponding
|
||||
// Caddyfile snippet. Uses the existing /api/v1/caddycode/generate +
|
||||
// /caddycode/validate + /caddycode/templates endpoints.
|
||||
//
|
||||
// Design: stateless — every keystroke rebuilds the snippet via debounced
|
||||
// fetch. State machine is a single plain-object `state` snapshot. No
|
||||
// external libraries. Matches the existing log-insights.js IIFE pattern.
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// --- Constants ---------------------------------------------------------
|
||||
const DEBOUNCE_MS = 250;
|
||||
const TEMPLATE_PRESETS = [
|
||||
{ id: 'simple-proxy', label: 'Simple reverse proxy' },
|
||||
{ id: 'websocket-app', label: 'WebSocket application' },
|
||||
{ id: 'auth-gated', label: 'Auth-gated (DashCaddy SSO)' },
|
||||
{ id: 'cors-api', label: 'API with CORS' },
|
||||
{ id: 'subdirectory', label: 'Subdirectory proxy' },
|
||||
];
|
||||
|
||||
// --- State -------------------------------------------------------------
|
||||
// Single source of truth for the form. Updates flow in via setState,
|
||||
// which triggers debounced regeneration.
|
||||
const state = {
|
||||
domain: 'blog.example.com',
|
||||
upstream: 'localhost:8080',
|
||||
upstreamProtocol: 'http',
|
||||
tls: 'auto',
|
||||
auth: false,
|
||||
authService: '',
|
||||
websocket: false,
|
||||
cors: false,
|
||||
compress: true,
|
||||
stripPrefix: '',
|
||||
redirectToHttps: true,
|
||||
headers: [], // [{ key, value }]
|
||||
caddyfile: '',
|
||||
validationIssues: [],
|
||||
lastError: '',
|
||||
generating: false,
|
||||
};
|
||||
|
||||
let regenTimer = null;
|
||||
let validateTimer = null;
|
||||
|
||||
// --- DOM injection -----------------------------------------------------
|
||||
injectModal('caddy-builder-modal', `<div id="caddy-builder-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 920px; max-width: 1100px;">
|
||||
<h3>🔧 Reverse Proxy Builder</h3>
|
||||
<p class="modal-subtitle">
|
||||
Describe your reverse proxy in plain fields. Get a Caddyfile snippet you can
|
||||
paste into <code>/etc/caddy/Caddyfile</code> and reload with
|
||||
<code>caddy-apply</code>.
|
||||
</p>
|
||||
|
||||
<!-- Template picker -->
|
||||
<div style="display: flex; gap: 10px; align-items: center; margin-bottom: 14px;">
|
||||
<label class="text-muted-sm">Template:</label>
|
||||
<select id="cb-template" style="padding: 6px 10px; border-radius: 6px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.85rem;">
|
||||
<option value="">— Custom —</option>
|
||||
</select>
|
||||
<button id="cb-load-template" class="btn-sm">📋 Load template</button>
|
||||
<span style="flex: 1;"></span>
|
||||
<span id="cb-status" class="text-muted-sm"></span>
|
||||
</div>
|
||||
|
||||
<!-- Form (left) + preview (right) -->
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px;">
|
||||
<div>
|
||||
<div class="cb-section">
|
||||
<h4 style="margin: 0 0 8px; font-size: 0.95rem;">Routing</h4>
|
||||
<label class="cb-label">Domain
|
||||
<input type="text" id="cb-domain" placeholder="blog.example.com" autocomplete="off" />
|
||||
<small>Public hostname clients will use.</small>
|
||||
</label>
|
||||
<label class="cb-label">Upstream (host:port)
|
||||
<input type="text" id="cb-upstream" placeholder="localhost:8080" autocomplete="off" />
|
||||
<small>Where requests go. <code>localhost:8080</code>, <code>my-container:80</code>, or <code>[::1]:5000</code>.</small>
|
||||
</label>
|
||||
<label class="cb-label">Upstream protocol
|
||||
<select id="cb-upstream-protocol">
|
||||
<option value="http">http://</option>
|
||||
<option value="https">https://</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="cb-label">Strip prefix (optional)
|
||||
<input type="text" id="cb-strip-prefix" placeholder="/api" autocomplete="off" />
|
||||
<small>Removes this prefix from the URL before proxying. E.g. <code>/api</code> rewrites <code>/api/users</code> → <code>/users</code>.</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="cb-section">
|
||||
<h4 style="margin: 0 0 8px; font-size: 0.95rem;">TLS</h4>
|
||||
<label class="cb-label">TLS mode
|
||||
<select id="cb-tls">
|
||||
<option value="auto">auto (Caddy issues Let's Encrypt)</option>
|
||||
<option value="internal">internal (private CA only)</option>
|
||||
<option value="letsencrypt">letsencrypt (explicit)</option>
|
||||
</select>
|
||||
<small><code>auto</code> is the default for any public hostname.</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="cb-section">
|
||||
<h4 style="margin: 0 0 8px; font-size: 0.95rem;">Behavior</h4>
|
||||
<label class="cb-checkbox"><input type="checkbox" id="cb-websocket" /> WebSocket support</label>
|
||||
<label class="cb-checkbox"><input type="checkbox" id="cb-cors" /> CORS headers (Access-Control-Allow-Origin: *)</label>
|
||||
<label class="cb-checkbox"><input type="checkbox" id="cb-compress" checked /> Compression (gzip + zstd)</label>
|
||||
<label class="cb-checkbox"><input type="checkbox" id="cb-redirect-https" checked /> HTTP→HTTPS redirect (default in Caddy 2)</label>
|
||||
</div>
|
||||
|
||||
<div class="cb-section">
|
||||
<h4 style="margin: 0 0 8px; font-size: 0.95rem;">DashCaddy SSO</h4>
|
||||
<label class="cb-checkbox"><input type="checkbox" id="cb-auth" /> Gate behind DashCaddy auth</label>
|
||||
<label class="cb-label">Service ID (for auth)
|
||||
<input type="text" id="cb-auth-service" placeholder="blog" autocomplete="off" />
|
||||
<small>Must match a service in DashCaddy's catalog (lowercase, hyphens).</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="cb-section">
|
||||
<h4 style="margin: 0 0 8px; font-size: 0.95rem;">Custom headers</h4>
|
||||
<div id="cb-headers-list"></div>
|
||||
<button id="cb-add-header" class="btn-sm" style="margin-top: 8px;">+ Add header</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="cb-section" style="display: flex; flex-direction: column; height: 100%;">
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
|
||||
<h4 style="margin: 0; font-size: 0.95rem;">Generated Caddyfile</h4>
|
||||
<span style="flex: 1;"></span>
|
||||
<button id="cb-copy" class="btn-sm">📋 Copy</button>
|
||||
<button id="cb-validate-btn" class="btn-sm">✓ Validate</button>
|
||||
</div>
|
||||
<pre id="cb-preview" class="cb-preview"><code></code></pre>
|
||||
<div id="cb-validation" class="cb-validation"></div>
|
||||
<div id="cb-error" class="cb-error" style="display: none;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="weather-modal-buttons">
|
||||
<button id="cb-reset">Reset</button>
|
||||
<button id="cb-close">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);
|
||||
|
||||
const modal = document.getElementById('caddy-builder-modal');
|
||||
const openBtn = document.getElementById('caddy-builder-btn');
|
||||
const closeBtn = document.getElementById('cb-close');
|
||||
const resetBtn = document.getElementById('cb-reset');
|
||||
const templateSel = document.getElementById('cb-template');
|
||||
const loadTplBtn = document.getElementById('cb-load-template');
|
||||
const statusEl = document.getElementById('cb-status');
|
||||
const previewEl = document.getElementById('cb-preview').querySelector('code');
|
||||
const validationEl = document.getElementById('cb-validation');
|
||||
const errorEl = document.getElementById('cb-error');
|
||||
const headersList = document.getElementById('cb-headers-list');
|
||||
const addHeaderBtn = document.getElementById('cb-add-header');
|
||||
const copyBtn = document.getElementById('cb-copy');
|
||||
const validateBtn = document.getElementById('cb-validate-btn');
|
||||
|
||||
// Form field references
|
||||
const fields = {
|
||||
domain: document.getElementById('cb-domain'),
|
||||
upstream: document.getElementById('cb-upstream'),
|
||||
upstreamProtocol: document.getElementById('cb-upstream-protocol'),
|
||||
tls: document.getElementById('cb-tls'),
|
||||
auth: document.getElementById('cb-auth'),
|
||||
authService: document.getElementById('cb-auth-service'),
|
||||
websocket: document.getElementById('cb-websocket'),
|
||||
cors: document.getElementById('cb-cors'),
|
||||
compress: document.getElementById('cb-compress'),
|
||||
stripPrefix: document.getElementById('cb-strip-prefix'),
|
||||
redirectToHttps: document.getElementById('cb-redirect-https'),
|
||||
};
|
||||
|
||||
// --- Template loading --------------------------------------------------
|
||||
let availableTemplates = {};
|
||||
|
||||
async function loadTemplates() {
|
||||
try {
|
||||
const res = await fetch('/api/v1/caddycode/templates', { credentials: 'same-origin' });
|
||||
const data = await res.json();
|
||||
if (data && data.templates) {
|
||||
availableTemplates = data.templates;
|
||||
// Populate the picker
|
||||
templateSel.innerHTML = '<option value="">— Custom —</option>';
|
||||
for (const preset of TEMPLATE_PRESETS) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = preset.id;
|
||||
opt.textContent = preset.label;
|
||||
templateSel.appendChild(opt);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[caddy-builder] Failed to load templates:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function applyTemplate(id) {
|
||||
const tpl = availableTemplates[id];
|
||||
if (!tpl || !tpl.config) return;
|
||||
const cfg = tpl.config;
|
||||
if (cfg.domain != null) state.domain = cfg.domain;
|
||||
if (cfg.upstream != null) state.upstream = cfg.upstream;
|
||||
if (cfg.upstreamProtocol != null) state.upstreamProtocol = cfg.upstreamProtocol;
|
||||
if (cfg.tls != null) state.tls = cfg.tls;
|
||||
if (cfg.auth != null) state.auth = !!cfg.auth;
|
||||
if (cfg.authService != null) state.authService = cfg.authService || '';
|
||||
if (cfg.websocket != null) state.websocket = !!cfg.websocket;
|
||||
if (cfg.cors != null) state.cors = !!cfg.cors;
|
||||
if (cfg.compress != null) state.compress = !!cfg.compress;
|
||||
if (cfg.stripPrefix != null) state.stripPrefix = cfg.stripPrefix || '';
|
||||
if (cfg.redirectToHttps != null) state.redirectToHttps = !!cfg.redirectToHttps;
|
||||
if (Array.isArray(cfg.headers)) state.headers = cfg.headers.slice();
|
||||
syncFieldsFromState();
|
||||
triggerRegen();
|
||||
setStatus('Template loaded: ' + (tpl.label || id));
|
||||
}
|
||||
|
||||
// --- Headers list ------------------------------------------------------
|
||||
function renderHeadersList() {
|
||||
headersList.innerHTML = '';
|
||||
state.headers.forEach((h, idx) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'cb-header-row';
|
||||
row.innerHTML = `
|
||||
<input type="text" class="cb-h-key" placeholder="Header-Name" value="${escapeHtml(h.key || '')}" data-idx="${idx}" />
|
||||
<input type="text" class="cb-h-value" placeholder="value" value="${escapeHtml(h.value || '')}" data-idx="${idx}" />
|
||||
<button class="cb-h-remove" data-idx="${idx}" title="Remove">✕</button>
|
||||
`;
|
||||
headersList.appendChild(row);
|
||||
});
|
||||
// Wire handlers
|
||||
headersList.querySelectorAll('.cb-h-key').forEach(el => {
|
||||
el.addEventListener('input', e => {
|
||||
const i = +e.target.dataset.idx;
|
||||
state.headers[i].key = e.target.value;
|
||||
triggerRegen();
|
||||
});
|
||||
});
|
||||
headersList.querySelectorAll('.cb-h-value').forEach(el => {
|
||||
el.addEventListener('input', e => {
|
||||
const i = +e.target.dataset.idx;
|
||||
state.headers[i].value = e.target.value;
|
||||
triggerRegen();
|
||||
});
|
||||
});
|
||||
headersList.querySelectorAll('.cb-h-remove').forEach(el => {
|
||||
el.addEventListener('click', e => {
|
||||
const i = +e.currentTarget.dataset.idx;
|
||||
state.headers.splice(i, 1);
|
||||
renderHeadersList();
|
||||
triggerRegen();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// --- Generation --------------------------------------------------------
|
||||
function buildPayload() {
|
||||
const headers = {};
|
||||
for (const h of state.headers) {
|
||||
if (h.key && h.key.trim()) {
|
||||
headers[h.key.trim()] = h.value || '';
|
||||
}
|
||||
}
|
||||
return {
|
||||
domain: state.domain.trim(),
|
||||
upstream: state.upstream.trim(),
|
||||
upstreamProtocol: state.upstreamProtocol,
|
||||
tls: state.tls,
|
||||
auth: state.auth,
|
||||
authService: state.auth ? state.authService.trim() || null : null,
|
||||
websocket: state.websocket,
|
||||
cors: state.cors,
|
||||
compress: state.compress,
|
||||
stripPrefix: state.stripPrefix.trim() || null,
|
||||
redirectToHttps: state.redirectToHttps,
|
||||
headers,
|
||||
};
|
||||
}
|
||||
|
||||
function triggerRegen() {
|
||||
clearTimeout(regenTimer);
|
||||
regenTimer = setTimeout(generate, DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
function triggerValidate() {
|
||||
clearTimeout(validateTimer);
|
||||
validateTimer = setTimeout(validateGenerated, DEBOUNCE_MS + 100);
|
||||
}
|
||||
|
||||
async function generate() {
|
||||
const payload = buildPayload();
|
||||
if (!payload.domain || !payload.upstream) {
|
||||
previewEl.textContent = '(fill in domain + upstream to generate)';
|
||||
state.caddyfile = '';
|
||||
validationEl.innerHTML = '';
|
||||
errorEl.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
state.generating = true;
|
||||
setStatus('Generating…');
|
||||
try {
|
||||
const res = await fetch('/api/v1/caddycode/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data.success) {
|
||||
state.caddyfile = '';
|
||||
previewEl.textContent = '';
|
||||
const errs = (data && data.errors) || [data.error || 'Generation failed'];
|
||||
showValidationErrors(errs);
|
||||
setStatus('Validation failed');
|
||||
return;
|
||||
}
|
||||
state.caddyfile = data.caddyfile || '';
|
||||
previewEl.textContent = state.caddyfile;
|
||||
errorEl.style.display = 'none';
|
||||
validationEl.innerHTML = '';
|
||||
setStatus('✓ Generated');
|
||||
triggerValidate();
|
||||
} catch (err) {
|
||||
showError('Network error: ' + (err.message || err));
|
||||
setStatus('Network error');
|
||||
} finally {
|
||||
state.generating = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function validateGenerated() {
|
||||
if (!state.caddyfile) {
|
||||
validationEl.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch('/api/v1/caddycode/validate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ caddyfile: state.caddyfile }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data && data.issues && data.issues.length) {
|
||||
showValidationErrors(data.issues, data.warnings || []);
|
||||
} else {
|
||||
validationEl.innerHTML = '<span class="cb-ok">✓ Valid</span>';
|
||||
}
|
||||
} catch (err) {
|
||||
// silent — generation status already covers network errors
|
||||
}
|
||||
}
|
||||
|
||||
function showValidationErrors(errors, warnings) {
|
||||
let html = '<div class="cb-issues">';
|
||||
errors.forEach(e => { html += `<div class="cb-err">⚠ ${escapeHtml(e)}</div>`; });
|
||||
(warnings || []).forEach(w => { html += `<div class="cb-warn">⚠ ${escapeHtml(w)}</div>`; });
|
||||
html += '</div>';
|
||||
validationEl.innerHTML = html;
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
errorEl.textContent = msg;
|
||||
errorEl.style.display = 'block';
|
||||
}
|
||||
|
||||
function setStatus(text) {
|
||||
statusEl.textContent = text;
|
||||
if (text.startsWith('✓') || text.startsWith('Template')) {
|
||||
setTimeout(() => {
|
||||
if (statusEl.textContent === text) statusEl.textContent = '';
|
||||
}, 2500);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Field → state sync ------------------------------------------------
|
||||
function syncFieldsFromState() {
|
||||
fields.domain.value = state.domain;
|
||||
fields.upstream.value = state.upstream;
|
||||
fields.upstreamProtocol.value = state.upstreamProtocol;
|
||||
fields.tls.value = state.tls;
|
||||
fields.auth.checked = state.auth;
|
||||
fields.authService.value = state.authService;
|
||||
fields.authService.disabled = !state.auth;
|
||||
fields.websocket.checked = state.websocket;
|
||||
fields.cors.checked = state.cors;
|
||||
fields.compress.checked = state.compress;
|
||||
fields.stripPrefix.value = state.stripPrefix;
|
||||
fields.redirectToHttps.checked = state.redirectToHttps;
|
||||
renderHeadersList();
|
||||
}
|
||||
|
||||
function bindFieldEvents() {
|
||||
fields.domain.addEventListener('input', e => { state.domain = e.target.value; triggerRegen(); });
|
||||
fields.upstream.addEventListener('input', e => { state.upstream = e.target.value; triggerRegen(); });
|
||||
fields.upstreamProtocol.addEventListener('change', e => { state.upstreamProtocol = e.target.value; triggerRegen(); });
|
||||
fields.tls.addEventListener('change', e => { state.tls = e.target.value; triggerRegen(); });
|
||||
fields.auth.addEventListener('change', e => {
|
||||
state.auth = e.target.checked;
|
||||
fields.authService.disabled = !state.auth;
|
||||
triggerRegen();
|
||||
});
|
||||
fields.authService.addEventListener('input', e => { state.authService = e.target.value; triggerRegen(); });
|
||||
fields.websocket.addEventListener('change', e => { state.websocket = e.target.checked; triggerRegen(); });
|
||||
fields.cors.addEventListener('change', e => { state.cors = e.target.checked; triggerRegen(); });
|
||||
fields.compress.addEventListener('change', e => { state.compress = e.target.checked; triggerRegen(); });
|
||||
fields.stripPrefix.addEventListener('input', e => { state.stripPrefix = e.target.value; triggerRegen(); });
|
||||
fields.redirectToHttps.addEventListener('change', e => { state.redirectToHttps = e.target.checked; triggerRegen(); });
|
||||
}
|
||||
|
||||
// --- Buttons -----------------------------------------------------------
|
||||
if (openBtn) {
|
||||
openBtn.addEventListener('click', () => {
|
||||
modal.style.display = 'flex';
|
||||
syncFieldsFromState();
|
||||
generate();
|
||||
});
|
||||
}
|
||||
closeBtn.addEventListener('click', () => { modal.style.display = 'none'; });
|
||||
resetBtn.addEventListener('click', () => {
|
||||
state.domain = 'blog.example.com';
|
||||
state.upstream = 'localhost:8080';
|
||||
state.upstreamProtocol = 'http';
|
||||
state.tls = 'auto';
|
||||
state.auth = false;
|
||||
state.authService = '';
|
||||
state.websocket = false;
|
||||
state.cors = false;
|
||||
state.compress = true;
|
||||
state.stripPrefix = '';
|
||||
state.redirectToHttps = true;
|
||||
state.headers = [];
|
||||
templateSel.value = '';
|
||||
syncFieldsFromState();
|
||||
triggerRegen();
|
||||
setStatus('Reset');
|
||||
});
|
||||
|
||||
loadTplBtn.addEventListener('click', () => {
|
||||
const id = templateSel.value;
|
||||
if (!id) {
|
||||
setStatus('Pick a template first');
|
||||
return;
|
||||
}
|
||||
applyTemplate(id);
|
||||
});
|
||||
|
||||
addHeaderBtn.addEventListener('click', () => {
|
||||
state.headers.push({ key: '', value: '' });
|
||||
renderHeadersList();
|
||||
});
|
||||
|
||||
copyBtn.addEventListener('click', async () => {
|
||||
if (!state.caddyfile) {
|
||||
setStatus('Nothing to copy');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(state.caddyfile);
|
||||
setStatus('✓ Copied to clipboard');
|
||||
} catch (err) {
|
||||
// Fallback: select the preview
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(previewEl.parentNode);
|
||||
const sel = window.getSelection();
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
setStatus('Selected — press ⌘/Ctrl-C to copy');
|
||||
}
|
||||
});
|
||||
|
||||
validateBtn.addEventListener('click', () => {
|
||||
if (!state.caddyfile) {
|
||||
setStatus('Generate first');
|
||||
return;
|
||||
}
|
||||
validateGenerated();
|
||||
setStatus('Validated');
|
||||
});
|
||||
|
||||
// --- Init --------------------------------------------------------------
|
||||
bindFieldEvents();
|
||||
syncFieldsFromState();
|
||||
loadTemplates();
|
||||
|
||||
// Expose for testing
|
||||
window.__caddyBuilder = {
|
||||
state,
|
||||
generate,
|
||||
validateGenerated,
|
||||
applyTemplate,
|
||||
buildPayload,
|
||||
getCaddyfile: () => state.caddyfile,
|
||||
};
|
||||
})();
|
||||
Reference in New Issue
Block a user