@jini-ai/admin 0.4.11 → 0.4.13

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 (165) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/dist/agent-plugins/controllers/agent-plugin-files.controller.js +14 -9
  3. package/dist/agent-plugins/controllers/agent-plugins.controller.js +12 -9
  4. package/dist/agent-plugins/package-file-tree.js +54 -51
  5. package/dist/agent-plugins/react/components/AgentPluginRow.d.ts +1 -1
  6. package/dist/agent-plugins/react/components/AgentPluginRow.js +9 -2
  7. package/dist/agent-plugins/react/hooks/AgentPluginInspector.hooks.d.ts +5 -5
  8. package/dist/agent-plugins/react/hooks/AgentPluginInspector.hooks.js +14 -5
  9. package/dist/agent-plugins/react/hooks/AgentPluginPanel.hooks.d.ts +15 -13
  10. package/dist/agent-plugins/react/hooks/AgentPluginPanel.hooks.js +54 -40
  11. package/dist/agent-plugins/react/hooks/AgentPluginsPage.hooks.d.ts +13 -13
  12. package/dist/agent-plugins/react/hooks/AgentPluginsPage.hooks.js +23 -11
  13. package/dist/browser/navigation.js +44 -27
  14. package/dist/comments/adapters/memory.js +30 -12
  15. package/dist/comments/conformance/comments-api.conformance.js +55 -33
  16. package/dist/comments/controllers/comment-queue.controller.js +13 -4
  17. package/dist/comments/react/hooks/use-comment-queue.hooks.d.ts +17 -2
  18. package/dist/comments/react/hooks/use-comment-queue.hooks.js +79 -30
  19. package/dist/comments/react/hooks/use-comment-settings.hooks.js +9 -5
  20. package/dist/comments/react/index.d.ts +2 -1
  21. package/dist/comments/react/index.js +1 -1
  22. package/dist/consent/react/ConsentFields.d.ts +35 -0
  23. package/dist/consent/react/ConsentFields.js +74 -0
  24. package/dist/consent/react/ConsentPreviewFrame.d.ts +14 -0
  25. package/dist/consent/react/ConsentPreviewFrame.js +14 -0
  26. package/dist/consent/react/ConsentSettingsPanel.d.ts +27 -4
  27. package/dist/consent/react/ConsentSettingsPanel.js +6 -17
  28. package/dist/consent/react/index.d.ts +6 -1
  29. package/dist/consent/react/index.js +2 -0
  30. package/dist/consent/react/settings-panel.hooks.d.ts +28 -1
  31. package/dist/consent/react/settings-panel.hooks.js +26 -0
  32. package/dist/core/entities/rules.js +126 -81
  33. package/dist/core/manifest/rules.js +10 -10
  34. package/dist/core/module/create-admin.js +195 -117
  35. package/dist/core/ports/redirects.d.ts +8 -6
  36. package/dist/core/ports/redirects.js +8 -6
  37. package/dist/core/ports/seo.d.ts +20 -0
  38. package/dist/core/routing/rules.js +9 -4
  39. package/dist/core/transport/http.js +37 -20
  40. package/dist/forms/adapters/memory.js +22 -11
  41. package/dist/forms/conformance/forms-api.conformance.d.ts +6 -4
  42. package/dist/forms/conformance/forms-api.conformance.js +42 -46
  43. package/dist/forms/field-settings.js +150 -115
  44. package/dist/forms/html-rules.js +45 -28
  45. package/dist/forms/react/components/FieldSettingsRows.d.ts +22 -0
  46. package/dist/forms/react/components/FieldSettingsRows.js +29 -0
  47. package/dist/forms/react/components/FieldSettingsSection.d.ts +1 -1
  48. package/dist/forms/react/components/FieldSettingsSection.js +5 -6
  49. package/dist/forms/react/components/FormEditorFieldsBody.d.ts +1 -1
  50. package/dist/forms/react/components/FormEditorFieldsBody.js +4 -15
  51. package/dist/forms/react/components/FormEditorFieldsBodyParts.d.ts +18 -0
  52. package/dist/forms/react/components/FormEditorFieldsBodyParts.js +32 -0
  53. package/dist/forms/react/components/FormSubmissions.d.ts +1 -1
  54. package/dist/forms/react/components/FormSubmissions.js +4 -20
  55. package/dist/forms/react/components/FormSubmissionsTable.d.ts +19 -0
  56. package/dist/forms/react/components/FormSubmissionsTable.js +22 -0
  57. package/dist/media/adapters/memory.js +31 -26
  58. package/dist/media/controllers/edit-media.controller.js +2 -1
  59. package/dist/media/controllers/library.controller.js +48 -35
  60. package/dist/media/controllers/providers.controller.js +2 -1
  61. package/dist/media/html-attributes.js +29 -15
  62. package/dist/media/index.d.ts +1 -1
  63. package/dist/media/index.js +1 -1
  64. package/dist/media/react/components/MediaIcons.d.ts +0 -3
  65. package/dist/media/react/components/MediaIcons.js +0 -9
  66. package/dist/media/react/components/MediaLightbox.d.ts +2 -1
  67. package/dist/media/react/components/MediaLightbox.js +11 -5
  68. package/dist/media/react/hooks/LibraryTab.hooks.d.ts +55 -45
  69. package/dist/media/react/hooks/LibraryTab.hooks.js +107 -70
  70. package/dist/media/react/hooks/MediaCard.hooks.d.ts +27 -31
  71. package/dist/media/react/hooks/MediaCard.hooks.js +52 -26
  72. package/dist/media/react/hooks/MediaGrid.hooks.d.ts +1 -1
  73. package/dist/media/react/hooks/MediaLightbox.hooks.d.ts +10 -14
  74. package/dist/media/react/hooks/MediaLightbox.hooks.js +9 -56
  75. package/dist/media/react/hooks/MediaPage.hooks.d.ts +6 -0
  76. package/dist/media/react/hooks/MediaPage.hooks.js +29 -12
  77. package/dist/media/react/hooks/ProvidersTab.hooks.d.ts +33 -33
  78. package/dist/media/react/hooks/ProvidersTab.hooks.js +119 -79
  79. package/dist/media/react/hooks/UploadButton.hooks.d.ts +7 -5
  80. package/dist/media/react/hooks/UploadButton.hooks.js +36 -24
  81. package/dist/media/react/index.js +4 -0
  82. package/dist/media/react/tabs/LibraryTab.d.ts +1 -1
  83. package/dist/media/react/tabs/LibraryTab.js +2 -2
  84. package/dist/media/rules.d.ts +7 -0
  85. package/dist/media/rules.js +25 -2
  86. package/dist/react/components/ConfirmDialog/ConfirmDialog.js +5 -8
  87. package/dist/react/components/ConfirmDialog/confirm-dialog-attrs.d.ts +20 -0
  88. package/dist/react/components/ConfirmDialog/confirm-dialog-attrs.js +24 -0
  89. package/dist/react/components/InteractiveHtmlEditor/InteractiveHtmlEditor.js +16 -7
  90. package/dist/react/components/RowMenu/RowMenu.js +16 -10
  91. package/dist/react/components/Sidebar.js +32 -26
  92. package/dist/react/entities/EntityEdit.js +17 -14
  93. package/dist/react/entities/EntityField.d.ts +5 -2
  94. package/dist/react/entities/EntityField.js +38 -23
  95. package/dist/react/entities/EntityList.js +6 -5
  96. package/dist/react/entities/RelationTruncationNotice.d.ts +9 -0
  97. package/dist/react/entities/RelationTruncationNotice.js +9 -0
  98. package/dist/react/entities/use-entity-edit.d.ts +4 -2
  99. package/dist/react/entities/use-entity-edit.js +52 -33
  100. package/dist/react/shell/AdminShell.js +40 -23
  101. package/dist/react/shell/model.d.ts +4 -1
  102. package/dist/react/shell/model.js +3 -2
  103. package/dist/react/shell/use-admin-shell-session.js +25 -26
  104. package/dist/redirects/adapters/memory.js +9 -2
  105. package/dist/redirects/conformance/redirects-api.conformance.js +66 -36
  106. package/dist/redirects/react/hooks/use-import-redirects-form.hooks.d.ts +1 -1
  107. package/dist/redirects/react/hooks/use-import-redirects-form.hooks.js +10 -3
  108. package/dist/redirects/react/hooks/wired.hooks.d.ts +1 -1
  109. package/dist/security/adapters/http.js +25 -10
  110. package/dist/security/adapters/memory.js +19 -5
  111. package/dist/security/controllers/access-tokens.controller.js +27 -26
  112. package/dist/security/controllers/other-credentials.controller.js +4 -2
  113. package/dist/security/controllers/root-key.controller.js +17 -9
  114. package/dist/security/react/hooks/AccessTokensTab.hooks.d.ts +40 -38
  115. package/dist/security/react/hooks/AccessTokensTab.hooks.js +36 -9
  116. package/dist/security/react/hooks/RootKeyTab.hooks.d.ts +14 -14
  117. package/dist/security/react/hooks/RootKeyTab.hooks.js +12 -3
  118. package/dist/security/react/tabs/RootKeyTab.d.ts +1 -1
  119. package/dist/security/react/tabs/RootKeyTab.js +12 -3
  120. package/dist/security/rules.js +45 -25
  121. package/dist/seo/adapters/http.js +5 -0
  122. package/dist/seo/controllers/seo.controller.js +4 -2
  123. package/dist/seo/messages.en.d.ts +15 -10
  124. package/dist/seo/messages.en.js +15 -10
  125. package/dist/seo/models.d.ts +11 -0
  126. package/dist/seo/react/components/MediaRefField.d.ts +17 -5
  127. package/dist/seo/react/components/MediaRefField.js +31 -4
  128. package/dist/seo/react/hooks/MediaRefField.hooks.js +12 -2
  129. package/dist/seo/react/hooks/SeoPage.hooks.d.ts +1 -0
  130. package/dist/seo/react/hooks/SeoPage.hooks.js +2 -2
  131. package/dist/seo/react/hooks/SeoQuery.hooks.d.ts +1 -0
  132. package/dist/seo/react/hooks/SeoQuery.hooks.js +1 -0
  133. package/dist/seo/react/hooks/use-entry-picker.hooks.js +7 -2
  134. package/dist/seo/react/hooks/use-seo-entry-panel.hooks.js +13 -2
  135. package/dist/seo/react/hooks/use-share-image-warning.hooks.d.ts +4 -1
  136. package/dist/seo/react/hooks/use-share-image-warning.hooks.js +48 -33
  137. package/dist/seo/react/index.d.ts +45 -30
  138. package/dist/seo/react/pages/SeoPage.d.ts +6 -4
  139. package/dist/seo/react/pages/SeoPage.js +51 -29
  140. package/dist/seo/rules.d.ts +91 -0
  141. package/dist/seo/rules.js +161 -6
  142. package/dist/seo/seo.module.d.ts +15 -10
  143. package/dist/source-control/conformance/source-control-api.conformance.js +68 -38
  144. package/dist/source-control/controllers/source-control.controller.js +20 -8
  145. package/dist/source-control/react/hooks/ProvidersTab.hooks.d.ts +2 -1
  146. package/dist/source-control/react/hooks/ProvidersTab.hooks.js +18 -11
  147. package/dist/widgets/conformance/widgets-api.conformance.js +15 -1
  148. package/dist/widgets/controllers/instance-editor.controller.js +33 -24
  149. package/dist/widgets/controllers/library.controller.js +18 -12
  150. package/dist/widgets/controllers/region-editor.controller.js +6 -5
  151. package/dist/widgets/react/hooks/page-views.hooks.d.ts +1 -0
  152. package/dist/widgets/react/hooks/page-views.hooks.js +3 -2
  153. package/dist/widgets/react/hooks/use-widget-regions.hooks.d.ts +1 -1
  154. package/dist/widgets/react/hooks/use-widget-regions.hooks.js +9 -3
  155. package/dist/widgets/react/hooks/use-widgets-library.hooks.js +9 -2
  156. package/dist/widgets/react/pages/WidgetInstanceEditor.d.ts +1 -1
  157. package/dist/widgets/react/pages/WidgetInstanceEditor.js +7 -8
  158. package/dist/widgets/rules.d.ts +14 -2
  159. package/dist/widgets/rules.js +9 -6
  160. package/package.json +19 -10
  161. package/src/themes/default/admin.css +51 -0
  162. package/src/themes/default/chat.css +21 -0
  163. package/src/themes/default/manifest.json +29 -0
  164. package/dist/media/styles.d.ts +0 -4
  165. package/dist/media/styles.js +0 -5
