SEO Config

Manage SEO JSON config and OG image tooling

SEO Config JSON

Fixed slug: seo-config (stored in JSON Configs). SVG is stored separately in Global Settings.

Developer helper

Fetch SEO config via internal JSON Config service.


        

AI helpers (SEO JSON)

Generate or improve pages entries with AI, then apply them into the seo-config JSON.

Add new entry from EJS view

<%- include('partials/llm-provider-model-picker', { providerInputId: 'seoAiProviderKey', modelInputId: 'seoAiModel', providerLabel: 'Provider', modelLabel: 'Model', showOpenRouterFetch: true, }) %>

              

Improve existing entry

<%- include('partials/llm-provider-model-picker', { providerInputId: 'seoAiImproveProviderKey', modelInputId: 'seoAiImproveModel', providerLabel: 'Provider', modelLabel: 'Model', showOpenRouterFetch: true, }) %>
Endpoints used: /api/admin/seo-config/ai/* and /api/admin/seo-config/pages/apply-entry

OG Share Image

Edit SVG, preview live, and generate a 1200×630 PNG.

Updates on change
Not available
const html = `\n${svgRaw || ''}`; iframe.srcdoc = html; } function debounce(fn, wait) { let t; return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), wait); }; } const debouncedPreview = debounce(() => { const svgRaw = document.getElementById('og-svg-raw').value; setSvgPreview(svgRaw); }, 200); async function loadSeoConfig() { const status = document.getElementById('seo-json-status'); status.textContent = 'Loading...'; try { const res = await fetch(`${API_BASE}/api/admin/seo-config`); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data?.error || 'Failed to load'); document.getElementById('seo-public-enabled').checked = Boolean(data?.config?.publicEnabled); document.getElementById('seo-cache-ttl').value = String(data?.config?.cacheTtlSeconds ?? 0); document.getElementById('seo-json-raw').value = String(data?.config?.jsonRaw || ''); document.getElementById('seo-updated-at').textContent = data?.config?.updatedAt ? `Updated: ${new Date(data.config.updatedAt).toLocaleString()}` : ''; document.getElementById('og-svg-raw').value = String(data?.og?.svgRaw || ''); document.getElementById('og-png-output').value = String(data?.og?.defaultPngOutputPath || 'public/og/og-default.png'); setSvgPreview(String(data?.og?.svgRaw || '')); status.textContent = 'Loaded'; document.getElementById('og-svg-status').textContent = ''; try { seoAiRefreshRoutesFromJson(document.getElementById('seo-json-raw').value); } catch {} await reloadOgPng(); return data; } catch (e) { status.textContent = e.message || 'Failed to load'; showToast(status.textContent, 'error'); } } function seoAiRefreshRoutesFromJson(jsonRaw) { let parsed; parsed = JSON.parse(String(jsonRaw || '')); const pages = parsed && typeof parsed === 'object' ? parsed.pages : null; const routes = pages && typeof pages === 'object' ? Object.keys(pages) : []; routes.sort(); const select = document.getElementById('seo-ai-existing-route'); const current = select.value; select.innerHTML = ''; for (const r of routes) { const opt = document.createElement('option'); opt.value = r; opt.textContent = r; select.appendChild(opt); } if (routes.includes(current)) select.value = current; } async function seoAiLoadViews() { const status = document.getElementById('seo-ai-views-status'); status.textContent = 'Loading views...'; try { const res = await fetch(`${API_BASE}/api/admin/seo-config/ai/views`); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data?.error || 'Failed to load views'); const views = Array.isArray(data?.views) ? data.views : []; const select = document.getElementById('seo-ai-view'); select.innerHTML = ''; for (const v of views) { const opt = document.createElement('option'); opt.value = v; opt.textContent = v; select.appendChild(opt); } status.textContent = `Loaded ${views.length} view(s)`; } catch (e) { status.textContent = e.message || 'Failed to load views'; showToast(status.textContent, 'error'); } } let seoAiProposed = null; function seoAiNormalizeRobotsForImprove(entry) { const robots = document.getElementById('seo-ai-robots-noindex'); if (!entry || typeof entry !== 'object') return entry; if (!robots) return entry; if (robots.checked) { entry.robots = 'noindex,follow'; } else { delete entry.robots; } return entry; } function seoAiTryPatchJsonTextareaRobots(routePath) { const jsonEl = document.getElementById('seo-json-raw'); const robots = document.getElementById('seo-ai-robots-noindex'); if (!jsonEl || !robots) return; try { const parsed = JSON.parse(String(jsonEl.value || '')); if (!parsed || typeof parsed !== 'object') return; if (!parsed.pages || typeof parsed.pages !== 'object') return; if (!routePath || typeof routePath !== 'string') return; if (!parsed.pages[routePath] || typeof parsed.pages[routePath] !== 'object') return; const entry = { ...(parsed.pages[routePath] || {}) }; if (robots.checked) { entry.robots = 'noindex,follow'; } else { delete entry.robots; } parsed.pages[routePath] = entry; jsonEl.value = JSON.stringify(parsed, null, 2); } catch { // ignore parse errors; user may be editing invalid JSON } } function seoAiSetProposed({ routePath, entry, model }) { seoAiProposed = { routePath, entry, model }; const pre = document.getElementById('seo-ai-proposed'); pre.textContent = JSON.stringify({ routePath, entry, model }, null, 2); } async function seoAiGenerateFromView() { const status = document.getElementById('seo-ai-generate-status'); status.textContent = 'Generating...'; try { const viewPath = document.getElementById('seo-ai-view').value; const routePath = document.getElementById('seo-ai-route').value; const providerKey = document.getElementById('seoAiProviderKey')?.value; const model = document.getElementById('seoAiModel')?.value; const res = await fetch(`${API_BASE}/api/admin/seo-config/ai/generate-entry`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ viewPath, routePath, providerKey: providerKey ? String(providerKey).trim() : undefined, model: model ? String(model).trim() : undefined, }), }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data?.error || 'Failed to generate entry'); seoAiSetProposed({ routePath: data.routePath, entry: data.entry, model: data.model }); status.textContent = `Generated (model: ${data.model || 'unknown'})`; showToast('Generated SEO entry'); } catch (e) { status.textContent = e.message || 'Failed to generate entry'; showToast(status.textContent, 'error'); } } async function seoAiImproveEntry() { const status = document.getElementById('seo-ai-improve-status'); status.textContent = 'Generating...'; try { const routePath = document.getElementById('seo-ai-existing-route').value; const instruction = document.getElementById('seo-ai-instruction').value; const providerKey = document.getElementById('seoAiImproveProviderKey')?.value; const model = document.getElementById('seoAiImproveModel')?.value; const res = await fetch(`${API_BASE}/api/admin/seo-config/ai/improve-entry`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ routePath, instruction, providerKey: providerKey ? String(providerKey).trim() : undefined, model: model ? String(model).trim() : undefined, }), }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data?.error || 'Failed to improve entry'); const nextEntry = seoAiNormalizeRobotsForImprove({ ...(data.entry || {}) }); seoAiProposed = { routePath: data.routePath, entry: nextEntry, model: data.model, source: 'improve' }; const pre = document.getElementById('seo-ai-proposed'); pre.textContent = JSON.stringify({ routePath: seoAiProposed.routePath, entry: seoAiProposed.entry, model: seoAiProposed.model }, null, 2); status.textContent = `Generated (model: ${data.model || 'unknown'})`; showToast('Generated improved SEO entry'); } catch (e) { status.textContent = e.message || 'Failed'; showToast(status.textContent, 'error'); } } async function seoAiApplyProposed() { const status = document.getElementById('seo-json-status'); if (!seoAiProposed?.routePath || !seoAiProposed?.entry) { showToast('No proposed entry to apply', 'error'); return; } if (seoAiProposed.source === 'improve') { seoAiProposed.entry = seoAiNormalizeRobotsForImprove({ ...(seoAiProposed.entry || {}) }); const pre = document.getElementById('seo-ai-proposed'); pre.textContent = JSON.stringify({ routePath: seoAiProposed.routePath, entry: seoAiProposed.entry, model: seoAiProposed.model }, null, 2); } status.textContent = 'Applying...'; try { const res = await fetch(`${API_BASE}/api/admin/seo-config/pages/apply-entry`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ routePath: seoAiProposed.routePath, entry: seoAiProposed.entry }), }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data?.error || 'Failed to apply entry'); const jsonRaw = data?.result?.jsonRaw; if (typeof jsonRaw === 'string') { document.getElementById('seo-json-raw').value = jsonRaw; seoAiRefreshRoutesFromJson(jsonRaw); } status.textContent = 'Applied'; showToast('Applied entry to SEO JSON'); } catch (e) { status.textContent = e.message || 'Failed to apply'; showToast(status.textContent, 'error'); } } async function saveSeoConfig() { const status = document.getElementById('seo-json-status'); status.textContent = 'Saving...'; try { const jsonRaw = document.getElementById('seo-json-raw').value; const publicEnabled = document.getElementById('seo-public-enabled').checked; const cacheTtlSeconds = Number(document.getElementById('seo-cache-ttl').value || 0); const res = await fetch(`${API_BASE}/api/admin/seo-config`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ jsonRaw, publicEnabled, cacheTtlSeconds }), }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data?.error || 'Failed to save'); status.textContent = 'Saved'; showToast('Saved SEO config'); document.getElementById('seo-updated-at').textContent = data?.config?.updatedAt ? `Updated: ${new Date(data.config.updatedAt).toLocaleString()}` : ''; } catch (e) { status.textContent = e.message || 'Failed to save'; showToast(status.textContent, 'error'); } } (() => { const robots = document.getElementById('seo-ai-robots-noindex'); const existingRoute = document.getElementById('seo-ai-existing-route'); if (!robots) return; robots.addEventListener('change', () => { const routePath = String(existingRoute?.value || seoAiProposed?.routePath || '').trim(); if (seoAiProposed?.source === 'improve' && seoAiProposed?.routePath === routePath && seoAiProposed?.entry) { seoAiProposed.entry = seoAiNormalizeRobotsForImprove({ ...(seoAiProposed.entry || {}) }); const pre = document.getElementById('seo-ai-proposed'); pre.textContent = JSON.stringify({ routePath: seoAiProposed.routePath, entry: seoAiProposed.entry, model: seoAiProposed.model }, null, 2); } seoAiTryPatchJsonTextareaRobots(routePath); }); })(); async function saveOgSvg() { const status = document.getElementById('og-svg-status'); status.textContent = 'Saving...'; try { const svgRaw = document.getElementById('og-svg-raw').value; const res = await fetch(`${API_BASE}/api/admin/seo-config/og/svg`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ svgRaw }), }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data?.error || 'Failed to save'); status.textContent = 'Saved'; showToast('Saved OG SVG'); } catch (e) { status.textContent = e.message || 'Failed to save'; showToast(status.textContent, 'error'); } } async function generatePng() { const status = document.getElementById('og-png-status'); status.textContent = 'Generating...'; try { const svgRaw = document.getElementById('og-svg-raw').value; const outputPath = document.getElementById('og-png-output').value; const res = await fetch(`${API_BASE}/api/admin/seo-config/og/generate-png`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ svgRaw, outputPath }), }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data?.error || 'Failed to generate'); const tool = data?.result?.tool ? ` (${data.result.tool})` : ''; status.textContent = `Generated${tool}`; showToast('Generated PNG'); await reloadOgPng(data?.result?.publicUrlPath); } catch (e) { status.textContent = e.message || 'Failed to generate'; showToast(status.textContent, 'error'); } } async function reloadOgPng(forcedPublicUrlPath) { const outputPath = document.getElementById('og-png-output').value; const publicPath = forcedPublicUrlPath || ('/' + String(outputPath || '').replace(/^public\//, '')); const img = document.getElementById('png-preview'); const empty = document.getElementById('png-preview-empty'); const url = publicPath.includes('?') ? publicPath + '&t=' + Date.now() : publicPath + '?t=' + Date.now(); img.onload = () => { img.classList.remove('hidden'); empty.classList.add('hidden'); }; img.onerror = () => { img.classList.add('hidden'); empty.classList.remove('hidden'); empty.textContent = 'Not available'; }; img.src = url; } async function copySnippet() { try { const text = document.getElementById('dev-snippet').textContent; await navigator.clipboard.writeText(text); showToast('Copied'); } catch (e) { showToast('Failed to copy', 'error'); } } function openAiModal() { document.getElementById('ai-status').textContent = ''; document.getElementById('ai-instruction').value = ''; document.getElementById('ai-model').value = ''; document.getElementById('ai-modal').classList.remove('hidden'); initLlmPickers(); } function closeAiModal() { document.getElementById('ai-modal').classList.add('hidden'); } async function runAiEdit() { const status = document.getElementById('ai-status'); const btn = document.getElementById('ai-run-btn'); status.textContent = 'Running...'; btn.disabled = true; btn.classList.add('opacity-50'); try { const svgRaw = document.getElementById('og-svg-raw').value; const instruction = document.getElementById('ai-instruction').value; const model = document.getElementById('ai-model').value; const providerKey = document.getElementById('ogAiProviderKey')?.value; const res = await fetch(`${API_BASE}/api/admin/seo-config/ai/edit-svg`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ svgRaw, instruction, providerKey: providerKey ? String(providerKey).trim() : undefined, model: model || undefined, }), }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data?.error || 'AI failed'); document.getElementById('og-svg-raw').value = String(data?.svgRaw || ''); setSvgPreview(String(data?.svgRaw || '')); status.textContent = `Done (model: ${data?.model || 'unknown'})`; showToast('AI updated SVG'); } catch (e) { status.textContent = e.message || 'AI failed'; showToast(status.textContent, 'error'); } finally { btn.disabled = false; btn.classList.remove('opacity-50'); } } document.getElementById('og-svg-raw').addEventListener('input', debouncedPreview); buildSnippet(); loadSeoConfig();