@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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.12 — 2026-10-16
4
+
5
+ - SEO: searchable entry picker and grouped per-entry form (6c128f9d); image fields name the file, styled analysis, slug for same-titled entries (bc1dbf0d); one Social preview section with an optional "Customize for X" (ff1a709f); X/Twitter labels, file names with extensions, muted inherited site-default image (4919371d); "Using image from page content" when the share image comes from the page (746457d9).
6
+ - Peers: `@jini-ai/ui` `^0.5.5` (grouped Select) and `@jini-ai/cms` `^0.5.13` (share-image source).
7
+
8
+ ## 0.4.11 — 2026-10-14
9
+
10
+ - `FieldAttributesDialog` and `SitemapModal` use the neutral `jini-domain-dialog` class instead of a host-named one; hosts style `.jini-domain-dialog` (47b9fa4f).
11
+ - Rides along, no API change: forms option row keys come from the editor's own row list instead of a module-level counter (6281ddc8); redirects imports its shared widgets directly instead of the React barrel (9b45c742); comment scrubs (8e508fb6, 6281ddc8).
12
+
3
13
  ## 0.4.10 — 2026-10-13
4
14
 
5
15
  - Forms HTML starter: URL fields emit `type="text"` plus the registry's URL hints (`inputmode`, `autocomplete`, `spellcheck`, `autocapitalize`), so bare domains such as `tovu.dev` are no longer refused by the browser. CMS peer floor ^0.5.11.
@@ -31,11 +31,11 @@ export declare const KEYS: {
31
31
  /** The moderation queue's name on `lib/content-refresh-bus.ts` — see `taxonomy/rules.ts`'s
32
32
  * `TAXONOMY_RESOURCE` for why this is a plain colocated constant rather than a shared registry.
33
33
  * Agent-writable via `comments_approve_comment`/`comments_mark_comment_spam`/
34
- * `comments_trash_comment`/`comments_restore_comment` (`apps/website/src/features/comments/
35
- * agent-tools.ts`), all of which move a comment between statuses this queue lists.
34
+ * `comments_trash_comment`/`comments_restore_comment` (the host's comment agent tools),
35
+ * all of which move a comment between statuses this queue lists.
36
36
  *
37
37
  * There is deliberately NO sibling `COMMENTS_SETTINGS_RESOURCE`: `react/hooks/use-comment-settings.hooks.ts`'s
38
- * own header records a real, already-fixed lost-update bug (TM-TOVU-2026-08-12-A) whose fix is a
38
+ * own header records a real, already-fixed lost-update bug (2026-08-12) whose fix is a
39
39
  * ONE-SHOT seed guard that refuses to re-seed `settings` from any later successful load, including
40
40
  * this bus's own out-of-band notification. Wiring that hook to the bus would either do nothing
41
41
  * (the guard blocks it) or, if the guard were bypassed instead, reintroduce the exact silent
@@ -35,11 +35,11 @@ export const KEYS = {
35
35
  /** The moderation queue's name on `lib/content-refresh-bus.ts` — see `taxonomy/rules.ts`'s
36
36
  * `TAXONOMY_RESOURCE` for why this is a plain colocated constant rather than a shared registry.
37
37
  * Agent-writable via `comments_approve_comment`/`comments_mark_comment_spam`/
38
- * `comments_trash_comment`/`comments_restore_comment` (`apps/website/src/features/comments/
39
- * agent-tools.ts`), all of which move a comment between statuses this queue lists.
38
+ * `comments_trash_comment`/`comments_restore_comment` (the host's comment agent tools),
39
+ * all of which move a comment between statuses this queue lists.
40
40
  *
41
41
  * There is deliberately NO sibling `COMMENTS_SETTINGS_RESOURCE`: `react/hooks/use-comment-settings.hooks.ts`'s
42
- * own header records a real, already-fixed lost-update bug (TM-TOVU-2026-08-12-A) whose fix is a
42
+ * own header records a real, already-fixed lost-update bug (2026-08-12) whose fix is a
43
43
  * ONE-SHOT seed guard that refuses to re-seed `settings` from any later successful load, including
44
44
  * this bus's own out-of-band notification. Wiring that hook to the bus would either do nothing
45
45
  * (the guard blocks it) or, if the guard were bypassed instead, reintroduce the exact silent
@@ -76,6 +76,10 @@ export interface AdminSeoOpenGraph {
76
76
  readonly image?: string;
77
77
  /** Source reference before resizing, used to diagnose the selected share image. */
78
78
  readonly imageRef?: string;
79
+ /** Effective image provenance; absent from older hosts. */
80
+ readonly imageSource?: 'override' | 'content' | 'default';
81
+ /** Present (true) only when the entry stores its own image override; absent from older hosts. */
82
+ readonly imageOverridden?: true;
79
83
  readonly siteName?: string;
80
84
  }