@@ -1,9 +1,10 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } 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,36 @@ 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.
85
+ // 2026-10-20: exemption retired under the ≤10/≤10 bar — the head row, the "Search appearance"
86
+ // group, the "Social preview" group and its X disclosure are now their own components below, each
87
+ // scoring ≤10. The fallback chains themselves are unchanged and still read in form order.
83
88
  function SeoEntryPanel({ locale, entryId, useSeoEntryPanelHook = useWiredSeoEntryPanel }) {
84
89
  const t = useSeoCopy();
85
- const { resolved, analysis, loadError, saving, saveError, notice, fieldValue, setField, save, touched } = useSeoEntryPanelHook({ entryId });
90
+ const { resolved, analysis, loadError, saving, saveError, notice, fieldValue, setField, save, touched, ogImageInherited = false } = useSeoEntryPanelHook({ entryId });
86
91
  if (loadError)
87
92
  return _jsx("div", { className: "jini-notice jini-notice-error", children: loadError });
88
93
  if (!resolved)
89
94
  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] }));
95
+ const fields = { locale, t, resolved, fieldValue, setField };
96
+ return (_jsxs("div", { className: "jini-seo-entry-panel", children: [_jsx(SeoEntryHead, { locale: locale, t: t, saving: saving, touched: touched, save: save }), notice ? _jsx("div", { className: "jini-notice", children: notice }) : null, saveError ? (_jsx("div", { className: "jini-notice jini-notice-error", role: "alert", children: saveError })) : null, _jsx(SeoEntrySearchFields, { ...fields }), _jsx(SeoEntrySocialFields, { ...fields, ogImageInherited: ogImageInherited }), analysis ? _jsx(AnalyzePanel, { locale: locale, analysis: analysis }) : null] }));
97
+ }
98
+ /** Sticky heading row: title, the ⓘ explanation and Save (disabled while saving or with nothing touched). */
99
+ function SeoEntryHead({ locale, t, saving, touched, save }) {
100
+ return (_jsx(_Fragment, { 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" }) }) })] }) }) }));
101
+ }
102
+ /** "Search appearance": title, description, canonical URL and the two robots checkboxes. */
103
+ function SeoEntrySearchFields({ locale, t, resolved, fieldValue, setField }) {
104
+ return (_jsx(_Fragment, { children: _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" })] })] })] }) }));
105
+ }
106
+ /** "Social preview": the Open Graph fields plus the optional X/Twitter disclosure. */
107
+ function SeoEntrySocialFields({ locale, t, resolved, fieldValue, setField, ogImageInherited }) {
108
+ return (_jsx(_Fragment, { children: _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" }), _jsx(SeoEntryXFields, { locale: locale, t: t, resolved: resolved, fieldValue: fieldValue, setField: setField })] }) }));
109
+ }
110
+ /** The X/Twitter disclosure inside "Social preview". */
111
+ function SeoEntryXFields({ locale, t, resolved, fieldValue, setField }) {
112
+ return (_jsx(_Fragment, { children: _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" })] })] }) }));
91
113
  }
92
114
  /** Section wrapper (REQ-06/07) — entry picker over the per-entry edit + analyze panels. This IS the
93
115
  * "Pages & posts" tab panel; it kept its name and its markup, because nothing about it changed
@@ -99,7 +121,7 @@ function SeoEntryPanel({ locale, entryId, useSeoEntryPanelHook = useWiredSeoEntr
99
121
  * and nothing else — the exact regression `deployment/HistoryTab.tsx`'s own comment records
100
122
  * measuring live on its tab. It is also not a duplicate: the tab reads "Pages & posts" (what you
101
123
  * pick from), the heading reads "Per-entry SEO" (what you are editing). */
102
- export function SeoEntrySection({ locale, useSeoEntrySectionHook = useSeoEntrySection }, _optional = {}) {
124
+ export function SeoEntrySection({ locale, useSeoEntrySectionHook = useSeoEntrySection }) {
103
125
  const t = useSeoCopy();
104
126
  const { entryId, setEntryId } = useSeoEntrySectionHook();
105
127
  return (_jsx("div", { className: "jini-seo-panel jini-seo-panel--full jini-seo-entry-section", ...agentHandle({ handle: "seo-per-entry" }, {
@@ -126,7 +148,7 @@ export function SeoEntrySection({ locale, useSeoEntrySectionHook = useSeoEntrySe
126
148
  * inline `??`/`||` field reads — same fields, same fallbacks, same payload, just not written in the
127
149
  * `.tsx`.
128
150
  */
129
- export function SeoDefaultsTab({ controller }, _optional = {}) {
151
+ export function SeoDefaultsTab({ controller }) {
130
152
  const t = useSeoCopy();
131
153
  const { locale, settings, saving, defaultOgImage, setDefaultOgImage } = controller;
132
154
  const { submit } = useSeoDefaults({ save: controller.save });
@@ -139,7 +161,7 @@ export function SeoDefaultsTab({ controller }, _optional = {}) {
139
161
  }) })] }), _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
162
  role: "field",
141
163
  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" }, {
164
+ }) })] })] }), _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
165
  role: "field",
144
166
  label: "The site's Twitter @handle, used in Twitter card metadata",
145
167
  }) })] })] }), _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" }, {
