@dineway-ai/plugin-seo-graph 0.1.7
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/README.md +227 -0
- package/package.json +49 -0
- package/src/admin-redirects.tsx +317 -0
- package/src/admin.tsx +529 -0
- package/src/canonical.ts +46 -0
- package/src/descriptions.ts +17 -0
- package/src/fuzzy.ts +112 -0
- package/src/hreflang.ts +103 -0
- package/src/index.ts +98 -0
- package/src/indexnow.ts +139 -0
- package/src/llms.ts +151 -0
- package/src/metadata.ts +93 -0
- package/src/opengraph.ts +327 -0
- package/src/robots.ts +29 -0
- package/src/schema/article.ts +70 -0
- package/src/schema/breadcrumb.ts +158 -0
- package/src/schema/endpoints.ts +69 -0
- package/src/schema/index.ts +175 -0
- package/src/schema/organization.ts +133 -0
- package/src/schema/person.ts +54 -0
- package/src/schema/webpage.ts +84 -0
- package/src/schema/website.ts +52 -0
- package/src/settings.ts +330 -0
- package/src/terms.ts +33 -0
- package/src/titles.ts +59 -0
- package/src/urls.ts +72 -0
package/src/admin.tsx
ADDED
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
import { apiFetch as baseFetch, parseApiResponse } from "dineway/plugin-utils";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
|
|
4
|
+
import { FuzzyRedirectsPage } from "./admin-redirects.js";
|
|
5
|
+
|
|
6
|
+
export const SEO_GRAPH_PLUGIN_API_BASE = "/_dineway/api/plugins/seo-graph";
|
|
7
|
+
|
|
8
|
+
async function apiFetch(route: string, body?: unknown): Promise<Response> {
|
|
9
|
+
return baseFetch(`${SEO_GRAPH_PLUGIN_API_BASE}/${route}`, {
|
|
10
|
+
method: "POST",
|
|
11
|
+
headers: { "Content-Type": "application/json" },
|
|
12
|
+
body: JSON.stringify(body ?? {}),
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function toSettingString(value: unknown): string {
|
|
17
|
+
if (typeof value === "string") return value;
|
|
18
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
19
|
+
return "";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface FieldDef {
|
|
23
|
+
key: string;
|
|
24
|
+
type: "string" | "select";
|
|
25
|
+
label: string;
|
|
26
|
+
description?: string;
|
|
27
|
+
multiline?: boolean;
|
|
28
|
+
options?: Array<{ value: string; label: string }>;
|
|
29
|
+
default?: string;
|
|
30
|
+
section?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const FIELDS: FieldDef[] = [
|
|
34
|
+
{
|
|
35
|
+
key: "siteRepresents",
|
|
36
|
+
type: "select",
|
|
37
|
+
label: "Site represents",
|
|
38
|
+
description: "Does this site represent a person or an organization?",
|
|
39
|
+
options: [
|
|
40
|
+
{ value: "person", label: "Person" },
|
|
41
|
+
{ value: "organization", label: "Organization" },
|
|
42
|
+
],
|
|
43
|
+
default: "person",
|
|
44
|
+
section: "general",
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
key: "separator",
|
|
48
|
+
type: "select",
|
|
49
|
+
label: "Title separator",
|
|
50
|
+
description: "Character between page title and site name",
|
|
51
|
+
options: [
|
|
52
|
+
{ value: " — ", label: "— (em dash)" },
|
|
53
|
+
{ value: " | ", label: "| (pipe)" },
|
|
54
|
+
{ value: " - ", label: "- (hyphen)" },
|
|
55
|
+
{ value: " · ", label: "· (dot)" },
|
|
56
|
+
],
|
|
57
|
+
default: " — ",
|
|
58
|
+
section: "general",
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
key: "defaultDescription",
|
|
62
|
+
type: "string",
|
|
63
|
+
label: "Default meta description",
|
|
64
|
+
description: "Fallback for pages without their own",
|
|
65
|
+
multiline: true,
|
|
66
|
+
section: "general",
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
key: "personName",
|
|
70
|
+
type: "string",
|
|
71
|
+
label: "Person name",
|
|
72
|
+
description: "Full name of the person this site represents",
|
|
73
|
+
section: "person",
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
key: "personDescription",
|
|
77
|
+
type: "string",
|
|
78
|
+
label: "Person bio",
|
|
79
|
+
description: "Short biography (max 250 characters for schema.org)",
|
|
80
|
+
multiline: true,
|
|
81
|
+
section: "person",
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
key: "personImageUrl",
|
|
85
|
+
type: "string",
|
|
86
|
+
label: "Person image URL",
|
|
87
|
+
description: "URL to the person's photo",
|
|
88
|
+
section: "person",
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
key: "personJobTitle",
|
|
92
|
+
type: "string",
|
|
93
|
+
label: "Person job title",
|
|
94
|
+
description: "Job title for schema.org Person",
|
|
95
|
+
section: "person",
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
key: "personUrl",
|
|
99
|
+
type: "string",
|
|
100
|
+
label: "Person URL",
|
|
101
|
+
description: "About page or personal website",
|
|
102
|
+
section: "person",
|
|
103
|
+
},
|
|
104
|
+
{ key: "orgName", type: "string", label: "Organization name", section: "org" },
|
|
105
|
+
{ key: "orgLogoUrl", type: "string", label: "Organization logo URL", section: "org" },
|
|
106
|
+
{ key: "socialTwitter", type: "string", label: "X (Twitter) URL", section: "social" },
|
|
107
|
+
{ key: "socialFacebook", type: "string", label: "Facebook URL", section: "social" },
|
|
108
|
+
{ key: "socialLinkedIn", type: "string", label: "LinkedIn URL", section: "social" },
|
|
109
|
+
{ key: "socialInstagram", type: "string", label: "Instagram URL", section: "social" },
|
|
110
|
+
{ key: "socialYouTube", type: "string", label: "YouTube URL", section: "social" },
|
|
111
|
+
{ key: "socialGitHub", type: "string", label: "GitHub URL", section: "social" },
|
|
112
|
+
{ key: "socialBluesky", type: "string", label: "Bluesky URL", section: "social" },
|
|
113
|
+
{ key: "socialMastodon", type: "string", label: "Mastodon URL", section: "social" },
|
|
114
|
+
{ key: "socialWikipedia", type: "string", label: "Wikipedia URL", section: "social" },
|
|
115
|
+
{
|
|
116
|
+
key: "llmsTxtEnabled",
|
|
117
|
+
type: "select",
|
|
118
|
+
label: "llms.txt",
|
|
119
|
+
description: "Expose an llms.txt index of published content.",
|
|
120
|
+
options: [
|
|
121
|
+
{ value: "true", label: "Enabled" },
|
|
122
|
+
{ value: "false", label: "Disabled" },
|
|
123
|
+
],
|
|
124
|
+
default: "true",
|
|
125
|
+
section: "discovery",
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
key: "llmsTxtDescription",
|
|
129
|
+
type: "string",
|
|
130
|
+
label: "llms.txt site description",
|
|
131
|
+
description: "Optional blurb rendered at the top of llms.txt.",
|
|
132
|
+
multiline: true,
|
|
133
|
+
section: "discovery",
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
key: "nlwebEndpoint",
|
|
137
|
+
type: "string",
|
|
138
|
+
label: "NLWeb endpoint URL",
|
|
139
|
+
description: "Absolute URL of the site's conversational endpoint.",
|
|
140
|
+
section: "discovery",
|
|
141
|
+
},
|
|
142
|
+
];
|
|
143
|
+
|
|
144
|
+
const inputStyle: React.CSSProperties = {
|
|
145
|
+
width: "100%",
|
|
146
|
+
padding: "0.5rem 0.75rem",
|
|
147
|
+
borderRadius: 6,
|
|
148
|
+
border: "1px solid #d1d5db",
|
|
149
|
+
fontSize: "0.875rem",
|
|
150
|
+
fontFamily: "inherit",
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const buttonStyle: React.CSSProperties = {
|
|
154
|
+
padding: "0.375rem 0.75rem",
|
|
155
|
+
borderRadius: 6,
|
|
156
|
+
background: "#f3f4f6",
|
|
157
|
+
color: "#374151",
|
|
158
|
+
border: "1px solid #d1d5db",
|
|
159
|
+
cursor: "pointer",
|
|
160
|
+
fontSize: "0.75rem",
|
|
161
|
+
fontFamily: "inherit",
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
function Field({
|
|
165
|
+
field,
|
|
166
|
+
value,
|
|
167
|
+
onChange,
|
|
168
|
+
}: {
|
|
169
|
+
field: FieldDef;
|
|
170
|
+
value: string;
|
|
171
|
+
onChange: (v: string) => void;
|
|
172
|
+
}) {
|
|
173
|
+
return (
|
|
174
|
+
<div style={{ marginBottom: "1rem" }}>
|
|
175
|
+
<label style={{ display: "block", fontWeight: 500, marginBottom: 4, fontSize: "0.875rem" }}>
|
|
176
|
+
{field.label}
|
|
177
|
+
</label>
|
|
178
|
+
{field.description && (
|
|
179
|
+
<div style={{ fontSize: "0.75rem", color: "#666", marginBottom: 4 }}>
|
|
180
|
+
{field.description}
|
|
181
|
+
</div>
|
|
182
|
+
)}
|
|
183
|
+
{field.type === "select" ? (
|
|
184
|
+
<select value={value} onChange={(e) => onChange(e.target.value)} style={inputStyle}>
|
|
185
|
+
{field.options?.map((opt) => (
|
|
186
|
+
<option key={opt.value} value={opt.value}>
|
|
187
|
+
{opt.label}
|
|
188
|
+
</option>
|
|
189
|
+
))}
|
|
190
|
+
</select>
|
|
191
|
+
) : field.multiline ? (
|
|
192
|
+
<textarea
|
|
193
|
+
value={value}
|
|
194
|
+
onChange={(e) => onChange(e.target.value)}
|
|
195
|
+
rows={3}
|
|
196
|
+
style={{ ...inputStyle, resize: "vertical" }}
|
|
197
|
+
/>
|
|
198
|
+
) : (
|
|
199
|
+
<input
|
|
200
|
+
type="text"
|
|
201
|
+
value={value}
|
|
202
|
+
onChange={(e) => onChange(e.target.value)}
|
|
203
|
+
style={inputStyle}
|
|
204
|
+
/>
|
|
205
|
+
)}
|
|
206
|
+
</div>
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Editor for the breadcrumb segment → label map. Each row is a
|
|
212
|
+
* `(segment, label)` pair; "blog" → "Blog" fixes ugly auto-derived
|
|
213
|
+
* crumbs without requiring code changes to the theme.
|
|
214
|
+
*
|
|
215
|
+
* Serializes to JSON on every change; stored as the `breadcrumbLabels`
|
|
216
|
+
* setting value.
|
|
217
|
+
*/
|
|
218
|
+
function BreadcrumbLabelsEditor({
|
|
219
|
+
value,
|
|
220
|
+
onChange,
|
|
221
|
+
}: {
|
|
222
|
+
value: string;
|
|
223
|
+
onChange: (v: string) => void;
|
|
224
|
+
}) {
|
|
225
|
+
const parsed = React.useMemo<Array<{ segment: string; label: string }>>(() => {
|
|
226
|
+
if (!value) return [];
|
|
227
|
+
try {
|
|
228
|
+
const obj = JSON.parse(value) as unknown;
|
|
229
|
+
if (obj && typeof obj === "object" && !Array.isArray(obj)) {
|
|
230
|
+
return Object.entries(obj as Record<string, unknown>).map(([segment, label]) => ({
|
|
231
|
+
segment,
|
|
232
|
+
label: toSettingString(label),
|
|
233
|
+
}));
|
|
234
|
+
}
|
|
235
|
+
} catch {
|
|
236
|
+
// fall through
|
|
237
|
+
}
|
|
238
|
+
return [];
|
|
239
|
+
}, [value]);
|
|
240
|
+
|
|
241
|
+
const commit = (rows: Array<{ segment: string; label: string }>) => {
|
|
242
|
+
const obj: Record<string, string> = {};
|
|
243
|
+
for (const row of rows) {
|
|
244
|
+
const key = row.segment.trim();
|
|
245
|
+
if (key) obj[key] = row.label;
|
|
246
|
+
}
|
|
247
|
+
onChange(Object.keys(obj).length > 0 ? JSON.stringify(obj) : "");
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
const updateRow = (index: number, patch: Partial<{ segment: string; label: string }>) => {
|
|
251
|
+
const next = parsed.map((r, i) => (i === index ? { ...r, ...patch } : r));
|
|
252
|
+
commit(next);
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
const addRow = () => commit([...parsed, { segment: "", label: "" }]);
|
|
256
|
+
const removeRow = (index: number) => commit(parsed.filter((_, i) => i !== index));
|
|
257
|
+
|
|
258
|
+
return (
|
|
259
|
+
<div style={{ marginBottom: "1rem" }}>
|
|
260
|
+
<label style={{ display: "block", fontWeight: 500, marginBottom: 4, fontSize: "0.875rem" }}>
|
|
261
|
+
Segment labels
|
|
262
|
+
</label>
|
|
263
|
+
<div style={{ fontSize: "0.75rem", color: "#666", marginBottom: 8 }}>
|
|
264
|
+
Override the default title-cased segment name for breadcrumbs. Segments are matched anywhere
|
|
265
|
+
in the path — <code>blog</code> → <code>Blog</code> relabels the <code>/blog/</code> crumb
|
|
266
|
+
on every page under it.
|
|
267
|
+
</div>
|
|
268
|
+
{parsed.length === 0 && (
|
|
269
|
+
<div
|
|
270
|
+
style={{ fontSize: "0.75rem", color: "#9ca3af", fontStyle: "italic", marginBottom: 8 }}
|
|
271
|
+
>
|
|
272
|
+
No overrides — breadcrumbs will use cleaned-up segment names.
|
|
273
|
+
</div>
|
|
274
|
+
)}
|
|
275
|
+
{parsed.map((row, i) => (
|
|
276
|
+
<div key={i} style={{ display: "flex", gap: 8, marginBottom: 6 }}>
|
|
277
|
+
<input
|
|
278
|
+
type="text"
|
|
279
|
+
placeholder="segment"
|
|
280
|
+
value={row.segment}
|
|
281
|
+
onChange={(e) => updateRow(i, { segment: e.target.value })}
|
|
282
|
+
style={{ ...inputStyle, flex: "1 1 40%" }}
|
|
283
|
+
/>
|
|
284
|
+
<input
|
|
285
|
+
type="text"
|
|
286
|
+
placeholder="Display label"
|
|
287
|
+
value={row.label}
|
|
288
|
+
onChange={(e) => updateRow(i, { label: e.target.value })}
|
|
289
|
+
style={{ ...inputStyle, flex: "1 1 60%" }}
|
|
290
|
+
/>
|
|
291
|
+
<button
|
|
292
|
+
type="button"
|
|
293
|
+
onClick={() => removeRow(i)}
|
|
294
|
+
style={buttonStyle}
|
|
295
|
+
aria-label="Remove"
|
|
296
|
+
>
|
|
297
|
+
×
|
|
298
|
+
</button>
|
|
299
|
+
</div>
|
|
300
|
+
))}
|
|
301
|
+
<button type="button" onClick={addRow} style={buttonStyle}>
|
|
302
|
+
+ Add label
|
|
303
|
+
</button>
|
|
304
|
+
</div>
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Advanced editor for per-`pageType` breadcrumb rules. Raw JSON editor
|
|
310
|
+
* because the shape is nested (pageType → ordered array of crumbs)
|
|
311
|
+
* and a structured form would be heavy for a rarely-edited field.
|
|
312
|
+
*
|
|
313
|
+
* Validation is cosmetic (red border on parse error); the plugin's
|
|
314
|
+
* runtime `parseSettings` falls back to `{}` on malformed JSON, so
|
|
315
|
+
* bad input degrades to path derivation rather than crashing.
|
|
316
|
+
*/
|
|
317
|
+
function BreadcrumbRulesEditor({
|
|
318
|
+
value,
|
|
319
|
+
onChange,
|
|
320
|
+
}: {
|
|
321
|
+
value: string;
|
|
322
|
+
onChange: (v: string) => void;
|
|
323
|
+
}) {
|
|
324
|
+
const [draft, setDraft] = React.useState(value);
|
|
325
|
+
const [error, setError] = React.useState<string | null>(null);
|
|
326
|
+
|
|
327
|
+
React.useEffect(() => {
|
|
328
|
+
setDraft(value);
|
|
329
|
+
}, [value]);
|
|
330
|
+
|
|
331
|
+
const handleChange = (next: string) => {
|
|
332
|
+
setDraft(next);
|
|
333
|
+
if (!next.trim()) {
|
|
334
|
+
setError(null);
|
|
335
|
+
onChange("");
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
try {
|
|
339
|
+
JSON.parse(next);
|
|
340
|
+
setError(null);
|
|
341
|
+
onChange(next);
|
|
342
|
+
} catch (err) {
|
|
343
|
+
setError((err as Error).message);
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
return (
|
|
348
|
+
<div style={{ marginBottom: "1rem" }}>
|
|
349
|
+
<label style={{ display: "block", fontWeight: 500, marginBottom: 4, fontSize: "0.875rem" }}>
|
|
350
|
+
Page type rules (advanced)
|
|
351
|
+
</label>
|
|
352
|
+
<div style={{ fontSize: "0.75rem", color: "#666", marginBottom: 4 }}>
|
|
353
|
+
JSON map from <code>pageType</code> to an ordered list of crumbs. Use{" "}
|
|
354
|
+
<code>{"{title}"}</code> as a placeholder for the current page title, and omit{" "}
|
|
355
|
+
<code>href</code> on the last crumb to point it at the canonical URL.
|
|
356
|
+
</div>
|
|
357
|
+
<pre
|
|
358
|
+
style={{
|
|
359
|
+
fontSize: "0.7rem",
|
|
360
|
+
color: "#6b7280",
|
|
361
|
+
background: "#f9fafb",
|
|
362
|
+
padding: 8,
|
|
363
|
+
borderRadius: 4,
|
|
364
|
+
marginBottom: 6,
|
|
365
|
+
overflowX: "auto",
|
|
366
|
+
}}
|
|
367
|
+
>
|
|
368
|
+
{`{
|
|
369
|
+
"blogPost": [
|
|
370
|
+
{ "label": "Home", "href": "/" },
|
|
371
|
+
{ "label": "Blog", "href": "/blog/" },
|
|
372
|
+
{ "label": "{title}" }
|
|
373
|
+
]
|
|
374
|
+
}`}
|
|
375
|
+
</pre>
|
|
376
|
+
<textarea
|
|
377
|
+
value={draft}
|
|
378
|
+
onChange={(e) => handleChange(e.target.value)}
|
|
379
|
+
rows={8}
|
|
380
|
+
style={{
|
|
381
|
+
...inputStyle,
|
|
382
|
+
resize: "vertical",
|
|
383
|
+
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
|
|
384
|
+
fontSize: "0.75rem",
|
|
385
|
+
borderColor: error ? "#dc2626" : "#d1d5db",
|
|
386
|
+
}}
|
|
387
|
+
placeholder="{}"
|
|
388
|
+
/>
|
|
389
|
+
{error && (
|
|
390
|
+
<div style={{ fontSize: "0.7rem", color: "#dc2626", marginTop: 4 }}>
|
|
391
|
+
Invalid JSON: {error}
|
|
392
|
+
</div>
|
|
393
|
+
)}
|
|
394
|
+
</div>
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function SettingsPage() {
|
|
399
|
+
const [settings, setSettings] = React.useState<Record<string, string>>({});
|
|
400
|
+
const [saving, setSaving] = React.useState(false);
|
|
401
|
+
const [saved, setSaved] = React.useState(false);
|
|
402
|
+
const [loading, setLoading] = React.useState(true);
|
|
403
|
+
const [error, setError] = React.useState<string | null>(null);
|
|
404
|
+
|
|
405
|
+
React.useEffect(() => {
|
|
406
|
+
const loadSettings = async () => {
|
|
407
|
+
try {
|
|
408
|
+
const res = await apiFetch("settings");
|
|
409
|
+
const data = await parseApiResponse<{ settings: Record<string, string> }>(res);
|
|
410
|
+
setSettings(data.settings || {});
|
|
411
|
+
} catch (err) {
|
|
412
|
+
setError(String(err));
|
|
413
|
+
} finally {
|
|
414
|
+
setLoading(false);
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
void loadSettings();
|
|
419
|
+
}, []);
|
|
420
|
+
|
|
421
|
+
const handleSave = async () => {
|
|
422
|
+
setSaving(true);
|
|
423
|
+
setSaved(false);
|
|
424
|
+
setError(null);
|
|
425
|
+
try {
|
|
426
|
+
await apiFetch("settings/save", { settings });
|
|
427
|
+
setSaved(true);
|
|
428
|
+
setTimeout(setSaved, 3000, false);
|
|
429
|
+
} catch (err) {
|
|
430
|
+
setError(String(err));
|
|
431
|
+
}
|
|
432
|
+
setSaving(false);
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
const update = (key: string, value: string) => {
|
|
436
|
+
setSettings((prev) => ({ ...prev, [key]: value }));
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
if (loading) return <div style={{ padding: "2rem" }}>Loading settings...</div>;
|
|
440
|
+
if (error) return <div style={{ padding: "2rem", color: "#dc2626" }}>Error: {error}</div>;
|
|
441
|
+
|
|
442
|
+
const siteRepresents = settings.siteRepresents || "person";
|
|
443
|
+
|
|
444
|
+
const sections = [
|
|
445
|
+
{ id: "general", label: "General" },
|
|
446
|
+
...(siteRepresents === "person"
|
|
447
|
+
? [{ id: "person", label: "Person" }]
|
|
448
|
+
: [{ id: "org", label: "Organization" }]),
|
|
449
|
+
{ id: "social", label: "Social Profiles" },
|
|
450
|
+
{ id: "discovery", label: "Discovery" },
|
|
451
|
+
];
|
|
452
|
+
|
|
453
|
+
return (
|
|
454
|
+
<div style={{ maxWidth: 640, padding: "1.5rem 0" }}>
|
|
455
|
+
<h1 style={{ fontSize: "1.5rem", fontWeight: 700, marginBottom: "1.5rem" }}>SEO Settings</h1>
|
|
456
|
+
{sections.map((section) => (
|
|
457
|
+
<div key={section.id} style={{ marginBottom: "2rem" }}>
|
|
458
|
+
<h3
|
|
459
|
+
style={{
|
|
460
|
+
fontSize: "1rem",
|
|
461
|
+
fontWeight: 600,
|
|
462
|
+
marginBottom: "0.75rem",
|
|
463
|
+
borderBottom: "1px solid #e5e7eb",
|
|
464
|
+
paddingBottom: "0.5rem",
|
|
465
|
+
}}
|
|
466
|
+
>
|
|
467
|
+
{section.label}
|
|
468
|
+
</h3>
|
|
469
|
+
{FIELDS.filter((f) => f.section === section.id).map((field) => (
|
|
470
|
+
<Field
|
|
471
|
+
key={field.key}
|
|
472
|
+
field={field}
|
|
473
|
+
value={settings[field.key] || field.default || ""}
|
|
474
|
+
onChange={(v) => update(field.key, v)}
|
|
475
|
+
/>
|
|
476
|
+
))}
|
|
477
|
+
</div>
|
|
478
|
+
))}
|
|
479
|
+
|
|
480
|
+
<div style={{ marginBottom: "2rem" }}>
|
|
481
|
+
<h3
|
|
482
|
+
style={{
|
|
483
|
+
fontSize: "1rem",
|
|
484
|
+
fontWeight: 600,
|
|
485
|
+
marginBottom: "0.75rem",
|
|
486
|
+
borderBottom: "1px solid #e5e7eb",
|
|
487
|
+
paddingBottom: "0.5rem",
|
|
488
|
+
}}
|
|
489
|
+
>
|
|
490
|
+
Breadcrumbs
|
|
491
|
+
</h3>
|
|
492
|
+
<BreadcrumbLabelsEditor
|
|
493
|
+
value={settings.breadcrumbLabels || ""}
|
|
494
|
+
onChange={(v) => update("breadcrumbLabels", v)}
|
|
495
|
+
/>
|
|
496
|
+
<BreadcrumbRulesEditor
|
|
497
|
+
value={settings.breadcrumbRules || ""}
|
|
498
|
+
onChange={(v) => update("breadcrumbRules", v)}
|
|
499
|
+
/>
|
|
500
|
+
</div>
|
|
501
|
+
|
|
502
|
+
<button
|
|
503
|
+
onClick={handleSave}
|
|
504
|
+
disabled={saving}
|
|
505
|
+
style={{
|
|
506
|
+
padding: "0.5rem 1.5rem",
|
|
507
|
+
borderRadius: 6,
|
|
508
|
+
background: "#4a1525",
|
|
509
|
+
color: "white",
|
|
510
|
+
border: "none",
|
|
511
|
+
cursor: saving ? "wait" : "pointer",
|
|
512
|
+
fontWeight: 500,
|
|
513
|
+
}}
|
|
514
|
+
>
|
|
515
|
+
{saving ? "Saving..." : "Save Settings"}
|
|
516
|
+
</button>
|
|
517
|
+
{saved && (
|
|
518
|
+
<span style={{ marginLeft: 12, color: "#16a34a", fontSize: "0.875rem" }}>
|
|
519
|
+
Settings saved!
|
|
520
|
+
</span>
|
|
521
|
+
)}
|
|
522
|
+
</div>
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
export const pages = {
|
|
527
|
+
"/settings": SettingsPage,
|
|
528
|
+
"/fuzzy-redirects": FuzzyRedirectsPage,
|
|
529
|
+
};
|
package/src/canonical.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { PublicPageContext } from "dineway";
|
|
2
|
+
|
|
3
|
+
const NOINDEX_PATHS = new Set(["/search"]);
|
|
4
|
+
const MULTI_SLASH_RE = /\/+/g;
|
|
5
|
+
const TRAILING_SLASH_RE = /\/$/;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Generate canonical URL.
|
|
9
|
+
*
|
|
10
|
+
* - Every indexable page gets one
|
|
11
|
+
* - Omit on 404 and noindex pages
|
|
12
|
+
* - Absolute, clean, with trailing slash
|
|
13
|
+
* - Respect user override
|
|
14
|
+
* - Include pagination parameter
|
|
15
|
+
*/
|
|
16
|
+
export function generateCanonical(page: PublicPageContext, siteUrl: string): string | null {
|
|
17
|
+
const path = page.path || "/";
|
|
18
|
+
|
|
19
|
+
// No canonical for 404 or noindex pages
|
|
20
|
+
if (path === "/404") return null;
|
|
21
|
+
if (page.seo?.robots?.includes("noindex")) return null;
|
|
22
|
+
if (NOINDEX_PATHS.has(path)) return null;
|
|
23
|
+
|
|
24
|
+
// User override
|
|
25
|
+
if (page.canonical) return page.canonical;
|
|
26
|
+
|
|
27
|
+
// Build from page URL
|
|
28
|
+
try {
|
|
29
|
+
const u = new URL(page.url, siteUrl);
|
|
30
|
+
let pathname = u.pathname.toLowerCase().replace(MULTI_SLASH_RE, "/");
|
|
31
|
+
|
|
32
|
+
// Ensure trailing slash
|
|
33
|
+
if (!pathname.endsWith("/")) pathname += "/";
|
|
34
|
+
|
|
35
|
+
// Build clean URL with only pagination param
|
|
36
|
+
const pageParam = u.searchParams.get("page");
|
|
37
|
+
let canonical = `${siteUrl.replace(TRAILING_SLASH_RE, "")}${pathname}`;
|
|
38
|
+
if (pageParam && Number(pageParam) > 1) {
|
|
39
|
+
canonical += `?page=${pageParam}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return canonical;
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { PublicPageContext } from "dineway";
|
|
2
|
+
|
|
3
|
+
import type { SeoSettings } from "./settings.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Determine the meta description for a page.
|
|
7
|
+
* No auto-generation. Use explicit values or omit.
|
|
8
|
+
*
|
|
9
|
+
* Priority:
|
|
10
|
+
* 1. Per-content SEO description
|
|
11
|
+
* 2. Page description (excerpt for posts)
|
|
12
|
+
* 3. Settings default description (homepage/archives)
|
|
13
|
+
* 4. null (omit)
|
|
14
|
+
*/
|
|
15
|
+
export function generateDescription(page: PublicPageContext, settings: SeoSettings): string | null {
|
|
16
|
+
return page.seo?.ogDescription || page.description || settings.defaultDescription || null;
|
|
17
|
+
}
|
package/src/fuzzy.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fuzzy URL-path matching for redirect suggestions.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const SPLIT_RE = /[/\-_]+/g;
|
|
6
|
+
const SEG_SPLIT_RE = /[-_]+/g;
|
|
7
|
+
const MULTI_SLASH_RE = /\/+/g;
|
|
8
|
+
|
|
9
|
+
function normalize(path: string): string {
|
|
10
|
+
let normalized = path.toLowerCase().trim();
|
|
11
|
+
normalized = normalized.replace(MULTI_SLASH_RE, "/");
|
|
12
|
+
if (normalized.endsWith("/") && normalized.length > 1) normalized = normalized.slice(0, -1);
|
|
13
|
+
if (!normalized.startsWith("/")) normalized = "/" + normalized;
|
|
14
|
+
return normalized;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function tokenize(path: string): string[] {
|
|
18
|
+
return path.split(SPLIT_RE).filter(Boolean);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function sortedStrings(values: string[]): string[] {
|
|
22
|
+
const sorted: string[] = [];
|
|
23
|
+
for (const value of values) {
|
|
24
|
+
let index = 0;
|
|
25
|
+
while (index < sorted.length && sorted[index]! <= value) index++;
|
|
26
|
+
sorted.splice(index, 0, value);
|
|
27
|
+
}
|
|
28
|
+
return sorted;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function lastSegmentKey(path: string): string {
|
|
32
|
+
const segments = path.split("/").filter(Boolean);
|
|
33
|
+
const last = segments.at(-1) ?? "";
|
|
34
|
+
return sortedStrings(last.split(SEG_SPLIT_RE).filter(Boolean)).join("|");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Classic dynamic-programming Levenshtein distance.
|
|
39
|
+
*/
|
|
40
|
+
export function levenshtein(a: string, b: string): number {
|
|
41
|
+
if (a === b) return 0;
|
|
42
|
+
if (!a.length) return b.length;
|
|
43
|
+
if (!b.length) return a.length;
|
|
44
|
+
|
|
45
|
+
let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
|
|
46
|
+
let curr: number[] = [];
|
|
47
|
+
for (let j = 0; j <= b.length; j++) curr.push(0);
|
|
48
|
+
|
|
49
|
+
for (let i = 1; i <= a.length; i++) {
|
|
50
|
+
curr[0] = i;
|
|
51
|
+
for (let j = 1; j <= b.length; j++) {
|
|
52
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
53
|
+
curr[j] = Math.min(curr[j - 1]! + 1, prev[j]! + 1, prev[j - 1]! + cost);
|
|
54
|
+
}
|
|
55
|
+
[prev, curr] = [curr, prev];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return prev[b.length]!;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Score the similarity of two URL paths on [0, 1]. Higher is better.
|
|
63
|
+
*/
|
|
64
|
+
export function scoreSlugMatch(target: string, candidate: string): number {
|
|
65
|
+
const targetPath = normalize(target);
|
|
66
|
+
const candidatePath = normalize(candidate);
|
|
67
|
+
if (targetPath === candidatePath) return 1;
|
|
68
|
+
|
|
69
|
+
const distance = levenshtein(targetPath, candidatePath);
|
|
70
|
+
const maxLen = Math.max(targetPath.length, candidatePath.length);
|
|
71
|
+
const levSim = maxLen === 0 ? 0 : 1 - distance / maxLen;
|
|
72
|
+
|
|
73
|
+
const targetTokens = new Set(tokenize(targetPath));
|
|
74
|
+
const candidateTokens = new Set(tokenize(candidatePath));
|
|
75
|
+
const intersection = new Set([...targetTokens].filter((token) => candidateTokens.has(token)));
|
|
76
|
+
const union = new Set([...targetTokens, ...candidateTokens]);
|
|
77
|
+
const jaccard = union.size === 0 ? 0 : intersection.size / union.size;
|
|
78
|
+
|
|
79
|
+
const targetKey = lastSegmentKey(targetPath);
|
|
80
|
+
const slugMatch = targetKey && targetKey === lastSegmentKey(candidatePath) ? 1 : 0;
|
|
81
|
+
|
|
82
|
+
return levSim * 0.5 + jaccard * 0.2 + slugMatch * 0.3;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface RankedMatch {
|
|
86
|
+
candidate: string;
|
|
87
|
+
score: number;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Rank candidate paths against a target by fuzzy similarity.
|
|
92
|
+
*/
|
|
93
|
+
export function rankCandidates(
|
|
94
|
+
target: string,
|
|
95
|
+
candidates: string[],
|
|
96
|
+
opts: { limit?: number; minScore?: number } = {},
|
|
97
|
+
): RankedMatch[] {
|
|
98
|
+
const limit = opts.limit ?? 3;
|
|
99
|
+
const minScore = opts.minScore ?? 0.5;
|
|
100
|
+
|
|
101
|
+
const scored: RankedMatch[] = [];
|
|
102
|
+
for (const candidate of candidates) {
|
|
103
|
+
const match = { candidate, score: scoreSlugMatch(target, candidate) };
|
|
104
|
+
if (match.score < minScore) continue;
|
|
105
|
+
|
|
106
|
+
let index = 0;
|
|
107
|
+
while (index < scored.length && scored[index]!.score >= match.score) index++;
|
|
108
|
+
scored.splice(index, 0, match);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return scored.slice(0, limit);
|
|
112
|
+
}
|