@jini-ai/admin 0.4.10 → 0.4.12
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/CHANGELOG.md +10 -0
- package/dist/comments/rules.d.ts +3 -3
- package/dist/comments/rules.js +3 -3
- package/dist/core/ports/seo.d.ts +20 -0
- package/dist/forms/field-settings.d.ts +1 -0
- package/dist/forms/field-settings.js +4 -4
- package/dist/forms/react/components/FieldAttributesDialog.js +1 -1
- package/dist/forms/react/hooks/use-forms-list.hooks.d.ts +1 -1
- package/dist/forms/rules.d.ts +2 -2
- package/dist/forms/rules.js +2 -2
- package/dist/redirects/react/hooks/RedirectsPage.hooks.js +1 -1
- package/dist/redirects/react/pages/RedirectsPage.js +2 -1
- package/dist/redirects/rules.d.ts +2 -2
- package/dist/redirects/rules.js +2 -2
- package/dist/seo/adapters/http.js +5 -0
- package/dist/seo/adapters/memory.js +1 -1
- package/dist/seo/messages.en.d.ts +15 -10
- package/dist/seo/messages.en.js +15 -10
- package/dist/seo/models.d.ts +11 -0
- package/dist/seo/react/components/MediaRefField.d.ts +17 -5
- package/dist/seo/react/components/MediaRefField.js +14 -4
- package/dist/seo/react/components/SitemapModal.js +1 -1
- package/dist/seo/react/hooks/MediaRefField.hooks.js +10 -2
- package/dist/seo/react/hooks/SeoPage.hooks.d.ts +1 -0
- package/dist/seo/react/hooks/SeoPage.hooks.js +2 -2
- package/dist/seo/react/hooks/SeoQuery.hooks.d.ts +1 -0
- package/dist/seo/react/hooks/SeoQuery.hooks.js +1 -0
- package/dist/seo/react/hooks/use-entry-picker.hooks.js +7 -2
- package/dist/seo/react/hooks/use-seo-entry-panel.hooks.js +13 -2
- package/dist/seo/react/hooks/use-share-image-warning.hooks.d.ts +4 -1
- package/dist/seo/react/hooks/use-share-image-warning.hooks.js +1 -0
- package/dist/seo/react/index.d.ts +45 -30
- package/dist/seo/react/pages/SeoPage.d.ts +3 -1
- package/dist/seo/react/pages/SeoPage.js +26 -24
- package/dist/seo/rules.d.ts +95 -4
- package/dist/seo/rules.js +134 -8
- package/dist/seo/seo.module.d.ts +15 -10
- package/package.json +14 -8
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { describeSeoError } from '../../models.js';
|
|
2
2
|
import { useEffect, useRef } from 'react';
|
|
3
|
-
import { useSeoEntryQuery } from './SeoQuery.hooks.js';
|
|
3
|
+
import { useSeoEntryQuery, SEO_KEYS } from './SeoQuery.hooks.js';
|
|
4
|
+
import { useFetchQuery } from '@jini-ai/ui/fetch-query';
|
|
4
5
|
import { useController } from '../../../core/react/use-controller.js';
|
|
5
6
|
import { createSeoEntryController } from '../../controllers/seo-entry.controller.js';
|
|
6
7
|
import { initialSeoEntryState } from '../../models.js';
|
|
8
|
+
import { inheritedXFor, ogImageInherited } from '../../rules.js';
|
|
7
9
|
import { useSeoOptions, useSeoPorts } from './SeoPorts.hooks.js';
|
|
8
10
|
export function useSeoEntryPanel({ api, entryId }, { t, describeError } = {}) {
|
|
9
11
|
const cached = useSeoEntryQuery({ api, entryId });
|
|
@@ -17,11 +19,20 @@ export function useSeoEntryPanel({ api, entryId }, { t, describeError } = {}) {
|
|
|
17
19
|
if (controller && cached.query.data)
|
|
18
20
|
controller.set({ patch: cached.query.data });
|
|
19
21
|
}, [controller, cached.query.data]);
|
|
22
|
+
// The site defaults read the Site defaults tab already caches; only the default image is used here.
|
|
23
|
+
const settings = useFetchQuery({ key: SEO_KEYS.settings, fetch: () => api.getSeoSettings({}) });
|
|
20
24
|
const state = snapshot ?? initialSeoEntryState;
|
|
21
25
|
return {
|
|
22
26
|
...state,
|
|
27
|
+
ogImageInherited: state.resolved ? ogImageInherited({ meta: state.resolved, defaultOgImage: settings.data?.defaultOgImage, touched: state.touched }) : false,
|
|
23
28
|
fieldValue: (key, resolvedValue) => controller ? controller.fieldValue({ key, resolvedValue }) : resolvedValue,
|
|
24
|
-
setField: (key, value) =>
|
|
29
|
+
setField: (key, value) => {
|
|
30
|
+
// An edit to the social preview also clears an X field that was following it (rules.ts).
|
|
31
|
+
const xKey = state.resolved ? inheritedXFor({ key, meta: state.resolved, touched: state.touched }) : null;
|
|
32
|
+
controller?.setField({ key, value });
|
|
33
|
+
if (xKey)
|
|
34
|
+
controller?.setField({ key: xKey, value: '' });
|
|
35
|
+
},
|
|
25
36
|
save: async () => { await controller?.save({}); },
|
|
26
37
|
};
|
|
27
38
|
}
|
|
@@ -15,5 +15,8 @@ export declare function useShareImageWarning({ value, previewUrl, api }: {
|
|
|
15
15
|
api: AdminSeoPort;
|
|
16
16
|
}, { t }?: {
|
|
17
17
|
t?: SeoTranslator | undefined;
|
|
18
|
-
}): Pick<MediaRefFieldController, 'warning' | 'onPreviewLoad' | 'onPreviewError'
|
|
18
|
+
}): Pick<MediaRefFieldController, 'warning' | 'onPreviewLoad' | 'onPreviewError'> & {
|
|
19
|
+
naturalWidth?: number | undefined;
|
|
20
|
+
naturalHeight?: number | undefined;
|
|
21
|
+
};
|
|
19
22
|
//# sourceMappingURL=use-share-image-warning.hooks.d.ts.map
|
|
@@ -62,6 +62,7 @@ export function useShareImageWarning({ value, previewUrl, api }, { t = key => ke
|
|
|
62
62
|
const checked = inspection?.url === previewUrl ? inspection : null;
|
|
63
63
|
const error = checked?.error;
|
|
64
64
|
return {
|
|
65
|
+
naturalWidth: source?.width, naturalHeight: source?.height,
|
|
65
66
|
warning: shareImageWarning({ value, previewUrl, width: source?.width, height: source?.height, previewFailed: source?.failed, byteSize: checked?.byteSize,
|
|
66
67
|
error: error ? interpolate({ template: t(error.key, error.vars), vars: error.vars ?? {} }) : undefined }, { t }),
|
|
67
68
|
onPreviewLoad: ({ currentTarget }) => {
|
|
@@ -36,21 +36,24 @@ export declare function seo({ siteUrl }: Pick<SeoReactOptions, 'siteUrl'>, optio
|
|
|
36
36
|
readonly "Canonical URL": "Canonical URL";
|
|
37
37
|
readonly Noindex: "Noindex";
|
|
38
38
|
readonly Nofollow: "Nofollow";
|
|
39
|
-
readonly "
|
|
40
|
-
readonly "
|
|
41
|
-
readonly "
|
|
42
|
-
readonly "Twitter title": "Twitter title";
|
|
43
|
-
readonly "Twitter description": "Twitter description";
|
|
44
|
-
readonly "Twitter image": "Twitter image";
|
|
39
|
+
readonly "Preview title": "Preview title";
|
|
40
|
+
readonly "Preview description": "Preview description";
|
|
41
|
+
readonly "Preview image": "Preview image";
|
|
42
|
+
readonly "X/Twitter title": "X/Twitter title";
|
|
43
|
+
readonly "X/Twitter description": "X/Twitter description";
|
|
44
|
+
readonly "X/Twitter image": "X/Twitter image";
|
|
45
|
+
readonly "Customize for X/Twitter (optional)": "Customize for X/Twitter (optional)";
|
|
46
|
+
readonly "Same as the preview image": "Same as the preview image";
|
|
47
|
+
readonly "Used by Facebook, LinkedIn, Slack, Discord, iMessage, WhatsApp and X/Twitter": "Used by Facebook, LinkedIn, Slack, Discord, iMessage, WhatsApp and X/Twitter";
|
|
45
48
|
readonly "Saving\u2026": "Saving…";
|
|
46
49
|
readonly "Save overrides": "Save overrides";
|
|
47
50
|
readonly "Per-entry SEO": "Per-entry SEO";
|
|
48
51
|
readonly "Search appearance": "Search appearance";
|
|
49
52
|
readonly "Title template (must contain %s)": "Title template (must contain %s)";
|
|
50
53
|
readonly "Default meta description": "Default meta description";
|
|
51
|
-
readonly "Social
|
|
52
|
-
readonly "Default
|
|
53
|
-
readonly "Twitter @
|
|
54
|
+
readonly "Social preview": "Social preview";
|
|
55
|
+
readonly "Default preview image": "Default preview image";
|
|
56
|
+
readonly "X/Twitter @handle": "X/Twitter @handle";
|
|
54
57
|
readonly Crawling: "Crawling";
|
|
55
58
|
readonly "Default noindex": "Default noindex";
|
|
56
59
|
readonly "Default nofollow": "Default nofollow";
|
|
@@ -63,7 +66,7 @@ export declare function seo({ siteUrl }: Pick<SeoReactOptions, 'siteUrl'>, optio
|
|
|
63
66
|
readonly "View sitemap": "View sitemap";
|
|
64
67
|
readonly "Loading SEO settings\u2026": "Loading SEO settings…";
|
|
65
68
|
readonly Marketing: "Marketing";
|
|
66
|
-
readonly "Site-wide defaults for meta titles, descriptions,
|
|
69
|
+
readonly "Site-wide defaults for meta titles, descriptions, social previews, and robots directives.": "Site-wide defaults for meta titles, descriptions, social previews, and robots directives.";
|
|
67
70
|
readonly "Choose image": "Choose image";
|
|
68
71
|
readonly Remove: "Remove";
|
|
69
72
|
readonly "Sitemap \u00B7 {count} URLs": "Sitemap · {count} URLs";
|
|
@@ -91,6 +94,8 @@ export declare function seo({ siteUrl }: Pick<SeoReactOptions, 'siteUrl'>, optio
|
|
|
91
94
|
readonly "failed to load the sitemap": "failed to load the sitemap";
|
|
92
95
|
readonly "Could not load the sitemap (HTTP {status}).": "Could not load the sitemap (HTTP {status}).";
|
|
93
96
|
readonly "Selected image": "Selected image";
|
|
97
|
+
readonly "Using site default: {name}": "Using site default: {name}";
|
|
98
|
+
readonly "Using image from page content: {name}": "Using image from page content: {name}";
|
|
94
99
|
};
|
|
95
100
|
pages: {
|
|
96
101
|
readonly settings: {
|
|
@@ -158,21 +163,24 @@ export declare function seo({ siteUrl }: Pick<SeoReactOptions, 'siteUrl'>, optio
|
|
|
158
163
|
readonly "Canonical URL": "Canonical URL";
|
|
159
164
|
readonly Noindex: "Noindex";
|
|
160
165
|
readonly Nofollow: "Nofollow";
|
|
161
|
-
readonly "
|
|
162
|
-
readonly "
|
|
163
|
-
readonly "
|
|
164
|
-
readonly "Twitter title": "Twitter title";
|
|
165
|
-
readonly "Twitter description": "Twitter description";
|
|
166
|
-
readonly "Twitter image": "Twitter image";
|
|
166
|
+
readonly "Preview title": "Preview title";
|
|
167
|
+
readonly "Preview description": "Preview description";
|
|
168
|
+
readonly "Preview image": "Preview image";
|
|
169
|
+
readonly "X/Twitter title": "X/Twitter title";
|
|
170
|
+
readonly "X/Twitter description": "X/Twitter description";
|
|
171
|
+
readonly "X/Twitter image": "X/Twitter image";
|
|
172
|
+
readonly "Customize for X/Twitter (optional)": "Customize for X/Twitter (optional)";
|
|
173
|
+
readonly "Same as the preview image": "Same as the preview image";
|
|
174
|
+
readonly "Used by Facebook, LinkedIn, Slack, Discord, iMessage, WhatsApp and X/Twitter": "Used by Facebook, LinkedIn, Slack, Discord, iMessage, WhatsApp and X/Twitter";
|
|
167
175
|
readonly "Saving\u2026": "Saving…";
|
|
168
176
|
readonly "Save overrides": "Save overrides";
|
|
169
177
|
readonly "Per-entry SEO": "Per-entry SEO";
|
|
170
178
|
readonly "Search appearance": "Search appearance";
|
|
171
179
|
readonly "Title template (must contain %s)": "Title template (must contain %s)";
|
|
172
180
|
readonly "Default meta description": "Default meta description";
|
|
173
|
-
readonly "Social
|
|
174
|
-
readonly "Default
|
|
175
|
-
readonly "Twitter @
|
|
181
|
+
readonly "Social preview": "Social preview";
|
|
182
|
+
readonly "Default preview image": "Default preview image";
|
|
183
|
+
readonly "X/Twitter @handle": "X/Twitter @handle";
|
|
176
184
|
readonly Crawling: "Crawling";
|
|
177
185
|
readonly "Default noindex": "Default noindex";
|
|
178
186
|
readonly "Default nofollow": "Default nofollow";
|
|
@@ -185,7 +193,7 @@ export declare function seo({ siteUrl }: Pick<SeoReactOptions, 'siteUrl'>, optio
|
|
|
185
193
|
readonly "View sitemap": "View sitemap";
|
|
186
194
|
readonly "Loading SEO settings\u2026": "Loading SEO settings…";
|
|
187
195
|
readonly Marketing: "Marketing";
|
|
188
|
-
readonly "Site-wide defaults for meta titles, descriptions,
|
|
196
|
+
readonly "Site-wide defaults for meta titles, descriptions, social previews, and robots directives.": "Site-wide defaults for meta titles, descriptions, social previews, and robots directives.";
|
|
189
197
|
readonly "Choose image": "Choose image";
|
|
190
198
|
readonly Remove: "Remove";
|
|
191
199
|
readonly "Sitemap \u00B7 {count} URLs": "Sitemap · {count} URLs";
|
|
@@ -213,6 +221,8 @@ export declare function seo({ siteUrl }: Pick<SeoReactOptions, 'siteUrl'>, optio
|
|
|
213
221
|
readonly "failed to load the sitemap": "failed to load the sitemap";
|
|
214
222
|
readonly "Could not load the sitemap (HTTP {status}).": "Could not load the sitemap (HTTP {status}).";
|
|
215
223
|
readonly "Selected image": "Selected image";
|
|
224
|
+
readonly "Using site default: {name}": "Using site default: {name}";
|
|
225
|
+
readonly "Using image from page content: {name}": "Using image from page content: {name}";
|
|
216
226
|
};
|
|
217
227
|
readonly pages: {
|
|
218
228
|
readonly settings: {
|
|
@@ -281,21 +291,24 @@ export declare function seo({ siteUrl }: Pick<SeoReactOptions, 'siteUrl'>, optio
|
|
|
281
291
|
readonly "Canonical URL": "Canonical URL";
|
|
282
292
|
readonly Noindex: "Noindex";
|
|
283
293
|
readonly Nofollow: "Nofollow";
|
|
284
|
-
readonly "
|
|
285
|
-
readonly "
|
|
286
|
-
readonly "
|
|
287
|
-
readonly "Twitter title": "Twitter title";
|
|
288
|
-
readonly "Twitter description": "Twitter description";
|
|
289
|
-
readonly "Twitter image": "Twitter image";
|
|
294
|
+
readonly "Preview title": "Preview title";
|
|
295
|
+
readonly "Preview description": "Preview description";
|
|
296
|
+
readonly "Preview image": "Preview image";
|
|
297
|
+
readonly "X/Twitter title": "X/Twitter title";
|
|
298
|
+
readonly "X/Twitter description": "X/Twitter description";
|
|
299
|
+
readonly "X/Twitter image": "X/Twitter image";
|
|
300
|
+
readonly "Customize for X/Twitter (optional)": "Customize for X/Twitter (optional)";
|
|
301
|
+
readonly "Same as the preview image": "Same as the preview image";
|
|
302
|
+
readonly "Used by Facebook, LinkedIn, Slack, Discord, iMessage, WhatsApp and X/Twitter": "Used by Facebook, LinkedIn, Slack, Discord, iMessage, WhatsApp and X/Twitter";
|
|
290
303
|
readonly "Saving\u2026": "Saving…";
|
|
291
304
|
readonly "Save overrides": "Save overrides";
|
|
292
305
|
readonly "Per-entry SEO": "Per-entry SEO";
|
|
293
306
|
readonly "Search appearance": "Search appearance";
|
|
294
307
|
readonly "Title template (must contain %s)": "Title template (must contain %s)";
|
|
295
308
|
readonly "Default meta description": "Default meta description";
|
|
296
|
-
readonly "Social
|
|
297
|
-
readonly "Default
|
|
298
|
-
readonly "Twitter @
|
|
309
|
+
readonly "Social preview": "Social preview";
|
|
310
|
+
readonly "Default preview image": "Default preview image";
|
|
311
|
+
readonly "X/Twitter @handle": "X/Twitter @handle";
|
|
299
312
|
readonly Crawling: "Crawling";
|
|
300
313
|
readonly "Default noindex": "Default noindex";
|
|
301
314
|
readonly "Default nofollow": "Default nofollow";
|
|
@@ -308,7 +321,7 @@ export declare function seo({ siteUrl }: Pick<SeoReactOptions, 'siteUrl'>, optio
|
|
|
308
321
|
readonly "View sitemap": "View sitemap";
|
|
309
322
|
readonly "Loading SEO settings\u2026": "Loading SEO settings…";
|
|
310
323
|
readonly Marketing: "Marketing";
|
|
311
|
-
readonly "Site-wide defaults for meta titles, descriptions,
|
|
324
|
+
readonly "Site-wide defaults for meta titles, descriptions, social previews, and robots directives.": "Site-wide defaults for meta titles, descriptions, social previews, and robots directives.";
|
|
312
325
|
readonly "Choose image": "Choose image";
|
|
313
326
|
readonly Remove: "Remove";
|
|
314
327
|
readonly "Sitemap \u00B7 {count} URLs": "Sitemap · {count} URLs";
|
|
@@ -336,6 +349,8 @@ export declare function seo({ siteUrl }: Pick<SeoReactOptions, 'siteUrl'>, optio
|
|
|
336
349
|
readonly "failed to load the sitemap": "failed to load the sitemap";
|
|
337
350
|
readonly "Could not load the sitemap (HTTP {status}).": "Could not load the sitemap (HTTP {status}).";
|
|
338
351
|
readonly "Selected image": "Selected image";
|
|
352
|
+
readonly "Using site default: {name}": "Using site default: {name}";
|
|
353
|
+
readonly "Using image from page content: {name}": "Using image from page content: {name}";
|
|
339
354
|
};
|
|
340
355
|
readonly pages: {
|
|
341
356
|
readonly settings: {
|
|
@@ -20,7 +20,7 @@ import { useWiredSeo } from "../hooks/use-seo.hooks.js";
|
|
|
20
20
|
* still a minimal textarea-per-rule form (unchanged from the original disclosed scope note).
|
|
21
21
|
*
|
|
22
22
|
* Copy comes from the module's injected t function. Legacy locale props remain render-contract
|
|
23
|
-
* metadata only;
|
|
23
|
+
* metadata only; the host retains its dictionaries and passes the bound translator through the module.
|
|
24
24
|
* Host publish behavior is injected as headerActions, and media selection uses its existing slot
|
|
25
25
|
* or the shared mediaPickerToken service. The original rationale is archived in SOURCE-RATIONALE.
|
|
26
26
|
*
|
|
@@ -65,6 +65,8 @@ import { useWiredSeo } from "../hooks/use-seo.hooks.js";
|
|
|
65
65
|
*/
|
|
66
66
|
export interface SeoSectionProps {
|
|
67
67
|
title: string;
|
|
68
|
+
/** Optional ⓘ explanation beside the title (explanations are tips, never loose paragraphs). */
|
|
69
|
+
tip?: string;
|
|
68
70
|
children: ReactNode;
|
|
69
71
|
}
|
|
70
72
|
export interface EntryPickerProps {
|
|
@@ -2,8 +2,9 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { useAnalyzePanel, useSeoDefaults, useSeoTabs, seoTabPanel } from "../hooks/SeoPage.hooks.js";
|
|
3
3
|
import { agentHandle } from "@jini-ai/agentic";
|
|
4
4
|
import { TabBar } from "@jini-ai/ui/tab-strip";
|
|
5
|
+
import { InfoTip, Select } from "@jini-ai/ui/admin-widgets";
|
|
5
6
|
import { useSeoCopy, useSeoOptions } from "../hooks/SeoPorts.hooks.js";
|
|
6
|
-
import { actionLabel, orEmpty } from "../../rules.js";
|
|
7
|
+
import { actionLabel, orEmpty, xIsCustomized, xResolvedValue } from "../../rules.js";
|
|
7
8
|
import { useWiredEntryPicker } from "../hooks/use-entry-picker.hooks.js";
|
|
8
9
|
import { useWiredSeoEntryPanel } from "../hooks/use-seo-entry-panel.hooks.js";
|
|
9
10
|
import { useSeoEntrySection } from "../hooks/use-seo-entry-section.hooks.js";
|
|
@@ -29,31 +30,27 @@ import { SitemapModal } from "../components/SitemapModal.js";
|
|
|
29
30
|
* overrides", which sits INSIDE the "Per-entry SEO" section — keeps its own `<h3>` and is not
|
|
30
31
|
* routed through here.
|
|
31
32
|
*/
|
|
32
|
-
function SeoSection({ title, children }) {
|
|
33
|
-
return (_jsxs("section", { className: "jini-seo-section", children: [_jsx("div", { className: "jini-seo-section-head", children:
|
|
33
|
+
function SeoSection({ title, tip, children }) {
|
|
34
|
+
return (_jsxs("section", { className: "jini-seo-section", children: [_jsx("div", { className: "jini-seo-section-head", children: _jsxs("h2", { className: "jini-seo-section-title", children: [title, tip ? _jsx(InfoTip, { label: tip }) : null] }) }), _jsx("div", { className: "jini-seo-section-body", children: children })] }));
|
|
34
35
|
}
|
|
35
|
-
/**
|
|
36
|
-
* routes
|
|
36
|
+
/** Searchable picker over every page + post, sourced from the already-existing `listPosts`/`listPages`
|
|
37
|
+
* routes (REQ-06). It was a native `<select>`; with a few dozen entries that opened as one tall OS
|
|
38
|
+
* list with "(published)" after every row (owner, 2026-10-15: "we have a searchable dropdown
|
|
39
|
+
* right?"). The shared admin-widgets `Select` already types-to-filter and handles the keyboard, so
|
|
40
|
+
* rows are just grouped Pages/Posts with a pill only for the non-published exceptions
|
|
41
|
+
* (`entryPickerOptions` in `rules.ts`). */
|
|
37
42
|
function EntryPicker({ locale, entryId, onChange, useEntryPickerHook = useWiredEntryPicker }) {
|
|
38
43
|
const t = useSeoCopy();
|
|
39
|
-
const { entries, error } = useEntryPickerHook();
|
|
44
|
+
const { entries, options, error } = useEntryPickerHook();
|
|
40
45
|
if (error)
|
|
41
46
|
return _jsx("div", { className: "jini-notice jini-notice-error", children: error });
|
|
42
47
|
if (!entries)
|
|
43
48
|
return _jsx("div", { className: "jini-notice", children: t({ locale: locale, key: "Loading entries…" }) });
|
|
44
49
|
/* The "Entry" caption is no longer painted (owner, 2026-09-06: "you can actually just get rid of
|
|
45
|
-
'Entry'") — the placeholder
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
the four controls a previous accessibility pass found unnamed — including, by name, a
|
|
50
|
-
`<select>` with no accessible name at all. `display: contents` drops the wrapper's own box, so
|
|
51
|
-
the select keeps the section body's sizing exactly as if the label were not there.
|
|
52
|
-
The i18n key is unchanged and still passed through `t`, so nothing dangles. */
|
|
53
|
-
return (_jsxs("label", { className: "jini-a11y-label-wrap", children: [_jsx("span", { className: "jini-visually-hidden", children: t({ locale: locale, key: "Entry" }) }), _jsxs("select", { className: "jini-select", value: entryId, onChange: (e) => onChange(e.target.value), ...agentHandle({ handle: "seo-entry-picker" }, {
|
|
54
|
-
role: "field",
|
|
55
|
-
label: "Choose which page or post to edit or analyze SEO for",
|
|
56
|
-
}), children: [_jsx("option", { value: "", children: t({ locale: locale, key: "Choose an entry…" }) }), entries.map((entry) => (_jsxs("option", { value: entry.id, children: [entry.title, " (", entry.status, ")"] }, entry.id)))] })] }));
|
|
50
|
+
'Entry'") — the placeholder already says "Choose an entry…", so the visible word was saying it
|
|
51
|
+
twice. It stays the control's accessible name (now `aria-label` on the combobox trigger) rather
|
|
52
|
+
than leaving a placeholder-only control with no name at all. The i18n key is unchanged. */
|
|
53
|
+
return (_jsx("div", { className: "jini-seo-entry-picker", children: _jsx(Select, { value: entryId, onChange: onChange, options: options ?? [], placeholder: t({ locale: locale, key: "Choose an entry…" }), "aria-label": t({ locale: locale, key: "Entry" }), agentHandle: "seo-entry-picker", translate: (key) => t({ locale, key }) }) }));
|
|
57
54
|
}
|
|
58
55
|
/** Read-only score + issues view (REQ-07) — exact field names read off `SeoAnalysis`/`SeoIssue`
|
|
59
56
|
* (`src/seo/types.ts`), not guessed. No state of its own — only the severity sort, which lives in
|
|
@@ -61,8 +58,11 @@ function EntryPicker({ locale, entryId, onChange, useEntryPickerHook = useWiredE
|
|
|
61
58
|
function AnalyzePanel(props) {
|
|
62
59
|
const t = useSeoCopy();
|
|
63
60
|
const { locale } = props;
|
|
64
|
-
const { sortedIssues } = useAnalyzePanel({ analysis: props.analysis });
|
|
65
|
-
|
|
61
|
+
const { sortedIssues, tone } = useAnalyzePanel({ analysis: props.analysis });
|
|
62
|
+
/* Heading + score pill on one line, issues as a compact list (owner, 2026-10-15: the bare
|
|
63
|
+
"Score: 100 / No issues." read as unstyled text). The pill reuses the issue pills' status
|
|
64
|
+
tones, so the score and the issues under it speak one colour language. */
|
|
65
|
+
return (_jsxs("section", { className: "jini-seo-analyze-panel", "aria-labelledby": "seo-analysis-heading", children: [_jsxs("div", { className: "jini-seo-analyze-head", children: [_jsx("h3", { id: "seo-analysis-heading", children: t({ locale: locale, key: "Analysis" }) }), _jsxs("span", { className: `jini-status jini-status-${tone} jini-seo-score`, children: [t({ locale: locale, key: "Score:" }), " ", _jsx("strong", { children: props.analysis.score })] })] }), sortedIssues.length === 0 ? (_jsx("p", { className: "jini-seo-analyze-empty", children: t({ locale: locale, key: "No issues." }) })) : (_jsx("ul", { className: "jini-seo-issues", children: sortedIssues.map((issue, i) => (_jsxs("li", { className: "jini-seo-issue", children: [_jsx("span", { className: `jini-status jini-status-${issue.severity === "error" ? "failure" : issue.severity === "warning" ? "warning" : "success"}`, children: issue.severity }), _jsxs("span", { className: "jini-seo-issue-text", children: [issue.message, issue.field ? _jsxs("span", { className: "jini-muted-cell", children: [" (", issue.field, ")"] }) : null] })] }, `${issue.code}-${i}`))) }))] }));
|
|
66
66
|
}
|
|
67
67
|
/** Per-entry overrides edit form (REQ-06). Pre-fills from `getSeoEntry`'s resolved meta, but
|
|
68
68
|
* tracks which fields the user actually touched so `putSeoEntry` only ever sends a genuine
|
|
@@ -80,14 +80,16 @@ function AnalyzePanel(props) {
|
|
|
80
80
|
// the ceiling on their own. There is nothing to extract: splitting the eleven fields into their own
|
|
81
81
|
// components would still evaluate the same fallback chains, just spread across more functions, for
|
|
82
82
|
// no complexity benefit and a real loss of "one form, one place to read its fields."
|
|
83
|
+
// 2026-10-15: now 27 — the X fields' placeholders (the live social preview value) add two more
|
|
84
|
+
// flat `??` fallbacks of exactly that kind.
|
|
83
85
|
function SeoEntryPanel({ locale, entryId, useSeoEntryPanelHook = useWiredSeoEntryPanel }) {
|
|
84
86
|
const t = useSeoCopy();
|
|
85
|
-
const { resolved, analysis, loadError, saving, saveError, notice, fieldValue, setField, save, touched } = useSeoEntryPanelHook({ entryId });
|
|
87
|
+
const { resolved, analysis, loadError, saving, saveError, notice, fieldValue, setField, save, touched, ogImageInherited = false } = useSeoEntryPanelHook({ entryId });
|
|
86
88
|
if (loadError)
|
|
87
89
|
return _jsx("div", { className: "jini-notice jini-notice-error", children: loadError });
|
|
88
90
|
if (!resolved)
|
|
89
91
|
return _jsx("div", { className: "jini-notice", children: t({ locale: locale, key: "Loading entry SEO…" }) });
|
|
90
|
-
return (_jsxs("div", { className: "jini-seo-entry-panel", children: [_jsx("h3", { children: t({ locale: locale, key: "Per-entry overrides" }) }),
|
|
92
|
+
return (_jsxs("div", { className: "jini-seo-entry-panel", children: [_jsx("div", { className: "jini-seo-entry-head", children: _jsxs("div", { className: "jini-seo-entry-head-bar", children: [_jsx("h3", { children: t({ locale: locale, key: "Per-entry overrides" }) }), _jsx(InfoTip, { label: `${t({ locale: locale, key: "Fields show the currently-effective value (author override, or site default, or derived from the entry). Only fields you change here are saved as overrides." })} ${t({ locale: locale, key: "Empty a field and save to remove its override — the entry falls back to the site default." })}` }), _jsx("span", { className: "jini-seo-entry-head-actions", children: _jsx("button", { type: "button", className: "jini-btn", onClick: save, disabled: saving || Object.keys(touched).length === 0, ...agentHandle({ handle: "seo-entry-save-overrides" }, { role: "button", label: "Save this entry's SEO overrides" }), children: saving ? t({ locale: locale, key: "Saving…" }) : t({ locale: locale, key: "Save overrides" }) }) })] }) }), notice ? _jsx("div", { className: "jini-notice", children: notice }) : null, saveError ? (_jsx("div", { className: "jini-notice jini-notice-error", role: "alert", children: saveError })) : null, _jsxs("fieldset", { className: "jini-seo-entry-group", children: [_jsx("legend", { children: t({ locale: locale, key: "Search appearance" }) }), _jsxs("label", { children: [t({ locale: locale, key: "Title" }), _jsx("input", { className: "jini-input", value: fieldValue("title", resolved.title) ?? "", onChange: (e) => setField("title", e.target.value), ...agentHandle({ handle: "seo-entry-title" }, { role: "field", label: "This entry's SEO title override" }) })] }), _jsxs("label", { children: [t({ locale: locale, key: "Description" }), _jsx("textarea", { className: "jini-textarea", value: fieldValue("description", resolved.description ?? "") ?? "", onChange: (e) => setField("description", e.target.value), ...agentHandle({ handle: "seo-entry-description" }, { role: "field", label: "This entry's SEO description override" }) })] }), _jsxs("label", { children: [t({ locale: locale, key: "Canonical URL" }), _jsx("input", { className: "jini-input", value: fieldValue("canonical", resolved.canonical) ?? "", onChange: (e) => setField("canonical", e.target.value), ...agentHandle({ handle: "seo-entry-canonical" }, { role: "field", label: "This entry's canonical URL override" }) })] }), _jsxs("div", { className: "jini-seo-entry-checks", children: [_jsxs("label", { children: [_jsx("input", { className: "jini-input", type: "checkbox", checked: fieldValue("noindex", resolved.robots.noindex) ?? false, onChange: (e) => setField("noindex", e.target.checked), ...agentHandle({ handle: "seo-entry-noindex" }, { role: "checkbox", label: "Override this entry's robots noindex directive" }) }), t({ locale: locale, key: "Noindex" })] }), _jsxs("label", { children: [_jsx("input", { className: "jini-input", type: "checkbox", checked: fieldValue("nofollow", resolved.robots.nofollow) ?? false, onChange: (e) => setField("nofollow", e.target.checked), ...agentHandle({ handle: "seo-entry-nofollow" }, { role: "checkbox", label: "Override this entry's robots nofollow directive" }) }), t({ locale: locale, key: "Nofollow" })] })] })] }), _jsxs("fieldset", { className: "jini-seo-entry-group", children: [_jsxs("legend", { children: [t({ locale: locale, key: "Social preview" }), _jsx(InfoTip, { label: t({ locale: locale, key: "Used by Facebook, LinkedIn, Slack, Discord, iMessage, WhatsApp and X/Twitter" }) })] }), _jsxs("label", { children: [t({ locale: locale, key: "Preview title" }), _jsx("input", { className: "jini-input", value: fieldValue("ogTitle", resolved.openGraph.title) ?? "", onChange: (e) => setField("ogTitle", e.target.value), ...agentHandle({ handle: "seo-entry-og-title" }, { role: "field", label: "This entry's Open Graph title override" }) })] }), _jsxs("label", { children: [t({ locale: locale, key: "Preview description" }), _jsx("input", { className: "jini-input", value: fieldValue("ogDescription", resolved.openGraph.description ?? "") ?? "", onChange: (e) => setField("ogDescription", e.target.value), ...agentHandle({ handle: "seo-entry-og-description" }, { role: "field", label: "This entry's Open Graph description override" }) })] }), _jsx(MediaRefField, { locale: locale, id: "seo-entry-og-image", label: t({ locale: locale, key: "Preview image" }), value: fieldValue("ogImage", resolved.openGraph.imageRef ?? resolved.openGraph.image ?? "") ?? "", onChange: (value) => setField("ogImage", value), inherited: ogImageInherited, agentHandle: "seo-entry-og-image" }), _jsxs("details", { className: "jini-seo-x-disclosure", open: xIsCustomized({ meta: resolved }), children: [_jsx("summary", { children: t({ locale: locale, key: "Customize for X/Twitter (optional)" }) }), _jsxs("div", { className: "jini-seo-x-fields", children: [_jsxs("label", { children: [t({ locale: locale, key: "X/Twitter title" }), _jsx("input", { className: "jini-input", value: fieldValue("twitterTitle", xResolvedValue({ key: "twitterTitle", meta: resolved })) ?? "", placeholder: fieldValue("ogTitle", resolved.openGraph.title) ?? "", onChange: (e) => setField("twitterTitle", e.target.value), ...agentHandle({ handle: "seo-entry-twitter-title" }, { role: "field", label: "This entry's Twitter card title override" }) })] }), _jsxs("label", { children: [t({ locale: locale, key: "X/Twitter description" }), _jsx("input", { className: "jini-input", value: fieldValue("twitterDescription", xResolvedValue({ key: "twitterDescription", meta: resolved })) ?? "", placeholder: fieldValue("ogDescription", resolved.openGraph.description ?? "") ?? "", onChange: (e) => setField("twitterDescription", e.target.value), ...agentHandle({ handle: "seo-entry-twitter-description" }, { role: "field", label: "This entry's Twitter card description override" }) })] }), _jsx(MediaRefField, { locale: locale, id: "seo-entry-twitter-image", label: t({ locale: locale, key: "X/Twitter image" }), value: fieldValue("twitterImage", xResolvedValue({ key: "twitterImage", meta: resolved })) ?? "", emptyHint: t({ locale: locale, key: "Same as the preview image" }), onChange: (value) => setField("twitterImage", value), agentHandle: "seo-entry-twitter-image" })] })] })] }), analysis ? _jsx(AnalyzePanel, { locale: locale, analysis: analysis }) : null] }));
|
|
91
93
|
}
|
|
92
94
|
/** Section wrapper (REQ-06/07) — entry picker over the per-entry edit + analyze panels. This IS the
|
|
93
95
|
* "Pages & posts" tab panel; it kept its name and its markup, because nothing about it changed
|
|
@@ -139,7 +141,7 @@ export function SeoDefaultsTab({ controller }, _optional = {}) {
|
|
|
139
141
|
}) })] }), _jsxs("div", { className: "jini-field", children: [_jsx("label", { className: "jini-field-label", htmlFor: "seo-default-description", children: t({ locale: locale, key: "Default meta description" }) }), _jsx("textarea", { className: "jini-textarea", id: "seo-default-description", name: "defaultDescription", defaultValue: orEmpty({ value: settings.defaultDescription }), ...agentHandle({ handle: "seo-default-description" }, {
|
|
140
142
|
role: "field",
|
|
141
143
|
label: "Fallback meta description for pages that set none of their own",
|
|
142
|
-
}) })] })] }), _jsxs(SeoSection, { title: t({ locale: locale, key: "Social
|
|
144
|
+
}) })] })] }), _jsxs(SeoSection, { title: t({ locale: locale, key: "Social preview" }), tip: t({ locale: locale, key: "Used by Facebook, LinkedIn, Slack, Discord, iMessage, WhatsApp and X/Twitter" }), children: [_jsx(MediaRefField, { locale: locale, id: "seo-default-og-image", name: "defaultOgImage", label: t({ locale: locale, key: "Default preview image" }), value: defaultOgImage, onChange: setDefaultOgImage, agentHandle: "seo-default-og-image" }), _jsxs("div", { className: "jini-field", children: [_jsx("label", { className: "jini-field-label", htmlFor: "seo-twitter-site", children: t({ locale: locale, key: "X/Twitter @handle" }) }), _jsx("input", { className: "jini-input", id: "seo-twitter-site", name: "twitterSite", defaultValue: orEmpty({ value: settings.twitterSite }), ...agentHandle({ handle: "seo-twitter-site" }, {
|
|
143
145
|
role: "field",
|
|
144
146
|
label: "The site's Twitter @handle, used in Twitter card metadata",
|
|
145
147
|
}) })] })] }), _jsxs(SeoSection, { title: t({ locale: locale, key: "Crawling" }), children: [_jsxs("label", { className: "jini-form-checkbox-field", children: [_jsx("input", { className: "jini-input", type: "checkbox", name: "noindex", defaultChecked: settings.defaultRobots.noindex, ...agentHandle({ handle: "seo-default-noindex" }, {
|
|
@@ -208,7 +210,7 @@ export function Seo({ tabId, onTabChange, useSeoHook = useWiredSeo } = {}, _opti
|
|
|
208
210
|
regenerateSitemap,
|
|
209
211
|
openSitemapModal,
|
|
210
212
|
};
|
|
211
|
-
return (_jsxs("div", { className: "jini-page jini-seo-page", children: [_jsxs("div", { className: "jini-page-header", children: [_jsxs("div", { className: "jini-page-header-text", children: [_jsx("p", { className: "jini-page-kicker", children: t({ locale: locale, key: "Marketing" }) }), _jsx("h1", { className: "jini-page-title", children: "SEO" }), _jsx("p", { className: "jini-page-description", children: t({ locale: locale, key: "Site-wide defaults for meta titles, descriptions,
|
|
213
|
+
return (_jsxs("div", { className: "jini-page jini-seo-page", children: [_jsxs("div", { className: "jini-page-header", children: [_jsxs("div", { className: "jini-page-header-text", children: [_jsx("p", { className: "jini-page-kicker", children: t({ locale: locale, key: "Marketing" }) }), _jsx("h1", { className: "jini-page-title", children: "SEO" }), _jsx("p", { className: "jini-page-description", children: t({ locale: locale, key: "Site-wide defaults for meta titles, descriptions, social previews, and robots directives." }) })] }), _jsx("div", { className: "jini-page-actions", children: headerActions })] }), error ? _jsx("div", { className: "jini-notice jini-notice-error", children: error }) : null, notice ? _jsx("div", { className: "jini-notice", children: notice }) : null, _jsx(TabBar, { ariaLabel: "SEO", tabs: tabs, activeId: activeTabId, onChange: changeTab, containerHandle: "seo-tab-bar" }), seoTabPanel({ activeTabId, controller, components: { SeoDefaultsTab, SeoSitemapTab, SeoEntrySection } }), sitemapModalOpen ? (_jsx(SitemapModal, { locale: locale, sitemapEnabled: settings.sitemapEnabled, regenerating: saving, onRegenerate: regenerateSitemap, onClose: closeSitemapModal })) : null] }));
|
|
212
214
|
}
|
|
213
215
|
export { Seo as SeoPage };
|
|
214
216
|
export default Seo;
|
package/dist/seo/rules.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { AdminSeoIssue as SeoIssue, AdminSeoSettingsPatch as SeoSettingsPatch } from "../core/ports/seo.js";
|
|
2
2
|
import type { SeoTranslator } from './models.js';
|
|
3
|
+
import type { AdminSeoEntryChoice, AdminSeoMediaSummary, AdminSeoMeta } from '../core/ports/seo.js';
|
|
3
4
|
/**
|
|
4
5
|
* Orders issues error-first, then warning, then info, matching the severities' natural urgency.
|
|
5
6
|
* An unrecognized severity sorts last (falls back to `9`) rather than throwing, since this reads
|
|
@@ -14,7 +15,7 @@ export declare function sortIssuesBySeverity({ issues }: {
|
|
|
14
15
|
/**
|
|
15
16
|
* One edited per-entry override field's outgoing value. An emptied text/URL box means "remove this
|
|
16
17
|
* override so the entry falls back to the site default", which on the wire is `null` — the clear
|
|
17
|
-
* sentinel `SeoExtFieldsPatch` documents (`
|
|
18
|
+
* sentinel `SeoExtFieldsPatch` documents (`features/seo/types.ts`) and
|
|
18
19
|
* `setEntrySeoOverrides` implements by `delete`-ing the key.
|
|
19
20
|
*
|
|
20
21
|
* It is NOT `""`. `write-service.ts` stores `""` as a genuine override and `seo.ts` resolves
|
|
@@ -37,6 +38,36 @@ export declare function overrideOrClear<V>({ value }: {
|
|
|
37
38
|
export declare function orEmpty({ value }: {
|
|
38
39
|
value: string | undefined;
|
|
39
40
|
}, _optional?: Record<string, never>): string;
|
|
41
|
+
/** The X (twitter) fields the "Customize for X/Twitter" disclosure holds, each paired with the social
|
|
42
|
+
* preview (Open Graph) value it falls back to. */
|
|
43
|
+
export type SeoXFieldKey = 'twitterTitle' | 'twitterDescription' | 'twitterImage';
|
|
44
|
+
/** Whether this entry's X card differs from its social preview in any field — i.e. an X override
|
|
45
|
+
* is in effect, so the "Customize for X/Twitter" disclosure opens on load. (2026-10-15, owner: one
|
|
46
|
+
* generic social preview, X only as an optional customization.)
|
|
47
|
+
* @complexity O(1). */
|
|
48
|
+
export declare function xIsCustomized({ meta }: {
|
|
49
|
+
meta: AdminSeoMeta;
|
|
50
|
+
}, _optional?: Record<string, never>): boolean;
|
|
51
|
+
/** An X field's untouched box value, handed to `fieldValue` as its resolved value (so a pending
|
|
52
|
+
* edit or clear still wins there). An X value equal to the social preview's is inherited, so the
|
|
53
|
+
* box stays empty and its placeholder shows the live social value — typing there is what creates
|
|
54
|
+
* an X override.
|
|
55
|
+
* @complexity O(1). */
|
|
56
|
+
export declare function xResolvedValue({ key, meta }: {
|
|
57
|
+
key: SeoXFieldKey;
|
|
58
|
+
meta: AdminSeoMeta;
|
|
59
|
+
}, _optional?: Record<string, never>): string;
|
|
60
|
+
/** The X field an edit to a social preview field should carry along: when X still shows the
|
|
61
|
+
* preview's value and has no pending edit of its own, the edit also clears X's stored override so
|
|
62
|
+
* X keeps following the preview after save. Without it, an X override saved equal to the preview
|
|
63
|
+
* (indistinguishable here, the screen sees only effective values) would silently pin X to the old
|
|
64
|
+
* text. Clearing an override that does not exist is a no-op on the server.
|
|
65
|
+
* @complexity O(1). */
|
|
66
|
+
export declare function inheritedXFor({ key, meta, touched }: {
|
|
67
|
+
key: string;
|
|
68
|
+
meta: AdminSeoMeta;
|
|
69
|
+
touched: Readonly<Record<string, unknown>>;
|
|
70
|
+
}, _optional?: Record<string, never>): SeoXFieldKey | null;
|
|
40
71
|
/** A pending-action button's label — `react/pages/SeoPage.tsx` uses this for both the save-settings button
|
|
41
72
|
* ("Saving…"/"Save settings") and the regenerate-sitemap button ("Working…"/"Regenerate
|
|
42
73
|
* sitemap"), same `pending ? … : …` shape, different copy. Also reused by `SitemapModal.tsx`'s
|
|
@@ -47,7 +78,7 @@ export declare function actionLabel({ pending, pendingLabel, idleLabel }: {
|
|
|
47
78
|
idleLabel: string;
|
|
48
79
|
}, _optional?: Record<string, never>): string;
|
|
49
80
|
/**
|
|
50
|
-
* One `<url>` entry from a parsed sitemap. Only the fields
|
|
81
|
+
* One `<url>` entry from a parsed sitemap. Only the fields the host's
|
|
51
82
|
* `registerSeoSitemapRoute` (`server/inbound/public-http/routes/site/sitemap.ts`) actually emits —
|
|
52
83
|
* `<loc>` always, `<lastmod>` when the entry has one — verified by reading that route rather than
|
|
53
84
|
* guessed: it never writes `changefreq`/`priority`, so `SitemapModal.tsx`'s table has no columns
|
|
@@ -85,10 +116,43 @@ export declare function filterSitemapEntries({ entries, query }: {
|
|
|
85
116
|
export declare function isStoredMediaRef({ value }: {
|
|
86
117
|
value: string;
|
|
87
118
|
}, _optional?: Record<string, never>): boolean;
|
|
119
|
+
/** What an image field shows for its value — the file's name and pixel size — in place of the raw
|
|
120
|
+
* `{slug}:public` reference or URL (owner, 2026-10-15: a read-only "Selected image" box "looks
|
|
121
|
+
* useless"). The library row wins when the host can list media; otherwise a readable slug or the
|
|
122
|
+
* URL's file name stands in, and an opaque id falls back to the translated "Image".
|
|
123
|
+
* Size prefers the stored dimensions, then the decoded preview's natural size.
|
|
124
|
+
* @example describeMediaRef({ value: 'sunset:public', media: [{ id: 'a1', slug: 'sunset', title: 'Sunset', width: 1200, height: 630 }] }) // { name: 'Sunset', size: '1200 × 630' }
|
|
125
|
+
* @complexity O(m) over the media rows; no I/O.
|
|
126
|
+
*/
|
|
127
|
+
export declare function describeMediaRef({ value, media }: {
|
|
128
|
+
value: string;
|
|
129
|
+
media?: readonly AdminSeoMediaSummary[] | null | undefined;
|
|
130
|
+
}, { width, height, t, extensionFor }?: {
|
|
131
|
+
width?: number | undefined;
|
|
132
|
+
height?: number | undefined;
|
|
133
|
+
t?: SeoTranslator | undefined;
|
|
134
|
+
/** `image/png` → `.png`; injected (the host UI kit owns the table) so this file stays pure. */
|
|
135
|
+
extensionFor?: ((contentType: string) => string) | undefined;
|
|
136
|
+
}): {
|
|
137
|
+
name: string;
|
|
138
|
+
size: string | null;
|
|
139
|
+
} | null;
|
|
140
|
+
/** Whether an entry's social preview image comes from content or the site default, with no override
|
|
141
|
+
* of its own, so the field shows its source muted. "Using site default" is the original copy (owner,
|
|
142
|
+
* 2026-10-15). A pending edit is the entry's own, and so is a stored override the host flags
|
|
143
|
+
* (`imageOverridden`) even when it equals the default. A host without that flag can't tell such an
|
|
144
|
+
* override apart, so it reads as inherited (same image either way). Returns 'content' for an image
|
|
145
|
+
* the host identifies as content, 'default' for the site default, and false for an own/absent image.
|
|
146
|
+
* @complexity O(1). */
|
|
147
|
+
export declare function ogImageInherited({ meta, defaultOgImage, touched }: {
|
|
148
|
+
meta: AdminSeoMeta;
|
|
149
|
+
defaultOgImage: string | undefined;
|
|
150
|
+
touched: Readonly<Record<string, unknown>>;
|
|
151
|
+
}, _optional?: Record<string, never>): false | 'default' | 'content';
|
|
88
152
|
/** Builds the `{slug}:public` reference `MediaRefField` writes into the field on selection —
|
|
89
153
|
* prefers the asset's readable slug over its id (readable-slugs S5b, 2026-09-23), matching
|
|
90
154
|
* `resolveSeoImageRef`'s own id-or-slug lookup server-side (`findMediaByIdOrSlug`,
|
|
91
|
-
* `
|
|
155
|
+
* `features/seo/media.ts`, S4) so a saved override reads as a slug, not an opaque
|
|
92
156
|
* uuid. Falls back to `id` when `slug` is empty — defensive only; a real upload always derives one
|
|
93
157
|
* (`deriveUniqueMediaSlug`). Same `transformName: "public"` `EmbedInsertControl.tsx` already writes
|
|
94
158
|
* for an inserted image node. An old stored `{assetId}:public` value keeps resolving regardless
|
|
@@ -110,7 +174,7 @@ export declare function buildMediaRef({ item }: {
|
|
|
110
174
|
* "string"` also resolves to `null` rather than throwing — `MediaRefField`'s `value` prop is a
|
|
111
175
|
* `fieldValue(key, resolved) ?? ""` fallback chain (`react/pages/SeoPage.tsx`), where `??` does not replace a
|
|
112
176
|
* non-nullish-but-wrong-typed result from a misbehaving caller; matches `resolveSeoImageRef`'s own
|
|
113
|
-
* "never throws over one bad reference" contract (`
|
|
177
|
+
* "never throws over one bad reference" contract (`features/seo/media.ts`).
|
|
114
178
|
*
|
|
115
179
|
* @complexity O(1) — string parsing only, no I/O (the returned URL is a template; the browser
|
|
116
180
|
* performs the actual fetch only once it is used as an `<img src>`). */
|
|
@@ -159,4 +223,31 @@ export type SeoTabId = (typeof SEO_TAB_IDS)[number];
|
|
|
159
223
|
export declare function resolveSeoTabId({ tabId }: {
|
|
160
224
|
tabId?: string | null | undefined;
|
|
161
225
|
}, _optional?: Record<string, never>): SeoTabId;
|
|
226
|
+
/** Colour band for the analysis score pill: the same success/warning/failure tones the issue pills
|
|
227
|
+
* use, so a 100 reads green and a failing page reads red at a glance.
|
|
228
|
+
* @example scoreTone({ score: 100 }) // 'success'
|
|
229
|
+
*/
|
|
230
|
+
export declare function scoreTone({ score }: {
|
|
231
|
+
score: number;
|
|
232
|
+
}, _optional?: Record<string, never>): 'success' | 'warning' | 'failure';
|
|
233
|
+
/** One row of the searchable entry picker: grouped Pages then Posts, status only when not live. */
|
|
234
|
+
export interface SeoEntryPickerOption {
|
|
235
|
+
value: string;
|
|
236
|
+
label: string;
|
|
237
|
+
group: string;
|
|
238
|
+
badge?: string;
|
|
239
|
+
detail?: string;
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Picker rows for the Pages & posts tab. Pages lead because they are fewer and are the usual SEO
|
|
243
|
+
* targets (home, about, pricing); a published entry carries no pill, so only the exceptions
|
|
244
|
+
* (draft, scheduled, …) draw the eye. An untitled entry stays pickable by its id.
|
|
245
|
+
* @example entryPickerOptions({ posts, pages }, { t }) // [{ value: 'p1', label: 'About', group: 'Pages' }, …]
|
|
246
|
+
*/
|
|
247
|
+
export declare function entryPickerOptions({ posts, pages }: {
|
|
248
|
+
posts: readonly AdminSeoEntryChoice[];
|
|
249
|
+
pages: readonly AdminSeoEntryChoice[];
|
|
250
|
+
}, { t }?: {
|
|
251
|
+
t?: SeoTranslator | undefined;
|
|
252
|
+
}): SeoEntryPickerOption[];
|
|
162
253
|
//# sourceMappingURL=rules.d.ts.map
|