81
85
  export interface AdminSeoTwitterCard {
@@ -149,6 +153,18 @@ export interface AdminSeoEntryChoice {
149
153
  readonly id: string;
150
154
  readonly title: string;
151
155
  readonly status: string;
156
+ /** Tells two same-titled entries apart in the picker; optional for hosts that omit it. */
157
+ readonly slug?: string;
158
+ }
159
+ /** What an image field shows for a chosen library file: its name and, when stored, its size. */
160
+ export interface AdminSeoMediaSummary {
161
+ readonly id: string;
162
+ readonly slug?: string;
163
+ readonly title: string;
164
+ readonly width?: number | null;
165
+ readonly height?: number | null;
166
+ /** The stored bytes' sniffed type (`image/png`); gives the displayed name its extension. */
167
+ readonly contentType?: string | null;
152
168
  }
153
169
  export interface AdminSeoPort {
154
170
  getSeoEntry(requiredArgs: {
@@ -189,6 +205,10 @@ export interface AdminSeoPort {
189
205
  mediaPreviewUrl?(required: {
190
206
  value: string;
191
207
  }, optional?: Record<string, never>): string | null;
208
+ /** Optional: library rows so an image field can name its file instead of showing a reference. */
209
+ listSeoMedia?(required: Record<string, never>, optional?: {
210
+ signal?: AbortSignal;
211
+ }): Promise<readonly AdminSeoMediaSummary[]>;
192
212
  /** Original thumbnail URL; media-reference resolution stays on the server. */
193
213
  mediaOriginalUrl(requiredArgs: {
194
214
  id: string;
@@ -65,6 +65,7 @@ export declare function retypeField({ field, type }: {
65
65
  field: AdminFormField;
66
66
  type: AdminFormField["type"];
67
67
  }, _optional?: Record<string, never>): AdminFormField;
68
+ /** Local React keys for option rows; identity is scoped to the editor's own row list. */
68
69
  export interface OptionRow {
69
70
  _rowId: number;
70
71
  value: string;
@@ -134,8 +134,6 @@ export function retypeField({ field, type }, _optional = {}) {
134
134
  next.default = field.default;
135
135
  return next;
136
136
  }
137
- /** Local React keys for option rows; same module-counter pattern as `rules.ts`'s attribute rows. */
138
- let nextOptionRowId = 0;
139
137
  const text = (value) => (value === undefined || value === null ? "" : String(value));
140
138
  /** Seed the draft from a stored field. @complexity O(o) in options. */
141
139
  export function settingsFormFromField({ field }, _optional = {}) {
@@ -147,7 +145,7 @@ export function settingsFormFromField({ field }, _optional = {}) {
147
145
  defaultChoices: Array.isArray(defaults) ? [...defaults] : typeof defaults === "string" && fieldSettingsFor({ type: field.type }).options ? [defaults] : [],
148
146
  minLength: text(field.minLength), maxLength: text(field.maxLength), pattern: text(field.pattern),
149
147
  min: text(field.min), max: text(field.max), step: text(field.step),
150
- options: (field.options ?? []).map((option) => ({ _rowId: nextOptionRowId++, value: option.value, label: option.label })),
148
+ options: (field.options ?? []).map((option, index) => ({ _rowId: index, value: option.value, label: option.label })),
151
149
  accept: [...(field.accept ?? [])],
152
150
  maxFileSizeMb: field.maxFileSize === undefined ? "" : String(Math.round((field.maxFileSize / 1024 / 1024) * 100) / 100),
153
151
  };
@@ -167,7 +165,9 @@ export function updateOptionRow({ rows, rowId, patch }, _optional = {}) {
167
165
  }
168
166
  /** @complexity O(o). */
169
167
  export function addOptionRow({ rows }, _optional = {}) {
170
- return [...rows, { _rowId: nextOptionRowId++, value: "", label: "" }];
168
+ // Use the highest current key so deleting or reordering rows cannot create a duplicate.
169
+ const rowId = rows.reduce((max, row) => Math.max(max, row._rowId), -1) + 1;
170
+ return [...rows, { _rowId: rowId, value: "", label: "" }];
171
171
  }
172
172
  /** @complexity O(o). */
173
173
  export function removeOptionRow({ rows, rowId }, _optional = {}) {
@@ -19,7 +19,7 @@ export function FieldAttributesDialog({ field, fieldIndex, onSave, onCancel, use
19
19
  // it was not judged worth the added indirection.
20
20
  const { className, addRow, error, submit } = controller;
21
21
  const { rows, displayName, onClose, onClassName, hasStyling } = useFieldAttributesView({ field, fieldIndex, controller, onCancel });
22
- return (_jsx(Dialog, { open: true, title: `${t("Field settings —")} ${displayName}`, onClose: onClose, className: "settings-dialog tovu-domain-dialog field-attrs-dialog", children: _jsxs("form", { onSubmit: submit, ...agentHandle({ handle: "form-field-attrs-dialog" }, {
22
+ return (_jsx(Dialog, { open: true, title: `${t("Field settings —")} ${displayName}`, onClose: onClose, className: "settings-dialog jini-domain-dialog field-attrs-dialog", children: _jsxs("form", { onSubmit: submit, ...agentHandle({ handle: "form-field-attrs-dialog" }, {
23
23
  role: "region",
24
24
  label: "Field settings dialog — type-specific settings, CSS classes and HTML attributes for one form field",
25
25
  }), children: [_jsx(FieldSettingsSection, { field: field, controller: controller, t: t }), _jsxs("details", { className: "field-settings-advanced", open: hasStyling, children: [_jsx("summary", { children: t("CSS classes and HTML attributes") }), _jsxs("div", { className: "field", children: [_jsxs("span", { className: "field-settings-label", children: [_jsx("label", { className: "field-label", htmlFor: "field-attrs-classname", children: t("CSS classes") }), _jsx(InfoTip, { label: t("Classes are unrestricted, e.g. Tailwind md:col-span-2. Attribute names must be aria-*, data-* or a safe layout attribute; event handlers like onclick are rejected.") })] }), _jsx("input", { id: "field-attrs-classname", value: className, placeholder: "e.g. md:col-span-2 w-1/2 focus:ring-2", onChange: onClassName, ...agentHandle({ handle: "form-field-attrs-classname" }, {
@@ -37,7 +37,7 @@ import { type FormDateLine } from "../../rules.js";
37
37
  *
38
38
  * the scoped formsEvents subscription (staleness-bug generalization pass, see that hook's own header):
39
39
  * `forms_create_definition`/`forms_update_definition`/`forms_set_definition_status`
40
- * (`apps/website/src/features/forms/agent-tools.ts`) are agent-callable, so this list re-invalidates
40
+ * (the host's forms agent tools) are agent-callable, so this list re-invalidates
41
41
  * `KEYS.list` on an out-of-band content-refresh notification the same way `use-taxonomy.hooks.ts`
42
42
  * does for its own resource.
43
43
  */
@@ -31,8 +31,8 @@ import { type FieldType } from "@jini-ai/cms/forms";
31
31
  /**
32
32
  * This screen's name on `lib/content-refresh-bus.ts` — see `taxonomy/rules.ts`'s `TAXONOMY_RESOURCE`
33
33
  * for why this is a plain colocated constant rather than a shared registry. `forms_create_definition`/
34
- * `forms_update_definition`/`forms_set_definition_status` (`apps/website/src/features/forms/agent-
35
- * tools.ts`) are agent-callable, so `FormsList.tsx` needs the same "an assistant write shows up
34
+ * `forms_update_definition`/`forms_set_definition_status` (the host's forms agent tools)
35
+ * are agent-callable, so `FormsList.tsx` needs the same "an assistant write shows up
36
36
  * without a reload" fix `use-taxonomy.hooks.ts` shipped first.
37
37
  */
38
38
  export declare const FORMS_LIST_RESOURCE = "forms";
@@ -30,8 +30,8 @@ import { FIELD_TYPE_REGISTRY } from "@jini-ai/cms/forms";
30
30
  /**
31
31
  * This screen's name on `lib/content-refresh-bus.ts` — see `taxonomy/rules.ts`'s `TAXONOMY_RESOURCE`
32
32
  * for why this is a plain colocated constant rather than a shared registry. `forms_create_definition`/
33
- * `forms_update_definition`/`forms_set_definition_status` (`apps/website/src/features/forms/agent-
34
- * tools.ts`) are agent-callable, so `FormsList.tsx` needs the same "an assistant write shows up
33
+ * `forms_update_definition`/`forms_set_definition_status` (the host's forms agent tools)
34
+ * are agent-callable, so `FormsList.tsx` needs the same "an assistant write shows up
35
35
  * without a reload" fix `use-taxonomy.hooks.ts` shipped first.
36
36
  */
37
37
  export const FORMS_LIST_RESOURCE = "forms";
@@ -1,5 +1,5 @@
1
1
  import { createElement } from 'react';
2
- import { RowMenu } from '../../../react/index.js';
2
+ import { RowMenu } from '../../../react/components/RowMenu/RowMenu.js';
3
3
  import { redirectRowMenuItems } from '../../rules.js';
4
4
  import { HitCountCell } from '../components/HitCountCell.js';
5
5
  import { useRedirectsOptions } from './RedirectsOptions.hooks.js';
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { DataTable, ConfirmDialog } from '../../../react/index.js';
2
+ import { DataTable } from '../../../react/components/DataTable.js';
3
+ import { ConfirmDialog } from '../../../react/components/ConfirmDialog/ConfirmDialog.js';
3
4
  import { agentHandle } from '@jini-ai/agentic';
4
5
  import { describeApiError } from '../../../core/transport/errors.js';
5
6
  import { ImportRedirectsForm } from '../components/ImportRedirectsForm.js';
@@ -22,8 +22,8 @@ export declare const KEYS: {
22
22
  };
23
23
  /** This screen's name on the host refresh bus — see `taxonomy/rules.ts`'s `TAXONOMY_RESOURCE`
24
24
  * for why this is a plain colocated constant rather than a shared registry. Agent-writable via
25
- * `redirects_create`/`redirects_update`/`redirects_tombstone` (`apps/website/src/features/redirects/
26
- * agent-tools.ts`) — `redirects_import` is excluded from that catalog entirely, but the three wired
25
+ * `redirects_create`/`redirects_update`/`redirects_tombstone` (the host's redirect agent tools) —
26
+ * `redirects_import` is excluded from that catalog entirely, but the three wired
27
27
  * single-row writes are enough to make this list go stale the same way Posts/Media did. */
28
28
  export declare const REDIRECTS_RESOURCE = "redirects";
29
29
  /**
@@ -18,8 +18,8 @@ export const KEYS = {
18
18
  };
19
19
  /** This screen's name on the host refresh bus — see `taxonomy/rules.ts`'s `TAXONOMY_RESOURCE`
20
20
  * for why this is a plain colocated constant rather than a shared registry. Agent-writable via
21
- * `redirects_create`/`redirects_update`/`redirects_tombstone` (`apps/website/src/features/redirects/
22
- * agent-tools.ts`) — `redirects_import` is excluded from that catalog entirely, but the three wired
21
+ * `redirects_create`/`redirects_update`/`redirects_tombstone` (the host's redirect agent tools) —
22
+ * `redirects_import` is excluded from that catalog entirely, but the three wired
23
23
  * single-row writes are enough to make this list go stale the same way Posts/Media did. */
24
24
  export const REDIRECTS_RESOURCE = "redirects";
25
25
  /**
@@ -20,6 +20,11 @@ export function createHttpSeoApi({ transport, basePath }, { sitemapTransport, im
20
20
  analyzeSeoEntry: ({ entryId }) => data(`${entryPath(entryId)}/analyze`, 'GET'),
21
21
  listSeoPosts: () => choices('posts'),
22
22
  listSeoPages: () => choices('pages'),
23
+ // The admin media list route the picker already reads; projected so no media row type leaks in.
24
+ async listSeoMedia() {
25
+ const response = await transport.request({ path: `${base}/media`, method: 'GET' });
26
+ return response.media.map(({ id, slug, title, width, height, contentType }) => ({ id, title, ...(slug ? { slug } : {}), width: width ?? null, height: height ?? null, contentType: contentType ?? null }));
27
+ },
23
28
  mediaOriginalUrl: ({ id }) => transport.url({ path: `${base}/media/${encodeURIComponent(id)}/original` }),
24
29
  mediaPreviewUrl: ({ value }) => resolveMediaRefPreviewUrl({ value, mediaOriginalUrl: id => transport.url({ path: `${base}/media/${encodeURIComponent(id)}/original` }) }, { siteOrigin }),
25
30
  ...(imageTransport ? { async inspectShareImage({ url }, options = {}) {
@@ -49,7 +49,7 @@ function applySeoPatch(meta, patch) {
49
49
  };
50
50
  }
51
51
  /** One of the three site-wide scalars the server registers as `nullable: true` (`SEO_DEFINITIONS`,
52
- * `apps/website/src/features/seo/settings.ts`), under the real write's own rule: an omitted key
52
+ * `features/seo/settings.ts`), under the real write's own rule: an omitted key
53
53
  * (`undefined`) keeps the current value, `null` clears it, and a string overwrites. */
54
54
  function settingOrCleared(patchValue, currentValue) {
55
55
  if (patchValue === undefined)
@@ -20,21 +20,24 @@ export declare const seoMessagesEn: {
20
20
  readonly "Canonical URL": "Canonical URL";
21
21
  readonly Noindex: "Noindex";
22
22
  readonly Nofollow: "Nofollow";
23
- readonly "OG title": "OG title";
24
- readonly "OG description": "OG description";
25
- readonly "OG image": "OG image";
26
- readonly "Twitter title": "Twitter title";
27
- readonly "Twitter description": "Twitter description";
28
- readonly "Twitter image": "Twitter image";
23
+ readonly "Preview title": "Preview title";
24
+ readonly "Preview description": "Preview description";
25
+ readonly "Preview image": "Preview image";
26
+ readonly "X/Twitter title": "X/Twitter title";
27
+ readonly "X/Twitter description": "X/Twitter description";
28
+ readonly "X/Twitter image": "X/Twitter image";
29
+ readonly "Customize for X/Twitter (optional)": "Customize for X/Twitter (optional)";
30
+ readonly "Same as the preview image": "Same as the preview image";
31
+ readonly "Used by Facebook, LinkedIn, Slack, Discord, iMessage, WhatsApp and X/Twitter": "Used by Facebook, LinkedIn, Slack, Discord, iMessage, WhatsApp and X/Twitter";
29
32
  readonly "Saving\u2026": "Saving…";
30
33
  readonly "Save overrides": "Save overrides";
31
34
  readonly "Per-entry SEO": "Per-entry SEO";
32
35
  readonly "Search appearance": "Search appearance";
33
36
  readonly "Title template (must contain %s)": "Title template (must contain %s)";
34
37
  readonly "Default meta description": "Default meta description";
35
- readonly "Social sharing": "Social sharing";
36
- readonly "Default Open Graph / Twitter image": "Default Open Graph / Twitter image";
37
- readonly "Twitter @site handle": "Twitter @site handle";
38
+ readonly "Social preview": "Social preview";
39
+ readonly "Default preview image": "Default preview image";
40
+ readonly "X/Twitter @handle": "X/Twitter @handle";
38
41
  readonly Crawling: "Crawling";
39
42
  readonly "Default noindex": "Default noindex";
40
43
  readonly "Default nofollow": "Default nofollow";
@@ -47,7 +50,7 @@ export declare const seoMessagesEn: {
47
50
  readonly "View sitemap": "View sitemap";
48
51
  readonly "Loading SEO settings\u2026": "Loading SEO settings…";
49
52
  readonly Marketing: "Marketing";
50
- 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.";
53
+ 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.";
51
54
  readonly "Choose image": "Choose image";
52
55
  readonly Remove: "Remove";
53
56
  readonly "Sitemap \u00B7 {count} URLs": "Sitemap · {count} URLs";
@@ -75,5 +78,7 @@ export declare const seoMessagesEn: {
75
78
  readonly "failed to load the sitemap": "failed to load the sitemap";
76
79
  readonly "Could not load the sitemap (HTTP {status}).": "Could not load the sitemap (HTTP {status}).";
77
80
  readonly "Selected image": "Selected image";
81
+ readonly "Using site default: {name}": "Using site default: {name}";
82
+ readonly "Using image from page content: {name}": "Using image from page content: {name}";
78
83
  };
79
84
  //# sourceMappingURL=messages.en.d.ts.map
@@ -20,21 +20,24 @@ export const seoMessagesEn = {
20
20
  "Canonical URL": "Canonical URL",
21
21
  "Noindex": "Noindex",
22
22
  "Nofollow": "Nofollow",
23
- "OG title": "OG title",
24
- "OG description": "OG description",
25
- "OG image": "OG image",
26
- "Twitter title": "Twitter title",
27
- "Twitter description": "Twitter description",
28
- "Twitter image": "Twitter image",
23
+ "Preview title": "Preview title",
24
+ "Preview description": "Preview description",
25
+ "Preview image": "Preview image",
26
+ "X/Twitter title": "X/Twitter title",
27
+ "X/Twitter description": "X/Twitter description",
28
+ "X/Twitter image": "X/Twitter image",
29
+ "Customize for X/Twitter (optional)": "Customize for X/Twitter (optional)",
30
+ "Same as the preview image": "Same as the preview image",
31
+ "Used by Facebook, LinkedIn, Slack, Discord, iMessage, WhatsApp and X/Twitter": "Used by Facebook, LinkedIn, Slack, Discord, iMessage, WhatsApp and X/Twitter",
29
32
  "Saving…": "Saving…",
30
33
  "Save overrides": "Save overrides",
31
34
  "Per-entry SEO": "Per-entry SEO",
32
35
  "Search appearance": "Search appearance",
33
36
  "Title template (must contain %s)": "Title template (must contain %s)",
34
37
  "Default meta description": "Default meta description",
35
- "Social sharing": "Social sharing",
36
- "Default Open Graph / Twitter image": "Default Open Graph / Twitter image",
37
- "Twitter @site handle": "Twitter @site handle",
38
+ "Social preview": "Social preview",
39
+ "Default preview image": "Default preview image",
40
+ "X/Twitter @handle": "X/Twitter @handle",
38
41
  "Crawling": "Crawling",
39
42
  "Default noindex": "Default noindex",
40
43
  "Default nofollow": "Default nofollow",
@@ -47,7 +50,7 @@ export const seoMessagesEn = {
47
50
  "View sitemap": "View sitemap",
48
51
  "Loading SEO settings…": "Loading SEO settings…",
49
52
  "Marketing": "Marketing",
50
- "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.",
53
+ "Site-wide defaults for meta titles, descriptions, social previews, and robots directives.": "Site-wide defaults for meta titles, descriptions, social previews, and robots directives.",
51
54
  "Choose image": "Choose image",
52
55
  "Remove": "Remove",
53
56
  "Sitemap · {count} URLs": "Sitemap · {count} URLs",
@@ -75,5 +78,7 @@ export const seoMessagesEn = {
75
78
  "failed to load the sitemap": "failed to load the sitemap",
76
79
  "Could not load the sitemap (HTTP {status}).": "Could not load the sitemap (HTTP {status}).",
77
80
  "Selected image": "Selected image",
81
+ "Using site default: {name}": "Using site default: {name}",
82
+ "Using image from page content: {name}": "Using image from page content: {name}",
78
83
  };
79
84
  //# sourceMappingURL=messages.en.js.map
@@ -75,6 +75,8 @@ export interface SeoController {
75
75
  }
76
76
  export interface EntryPickerController {
77
77
  entries: readonly AdminPost[] | null;
78
+ /** Searchable-picker rows (grouped Pages/Posts); null while entries load. */
79
+ options?: import('./rules.js').SeoEntryPickerOption[] | null;
78
80
  error: string | null;
79
81
  }
80
82
  export interface SeoEntryPanelOptions {
@@ -98,6 +100,9 @@ export interface SeoEntryPanelController {
98
100
  * `null` for each one they emptied. An untouched field is ABSENT, never `null`: "unchanged" must
99
101
  * never become "clear". */
100
102
  touched: SeoEntryOverridesPatch;
103
+ /** The social preview image comes from content or the site default (`ogImageInherited`, rules.ts). Optional
104
+ * for injected legacy views. */
105
+ ogImageInherited?: false | 'default' | 'content';
101
106
  }
102
107
  export interface SeoEntrySectionController {
103
108
  entryId: string;
@@ -153,6 +158,12 @@ export interface MediaRefFieldController {
153
158
  /** The field's current value resolved to a thumbnail `<img src>`, or `null` when the value is
154
159
  * empty or unparseable — see `resolveMediaRefPreviewUrl`'s own doc. */
155
160
  previewUrl: string | null;
161
+ /** The chosen file's display name and pixel size (`describeMediaRef`), or null when empty.
162
+ * Optional for injected legacy views. */
163
+ file?: {
164
+ name: string;
165
+ size: string | null;
166
+ } | null;
156
167
  /** Shared diagnostics for defaults, OG and Twitter fields; optional for injected legacy views. */
157
168
  warning?: string | null;
158
169
  onPreviewLoad?: (event: {
@@ -1,7 +1,7 @@
1
1
  import { useWiredMediaRefField } from "../hooks/MediaRefField.hooks.js";
2
2
  /**
3
- * @file `MediaRefField` — a text input for an `{assetId}:{transformName}` media reference (or a
4
- * pasted absolute URL), plus a "Choose image" affordance that opens the existing
3
+ * @file `MediaRefField` — an `{assetId}:{transformName}` media reference (or an absolute URL),
4
+ * shown as the chosen file's thumbnail and name, plus a "Choose image" affordance that opens the existing
5
5
  * `MediaPickerDialog`, a thumbnail preview of the current value, and a Remove control.
6
6
  *
7
7
  * Built for `react/pages/SeoPage.tsx`'s `defaultOgImage`/`ogImage`/`twitterImage` fields (SPEC intent: "make the OG
@@ -16,11 +16,15 @@ import { useWiredMediaRefField } from "../hooks/MediaRefField.hooks.js";
16
16
  * 2026-10-08 polish: the original editable-input rationale above still applies to pasted URLs
17
17
  * and empty fields. A selected library reference now shows plain copy with its preview; Remove
18
18
  * returns to manual entry. A hidden input preserves the exact FormData value on defaults saves.
19
+ * 2026-10-15: superseded — the input is removed entirely (owner: the read-only box "looks
20
+ * useless"). An existing pasted URL still displays (by its file name) and still saves unchanged;
21
+ * a new URL can no longer be typed here, only chosen from the library.
19
22
  */
20
23
  export interface MediaRefFieldProps {
21
24
  locale: string;
22
- /** `<input id>`/`<label htmlFor>` pairing — same `getByLabelText`-friendly shape `react/pages/SeoPage.tsx`'s
23
- * other explicit-`htmlFor` fields already use. */
25
+ /** Base id: the caption is `{id}-label` and names the row's `role="group"`; the hidden input (when
26
+ * `name` is set) carries the id itself. Was an `<input id>`/`<label htmlFor>` pair until the text
27
+ * box was removed (2026-10-15). */
24
28
  id: string;
25
29
  /** Forwarded to the `<input name>` only when set — `defaultOgImage` needs this (its `<form>` reads
26
30
  * `FormData` by name on submit); the per-entry `ogImage`/`twitterImage` fields do not (they save
@@ -32,9 +36,17 @@ export interface MediaRefFieldProps {
32
36
  /** This field's own base handle — appended `-choose`/`-clear`/`-dialog` for its own controls, and
33
37
  * forwarded as-is to the `<input>` itself. Omit to leave every element here untagged. */
34
38
  agentHandle?: string;
39
+ /** Shown in the file slot while no image is set — for a field that inherits another field's
40
+ * image when empty (the X image falls back to the social preview image). */
41
+ emptyHint?: string;
42
+ /** The value comes from the site default or page content, with no SEO override of its own: the file
43
+ * shows muted as "Using site default: …" or "Using image from page content: …"; size and Remove
44
+ * are hidden — there is no SEO override to remove. "Choose image" is how an override starts
45
+ * (owner, 2026-10-15). */
46
+ inherited?: false | 'default' | 'content';
35
47
  /** Injectable seam for the picker's own state — same convention as `MediaPickerDialog`'s own
36
48
  * `useDialog` prop. Defaults to the real {@link useWiredMediaRefField}. */
37
49
  useField?: typeof useWiredMediaRefField;
38
50
  }
39
- export declare function MediaRefField({ locale, id, name, label, value, onChange, agentHandle: base, useField, }: MediaRefFieldProps, _optional?: Record<string, never>): import("react").JSX.Element;
51
+ export declare function MediaRefField({ locale, id, name, label, value, onChange, agentHandle: base, emptyHint, inherited, useField, }: MediaRefFieldProps, _optional?: Record<string, never>): import("react").JSX.Element;
40
52
  //# sourceMappingURL=MediaRefField.d.ts.map
@@ -1,12 +1,22 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { agentHandle } from "@jini-ai/agentic";
3
+ import { interpolate } from "@jini-ai/ui/i18n";
3
4
  import { MediaPickerBridge } from "./MediaPickerBridge.js";
4
5
  import { useSeoCopy } from "../hooks/SeoPorts.hooks.js";
5
6
  import { useWiredMediaRefField, useMediaRefPresentation, handleSpread } from "../hooks/MediaRefField.hooks.js";
6
- export function MediaRefField({ locale, id, name, label, value, onChange, agentHandle: base, useField = useWiredMediaRefField, }, _optional = {}) {
7
+ export function MediaRefField({ locale, id, name, label, value, onChange, agentHandle: base, emptyHint, inherited = false, useField = useWiredMediaRefField, }, _optional = {}) {
7
8
  const t = useSeoCopy();
8
- const { Picker = MediaPickerBridge, storedReference } = useMediaRefPresentation({ value });
9
- const { pickerOpen, openPicker, closePicker, handleSelect, clear, previewUrl, accept, warning, onPreviewLoad, onPreviewError } = useField({ value, onChange });
10
- return (_jsxs("div", { className: "jini-field jini-media-ref-field", children: [_jsx("label", { className: "jini-field-label", htmlFor: id, children: label }), _jsxs("div", { className: "jini-media-ref-field-row", children: [_jsx("input", { className: "jini-input", id: id, name: storedReference ? undefined : name, value: storedReference ? t({ locale, key: "Selected image" }) : value, readOnly: storedReference, "aria-describedby": warning ? `${id}-warning` : undefined, onChange: (e) => onChange(e.target.value), ...(base ? agentHandle({ handle: base }, { role: "field", label }) : {}) }), storedReference && name ? _jsx("input", { type: "hidden", name: name, value: value }) : null, _jsx("button", { type: "button", className: "jini-btn jini-btn-secondary", onClick: openPicker, ...handleSpread({ ...(base === undefined ? {} : { base }), suffix: "choose", label: `Choose an image for: ${label}` }), children: t({ locale: locale, key: "Choose image" }) }), value ? (_jsx("button", { type: "button", className: "jini-btn jini-btn-secondary", onClick: clear, ...handleSpread({ ...(base === undefined ? {} : { base }), suffix: "clear", label: `Remove the selected image for: ${label}` }), children: t({ locale: locale, key: "Remove" }) })) : null] }), previewUrl ? _jsx("img", { className: "jini-media-ref-field-preview", src: previewUrl, alt: `${label} preview`, onLoad: onPreviewLoad, onError: onPreviewError }, previewUrl) : null, warning ? _jsx("p", { id: `${id}-warning`, className: "jini-notice jini-notice-warning", role: "status", children: warning }) : null, pickerOpen ? (_jsx(Picker, { onSelect: handleSelect, onCancel: closePicker, accept: accept, agentHandle: base ? `${base}-dialog` : undefined })) : null] }));
9
+ const { Picker = MediaPickerBridge } = useMediaRefPresentation({ value });
10
+ const { pickerOpen, openPicker, closePicker, handleSelect, clear, previewUrl, file, accept, onPreviewLoad, onPreviewError } = useField({ value, onChange });
11
+ const labelId = `${id}-label`;
12
+ /* One compact row: thumbnail, the file's name and size, then Choose/Remove kept together in their
13
+ own group so a narrow column wraps the pair as one unit instead of stranding Remove on a line of
14
+ its own (owner, 2026-10-15). The thumbnail used to sit under the row at 4rem, repeated per field.
15
+ `warning` (share-image size/byte/HTTP diagnostics) is still computed by the hook but no longer
16
+ painted: the owner called the yellow callout under every image "completely useless".
17
+ 2026-10-15 (round 3): the text box is gone — for a library pick it only ever said a read-only
18
+ "Selected image" ("i can't change it and looks useless"), so the row names the file instead and
19
+ the picker is the one way in. The exact value still rides a hidden input for FormData saves. */
20
+ return (_jsxs("div", { className: "jini-field jini-media-ref-field", children: [_jsx("span", { className: "jini-field-label", id: labelId, children: label }), _jsxs("div", { className: "jini-media-ref-field-row", role: "group", "aria-labelledby": labelId, ...(base ? agentHandle({ handle: base }, { role: "region", label }) : {}), children: [file ? (_jsxs("span", { className: inherited ? "jini-media-ref-field-file jini-media-ref-field-file-inherited" : "jini-media-ref-field-file", children: [previewUrl ? _jsx("img", { className: "jini-media-ref-field-preview", src: previewUrl, alt: `${label} preview`, onLoad: onPreviewLoad, onError: onPreviewError }, previewUrl) : null, _jsx("span", { className: "jini-media-ref-field-name", title: file.name, children: inherited ? interpolate({ template: t({ locale: locale, key: inherited === 'content' ? "Using image from page content: {name}" : "Using site default: {name}", vars: { name: file.name } }), vars: { name: file.name } }) : file.name }), file.size && !inherited ? _jsx("span", { className: "jini-media-ref-field-size", children: file.size }) : null] })) : (emptyHint && _jsx("span", { className: "jini-media-ref-field-empty", children: emptyHint })), name ? _jsx("input", { type: "hidden", id: id, name: name, value: value }) : null, _jsxs("span", { className: "jini-media-ref-field-actions", children: [_jsx("button", { type: "button", className: "jini-btn jini-btn-secondary", onClick: openPicker, ...handleSpread({ ...(base === undefined ? {} : { base }), suffix: "choose", label: `Choose an image for: ${label}` }), children: t({ locale: locale, key: "Choose image" }) }), value && !inherited ? (_jsx("button", { type: "button", className: "jini-btn jini-btn-secondary", onClick: clear, ...handleSpread({ ...(base === undefined ? {} : { base }), suffix: "clear", label: `Remove the selected image for: ${label}` }), children: t({ locale: locale, key: "Remove" }) })) : null] })] }), pickerOpen ? (_jsx(Picker, { onSelect: handleSelect, onCancel: closePicker, accept: accept, agentHandle: base ? `${base}-dialog` : undefined })) : null] }));
11
21
  }
12
22
  //# sourceMappingURL=MediaRefField.js.map
@@ -64,7 +64,7 @@ export function SitemapModal({ locale, sitemapEnabled, regenerating, onRegenerat
64
64
  const { title } = useSitemapPresentation({ locale, modal });
65
65
  const { handleRegenerate } = useSitemapModalRegenerate({ onRegenerate });
66
66
  // Native modality keeps Tab from reaching the background, as MediaPickerDialog now does too.
67
- return (_jsxs(Dialog, { open: true, title: title, onClose: () => onClose(), className: "settings-dialog tovu-domain-dialog sitemap-modal", children: [_jsx("div", { className: "sitemap-modal-header", children: _jsx(SitemapModalHeaderActions, { locale: locale, modal: modal }) }), _jsx("div", { className: "sitemap-modal-body", children: _jsx(SitemapModalBody, { locale: locale, sitemapEnabled: sitemapEnabled, modal: modal }) }), _jsx("div", { className: "widget-picker-footer", children: _jsxs("span", { className: "editor-actions", children: [_jsx("button", { type: "button", className: "btn-secondary", onClick: handleRegenerate, disabled: regenerating, ...agentHandle({ handle: "seo-sitemap-modal-regenerate" }, {
67
+ return (_jsxs(Dialog, { open: true, title: title, onClose: () => onClose(), className: "settings-dialog jini-domain-dialog sitemap-modal", children: [_jsx("div", { className: "sitemap-modal-header", children: _jsx(SitemapModalHeaderActions, { locale: locale, modal: modal }) }), _jsx("div", { className: "sitemap-modal-body", children: _jsx(SitemapModalBody, { locale: locale, sitemapEnabled: sitemapEnabled, modal: modal }) }), _jsx("div", { className: "widget-picker-footer", children: _jsxs("span", { className: "editor-actions", children: [_jsx("button", { type: "button", className: "btn-secondary", onClick: handleRegenerate, disabled: regenerating, ...agentHandle({ handle: "seo-sitemap-modal-regenerate" }, {
68
68
  role: "button",
69
69
  label: "Rebuild the cached sitemap and refresh this view",
70
70
  }), children: actionLabel({ pending: regenerating, pendingLabel: t({ locale: locale, key: "Working…" }), idleLabel: t({ locale: locale, key: "Regenerate sitemap" }) }) }), _jsx("button", { type: "button", className: "btn-secondary", onClick: onClose, "data-jini-autofocus": "", ...agentHandle({ handle: "seo-sitemap-modal-close" }, { role: "button", label: "Close the sitemap viewer" }), children: t({ locale: locale, key: "Close" }) })] }) })] }));
@@ -3,16 +3,24 @@ import { useShareImageWarning } from './use-share-image-warning.hooks.js';
3
3
  import { agentHandle } from '@jini-ai/agentic';
4
4
  import { useController } from '../../../core/react/use-controller.js';
5
5
  import { createMediaRefController } from '../../controllers/media-ref.controller.js';
6
- import { isStoredMediaRef, resolveMediaRefPreviewUrl } from '../../rules.js';
6
+ import { describeMediaRef, isStoredMediaRef, resolveMediaRefPreviewUrl } from '../../rules.js';
7
+ import { useFetchQuery } from '@jini-ai/ui/fetch-query';
8
+ import { extensionForMimeType } from '@jini-ai/ui';
9
+ import { SEO_KEYS } from './SeoQuery.hooks.js';
7
10
  import { useSeoPorts, useSeoOptions } from './SeoPorts.hooks.js';
8
11
  export function useMediaRefField({ value, onChange, api }, { t } = {}) {
9
12
  const change = useRef(onChange);
10
13
  change.current = onChange;
11
14
  const { controller, snapshot } = useController({ create: () => createMediaRefController({ onChange: value => change.current(value) }), dependencies: [] });
12
15
  const previewUrl = api.mediaPreviewUrl ? api.mediaPreviewUrl({ value }) : resolveMediaRefPreviewUrl({ value, mediaOriginalUrl: id => api.mediaOriginalUrl({ id }) });
13
- const diagnostics = useShareImageWarning({ value, previewUrl, api }, { t });
16
+ const { naturalWidth, naturalHeight, ...diagnostics } = useShareImageWarning({ value, previewUrl, api }, { t });
17
+ // One cached library read shared by every image field on the screen; only a library reference
18
+ // needs it (a URL names itself), and a host without `listSeoMedia` falls back to the slug.
19
+ const list = api.listSeoMedia;
20
+ const media = useFetchQuery({ key: SEO_KEYS.media, fetch: () => list ? list.call(api, {}) : Promise.resolve([]) }, { enabled: Boolean(list) && isStoredMediaRef({ value }) });
14
21
  return {
15
22
  ...diagnostics,
23
+ file: describeMediaRef({ value, media: media.data }, { width: naturalWidth, height: naturalHeight, t, extensionFor: extensionForMimeType }),
16
24
  pickerOpen: snapshot?.pickerOpen ?? false,
17
25
  openPicker: () => controller?.openPicker({}), closePicker: () => controller?.closePicker({}),
18
26
  handleSelect: item => controller?.handleSelect({ item }), clear: () => controller?.clear({}),
@@ -29,6 +29,7 @@ export declare function useAnalyzePanel({ analysis }: {
29
29
  analysis: SeoEntryAnalysis;
30
30
  }, _optional?: Record<string, never>): {
31
31
  sortedIssues: import("../../../core/index.js").AdminSeoIssue[];
32
+ tone: "warning" | "success" | "failure";
32
33
  };
33
34
  /** All seven defaults remain in one FormData submission; tabs never split this form. */
34
35
  export declare function useSeoDefaults({ save }: {
@@ -1,6 +1,6 @@
1
1
  import { createElement } from 'react';
2
2
  import { SeoDefaultsIcon, SeoSitemapIcon, SeoEntriesIcon } from '../components/SeoIcons.js';
3
- import { buildSeoSettingsPatch, resolveSeoTabId, sortIssuesBySeverity } from '../../rules.js';
3
+ import { buildSeoSettingsPatch, resolveSeoTabId, sortIssuesBySeverity, scoreTone } from '../../rules.js';
4
4
  import { useSeoOptions } from './SeoPorts.hooks.js';
5
5
  export { buildSeoSettingsPatch, resolveSeoTabId } from '../../rules.js';
6
6
  export function resolveSeoTabs(_required = {}, { t = key => key } = {}) {
@@ -20,7 +20,7 @@ export function useSeoTabs({ tabId }, _optional = {}) {
20
20
  return { activeTabId: resolveSeoTabId({ tabId }), tabs: resolveSeoTabs({}, { t }) };
21
21
  }
22
22
  export function useAnalyzePanel({ analysis }, _optional = {}) {
23
- return { sortedIssues: sortIssuesBySeverity({ issues: analysis.issues }) };
23
+ return { sortedIssues: sortIssuesBySeverity({ issues: analysis.issues }), tone: scoreTone({ score: analysis.score }) };
24
24
  }
25
25
  /** All seven defaults remain in one FormData submission; tabs never split this form. */
26
26
  export function useSeoDefaults({ save }, _optional = {}) {
@@ -8,6 +8,7 @@ export declare const SEO_KEYS: {
8
8
  }, _optional?: Record<string, never>) => readonly ["seo", "entry", string];
9
9
  choices: readonly ["seo", "choices"];
10
10
  sitemap: readonly ["seo", "sitemap"];
11
+ media: readonly ["seo", "media"];
11
12
  };
12
13
  /** Controllers keep their draft/error contracts; these injected ports share the provider cache.
13
14
  * Reads omit a controller's abort signal because another mounted consumer can own the same read.
@@ -7,6 +7,7 @@ export const SEO_KEYS = {
7
7
  entry: ({ entryId }, _optional = {}) => ['seo', 'entry', entryId],
8
8
  choices: ['seo', 'choices'],
9
9
  sitemap: ['seo', 'sitemap'],
10
+ media: ['seo', 'media'],
10
11
  };
11
12
  /** Controllers keep their draft/error contracts; these injected ports share the provider cache.
12
13
  * Reads omit a controller's abort signal because another mounted consumer can own the same read.
@@ -2,13 +2,18 @@ import { describeSeoError } from '../../models.js';
2
2
  import { useFetchQuery } from '@jini-ai/ui/fetch-query';
3
3
  import { SEO_KEYS } from './SeoQuery.hooks.js';
4
4
  import { initialEntryPickerState } from '../../models.js';
5
+ import { entryPickerOptions } from '../../rules.js';
5
6
  import { useSeoOptions, useSeoPorts } from './SeoPorts.hooks.js';
6
7
  export function useEntryPicker({ api }, { t, describeError } = {}) {
7
8
  const query = useFetchQuery({ key: SEO_KEYS.choices, fetch: async () => {
8
9
  const [posts, pages] = await Promise.all([api.listSeoPosts({}), api.listSeoPages({})]);
9
- return [...posts, ...pages];
10
+ return { posts, pages };
10
11
  } });
11
- return { ...initialEntryPickerState, entries: query.data ?? null, error: query.error ? (describeError ?? describeSeoError)({ error: query.error, fallback: t?.('failed to load entries') ?? 'failed to load entries' }) : null };
12
+ const data = query.data;
13
+ return { ...initialEntryPickerState,
14
+ entries: data ? [...data.posts, ...data.pages] : null,
15
+ options: data ? entryPickerOptions(data, { t }) : null,
16
+ error: query.error ? (describeError ?? describeSeoError)({ error: query.error, fallback: t?.('failed to load entries') ?? 'failed to load entries' }) : null };
12
17
  }
13
18
  export function useWiredEntryPicker(_required = {}, _optional = {}) {
14
19
  const { seoApi: api } = useSeoPorts();