@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.
Files changed (38) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/comments/rules.d.ts +3 -3
  3. package/dist/comments/rules.js +3 -3
  4. package/dist/core/ports/seo.d.ts +20 -0
  5. package/dist/forms/field-settings.d.ts +1 -0
  6. package/dist/forms/field-settings.js +4 -4
  7. package/dist/forms/react/components/FieldAttributesDialog.js +1 -1
  8. package/dist/forms/react/hooks/use-forms-list.hooks.d.ts +1 -1
  9. package/dist/forms/rules.d.ts +2 -2
  10. package/dist/forms/rules.js +2 -2
  11. package/dist/redirects/react/hooks/RedirectsPage.hooks.js +1 -1
  12. package/dist/redirects/react/pages/RedirectsPage.js +2 -1
  13. package/dist/redirects/rules.d.ts +2 -2
  14. package/dist/redirects/rules.js +2 -2
  15. package/dist/seo/adapters/http.js +5 -0
  16. package/dist/seo/adapters/memory.js +1 -1
  17. package/dist/seo/messages.en.d.ts +15 -10
  18. package/dist/seo/messages.en.js +15 -10
  19. package/dist/seo/models.d.ts +11 -0
  20. package/dist/seo/react/components/MediaRefField.d.ts +17 -5
  21. package/dist/seo/react/components/MediaRefField.js +14 -4
  22. package/dist/seo/react/components/SitemapModal.js +1 -1
  23. package/dist/seo/react/hooks/MediaRefField.hooks.js +10 -2
  24. package/dist/seo/react/hooks/SeoPage.hooks.d.ts +1 -0
  25. package/dist/seo/react/hooks/SeoPage.hooks.js +2 -2
  26. package/dist/seo/react/hooks/SeoQuery.hooks.d.ts +1 -0
  27. package/dist/seo/react/hooks/SeoQuery.hooks.js +1 -0
  28. package/dist/seo/react/hooks/use-entry-picker.hooks.js +7 -2
  29. package/dist/seo/react/hooks/use-seo-entry-panel.hooks.js +13 -2
  30. package/dist/seo/react/hooks/use-share-image-warning.hooks.d.ts +4 -1
  31. package/dist/seo/react/hooks/use-share-image-warning.hooks.js +1 -0
  32. package/dist/seo/react/index.d.ts +45 -30
  33. package/dist/seo/react/pages/SeoPage.d.ts +3 -1
  34. package/dist/seo/react/pages/SeoPage.js +26 -24
  35. package/dist/seo/rules.d.ts +95 -4
  36. package/dist/seo/rules.js +134 -8
  37. package/dist/seo/seo.module.d.ts +15 -10
  38. 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) => controller?.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 "OG title": "OG title";
