domma-cms 0.88.0 → 0.89.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/admin/css/admin.css +1 -1
- package/admin/js/lib/login-arrange.js +1 -1
- package/admin/js/templates/login.html +74 -52
- package/admin/js/views/login.js +1 -11
- package/package.json +1 -1
- package/plugins/blog/CLAUDE.md +6 -0
- package/plugins/blog/plugin.js +23 -0
- package/plugins/blog/plugin.json +2 -2
- package/plugins/blog/plugin.public.js +15 -2
- package/plugins/free-tier.lock.json +34 -14
- package/plugins/security/CLAUDE.md +7 -0
- package/plugins/security/admin/lib/resets.js +82 -0
- package/plugins/security/admin/lib/settings.js +1 -1
- package/plugins/security/admin/templates/security.html +3 -0
- package/plugins/security/admin/views/security.js +23 -10
- package/plugins/security/plugin.js +39 -1
- package/plugins/security/plugin.json +2 -2
- package/plugins/security/tests/security.test.js +34 -0
- package/plugins/seo/CLAUDE.md +48 -0
- package/plugins/seo/admin/lib/analyse.js +286 -0
- package/plugins/seo/admin/lib/redirects.js +163 -0
- package/plugins/seo/admin/lib/schema.js +113 -0
- package/plugins/seo/admin/lib/settings.js +141 -0
- package/plugins/seo/admin/templates/seo.html +224 -0
- package/plugins/seo/admin/views/seo.js +912 -0
- package/plugins/seo/config.js +7 -0
- package/plugins/seo/plugin.js +477 -0
- package/plugins/seo/plugin.json +64 -0
- package/plugins/seo/server/audit.js +106 -0
- package/plugins/seo/server/registry.js +57 -0
- package/plugins/seo/server/store.js +76 -0
- package/plugins/seo/tests/seo.test.js +192 -0
- package/server/routes/api/forms.js +3 -0
- package/server/routes/public.js +4 -3
- package/server/services/content.js +11 -0
- package/server/services/hooks.js +45 -1
- package/server/services/plugins.js +6 -0
- package/server/services/renderer.js +136 -55
- package/server/services/siteGitignore.js +114 -3
- package/server/services/sitemap.js +89 -18
|
@@ -0,0 +1,912 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SEO - the admin screen: Pages (the audit), Redirects and Sitemap, plus any
|
|
3
|
+
* tabs an extension (SEO Pro) adds. A page opens in a slideover: its search
|
|
4
|
+
* result and share card as they will look, the checks, and its fields.
|
|
5
|
+
* Settings in a slideover behind the banner cog; "Check again" beside it.
|
|
6
|
+
*
|
|
7
|
+
* The Tool upgrade path: domma-reactive state (M.observable / computed /
|
|
8
|
+
* effect, M.applyBindings), context menus on rows (right-click, ⋮, Shift+F10),
|
|
9
|
+
* help popovers, theme tokens, a sidebar badge (GET /count). Titles and
|
|
10
|
+
* descriptions are the site's own text but still reach the DOM through
|
|
11
|
+
* data-bind-text only, and rows are addressed by index.
|
|
12
|
+
* `#/plugins/seo/<tab>` opens a tab. Pure logic: ../lib/*.js.
|
|
13
|
+
*/
|
|
14
|
+
import {snippet} from '../lib/analyse.js';
|
|
15
|
+
import {checkRule, matchRedirect} from '../lib/redirects.js';
|
|
16
|
+
import {checkSettings, mergeDefaults, ORG_TYPES} from '../lib/settings.js';
|
|
17
|
+
|
|
18
|
+
const API = '/plugins/seo';
|
|
19
|
+
const ROUTE = /^#\/plugins\/seo(\/[a-z0-9-]+)?\/?(\?.*)?$/;
|
|
20
|
+
const TABS = ['overview', 'redirects', 'sitemap'];
|
|
21
|
+
const SIDEBAR_URL = '#/plugins/seo';
|
|
22
|
+
const GLYPH = {pass: '✓', warn: '!', fail: '✕', info: 'i'};
|
|
23
|
+
const CODE_TEXT = {301: '301 moved', 302: '302 for now', 307: '307 for now', 308: '308 moved', 410: '410 gone'};
|
|
24
|
+
|
|
25
|
+
let kit = null;
|
|
26
|
+
async function loadKit() {
|
|
27
|
+
if (kit) return kit;
|
|
28
|
+
const [api, chrome, help, tk] = await Promise.all([
|
|
29
|
+
import('/admin/js/api.js'),
|
|
30
|
+
import('/admin/js/lib/plugin-chrome.js').catch(() => ({})),
|
|
31
|
+
import('/admin/js/lib/help-popover.js').catch(() => ({})),
|
|
32
|
+
import('/admin/js/lib/tool-kit.js')
|
|
33
|
+
]);
|
|
34
|
+
kit = {
|
|
35
|
+
apiRequest: api.apiRequest,
|
|
36
|
+
addBannerAction: chrome.addBannerAction || (() => null),
|
|
37
|
+
helpPopovers: help.helpPopovers || (() => () => {}),
|
|
38
|
+
onceClosed: tk.onceClosed, openMenuAt: tk.openMenuAt, viewLifetime: tk.viewLifetime
|
|
39
|
+
};
|
|
40
|
+
return kit;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
44
|
+
function fragment(html) {
|
|
45
|
+
const t = document.createElement('template');
|
|
46
|
+
t.innerHTML = html.trim();
|
|
47
|
+
return t.content.firstElementChild;
|
|
48
|
+
}
|
|
49
|
+
async function copy(text, what) {
|
|
50
|
+
try { await navigator.clipboard.writeText(text); E.toast(`Copied ${what}.`, {type: 'success'}); }
|
|
51
|
+
catch { E.toast(`Could not copy - ${text}`, {type: 'warning', duration: 6000}); }
|
|
52
|
+
}
|
|
53
|
+
function refreshBadge() {
|
|
54
|
+
try { window.dispatchEvent(new CustomEvent('dm:sidebar-count', {detail: {url: SIDEBAR_URL}})); } catch { /* no sidebar */ }
|
|
55
|
+
}
|
|
56
|
+
const tone = (score) => (typeof score !== 'number' ? '' : score >= 85 ? 'good' : score >= 60 ? 'poor' : 'bad');
|
|
57
|
+
const day = (iso) => (iso ? new Date(iso).toLocaleDateString('en-GB', {day: 'numeric', month: 'short', year: 'numeric'}) : '');
|
|
58
|
+
const ago = (iso) => {
|
|
59
|
+
if (!iso) return '';
|
|
60
|
+
const m = Math.round((Date.now() - Date.parse(iso)) / 60_000);
|
|
61
|
+
if (m < 1) return 'just now';
|
|
62
|
+
if (m < 60) return `${m} min ago`;
|
|
63
|
+
const h = Math.round(m / 60);
|
|
64
|
+
if (h < 48) return `${h} hour${h === 1 ? '' : 's'} ago`;
|
|
65
|
+
return day(iso);
|
|
66
|
+
};
|
|
67
|
+
/** CSV with formula-leading cells defused. */
|
|
68
|
+
function toCsv(rows) {
|
|
69
|
+
const cell = (v) => {
|
|
70
|
+
let s = String(v ?? '');
|
|
71
|
+
if (/^[=+\-@\t\r]/.test(s)) s = `'${s}`;
|
|
72
|
+
return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
|
73
|
+
};
|
|
74
|
+
const cols = Object.keys(rows[0] || {});
|
|
75
|
+
return `${[cols.join(','), ...rows.map(r => cols.map(c => cell(r[c])).join(','))].join('\r\n')}\r\n`;
|
|
76
|
+
}
|
|
77
|
+
function download(name, text, type = 'text/csv;charset=utf-8') {
|
|
78
|
+
const url = URL.createObjectURL(new Blob([text], {type}));
|
|
79
|
+
const a = Object.assign(document.createElement('a'), {href: url, download: name});
|
|
80
|
+
document.body.appendChild(a); a.click(); a.remove();
|
|
81
|
+
setTimeout(() => URL.revokeObjectURL(url), 10_000);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const PAGE_ROW = `
|
|
85
|
+
<div class="sox-row" data-bind-data-row="id" tabindex="0" data-bind-title="path">
|
|
86
|
+
<span class="sox-pill" data-bind-data-tone="tone" data-bind-text="scoreText"></span>
|
|
87
|
+
<span class="sox-cell"><span data-bind-text="path"></span><small data-bind-text="sourceText"></small></span>
|
|
88
|
+
<span class="sox-cell sox-hide-sm" data-bind-text="title" data-bind-title="title"></span>
|
|
89
|
+
<span class="sox-cell sox-muted sox-hide-sm" data-bind-text="problems" data-bind-title="problems"></span>
|
|
90
|
+
<button type="button" class="sox-kebab" aria-label="Actions" title="Actions">⋮</button>
|
|
91
|
+
</div>`;
|
|
92
|
+
const SITE_ROW = `
|
|
93
|
+
<div class="sox-row" data-bind-data-row="id" tabindex="0">
|
|
94
|
+
<span class="sox-cell"><span data-bind-text="text" data-bind-title="text"></span><small data-bind-text="detail" data-bind-title="detail"></small></span>
|
|
95
|
+
<span class="sox-pill" data-bind-data-tone="tone" data-bind-text="badge"></span>
|
|
96
|
+
<button type="button" class="sox-kebab" aria-label="Actions" title="Actions">⋮</button>
|
|
97
|
+
</div>`;
|
|
98
|
+
const RULE_ROW = `
|
|
99
|
+
<div class="sox-row" data-bind-data-row="id" tabindex="0" data-bind-title="note">
|
|
100
|
+
<span class="sox-cell sox-mono" data-bind-text="from" data-bind-title="from"></span>
|
|
101
|
+
<span class="sox-arrow" aria-hidden="true">→</span>
|
|
102
|
+
<span class="sox-cell sox-mono"><span data-bind-text="to" data-bind-title="to"></span><small data-bind-text="note"></small></span>
|
|
103
|
+
<span class="sox-pill sox-hide-sm" data-bind-data-tone="tone" data-bind-text="code"></span>
|
|
104
|
+
<span class="sox-cell sox-muted sox-hide-sm" data-bind-text="hits" data-bind-title="lastHit"></span>
|
|
105
|
+
<button type="button" class="sox-kebab" aria-label="Actions" title="Actions">⋮</button>
|
|
106
|
+
</div>`;
|
|
107
|
+
const ENTRY_ROW = `
|
|
108
|
+
<div class="sox-row" data-bind-data-row="id" tabindex="0">
|
|
109
|
+
<span class="sox-cell"><span data-bind-text="path" data-bind-title="path"></span><small data-bind-text="title"></small></span>
|
|
110
|
+
<span class="sox-cell sox-muted sox-hide-sm" data-bind-text="source"></span>
|
|
111
|
+
<span class="sox-cell sox-muted sox-hide-sm" data-bind-text="note" data-bind-title="note"></span>
|
|
112
|
+
<button type="button" class="sox-kebab" aria-label="Actions" title="Actions">⋮</button>
|
|
113
|
+
</div>`;
|
|
114
|
+
const SOURCE_ROW = `
|
|
115
|
+
<div class="sox-row" data-bind-data-row="id" tabindex="0">
|
|
116
|
+
<span class="sox-cell"><span data-bind-text="label"></span><small data-bind-text="detail"></small></span>
|
|
117
|
+
<span class="sox-pill" data-bind-data-tone="tone" data-bind-text="state"></span>
|
|
118
|
+
<button type="button" class="sox-kebab" aria-label="Actions" title="Actions">⋮</button>
|
|
119
|
+
</div>`;
|
|
120
|
+
const CHECK_ROW = `
|
|
121
|
+
<div>
|
|
122
|
+
<div class="sox-group" data-bind-hidden="!head" data-bind-text="head"></div>
|
|
123
|
+
<div class="sox-check" data-bind-hidden="!title">
|
|
124
|
+
<span class="sox-status" data-bind-data-status="status" data-bind-text="glyph"></span>
|
|
125
|
+
<span class="sox-check-main"><strong data-bind-text="title"></strong><small data-bind-text="detail"></small><small data-bind-text="fix" data-bind-hidden="!fix"></small></span>
|
|
126
|
+
</div>
|
|
127
|
+
</div>`;
|
|
128
|
+
|
|
129
|
+
export const seoView = {
|
|
130
|
+
templateUrl: '/plugins/seo/admin/templates/seo.html',
|
|
131
|
+
|
|
132
|
+
async onMount($container) {
|
|
133
|
+
const k = await loadKit();
|
|
134
|
+
if (!ROUTE.test(location.hash)) return;
|
|
135
|
+
const lifetime = (seoView._lifetime ??= k.viewLifetime(ROUTE));
|
|
136
|
+
lifetime.reset();
|
|
137
|
+
const root = $container.get(0).querySelector('.sox') || $container.get(0);
|
|
138
|
+
const here = () => ROUTE.test(location.hash) && root.isConnected;
|
|
139
|
+
const scope = (fn) => { lifetime.add(fn); return fn; };
|
|
140
|
+
const api = (path, opts) => k.apiRequest(`${API}${path}`, opts);
|
|
141
|
+
|
|
142
|
+
// ---- State ----
|
|
143
|
+
const fromHash = location.hash.match(/seo\/([a-z0-9-]+)/)?.[1] || 'overview';
|
|
144
|
+
const tab = M.observable(fromHash);
|
|
145
|
+
const busy = M.observable(true);
|
|
146
|
+
const error = M.observable('');
|
|
147
|
+
const hooksOk = M.observable(true);
|
|
148
|
+
const canManage = M.observable(true);
|
|
149
|
+
const overview = M.observable(null);
|
|
150
|
+
const audit = M.observable({results: []});
|
|
151
|
+
const show = M.observable('all');
|
|
152
|
+
const source = M.observable('');
|
|
153
|
+
const query = M.observable('');
|
|
154
|
+
const redirects = M.observable({rules: [], chains: [], onRename: true});
|
|
155
|
+
const ruleQuery = M.observable('');
|
|
156
|
+
const newFrom = M.observable('');
|
|
157
|
+
const newTo = M.observable('');
|
|
158
|
+
const newCode = M.observable('301');
|
|
159
|
+
const sitemap = M.observable({entries: [], left: [], sources: [], labels: {}, robots: ''});
|
|
160
|
+
const smShow = M.observable('in');
|
|
161
|
+
const smQuery = M.observable('');
|
|
162
|
+
const extTabs = M.observable([]);
|
|
163
|
+
const extOn = M.computed(() => !TABS.includes(tab.value));
|
|
164
|
+
let polling = null;
|
|
165
|
+
|
|
166
|
+
// ---- Derived: pages ----
|
|
167
|
+
const labelOf = (id) => sitemap.value.labels?.[id] || (id === 'pages' ? 'Pages' : id);
|
|
168
|
+
const scoreText = M.computed(() => (overview.value?.score ?? null) === null ? '—' : `${overview.value.score}%`);
|
|
169
|
+
const scoreWidth = M.computed(() => `${overview.value?.score ?? 0}%`);
|
|
170
|
+
const scoreTone = M.computed(() => tone(overview.value?.score));
|
|
171
|
+
const ov = M.computed(() => {
|
|
172
|
+
const o = overview.value;
|
|
173
|
+
return o ? {pages: String(o.pages), failing: String(o.failing), duplicates: String(o.duplicates), redirects: String(o.redirects)}
|
|
174
|
+
: {pages: '—', failing: '—', duplicates: '—', redirects: '—'};
|
|
175
|
+
});
|
|
176
|
+
const attention = M.computed(() => (overview.value?.failing ? String(overview.value.failing) : ''));
|
|
177
|
+
const asOf = M.computed(() => {
|
|
178
|
+
const o = overview.value;
|
|
179
|
+
if (o?.running) return `Checking… ${o.progress?.done || 0} of ${o.progress?.total || '?'}`;
|
|
180
|
+
return o?.at ? `Checked ${ago(o.at)}` : 'Not checked yet';
|
|
181
|
+
});
|
|
182
|
+
const sourceOptions = M.computed(() => [...new Set((audit.value.results || []).map(r => r.source))]);
|
|
183
|
+
|
|
184
|
+
const pageRows = M.observable([]);
|
|
185
|
+
let pageList = [];
|
|
186
|
+
scope(M.effect(() => {
|
|
187
|
+
const q = query.value.trim().toLowerCase();
|
|
188
|
+
const s = show.value;
|
|
189
|
+
const src = source.value;
|
|
190
|
+
pageList = (audit.value.results || []).filter(r => (s === 'all' || (s === 'hidden' ? r.noindex || r.unlisted : !r.noindex && (r.error || r.failing > 0)))
|
|
191
|
+
&& (!src || r.source === src)
|
|
192
|
+
&& (!q || [r.urlPath, r.title, r.label].some(v => String(v || '').toLowerCase().includes(q))));
|
|
193
|
+
pageList.sort((a, b) => (a.error ? -1 : 0) - (b.error ? -1 : 0) || (a.score ?? 101) - (b.score ?? 101));
|
|
194
|
+
pageRows.value = pageList.slice(0, 1000).map((r, i) => ({
|
|
195
|
+
id: `p${i}`, path: r.urlPath,
|
|
196
|
+
sourceText: [labelOf(r.source), r.noindex ? 'hidden from search engines' : r.unlisted ? 'not in the sitemap' : ''].filter(Boolean).join(' · '),
|
|
197
|
+
title: r.title || r.label || '',
|
|
198
|
+
scoreText: r.error ? '!' : typeof r.score === 'number' ? String(r.score) : '—',
|
|
199
|
+
tone: r.error ? 'bad' : r.noindex ? 'info' : tone(r.score),
|
|
200
|
+
problems: r.error || (r.problems?.length ? r.problems.join(' · ') : 'Nothing to fix')
|
|
201
|
+
}));
|
|
202
|
+
}));
|
|
203
|
+
const pagesEmpty = M.computed(() => (!overview.value?.at ? (overview.value?.running ? 'Checking every page - this takes a moment…' : 'Not checked yet. Check now with the button top right.')
|
|
204
|
+
: query.value.trim() ? 'No page matches.' : show.value === 'problems' ? 'Nothing needs attention.' : 'No pages.'));
|
|
205
|
+
|
|
206
|
+
const siteRows = M.observable([]);
|
|
207
|
+
let siteList = [];
|
|
208
|
+
scope(M.effect(() => {
|
|
209
|
+
const o = overview.value;
|
|
210
|
+
siteList = [];
|
|
211
|
+
if (o) {
|
|
212
|
+
const seo = o.siteSeo || {};
|
|
213
|
+
for (const d of o.issues?.duplicateTitles || []) siteList.push({kind: 'dupe', text: `Title on ${d.paths.length} pages: "${d.text}"`, detail: d.paths.join(', '), paths: d.paths, tone: 'poor', badge: 'duplicate'});
|
|
214
|
+
for (const d of o.issues?.duplicateDescriptions || []) {
|
|
215
|
+
const isDefault = seo.defaultDescription && d.text === seo.defaultDescription;
|
|
216
|
+
siteList.push({kind: 'dupe', text: isDefault ? `${d.paths.length} pages use the site's default description` : `Description on ${d.paths.length} pages: "${d.text}"`,
|
|
217
|
+
detail: d.paths.join(', '), paths: d.paths, tone: 'poor', badge: isDefault ? 'default' : 'duplicate'});
|
|
218
|
+
}
|
|
219
|
+
if (!seo.defaultImage) siteList.push({kind: 'setting', text: 'No default share image', detail: 'Pages without their own image share with none. Set one in Settings › SEO.', link: '#/settings', tone: 'poor', badge: 'setting'});
|
|
220
|
+
if (!o.origin || /localhost|127\.0\.0\.1/.test(o.origin)) siteList.push({kind: 'setting', text: 'The site address is not set', detail: 'Canonical links and the sitemap use whatever address a visitor came in on. Set the site address in Settings.', link: '#/settings', tone: 'poor', badge: 'setting'});
|
|
221
|
+
if (o.chains) siteList.push({kind: 'chains', text: `${o.chains} redirect${o.chains === 1 ? ' leads' : 's lead'} to another redirect`, detail: 'Each hop is slower and loses a little standing. The Redirects tab shows which.', tone: 'poor', badge: 'redirects'});
|
|
222
|
+
}
|
|
223
|
+
siteRows.value = siteList.map((x, i) => ({id: `s${i}`, text: x.text, detail: x.detail || '', tone: x.tone, badge: x.badge}));
|
|
224
|
+
}));
|
|
225
|
+
|
|
226
|
+
// ---- Derived: redirects ----
|
|
227
|
+
const ruleRows = M.observable([]);
|
|
228
|
+
let ruleList = [];
|
|
229
|
+
scope(M.effect(() => {
|
|
230
|
+
const q = ruleQuery.value.trim().toLowerCase();
|
|
231
|
+
const chained = new Set(redirects.value.chains.map(c => c.id));
|
|
232
|
+
ruleList = redirects.value.rules.filter(r => !q || `${r.from} ${r.to} ${r.note}`.toLowerCase().includes(q));
|
|
233
|
+
ruleRows.value = ruleList.map((r, i) => ({
|
|
234
|
+
id: `r${i}`, from: r.from, to: r.code === 410 ? '(gone)' : r.to,
|
|
235
|
+
note: [r.auto ? 'Added when the page moved' : r.note, chained.has(r.id) ? 'leads to another redirect' : ''].filter(Boolean).join(' · '),
|
|
236
|
+
code: String(r.code), tone: r.code === 410 ? 'bad' : chained.has(r.id) ? 'poor' : r.code === 301 || r.code === 308 ? 'good' : 'info',
|
|
237
|
+
hits: r.hits ? `${r.hits.toLocaleString('en-GB')} use${r.hits === 1 ? '' : 's'}` : 'unused',
|
|
238
|
+
lastHit: r.lastHit ? `Last used ${ago(r.lastHit)}` : 'Not used yet'
|
|
239
|
+
}));
|
|
240
|
+
}));
|
|
241
|
+
const ruleCount = M.computed(() => (redirects.value.rules.length ? String(redirects.value.rules.length) : ''));
|
|
242
|
+
const ruleText = M.computed(() => `${ruleList.length + 0 * ruleRows.value.length} of ${redirects.value.rules.length}`);
|
|
243
|
+
const rulesEmpty = M.computed(() => (ruleQuery.value.trim() ? 'No redirect matches.' : 'No redirects yet.'));
|
|
244
|
+
const chainText = M.computed(() => {
|
|
245
|
+
const n = redirects.value.chains.length;
|
|
246
|
+
return n ? `${n} redirect${n === 1 ? ' leads' : 's lead'} to another. Right-click one and choose "Go straight there" to make it one hop.` : '';
|
|
247
|
+
});
|
|
248
|
+
const renameText = M.computed(() => (redirects.value.onRename ? 'When a page is renamed, a redirect from its old address is added for you.' : 'Redirects are not added when a page is renamed (see Settings).'));
|
|
249
|
+
|
|
250
|
+
// ---- Derived: sitemap ----
|
|
251
|
+
const sourceRows = M.observable([]);
|
|
252
|
+
scope(M.effect(() => {
|
|
253
|
+
sourceRows.value = sitemap.value.sources.map((s, i) => ({
|
|
254
|
+
id: `x${i}`, label: labelOf(s.id),
|
|
255
|
+
detail: `${s.listed} of ${s.count} listed`,
|
|
256
|
+
state: s.on ? 'on' : 'off', tone: s.on ? 'good' : ''
|
|
257
|
+
}));
|
|
258
|
+
}));
|
|
259
|
+
const smRows = M.observable([]);
|
|
260
|
+
let smList = [];
|
|
261
|
+
scope(M.effect(() => {
|
|
262
|
+
const q = smQuery.value.trim().toLowerCase();
|
|
263
|
+
const list = smShow.value === 'in' ? sitemap.value.entries : sitemap.value.left;
|
|
264
|
+
smList = list.filter(e => !q || `${e.urlPath} ${e.title}`.toLowerCase().includes(q));
|
|
265
|
+
smRows.value = smList.slice(0, 2000).map((e, i) => ({
|
|
266
|
+
id: `e${i}`, path: e.urlPath, title: e.title || '', source: labelOf(e.source),
|
|
267
|
+
note: e.why || [e.lastmod ? `Changed ${day(e.lastmod)}` : '', e.changefreq || '', e.priority !== undefined ? `priority ${e.priority}` : ''].filter(Boolean).join(' · ')
|
|
268
|
+
}));
|
|
269
|
+
}));
|
|
270
|
+
const robots = M.computed(() => sitemap.value.robots || '');
|
|
271
|
+
const smText = M.computed(() => `${smList.length + 0 * smRows.value.length} address${smList.length === 1 ? '' : 'es'}`);
|
|
272
|
+
const smEmpty = M.computed(() => (!sitemap.value.hooks ? 'This version of Domma CMS lists only its own pages in the sitemap.' : smQuery.value.trim() ? 'No address matches.' : smShow.value === 'in' ? 'The sitemap is empty.' : 'Nothing is left out.'));
|
|
273
|
+
|
|
274
|
+
// ---- Server ----
|
|
275
|
+
const denied = (e) => /403|forbidden/i.test(String(e?.message));
|
|
276
|
+
async function loadOverview() {
|
|
277
|
+
try {
|
|
278
|
+
const o = await api('/overview');
|
|
279
|
+
if (!here() || !o) return;
|
|
280
|
+
overview.value = o;
|
|
281
|
+
hooksOk.value = o.hooks !== false;
|
|
282
|
+
error.value = '';
|
|
283
|
+
if (o.running) poll();
|
|
284
|
+
} catch (e) {
|
|
285
|
+
if (here()) error.value = denied(e) ? 'You do not have access to SEO.' : `Could not load SEO: ${e.message || e}`;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
async function loadAudit() {
|
|
289
|
+
try {
|
|
290
|
+
const a = await api('/audit');
|
|
291
|
+
if (here() && a) audit.value = a;
|
|
292
|
+
} catch { /* the overview error says enough */ }
|
|
293
|
+
}
|
|
294
|
+
async function loadRedirects() {
|
|
295
|
+
try {
|
|
296
|
+
const r = await api('/redirects');
|
|
297
|
+
if (here() && r) redirects.value = r;
|
|
298
|
+
} catch { /* shown empty */ }
|
|
299
|
+
}
|
|
300
|
+
async function loadSitemap() {
|
|
301
|
+
try {
|
|
302
|
+
const s = await api('/sitemap');
|
|
303
|
+
if (here() && s) sitemap.value = s;
|
|
304
|
+
} catch (e) { if (here() && !denied(e)) E.toast(`Could not load the sitemap: ${e.message || e}`, {type: 'error'}); }
|
|
305
|
+
}
|
|
306
|
+
async function loadAll() {
|
|
307
|
+
busy.value = true;
|
|
308
|
+
await Promise.all([loadOverview(), loadAudit(), loadRedirects(), loadSitemap()]);
|
|
309
|
+
if (here()) busy.value = false;
|
|
310
|
+
}
|
|
311
|
+
function poll() {
|
|
312
|
+
if (polling) return;
|
|
313
|
+
polling = setInterval(async () => {
|
|
314
|
+
if (!here()) { clearInterval(polling); polling = null; return; }
|
|
315
|
+
await loadOverview();
|
|
316
|
+
if (!overview.peek()?.running) {
|
|
317
|
+
clearInterval(polling);
|
|
318
|
+
polling = null;
|
|
319
|
+
await loadAudit();
|
|
320
|
+
refreshBadge();
|
|
321
|
+
}
|
|
322
|
+
}, 1500);
|
|
323
|
+
}
|
|
324
|
+
scope(() => { if (polling) clearInterval(polling); });
|
|
325
|
+
async function checkNow() {
|
|
326
|
+
try {
|
|
327
|
+
const o = await api('/audit', {method: 'POST', body: JSON.stringify({})});
|
|
328
|
+
if (here()) overview.value = {...overview.peek(), ...o, running: true};
|
|
329
|
+
poll();
|
|
330
|
+
} catch (e) { E.toast(`Could not start the check: ${e.message || e}`, {type: 'error'}); }
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ---- Actions: redirects ----
|
|
334
|
+
async function act(fn, done, after = loadRedirects) {
|
|
335
|
+
try {
|
|
336
|
+
const r = await fn();
|
|
337
|
+
if (done) E.toast(done, {type: 'success'});
|
|
338
|
+
await Promise.all([after(), loadOverview()]);
|
|
339
|
+
return r;
|
|
340
|
+
} catch (e) {
|
|
341
|
+
if (denied(e)) { canManage.value = false; E.toast('Only people who may manage SEO can change this.', {type: 'warning'}); }
|
|
342
|
+
else E.toast(`Not done: ${e.message || e}`, {type: 'error'});
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
const addRedirect = (body, done = `Redirect from ${body.from} added.`) => act(() => api('/redirects', {method: 'POST', body: JSON.stringify(body)}), done);
|
|
347
|
+
const removeRules = (ids, what) => act(() => api(`/redirects/${encodeURIComponent(ids.join(','))}`, {method: 'DELETE'}), `${what} removed.`);
|
|
348
|
+
async function testRule(from) {
|
|
349
|
+
try {
|
|
350
|
+
const r = await api(`/redirects/test?path=${encodeURIComponent(from.replace(/\/\*$/, '/example'))}`);
|
|
351
|
+
const hops = r.hops || [];
|
|
352
|
+
E.toast(hops.length ? hops.map(h => `${h.from} → ${h.code === 410 ? 'gone' : h.to} (${h.code})`).join(' · ') : 'No redirect applies.', {type: hops.length > 1 ? 'warning' : 'info', duration: 7000});
|
|
353
|
+
} catch (e) { E.toast(`Could not test: ${e.message || e}`, {type: 'error'}); }
|
|
354
|
+
}
|
|
355
|
+
function editRule(rule) {
|
|
356
|
+
const f = {from: M.observable(rule.from), to: M.observable(rule.code === 410 ? '' : rule.to), code: M.observable(String(rule.code)), note: M.observable(rule.note || '')};
|
|
357
|
+
formSlideover(`Redirect from ${rule.from}`, f, `
|
|
358
|
+
<div class="sox-form-grid">
|
|
359
|
+
<div class="sox-wide"><label class="form-label" for="sox-r-from">From</label><input id="sox-r-from" class="form-input sox-mono" data-model="from.value"></div>
|
|
360
|
+
<div class="sox-wide"><label class="form-label" for="sox-r-to">To</label><input id="sox-r-to" class="form-input sox-mono" data-model="to.value" data-bind-disabled="code.value === '410'"></div>
|
|
361
|
+
<div><label class="form-label" for="sox-r-code">Kind</label><select id="sox-r-code" class="form-input" data-model="code.value">
|
|
362
|
+
${Object.entries(CODE_TEXT).map(([c, t]) => `<option value="${c}">${t}</option>`).join('')}</select></div>
|
|
363
|
+
<div><label class="form-label" for="sox-r-note">Note</label><input id="sox-r-note" class="form-input" data-model="note.value"></div>
|
|
364
|
+
</div>`,
|
|
365
|
+
() => {
|
|
366
|
+
const body = {from: f.from.value, to: f.to.value, code: Number(f.code.value), note: f.note.value};
|
|
367
|
+
const others = redirects.peek().rules.filter(r => r.id !== rule.id);
|
|
368
|
+
return {body, errors: checkRule(body, others).errors};
|
|
369
|
+
},
|
|
370
|
+
async (body) => {
|
|
371
|
+
await api(`/redirects/${encodeURIComponent(rule.id)}`, {method: 'PUT', body: JSON.stringify(body)});
|
|
372
|
+
E.toast('Redirect saved.', {type: 'success'});
|
|
373
|
+
await loadRedirects();
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
function importCsv() {
|
|
377
|
+
const f = {csv: M.observable('')};
|
|
378
|
+
formSlideover('Import redirects', f, `
|
|
379
|
+
<p class="sox-note">One redirect per line: <span class="sox-mono">from, to</span>, and optionally the kind (301, 302, 307, 308, 410) and a note. A header line is skipped. Paste from a spreadsheet, or another CMS's export.</p>
|
|
380
|
+
<textarea class="form-input sox-mono" rows="14" data-model="csv.value" placeholder="/old-page, /new-page /news/*, /blog/*, 301 /retired, , 410"></textarea>`,
|
|
381
|
+
() => ({body: {csv: f.csv.value}, errors: f.csv.value.trim() ? {} : {csv: 'Paste some redirects first.'}}),
|
|
382
|
+
async (body) => {
|
|
383
|
+
const r = await api('/redirects/import', {method: 'POST', body: JSON.stringify(body)});
|
|
384
|
+
const s = r.skipped || [];
|
|
385
|
+
E.toast(`${r.added} added${s.length ? `, ${s.length} skipped - line ${s[0].line}: ${s[0].why}` : '.'}`, {type: s.length ? 'warning' : 'success', duration: s.length ? 9000 : 4000});
|
|
386
|
+
await Promise.all([loadRedirects(), loadOverview()]);
|
|
387
|
+
}, 'Import');
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** A small form in a slideover: `check()` -> {body, errors}; `submit(body)` saves. */
|
|
391
|
+
function formSlideover(title, fields, html, check, submit, verb = 'Save') {
|
|
392
|
+
const saving = M.observable(false);
|
|
393
|
+
const checked = M.computed(() => check());
|
|
394
|
+
const errText = M.computed(() => Object.values(checked.value.errors).join(' '));
|
|
395
|
+
const model = {...fields, saving, errText, cannot: M.computed(() => saving.value || Boolean(errText.value))};
|
|
396
|
+
const so = E.slideover({title, size: 'md', position: 'right'});
|
|
397
|
+
const panel = fragment(`<div class="sox-form">${html}
|
|
398
|
+
<p class="sox-err" data-bind-hidden="!errText.value" data-bind-text="errText.value"></p>
|
|
399
|
+
<div class="sox-actions"><small class="sox-muted">Ctrl+Enter ${verb.toLowerCase()}s.</small><span class="sox-spacer"></span>
|
|
400
|
+
<button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
|
|
401
|
+
<button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-bind-disabled="cannot.value">${esc(verb)}</button></div></div>`);
|
|
402
|
+
async function save() {
|
|
403
|
+
if (model.cannot.peek()) return;
|
|
404
|
+
saving.value = true;
|
|
405
|
+
try { await submit(checked.peek().body); so.close(); }
|
|
406
|
+
catch (e) { E.toast(`Not saved: ${e.message || e}`, {type: 'error'}); }
|
|
407
|
+
finally { saving.value = false; }
|
|
408
|
+
}
|
|
409
|
+
so.element.appendChild(panel);
|
|
410
|
+
const disposeHelp = k.helpPopovers(panel);
|
|
411
|
+
const handle = M.applyBindings(model, panel, {methods: {save, cancel: () => so.close()}});
|
|
412
|
+
panel.addEventListener('keydown', (e) => { if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); save(); } });
|
|
413
|
+
k.onceClosed(so, () => { handle.dispose(); disposeHelp(); });
|
|
414
|
+
scope(() => { try { so.close(); } catch { /* gone */ } });
|
|
415
|
+
I.scan(panel);
|
|
416
|
+
so.open();
|
|
417
|
+
panel.querySelector('input, textarea')?.focus();
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// ---- The page editor ----
|
|
421
|
+
let itemOpen = false;
|
|
422
|
+
async function openItem(urlPath) {
|
|
423
|
+
if (itemOpen) return;
|
|
424
|
+
itemOpen = true;
|
|
425
|
+
let d;
|
|
426
|
+
try {
|
|
427
|
+
d = await api(`/item?path=${encodeURIComponent(urlPath)}`);
|
|
428
|
+
} catch (e) {
|
|
429
|
+
itemOpen = false;
|
|
430
|
+
E.toast(`Could not open ${urlPath}: ${e.message || e}`, {type: 'error'});
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
const f = {
|
|
434
|
+
title: M.observable(d.fields.title), description: M.observable(d.fields.description), focus: M.observable(d.fields.focus),
|
|
435
|
+
image: M.observable(d.fields.image), noindex: M.observable(d.fields.noindex)
|
|
436
|
+
};
|
|
437
|
+
const L = d.limits || {};
|
|
438
|
+
const pageTitle = d.page?.title || d.live?.h1 || '';
|
|
439
|
+
const defTitle = d.kind === 'page' ? `${pageTitle}${d.defaults.separator}${d.defaults.siteTitle}` : (d.live?.title || '');
|
|
440
|
+
const defDesc = d.kind === 'page' ? (d.defaults.description || '') : (d.live?.description || '');
|
|
441
|
+
const defImage = d.kind === 'page' ? d.defaults.image : (d.live?.image || '');
|
|
442
|
+
const effTitle = M.computed(() => f.title.value.trim() || defTitle);
|
|
443
|
+
const effDesc = M.computed(() => f.description.value.trim() || defDesc);
|
|
444
|
+
const effImage = M.computed(() => f.image.value.trim() || defImage);
|
|
445
|
+
const origin = d.origin || location.origin;
|
|
446
|
+
const serp = M.computed(() => snippet({title: effTitle.value, description: effDesc.value, url: `${origin}${urlPath}`, limits: L}));
|
|
447
|
+
const meter = (text, min, max) => {
|
|
448
|
+
const n = text.length;
|
|
449
|
+
return {text: `${n} / ${max}`, width: `${Math.min(100, (n / max) * 100)}%`, tone: !n ? 'bad' : n < min || n > max ? 'poor' : 'good'};
|
|
450
|
+
};
|
|
451
|
+
const titleMeter = M.computed(() => meter(effTitle.value, L.titleMin || 30, L.titleMax || 60));
|
|
452
|
+
const descMeter = M.computed(() => meter(effDesc.value, L.descMin || 70, L.descMax || 160));
|
|
453
|
+
const host = (() => { try { return new URL(origin).host; } catch { return location.host; } })();
|
|
454
|
+
const shareBg = M.computed(() => (effImage.value ? `url("${effImage.value.replace(/["\\\n\r]/g, '')}")` : 'none'));
|
|
455
|
+
const checks = M.observable([]);
|
|
456
|
+
const setChecks = (analysis) => {
|
|
457
|
+
const rows = [];
|
|
458
|
+
const list = analysis?.checks || [];
|
|
459
|
+
const order = {fail: 0, warn: 1, pass: 2, info: 3};
|
|
460
|
+
for (const g of [...new Set(list.map(c => c.group))]) {
|
|
461
|
+
rows.push({id: `g-${g}`, head: g, title: '', detail: '', fix: '', status: '', glyph: ''});
|
|
462
|
+
for (const c of list.filter(x => x.group === g).sort((a, b) => order[a.status] - order[b.status])) {
|
|
463
|
+
rows.push({id: `c-${c.id}`, head: '', title: c.title, detail: c.detail, fix: c.status === 'pass' ? '' : c.fix, status: c.status, glyph: GLYPH[c.status] || '?'});
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
checks.value = rows;
|
|
467
|
+
};
|
|
468
|
+
setChecks(d.analysis);
|
|
469
|
+
const scoreLine = M.observable(d.analysis ? `Score ${d.analysis.score}% · ${d.analysis.failing} failing, ${d.analysis.warnings} could be better` : (d.status === 200 ? '' : `Not live (answers ${d.status || 'nothing'}) - the checks run once it is published.`));
|
|
470
|
+
const body = () => ({path: urlPath, title: f.title.value, description: f.description.value, focus: f.focus.value, image: f.image.value, noindex: f.noindex.value});
|
|
471
|
+
let opened = JSON.stringify(body());
|
|
472
|
+
const saving = M.observable(false);
|
|
473
|
+
const errText = M.computed(() => (f.image.value.trim() && !/^(https?:\/\/|\/)/i.test(f.image.value.trim()) ? 'The image: a path like /media/photo.jpg, or a full web address.' : ''));
|
|
474
|
+
const model = {...f, saving, errText, checks, scoreLine, serp, titleMeter, descMeter, shareBg, effTitle, effDesc, host,
|
|
475
|
+
hasImage: M.computed(() => Boolean(effImage.value)), defTitle, defDesc, canManage,
|
|
476
|
+
cannot: M.computed(() => saving.value || Boolean(errText.value) || !canManage.value)};
|
|
477
|
+
const so = E.slideover({title: `SEO · ${urlPath}`, size: 'lg', position: 'right'});
|
|
478
|
+
const note = d.kind === 'url'
|
|
479
|
+
? '<p class="sox-note is-warn">A plugin serves this address (a blog post, a listing). What you set here takes the place of its own SEO fields.</p>'
|
|
480
|
+
: d.page?.status !== 'published' ? `<p class="sox-note is-warn">This page is a ${esc(d.page?.status || 'draft')} - not live yet.</p>` : '';
|
|
481
|
+
const redirectNote = d.redirect ? `<p class="sox-note is-bad">A redirect sends visitors from here to ${esc(d.redirect.to || '(gone)')} - they never see this page.</p>` : '';
|
|
482
|
+
const panel = fragment(`
|
|
483
|
+
<div class="sox-form">
|
|
484
|
+
${note}${redirectNote}
|
|
485
|
+
<section><h4>As it will look <span data-help="The search result is drawn the way Google draws it, cut where it cuts (roughly - it measures pixels). The share card is what Facebook, LinkedIn, WhatsApp and others show when someone shares the link."></span>
|
|
486
|
+
<span class="sox-spacer"></span><a class="btn btn-xs btn-ghost" href="${esc(urlPath)}" target="_blank" rel="noopener"><span data-icon="external-link" data-icon-size="12"></span> Open</a></h4>
|
|
487
|
+
<div class="sox-previews">
|
|
488
|
+
<div class="sox-serp" aria-label="Search result preview">
|
|
489
|
+
<div class="sox-serp-crumbs" data-bind-text="serp.value.crumbs"></div>
|
|
490
|
+
<div class="sox-serp-title" data-bind-text="serp.value.title"></div>
|
|
491
|
+
<div class="sox-serp-desc" data-bind-text="serp.value.description"></div>
|
|
492
|
+
</div>
|
|
493
|
+
<div class="sox-share" aria-label="Share card preview">
|
|
494
|
+
<div class="sox-share-img"><span data-bind-hidden="hasImage.value">No image</span></div>
|
|
495
|
+
<div class="sox-share-body"><small data-bind-text="host"></small><strong data-bind-text="effTitle.value"></strong><span data-bind-text="effDesc.value"></span></div>
|
|
496
|
+
</div>
|
|
497
|
+
</div>
|
|
498
|
+
</section>
|
|
499
|
+
<section><h4>Search and sharing</h4><div class="sox-form-grid">
|
|
500
|
+
<div class="sox-wide"><label class="form-label" for="sox-i-title">Title <span data-help="The blue link in search results and the headline of a share. Say what the page is about, words that matter first. Empty uses the page title and the site name."></span></label>
|
|
501
|
+
<input id="sox-i-title" class="form-input" data-model="title.value" placeholder="${esc(defTitle)}" maxlength="200">
|
|
502
|
+
<div class="sox-meter" data-bind-data-tone="titleMeter.value.tone"><span class="sox-bar"><span data-bind-style-width="titleMeter.value.width"></span></span><span data-bind-text="titleMeter.value.text"></span></div></div>
|
|
503
|
+
<div class="sox-wide"><label class="form-label" for="sox-i-desc">Description <span data-help="The grey text under the link. One or two sentences that make someone want to click. Empty uses the site's default - which every other such page shares."></span></label>
|
|
504
|
+
<textarea id="sox-i-desc" rows="3" class="form-input" data-model="description.value" placeholder="${esc(defDesc)}" maxlength="400"></textarea>
|
|
505
|
+
<div class="sox-meter" data-bind-data-tone="descMeter.value.tone"><span class="sox-bar"><span data-bind-style-width="descMeter.value.width"></span></span><span data-bind-text="descMeter.value.text"></span></div></div>
|
|
506
|
+
<div><label class="form-label" for="sox-i-focus">Focus keyphrase <span data-help="The search this page should be found for - 'garden tools', 'plumber in Leeds'. The checks then look for it in the title, description, heading, address and opening. It is not shown anywhere."></span></label>
|
|
507
|
+
<input id="sox-i-focus" class="form-input" data-model="focus.value" maxlength="100"></div>
|
|
508
|
+
<div><label class="form-label" for="sox-i-image">Share image <span data-help="Shown when the page is shared. 1200 × 630 pixels is ideal. A path from Media (/media/photo.jpg) or a full address. Empty uses the site's default image."></span></label>
|
|
509
|
+
<input id="sox-i-image" class="form-input sox-mono" data-model="image.value" list="sox-media" placeholder="${esc(defImage || '/media/…')}"></div>
|
|
510
|
+
<label class="sox-wide" style="display:flex;align-items:center;gap:.5rem"><input type="checkbox" class="form-switch" data-model="noindex.value"> Hide from search engines
|
|
511
|
+
<span data-help="Asks search engines not to list this page, and leaves it out of the sitemap. For thank-you pages, private landing pages and the like. It stays reachable by its address."></span></label>
|
|
512
|
+
</div><datalist id="sox-media"></datalist></section>
|
|
513
|
+
<section><h4>Checks <small class="sox-muted" data-bind-text="scoreLine.value"></small></h4>
|
|
514
|
+
<div class="sox-checks" data-each="checks.value key=id"></div>
|
|
515
|
+
</section>
|
|
516
|
+
<p class="sox-err" data-bind-hidden="!errText.value" data-bind-text="errText.value"></p>
|
|
517
|
+
<div class="sox-actions">
|
|
518
|
+
<small class="sox-muted">Ctrl+Enter saves.</small><span class="sox-spacer"></span>
|
|
519
|
+
<button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Close</button>
|
|
520
|
+
<button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-bind-disabled="cannot.value"><span data-icon="save" data-icon-size="13"></span> Save and check</button>
|
|
521
|
+
</div>
|
|
522
|
+
</div>`);
|
|
523
|
+
panel.querySelector('.sox-checks').innerHTML = CHECK_ROW;
|
|
524
|
+
fillMedia(panel.querySelector('#sox-media'));
|
|
525
|
+
async function save() {
|
|
526
|
+
if (model.cannot.peek()) return;
|
|
527
|
+
saving.value = true;
|
|
528
|
+
try {
|
|
529
|
+
await api('/item', {method: 'PUT', body: JSON.stringify(body())});
|
|
530
|
+
opened = JSON.stringify(body());
|
|
531
|
+
E.toast('Saved.', {type: 'success'});
|
|
532
|
+
const fresh = await api(`/item?path=${encodeURIComponent(urlPath)}`).catch(() => null);
|
|
533
|
+
if (fresh?.analysis) {
|
|
534
|
+
setChecks(fresh.analysis);
|
|
535
|
+
scoreLine.value = `Score ${fresh.analysis.score}% · ${fresh.analysis.failing} failing, ${fresh.analysis.warnings} could be better`;
|
|
536
|
+
}
|
|
537
|
+
loadAudit(); loadOverview(); refreshBadge();
|
|
538
|
+
} catch (e) {
|
|
539
|
+
if (denied(e)) canManage.value = false;
|
|
540
|
+
E.toast(`Not saved: ${e.message || e}`, {type: 'error'});
|
|
541
|
+
} finally {
|
|
542
|
+
saving.value = false;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
so.element.appendChild(panel);
|
|
546
|
+
const disposeHelp = k.helpPopovers(panel);
|
|
547
|
+
const handle = M.applyBindings(model, panel, {methods: {save, cancel: () => so.close()}});
|
|
548
|
+
const shareImg = panel.querySelector('.sox-share-img');
|
|
549
|
+
const stopBg = M.effect(() => { shareImg.style.backgroundImage = shareBg.value; });
|
|
550
|
+
panel.addEventListener('keydown', (e) => { if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); save(); } });
|
|
551
|
+
k.onceClosed(so, () => { handle.dispose(); disposeHelp(); stopBg?.(); itemOpen = false; });
|
|
552
|
+
guardClose(so, () => JSON.stringify(body()) !== opened);
|
|
553
|
+
scope(() => { try { so.close(true); } catch { /* gone */ } });
|
|
554
|
+
I.scan(panel);
|
|
555
|
+
so.open();
|
|
556
|
+
}
|
|
557
|
+
let media = null;
|
|
558
|
+
async function fillMedia(list) {
|
|
559
|
+
try {
|
|
560
|
+
media ??= await k.apiRequest('/media').then(r => (Array.isArray(r) ? r : r?.files || r?.items || []));
|
|
561
|
+
for (const m of media) {
|
|
562
|
+
const url = m.url || m.path || (m.filename ? `/media/${m.filename}` : '');
|
|
563
|
+
if (url && /\.(jpe?g|png|webp|gif|avif|svg)$/i.test(url)) list.appendChild(Object.assign(document.createElement('option'), {value: url}));
|
|
564
|
+
}
|
|
565
|
+
} catch { media = []; }
|
|
566
|
+
}
|
|
567
|
+
function guardClose(so, dirty) {
|
|
568
|
+
const close = so.close.bind(so);
|
|
569
|
+
let asking = false;
|
|
570
|
+
so.close = async (force) => {
|
|
571
|
+
if (force !== true && dirty()) {
|
|
572
|
+
if (asking) return undefined;
|
|
573
|
+
asking = true;
|
|
574
|
+
const ok = await E.confirm('Discard the changes you have not saved?');
|
|
575
|
+
asking = false;
|
|
576
|
+
if (!ok) return undefined;
|
|
577
|
+
}
|
|
578
|
+
return close();
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// ---- Settings (slideover) ----
|
|
583
|
+
let settingsOpen = false;
|
|
584
|
+
async function openSettings() {
|
|
585
|
+
if (settingsOpen) return;
|
|
586
|
+
settingsOpen = true;
|
|
587
|
+
let s;
|
|
588
|
+
try {
|
|
589
|
+
const r = await api('/settings');
|
|
590
|
+
s = mergeDefaults(r.settings);
|
|
591
|
+
hooksOk.value = r.hooks !== false;
|
|
592
|
+
} catch (e) {
|
|
593
|
+
settingsOpen = false;
|
|
594
|
+
E.toast(`Could not load the settings: ${e.message || e}`, {type: 'error'});
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
const o = s.organisation;
|
|
598
|
+
const f = {
|
|
599
|
+
orgOn: M.observable(o.enabled), orgType: M.observable(o.type), orgName: M.observable(o.name), orgUrl: M.observable(o.url), orgLogo: M.observable(o.logo),
|
|
600
|
+
orgEmail: M.observable(o.email), orgPhone: M.observable(o.phone), orgSame: M.observable(o.sameAs.join('\n')),
|
|
601
|
+
street: M.observable(o.address.street), locality: M.observable(o.address.locality), region: M.observable(o.address.region),
|
|
602
|
+
postcode: M.observable(o.address.postcode), country: M.observable(o.address.country),
|
|
603
|
+
articles: M.observable(s.articles), exclude: M.observable(s.sitemap.exclude.join('\n')),
|
|
604
|
+
blockAll: M.observable(s.robots.blockAll), robotsExtra: M.observable(s.robots.extra), onRename: M.observable(s.redirects.onRename),
|
|
605
|
+
every: M.observable(String(s.audit.everyHours)), tMin: M.observable(String(s.audit.titleMin)), tMax: M.observable(String(s.audit.titleMax)),
|
|
606
|
+
dMin: M.observable(String(s.audit.descMin)), dMax: M.observable(String(s.audit.descMax)), words: M.observable(String(s.audit.minWords))
|
|
607
|
+
};
|
|
608
|
+
const body = () => ({
|
|
609
|
+
organisation: {enabled: f.orgOn.value, type: f.orgType.value, name: f.orgName.value, url: f.orgUrl.value, logo: f.orgLogo.value, email: f.orgEmail.value, phone: f.orgPhone.value, sameAs: f.orgSame.value,
|
|
610
|
+
address: {street: f.street.value, locality: f.locality.value, region: f.region.value, postcode: f.postcode.value, country: f.country.value}},
|
|
611
|
+
articles: f.articles.value,
|
|
612
|
+
sitemap: {exclude: f.exclude.value, sources: s.sitemap.sources},
|
|
613
|
+
robots: {blockAll: f.blockAll.value, extra: f.robotsExtra.value},
|
|
614
|
+
redirects: {onRename: f.onRename.value},
|
|
615
|
+
audit: {everyHours: f.every.value, titleMin: f.tMin.value, titleMax: f.tMax.value, descMin: f.dMin.value, descMax: f.dMax.value, minWords: f.words.value}
|
|
616
|
+
});
|
|
617
|
+
const opened = JSON.stringify(body());
|
|
618
|
+
const saving = M.observable(false);
|
|
619
|
+
const checked = M.computed(() => checkSettings(body(), s));
|
|
620
|
+
const errText = M.computed(() => Object.values(checked.value.errors).join(' '));
|
|
621
|
+
const model = {...f, saving, errText, hooksOk, cannot: M.computed(() => saving.value || Boolean(errText.value)), isPerson: M.computed(() => f.orgType.value === 'Person')};
|
|
622
|
+
const input = (id, key, label, help = '', attrs = '') => `<div><label class="form-label" for="sox-s-${id}">${label}${help ? ` <span data-help="${esc(help)}"></span>` : ''}</label>
|
|
623
|
+
<input id="sox-s-${id}" class="form-input" data-model="${key}.value" ${attrs}></div>`;
|
|
624
|
+
const num = (id, key, label, min, max, help = '') => input(id, key, label, help, `type="number" min="${min}" max="${max}"`);
|
|
625
|
+
const sw = (key, label, help = '') => `<label class="sox-wide" style="display:flex;align-items:center;gap:.5rem"><input type="checkbox" class="form-switch" data-model="${key}.value"> ${label}${help ? ` <span data-help="${esc(help)}"></span>` : ''}</label>`;
|
|
626
|
+
const so = E.slideover({title: 'SEO settings', size: 'md', position: 'right'});
|
|
627
|
+
const panel = fragment(`
|
|
628
|
+
<div class="sox-form">
|
|
629
|
+
<p class="sox-note is-warn" data-bind-hidden="hooksOk.value">This version of Domma CMS cannot take the sitemap, robots.txt and structured data settings below - they apply once it is updated (0.89 or later).</p>
|
|
630
|
+
<p class="sox-note">The site's default title, description and share image are in <a href="#/settings">Settings › SEO</a>.</p>
|
|
631
|
+
<section><h4>Who is behind the site <span data-help="Structured data on the home page, so search engines can show the name, logo and contact details - a knowledge panel, or a local business in maps."></span></h4><div class="sox-form-grid">
|
|
632
|
+
${sw('orgOn', 'Tell search engines who runs the site')}
|
|
633
|
+
<div><label class="form-label" for="sox-s-type">It is</label><select id="sox-s-type" class="form-input" data-model="orgType.value">
|
|
634
|
+
${ORG_TYPES.map(t => `<option value="${t}">${{Organization: 'An organisation', LocalBusiness: 'A local business', Person: 'A person'}[t]}</option>`).join('')}</select></div>
|
|
635
|
+
${input('name', 'orgName', 'Name')}
|
|
636
|
+
${input('url', 'orgUrl', 'Web address', 'Empty uses the site address.', 'placeholder="https://…"')}
|
|
637
|
+
${input('logo', 'orgLogo', 'Logo (or photo)', 'A square image works best - a path from Media or a full address.', 'placeholder="/media/logo.png"')}
|
|
638
|
+
${input('email', 'orgEmail', 'Email', '', 'type="email"')}
|
|
639
|
+
${input('phone', 'orgPhone', 'Phone')}
|
|
640
|
+
<div class="sox-wide" data-bind-hidden="isPerson.value"><label class="form-label">Address <span data-help="For a local business, the address customers visit. Leave empty for an organisation without one."></span></label>
|
|
641
|
+
<div class="sox-form-grid"><input class="form-input sox-wide" placeholder="Street" aria-label="Street" data-model="street.value">
|
|
642
|
+
<input class="form-input" placeholder="Town or city" aria-label="Town or city" data-model="locality.value"><input class="form-input" placeholder="County or region" aria-label="County or region" data-model="region.value">
|
|
643
|
+
<input class="form-input" placeholder="Postcode" aria-label="Postcode" data-model="postcode.value"><input class="form-input" placeholder="Country (GB)" aria-label="Country" data-model="country.value"></div></div>
|
|
644
|
+
<div class="sox-wide"><label class="form-label" for="sox-s-same">Profiles elsewhere <span data-help="Your pages on Facebook, LinkedIn, Instagram, X and so on - one address per line. Search engines use them to know they are you."></span></label>
|
|
645
|
+
<textarea id="sox-s-same" rows="3" class="form-input sox-mono" data-model="orgSame.value" placeholder="https://www.linkedin.com/company/…"></textarea></div>
|
|
646
|
+
${sw('articles', 'Mark blog posts as articles', 'Adds BlogPosting structured data to each post - headline, image, publisher - which search engines use for article results.')}
|
|
647
|
+
</div></section>
|
|
648
|
+
<section><h4>Sitemap and robots.txt</h4><div class="sox-form-grid">
|
|
649
|
+
<div class="sox-wide"><label class="form-label" for="sox-s-ex">Leave out of the sitemap <span data-help="One address per line. Ending with / leaves out everything under it: /members/ covers /members/area. To hide one page from search engines altogether, open it on the Pages tab instead."></span></label>
|
|
650
|
+
<textarea id="sox-s-ex" rows="3" class="form-input sox-mono" data-model="exclude.value" placeholder="/thank-you /members/"></textarea></div>
|
|
651
|
+
${sw('blockAll', 'Ask every search engine to stay away', 'robots.txt then says Disallow: / to everyone. For a site that is not ready yet - remember to switch it off at launch. It is a request: well-behaved crawlers honour it.')}
|
|
652
|
+
<div class="sox-wide"><label class="form-label" for="sox-s-rob">More robots.txt rules <span data-help="Lines like Disallow: /private/ join the rules for every crawler. Start with User-agent: to add rules for one crawler, e.g. User-agent: GPTBot then Disallow: / to keep an AI crawler out."></span></label>
|
|
653
|
+
<textarea id="sox-s-rob" rows="3" class="form-input sox-mono" data-model="robotsExtra.value" placeholder="Disallow: /private/"></textarea></div>
|
|
654
|
+
</div></section>
|
|
655
|
+
<section><h4>Redirects</h4><div class="sox-form-grid">
|
|
656
|
+
${sw('onRename', 'Add a redirect when a page is renamed', 'The old address keeps working, and any redirect that pointed at it is re-aimed at the new one - so there are never chains.')}
|
|
657
|
+
</div></section>
|
|
658
|
+
<section><h4>The checks</h4><div class="sox-form-grid">
|
|
659
|
+
${num('every', 'every', 'Check every page every (hours)', 0, 168, '0 checks only when you ask.')}
|
|
660
|
+
${num('words', 'words', 'Words a page should have', 0, 5000)}
|
|
661
|
+
${num('tmin', 'tMin', 'Shortest title', 10, 70)}
|
|
662
|
+
${num('tmax', 'tMax', 'Longest title', 30, 100, 'Google cuts titles at about 60 characters.')}
|
|
663
|
+
${num('dmin', 'dMin', 'Shortest description', 30, 160)}
|
|
664
|
+
${num('dmax', 'dMax', 'Longest description', 80, 320, 'Google cuts descriptions at about 155-160 characters.')}
|
|
665
|
+
</div></section>
|
|
666
|
+
<p class="sox-err" data-bind-hidden="!errText.value" data-bind-text="errText.value"></p>
|
|
667
|
+
<div class="sox-actions">
|
|
668
|
+
<small class="sox-muted">Ctrl+Enter saves.</small><span class="sox-spacer"></span>
|
|
669
|
+
<button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
|
|
670
|
+
<button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-bind-disabled="cannot.value"><span data-icon="save" data-icon-size="13"></span> Save</button>
|
|
671
|
+
</div>
|
|
672
|
+
</div>`);
|
|
673
|
+
async function save() {
|
|
674
|
+
if (model.cannot.peek()) return;
|
|
675
|
+
saving.value = true;
|
|
676
|
+
try {
|
|
677
|
+
await api('/settings', {method: 'PUT', body: JSON.stringify(checked.peek().settings)});
|
|
678
|
+
E.toast('Settings saved.', {type: 'success'});
|
|
679
|
+
so.close(true);
|
|
680
|
+
loadAll();
|
|
681
|
+
} catch (e) {
|
|
682
|
+
E.toast(`Not saved: ${e.message || e}`, {type: 'error'});
|
|
683
|
+
} finally {
|
|
684
|
+
saving.value = false;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
so.element.appendChild(panel);
|
|
688
|
+
const disposeHelp = k.helpPopovers(panel);
|
|
689
|
+
const handle = M.applyBindings(model, panel, {methods: {save, cancel: () => so.close()}});
|
|
690
|
+
panel.addEventListener('keydown', (e) => { if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); save(); } });
|
|
691
|
+
k.onceClosed(so, () => { handle.dispose(); disposeHelp(); settingsOpen = false; });
|
|
692
|
+
guardClose(so, () => JSON.stringify(body()) !== opened);
|
|
693
|
+
scope(() => { try { so.close(true); } catch { /* gone */ } });
|
|
694
|
+
I.scan(panel);
|
|
695
|
+
so.open();
|
|
696
|
+
}
|
|
697
|
+
async function setSource(id, on) {
|
|
698
|
+
try {
|
|
699
|
+
const r = await api('/settings');
|
|
700
|
+
const s = mergeDefaults(r.settings);
|
|
701
|
+
s.sitemap.sources[id] = on;
|
|
702
|
+
await api('/settings', {method: 'PUT', body: JSON.stringify(s)});
|
|
703
|
+
E.toast(`${labelOf(id)} ${on ? 'back in' : 'left out of'} the sitemap.`, {type: 'success'});
|
|
704
|
+
loadSitemap();
|
|
705
|
+
} catch (e) { E.toast(`Not changed: ${e.message || e}`, {type: 'error'}); }
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// ---- Menus ----
|
|
709
|
+
const rowIndex = (row) => Number(String(row?.dataset?.row || '').slice(1));
|
|
710
|
+
function pageItems(row) {
|
|
711
|
+
const r = pageList[rowIndex(row)];
|
|
712
|
+
if (!r) return [];
|
|
713
|
+
return [
|
|
714
|
+
{header: r.urlPath},
|
|
715
|
+
{label: 'Title, description and checks…', icon: 'edit-3', action: () => openItem(r.urlPath)},
|
|
716
|
+
{label: 'Open the page', icon: 'external-link', action: () => window.open(r.urlPath, '_blank', 'noopener')},
|
|
717
|
+
{label: 'Redirect it elsewhere…', icon: 'corner-up-right', disabled: !canManage.peek(), action: () => { setTab('redirects'); newFrom.value = r.urlPath; newTo.value = ''; }},
|
|
718
|
+
{type: 'divider'},
|
|
719
|
+
{label: 'Copy the address', icon: 'copy', action: () => copy(r.urlPath, 'the address')},
|
|
720
|
+
{label: 'Copy the problems', icon: 'clipboard', disabled: !r.problems?.length && !r.error, action: () => copy(`${r.urlPath}\n${r.error || r.problems.join('\n')}`, 'the problems')}
|
|
721
|
+
];
|
|
722
|
+
}
|
|
723
|
+
function siteItems(row) {
|
|
724
|
+
const x = siteList[rowIndex(row)];
|
|
725
|
+
if (!x) return [];
|
|
726
|
+
if (x.kind === 'dupe') {
|
|
727
|
+
return [{header: 'Open one of them'}, ...x.paths.slice(0, 12).map(p => ({label: p, icon: 'edit-3', action: () => openItem(p)})),
|
|
728
|
+
{type: 'divider'}, {label: 'Copy the addresses', icon: 'copy', action: () => copy(x.paths.join('\n'), 'the addresses')}];
|
|
729
|
+
}
|
|
730
|
+
if (x.kind === 'chains') return [{label: 'Show the redirects', icon: 'corner-up-right', action: () => setTab('redirects')}];
|
|
731
|
+
return [{label: 'Go to Settings', icon: 'settings', action: () => { location.hash = x.link; }}];
|
|
732
|
+
}
|
|
733
|
+
function siteMain(row) {
|
|
734
|
+
const x = siteList[rowIndex(row)];
|
|
735
|
+
if (x?.kind === 'dupe') openItem(x.paths[0]);
|
|
736
|
+
else if (x?.kind === 'chains') setTab('redirects');
|
|
737
|
+
else if (x?.link) location.hash = x.link;
|
|
738
|
+
}
|
|
739
|
+
function ruleItems(row) {
|
|
740
|
+
const r = ruleList[rowIndex(row)];
|
|
741
|
+
if (!r) return [];
|
|
742
|
+
const rules = redirects.peek().rules;
|
|
743
|
+
const chained = redirects.peek().chains.some(c => c.id === r.id);
|
|
744
|
+
// Where the chain really ends: follow it, as a visitor would.
|
|
745
|
+
let end = null;
|
|
746
|
+
if (chained) {
|
|
747
|
+
end = r.to;
|
|
748
|
+
for (let i = 0; i < 10; i++) {
|
|
749
|
+
const hop = matchRedirect(rules, end);
|
|
750
|
+
if (!hop) break;
|
|
751
|
+
if (hop.code === 410) { end = null; break; }
|
|
752
|
+
end = hop.location;
|
|
753
|
+
if (/^https?:/i.test(end)) break;
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
return [
|
|
757
|
+
{header: `${r.from} → ${r.code === 410 ? 'gone' : r.to}`},
|
|
758
|
+
{label: 'Edit…', icon: 'edit-3', disabled: !canManage.peek(), action: () => editRule(r)},
|
|
759
|
+
{label: 'Test it', icon: 'play', action: () => testRule(r.from)},
|
|
760
|
+
{label: 'Go straight there', icon: 'fast-forward', disabled: !end || !canManage.peek(),
|
|
761
|
+
action: () => act(() => api(`/redirects/${encodeURIComponent(r.id)}`, {method: 'PUT', body: JSON.stringify({to: end})}), `${r.from} now goes straight to ${end}.`)},
|
|
762
|
+
{label: 'Open the old address', icon: 'external-link', disabled: r.from.endsWith('/*'), action: () => window.open(r.from, '_blank', 'noopener')},
|
|
763
|
+
{type: 'divider'},
|
|
764
|
+
{label: 'Copy', icon: 'copy', action: () => copy(`${r.from} ${r.to} ${r.code}`, 'the redirect')},
|
|
765
|
+
{label: 'Remove', icon: 'trash-2', danger: true, disabled: !canManage.peek(), action: async () => {
|
|
766
|
+
if (await E.confirm(`Remove the redirect from ${esc(r.from)}? Visitors to it will get "not found".`)) removeRules([r.id], 'Redirect');
|
|
767
|
+
}}
|
|
768
|
+
];
|
|
769
|
+
}
|
|
770
|
+
function entryItems(row) {
|
|
771
|
+
const e = smList[rowIndex(row)];
|
|
772
|
+
if (!e) return [];
|
|
773
|
+
return [
|
|
774
|
+
{header: e.urlPath},
|
|
775
|
+
{label: 'Title, description and checks…', icon: 'edit-3', action: () => openItem(e.urlPath)},
|
|
776
|
+
{label: 'Open', icon: 'external-link', action: () => window.open(e.urlPath, '_blank', 'noopener')},
|
|
777
|
+
{type: 'divider'},
|
|
778
|
+
{label: 'Copy the address', icon: 'copy', action: () => copy(e.urlPath, 'the address')}
|
|
779
|
+
];
|
|
780
|
+
}
|
|
781
|
+
function sourceItems(row) {
|
|
782
|
+
const s = sitemap.peek().sources[rowIndex(row)];
|
|
783
|
+
if (!s) return [];
|
|
784
|
+
return [{header: labelOf(s.id)},
|
|
785
|
+
{label: s.on ? 'Leave all of it out' : 'List it again', icon: s.on ? 'eye-off' : 'eye', disabled: !canManage.peek(), action: () => setSource(s.id, !s.on)}];
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
function wireList(el, items, main) {
|
|
789
|
+
const menu = E.contextMenu(el, {cursor: 'glyph', match: '[data-row]', inherit: false, density: 'compact', items: (row) => items(row)});
|
|
790
|
+
scope(() => menu?.destroy?.());
|
|
791
|
+
const onClick = (e) => {
|
|
792
|
+
const row = e.target.closest('[data-row]');
|
|
793
|
+
if (!row || e.target.closest('a')) return;
|
|
794
|
+
if (e.target.closest('.sox-kebab')) { k.openMenuAt(row); return; }
|
|
795
|
+
main?.(row);
|
|
796
|
+
};
|
|
797
|
+
const onKey = (e) => {
|
|
798
|
+
const row = e.target.closest('[data-row]');
|
|
799
|
+
if (!row) return;
|
|
800
|
+
if (e.key === 'F10' && e.shiftKey) { e.preventDefault(); k.openMenuAt(row); }
|
|
801
|
+
else if (e.key === 'Enter' && e.target === row) main?.(row);
|
|
802
|
+
};
|
|
803
|
+
el.addEventListener('click', onClick);
|
|
804
|
+
el.addEventListener('keydown', onKey);
|
|
805
|
+
scope(() => { el.removeEventListener('click', onClick); el.removeEventListener('keydown', onKey); });
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// ---- Extension tabs (SEO Pro) ----
|
|
809
|
+
const extHost = root.querySelector('.sox-ext-body');
|
|
810
|
+
const extButtons = root.querySelector('.sox-ext-tabs');
|
|
811
|
+
let extMounted = null;
|
|
812
|
+
async function loadExtensions() {
|
|
813
|
+
let tabs = [];
|
|
814
|
+
try { tabs = (await api('/extensions'))?.tabs || []; } catch { /* none */ }
|
|
815
|
+
if (!here()) return;
|
|
816
|
+
extTabs.value = tabs;
|
|
817
|
+
extButtons.replaceChildren(...tabs.map(t => {
|
|
818
|
+
const b = document.createElement('button');
|
|
819
|
+
b.type = 'button';
|
|
820
|
+
b.setAttribute('role', 'tab');
|
|
821
|
+
b.dataset.ext = t.key;
|
|
822
|
+
b.innerHTML = `<span data-icon="${esc(t.icon || 'box')}" data-icon-size="14"></span> ${esc(t.label)} <span class="sox-pro">PRO</span>`;
|
|
823
|
+
if (t.help) b.title = t.help;
|
|
824
|
+
b.addEventListener('click', () => setTab(t.key));
|
|
825
|
+
return b;
|
|
826
|
+
}));
|
|
827
|
+
I.scan(extButtons);
|
|
828
|
+
syncExt();
|
|
829
|
+
}
|
|
830
|
+
async function syncExt() {
|
|
831
|
+
for (const b of extButtons.querySelectorAll('button')) b.classList.toggle('is-on', b.dataset.ext === tab.peek());
|
|
832
|
+
const t = extTabs.peek().find(x => x.key === tab.peek());
|
|
833
|
+
if (!t) {
|
|
834
|
+
if (!TABS.includes(tab.peek())) setTab('overview');
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
if (extMounted?.key === t.key) return;
|
|
838
|
+
try { extMounted?.dispose?.(); } catch { /* gone */ }
|
|
839
|
+
extHost.replaceChildren();
|
|
840
|
+
try {
|
|
841
|
+
const mod = await import(t.module);
|
|
842
|
+
if (!here() || tab.peek() !== t.key) return;
|
|
843
|
+
const dispose = await mod.mount(extHost, {kit: k, api: k.apiRequest, scope, here, openItem, reload: loadAll, E, M, I});
|
|
844
|
+
extMounted = {key: t.key, dispose};
|
|
845
|
+
} catch (e) {
|
|
846
|
+
extHost.innerHTML = `<p class="sox-note is-bad">${esc(t.label)} could not be shown: ${esc(e.message || e)}</p>`;
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
scope(() => { try { extMounted?.dispose?.(); } catch { /* gone */ } });
|
|
850
|
+
|
|
851
|
+
// ---- Bindings ----
|
|
852
|
+
root.querySelector('.sox-pages').innerHTML = PAGE_ROW;
|
|
853
|
+
root.querySelector('.sox-site').innerHTML = SITE_ROW;
|
|
854
|
+
root.querySelector('.sox-rules').innerHTML = RULE_ROW;
|
|
855
|
+
root.querySelector('.sox-entries').innerHTML = ENTRY_ROW;
|
|
856
|
+
root.querySelector('.sox-sources').innerHTML = SOURCE_ROW;
|
|
857
|
+
const select = root.querySelector('.sox-select');
|
|
858
|
+
scope(M.effect(() => {
|
|
859
|
+
const opts = sourceOptions.value;
|
|
860
|
+
select.replaceChildren(new Option('Everything', ''), ...opts.map(o => new Option(labelOf(o), o)));
|
|
861
|
+
select.value = source.peek();
|
|
862
|
+
}));
|
|
863
|
+
function setTab(t) {
|
|
864
|
+
if (tab.peek() !== t) {
|
|
865
|
+
tab.value = t;
|
|
866
|
+
history.replaceState(null, '', `#/plugins/seo${t === 'overview' ? '' : `/${t}`}`);
|
|
867
|
+
if (t === 'redirects') loadRedirects();
|
|
868
|
+
if (t === 'sitemap') loadSitemap();
|
|
869
|
+
}
|
|
870
|
+
syncExt();
|
|
871
|
+
}
|
|
872
|
+
const model = {tab, busy, error, hooksOk, canManage, scoreText, scoreWidth, scoreTone, ov, attention, asOf, sourceOptions, source, show, query,
|
|
873
|
+
pageRows, pagesEmpty, siteRows, ruleRows, ruleCount, ruleText, rulesEmpty, chainText, renameText, ruleQuery, newFrom, newTo, newCode,
|
|
874
|
+
sourceRows, smRows, smShow, smQuery, smText, smEmpty, robots, extOn};
|
|
875
|
+
const methods = {
|
|
876
|
+
tab_overview: () => setTab('overview'), tab_redirects: () => setTab('redirects'), tab_sitemap: () => setTab('sitemap'),
|
|
877
|
+
show_all: () => { show.value = 'all'; }, show_problems: () => { show.value = 'problems'; }, show_hidden: () => { show.value = 'hidden'; },
|
|
878
|
+
sm_in: () => { smShow.value = 'in'; }, sm_out: () => { smShow.value = 'out'; },
|
|
879
|
+
importCsv,
|
|
880
|
+
exportRules: () => {
|
|
881
|
+
const rules = redirects.peek().rules;
|
|
882
|
+
if (!rules.length) { E.toast('Nothing to export.', {type: 'info'}); return; }
|
|
883
|
+
download(`redirects-${location.hostname}-${new Date().toISOString().slice(0, 10)}.csv`,
|
|
884
|
+
toCsv(rules.map(r => ({from: r.from, to: r.to, code: r.code, note: r.note || '', hits: r.hits || 0, last_used: r.lastHit || ''}))));
|
|
885
|
+
},
|
|
886
|
+
addRule: () => {
|
|
887
|
+
const body = {from: newFrom.peek(), to: newTo.peek(), code: Number(newCode.peek())};
|
|
888
|
+
const {errors} = checkRule(body, redirects.peek().rules);
|
|
889
|
+
if (Object.keys(errors).length) { E.toast(Object.values(errors).join(' '), {type: 'warning'}); return false; }
|
|
890
|
+
addRedirect(body).then((r) => { if (r) { newFrom.value = ''; newTo.value = ''; } });
|
|
891
|
+
return false;
|
|
892
|
+
}
|
|
893
|
+
};
|
|
894
|
+
const bindings = M.applyBindings(model, root, {methods});
|
|
895
|
+
scope(() => bindings.dispose());
|
|
896
|
+
scope(k.helpPopovers(root, {position: 'bottom'}));
|
|
897
|
+
wireList(root.querySelector('.sox-pages'), pageItems, (row) => { const r = pageList[rowIndex(row)]; if (r) openItem(r.urlPath); });
|
|
898
|
+
wireList(root.querySelector('.sox-site'), siteItems, siteMain);
|
|
899
|
+
wireList(root.querySelector('.sox-rules'), ruleItems, (row) => { const r = ruleList[rowIndex(row)]; if (r && canManage.peek()) editRule(r); });
|
|
900
|
+
wireList(root.querySelector('.sox-entries'), entryItems, (row) => { const e = smList[rowIndex(row)]; if (e) openItem(e.urlPath); });
|
|
901
|
+
wireList(root.querySelector('.sox-sources'), sourceItems);
|
|
902
|
+
|
|
903
|
+
k.addBannerAction($container, {id: 'seo-check', icon: 'refresh-cw', title: 'Check every page again', onClick: checkNow});
|
|
904
|
+
k.addBannerAction($container, {id: 'seo-settings', icon: 'settings', title: 'SEO settings', onClick: openSettings});
|
|
905
|
+
|
|
906
|
+
I.scan(root);
|
|
907
|
+
await Promise.all([loadAll(), loadExtensions()]);
|
|
908
|
+
if (here()) I.scan(root);
|
|
909
|
+
if (here() && !overview.peek()?.at && !overview.peek()?.running) checkNow();
|
|
910
|
+
}
|
|
911
|
+
};
|
|
912
|
+
|