domma-cms 0.89.0 → 0.90.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/dist/domma/domma-tools.css +3 -3
- package/admin/dist/domma/domma-tools.min.js +3 -3
- package/admin/js/app.js +2 -2
- package/admin/js/lib/page-editor-arrange.js +1 -1
- package/admin/js/lib/page-editor-reference.js +153 -0
- package/admin/js/lib/seo-analyse.js +1 -0
- package/admin/js/lib/seo-redirects.js +1 -0
- package/admin/js/lib/seo-schema.js +2 -0
- package/admin/js/lib/seo-settings.js +2 -0
- package/admin/js/templates/page-editor.html +119 -80
- package/{plugins/seo/admin → admin/js}/templates/seo.html +1 -2
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/page-editor.js +62 -90
- package/admin/js/views/seo.js +144 -0
- package/package.json +2 -2
- package/plugins/free-tier.lock.json +0 -19
- package/server/routes/api/pages.js +10 -13
- package/server/routes/api/seo.js +201 -0
- package/server/server.js +12 -1
- package/server/services/menus.js +43 -0
- package/server/services/permissionRegistry.js +11 -0
- package/server/services/plugins.js +12 -2
- package/{plugins/seo/server → server/services/seo}/audit.js +2 -2
- package/server/services/seo/index.js +345 -0
- package/{plugins/seo/server → server/services/seo}/registry.js +5 -3
- package/server/services/seo/store.js +110 -0
- package/server/services/siteGitignore.js +4 -1
- package/server/services/tools.js +2 -1
- package/admin/js/templates/navigation.html +0 -226
- package/admin/js/views/navigation.js +0 -23
- package/plugins/seo/CLAUDE.md +0 -48
- package/plugins/seo/admin/lib/analyse.js +0 -286
- package/plugins/seo/admin/lib/redirects.js +0 -163
- package/plugins/seo/admin/lib/schema.js +0 -113
- package/plugins/seo/admin/lib/settings.js +0 -141
- package/plugins/seo/admin/views/seo.js +0 -912
- package/plugins/seo/config.js +0 -7
- package/plugins/seo/plugin.js +0 -477
- package/plugins/seo/plugin.json +0 -64
- package/plugins/seo/server/store.js +0 -76
- package/plugins/seo/tests/seo.test.js +0 -192
|
@@ -1,226 +0,0 @@
|
|
|
1
|
-
<div class="view-header">
|
|
2
|
-
<h1><span data-icon="menu"></span> Navigation</h1>
|
|
3
|
-
<button id="save-nav-btn" class="btn btn-primary"><span data-icon="save"></span> Save</button>
|
|
4
|
-
</div>
|
|
5
|
-
|
|
6
|
-
<div id="nav-tabs" class="tabs">
|
|
7
|
-
<div class="tab-list">
|
|
8
|
-
<button class="tab-item active">Brand</button>
|
|
9
|
-
<button class="tab-item">Navbar Style</button>
|
|
10
|
-
<button class="tab-item">Menu</button>
|
|
11
|
-
<button class="tab-item">Footer</button>
|
|
12
|
-
</div>
|
|
13
|
-
|
|
14
|
-
<div class="tab-panel active" style="padding: 1.5rem;">
|
|
15
|
-
<div class="row mb-3">
|
|
16
|
-
<div class="col-4">
|
|
17
|
-
<label class="form-label">Site Name</label>
|
|
18
|
-
<input id="field-brand-text" type="text" class="form-input" placeholder="My Site">
|
|
19
|
-
</div>
|
|
20
|
-
<div class="col-3">
|
|
21
|
-
<label class="form-label">Brand URL</label>
|
|
22
|
-
<input id="field-brand-url" type="text" class="form-input" value="/">
|
|
23
|
-
</div>
|
|
24
|
-
<div class="col-3">
|
|
25
|
-
<label class="form-label">Icon <span class="text-muted" style="font-weight:normal;font-size:.8em;">(optional)</span></label>
|
|
26
|
-
<input id="field-brand-icon" type="text" class="form-input" placeholder="e.g. star, home">
|
|
27
|
-
</div>
|
|
28
|
-
<div class="col-2">
|
|
29
|
-
<label class="form-label">Variant</label>
|
|
30
|
-
<select id="field-nav-variant" class="form-select">
|
|
31
|
-
<option value="dark">Dark</option>
|
|
32
|
-
<option value="light">Light</option>
|
|
33
|
-
<option value="transparent">Transparent</option>
|
|
34
|
-
</select>
|
|
35
|
-
</div>
|
|
36
|
-
</div>
|
|
37
|
-
<div class="row mb-3">
|
|
38
|
-
<div class="col-4">
|
|
39
|
-
<label class="form-label">Tagline <span class="text-muted" style="font-weight:normal;font-size:.8em;">(optional)</span></label>
|
|
40
|
-
<input id="field-brand-tagline" type="text" class="form-input" placeholder="A short strapline">
|
|
41
|
-
</div>
|
|
42
|
-
<div class="col-4">
|
|
43
|
-
<label class="form-label">Logo URL <span class="text-muted" style="font-weight:normal;font-size:.8em;">(optional, overrides icon)</span></label>
|
|
44
|
-
<input id="field-brand-logo" type="text" class="form-input" placeholder="/content/media/logo.png">
|
|
45
|
-
</div>
|
|
46
|
-
<div class="col-2">
|
|
47
|
-
<label class="form-label">Position</label>
|
|
48
|
-
<select id="field-nav-position" class="form-select">
|
|
49
|
-
<option value="sticky">Sticky</option>
|
|
50
|
-
<option value="fixed">Fixed</option>
|
|
51
|
-
<option value="static">Static</option>
|
|
52
|
-
</select>
|
|
53
|
-
</div>
|
|
54
|
-
<div class="col-2">
|
|
55
|
-
<label class="form-label">Brand Size</label>
|
|
56
|
-
<select id="field-brand-size" class="form-select">
|
|
57
|
-
<option value="sm">Small</option>
|
|
58
|
-
<option value="md">Medium</option>
|
|
59
|
-
<option value="lg">Large</option>
|
|
60
|
-
</select>
|
|
61
|
-
</div>
|
|
62
|
-
</div>
|
|
63
|
-
<p class="form-hint">Icon uses Domma icon names (e.g. <code>star</code>, <code>home</code>, <code>bolt</code>).
|
|
64
|
-
Logo URL overrides the icon when set. Leave both blank for text-only brand.</p>
|
|
65
|
-
|
|
66
|
-
<hr style="margin: 1.25rem 0; border-color: var(--dm-border, rgba(255,255,255,.1));">
|
|
67
|
-
<h3 style="font-size:.9rem;font-weight:600;margin-bottom:1rem;opacity:.7;text-transform:uppercase;letter-spacing:.05em;">
|
|
68
|
-
Admin Panel</h3>
|
|
69
|
-
<div class="row">
|
|
70
|
-
<div class="col-4">
|
|
71
|
-
<label class="form-label">Admin Title</label>
|
|
72
|
-
<input id="field-admin-brand-title" type="text" class="form-input" placeholder="Domma CMS">
|
|
73
|
-
</div>
|
|
74
|
-
<div class="col-3">
|
|
75
|
-
<label class="form-label">Admin Icon <span class="text-muted"
|
|
76
|
-
style="font-weight:normal;font-size:.8em;">(optional)</span></label>
|
|
77
|
-
<input id="field-admin-brand-icon" type="text" class="form-input" placeholder="layout">
|
|
78
|
-
</div>
|
|
79
|
-
<div class="col-5">
|
|
80
|
-
<label class="form-label">Admin Logo URL <span class="text-muted"
|
|
81
|
-
style="font-weight:normal;font-size:.8em;">(optional, overrides icon)</span></label>
|
|
82
|
-
<input id="field-admin-brand-logo" type="text" class="form-input" placeholder="/content/media/logo.png">
|
|
83
|
-
</div>
|
|
84
|
-
</div>
|
|
85
|
-
</div>
|
|
86
|
-
|
|
87
|
-
<div class="tab-panel" style="padding: 1.5rem;">
|
|
88
|
-
<div class="row mb-3">
|
|
89
|
-
<div class="col-5">
|
|
90
|
-
<label class="form-label">Font Family</label>
|
|
91
|
-
<select id="field-nav-font-family" class="form-select">
|
|
92
|
-
<option value="">Inherit from site</option>
|
|
93
|
-
<option value="system-ui">System UI (no web font)</option>
|
|
94
|
-
<optgroup label="Sans-serif">
|
|
95
|
-
<option value="Roboto">Roboto</option>
|
|
96
|
-
<option value="Inter">Inter</option>
|
|
97
|
-
<option value="Open Sans">Open Sans</option>
|
|
98
|
-
<option value="Lato">Lato</option>
|
|
99
|
-
<option value="Nunito">Nunito</option>
|
|
100
|
-
<option value="Poppins">Poppins</option>
|
|
101
|
-
<option value="Montserrat">Montserrat</option>
|
|
102
|
-
<option value="Raleway">Raleway</option>
|
|
103
|
-
<option value="Oswald">Oswald</option>
|
|
104
|
-
<option value="Ubuntu">Ubuntu</option>
|
|
105
|
-
<option value="Mulish">Mulish</option>
|
|
106
|
-
<option value="Rubik">Rubik</option>
|
|
107
|
-
<option value="DM Sans">DM Sans</option>
|
|
108
|
-
<option value="Plus Jakarta Sans">Plus Jakarta Sans</option>
|
|
109
|
-
<option value="Outfit">Outfit</option>
|
|
110
|
-
<option value="Urbanist">Urbanist</option>
|
|
111
|
-
<option value="Manrope">Manrope</option>
|
|
112
|
-
<option value="Quicksand">Quicksand</option>
|
|
113
|
-
<option value="Source Sans 3">Source Sans 3</option>
|
|
114
|
-
<option value="Figtree">Figtree</option>
|
|
115
|
-
<option value="Noto Sans">Noto Sans</option>
|
|
116
|
-
<option value="Work Sans">Work Sans</option>
|
|
117
|
-
<option value="Barlow">Barlow</option>
|
|
118
|
-
<option value="IBM Plex Sans">IBM Plex Sans</option>
|
|
119
|
-
</optgroup>
|
|
120
|
-
<optgroup label="Serif">
|
|
121
|
-
<option value="Merriweather">Merriweather</option>
|
|
122
|
-
<option value="Playfair Display">Playfair Display</option>
|
|
123
|
-
<option value="Lora">Lora</option>
|
|
124
|
-
<option value="Cormorant Garamond">Cormorant Garamond</option>
|
|
125
|
-
<option value="DM Serif Display">DM Serif Display</option>
|
|
126
|
-
<option value="IBM Plex Serif">IBM Plex Serif</option>
|
|
127
|
-
</optgroup>
|
|
128
|
-
<optgroup label="Monospace">
|
|
129
|
-
<option value="Fira Code">Fira Code</option>
|
|
130
|
-
<option value="JetBrains Mono">JetBrains Mono</option>
|
|
131
|
-
<option value="IBM Plex Mono">IBM Plex Mono</option>
|
|
132
|
-
</optgroup>
|
|
133
|
-
<optgroup label="Dyslexia-friendly">
|
|
134
|
-
<option value="Lexend">Lexend</option>
|
|
135
|
-
<option value="Atkinson Hyperlegible">Atkinson Hyperlegible</option>
|
|
136
|
-
<option value="Andika">Andika</option>
|
|
137
|
-
</optgroup>
|
|
138
|
-
</select>
|
|
139
|
-
</div>
|
|
140
|
-
<div class="col-2">
|
|
141
|
-
<label class="form-label">Font Size</label>
|
|
142
|
-
<select id="field-nav-font-size" class="form-select">
|
|
143
|
-
<option value="">Default</option>
|
|
144
|
-
<option value="12px">12px - XS</option>
|
|
145
|
-
<option value="13px">13px - Small</option>
|
|
146
|
-
<option value="14px">14px - Compact</option>
|
|
147
|
-
<option value="15px">15px</option>
|
|
148
|
-
<option value="16px">16px - Base</option>
|
|
149
|
-
<option value="17px">17px</option>
|
|
150
|
-
<option value="18px">18px - Large</option>
|
|
151
|
-
<option value="20px">20px - XL</option>
|
|
152
|
-
</select>
|
|
153
|
-
</div>
|
|
154
|
-
<div class="col-2">
|
|
155
|
-
<label class="form-label">Font Weight</label>
|
|
156
|
-
<select id="field-nav-font-weight" class="form-select">
|
|
157
|
-
<option value="">Default</option>
|
|
158
|
-
<option value="300">Light (300)</option>
|
|
159
|
-
<option value="400">Normal (400)</option>
|
|
160
|
-
<option value="500">Medium (500)</option>
|
|
161
|
-
<option value="600">Semibold (600)</option>
|
|
162
|
-
<option value="700">Bold (700)</option>
|
|
163
|
-
</select>
|
|
164
|
-
</div>
|
|
165
|
-
<div class="col-3">
|
|
166
|
-
<label class="form-label">Letter Spacing</label>
|
|
167
|
-
<select id="field-nav-letter-spacing" class="form-select">
|
|
168
|
-
<option value="">Default</option>
|
|
169
|
-
<option value="-0.03em">Tight (−0.03em)</option>
|
|
170
|
-
<option value="0em">None (0)</option>
|
|
171
|
-
<option value="0.02em">Slightly wide (0.02em)</option>
|
|
172
|
-
<option value="0.05em">Wide (0.05em)</option>
|
|
173
|
-
<option value="0.1em">Very wide (0.1em)</option>
|
|
174
|
-
<option value="0.15em">Spaced (0.15em)</option>
|
|
175
|
-
</select>
|
|
176
|
-
</div>
|
|
177
|
-
</div>
|
|
178
|
-
<div class="row mb-3">
|
|
179
|
-
<div class="col-3">
|
|
180
|
-
<label class="form-label">Icon Size</label>
|
|
181
|
-
<select id="field-nav-icon-size" class="form-select">
|
|
182
|
-
<option value="">Default</option>
|
|
183
|
-
<option value="12px">12px - XS</option>
|
|
184
|
-
<option value="14px">14px - Small</option>
|
|
185
|
-
<option value="16px">16px - Base</option>
|
|
186
|
-
<option value="18px">18px</option>
|
|
187
|
-
<option value="20px">20px</option>
|
|
188
|
-
<option value="22px">22px</option>
|
|
189
|
-
<option value="24px">24px - Large</option>
|
|
190
|
-
<option value="28px">28px - XL</option>
|
|
191
|
-
</select>
|
|
192
|
-
</div>
|
|
193
|
-
</div>
|
|
194
|
-
<p class="form-hint">Font is loaded from Google Fonts automatically. Leave all fields at default to inherit the
|
|
195
|
-
public site font.</p>
|
|
196
|
-
</div>
|
|
197
|
-
|
|
198
|
-
<div class="tab-panel">
|
|
199
|
-
<div style="display:flex;align-items:center;justify-content:space-between;padding:.75rem 1rem;border-bottom:1px solid var(--dm-border,rgba(255,255,255,.08));">
|
|
200
|
-
<span class="text-muted" style="font-size:.85rem;">Add and arrange navbar menu items.</span>
|
|
201
|
-
<button id="add-nav-item" class="btn btn-sm btn-outline"><span data-icon="plus"></span> Add Item</button>
|
|
202
|
-
</div>
|
|
203
|
-
<div class="nav-items-header">
|
|
204
|
-
<span class="nav-col-indent"></span>
|
|
205
|
-
<span class="nav-col-main text-muted"><small>Label</small></span>
|
|
206
|
-
<span class="nav-col-main text-muted"><small>URL</small></span>
|
|
207
|
-
<span class="nav-col-icon text-muted"><small>Icon</small></span>
|
|
208
|
-
<span class="nav-col-parent text-muted"><small>Parent</small></span>
|
|
209
|
-
<span class="nav-col-action text-muted"><small>Actions</small></span>
|
|
210
|
-
</div>
|
|
211
|
-
<div id="nav-items-list"></div>
|
|
212
|
-
</div>
|
|
213
|
-
|
|
214
|
-
<div class="tab-panel">
|
|
215
|
-
<div style="display:flex;align-items:center;justify-content:space-between;padding:.75rem 1rem;border-bottom:1px solid var(--dm-border,rgba(255,255,255,.08));">
|
|
216
|
-
<span class="text-muted" style="font-size:.85rem;">Links shown in the site footer.</span>
|
|
217
|
-
<button id="add-footer-link" class="btn btn-sm btn-outline"><span data-icon="plus"></span> Add Link</button>
|
|
218
|
-
</div>
|
|
219
|
-
<div class="nav-items-header">
|
|
220
|
-
<span class="nav-col-main text-muted"><small>Label</small></span>
|
|
221
|
-
<span class="nav-col-main text-muted"><small>URL</small></span>
|
|
222
|
-
<span class="nav-col-action"></span>
|
|
223
|
-
</div>
|
|
224
|
-
<div id="footer-links-list"></div>
|
|
225
|
-
</div>
|
|
226
|
-
</div>
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
import{api as x}from"../api.js";let I=1;const v=t=>String(t||"").replace(/&/g,"&").replace(/"/g,""").replace(/</g,"<");function S(t){const d=[];return(t||[]).forEach(l=>{const o=I++;d.push({_id:o,text:l.text||"",url:l.url||"",icon:l.icon||"",hidden:l.hidden||!1,bundled:l.bundled||!1,parentId:null}),(l.items||l.children||[]).forEach(a=>{d.push({_id:I++,text:a.text||"",url:a.url||"",icon:a.icon||"",hidden:a.hidden||!1,bundled:a.bundled||!1,parentId:o})})}),d}function F(t){return t.filter(d=>d.parentId===null).map(d=>{const l=t.filter(a=>a.parentId===d._id).map(a=>({text:a.text,url:a.url,...a.icon&&{icon:a.icon},...a.hidden&&{hidden:!0},...a.bundled&&{bundled:!0}})),o={text:d.text,url:d.url,...d.icon&&{icon:d.icon},...d.hidden&&{hidden:!0},...d.bundled&&{bundled:!0}};return l.length&&(o.items=l),o})}function A(t){const d=[];return t.filter(l=>l.parentId===null).forEach(l=>{d.push(l),t.filter(o=>o.parentId===l._id).forEach(o=>d.push(o))}),d}export const navigationView={templateUrl:"/admin/js/templates/navigation.html",async onMount(t){let[d,l]=await Promise.all([x.navigation.get().catch(()=>({brand:{},items:[]})),x.settings.get().catch(()=>({}))]),o=S(d.items),a=(l.footer?.links||[]).map(e=>({text:e.text||"",url:e.url||"",...e.hidden&&{hidden:!0}}));const m=()=>{t.find("#nav-items-list .nav-item-row").each(function(){const i=parseInt($(this).data("id"),10),n=o.find(f=>f._id===i);if(!n)return;n.text=$(this).find(".item-text").val(),n.url=$(this).find(".item-url").val(),n.icon=$(this).find(".item-icon").val(),n.hidden=$(this).find(".btn-toggle-hidden").attr("data-hidden")==="true",n.bundled=$(this).find(".btn-toggle-bundled").attr("data-bundled")==="true";const r=$(this).find(".item-parent").val();n.parentId=r?parseInt(r,10):null});const e=new Set(o.filter(i=>i.parentId!==null).map(i=>i._id));o.forEach(i=>{i.parentId!==null&&e.has(i.parentId)&&(i.parentId=null)})},p=()=>{const e=t.find("#nav-items-list").empty(),i=o.filter(n=>n.parentId===null);A(o).forEach(n=>{const r=n.parentId!==null,f=n.hidden===!0,g='<option value="">- top-level -</option>'+i.filter(c=>c._id!==n._id).map(c=>`<option value="${c._id}"${c._id===n.parentId?" selected":""}>${v(c.text)||"(untitled)"}</option>`).join("");e.append(`
|
|
2
|
-
<div class="nav-item-row${r?" nav-item-row--child":""}${f?" nav-item-row--hidden":""}" data-id="${n._id}">
|
|
3
|
-
<span class="nav-col-indent">${r?"\u21B3":""}</span>
|
|
4
|
-
<input type="text" class="form-input item-text nav-col-main" value="${v(n.text)}" placeholder="Label">
|
|
5
|
-
<input type="text" class="form-input item-url nav-col-main" value="${v(n.url)}" placeholder="/url">
|
|
6
|
-
<input type="text" class="form-input item-icon nav-col-icon" value="${v(n.icon)}" placeholder="icon">
|
|
7
|
-
<select class="form-select item-parent nav-col-parent">${g}</select>
|
|
8
|
-
<span class="nav-col-action">
|
|
9
|
-
<button class="btn btn-sm btn-ghost btn-toggle-bundled${n.bundled?" active":""}" data-id="${n._id}" data-bundled="${!!n.bundled}" data-tooltip="${n.bundled?"Unbundle":"Bundle (seed on install)"}"><span data-icon="package"></span></button>
|
|
10
|
-
<button class="btn btn-sm btn-ghost btn-toggle-hidden${f?" active":""}" data-id="${n._id}" data-hidden="${f}" data-tooltip="${f?"Show":"Hide"}"><span data-icon="eye-off"></span></button>
|
|
11
|
-
<button class="btn btn-sm btn-ghost btn-remove-item" data-id="${n._id}" data-tooltip="Remove"><span data-icon="trash"></span></button>
|
|
12
|
-
</span>
|
|
13
|
-
</div>
|
|
14
|
-
`)}),Domma.icons.scan("#nav-items-list"),document.querySelectorAll("#nav-items-list [data-tooltip]").forEach(n=>{E.tooltip(n,{content:n.getAttribute("data-tooltip"),position:"top"})})},u=()=>{const e=t.find("#footer-links-list").empty();a.forEach((i,n)=>{const r=i.hidden===!0;e.append(`
|
|
15
|
-
<div class="nav-item-row${r?" nav-item-row--hidden":""}" data-footer-idx="${n}">
|
|
16
|
-
<input type="text" class="form-input footer-link-text nav-col-main" value="${v(i.text)}" placeholder="Label">
|
|
17
|
-
<input type="text" class="form-input footer-link-url nav-col-main" value="${v(i.url)}" placeholder="/url">
|
|
18
|
-
<span class="nav-col-action">
|
|
19
|
-
<button class="btn btn-sm btn-ghost btn-toggle-hidden${r?" active":""}" data-idx="${n}" data-hidden="${r}" data-tooltip="${r?"Show":"Hide"}"><span data-icon="eye-off"></span></button>
|
|
20
|
-
<button class="btn btn-sm btn-ghost btn-remove-footer" data-idx="${n}" data-tooltip="Remove"><span data-icon="trash"></span></button>
|
|
21
|
-
</span>
|
|
22
|
-
</div>
|
|
23
|
-
`)}),Domma.icons.scan("#footer-links-list"),document.querySelectorAll("#footer-links-list [data-tooltip]").forEach(i=>{E.tooltip(i,{content:i.getAttribute("data-tooltip"),position:"top"})})},h=()=>{a=[],t.find("#footer-links-list .nav-item-row").each(function(){const e=$(this).find(".btn-toggle-hidden").attr("data-hidden")==="true",i={text:$(this).find(".footer-link-text").val().trim(),url:$(this).find(".footer-link-url").val().trim()};e&&(i.hidden=!0),a.push(i)})};t.find("#add-footer-link").on("click",()=>{h(),a.push({text:"",url:"",hidden:!1}),u()}),t.off("click",".btn-remove-footer").on("click",".btn-remove-footer",function(){h();const e=parseInt($(this).data("idx"),10);a.splice(e,1),u()}),t.find("#field-brand-text").val(d.brand?.text||""),t.find("#field-brand-url").val(d.brand?.url||"/"),t.find("#field-brand-icon").val(d.brand?.icon||""),t.find("#field-nav-variant").val(d.variant||"dark"),t.find("#field-brand-tagline").val(d.brand?.tagline||""),t.find("#field-brand-logo").val(d.brand?.logo||""),t.find("#field-nav-position").val(d.position||"sticky"),t.find("#field-brand-size").val(d.brand?.size||"md"),t.find("#field-nav-font-family").val(d.style?.fontFamily||""),t.find("#field-nav-font-size").val(d.style?.fontSize||""),t.find("#field-nav-font-weight").val(d.style?.fontWeight||""),t.find("#field-nav-letter-spacing").val(d.style?.letterSpacing||""),t.find("#field-nav-icon-size").val(d.style?.iconSize||""),t.find("#field-admin-brand-title").val(l.adminBrand?.title||""),t.find("#field-admin-brand-icon").val(l.adminBrand?.icon||""),t.find("#field-admin-brand-logo").val(l.adminBrand?.logo||""),p(),u(),E.tabs(t.find("#nav-tabs").get(0)),t.find("#add-nav-item").on("click",()=>{m(),o.push({_id:I++,text:"",url:"",icon:"",hidden:!1,parentId:null}),p()}),t.off("click",".btn-remove-item").on("click",".btn-remove-item",function(){m();const e=parseInt($(this).data("id"),10);o=o.filter(i=>i._id!==e&&i.parentId!==e),p()}),t.off("click","#nav-items-list .btn-toggle-bundled").on("click","#nav-items-list .btn-toggle-bundled",function(e){e.stopPropagation(),m();const i=parseInt($(this).data("id"),10),n=o.find(r=>r._id===i);n&&(n.bundled=!n.bundled),p()}),t.off("click","#nav-items-list .btn-toggle-hidden").on("click","#nav-items-list .btn-toggle-hidden",function(e){e.stopPropagation(),m();const i=parseInt($(this).data("id"),10),n=o.find(r=>r._id===i);n&&(n.hidden=!n.hidden),p()}),t.off("click","#footer-links-list .btn-toggle-hidden").on("click","#footer-links-list .btn-toggle-hidden",function(e){e.stopPropagation(),h();const i=parseInt($(this).data("idx"),10);a[i]&&(a[i].hidden=!a[i].hidden),u()}),t.off("change",".item-parent").on("change",".item-parent",function(){m(),p()}),t.find("#save-nav-btn").on("click",async()=>{m(),h();const e=F(o.map(s=>({...s,text:s.text.trim(),url:s.url.trim(),icon:s.icon.trim()}))).filter(s=>s.text||s.url),i=t.find("#field-brand-icon").val().trim(),n=t.find("#field-brand-tagline").val().trim(),r=t.find("#field-brand-logo").val().trim(),f=t.find("#field-brand-size").val(),g={brand:{text:t.find("#field-brand-text").val().trim(),url:t.find("#field-brand-url").val().trim()||"/",...r&&{logo:r},...i&&!r&&{icon:i},...n&&{tagline:n},...f!=="md"&&{size:f}},items:e,variant:t.find("#field-nav-variant").val(),position:t.find("#field-nav-position").val()||"sticky",style:{...t.find("#field-nav-font-family").val()&&{fontFamily:t.find("#field-nav-font-family").val()},...t.find("#field-nav-font-size").val()&&{fontSize:t.find("#field-nav-font-size").val()},...t.find("#field-nav-font-weight").val()&&{fontWeight:t.find("#field-nav-font-weight").val()},...t.find("#field-nav-letter-spacing").val()&&{letterSpacing:t.find("#field-nav-letter-spacing").val()},...t.find("#field-nav-icon-size").val()&&{iconSize:t.find("#field-nav-icon-size").val()}}},c=a.filter(s=>s.text||s.url);try{await x.navigation.save(g),d=g,o=S(d.items),p(),E.toast("Navigation saved.",{type:"success"})}catch(s){console.error("[navigation] save failed:",s),E.toast("Failed to save navigation.",{type:"error"});return}const y=t.find("#field-admin-brand-logo").val().trim(),k=t.find("#field-admin-brand-icon").val().trim(),_=t.find("#field-admin-brand-title").val().trim(),b={..._&&{title:_},...y&&{logo:y},...!y&&k&&{icon:k}};try{await x.settings.save({adminBrand:b,footer:{links:c}}),l={...l,adminBrand:b},a=c,u();const s=b.title||"CMS Admin",B=b.icon||"layout",w=b.logo||"",z=$("#admin-topbar .topbar-brand");w?z.html(`<img src="${w}" class="topbar-brand-logo" alt="${s}"><span class="topbar-brand-text">${s}</span>`):z.html(`<span data-icon="${B}"></span><span class="topbar-brand-text">${s}</span>`),Domma.icons.scan("#admin-topbar .topbar-brand")}catch(s){console.error("[navigation] admin brand / footer save failed:",s),E.toast(`Admin brand save failed: ${s.message||s}`,{type:"error"})}})}};
|
package/plugins/seo/CLAUDE.md
DELETED
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
# SEO (free) - AI Assistant Guide
|
|
2
|
-
|
|
3
|
-
How the site looks to search engines, and what to fix. Free tier (`make sync-free-tier` ships it with the engine).
|
|
4
|
-
SEO Pro (paid) is an ADD-ON (`requires: ["seo"]`, no supersedes) that plugs in through `server/registry.js`.
|
|
5
|
-
Social (paid, planned) will be its own plugin, sold with SEO Pro.
|
|
6
|
-
|
|
7
|
-
Needs core 0.89 for: `hooks.registerSitemapSource` / `sitemapChanged`, and the `seo:meta`, `sitemap:entries`,
|
|
8
|
-
`robots:lines` transforms (core `renderer.js` buildSeoMeta/renderSeoTags, `sitemap.js`). On an older engine the plugin
|
|
9
|
-
logs it, the screen says so, and the audit, editor and redirects still work (`hasCore` in plugin.js).
|
|
10
|
-
|
|
11
|
-
## What it does
|
|
12
|
-
- **Audit** (`server/audit.js`): every sitemap URL (`listSitemapEntries`) plus published pages kept out of it, fetched
|
|
13
|
-
IN-PROCESS with `fastify.inject` (UA `DommaSEO/1 (audit)`, header `x-domma-seo: audit` - analytics / SEO Pro skip
|
|
14
|
-
those), judged by `admin/lib/analyse.js` (pure: `scanHtml` regex scanner - no DOM, `analyse`, `siteIssues`, `snippet`).
|
|
15
|
-
Every `audit.everyHours` (first run 3 min after start if stale), or POST /audit. Kept in `data/audit.json`.
|
|
16
|
-
- **Editor per URL** (GET/PUT /item): a core page is edited IN PLACE (`updatePage(path, {seo})`, empty -> null so core's
|
|
17
|
-
merge removes the key); anything else (a blog post) gets an override in `data/overrides.json`, applied by the
|
|
18
|
-
`seo:meta` transform (`admin/lib/schema.js applySeo`). Fields: title, description, image, noindex, focus.
|
|
19
|
-
- **Redirects** (`admin/lib/redirects.js`, pure): exact and "/prefix/*" rules, 301/302/307/308/410, query kept, no
|
|
20
|
-
loops (checkRule follows the chain), `onMoved` on core's `content:pageRenamed` re-aims older rules (no chains) and
|
|
21
|
-
drops a rule FROM the new address. Guard = global `onRequest` (fastify-plugin), GET/HEAD, never /api or /admin.
|
|
22
|
-
Hits counted in memory, saved each minute.
|
|
23
|
-
- **Sitemap/robots**: `sitemap:entries` drops excluded paths, switched-off sources, overridden noindex and redirected
|
|
24
|
-
addresses; `robots:lines` adds rules (plain rules into the `*` group; `User-agent:` blocks as their own groups) or
|
|
25
|
-
blocks everything. JSON-LD: Organization/LocalBusiness/Person on `/`, BlogPosting when `page.item.type === 'blog:post'`.
|
|
26
|
-
|
|
27
|
-
## Data (plugins/seo/data - kept by updates, never bundled)
|
|
28
|
-
`redirects.json`, `overrides.json`, `audit.json` - in memory (the guard and transform run on every request), debounced
|
|
29
|
-
writes, re-read within ~3 s when changed on disk (`server/store.js`).
|
|
30
|
-
|
|
31
|
-
## Permission
|
|
32
|
-
`seo` (read / manage), granted to admin. Routes: /overview /count /audit (GET, POST) /item (GET, PUT) /redirects
|
|
33
|
-
(GET, POST, PUT/:id, DELETE/:id - ids comma-separated) /redirects/import /redirects/test /sitemap /settings /extensions.
|
|
34
|
-
|
|
35
|
-
## Extensions (SEO Pro)
|
|
36
|
-
`globalThis[Symbol.for('domma.seo')]` (`server/registry.js`): `register(owner, {label, tabs})`, `on('audit', fn)`,
|
|
37
|
-
`services` {settings, listEntries, auditList, fetchPage, scanHtml, analyse, audit {latest, summary, run},
|
|
38
|
-
redirects {list, match, add}, auditUserAgent}. Tabs: `{key, label, icon, module, help}`; `module` is a
|
|
39
|
-
`/plugins/<slug>/...` URL exporting `mount(root, ctx)` -> dispose; ctx = {kit, api, scope, here, openItem, reload, E, M, I}.
|
|
40
|
-
|
|
41
|
-
## Admin
|
|
42
|
-
`admin/views/seo.js` + `admin/templates/seo.html` (prefix `sox-`): Pages (the audit + "Across the site"), Redirects,
|
|
43
|
-
Sitemap, extension tabs; page editor slideover (search result + share card previews, meters, checks); settings
|
|
44
|
-
slideover. Rows by index, text through data-bind-text only.
|
|
45
|
-
|
|
46
|
-
## Tests
|
|
47
|
-
`tests/seo.test.js` (pure). Headless: throwaway CMS from a clean domma-cms worktree with plugins/seo + blog copied in;
|
|
48
|
-
puppeteer-core + ~/.cache/ms-playwright chromium_headless_shell for screenshots.
|
|
@@ -1,286 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* SEO - reading a rendered page and judging it. Pure: no DOM, no network, so
|
|
3
|
-
* the server's audit, the admin screen and the tests share it.
|
|
4
|
-
*
|
|
5
|
-
* scanHtml(html) -> facts (title, description, headings, images, links, words...)
|
|
6
|
-
* analyse(facts, opts) -> {score, checks: [{id, group, status, title, detail, fix}]}
|
|
7
|
-
* siteIssues(results) -> duplicates across pages (titles, descriptions)
|
|
8
|
-
* snippet({title, ...}) -> what a search result would show, cut where Google cuts
|
|
9
|
-
*
|
|
10
|
-
* A status is 'pass' | 'warn' | 'fail' | 'info'. The score counts a pass as
|
|
11
|
-
* 1, a warn as 0.5, a fail as 0, and leaves 'info' out - as Security does.
|
|
12
|
-
*
|
|
13
|
-
* @module seo/admin/lib/analyse
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
export const LIMITS = {titleMin: 30, titleMax: 60, descMin: 70, descMax: 160, minWords: 300};
|
|
17
|
-
|
|
18
|
-
const ENTITIES = {amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', ndash: '–', mdash: '—', hellip: '…',
|
|
19
|
-
lsquo: '‘', rsquo: '’', ldquo: '“', rdquo: '”', pound: '£', euro: '€', copy: '©'};
|
|
20
|
-
|
|
21
|
-
/** Undo HTML entities in text. */
|
|
22
|
-
export function decode(s) {
|
|
23
|
-
return String(s ?? '').replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (m, e) => {
|
|
24
|
-
if (e[0] === '#') {
|
|
25
|
-
const n = e[1] === 'x' || e[1] === 'X' ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10);
|
|
26
|
-
return Number.isFinite(n) && n > 0 && n < 0x110000 ? String.fromCodePoint(n) : m;
|
|
27
|
-
}
|
|
28
|
-
return ENTITIES[e.toLowerCase()] ?? m;
|
|
29
|
-
});
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const squash = (s) => decode(String(s ?? '').replace(/<[^>]*>/g, ' ')).replace(/\s+/g, ' ').trim();
|
|
33
|
-
|
|
34
|
-
/** The attributes of one start tag: `<meta name="x" content='y' async>` -> {name, content, async: ''}. */
|
|
35
|
-
export function attrs(tag) {
|
|
36
|
-
const out = {};
|
|
37
|
-
const body = String(tag).replace(/^<\s*[a-z0-9-]+/i, '').replace(/\/?>$/, '');
|
|
38
|
-
const re = /([^\s"'=<>`/]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;
|
|
39
|
-
let m;
|
|
40
|
-
while ((m = re.exec(body))) out[m[1].toLowerCase()] = decode(m[2] ?? m[3] ?? m[4] ?? '');
|
|
41
|
-
return out;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* The facts of one page, from its HTML. Scripts, styles, templates and
|
|
46
|
-
* comments are removed first, so nothing inside them counts.
|
|
47
|
-
*
|
|
48
|
-
* @param {string} html
|
|
49
|
-
* @returns {object}
|
|
50
|
-
*/
|
|
51
|
-
export function scanHtml(html) {
|
|
52
|
-
const src = String(html ?? '');
|
|
53
|
-
const headEnd = src.search(/<\/head\s*>/i);
|
|
54
|
-
const head = headEnd >= 0 ? src.slice(0, headEnd) : src;
|
|
55
|
-
|
|
56
|
-
const jsonLdTypes = [];
|
|
57
|
-
for (const m of src.matchAll(/<script\b[^>]*type\s*=\s*["']?application\/ld\+json["']?[^>]*>([\s\S]*?)<\/script>/gi)) {
|
|
58
|
-
try {
|
|
59
|
-
const j = JSON.parse(m[1]);
|
|
60
|
-
for (const x of [j, ...(Array.isArray(j) ? j : []), ...(Array.isArray(j?.['@graph']) ? j['@graph'] : [])]) {
|
|
61
|
-
const t = x?.['@type'];
|
|
62
|
-
if (t) jsonLdTypes.push(...(Array.isArray(t) ? t : [t]).map(String));
|
|
63
|
-
}
|
|
64
|
-
} catch { jsonLdTypes.push('(invalid)'); }
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
const clean = src
|
|
68
|
-
.replace(/<!--[\s\S]*?-->/g, ' ')
|
|
69
|
-
.replace(/<(script|style|template|noscript|svg)\b[\s\S]*?<\/\1\s*>/gi, ' ');
|
|
70
|
-
|
|
71
|
-
const metas = [...head.matchAll(/<meta\b[^>]*>/gi)].map(m => attrs(m[0]));
|
|
72
|
-
const meta = (key, val) => metas.find(a => (a[key] || '').toLowerCase() === val)?.content;
|
|
73
|
-
const links = [...head.matchAll(/<link\b[^>]*>/gi)].map(m => attrs(m[0]));
|
|
74
|
-
|
|
75
|
-
const bodyStart = clean.search(/<body\b/i);
|
|
76
|
-
const body = bodyStart >= 0 ? clean.slice(bodyStart) : clean;
|
|
77
|
-
|
|
78
|
-
const headings = [...body.matchAll(/<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1\s*>/gi)].map(m => ({level: Number(m[1]), text: squash(m[2])}));
|
|
79
|
-
const images = [...body.matchAll(/<img\b[^>]*>/gi)].map(m => {
|
|
80
|
-
const a = attrs(m[0]);
|
|
81
|
-
return {src: a.src || a['data-src'] || '', alt: 'alt' in a ? a.alt : null, role: a.role || ''};
|
|
82
|
-
});
|
|
83
|
-
const anchors = [...body.matchAll(/<a\b([^>]*)>([\s\S]*?)<\/a\s*>/gi)].map(m => {
|
|
84
|
-
const a = attrs(`<a ${m[1]}>`);
|
|
85
|
-
return {href: a.href || '', text: squash(m[2]), rel: a.rel || '', title: a.title || ''};
|
|
86
|
-
});
|
|
87
|
-
const text = squash(body);
|
|
88
|
-
const firstPara = squash(body.match(/<p\b[^>]*>([\s\S]*?)<\/p\s*>/i)?.[1] || '');
|
|
89
|
-
|
|
90
|
-
return {
|
|
91
|
-
title: squash(head.match(/<title\b[^>]*>([\s\S]*?)<\/title\s*>/i)?.[1] || ''),
|
|
92
|
-
description: meta('name', 'description') ?? null,
|
|
93
|
-
robots: meta('name', 'robots') || '',
|
|
94
|
-
viewport: Boolean(meta('name', 'viewport')),
|
|
95
|
-
canonical: links.find(l => (l.rel || '').toLowerCase() === 'canonical')?.href || '',
|
|
96
|
-
lang: attrs(src.match(/<html\b[^>]*>/i)?.[0] || '<html>').lang || '',
|
|
97
|
-
og: {
|
|
98
|
-
title: meta('property', 'og:title') || '',
|
|
99
|
-
description: meta('property', 'og:description') || '',
|
|
100
|
-
image: meta('property', 'og:image') || '',
|
|
101
|
-
type: meta('property', 'og:type') || ''
|
|
102
|
-
},
|
|
103
|
-
twitter: {card: meta('name', 'twitter:card') || '', image: meta('name', 'twitter:image') || ''},
|
|
104
|
-
headings,
|
|
105
|
-
images,
|
|
106
|
-
links: anchors,
|
|
107
|
-
words: text ? text.split(/\s+/).filter(w => /[\p{L}\p{N}]/u.test(w)).length : 0,
|
|
108
|
-
text: text.slice(0, 20000),
|
|
109
|
-
firstPara: firstPara.slice(0, 1000),
|
|
110
|
-
jsonLdTypes: [...new Set(jsonLdTypes)]
|
|
111
|
-
};
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
115
|
-
const has = (hay, needle) => Boolean(needle) && String(hay || '').toLowerCase().includes(needle);
|
|
116
|
-
const slugWords = (s) => String(s || '').toLowerCase().normalize('NFKD').replace(/[̀-ͯ]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* Judge one page.
|
|
120
|
-
*
|
|
121
|
-
* @param {object} f - scanHtml() facts
|
|
122
|
-
* @param {object} [opts]
|
|
123
|
-
* @param {string} [opts.urlPath]
|
|
124
|
-
* @param {string} [opts.focus] - the focus keyphrase, if one is set
|
|
125
|
-
* @param {object} [opts.limits] - LIMITS overrides
|
|
126
|
-
* @param {string} [opts.origin] - the site's own origin, to tell internal links
|
|
127
|
-
* @returns {{score: number, failing: number, checks: object[]}}
|
|
128
|
-
*/
|
|
129
|
-
export function analyse(f, {urlPath = '/', focus = '', limits = {}, origin = ''} = {}) {
|
|
130
|
-
const L = {...LIMITS, ...limits};
|
|
131
|
-
const checks = [];
|
|
132
|
-
const add = (id, group, status, title, detail, fix = '') => checks.push({id, group, status, title, detail, fix});
|
|
133
|
-
const noindex = /noindex/i.test(f.robots);
|
|
134
|
-
|
|
135
|
-
// --- Search result ---
|
|
136
|
-
const tl = f.title.length;
|
|
137
|
-
if (!tl) add('title', 'Search result', 'fail', 'Title', 'The page has no title.', 'Give it one: it is the blue link people click in the results.');
|
|
138
|
-
else if (tl < L.titleMin) add('title', 'Search result', 'warn', 'Title', `${tl} characters - short. Search engines may write their own.`, `Aim for ${L.titleMin}-${L.titleMax} characters that say what the page is about.`);
|
|
139
|
-
else if (tl > L.titleMax) add('title', 'Search result', 'warn', 'Title', `${tl} characters - it will be cut off after about ${L.titleMax}.`, 'Put the words that matter first, or shorten it.');
|
|
140
|
-
else add('title', 'Search result', 'pass', 'Title', `${tl} characters.`);
|
|
141
|
-
|
|
142
|
-
const d = f.description;
|
|
143
|
-
const dl = (d || '').length;
|
|
144
|
-
if (!dl) add('description', 'Search result', 'fail', 'Description', 'No description: search engines pick any sentence from the page.', 'Write one or two sentences that make someone want to click.');
|
|
145
|
-
else if (dl < L.descMin) add('description', 'Search result', 'warn', 'Description', `${dl} characters - short.`, `Aim for ${L.descMin}-${L.descMax} characters.`);
|
|
146
|
-
else if (dl > L.descMax) add('description', 'Search result', 'warn', 'Description', `${dl} characters - it will be cut off after about ${L.descMax}.`, 'Shorten it, keeping the point in the first half.');
|
|
147
|
-
else add('description', 'Search result', 'pass', 'Description', `${dl} characters.`);
|
|
148
|
-
|
|
149
|
-
const slug = urlPath.split('/').filter(Boolean).pop() || '';
|
|
150
|
-
if (urlPath !== '/' && (slug.length > 60 || /[A-Z_ %]/.test(slug) || /\d{5,}/.test(slug))) {
|
|
151
|
-
add('url', 'Search result', 'warn', 'Address', `"${slug}" is long or hard to read.`, 'Short, lower-case words joined by hyphens read best - rename the page (a redirect is added for you).');
|
|
152
|
-
} else {
|
|
153
|
-
add('url', 'Search result', 'pass', 'Address', urlPath === '/' ? 'The home page.' : `"${slug}" reads well.`);
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
// --- Content ---
|
|
157
|
-
const h1s = f.headings.filter(h => h.level === 1);
|
|
158
|
-
if (!h1s.length) add('h1', 'Content', 'fail', 'Main heading', 'There is no H1 heading.', 'Start the page with one main heading (# in Markdown) saying what it is about.');
|
|
159
|
-
else if (h1s.length > 1) add('h1', 'Content', 'warn', 'Main heading', `${h1s.length} H1 headings: "${h1s.slice(0, 3).map(h => h.text).join('", "')}".`, 'Keep one H1; make the others H2.');
|
|
160
|
-
else if (!h1s[0].text) add('h1', 'Content', 'warn', 'Main heading', 'The H1 heading is empty.', 'Give it words.');
|
|
161
|
-
else add('h1', 'Content', 'pass', 'Main heading', `"${h1s[0].text.slice(0, 80)}".`);
|
|
162
|
-
|
|
163
|
-
const skips = [];
|
|
164
|
-
for (let i = 1; i < f.headings.length; i++) {
|
|
165
|
-
if (f.headings[i].level > f.headings[i - 1].level + 1) skips.push(`H${f.headings[i - 1].level} → H${f.headings[i].level}`);
|
|
166
|
-
}
|
|
167
|
-
if (skips.length) add('headings', 'Content', 'warn', 'Heading order', `Levels are skipped: ${[...new Set(skips)].slice(0, 3).join(', ')}.`, 'Go down one level at a time (H2, then H3) - screen readers and search engines read them as an outline.');
|
|
168
|
-
else if (f.headings.length > 1) add('headings', 'Content', 'pass', 'Heading order', `${plural(f.headings.length, 'heading')}, in order.`);
|
|
169
|
-
|
|
170
|
-
if (f.words < Math.round(L.minWords / 3)) add('words', 'Content', 'warn', 'Length', `${plural(f.words, 'word')}. Very little for a search engine to go on.`, `Pages that rank usually have ${L.minWords}+ words - unless the page is a form or a contact page.`);
|
|
171
|
-
else if (f.words < L.minWords) add('words', 'Content', 'info', 'Length', `${plural(f.words, 'word')}.`, `Pages that rank usually have ${L.minWords}+ words.`);
|
|
172
|
-
else add('words', 'Content', 'pass', 'Length', `${f.words.toLocaleString('en-GB')} words.`);
|
|
173
|
-
|
|
174
|
-
const content = f.images.filter(i => i.role !== 'presentation');
|
|
175
|
-
const noAlt = content.filter(i => i.alt === null);
|
|
176
|
-
if (noAlt.length) add('alt', 'Content', 'fail', 'Image descriptions', `${plural(noAlt.length, 'image')} of ${content.length} ${noAlt.length === 1 ? 'has' : 'have'} no alt text.`, 'Describe each image in a few words (empty alt="" only for decoration). It is how search and screen readers see them.');
|
|
177
|
-
else if (content.length) add('alt', 'Content', 'pass', 'Image descriptions', `All ${plural(content.length, 'image')} described.`);
|
|
178
|
-
|
|
179
|
-
const internal = f.links.filter(l => isInternal(l.href, origin));
|
|
180
|
-
if (!internal.length && f.links.length < 50) add('links', 'Content', 'warn', 'Links to your other pages', 'None in the page itself.', 'Link to related pages: it helps visitors, and search engines find pages through links.');
|
|
181
|
-
else add('links', 'Content', 'pass', 'Links to your other pages', `${plural(internal.length, 'link')}.`);
|
|
182
|
-
|
|
183
|
-
// --- Focus keyphrase ---
|
|
184
|
-
const k = String(focus || '').trim().toLowerCase();
|
|
185
|
-
if (k) {
|
|
186
|
-
const where = [
|
|
187
|
-
['title', has(f.title, k)],
|
|
188
|
-
['description', has(d, k)],
|
|
189
|
-
['main heading', h1s.some(h => has(h.text, k))],
|
|
190
|
-
['address', slugWords(urlPath).includes(slugWords(k))],
|
|
191
|
-
['first paragraph', has(f.firstPara, k)]
|
|
192
|
-
];
|
|
193
|
-
const missing = where.filter(([, ok]) => !ok).map(([w]) => w);
|
|
194
|
-
const count = countPhrase(f.text, k);
|
|
195
|
-
const density = f.words ? (count * k.split(/\s+/).length * 100) / f.words : 0;
|
|
196
|
-
if (missing.length >= 3) add('focus', 'Keyphrase', 'fail', `"${focus}"`, `Missing from the ${missing.join(', ')}.`, 'Use the phrase people search for where they look first: the title, the heading and the opening.');
|
|
197
|
-
else if (missing.length) add('focus', 'Keyphrase', 'warn', `"${focus}"`, `Missing from the ${missing.join(', ')}.`, 'Work it in naturally - never at the cost of reading well.');
|
|
198
|
-
else add('focus', 'Keyphrase', 'pass', `"${focus}"`, 'In the title, description, heading, address and opening.');
|
|
199
|
-
if (f.words < 100) { /* too short for a share of the words to mean anything */ }
|
|
200
|
-
else if (density > 3) add('density', 'Keyphrase', 'warn', 'Repetition', `The phrase is ${density.toFixed(1)}% of the words (${plural(count, 'time')}).`, 'Over about 3% reads as stuffing - use other words for the same thing.');
|
|
201
|
-
else if (count) add('density', 'Keyphrase', 'pass', 'Repetition', `Used ${plural(count, 'time')} (${density.toFixed(1)}%).`);
|
|
202
|
-
} else {
|
|
203
|
-
add('focus', 'Keyphrase', 'info', 'Focus keyphrase', 'None set.', 'Say which search this page should be found for, and the checks will look for it.');
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
// --- Sharing and technical ---
|
|
207
|
-
if (noindex) add('index', 'Technical', 'info', 'Search engines', 'Hidden from search engines (noindex).', 'On purpose for thank-you pages and the like; otherwise switch it off.');
|
|
208
|
-
else add('index', 'Technical', 'pass', 'Search engines', 'May be indexed.');
|
|
209
|
-
if (!f.canonical) add('canonical', 'Technical', 'warn', 'Canonical address', 'No canonical link.', 'Set the site address in Settings so every page names its one true address.');
|
|
210
|
-
else if (!f.canonical.replace(/\/$/, '').endsWith(urlPath === '/' ? '' : urlPath)) add('canonical', 'Technical', 'info', 'Canonical address', `Points elsewhere: ${f.canonical}.`, 'Right when this page is a copy of that one; otherwise it hides this page.');
|
|
211
|
-
else add('canonical', 'Technical', 'pass', 'Canonical address', f.canonical);
|
|
212
|
-
if (!f.og.image) add('share-image', 'Sharing', 'warn', 'Share image', 'No image when the page is shared.', 'Choose an image (1200 × 630 is ideal), or set a default one for the site.');
|
|
213
|
-
else add('share-image', 'Sharing', 'pass', 'Share image', f.og.image);
|
|
214
|
-
if (f.jsonLdTypes.includes('(invalid)')) add('schema', 'Technical', 'fail', 'Structured data', 'A JSON-LD block is not valid JSON.', 'Fix or remove it - search engines ignore the lot.');
|
|
215
|
-
else if (f.jsonLdTypes.length) add('schema', 'Technical', 'pass', 'Structured data', f.jsonLdTypes.join(', '));
|
|
216
|
-
else add('schema', 'Technical', 'info', 'Structured data', 'None.', 'Structured data lets search engines show rich results.');
|
|
217
|
-
if (!f.lang) add('lang', 'Technical', 'warn', 'Language', 'The page does not say what language it is in.', 'The site template sets <html lang="…">.');
|
|
218
|
-
|
|
219
|
-
return {...score(checks), checks};
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
function isInternal(href, origin) {
|
|
223
|
-
const h = String(href || '').trim();
|
|
224
|
-
if (!h || h.startsWith('#') || /^(mailto|tel|javascript|data):/i.test(h)) return false;
|
|
225
|
-
if (h.startsWith('/') && !h.startsWith('//')) return true;
|
|
226
|
-
if (!origin) return false;
|
|
227
|
-
try { return new URL(h).origin === new URL(origin).origin; } catch { return false; }
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
function countPhrase(text, phrase) {
|
|
231
|
-
if (!phrase) return 0;
|
|
232
|
-
const esc = phrase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s+/g, '\\s+');
|
|
233
|
-
return (String(text).match(new RegExp(`(^|[^\\p{L}\\p{N}])${esc}(?=$|[^\\p{L}\\p{N}])`, 'giu')) || []).length;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
/** Score and failing count of a list of checks. */
|
|
237
|
-
export function score(checks) {
|
|
238
|
-
const counted = checks.filter(c => c.status !== 'info');
|
|
239
|
-
const got = counted.reduce((n, c) => n + (c.status === 'pass' ? 1 : c.status === 'warn' ? 0.5 : 0), 0);
|
|
240
|
-
return {
|
|
241
|
-
score: counted.length ? Math.round((got / counted.length) * 100) : 100,
|
|
242
|
-
failing: checks.filter(c => c.status === 'fail').length,
|
|
243
|
-
warnings: checks.filter(c => c.status === 'warn').length
|
|
244
|
-
};
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
/**
|
|
248
|
-
* Problems only visible across pages: the same title or description on more
|
|
249
|
-
* than one. `results` are audit rows `{urlPath, title, description, noindex}`.
|
|
250
|
-
*
|
|
251
|
-
* @returns {{duplicateTitles: Array<{text, paths}>, duplicateDescriptions: Array<{text, paths}>}}
|
|
252
|
-
*/
|
|
253
|
-
export function siteIssues(results) {
|
|
254
|
-
const dupes = (key) => {
|
|
255
|
-
const by = new Map();
|
|
256
|
-
for (const r of results) {
|
|
257
|
-
const v = String(r[key] || '').trim().toLowerCase();
|
|
258
|
-
if (!v || r.noindex || r.error) continue;
|
|
259
|
-
if (!by.has(v)) by.set(v, {text: r[key], paths: []});
|
|
260
|
-
by.get(v).paths.push(r.urlPath);
|
|
261
|
-
}
|
|
262
|
-
return [...by.values()].filter(x => x.paths.length > 1).sort((a, b) => b.paths.length - a.paths.length);
|
|
263
|
-
};
|
|
264
|
-
return {duplicateTitles: dupes('title'), duplicateDescriptions: dupes('description')};
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
/**
|
|
268
|
-
* A search result as Google would show it, cut where it cuts (roughly: it
|
|
269
|
-
* measures pixels; characters are close enough to show the problem).
|
|
270
|
-
*/
|
|
271
|
-
export function snippet({title = '', description = '', url = '', limits = {}} = {}) {
|
|
272
|
-
const L = {...LIMITS, ...limits};
|
|
273
|
-
const cut = (s, n) => (s.length > n ? `${s.slice(0, n - 1).replace(/\s+\S*$/, '')} …` : s);
|
|
274
|
-
let crumbs = url;
|
|
275
|
-
try {
|
|
276
|
-
const u = new URL(url);
|
|
277
|
-
crumbs = [u.hostname, ...u.pathname.split('/').filter(Boolean)].join(' › ');
|
|
278
|
-
} catch { /* a path */ }
|
|
279
|
-
return {
|
|
280
|
-
title: cut(String(title), L.titleMax),
|
|
281
|
-
description: cut(String(description), L.descMax),
|
|
282
|
-
crumbs,
|
|
283
|
-
titleCut: String(title).length > L.titleMax,
|
|
284
|
-
descriptionCut: String(description).length > L.descMax
|
|
285
|
-
};
|
|
286
|
-
}
|