@jini-ai/admin 0.4.12 → 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 (151) hide show
  1. package/CHANGELOG.md +4 -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/routing/rules.js +9 -4
  38. package/dist/core/transport/http.js +37 -20
  39. package/dist/forms/adapters/memory.js +22 -11
  40. package/dist/forms/conformance/forms-api.conformance.d.ts +6 -4
  41. package/dist/forms/conformance/forms-api.conformance.js +42 -46
  42. package/dist/forms/field-settings.js +150 -115
  43. package/dist/forms/html-rules.js +45 -28
  44. package/dist/forms/react/components/FieldSettingsRows.d.ts +22 -0
  45. package/dist/forms/react/components/FieldSettingsRows.js +29 -0
  46. package/dist/forms/react/components/FieldSettingsSection.d.ts +1 -1
  47. package/dist/forms/react/components/FieldSettingsSection.js +5 -6
  48. package/dist/forms/react/components/FormEditorFieldsBody.d.ts +1 -1
  49. package/dist/forms/react/components/FormEditorFieldsBody.js +4 -15
  50. package/dist/forms/react/components/FormEditorFieldsBodyParts.d.ts +18 -0
  51. package/dist/forms/react/components/FormEditorFieldsBodyParts.js +32 -0
  52. package/dist/forms/react/components/FormSubmissions.d.ts +1 -1
  53. package/dist/forms/react/components/FormSubmissions.js +4 -20
  54. package/dist/forms/react/components/FormSubmissionsTable.d.ts +19 -0
  55. package/dist/forms/react/components/FormSubmissionsTable.js +22 -0
  56. package/dist/media/adapters/memory.js +31 -26
  57. package/dist/media/controllers/edit-media.controller.js +2 -1
  58. package/dist/media/controllers/library.controller.js +48 -35
  59. package/dist/media/controllers/providers.controller.js +2 -1
  60. package/dist/media/html-attributes.js +29 -15
  61. package/dist/media/index.d.ts +1 -1
  62. package/dist/media/index.js +1 -1
  63. package/dist/media/react/components/MediaIcons.d.ts +0 -3
  64. package/dist/media/react/components/MediaIcons.js +0 -9
  65. package/dist/media/react/components/MediaLightbox.d.ts +2 -1
  66. package/dist/media/react/components/MediaLightbox.js +11 -5
  67. package/dist/media/react/hooks/LibraryTab.hooks.d.ts +55 -45
  68. package/dist/media/react/hooks/LibraryTab.hooks.js +107 -70
  69. package/dist/media/react/hooks/MediaCard.hooks.d.ts +27 -31
  70. package/dist/media/react/hooks/MediaCard.hooks.js +52 -26
  71. package/dist/media/react/hooks/MediaGrid.hooks.d.ts +1 -1
  72. package/dist/media/react/hooks/MediaLightbox.hooks.d.ts +10 -14
  73. package/dist/media/react/hooks/MediaLightbox.hooks.js +9 -56
  74. package/dist/media/react/hooks/MediaPage.hooks.d.ts +6 -0
  75. package/dist/media/react/hooks/MediaPage.hooks.js +29 -12
  76. package/dist/media/react/hooks/ProvidersTab.hooks.d.ts +33 -33
  77. package/dist/media/react/hooks/ProvidersTab.hooks.js +119 -79
  78. package/dist/media/react/hooks/UploadButton.hooks.d.ts +7 -5
  79. package/dist/media/react/hooks/UploadButton.hooks.js +36 -24
  80. package/dist/media/react/index.js +4 -0
  81. package/dist/media/react/tabs/LibraryTab.d.ts +1 -1
  82. package/dist/media/react/tabs/LibraryTab.js +2 -2
  83. package/dist/media/rules.d.ts +7 -0
  84. package/dist/media/rules.js +25 -2
  85. package/dist/react/components/ConfirmDialog/ConfirmDialog.js +5 -8
  86. package/dist/react/components/ConfirmDialog/confirm-dialog-attrs.d.ts +20 -0
  87. package/dist/react/components/ConfirmDialog/confirm-dialog-attrs.js +24 -0
  88. package/dist/react/components/InteractiveHtmlEditor/InteractiveHtmlEditor.js +16 -7
  89. package/dist/react/components/RowMenu/RowMenu.js +16 -10
  90. package/dist/react/components/Sidebar.js +32 -26
  91. package/dist/react/entities/EntityEdit.js +17 -14
  92. package/dist/react/entities/EntityField.d.ts +5 -2
  93. package/dist/react/entities/EntityField.js +38 -23
  94. package/dist/react/entities/EntityList.js +6 -5
  95. package/dist/react/entities/RelationTruncationNotice.d.ts +9 -0
  96. package/dist/react/entities/RelationTruncationNotice.js +9 -0
  97. package/dist/react/entities/use-entity-edit.d.ts +4 -2
  98. package/dist/react/entities/use-entity-edit.js +52 -33
  99. package/dist/react/shell/AdminShell.js +40 -23
  100. package/dist/react/shell/model.d.ts +4 -1
  101. package/dist/react/shell/model.js +3 -2
  102. package/dist/react/shell/use-admin-shell-session.js +25 -26
  103. package/dist/redirects/adapters/memory.js +9 -2
  104. package/dist/redirects/conformance/redirects-api.conformance.js +66 -36
  105. package/dist/redirects/react/hooks/use-import-redirects-form.hooks.d.ts +1 -1
  106. package/dist/redirects/react/hooks/use-import-redirects-form.hooks.js +10 -3
  107. package/dist/redirects/react/hooks/wired.hooks.d.ts +1 -1
  108. package/dist/security/adapters/http.js +25 -10
  109. package/dist/security/adapters/memory.js +19 -5
  110. package/dist/security/controllers/access-tokens.controller.js +27 -26
  111. package/dist/security/controllers/other-credentials.controller.js +4 -2
  112. package/dist/security/controllers/root-key.controller.js +17 -9
  113. package/dist/security/react/hooks/AccessTokensTab.hooks.d.ts +40 -38
  114. package/dist/security/react/hooks/AccessTokensTab.hooks.js +36 -9
  115. package/dist/security/react/hooks/RootKeyTab.hooks.d.ts +14 -14
  116. package/dist/security/react/hooks/RootKeyTab.hooks.js +12 -3
  117. package/dist/security/react/tabs/RootKeyTab.d.ts +1 -1
  118. package/dist/security/react/tabs/RootKeyTab.js +12 -3
  119. package/dist/security/rules.js +45 -25
  120. package/dist/seo/controllers/seo.controller.js +4 -2
  121. package/dist/seo/react/components/MediaRefField.d.ts +1 -1
  122. package/dist/seo/react/components/MediaRefField.js +19 -2
  123. package/dist/seo/react/hooks/MediaRefField.hooks.js +3 -1
  124. package/dist/seo/react/hooks/SeoPage.hooks.d.ts +1 -1
  125. package/dist/seo/react/hooks/use-share-image-warning.hooks.js +48 -34
  126. package/dist/seo/react/pages/SeoPage.d.ts +4 -4
  127. package/dist/seo/react/pages/SeoPage.js +26 -6
  128. package/dist/seo/rules.js +39 -10
  129. package/dist/source-control/conformance/source-control-api.conformance.js +68 -38
  130. package/dist/source-control/controllers/source-control.controller.js +20 -8
  131. package/dist/source-control/react/hooks/ProvidersTab.hooks.d.ts +2 -1
  132. package/dist/source-control/react/hooks/ProvidersTab.hooks.js +18 -11
  133. package/dist/widgets/conformance/widgets-api.conformance.js +15 -1
  134. package/dist/widgets/controllers/instance-editor.controller.js +33 -24
  135. package/dist/widgets/controllers/library.controller.js +18 -12
  136. package/dist/widgets/controllers/region-editor.controller.js +6 -5
  137. package/dist/widgets/react/hooks/page-views.hooks.d.ts +1 -0
  138. package/dist/widgets/react/hooks/page-views.hooks.js +3 -2
  139. package/dist/widgets/react/hooks/use-widget-regions.hooks.d.ts +1 -1
  140. package/dist/widgets/react/hooks/use-widget-regions.hooks.js +9 -3
  141. package/dist/widgets/react/hooks/use-widgets-library.hooks.js +9 -2
  142. package/dist/widgets/react/pages/WidgetInstanceEditor.d.ts +1 -1
  143. package/dist/widgets/react/pages/WidgetInstanceEditor.js +7 -8
  144. package/dist/widgets/rules.d.ts +14 -2
  145. package/dist/widgets/rules.js +9 -6
  146. package/package.json +17 -8
  147. package/src/themes/default/admin.css +51 -0
  148. package/src/themes/default/chat.css +21 -0
  149. package/src/themes/default/manifest.json +29 -0
  150. package/dist/media/styles.d.ts +0 -4
  151. package/dist/media/styles.js +0 -5