@@ -173,7 +195,7 @@ export function SeoDefaultsTab({ controller }, _optional = {}) {
173
195
  * It also has to exist at all — a `role="tab"` button is not in a screen reader's heading list, so
174
196
  * a panel with no `<h2>` leaves that reader nothing but the page `H1`.
175
197
  */
176
- export function SeoSitemapTab({ controller }, _optional = {}) {
198
+ export function SeoSitemapTab({ controller }) {
177
199
  const t = useSeoCopy();
178
200
  const { locale, settings, saving, regenerateSitemap, openSitemapModal } = controller;
179
201
  return (_jsx("div", { className: "jini-seo-panel jini-seo-panel--full", ...agentHandle({ handle: "seo-sitemap" }, {
@@ -187,7 +209,7 @@ export function SeoSitemapTab({ controller }, _optional = {}) {
187
209
  label: "Open a modal showing the sitemap's URLs, or its raw XML",
188
210
  }), children: t({ locale: locale, key: "View sitemap" }) })] })] }) }));
189
211
  }
190
- export function Seo({ tabId, onTabChange, useSeoHook = useWiredSeo } = {}, _optional = {}) {
212
+ export function Seo({ tabId, onTabChange, useSeoHook = useWiredSeo } = {}) {
191
213
  const t = useSeoCopy();
192
214
  const { headerActions } = useSeoOptions();
193
215
  const { changeTab } = useSeoPageActions({ onTabChange });
@@ -208,7 +230,7 @@ export function Seo({ tabId, onTabChange, useSeoHook = useWiredSeo } = {}, _opti
208
230
  regenerateSitemap,
209
231
  openSitemapModal,
210
232
  };
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] }));
233
+ 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
234
  }