40
- readonly "OG description": "OG description";
41
- readonly "OG image": "OG image";
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 sharing": "Social sharing";
52
- readonly "Default Open Graph / Twitter image": "Default Open Graph / Twitter image";
53
- readonly "Twitter @site handle": "Twitter @site handle";
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, Open Graph/Twitter cards, and robots directives.": "Site-wide defaults for meta titles, descriptions, Open Graph/Twitter cards, and robots directives.";
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 "OG title": "OG title";
162
- readonly "OG description": "OG description";
163
- readonly "OG image": "OG image";
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 sharing": "Social sharing";
174
- readonly "Default Open Graph / Twitter image": "Default Open Graph / Twitter image";
175
- readonly "Twitter @site handle": "Twitter @site handle";
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, Open Graph/Twitter cards, and robots directives.": "Site-wide defaults for meta titles, descriptions, Open Graph/Twitter cards, and robots directives.";
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 "OG title": "OG title";
285
- readonly "OG description": "OG description";
286
- readonly "OG image": "OG image";
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 sharing": "Social sharing";
297
- readonly "Default Open Graph / Twitter image": "Default Open Graph / Twitter image";
298
- readonly "Twitter @site handle": "Twitter @site handle";
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, Open Graph/Twitter cards, and robots directives.": "Site-wide defaults for meta titles, descriptions, Open Graph/Twitter cards, and robots directives.";
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; Tovu retains its dictionaries and passes the bound translator through the module.
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: _jsx("h2", { className: "jini-seo-section-title", children: title }) }), _jsx("div", { className: "jini-seo-section-body", children: 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
- /** Dropdown over every post + page, sourced from the already-existing `listPosts`/`listPages`
36
- * routes — cheapest entry-selection UX available given what's already built (REQ-06). */
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 option already says "Choose an entry…", so the visible word was
46
- saying it twice, and with no gap between the two it read as one smashed-together control.
47
- It stays a REAL label rather than becoming a placeholder-only select: this is the exact
48
- `<label class="a11y-label-wrap">` + `.visually-hidden` idiom `styles/editor.css` documents for
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
- return (_jsxs("div", { className: "jini-seo-analyze-panel", children: [_jsx("h3", { children: t({ locale: locale, key: "Analysis" }) }), _jsxs("p", { children: [t({ locale: locale, key: "Score:" }), " ", _jsx("strong", { children: props.analysis.score })] }), sortedIssues.length === 0 ? (_jsx("p", { className: "jini-muted-cell", children: t({ locale: locale, key: "No issues." }) })) : (_jsx("ul", { children: sortedIssues.map((issue, i) => (_jsxs("li", { children: [_jsx("span", { className: `jini-status jini-status-${issue.severity === "error" ? "failure" : issue.severity === "warning" ? "unavailable" : "success"}`, children: issue.severity }), " ", _jsx("code", { children: issue.code }), issue.field ? _jsxs("span", { className: "jini-muted-cell", children: [" (", issue.field, ")"] }) : null, " \u2014 ", issue.message] }, `${issue.code}-${i}`))) }))] }));
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" }) }), _jsxs("p", { className: "jini-muted-cell", children: [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." })] }), notice ? _jsx("div", { className: "jini-notice", children: notice }) : null, saveError ? (_jsx("div", { className: "jini-notice jini-notice-error", role: "alert", children: saveError })) : null, _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("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("label", { children: [t({ locale: locale, key: "OG 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: "OG 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: "OG image" }), value: fieldValue("ogImage", resolved.openGraph.imageRef ?? resolved.openGraph.image ?? "") ?? "", onChange: (value) => setField("ogImage", value), agentHandle: "seo-entry-og-image" }), _jsxs("label", { children: [t({ locale: locale, key: "Twitter title" }), _jsx("input", { className: "jini-input", value: fieldValue("twitterTitle", resolved.twitter.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: "Twitter description" }), _jsx("input", { className: "jini-input", value: fieldValue("twitterDescription", resolved.twitter.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: "Twitter image" }), value: fieldValue("twitterImage", resolved.twitter.imageRef ?? resolved.twitter.image ?? "") ?? "", onChange: (value) => setField("twitterImage", value), agentHandle: "seo-entry-twitter-image" }), _jsx("span", { className: "jini-editor-actions", children: _jsx("button", { type: "button", className: "jini-btn jini-btn-secondary", 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" }) }) }), analysis ? _jsx(AnalyzePanel, { locale: locale, analysis: analysis }) : null] }));
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 sharing" }), children: [_jsx(MediaRefField, { locale: locale, id: "seo-default-og-image", name: "defaultOgImage", label: t({ locale: locale, key: "Default Open Graph / Twitter 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: "Twitter @site handle" }) }), _jsx("input", { className: "jini-input", id: "seo-twitter-site", name: "twitterSite", defaultValue: orEmpty({ value: settings.twitterSite }), ...agentHandle({ handle: "seo-twitter-site" }, {
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, Open Graph/Twitter cards, 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] }));
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;
@@ -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 (`apps/website/src/features/seo/types.ts`) and
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 `apps/website`'s
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
- * `apps/website/src/features/seo/media.ts`, S4) so a saved override reads as a slug, not an opaque
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 (`apps/website/src/features/seo/media.ts`).
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