@@ -17,20 +17,8 @@ export function useShareImageWarning({ value, previewUrl, api }, { t = key => ke
17
17
  const [inspection, setInspection] = useState(null);
18
18
  useEffect(() => {
19
19
  const inspect = api.inspectShareImage;
20
- if (!previewUrl || !inspect)
20
+ if (!previewUrl || !inspect || !isBrowserSameOrigin({ url: previewUrl }))
21
21
  return;
22
- // Browser origin, rather than the public site's alias: a cross-origin HEAD cannot establish
23
- // that an otherwise loadable external image is missing when the server omits CORS headers.
24
- const location = globalThis.location;
25
- if (!location)
26
- return;
27
- try {
28
- if (new URL(previewUrl, location.href).origin !== location.origin)
29
- return;
30
- }
31
- catch {
32
- return;
33
- }
34
22
  const controller = new AbortController();
35
23
  let active = true;
36
24
  const timeout = setTimeout(() => {
@@ -39,17 +27,8 @@ export function useShareImageWarning({ value, previewUrl, api }, { t = key => ke
39
27
  setInspection({ url: previewUrl, error: { key: 'The share image check timed out after 10 seconds.' } });
40
28
  }, 10_000);
41
29
  inspect.call(api, { url: previewUrl }, { signal: controller.signal }).then(({ status, byteSize }) => {
42
- if (!active)
43
- return;
44
- if (status === 404 || status === 410) {
45
- setInspection({ url: previewUrl, error: { key: 'The share image is missing (HTTP {status}).', vars: { status } } });
46
- }
47
- else if (status < 200 || status >= 300) {
48
- setInspection({ url: previewUrl, error: { key: 'The share image could not be checked (HTTP {status}).', vars: { status } } });
49
- }
50
- else {
51
- setInspection({ url: previewUrl, byteSize });
52
- }
30
+ if (active)
31
+ setInspection(inspectionFromResponse({ url: previewUrl, status, byteSize }));
53
32
  }).catch((error) => {
54
33
  if (!active)
55
34
  return;
@@ -58,23 +37,58 @@ export function useShareImageWarning({ value, previewUrl, api }, { t = key => ke
58
37
  }).finally(() => { clearTimeout(timeout); });
59
38
  return () => { active = false; clearTimeout(timeout); controller.abort(); };
60
39
  }, [previewUrl, api]);
61
- const source = decoded?.url === previewUrl ? decoded : null;
62
- const checked = inspection?.url === previewUrl ? inspection : null;
63
- const error = checked?.error;
40
+ const source = stateForUrl({ state: decoded, url: previewUrl });
41
+ const checked = stateForUrl({ state: inspection, url: previewUrl });
64
42
  return {
65
- naturalWidth: source?.width, naturalHeight: source?.height,
66
- warning: shareImageWarning({ value, previewUrl, width: source?.width, height: source?.height, previewFailed: source?.failed, byteSize: checked?.byteSize,
67
- error: error ? interpolate({ template: t(error.key, error.vars), vars: error.vars ?? {} }) : undefined }, { t }),
43
+ naturalWidth: source.width, naturalHeight: source.height,
44
+ warning: shareImageWarning({ value, previewUrl, width: source.width, height: source.height, previewFailed: source.failed, byteSize: checked.byteSize,
45
+ error: translatedInspectionError({ error: checked.error }, { t }) }, { t }),
68
46
  onPreviewLoad: ({ currentTarget }) => {
69
- const url = currentTarget.getAttribute('src');
70
- if (url && url === currentUrl.current)
47
+ const url = currentPreviewSrc({ image: currentTarget, currentUrl: currentUrl.current });
48
+ if (url)
71
49
  setDecoded({ url, width: currentTarget.naturalWidth, height: currentTarget.naturalHeight });
72
50
  },
73
51
  onPreviewError: ({ currentTarget }) => {
74
- const url = currentTarget.getAttribute('src');
75
- if (url && url === currentUrl.current)
52
+ const url = currentPreviewSrc({ image: currentTarget, currentUrl: currentUrl.current });
53
+ if (url)
76
54
  setDecoded({ url, failed: true });
77
55
  },
78
56
  };
79
57
  }
58
+ /** URL-keyed state for the current preview only; a prior choice's state reads as nothing known. */
59
+ function stateForUrl({ state, url }, _optional = {}) {
60
+ return state?.url === url ? state : {};
61
+ }
62
+ /** Whether `url` resolves to the browser's own origin; `false` outside a browser or for an unparseable URL. */
63
+ function isBrowserSameOrigin({ url }, _optional = {}) {
64
+ // Browser origin, rather than the public site's alias: a cross-origin HEAD cannot establish
65
+ // that an otherwise loadable external image is missing when the server omits CORS headers.
66
+ const location = globalThis.location;
67
+ if (!location)
68
+ return false;
69
+ try {
70
+ return new URL(url, location.href).origin === location.origin;
71
+ }
72
+ catch {
73
+ return false;
74
+ }
75
+ }
76
+ /** A HEAD response as inspection state: 404/410 is a missing file, any other non-2xx an unchecked
77
+ * one, and a 2xx carries the reported byte size. */
78
+ function inspectionFromResponse({ url, status, byteSize }, _optional = {}) {
79
+ if (status === 404 || status === 410)
80
+ return { url, error: { key: 'The share image is missing (HTTP {status}).', vars: { status } } };
81
+ if (status < 200 || status >= 300)
82
+ return { url, error: { key: 'The share image could not be checked (HTTP {status}).', vars: { status } } };
83
+ return { url, byteSize };
84
+ }
85
+ /** The inspection error's copy, translated at render so a language switch re-renders it. */
86
+ function translatedInspectionError({ error }, { t }) {
87
+ return error ? interpolate({ template: t(error.key, error.vars), vars: error.vars ?? {} }) : undefined;
88
+ }
89
+ /** The preview image's `src` when it is still the current selection's; `null` for a stale event. */
90
+ function currentPreviewSrc({ image, currentUrl }, _optional = {}) {
91
+ const url = image.getAttribute('src');
92
+ return url && url === currentUrl ? url : null;
93
+ }
80
94
  //# sourceMappingURL=use-share-image-warning.hooks.js.map
@@ -96,7 +96,7 @@ export interface SeoEntrySectionProps {
96
96
  * and nothing else — the exact regression `deployment/HistoryTab.tsx`'s own comment records
97
97
  * measuring live on its tab. It is also not a duplicate: the tab reads "Pages & posts" (what you
98
98
  * pick from), the heading reads "Per-entry SEO" (what you are editing). */
99
- export declare function SeoEntrySection({ locale, useSeoEntrySectionHook }: SeoEntrySectionProps, _optional?: Record<string, never>): import("react").JSX.Element;
99
+ export declare function SeoEntrySection({ locale, useSeoEntrySectionHook }: SeoEntrySectionProps): import("react").JSX.Element;
100
100
  /** What the two site-wide tab panels need from `useWiredSeo()`. Named as one object rather than
101
101
  * passed as six loose props for the same reason `Roles.tsx`'s `RoleCreateFormController` documents:
102
102
  * none of these is meaningful without the others, so naming the group is what lets a test hand a
@@ -118,7 +118,7 @@ export declare function SeoEntrySection({ locale, useSeoEntrySectionHook }: SeoE
118
118
  */
119
119
  export declare function SeoDefaultsTab({ controller }: {
120
120
  controller: SeoTabController;
121
- }, _optional?: Record<string, never>): import("react").JSX.Element;
121
+ }): import("react").JSX.Element;
122
122
  /**
123
123
  * The "Sitemap" tab — the two sitemap actions, moved here verbatim, plus one line this screen never
124
124
  * had: whether a sitemap is being published at all.
@@ -138,7 +138,7 @@ export declare function SeoDefaultsTab({ controller }: {
138
138
  */
139
139
  export declare function SeoSitemapTab({ controller }: {
140
140
  controller: SeoTabController;
141
- }, _optional?: Record<string, never>): import("react").JSX.Element;
141
+ }): import("react").JSX.Element;
142
142
  /** Dispatches the one active tab's panel as a flat if-chain — a plain function rather than a
143
143
  * ternary written directly in `Seo`'s JSX, which would be counted against that component's own
144
144
  * complexity. Same split `Deployment.tsx`'s `deploymentTabPanel` and `Sites.tsx`'s `sitesTabPanel`
@@ -157,7 +157,7 @@ export interface SeoProps {
157
157
  tab: string;
158
158
  }, optional?: Record<string, never>) => void) | undefined;
159
159
  }
160
- export declare function Seo({ tabId, onTabChange, useSeoHook }?: SeoProps, _optional?: Record<string, never>): import("react").JSX.Element;
160
+ export declare function Seo({ tabId, onTabChange, useSeoHook }?: SeoProps): import("react").JSX.Element;
161
161
  export { Seo as SeoPage };
162
162
  export default Seo;
163
163
  //# sourceMappingURL=SeoPage.d.ts.map
@@ -1,4 +1,4 @@
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";
@@ -82,6 +82,9 @@ function AnalyzePanel(props) {
82
82
  // no complexity benefit and a real loss of "one form, one place to read its fields."
83
83
  // 2026-10-15: now 27 — the X fields' placeholders (the live social preview value) add two more
84
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.
85
88
  function SeoEntryPanel({ locale, entryId, useSeoEntryPanelHook = useWiredSeoEntryPanel }) {
86
89
  const t = useSeoCopy();
87
90
  const { resolved, analysis, loadError, saving, saveError, notice, fieldValue, setField, save, touched, ogImageInherited = false } = useSeoEntryPanelHook({ entryId });
@@ -89,7 +92,24 @@ function SeoEntryPanel({ locale, entryId, useSeoEntryPanelHook = useWiredSeoEntr
89
92
  return _jsx("div", { className: "jini-notice jini-notice-error", children: loadError });
90
93
  if (!resolved)
91
94
  return _jsx("div", { className: "jini-notice", children: t({ locale: locale, key: "Loading entry SEO…" }) });
92
- return (_jsxs("div", { className: "jini-seo-entry-panel", children: [_jsx("div", { className: "jini-seo-entry-head", children: _jsxs("div", { className: "jini-seo-entry-head-bar", children: [_jsx("h3", { children: t({ locale: locale, key: "Per-entry overrides" }) }), _jsx(InfoTip, { label: `${t({ locale: locale, key: "Fields show the currently-effective value (author override, or site default, or derived from the entry). Only fields you change here are saved as overrides." })} ${t({ locale: locale, key: "Empty a field and save to remove its override — the entry falls back to the site default." })}` }), _jsx("span", { className: "jini-seo-entry-head-actions", children: _jsx("button", { type: "button", className: "jini-btn", onClick: save, disabled: saving || Object.keys(touched).length === 0, ...agentHandle({ handle: "seo-entry-save-overrides" }, { role: "button", label: "Save this entry's SEO overrides" }), children: saving ? t({ locale: locale, key: "Saving…" }) : t({ locale: locale, key: "Save overrides" }) }) })] }) }), notice ? _jsx("div", { className: "jini-notice", children: notice }) : null, saveError ? (_jsx("div", { className: "jini-notice jini-notice-error", role: "alert", children: saveError })) : null, _jsxs("fieldset", { className: "jini-seo-entry-group", children: [_jsx("legend", { children: t({ locale: locale, key: "Search appearance" }) }), _jsxs("label", { children: [t({ locale: locale, key: "Title" }), _jsx("input", { className: "jini-input", value: fieldValue("title", resolved.title) ?? "", onChange: (e) => setField("title", e.target.value), ...agentHandle({ handle: "seo-entry-title" }, { role: "field", label: "This entry's SEO title override" }) })] }), _jsxs("label", { children: [t({ locale: locale, key: "Description" }), _jsx("textarea", { className: "jini-textarea", value: fieldValue("description", resolved.description ?? "") ?? "", onChange: (e) => setField("description", e.target.value), ...agentHandle({ handle: "seo-entry-description" }, { role: "field", label: "This entry's SEO description override" }) })] }), _jsxs("label", { children: [t({ locale: locale, key: "Canonical URL" }), _jsx("input", { className: "jini-input", value: fieldValue("canonical", resolved.canonical) ?? "", onChange: (e) => setField("canonical", e.target.value), ...agentHandle({ handle: "seo-entry-canonical" }, { role: "field", label: "This entry's canonical URL override" }) })] }), _jsxs("div", { className: "jini-seo-entry-checks", children: [_jsxs("label", { children: [_jsx("input", { className: "jini-input", type: "checkbox", checked: fieldValue("noindex", resolved.robots.noindex) ?? false, onChange: (e) => setField("noindex", e.target.checked), ...agentHandle({ handle: "seo-entry-noindex" }, { role: "checkbox", label: "Override this entry's robots noindex directive" }) }), t({ locale: locale, key: "Noindex" })] }), _jsxs("label", { children: [_jsx("input", { className: "jini-input", type: "checkbox", checked: fieldValue("nofollow", resolved.robots.nofollow) ?? false, onChange: (e) => setField("nofollow", e.target.checked), ...agentHandle({ handle: "seo-entry-nofollow" }, { role: "checkbox", label: "Override this entry's robots nofollow directive" }) }), t({ locale: locale, key: "Nofollow" })] })] })] }), _jsxs("fieldset", { className: "jini-seo-entry-group", children: [_jsxs("legend", { children: [t({ locale: locale, key: "Social preview" }), _jsx(InfoTip, { label: t({ locale: locale, key: "Used by Facebook, LinkedIn, Slack, Discord, iMessage, WhatsApp and X/Twitter" }) })] }), _jsxs("label", { children: [t({ locale: locale, key: "Preview title" }), _jsx("input", { className: "jini-input", value: fieldValue("ogTitle", resolved.openGraph.title) ?? "", onChange: (e) => setField("ogTitle", e.target.value), ...agentHandle({ handle: "seo-entry-og-title" }, { role: "field", label: "This entry's Open Graph title override" }) })] }), _jsxs("label", { children: [t({ locale: locale, key: "Preview description" }), _jsx("input", { className: "jini-input", value: fieldValue("ogDescription", resolved.openGraph.description ?? "") ?? "", onChange: (e) => setField("ogDescription", e.target.value), ...agentHandle({ handle: "seo-entry-og-description" }, { role: "field", label: "This entry's Open Graph description override" }) })] }), _jsx(MediaRefField, { locale: locale, id: "seo-entry-og-image", label: t({ locale: locale, key: "Preview image" }), value: fieldValue("ogImage", resolved.openGraph.imageRef ?? resolved.openGraph.image ?? "") ?? "", onChange: (value) => setField("ogImage", value), inherited: ogImageInherited, agentHandle: "seo-entry-og-image" }), _jsxs("details", { className: "jini-seo-x-disclosure", open: xIsCustomized({ meta: resolved }), children: [_jsx("summary", { children: t({ locale: locale, key: "Customize for X/Twitter (optional)" }) }), _jsxs("div", { className: "jini-seo-x-fields", children: [_jsxs("label", { children: [t({ locale: locale, key: "X/Twitter title" }), _jsx("input", { className: "jini-input", value: fieldValue("twitterTitle", xResolvedValue({ key: "twitterTitle", meta: resolved })) ?? "", placeholder: fieldValue("ogTitle", resolved.openGraph.title) ?? "", onChange: (e) => setField("twitterTitle", e.target.value), ...agentHandle({ handle: "seo-entry-twitter-title" }, { role: "field", label: "This entry's Twitter card title override" }) })] }), _jsxs("label", { children: [t({ locale: locale, key: "X/Twitter description" }), _jsx("input", { className: "jini-input", value: fieldValue("twitterDescription", xResolvedValue({ key: "twitterDescription", meta: resolved })) ?? "", placeholder: fieldValue("ogDescription", resolved.openGraph.description ?? "") ?? "", onChange: (e) => setField("twitterDescription", e.target.value), ...agentHandle({ handle: "seo-entry-twitter-description" }, { role: "field", label: "This entry's Twitter card description override" }) })] }), _jsx(MediaRefField, { locale: locale, id: "seo-entry-twitter-image", label: t({ locale: locale, key: "X/Twitter image" }), value: fieldValue("twitterImage", xResolvedValue({ key: "twitterImage", meta: resolved })) ?? "", emptyHint: t({ locale: locale, key: "Same as the preview image" }), onChange: (value) => setField("twitterImage", value), agentHandle: "seo-entry-twitter-image" })] })] })] }), analysis ? _jsx(AnalyzePanel, { locale: locale, analysis: analysis }) : null] }));
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" })] })] }) }));
93
113
  }
94
114
  /** Section wrapper (REQ-06/07) — entry picker over the per-entry edit + analyze panels. This IS the
95
115
  * "Pages & posts" tab panel; it kept its name and its markup, because nothing about it changed
@@ -101,7 +121,7 @@ function SeoEntryPanel({ locale, entryId, useSeoEntryPanelHook = useWiredSeoEntr
101
121
  * and nothing else — the exact regression `deployment/HistoryTab.tsx`'s own comment records
102
122
  * measuring live on its tab. It is also not a duplicate: the tab reads "Pages & posts" (what you
103
123
  * pick from), the heading reads "Per-entry SEO" (what you are editing). */
104
- export function SeoEntrySection({ locale, useSeoEntrySectionHook = useSeoEntrySection }, _optional = {}) {
124
+ export function SeoEntrySection({ locale, useSeoEntrySectionHook = useSeoEntrySection }) {
105
125
  const t = useSeoCopy();
106
126
  const { entryId, setEntryId } = useSeoEntrySectionHook();
107
127
  return (_jsx("div", { className: "jini-seo-panel jini-seo-panel--full jini-seo-entry-section", ...agentHandle({ handle: "seo-per-entry" }, {
@@ -128,7 +148,7 @@ export function SeoEntrySection({ locale, useSeoEntrySectionHook = useSeoEntrySe
128
148
  * inline `??`/`||` field reads — same fields, same fallbacks, same payload, just not written in the
129
149
  * `.tsx`.
130
150
  */
131
- export function SeoDefaultsTab({ controller }, _optional = {}) {
151
+ export function SeoDefaultsTab({ controller }) {
132
152
  const t = useSeoCopy();
133
153
  const { locale, settings, saving, defaultOgImage, setDefaultOgImage } = controller;
134
154
  const { submit } = useSeoDefaults({ save: controller.save });
@@ -175,7 +195,7 @@ export function SeoDefaultsTab({ controller }, _optional = {}) {
175
195
  * It also has to exist at all — a `role="tab"` button is not in a screen reader's heading list, so
176
196
  * a panel with no `<h2>` leaves that reader nothing but the page `H1`.
177
197
  */
178
- export function SeoSitemapTab({ controller }, _optional = {}) {
198
+ export function SeoSitemapTab({ controller }) {
179
199
  const t = useSeoCopy();
180
200
  const { locale, settings, saving, regenerateSitemap, openSitemapModal } = controller;
181
201
  return (_jsx("div", { className: "jini-seo-panel jini-seo-panel--full", ...agentHandle({ handle: "seo-sitemap" }, {
@@ -189,7 +209,7 @@ export function SeoSitemapTab({ controller }, _optional = {}) {
189
209
  label: "Open a modal showing the sitemap's URLs, or its raw XML",
190
210
  }), children: t({ locale: locale, key: "View sitemap" }) })] })] }) }));
191
211
  }
192
- export function Seo({ tabId, onTabChange, useSeoHook = useWiredSeo } = {}, _optional = {}) {
212
+ export function Seo({ tabId, onTabChange, useSeoHook = useWiredSeo } = {}) {
193
213
  const t = useSeoCopy();
194
214
  const { headerActions } = useSeoOptions();
195
215
  const { changeTab } = useSeoPageActions({ onTabChange });
package/dist/seo/rules.js CHANGED
@@ -159,10 +159,15 @@ function parseMediaRefAssetId(ref) {
159
159
  * URLs and incomplete input remain editable. @complexity O(n) in value length; no I/O.
160
160
  */
161
161
  export function isStoredMediaRef({ value }, _optional = {}) {
162
- const trimmed = typeof value === "string" ? value.trim() : "";
162
+ const trimmed = trimmedText(value);
163
163
  // A URL typed one character at a time reaches "https:/" before it reaches "https://".
164
164
  return !trimmed.includes(":/") && parseMediaRefAssetId(trimmed) !== null;
165
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
+ }
166
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;
167
172
  /** What an image field shows for its value — the file's name and pixel size — in place of the raw
168
173
  * `{slug}:public` reference or URL (owner, 2026-10-15: a read-only "Selected image" box "looks
@@ -173,14 +178,25 @@ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{
173
178
  * @complexity O(m) over the media rows; no I/O.
174
179
  */
175
180
  export function describeMediaRef({ value, media }, { width, height, t = key => key, extensionFor } = {}) {
176
- const trimmed = typeof value === 'string' ? value.trim() : '';
181
+ const trimmed = trimmedText(value);
177
182
  if (!trimmed)
178
183
  return null;
179
184
  const key = parseMediaRefAssetId(trimmed);
180
- const row = key ? media?.find(item => item.id === key || item.slug === key) : undefined;
181
- const name = row?.title ? withExtension({ name: row.title, extension: row.contentType && extensionFor ? extensionFor(row.contentType) : '' }) : fallbackMediaName({ key, value: trimmed, t });
185
+ const row = findMediaRow({ key, media });
186
+ const name = libraryFileName({ row }, { extensionFor }) ?? fallbackMediaName({ key, value: trimmed, t });
182
187
  return { name, size: pixelSize(row?.width ?? width, row?.height ?? height) };
183
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
+ }
184
200
  /** A library title reads as a file name (owner, 2026-10-15: "it should be testing-page.png or
185
201
  * whatever the extension is"). No original upload name is stored, so the extension comes from the
186
202
  * stored bytes' type; a title that already ends in it is left alone. */
@@ -249,7 +265,7 @@ export function buildMediaRef({ item }, _optional = {}) {
249
265
  * @complexity O(1) — string parsing only, no I/O (the returned URL is a template; the browser
250
266
  * performs the actual fetch only once it is used as an `<img src>`). */
251
267
  export function resolveMediaRefPreviewUrl({ value, mediaOriginalUrl }, { siteOrigin } = {}) {
252
- const trimmed = typeof value === "string" ? value.trim() : "";
268
+ const trimmed = trimmedText(value);
253
269
  if (!trimmed)
254
270
  return null;
255
271
  if (isAbsoluteMediaRef(trimmed)) {
@@ -280,14 +296,26 @@ function localRenditionAssetKey({ value, siteOrigin }, _optional = {}) {
280
296
  * @complexity O(n) in value/error length; fixed-count checks, no I/O.
281
297
  */
282
298
  export function shareImageWarning({ value, previewUrl, width, height, byteSize, error, previewFailed }, { t = key => key } = {}) {
283
- 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))
284
309
  return t('No share image is selected. Link previews may show no picture.');
285
310
  if (!previewUrl)
286
311
  return t('The share image reference is invalid. Choose an image or enter an absolute image URL.');
287
312
  if (error)
288
313
  return error;
289
- if (previewFailed)
290
- 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 }) {
291
319
  const reasons = [];
292
320
  if (width !== undefined && width < 1200)
293
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 } }));
@@ -295,7 +323,7 @@ export function shareImageWarning({ value, previewUrl, width, height, byteSize,
295
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 } }));
296
324
  if (byteSize !== undefined && byteSize !== null && byteSize < 5120)
297
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 } }));
298
- return reasons.length ? reasons.join(' ') : null;
326
+ return reasons;
299
327
  }
300
328
  /** One `FormData` field as a string, with the caller's fallback for an absent field. Its own named
301
329
  * function purely so {@link buildSeoSettingsPatch} below stays readable at a glance: written
@@ -380,7 +408,8 @@ export function entryPickerOptions({ posts, pages }, { t = key => key } = {}) {
380
408
  return entries.map((entry) => ({
381
409
  value: entry.id, label: labelOf(entry), group,
382
410
  ...(entry.status && entry.status !== 'published' ? { badge: t(entry.status) } : {}),
383
- ...((counts.get(labelOf(entry)) ?? 0) > 1 ? { detail: entry.slug || entry.id } : {}),
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 } : {}),
384
413
  }));
385
414
  };
386
415
  return [...rows(pages, t('Pages')), ...rows(posts, t('Posts'))];
@@ -1,47 +1,77 @@
1
- /** Creates/deletes named test credentials: run only on a disposable authorized backend.
2
- * Supply connectionFields for providers with required fields beyond the canonical token. */
3
- export async function runSourceControlApiConformance({ api, providerId }, { connectionFields = {} } = {}) {
4
- const checks = [], ids = [];
5
- const assert = (ok, name) => { if (!ok)
6
- throw new Error(`Source control conformance: ${name}`); checks.push(name); };
7
- async function rejects(run, name) { let rejected = false; try {
8
- await run();
1
+ /** Records `name` as passed, or throws naming the provider and the failed check. */
2
+ function pass({ run, ok, name }) {
3
+ if (!ok)
4
+ throw new Error(`Source control conformance failed for provider "${run.providerId}": ${name}`);
5
+ run.checks.push(name);
6
+ }
7
+ /** Passes `name` only when `attempt` rejects; the rejection reason itself is irrelevant to the contract. */
8
+ async function refuses({ run, attempt, name }) {
9
+ let rejected = false;
10
+ try {
11
+ await attempt();
9
12
  }
10
13
  catch {
11
14
  rejected = true;
12
- } assert(rejected, name); }
13
- const connection = { ...connectionFields, providerId, token: 'conformance-only-token' };
15
+ }
16
+ pass({ run, ok: rejected, name });
17
+ }
18
+ async function checkCreate({ run }) {
19
+ const { api, providerId, connection } = run;
20
+ pass({ run, ok: (await api.providers({})).some(p => p.id === providerId), name: 'provider is listed' });
21
+ const first = await api.create({ label: 'conformance-first', connection });
22
+ run.ids.push(first.id);
23
+ pass({ run, ok: first.configured && !!first.id, name: 'create returns configured summary' });
24
+ pass({ run, ok: !('token' in first) && !('connection' in first), name: 'saved secrets never read back' });
25
+ pass({ run, ok: (await api.list({})).some(r => r.id === first.id), name: 'list contains created row' });
26
+ await refuses({ run, attempt: () => api.create({ label: 'conformance-first', connection }), name: 'duplicate label rejects' });
27
+ return first;
28
+ }
29
+ async function checkDefaultSlot({ run }) {
30
+ const { api, providerId, connection } = run;
31
+ const second = await api.create({ label: 'conformance-second', connection, isDefault: true });
32
+ run.ids.push(second.id);
33
+ const list = await api.list({});
34
+ pass({ run, ok: list.filter(r => r.providerId === providerId && r.isDefault).length === 1 && list.find(r => r.id === second.id)?.isDefault === true, name: 'server owns unique default slot' });
35
+ }
36
+ async function checkUpdates({ run, first }) {
37
+ const { api, providerId, connection } = run;
38
+ const renamed = await api.update({ id: first.id, patch: { label: 'conformance-renamed' } });
39
+ pass({ run, ok: renamed.label === 'conformance-renamed' && renamed.configured, name: 'metadata-only update preserves configured credential' });
40
+ pass({ run, ok: first.label === 'conformance-first', name: 'prior snapshots remain immutable' });
41
+ const replaced = await api.update({ id: first.id, patch: { connection } });
42
+ pass({ run, ok: replaced.id === first.id && replaced.label === renamed.label, name: 'replacement retains identity and label' });
43
+ await refuses({ run, attempt: () => api.update({ id: first.id, patch: { connection: { providerId, token: '' } } }), name: 'blank replacement rejects' });
44
+ return renamed;
45
+ }
46
+ async function checkAborts({ run, first, renamed }) {
47
+ const { api } = run;
48
+ const abort = new AbortController();
49
+ abort.abort();
50
+ await refuses({ run, attempt: () => api.list({}, { signal: abort.signal }), name: 'aborted list rejects' });
51
+ await refuses({ run, attempt: () => api.update({ id: first.id, patch: { label: 'bad' } }, { signal: abort.signal }), name: 'aborted write rejects' });
52
+ pass({ run, ok: (await api.list({})).find(r => r.id === first.id)?.label === renamed.label, name: 'aborted write has no effects' });
53
+ }
54
+ async function checkRemove({ run, first }) {
55
+ const { api } = run;
56
+ await api.remove({ id: first.id });
57
+ await api.remove({ id: first.id });
58
+ pass({ run, ok: !(await api.list({})).some(r => r.id === first.id), name: 'remove is idempotent' });
59
+ await refuses({ run, attempt: () => api.update({ id: first.id, patch: {} }), name: 'missing update rejects' });
60
+ }
61
+ /** Creates/deletes named test credentials: run only on a disposable authorized backend.
62
+ * Supply connectionFields for providers with required fields beyond the canonical token. */
63
+ export async function runSourceControlApiConformance({ api, providerId }, { connectionFields = {} } = {}) {
64
+ const run = { api, providerId, connection: { ...connectionFields, providerId, token: 'conformance-only-token' }, ids: [], checks: [] };
14
65
  try {
15
- assert((await api.providers({})).some(p => p.id === providerId), 'provider is listed');
16
- const first = await api.create({ label: 'conformance-first', connection });
17
- ids.push(first.id);
18
- assert(first.configured && !!first.id, 'create returns configured summary');
19
- assert(!('token' in first) && !('connection' in first), 'saved secrets never read back');
20
- assert((await api.list({})).some(r => r.id === first.id), 'list contains created row');
21
- await rejects(() => api.create({ label: 'conformance-first', connection }), 'duplicate label rejects');
22
- const second = await api.create({ label: 'conformance-second', connection, isDefault: true });
23
- ids.push(second.id);
24
- const list = await api.list({});
25
- assert(list.filter(r => r.providerId === providerId && r.isDefault).length === 1 && list.find(r => r.id === second.id)?.isDefault === true, 'server owns unique default slot');
26
- const renamed = await api.update({ id: first.id, patch: { label: 'conformance-renamed' } });
27
- assert(renamed.label === 'conformance-renamed' && renamed.configured, 'metadata-only update preserves configured credential');
28
- assert(first.label === 'conformance-first', 'prior snapshots remain immutable');
29
- const replaced = await api.update({ id: first.id, patch: { connection } });
30
- assert(replaced.id === first.id && replaced.label === renamed.label, 'replacement retains identity and label');
31
- await rejects(() => api.update({ id: first.id, patch: { connection: { providerId, token: '' } } }), 'blank replacement rejects');
32
- const abort = new AbortController();
33
- abort.abort();
34
- await rejects(() => api.list({}, { signal: abort.signal }), 'aborted list rejects');
35
- await rejects(() => api.update({ id: first.id, patch: { label: 'bad' } }, { signal: abort.signal }), 'aborted write rejects');
36
- assert((await api.list({})).find(r => r.id === first.id)?.label === renamed.label, 'aborted write has no effects');
37
- await api.remove({ id: first.id });
38
- await api.remove({ id: first.id });
39
- assert(!(await api.list({})).some(r => r.id === first.id), 'remove is idempotent');
40
- await rejects(() => api.update({ id: first.id, patch: {} }), 'missing update rejects');
41
- return Object.freeze(checks);
66
+ const first = await checkCreate({ run });
67
+ await checkDefaultSlot({ run });
68
+ const renamed = await checkUpdates({ run, first });
69
+ await checkAborts({ run, first, renamed });
70
+ await checkRemove({ run, first });
71
+ return Object.freeze(run.checks);
42
72
  }
43
73
  finally {
44
- for (const id of ids)
74
+ for (const id of run.ids)
45
75
  await api.remove({ id });
46
76
  }
47
77
  }
@@ -1,5 +1,16 @@
1
1
  import { createControllerStore } from '../../core/module/controller-store.js';
2
2
  import { blankSourceControlDraft, sourceControlProviders, defaultSourceControlCredential, sourceControlReady, buildSourceControlConnection, sourceControlSummary, sourceControlErrorMessage } from '../rules.js';
3
+ /** The provider and draft a save would submit, or null when the loaded state cannot accept a save for `providerId`. */
4
+ function readySaveTarget({ state, providerId }) {
5
+ const draft = state.drafts[providerId] ?? blankSourceControlDraft({});
6
+ const info = sourceControlProviders({ providers: state.providers, credentials: state.credentials }).find(p => p.id === providerId);
7
+ return state.loaded && !draft.saving && info && sourceControlReady({ info, draft }) ? { info, draft } : null;
8
+ }
9
+ /** Replaces the row with `summary`'s id in place, or appends `summary` when it is new. */
10
+ function spliceCredential({ credentials, summary }) {
11
+ // Preserve server order: the first row is the defensive default when no row is marked default.
12
+ return Object.freeze(credentials.some(c => c.id === summary.id) ? credentials.map(c => c.id === summary.id ? summary : c) : [...credentials, summary]);
13
+ }
3
14
  export function createSourceControlController({ api, permissions = [] }, _optional = {}) {
4
15
  const grants = new Set(permissions), store = createControllerStore({ initial: { providers: [], credentials: [], drafts: {}, loading: false, loaded: false, error: null, catalogError: null } });
5
16
  let generation = 0, credentialRevision = 0;
@@ -35,22 +46,23 @@ export function createSourceControlController({ api, permissions = [] }, _option
35
46
  if (ticket === generation)
36
47
  store.set({ patch: { loading: false } });
37
48
  }
49
+ /** Replaces the provider's current default credential, or creates one labelled 'default'. */
50
+ function writeCredential({ current, connection }) {
51
+ return current ? api.update({ id: current.id, patch: { connection } }, { signal: store.signal }) : api.create({ label: 'default', connection }, { signal: store.signal });
52
+ }
38
53
  async function save({ providerId }, _optional = {}) {
39
- const state = store.getSnapshot(), draft = state.drafts[providerId] ?? blankSourceControlDraft({});
40
- const info = sourceControlProviders({ providers: state.providers, credentials: state.credentials }).find(p => p.id === providerId);
41
- if (!canWrite() || !state.loaded || draft.saving || !info || !sourceControlReady({ info, draft }))
54
+ const state = store.getSnapshot(), target = canWrite() ? readySaveTarget({ state, providerId }) : null;
55
+ if (!target)
42
56
  return false;
43
- const current = defaultSourceControlCredential({ credentials: state.credentials, providerId }), connection = buildSourceControlConnection({ info, draft });
57
+ const current = defaultSourceControlCredential({ credentials: state.credentials, providerId }), connection = buildSourceControlConnection(target);
44
58
  patchDraft(providerId, { saving: true, error: null });
45
59
  try {
46
- const row = current ? await api.update({ id: current.id, patch: { connection } }, { signal: store.signal }) : await api.create({ label: 'default', connection }, { signal: store.signal });
60
+ const row = await writeCredential({ current, connection });
47
61
  if (store.signal.aborted)
48
62
  return false;
49
63
  credentialRevision++;
50
64
  // One round trip per write: splice the summary, then erase accepted secret drafts immediately.
51
- const credentials = store.getSnapshot().credentials, summary = sourceControlSummary({ row });
52
- // Preserve server order: the first row is the defensive default when no row is marked default.
53
- store.set({ patch: { credentials: Object.freeze(credentials.some(c => c.id === row.id) ? credentials.map(c => c.id === row.id ? summary : c) : [...credentials, summary]) } });
65
+ store.set({ patch: { credentials: spliceCredential({ credentials: store.getSnapshot().credentials, summary: sourceControlSummary({ row }) }) } });
54
66
  patchDraft(providerId, blankSourceControlDraft({}));
55
67
  return true;
56
68
  }
@@ -1,4 +1,5 @@
1
1
  import type { TabViewProps } from '../../../core/react/bind-react.js';
2
+ import type { SourceControlCredential } from '../../models.js';
2
3
  export declare function useProvidersTab({ permissions }: TabViewProps, _optional?: {}): {
3
4
  denied: boolean;
4
5
  loading: boolean;
@@ -6,7 +7,7 @@ export declare function useProvidersTab({ permissions }: TabViewProps, _optional
6
7
  id: string;
7
8
  label: string;
8
9
  heading: string;
9
- saved: import("../../models.js").SourceControlCredential | undefined;
10
+ saved: SourceControlCredential | undefined;
10
11
  defaultOpen: boolean;
11
12
  canEdit: boolean;
12
13
  unlisted: boolean;
@@ -2,19 +2,26 @@ import { useController } from '../../../core/react/use-controller.js';
2
2
  import { createSourceControlController } from '../../controllers/source-control.controller.js';
3
3
  import { blankSourceControlDraft, sourceControlProviders, defaultSourceControlCredential, sourceControlReady, safeSourceControlUrl } from '../../rules.js';
4
4
  import { useSourceControlPorts } from './SourceControlPorts.hooks.js';
5
+ /** Stand-in snapshot until the controller mounts: nothing listed yet, still loading. */
6
+ const UNMOUNTED = { providers: [], credentials: [], drafts: {}, loading: true, loaded: false, error: null, catalogError: null };
7
+ function fieldView({ info, field, draft }, { controller }) {
8
+ return { name: field.name, label: `${info.label} ${field.label}`, value: draft.values[field.name] ?? '', secret: !!field.secret, required: !!field.required, hint: field.userHelp ?? field.hint ?? field.help, onValueChange({ value }) { controller?.setField({ providerId: info.id, name: field.name, value }); } };
9
+ }
10
+ function rowView({ info, context }, { controller }) {
11
+ const { credentials, drafts, first, canWrite } = context;
12
+ const saved = defaultSourceControlCredential({ credentials, providerId: info.id }), draft = drafts[info.id] ?? blankSourceControlDraft({}), tokenPageUrl = safeSourceControlUrl({ url: info.tokenPageUrl });
13
+ return { id: info.id, label: info.label, heading: saved ? info.label : `Connect ${info.label}`, saved, defaultOpen: info.id === first, canEdit: canWrite && info.listed, unlisted: !info.listed, connected: !!saved,
14
+ token: draft.token, tokenLabel: `${info.label} ${info.tokenLabel}`, hint: saved ? 'Leave blank to keep the current token.' : 'Once saved, the token is never displayed again.', saving: draft.saving, error: draft.error,
15
+ disabled: !canWrite || !sourceControlReady({ info, draft }), help: info.help, tokenPageUrl, hasGuidance: !!info.help || !!tokenPageUrl,
16
+ fields: info.fields.map(field => fieldView({ info, field, draft }, { controller })),
17
+ onToken({ value }) { controller?.setToken({ providerId: info.id, value }); }, save() { void controller?.save({ providerId: info.id }); }, };
18
+ }
5
19
  export function useProvidersTab({ permissions = [] }, _optional = {}) {
6
20
  const ports = useSourceControlPorts({}), key = permissions.join('\0'), canRead = permissions.includes('source-control.read'), canWrite = canRead && permissions.includes('source-control.credentials.write');
7
- const { controller, snapshot: state } = useController({ create: () => createSourceControlController({ api: ports.sourceControlApi, permissions }), dependencies: [ports.sourceControlApi, key] }, { start: ({ controller }) => { void controller.load({}); } });
8
- const credentials = state?.credentials ?? [], infos = sourceControlProviders({ providers: state?.providers ?? [], credentials });
21
+ const { controller, snapshot } = useController({ create: () => createSourceControlController({ api: ports.sourceControlApi, permissions }), dependencies: [ports.sourceControlApi, key] }, { start: ({ controller }) => { void controller.load({}); } });
22
+ const state = snapshot ?? UNMOUNTED, credentials = state.credentials, infos = sourceControlProviders({ providers: state.providers, credentials });
9
23
  const first = infos.find(p => p.listed && !defaultSourceControlCredential({ credentials, providerId: p.id }))?.id;
10
- const rows = infos.map(info => {
11
- const saved = defaultSourceControlCredential({ credentials, providerId: info.id }), draft = state?.drafts[info.id] ?? blankSourceControlDraft({});
12
- return { id: info.id, label: info.label, heading: saved ? info.label : `Connect ${info.label}`, saved, defaultOpen: info.id === first, canEdit: canWrite && info.listed, unlisted: !info.listed, connected: !!saved,
13
- token: draft.token, tokenLabel: `${info.label} ${info.tokenLabel}`, hint: saved ? 'Leave blank to keep the current token.' : 'Once saved, the token is never displayed again.', saving: draft.saving, error: draft.error,
14
- disabled: !canWrite || !sourceControlReady({ info, draft }), help: info.help, tokenPageUrl: safeSourceControlUrl({ url: info.tokenPageUrl }), hasGuidance: !!info.help || !!safeSourceControlUrl({ url: info.tokenPageUrl }),
15
- fields: info.fields.map(field => ({ name: field.name, label: `${info.label} ${field.label}`, value: draft.values[field.name] ?? '', secret: !!field.secret, required: !!field.required, hint: field.userHelp ?? field.hint ?? field.help, onValueChange({ value }) { controller?.setField({ providerId: info.id, name: field.name, value }); } })),
16
- onToken({ value }) { controller?.setToken({ providerId: info.id, value }); }, save() { void controller?.save({ providerId: info.id }); }, };
17
- });
18
- return { denied: !canRead, loading: !state || state.loading, rows, errors: [state?.error, state?.catalogError].filter((s) => !!s), reload() { void controller?.load({}); }, canManage: !!ports.sourceControlNavigation, manage() { ports.sourceControlNavigation?.open({ kind: 'source-control' }); } };
24
+ const rows = infos.map(info => rowView({ info, context: { credentials, drafts: state.drafts, first, canWrite } }, { controller }));
25
+ return { denied: !canRead, loading: state.loading, rows, errors: [state.error, state.catalogError].filter((s) => !!s), reload() { void controller?.load({}); }, canManage: !!ports.sourceControlNavigation, manage() { ports.sourceControlNavigation?.open({ kind: 'source-control' }); } };
19
26
  }
20
27
  //# sourceMappingURL=ProvidersTab.hooks.js.map