213
235
  export { Seo as SeoPage };
214
236
  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
@@ -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
@@ -85,6 +116,39 @@ 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`,
@@ -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
package/dist/seo/rules.js CHANGED
@@ -46,6 +46,43 @@ export function overrideOrClear({ value }, _optional = {}) {
46
46
  export function orEmpty({ value }, _optional = {}) {
47
47
  return value ?? "";
48
48
  }
49
+ /** One entry's effective X value next to its effective social preview value, per field. Images
50
+ * compare by source ref (the resolved URL is resized), falling back to the URL for pasted ones. */
51
+ function xAndSocial({ meta }) {
52
+ return {
53
+ twitterTitle: [meta.twitter.title, meta.openGraph.title],
54
+ twitterDescription: [meta.twitter.description, meta.openGraph.description],
55
+ twitterImage: [meta.twitter.imageRef ?? meta.twitter.image, meta.openGraph.imageRef ?? meta.openGraph.image],
56
+ };
57
+ }
58
+ /** Whether this entry's X card differs from its social preview in any field — i.e. an X override
59
+ * is in effect, so the "Customize for X/Twitter" disclosure opens on load. (2026-10-15, owner: one
60
+ * generic social preview, X only as an optional customization.)
61
+ * @complexity O(1). */
62
+ export function xIsCustomized({ meta }, _optional = {}) {
63
+ return Object.values(xAndSocial({ meta })).some(([x, social]) => x !== social);
64
+ }
65
+ /** An X field's untouched box value, handed to `fieldValue` as its resolved value (so a pending
66
+ * edit or clear still wins there). An X value equal to the social preview's is inherited, so the
67
+ * box stays empty and its placeholder shows the live social value — typing there is what creates
68
+ * an X override.
69
+ * @complexity O(1). */
70
+ export function xResolvedValue({ key, meta }, _optional = {}) {
71
+ const [x, social] = xAndSocial({ meta })[key];
72
+ return x === social ? '' : x ?? '';
73
+ }
74
+ /** The X field an edit to a social preview field should carry along: when X still shows the
75
+ * preview's value and has no pending edit of its own, the edit also clears X's stored override so
76
+ * X keeps following the preview after save. Without it, an X override saved equal to the preview
77
+ * (indistinguishable here, the screen sees only effective values) would silently pin X to the old
78
+ * text. Clearing an override that does not exist is a no-op on the server.
79
+ * @complexity O(1). */
80
+ export function inheritedXFor({ key, meta, touched }, _optional = {}) {
81
+ const xKey = { ogTitle: 'twitterTitle', ogDescription: 'twitterDescription', ogImage: 'twitterImage' }[key];
82
+ if (!xKey || xKey in touched)
83
+ return null;
84
+ return xResolvedValue({ key: xKey, meta }) === '' ? xKey : null;
85
+ }
49
86
  /** A pending-action button's label — `react/pages/SeoPage.tsx` uses this for both the save-settings button
50
87
  * ("Saving…"/"Save settings") and the regenerate-sitemap button ("Working…"/"Regenerate
51
88
  * sitemap"), same `pending ? … : …` shape, different copy. Also reused by `SitemapModal.tsx`'s
@@ -122,10 +159,84 @@ function parseMediaRefAssetId(ref) {
122
159
  * URLs and incomplete input remain editable. @complexity O(n) in value length; no I/O.
123
160
  */
124
161
  export function isStoredMediaRef({ value }, _optional = {}) {
125
- const trimmed = typeof value === "string" ? value.trim() : "";
162
+ const trimmed = trimmedText(value);
126
163
  // A URL typed one character at a time reaches "https:/" before it reaches "https://".
127
164
  return !trimmed.includes(":/") && parseMediaRefAssetId(trimmed) !== null;
128
165
  }
166
+ /** A field value trimmed, or `""` for a non-string — the `value` props arrive through `??` fallback
167
+ * chains that do not replace a wrong-typed result, and these rules never throw over one bad value. */
168
+ function trimmedText(value) {
169
+ return typeof value === 'string' ? value.trim() : '';
170
+ }
171
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
172
+ /** What an image field shows for its value — the file's name and pixel size — in place of the raw
173
+ * `{slug}:public` reference or URL (owner, 2026-10-15: a read-only "Selected image" box "looks
174
+ * useless"). The library row wins when the host can list media; otherwise a readable slug or the
175
+ * URL's file name stands in, and an opaque id falls back to the translated "Image".
176
+ * Size prefers the stored dimensions, then the decoded preview's natural size.
177
+ * @example describeMediaRef({ value: 'sunset:public', media: [{ id: 'a1', slug: 'sunset', title: 'Sunset', width: 1200, height: 630 }] }) // { name: 'Sunset', size: '1200 × 630' }
178
+ * @complexity O(m) over the media rows; no I/O.
179
+ */
180
+ export function describeMediaRef({ value, media }, { width, height, t = key => key, extensionFor } = {}) {
181
+ const trimmed = trimmedText(value);
182
+ if (!trimmed)
183
+ return null;
184
+ const key = parseMediaRefAssetId(trimmed);
185
+ const row = findMediaRow({ key, media });
186
+ const name = libraryFileName({ row }, { extensionFor }) ?? fallbackMediaName({ key, value: trimmed, t });
187
+ return { name, size: pixelSize(row?.width ?? width, row?.height ?? height) };
188
+ }
189
+ /** The library row an id-or-slug reference names, when the host could list media at all. */
190
+ function findMediaRow({ key, media }, _optional = {}) {
191
+ return key ? media?.find(item => item.id === key || item.slug === key) : undefined;
192
+ }
193
+ /** A titled library row's display name with its stored type's extension; `null` for no row or an
194
+ * untitled one, so the caller's fallback name applies. */
195
+ function libraryFileName({ row }, { extensionFor } = {}) {
196
+ if (!row?.title)
197
+ return null;
198
+ return withExtension({ name: row.title, extension: row.contentType && extensionFor ? extensionFor(row.contentType) : '' });
199
+ }
200
+ /** A library title reads as a file name (owner, 2026-10-15: "it should be testing-page.png or
201
+ * whatever the extension is"). No original upload name is stored, so the extension comes from the
202
+ * stored bytes' type; a title that already ends in it is left alone. */
203
+ function withExtension({ name, extension }) {
204
+ return extension && !name.toLowerCase().endsWith(extension) ? `${name}${extension}` : name;
205
+ }
206
+ /** Whether an entry's social preview image comes from content or the site default, with no override
207
+ * of its own, so the field shows its source muted. "Using site default" is the original copy (owner,
208
+ * 2026-10-15). A pending edit is the entry's own, and so is a stored override the host flags
209
+ * (`imageOverridden`) even when it equals the default. A host without that flag can't tell such an
210
+ * override apart, so it reads as inherited (same image either way). Returns 'content' for an image
211
+ * the host identifies as content, 'default' for the site default, and false for an own/absent image.
212
+ * @complexity O(1). */
213
+ export function ogImageInherited({ meta, defaultOgImage, touched }, _optional = {}) {
214
+ if ('ogImage' in touched || meta.openGraph.imageOverridden)
215
+ return false;
216
+ const image = meta.openGraph.imageRef ?? meta.openGraph.image;
217
+ if (meta.openGraph.imageSource === 'content' && image)
218
+ return 'content';
219
+ return defaultOgImage && image === defaultOgImage ? 'default' : false;
220
+ }
221
+ function fallbackMediaName({ key, value, t }) {
222
+ if (!key)
223
+ return urlFileName(value);
224
+ return UUID_PATTERN.test(key) ? t('Image') : key;
225
+ }
226
+ function pixelSize(width, height) {
227
+ return width && height ? `${width} × ${height}` : null;
228
+ }
229
+ /** The last path segment of an image URL ("https://cdn.example/a/pic.png" → "pic.png"). */
230
+ function urlFileName(value) {
231
+ try {
232
+ const url = new URL(value, 'https://placeholder.invalid');
233
+ const last = url.pathname.split('/').filter(Boolean).pop();
234
+ return last ? decodeURIComponent(last) : url.hostname || value;
235
+ }
236
+ catch {
237
+ return value;
238
+ }
239
+ }
129
240
  /** Builds the `{slug}:public` reference `MediaRefField` writes into the field on selection —
130
241
  * prefers the asset's readable slug over its id (readable-slugs S5b, 2026-09-23), matching
131
242
  * `resolveSeoImageRef`'s own id-or-slug lookup server-side (`findMediaByIdOrSlug`,
@@ -154,7 +265,7 @@ export function buildMediaRef({ item }, _optional = {}) {
154
265
  * @complexity O(1) — string parsing only, no I/O (the returned URL is a template; the browser
155
266
  * performs the actual fetch only once it is used as an `<img src>`). */
156
267
  export function resolveMediaRefPreviewUrl({ value, mediaOriginalUrl }, { siteOrigin } = {}) {
157
- const trimmed = typeof value === "string" ? value.trim() : "";
268
+ const trimmed = trimmedText(value);
158
269
  if (!trimmed)
159
270
  return null;
160
271
  if (isAbsoluteMediaRef(trimmed)) {
@@ -185,14 +296,26 @@ function localRenditionAssetKey({ value, siteOrigin }, _optional = {}) {
185
296
  * @complexity O(n) in value/error length; fixed-count checks, no I/O.
186
297
  */
187
298
  export function shareImageWarning({ value, previewUrl, width, height, byteSize, error, previewFailed }, { t = key => key } = {}) {
188
- if (typeof value !== 'string' || !value.trim())
299
+ const blocker = shareImageBlocker({ value, previewUrl, error, previewFailed }, { t });
300
+ if (blocker !== null)
301
+ return blocker;
302
+ const reasons = shareImageShortfalls({ width, height, byteSize }, { t });
303
+ return reasons.length ? reasons.join(' ') : null;
304
+ }
305
+ /** Why there is nothing to measure: no value, an unparseable one, a metadata error, or a failed
306
+ * decode — checked in that order. `null` when the image loaded and can be measured. */
307
+ function shareImageBlocker({ value, previewUrl, error, previewFailed }, { t }) {
308
+ if (!trimmedText(value))
189
309
  return t('No share image is selected. Link previews may show no picture.');
190
310
  if (!previewUrl)
191
311
  return t('The share image reference is invalid. Choose an image or enter an absolute image URL.');
192
312
  if (error)
193
313
  return error;
194
- if (previewFailed)
195
- return t('The share image could not be loaded. Choose an available image.');
314
+ return previewFailed ? t('The share image could not be loaded. Choose an available image.') : null;
315
+ }
316
+ /** Each measured dimension or size below the share-card minimum, as its own sentence. An unknown
317
+ * measurement (`undefined`, or a `null` byte size) is never reported. */
318
+ function shareImageShortfalls({ width, height, byteSize }, { t }) {
196
319
  const reasons = [];
197
320
  if (width !== undefined && width < 1200)
198
321
  reasons.push(interpolate({ template: t('The share image is only {width} px wide; use an image at least 1200 px wide.', { width }), vars: { width } }));
@@ -200,7 +323,7 @@ export function shareImageWarning({ value, previewUrl, width, height, byteSize,
200
323
  reasons.push(interpolate({ template: t('The share image is only {height} px tall; use an image at least 630 px tall.', { height }), vars: { height } }));
201
324
  if (byteSize !== undefined && byteSize !== null && byteSize < 5120)
202
325
  reasons.push(interpolate({ template: t('The share image is only {byteSize} bytes; use an image of at least 5 KB (5120 bytes).', { byteSize }), vars: { byteSize } }));
203
- return reasons.length ? reasons.join(' ') : null;
326
+ return reasons;
204
327
  }
205
328
  /** One `FormData` field as a string, with the caller's fallback for an absent field. Its own named
206
329
  * function purely so {@link buildSeoSettingsPatch} below stays readable at a glance: written
@@ -259,4 +382,36 @@ export const SEO_TAB_IDS = ['defaults', 'sitemap', 'entries'];
259
382
  export function resolveSeoTabId({ tabId }, _optional = {}) {
260
383
  return SEO_TAB_IDS.find(id => id === tabId) ?? 'defaults';
261
384
  }
385
+ /** Colour band for the analysis score pill: the same success/warning/failure tones the issue pills
386
+ * use, so a 100 reads green and a failing page reads red at a glance.
387
+ * @example scoreTone({ score: 100 }) // 'success'
388
+ */
389
+ export function scoreTone({ score }, _optional = {}) {
390
+ if (score >= 80)
391
+ return 'success';
392
+ return score >= 50 ? 'warning' : 'failure';
393
+ }
394
+ /**
395
+ * Picker rows for the Pages & posts tab. Pages lead because they are fewer and are the usual SEO
396
+ * targets (home, about, pricing); a published entry carries no pill, so only the exceptions
397
+ * (draft, scheduled, …) draw the eye. An untitled entry stays pickable by its id.
398
+ * @example entryPickerOptions({ posts, pages }, { t }) // [{ value: 'p1', label: 'About', group: 'Pages' }, …]
399
+ */
400
+ export function entryPickerOptions({ posts, pages }, { t = key => key } = {}) {
401
+ // Two entries can share a title ("Landing Page" twice, owner 2026-10-15); both stay listed and
402
+ // each duplicate shows its slug as muted secondary text so the operator can tell them apart.
403
+ const rows = (entries, group) => {
404
+ const labelOf = (entry) => entry.title.trim() || entry.id;
405
+ const counts = new Map();
406
+ for (const entry of entries)
407
+ counts.set(labelOf(entry), (counts.get(labelOf(entry)) ?? 0) + 1);
408
+ return entries.map((entry) => ({
409
+ value: entry.id, label: labelOf(entry), group,
410
+ ...(entry.status && entry.status !== 'published' ? { badge: t(entry.status) } : {}),
411
+ // Every label was counted above, so its count is at least 1 and "not exactly one" means a duplicate.
412
+ ...(counts.get(labelOf(entry)) !== 1 ? { detail: entry.slug || entry.id } : {}),
413
+ }));
414
+ };
415
+ return [...rows(pages, t('Pages')), ...rows(posts, t('Posts'))];
416
+ }
262
417
  //# sourceMappingURL=rules.js.map
@@ -29,21 +29,24 @@ export declare const seoModule: {
29
29
  readonly "Canonical URL": "Canonical URL";
30
30
  readonly Noindex: "Noindex";
31
31
  readonly Nofollow: "Nofollow";
32
- readonly "OG title": "OG title";
33
- readonly "OG description": "OG description";
34
- readonly "OG image": "OG image";
35
- readonly "Twitter title": "Twitter title";
36
- readonly "Twitter description": "Twitter description";
37
- readonly "Twitter image": "Twitter image";
32
+ readonly "Preview title": "Preview title";
33
+ readonly "Preview description": "Preview description";
34
+ readonly "Preview image": "Preview image";
35
+ readonly "X/Twitter title": "X/Twitter title";
36
+ readonly "X/Twitter description": "X/Twitter description";
37
+ readonly "X/Twitter image": "X/Twitter image";
38
+ readonly "Customize for X/Twitter (optional)": "Customize for X/Twitter (optional)";
39
+ readonly "Same as the preview image": "Same as the preview image";
40
+ readonly "Used by Facebook, LinkedIn, Slack, Discord, iMessage, WhatsApp and X/Twitter": "Used by Facebook, LinkedIn, Slack, Discord, iMessage, WhatsApp and X/Twitter";
38
41
  readonly "Saving\u2026": "Saving…";
39
42
  readonly "Save overrides": "Save overrides";
40
43
  readonly "Per-entry SEO": "Per-entry SEO";
41
44
  readonly "Search appearance": "Search appearance";
42
45
  readonly "Title template (must contain %s)": "Title template (must contain %s)";
43
46
  readonly "Default meta description": "Default meta description";
44
- readonly "Social sharing": "Social sharing";
45
- readonly "Default Open Graph / Twitter image": "Default Open Graph / Twitter image";
46
- readonly "Twitter @site handle": "Twitter @site handle";
47
+ readonly "Social preview": "Social preview";
48
+ readonly "Default preview image": "Default preview image";
49
+ readonly "X/Twitter @handle": "X/Twitter @handle";
47
50
  readonly Crawling: "Crawling";
48
51
  readonly "Default noindex": "Default noindex";
49
52
  readonly "Default nofollow": "Default nofollow";
@@ -56,7 +59,7 @@ export declare const seoModule: {
56
59
  readonly "View sitemap": "View sitemap";
57
60
  readonly "Loading SEO settings\u2026": "Loading SEO settings…";
58
61
  readonly Marketing: "Marketing";
59
- 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.";
62
+ 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.";
60
63
  readonly "Choose image": "Choose image";
61
64
  readonly Remove: "Remove";
62
65
  readonly "Sitemap \u00B7 {count} URLs": "Sitemap · {count} URLs";
@@ -84,6 +87,8 @@ export declare const seoModule: {
84
87
  readonly "failed to load the sitemap": "failed to load the sitemap";
85
88
  readonly "Could not load the sitemap (HTTP {status}).": "Could not load the sitemap (HTTP {status}).";
86
89
  readonly "Selected image": "Selected image";
90
+ readonly "Using site default: {name}": "Using site default: {name}";
91
+ readonly "Using image from page content: {name}": "Using image from page content: {name}";
87
92
  };
88
93
  readonly pages: {
89
94
  readonly settings: {