@jini-ai/admin 0.4.12 → 0.6.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +70 -2
- package/dist/agent-plugins/controllers/agent-plugin-files.controller.js +14 -9
- package/dist/agent-plugins/controllers/agent-plugins.controller.js +12 -9
- package/dist/agent-plugins/package-file-tree.js +54 -51
- package/dist/agent-plugins/react/components/AgentPluginRow.d.ts +1 -1
- package/dist/agent-plugins/react/components/AgentPluginRow.js +9 -2
- package/dist/agent-plugins/react/hooks/AgentPluginInspector.hooks.d.ts +5 -5
- package/dist/agent-plugins/react/hooks/AgentPluginInspector.hooks.js +14 -5
- package/dist/agent-plugins/react/hooks/AgentPluginPanel.hooks.d.ts +15 -13
- package/dist/agent-plugins/react/hooks/AgentPluginPanel.hooks.js +54 -40
- package/dist/agent-plugins/react/hooks/AgentPluginsPage.hooks.d.ts +13 -13
- package/dist/agent-plugins/react/hooks/AgentPluginsPage.hooks.js +23 -11
- package/dist/browser/navigation.js +44 -27
- package/dist/comments/adapters/memory.js +30 -12
- package/dist/comments/conformance/comments-api.conformance.js +55 -33
- package/dist/comments/controllers/comment-queue.controller.js +13 -4
- package/dist/comments/react/hooks/use-comment-queue.hooks.d.ts +17 -2
- package/dist/comments/react/hooks/use-comment-queue.hooks.js +79 -30
- package/dist/comments/react/hooks/use-comment-settings.hooks.js +9 -5
- package/dist/comments/react/index.d.ts +2 -1
- package/dist/comments/react/index.js +1 -1
- package/dist/consent/react/ConsentFields.d.ts +35 -0
- package/dist/consent/react/ConsentFields.js +76 -0
- package/dist/consent/react/ConsentPreviewFrame.d.ts +14 -0
- package/dist/consent/react/ConsentPreviewFrame.js +14 -0
- package/dist/consent/react/ConsentSettingsPanel.d.ts +27 -4
- package/dist/consent/react/ConsentSettingsPanel.js +6 -17
- package/dist/consent/react/index.d.ts +6 -1
- package/dist/consent/react/index.js +2 -0
- package/dist/consent/react/settings-panel.hooks.d.ts +28 -1
- package/dist/consent/react/settings-panel.hooks.js +26 -0
- package/dist/core/entities/rules.js +126 -81
- package/dist/core/manifest/rules.js +10 -10
- package/dist/core/module/create-admin.js +195 -117
- package/dist/core/ports/redirects.d.ts +8 -6
- package/dist/core/ports/redirects.js +8 -6
- package/dist/core/routing/rules.js +9 -4
- package/dist/core/transport/http.js +37 -20
- package/dist/forms/adapters/memory.js +22 -11
- package/dist/forms/conformance/forms-api.conformance.d.ts +6 -4
- package/dist/forms/conformance/forms-api.conformance.js +42 -46
- package/dist/forms/field-settings.js +150 -115
- package/dist/forms/html-rules.js +45 -28
- package/dist/forms/react/components/FieldSettingsRows.d.ts +22 -0
- package/dist/forms/react/components/FieldSettingsRows.js +29 -0
- package/dist/forms/react/components/FieldSettingsSection.d.ts +1 -1
- package/dist/forms/react/components/FieldSettingsSection.js +5 -6
- package/dist/forms/react/components/FormEditorFieldsBody.d.ts +1 -1
- package/dist/forms/react/components/FormEditorFieldsBody.js +4 -15
- package/dist/forms/react/components/FormEditorFieldsBodyParts.d.ts +18 -0
- package/dist/forms/react/components/FormEditorFieldsBodyParts.js +32 -0
- package/dist/forms/react/components/FormSubmissions.d.ts +1 -1
- package/dist/forms/react/components/FormSubmissions.js +4 -20
- package/dist/forms/react/components/FormSubmissionsTable.d.ts +19 -0
- package/dist/forms/react/components/FormSubmissionsTable.js +22 -0
- package/dist/media/adapters/memory.js +31 -26
- package/dist/media/controllers/edit-media.controller.js +2 -1
- package/dist/media/controllers/library.controller.js +48 -35
- package/dist/media/controllers/providers.controller.js +2 -1
- package/dist/media/html-attributes.js +29 -15
- package/dist/media/index.d.ts +1 -1
- package/dist/media/index.js +1 -1
- package/dist/media/react/components/MediaIcons.d.ts +0 -3
- package/dist/media/react/components/MediaIcons.js +0 -9
- package/dist/media/react/components/MediaLightbox.d.ts +2 -1
- package/dist/media/react/components/MediaLightbox.js +11 -5
- package/dist/media/react/hooks/LibraryTab.hooks.d.ts +55 -45
- package/dist/media/react/hooks/LibraryTab.hooks.js +107 -70
- package/dist/media/react/hooks/MediaCard.hooks.d.ts +27 -31
- package/dist/media/react/hooks/MediaCard.hooks.js +52 -26
- package/dist/media/react/hooks/MediaGrid.hooks.d.ts +1 -1
- package/dist/media/react/hooks/MediaLightbox.hooks.d.ts +10 -14
- package/dist/media/react/hooks/MediaLightbox.hooks.js +9 -56
- package/dist/media/react/hooks/MediaPage.hooks.d.ts +6 -0
- package/dist/media/react/hooks/MediaPage.hooks.js +29 -12
- package/dist/media/react/hooks/ProvidersTab.hooks.d.ts +33 -33
- package/dist/media/react/hooks/ProvidersTab.hooks.js +119 -79
- package/dist/media/react/hooks/UploadButton.hooks.d.ts +7 -5
- package/dist/media/react/hooks/UploadButton.hooks.js +36 -24
- package/dist/media/react/index.js +4 -0
- package/dist/media/react/tabs/LibraryTab.d.ts +1 -1
- package/dist/media/react/tabs/LibraryTab.js +2 -2
- package/dist/media/rules.d.ts +7 -0
- package/dist/media/rules.js +25 -2
- package/dist/react/components/ConfirmDialog/ConfirmDialog.js +5 -8
- package/dist/react/components/ConfirmDialog/confirm-dialog-attrs.d.ts +20 -0
- package/dist/react/components/ConfirmDialog/confirm-dialog-attrs.js +24 -0
- package/dist/react/components/InteractiveHtmlEditor/InteractiveHtmlEditor.js +16 -7
- package/dist/react/components/RowMenu/RowMenu.js +16 -10
- package/dist/react/components/Sidebar.js +32 -26
- package/dist/react/entities/EntityEdit.js +17 -14
- package/dist/react/entities/EntityField.d.ts +5 -2
- package/dist/react/entities/EntityField.js +38 -23
- package/dist/react/entities/EntityList.js +6 -5
- package/dist/react/entities/RelationTruncationNotice.d.ts +9 -0
- package/dist/react/entities/RelationTruncationNotice.js +9 -0
- package/dist/react/entities/use-entity-edit.d.ts +4 -2
- package/dist/react/entities/use-entity-edit.js +52 -33
- package/dist/react/shell/AdminShell.js +40 -23
- package/dist/react/shell/model.d.ts +4 -1
- package/dist/react/shell/model.js +3 -2
- package/dist/react/shell/use-admin-shell-session.js +25 -26
- package/dist/redirects/adapters/memory.js +9 -2
- package/dist/redirects/conformance/redirects-api.conformance.js +66 -36
- package/dist/redirects/react/hooks/use-import-redirects-form.hooks.d.ts +1 -1
- package/dist/redirects/react/hooks/use-import-redirects-form.hooks.js +10 -3
- package/dist/redirects/react/hooks/wired.hooks.d.ts +1 -1
- package/dist/security/adapters/http.js +25 -10
- package/dist/security/adapters/memory.js +19 -5
- package/dist/security/controllers/access-tokens.controller.js +27 -26
- package/dist/security/controllers/other-credentials.controller.js +4 -2
- package/dist/security/controllers/root-key.controller.js +17 -9
- package/dist/security/react/hooks/AccessTokensTab.hooks.d.ts +40 -38
- package/dist/security/react/hooks/AccessTokensTab.hooks.js +36 -9
- package/dist/security/react/hooks/RootKeyTab.hooks.d.ts +14 -14
- package/dist/security/react/hooks/RootKeyTab.hooks.js +12 -3
- package/dist/security/react/tabs/RootKeyTab.d.ts +1 -1
- package/dist/security/react/tabs/RootKeyTab.js +12 -3
- package/dist/security/rules.js +45 -25
- package/dist/seo/controllers/seo.controller.js +4 -2
- package/dist/seo/react/components/MediaRefField.d.ts +1 -1
- package/dist/seo/react/components/MediaRefField.js +19 -2
- package/dist/seo/react/hooks/MediaRefField.hooks.js +3 -1
- package/dist/seo/react/hooks/SeoPage.hooks.d.ts +1 -1
- package/dist/seo/react/hooks/use-share-image-warning.hooks.js +48 -34
- package/dist/seo/react/pages/SeoPage.d.ts +4 -4
- package/dist/seo/react/pages/SeoPage.js +26 -6
- package/dist/seo/rules.js +39 -10
- package/dist/source-control/conformance/source-control-api.conformance.js +68 -38
- package/dist/source-control/controllers/source-control.controller.js +20 -8
- package/dist/source-control/react/hooks/ProvidersTab.hooks.d.ts +2 -1
- package/dist/source-control/react/hooks/ProvidersTab.hooks.js +18 -11
- package/dist/widgets/conformance/widgets-api.conformance.js +15 -1
- package/dist/widgets/controllers/instance-editor.controller.js +33 -24
- package/dist/widgets/controllers/library.controller.js +18 -12
- package/dist/widgets/controllers/region-editor.controller.js +6 -5
- package/dist/widgets/react/hooks/page-views.hooks.d.ts +1 -0
- package/dist/widgets/react/hooks/page-views.hooks.js +3 -2
- package/dist/widgets/react/hooks/use-widget-regions.hooks.d.ts +1 -1
- package/dist/widgets/react/hooks/use-widget-regions.hooks.js +9 -3
- package/dist/widgets/react/hooks/use-widgets-library.hooks.js +9 -2
- package/dist/widgets/react/pages/WidgetInstanceEditor.d.ts +1 -1
- package/dist/widgets/react/pages/WidgetInstanceEditor.js +7 -8
- package/dist/widgets/rules.d.ts +14 -2
- package/dist/widgets/rules.js +9 -6
- package/package.json +23 -14
- package/src/themes/default/admin.css +51 -0
- package/src/themes/default/chat.css +21 -0
- package/src/themes/default/manifest.json +29 -0
- package/dist/media/styles.d.ts +0 -4
- package/dist/media/styles.js +0 -5
|
@@ -48,5 +48,5 @@ export interface MediaRefFieldProps {
|
|
|
48
48
|
* `useDialog` prop. Defaults to the real {@link useWiredMediaRefField}. */
|
|
49
49
|
useField?: typeof useWiredMediaRefField;
|
|
50
50
|
}
|
|
51
|
-
export declare function MediaRefField({ locale, id, name, label, value, onChange, agentHandle: base, emptyHint, inherited, useField, }: MediaRefFieldProps
|
|
51
|
+
export declare function MediaRefField({ locale, id, name, label, value, onChange, agentHandle: base, emptyHint, inherited, useField, }: MediaRefFieldProps): import("react").JSX.Element;
|
|
52
52
|
//# sourceMappingURL=MediaRefField.d.ts.map
|
|
@@ -4,7 +4,7 @@ import { interpolate } from "@jini-ai/ui/i18n";
|
|
|
4
4
|
import { MediaPickerBridge } from "./MediaPickerBridge.js";
|
|
5
5
|
import { useSeoCopy } from "../hooks/SeoPorts.hooks.js";
|
|
6
6
|
import { useWiredMediaRefField, useMediaRefPresentation, handleSpread } from "../hooks/MediaRefField.hooks.js";
|
|
7
|
-
export function MediaRefField({ locale, id, name, label, value, onChange, agentHandle: base, emptyHint, inherited = false, useField = useWiredMediaRefField, }
|
|
7
|
+
export function MediaRefField({ locale, id, name, label, value, onChange, agentHandle: base, emptyHint, inherited = false, useField = useWiredMediaRefField, }) {
|
|
8
8
|
const t = useSeoCopy();
|
|
9
9
|
const { Picker = MediaPickerBridge } = useMediaRefPresentation({ value });
|
|
10
10
|
const { pickerOpen, openPicker, closePicker, handleSelect, clear, previewUrl, file, accept, onPreviewLoad, onPreviewError } = useField({ value, onChange });
|
|
@@ -17,6 +17,23 @@ export function MediaRefField({ locale, id, name, label, value, onChange, agentH
|
|
|
17
17
|
2026-10-15 (round 3): the text box is gone — for a library pick it only ever said a read-only
|
|
18
18
|
"Selected image" ("i can't change it and looks useless"), so the row names the file instead and
|
|
19
19
|
the picker is the one way in. The exact value still rides a hidden input for FormData saves. */
|
|
20
|
-
return (_jsxs("div", { className: "jini-field jini-media-ref-field", children: [_jsx("span", { className: "jini-field-label", id: labelId, children: label }), _jsxs("div", { className: "jini-media-ref-field-row", role: "group", "aria-labelledby": labelId, ...(base ? agentHandle({ handle: base }, { role: "region", label }) : {}), children: [
|
|
20
|
+
return (_jsxs("div", { className: "jini-field jini-media-ref-field", children: [_jsx("span", { className: "jini-field-label", id: labelId, children: label }), _jsxs("div", { className: "jini-media-ref-field-row", role: "group", "aria-labelledby": labelId, ...(base ? agentHandle({ handle: base }, { role: "region", label }) : {}), children: [_jsx(MediaRefFile, { file: file, emptyHint: emptyHint, label: label, locale: locale, t: t, inherited: inherited, previewUrl: previewUrl, onPreviewLoad: onPreviewLoad, onPreviewError: onPreviewError }), name ? _jsx("input", { type: "hidden", id: id, name: name, value: value }) : null, _jsx(MediaRefActions, { label: label, locale: locale, t: t, base: base, value: value, inherited: inherited, openPicker: openPicker, clear: clear })] }), pickerOpen ? (_jsx(Picker, { onSelect: handleSelect, onCancel: closePicker, accept: accept, agentHandle: base ? `${base}-dialog` : undefined })) : null] }));
|
|
21
|
+
}
|
|
22
|
+
/** The chosen file: thumbnail, name (prefixed with its source when inherited) and, for an own
|
|
23
|
+
* override, its pixel size. With no file, the field's empty hint (if any) takes the slot. */
|
|
24
|
+
function MediaRefFile({ file, emptyHint, label, locale, t, inherited, previewUrl, onPreviewLoad, onPreviewError }) {
|
|
25
|
+
if (!file)
|
|
26
|
+
return emptyHint ? _jsx("span", { className: "jini-media-ref-field-empty", children: emptyHint }) : null;
|
|
27
|
+
return (_jsxs("span", { className: inherited ? "jini-media-ref-field-file jini-media-ref-field-file-inherited" : "jini-media-ref-field-file", children: [previewUrl ? _jsx("img", { className: "jini-media-ref-field-preview", src: previewUrl, alt: `${label} preview`, onLoad: onPreviewLoad, onError: onPreviewError }, previewUrl) : null, _jsx("span", { className: "jini-media-ref-field-name", title: file.name, children: inherited ? inheritedFileName({ name: file.name, inherited, locale, t }) : file.name }), file.size && !inherited ? _jsx("span", { className: "jini-media-ref-field-size", children: file.size }) : null] }));
|
|
28
|
+
}
|
|
29
|
+
/** "Using site default: {name}" or "Using image from page content: {name}", translated. */
|
|
30
|
+
function inheritedFileName({ name, inherited, locale, t }, _optional = {}) {
|
|
31
|
+
const key = inherited === "content" ? "Using image from page content: {name}" : "Using site default: {name}";
|
|
32
|
+
return interpolate({ template: t({ locale, key, vars: { name } }), vars: { name } });
|
|
33
|
+
}
|
|
34
|
+
/** Choose (always) and Remove (only for an own value), grouped so they wrap as one unit. */
|
|
35
|
+
function MediaRefActions({ label, locale, t, base, value, inherited, openPicker, clear }) {
|
|
36
|
+
const handleBase = base === undefined ? {} : { base };
|
|
37
|
+
return (_jsxs("span", { className: "jini-media-ref-field-actions", children: [_jsx("button", { type: "button", className: "jini-btn jini-btn-secondary", onClick: openPicker, ...handleSpread({ ...handleBase, suffix: "choose", label: `Choose an image for: ${label}` }), children: t({ locale, key: "Choose image" }) }), value && !inherited ? (_jsx("button", { type: "button", className: "jini-btn jini-btn-secondary", onClick: clear, ...handleSpread({ ...handleBase, suffix: "clear", label: `Remove the selected image for: ${label}` }), children: t({ locale, key: "Remove" }) })) : null] }));
|
|
21
38
|
}
|
|
22
39
|
//# sourceMappingURL=MediaRefField.js.map
|
|
@@ -17,7 +17,9 @@ export function useMediaRefField({ value, onChange, api }, { t } = {}) {
|
|
|
17
17
|
// One cached library read shared by every image field on the screen; only a library reference
|
|
18
18
|
// needs it (a URL names itself), and a host without `listSeoMedia` falls back to the slug.
|
|
19
19
|
const list = api.listSeoMedia;
|
|
20
|
-
|
|
20
|
+
// `fetch` runs only while the query is enabled, which requires `list`: the fetch-query adapters
|
|
21
|
+
// never call a disabled read's fetch (not on mount, focus or prefix invalidation).
|
|
22
|
+
const media = useFetchQuery({ key: SEO_KEYS.media, fetch: () => list.call(api, {}) }, { enabled: Boolean(list) && isStoredMediaRef({ value }) });
|
|
21
23
|
return {
|
|
22
24
|
...diagnostics,
|
|
23
25
|
file: describeMediaRef({ value, media: media.data }, { width: naturalWidth, height: naturalHeight, t, extensionFor: extensionForMimeType }),
|
|
@@ -29,7 +29,7 @@ export declare function useAnalyzePanel({ analysis }: {
|
|
|
29
29
|
analysis: SeoEntryAnalysis;
|
|
30
30
|
}, _optional?: Record<string, never>): {
|
|
31
31
|
sortedIssues: import("../../../core/index.js").AdminSeoIssue[];
|
|
32
|
-
tone: "
|
|
32
|
+
tone: "failure" | "warning" | "success";
|
|
33
33
|
};
|
|
34
34
|
/** All seven defaults remain in one FormData submission; tabs never split this form. */
|
|
35
35
|
export declare function useSeoDefaults({ save }: {
|
|
@@ -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 (
|
|
43
|
-
|
|
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 =
|
|
62
|
-
const checked =
|
|
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
|
|
66
|
-
warning: shareImageWarning({ value, previewUrl, width: source
|
|
67
|
-
error:
|
|
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.
|
|
70
|
-
if (url
|
|
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.
|
|
75
|
-
if (url
|
|
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
|
|
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
|
-
}
|
|
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
|
-
}
|
|
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
|
|
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
|
-
|
|
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 }
|
|
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 }
|
|
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 }
|
|
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 } = {}
|
|
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 =
|
|
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 =
|
|
181
|
+
const trimmed = trimmedText(value);
|
|
177
182
|
if (!trimmed)
|
|
178
183
|
return null;
|
|
179
184
|
const key = parseMediaRefAssetId(trimmed);
|
|
180
|
-
const row =
|
|
181
|
-
const name =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
290
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
}
|
|
13
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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(),
|
|
40
|
-
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
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
|
}
|