@jini-ai/admin 0.4.11 → 0.4.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- 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 +74 -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/ports/seo.d.ts +20 -0
- 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/adapters/http.js +5 -0
- package/dist/seo/controllers/seo.controller.js +4 -2
- package/dist/seo/messages.en.d.ts +15 -10
- package/dist/seo/messages.en.js +15 -10
- package/dist/seo/models.d.ts +11 -0
- package/dist/seo/react/components/MediaRefField.d.ts +17 -5
- package/dist/seo/react/components/MediaRefField.js +31 -4
- package/dist/seo/react/hooks/MediaRefField.hooks.js +12 -2
- package/dist/seo/react/hooks/SeoPage.hooks.d.ts +1 -0
- package/dist/seo/react/hooks/SeoPage.hooks.js +2 -2
- package/dist/seo/react/hooks/SeoQuery.hooks.d.ts +1 -0
- package/dist/seo/react/hooks/SeoQuery.hooks.js +1 -0
- package/dist/seo/react/hooks/use-entry-picker.hooks.js +7 -2
- package/dist/seo/react/hooks/use-seo-entry-panel.hooks.js +13 -2
- package/dist/seo/react/hooks/use-share-image-warning.hooks.d.ts +4 -1
- package/dist/seo/react/hooks/use-share-image-warning.hooks.js +48 -33
- package/dist/seo/react/index.d.ts +45 -30
- package/dist/seo/react/pages/SeoPage.d.ts +6 -4
- package/dist/seo/react/pages/SeoPage.js +51 -29
- package/dist/seo/rules.d.ts +91 -0
- package/dist/seo/rules.js +161 -6
- package/dist/seo/seo.module.d.ts +15 -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 +19 -10
- 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
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { useAnalyzePanel, useSeoDefaults, useSeoTabs, seoTabPanel } from "../hooks/SeoPage.hooks.js";
|
|
3
3
|
import { agentHandle } from "@jini-ai/agentic";
|
|
4
4
|
import { TabBar } from "@jini-ai/ui/tab-strip";
|
|
5
|
+
import { InfoTip, Select } from "@jini-ai/ui/admin-widgets";
|
|
5
6
|
import { useSeoCopy, useSeoOptions } from "../hooks/SeoPorts.hooks.js";
|
|
6
|
-
import { actionLabel, orEmpty } from "../../rules.js";
|
|
7
|
+
import { actionLabel, orEmpty, xIsCustomized, xResolvedValue } from "../../rules.js";
|
|
7
8
|
import { useWiredEntryPicker } from "../hooks/use-entry-picker.hooks.js";
|
|
8
9
|
import { useWiredSeoEntryPanel } from "../hooks/use-seo-entry-panel.hooks.js";
|
|
9
10
|
import { useSeoEntrySection } from "../hooks/use-seo-entry-section.hooks.js";
|
|
@@ -29,31 +30,27 @@ import { SitemapModal } from "../components/SitemapModal.js";
|
|
|
29
30
|
* overrides", which sits INSIDE the "Per-entry SEO" section — keeps its own `<h3>` and is not
|
|
30
31
|
* routed through here.
|
|
31
32
|
*/
|
|
32
|
-
function SeoSection({ title, children }) {
|
|
33
|
-
return (_jsxs("section", { className: "jini-seo-section", children: [_jsx("div", { className: "jini-seo-section-head", children:
|
|
33
|
+
function SeoSection({ title, tip, children }) {
|
|
34
|
+
return (_jsxs("section", { className: "jini-seo-section", children: [_jsx("div", { className: "jini-seo-section-head", children: _jsxs("h2", { className: "jini-seo-section-title", children: [title, tip ? _jsx(InfoTip, { label: tip }) : null] }) }), _jsx("div", { className: "jini-seo-section-body", children: children })] }));
|
|
34
35
|
}
|
|
35
|
-
/**
|
|
36
|
-
* routes
|
|
36
|
+
/** Searchable picker over every page + post, sourced from the already-existing `listPosts`/`listPages`
|
|
37
|
+
* routes (REQ-06). It was a native `<select>`; with a few dozen entries that opened as one tall OS
|
|
38
|
+
* list with "(published)" after every row (owner, 2026-10-15: "we have a searchable dropdown
|
|
39
|
+
* right?"). The shared admin-widgets `Select` already types-to-filter and handles the keyboard, so
|
|
40
|
+
* rows are just grouped Pages/Posts with a pill only for the non-published exceptions
|
|
41
|
+
* (`entryPickerOptions` in `rules.ts`). */
|
|
37
42
|
function EntryPicker({ locale, entryId, onChange, useEntryPickerHook = useWiredEntryPicker }) {
|
|
38
43
|
const t = useSeoCopy();
|
|
39
|
-
const { entries, error } = useEntryPickerHook();
|
|
44
|
+
const { entries, options, error } = useEntryPickerHook();
|
|
40
45
|
if (error)
|
|
41
46
|
return _jsx("div", { className: "jini-notice jini-notice-error", children: error });
|
|
42
47
|
if (!entries)
|
|
43
48
|
return _jsx("div", { className: "jini-notice", children: t({ locale: locale, key: "Loading entries…" }) });
|
|
44
49
|
/* The "Entry" caption is no longer painted (owner, 2026-09-06: "you can actually just get rid of
|
|
45
|
-
'Entry'") — the placeholder
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
the four controls a previous accessibility pass found unnamed — including, by name, a
|
|
50
|
-
`<select>` with no accessible name at all. `display: contents` drops the wrapper's own box, so
|
|
51
|
-
the select keeps the section body's sizing exactly as if the label were not there.
|
|
52
|
-
The i18n key is unchanged and still passed through `t`, so nothing dangles. */
|
|
53
|
-
return (_jsxs("label", { className: "jini-a11y-label-wrap", children: [_jsx("span", { className: "jini-visually-hidden", children: t({ locale: locale, key: "Entry" }) }), _jsxs("select", { className: "jini-select", value: entryId, onChange: (e) => onChange(e.target.value), ...agentHandle({ handle: "seo-entry-picker" }, {
|
|
54
|
-
role: "field",
|
|
55
|
-
label: "Choose which page or post to edit or analyze SEO for",
|
|
56
|
-
}), children: [_jsx("option", { value: "", children: t({ locale: locale, key: "Choose an entry…" }) }), entries.map((entry) => (_jsxs("option", { value: entry.id, children: [entry.title, " (", entry.status, ")"] }, entry.id)))] })] }));
|
|
50
|
+
'Entry'") — the placeholder already says "Choose an entry…", so the visible word was saying it
|
|
51
|
+
twice. It stays the control's accessible name (now `aria-label` on the combobox trigger) rather
|
|
52
|
+
than leaving a placeholder-only control with no name at all. The i18n key is unchanged. */
|
|
53
|
+
return (_jsx("div", { className: "jini-seo-entry-picker", children: _jsx(Select, { value: entryId, onChange: onChange, options: options ?? [], placeholder: t({ locale: locale, key: "Choose an entry…" }), "aria-label": t({ locale: locale, key: "Entry" }), agentHandle: "seo-entry-picker", translate: (key) => t({ locale, key }) }) }));
|
|
57
54
|
}
|
|
58
55
|
/** Read-only score + issues view (REQ-07) — exact field names read off `SeoAnalysis`/`SeoIssue`
|
|
59
56
|
* (`src/seo/types.ts`), not guessed. No state of its own — only the severity sort, which lives in
|
|
@@ -61,8 +58,11 @@ function EntryPicker({ locale, entryId, onChange, useEntryPickerHook = useWiredE
|
|
|
61
58
|
function AnalyzePanel(props) {
|
|
62
59
|
const t = useSeoCopy();
|
|
63
60
|
const { locale } = props;
|
|
64
|
-
const { sortedIssues } = useAnalyzePanel({ analysis: props.analysis });
|
|
65
|
-
|
|
61
|
+
const { sortedIssues, tone } = useAnalyzePanel({ analysis: props.analysis });
|
|
62
|
+
/* Heading + score pill on one line, issues as a compact list (owner, 2026-10-15: the bare
|
|
63
|
+
"Score: 100 / No issues." read as unstyled text). The pill reuses the issue pills' status
|
|
64
|
+
tones, so the score and the issues under it speak one colour language. */
|
|
65
|
+
return (_jsxs("section", { className: "jini-seo-analyze-panel", "aria-labelledby": "seo-analysis-heading", children: [_jsxs("div", { className: "jini-seo-analyze-head", children: [_jsx("h3", { id: "seo-analysis-heading", children: t({ locale: locale, key: "Analysis" }) }), _jsxs("span", { className: `jini-status jini-status-${tone} jini-seo-score`, children: [t({ locale: locale, key: "Score:" }), " ", _jsx("strong", { children: props.analysis.score })] })] }), sortedIssues.length === 0 ? (_jsx("p", { className: "jini-seo-analyze-empty", children: t({ locale: locale, key: "No issues." }) })) : (_jsx("ul", { className: "jini-seo-issues", children: sortedIssues.map((issue, i) => (_jsxs("li", { className: "jini-seo-issue", children: [_jsx("span", { className: `jini-status jini-status-${issue.severity === "error" ? "failure" : issue.severity === "warning" ? "warning" : "success"}`, children: issue.severity }), _jsxs("span", { className: "jini-seo-issue-text", children: [issue.message, issue.field ? _jsxs("span", { className: "jini-muted-cell", children: [" (", issue.field, ")"] }) : null] })] }, `${issue.code}-${i}`))) }))] }));
|
|
66
66
|
}
|
|
67
67
|
/** Per-entry overrides edit form (REQ-06). Pre-fills from `getSeoEntry`'s resolved meta, but
|
|
68
68
|
* tracks which fields the user actually touched so `putSeoEntry` only ever sends a genuine
|
|
@@ -80,14 +80,36 @@ function AnalyzePanel(props) {
|
|
|
80
80
|
// the ceiling on their own. There is nothing to extract: splitting the eleven fields into their own
|
|
81
81
|
// components would still evaluate the same fallback chains, just spread across more functions, for
|
|
82
82
|
// no complexity benefit and a real loss of "one form, one place to read its fields."
|
|
83
|
+
// 2026-10-15: now 27 — the X fields' placeholders (the live social preview value) add two more
|
|
84
|
+
// flat `??` fallbacks of exactly that kind.
|
|
85
|
+
// 2026-10-20: exemption retired under the ≤10/≤10 bar — the head row, the "Search appearance"
|
|
86
|
+
// group, the "Social preview" group and its X disclosure are now their own components below, each
|
|
87
|
+
// scoring ≤10. The fallback chains themselves are unchanged and still read in form order.
|
|
83
88
|
function SeoEntryPanel({ locale, entryId, useSeoEntryPanelHook = useWiredSeoEntryPanel }) {
|
|
84
89
|
const t = useSeoCopy();
|
|
85
|
-
const { resolved, analysis, loadError, saving, saveError, notice, fieldValue, setField, save, touched } = useSeoEntryPanelHook({ entryId });
|
|
90
|
+
const { resolved, analysis, loadError, saving, saveError, notice, fieldValue, setField, save, touched, ogImageInherited = false } = useSeoEntryPanelHook({ entryId });
|
|
86
91
|
if (loadError)
|
|
87
92
|
return _jsx("div", { className: "jini-notice jini-notice-error", children: loadError });
|
|
88
93
|
if (!resolved)
|
|
89
94
|
return _jsx("div", { className: "jini-notice", children: t({ locale: locale, key: "Loading entry SEO…" }) });
|
|
90
|
-
|
|
95
|
+
const fields = { locale, t, resolved, fieldValue, setField };
|
|
96
|
+
return (_jsxs("div", { className: "jini-seo-entry-panel", children: [_jsx(SeoEntryHead, { locale: locale, t: t, saving: saving, touched: touched, save: save }), notice ? _jsx("div", { className: "jini-notice", children: notice }) : null, saveError ? (_jsx("div", { className: "jini-notice jini-notice-error", role: "alert", children: saveError })) : null, _jsx(SeoEntrySearchFields, { ...fields }), _jsx(SeoEntrySocialFields, { ...fields, ogImageInherited: ogImageInherited }), analysis ? _jsx(AnalyzePanel, { locale: locale, analysis: analysis }) : null] }));
|
|
97
|
+
}
|
|
98
|
+
/** Sticky heading row: title, the ⓘ explanation and Save (disabled while saving or with nothing touched). */
|
|
99
|
+
function SeoEntryHead({ locale, t, saving, touched, save }) {
|
|
100
|
+
return (_jsx(_Fragment, { children: _jsx("div", { className: "jini-seo-entry-head", children: _jsxs("div", { className: "jini-seo-entry-head-bar", children: [_jsx("h3", { children: t({ locale: locale, key: "Per-entry overrides" }) }), _jsx(InfoTip, { label: `${t({ locale: locale, key: "Fields show the currently-effective value (author override, or site default, or derived from the entry). Only fields you change here are saved as overrides." })} ${t({ locale: locale, key: "Empty a field and save to remove its override — the entry falls back to the site default." })}` }), _jsx("span", { className: "jini-seo-entry-head-actions", children: _jsx("button", { type: "button", className: "jini-btn", onClick: save, disabled: saving || Object.keys(touched).length === 0, ...agentHandle({ handle: "seo-entry-save-overrides" }, { role: "button", label: "Save this entry's SEO overrides" }), children: saving ? t({ locale: locale, key: "Saving…" }) : t({ locale: locale, key: "Save overrides" }) }) })] }) }) }));
|
|
101
|
+
}
|
|
102
|
+
/** "Search appearance": title, description, canonical URL and the two robots checkboxes. */
|
|
103
|
+
function SeoEntrySearchFields({ locale, t, resolved, fieldValue, setField }) {
|
|
104
|
+
return (_jsx(_Fragment, { children: _jsxs("fieldset", { className: "jini-seo-entry-group", children: [_jsx("legend", { children: t({ locale: locale, key: "Search appearance" }) }), _jsxs("label", { children: [t({ locale: locale, key: "Title" }), _jsx("input", { className: "jini-input", value: fieldValue("title", resolved.title) ?? "", onChange: (e) => setField("title", e.target.value), ...agentHandle({ handle: "seo-entry-title" }, { role: "field", label: "This entry's SEO title override" }) })] }), _jsxs("label", { children: [t({ locale: locale, key: "Description" }), _jsx("textarea", { className: "jini-textarea", value: fieldValue("description", resolved.description ?? "") ?? "", onChange: (e) => setField("description", e.target.value), ...agentHandle({ handle: "seo-entry-description" }, { role: "field", label: "This entry's SEO description override" }) })] }), _jsxs("label", { children: [t({ locale: locale, key: "Canonical URL" }), _jsx("input", { className: "jini-input", value: fieldValue("canonical", resolved.canonical) ?? "", onChange: (e) => setField("canonical", e.target.value), ...agentHandle({ handle: "seo-entry-canonical" }, { role: "field", label: "This entry's canonical URL override" }) })] }), _jsxs("div", { className: "jini-seo-entry-checks", children: [_jsxs("label", { children: [_jsx("input", { className: "jini-input", type: "checkbox", checked: fieldValue("noindex", resolved.robots.noindex) ?? false, onChange: (e) => setField("noindex", e.target.checked), ...agentHandle({ handle: "seo-entry-noindex" }, { role: "checkbox", label: "Override this entry's robots noindex directive" }) }), t({ locale: locale, key: "Noindex" })] }), _jsxs("label", { children: [_jsx("input", { className: "jini-input", type: "checkbox", checked: fieldValue("nofollow", resolved.robots.nofollow) ?? false, onChange: (e) => setField("nofollow", e.target.checked), ...agentHandle({ handle: "seo-entry-nofollow" }, { role: "checkbox", label: "Override this entry's robots nofollow directive" }) }), t({ locale: locale, key: "Nofollow" })] })] })] }) }));
|
|
105
|
+
}
|
|
106
|
+
/** "Social preview": the Open Graph fields plus the optional X/Twitter disclosure. */
|
|
107
|
+
function SeoEntrySocialFields({ locale, t, resolved, fieldValue, setField, ogImageInherited }) {
|
|
108
|
+
return (_jsx(_Fragment, { children: _jsxs("fieldset", { className: "jini-seo-entry-group", children: [_jsxs("legend", { children: [t({ locale: locale, key: "Social preview" }), _jsx(InfoTip, { label: t({ locale: locale, key: "Used by Facebook, LinkedIn, Slack, Discord, iMessage, WhatsApp and X/Twitter" }) })] }), _jsxs("label", { children: [t({ locale: locale, key: "Preview title" }), _jsx("input", { className: "jini-input", value: fieldValue("ogTitle", resolved.openGraph.title) ?? "", onChange: (e) => setField("ogTitle", e.target.value), ...agentHandle({ handle: "seo-entry-og-title" }, { role: "field", label: "This entry's Open Graph title override" }) })] }), _jsxs("label", { children: [t({ locale: locale, key: "Preview description" }), _jsx("input", { className: "jini-input", value: fieldValue("ogDescription", resolved.openGraph.description ?? "") ?? "", onChange: (e) => setField("ogDescription", e.target.value), ...agentHandle({ handle: "seo-entry-og-description" }, { role: "field", label: "This entry's Open Graph description override" }) })] }), _jsx(MediaRefField, { locale: locale, id: "seo-entry-og-image", label: t({ locale: locale, key: "Preview image" }), value: fieldValue("ogImage", resolved.openGraph.imageRef ?? resolved.openGraph.image ?? "") ?? "", onChange: (value) => setField("ogImage", value), inherited: ogImageInherited, agentHandle: "seo-entry-og-image" }), _jsx(SeoEntryXFields, { locale: locale, t: t, resolved: resolved, fieldValue: fieldValue, setField: setField })] }) }));
|
|
109
|
+
}
|
|
110
|
+
/** The X/Twitter disclosure inside "Social preview". */
|
|
111
|
+
function SeoEntryXFields({ locale, t, resolved, fieldValue, setField }) {
|
|
112
|
+
return (_jsx(_Fragment, { children: _jsxs("details", { className: "jini-seo-x-disclosure", open: xIsCustomized({ meta: resolved }), children: [_jsx("summary", { children: t({ locale: locale, key: "Customize for X/Twitter (optional)" }) }), _jsxs("div", { className: "jini-seo-x-fields", children: [_jsxs("label", { children: [t({ locale: locale, key: "X/Twitter title" }), _jsx("input", { className: "jini-input", value: fieldValue("twitterTitle", xResolvedValue({ key: "twitterTitle", meta: resolved })) ?? "", placeholder: fieldValue("ogTitle", resolved.openGraph.title) ?? "", onChange: (e) => setField("twitterTitle", e.target.value), ...agentHandle({ handle: "seo-entry-twitter-title" }, { role: "field", label: "This entry's Twitter card title override" }) })] }), _jsxs("label", { children: [t({ locale: locale, key: "X/Twitter description" }), _jsx("input", { className: "jini-input", value: fieldValue("twitterDescription", xResolvedValue({ key: "twitterDescription", meta: resolved })) ?? "", placeholder: fieldValue("ogDescription", resolved.openGraph.description ?? "") ?? "", onChange: (e) => setField("twitterDescription", e.target.value), ...agentHandle({ handle: "seo-entry-twitter-description" }, { role: "field", label: "This entry's Twitter card description override" }) })] }), _jsx(MediaRefField, { locale: locale, id: "seo-entry-twitter-image", label: t({ locale: locale, key: "X/Twitter image" }), value: fieldValue("twitterImage", xResolvedValue({ key: "twitterImage", meta: resolved })) ?? "", emptyHint: t({ locale: locale, key: "Same as the preview image" }), onChange: (value) => setField("twitterImage", value), agentHandle: "seo-entry-twitter-image" })] })] }) }));
|
|
91
113
|
}
|
|
92
114
|
/** Section wrapper (REQ-06/07) — entry picker over the per-entry edit + analyze panels. This IS the
|
|
93
115
|
* "Pages & posts" tab panel; it kept its name and its markup, because nothing about it changed
|
|
@@ -99,7 +121,7 @@ function SeoEntryPanel({ locale, entryId, useSeoEntryPanelHook = useWiredSeoEntr
|
|
|
99
121
|
* and nothing else — the exact regression `deployment/HistoryTab.tsx`'s own comment records
|
|
100
122
|
* measuring live on its tab. It is also not a duplicate: the tab reads "Pages & posts" (what you
|
|
101
123
|
* pick from), the heading reads "Per-entry SEO" (what you are editing). */
|
|
102
|
-
export function SeoEntrySection({ locale, useSeoEntrySectionHook = useSeoEntrySection }
|
|
124
|
+
export function SeoEntrySection({ locale, useSeoEntrySectionHook = useSeoEntrySection }) {
|
|
103
125
|
const t = useSeoCopy();
|
|
104
126
|
const { entryId, setEntryId } = useSeoEntrySectionHook();
|
|
105
127
|
return (_jsx("div", { className: "jini-seo-panel jini-seo-panel--full jini-seo-entry-section", ...agentHandle({ handle: "seo-per-entry" }, {
|
|
@@ -126,7 +148,7 @@ export function SeoEntrySection({ locale, useSeoEntrySectionHook = useSeoEntrySe
|
|
|
126
148
|
* inline `??`/`||` field reads — same fields, same fallbacks, same payload, just not written in the
|
|
127
149
|
* `.tsx`.
|
|
128
150
|
*/
|
|
129
|
-
export function SeoDefaultsTab({ controller }
|
|
151
|
+
export function SeoDefaultsTab({ controller }) {
|
|
130
152
|
const t = useSeoCopy();
|
|
131
153
|
const { locale, settings, saving, defaultOgImage, setDefaultOgImage } = controller;
|
|
132
154
|
const { submit } = useSeoDefaults({ save: controller.save });
|
|
@@ -139,7 +161,7 @@ export function SeoDefaultsTab({ controller }, _optional = {}) {
|
|
|
139
161
|
}) })] }), _jsxs("div", { className: "jini-field", children: [_jsx("label", { className: "jini-field-label", htmlFor: "seo-default-description", children: t({ locale: locale, key: "Default meta description" }) }), _jsx("textarea", { className: "jini-textarea", id: "seo-default-description", name: "defaultDescription", defaultValue: orEmpty({ value: settings.defaultDescription }), ...agentHandle({ handle: "seo-default-description" }, {
|
|
140
162
|
role: "field",
|
|
141
163
|
label: "Fallback meta description for pages that set none of their own",
|
|
142
|
-
}) })] })] }), _jsxs(SeoSection, { title: t({ locale: locale, key: "Social
|
|
164
|
+
}) })] })] }), _jsxs(SeoSection, { title: t({ locale: locale, key: "Social preview" }), tip: t({ locale: locale, key: "Used by Facebook, LinkedIn, Slack, Discord, iMessage, WhatsApp and X/Twitter" }), children: [_jsx(MediaRefField, { locale: locale, id: "seo-default-og-image", name: "defaultOgImage", label: t({ locale: locale, key: "Default preview image" }), value: defaultOgImage, onChange: setDefaultOgImage, agentHandle: "seo-default-og-image" }), _jsxs("div", { className: "jini-field", children: [_jsx("label", { className: "jini-field-label", htmlFor: "seo-twitter-site", children: t({ locale: locale, key: "X/Twitter @handle" }) }), _jsx("input", { className: "jini-input", id: "seo-twitter-site", name: "twitterSite", defaultValue: orEmpty({ value: settings.twitterSite }), ...agentHandle({ handle: "seo-twitter-site" }, {
|
|
143
165
|
role: "field",
|
|
144
166
|
label: "The site's Twitter @handle, used in Twitter card metadata",
|
|
145
167
|
}) })] })] }), _jsxs(SeoSection, { title: t({ locale: locale, key: "Crawling" }), children: [_jsxs("label", { className: "jini-form-checkbox-field", children: [_jsx("input", { className: "jini-input", type: "checkbox", name: "noindex", defaultChecked: settings.defaultRobots.noindex, ...agentHandle({ handle: "seo-default-noindex" }, {
|
|
@@ -173,7 +195,7 @@ export function SeoDefaultsTab({ controller }, _optional = {}) {
|
|
|
173
195
|
* It also has to exist at all — a `role="tab"` button is not in a screen reader's heading list, so
|
|
174
196
|
* a panel with no `<h2>` leaves that reader nothing but the page `H1`.
|
|
175
197
|
*/
|
|
176
|
-
export function SeoSitemapTab({ controller }
|
|
198
|
+
export function SeoSitemapTab({ controller }) {
|
|
177
199
|
const t = useSeoCopy();
|
|
178
200
|
const { locale, settings, saving, regenerateSitemap, openSitemapModal } = controller;
|
|
179
201
|
return (_jsx("div", { className: "jini-seo-panel jini-seo-panel--full", ...agentHandle({ handle: "seo-sitemap" }, {
|
|
@@ -187,7 +209,7 @@ export function SeoSitemapTab({ controller }, _optional = {}) {
|
|
|
187
209
|
label: "Open a modal showing the sitemap's URLs, or its raw XML",
|
|
188
210
|
}), children: t({ locale: locale, key: "View sitemap" }) })] })] }) }));
|
|
189
211
|
}
|
|
190
|
-
export function Seo({ tabId, onTabChange, useSeoHook = useWiredSeo } = {}
|
|
212
|
+
export function Seo({ tabId, onTabChange, useSeoHook = useWiredSeo } = {}) {
|
|
191
213
|
const t = useSeoCopy();
|
|
192
214
|
const { headerActions } = useSeoOptions();
|
|
193
215
|
const { changeTab } = useSeoPageActions({ onTabChange });
|
|
@@ -208,7 +230,7 @@ export function Seo({ tabId, onTabChange, useSeoHook = useWiredSeo } = {}, _opti
|
|
|
208
230
|
regenerateSitemap,
|
|
209
231
|
openSitemapModal,
|
|
210
232
|
};
|
|
211
|
-
return (_jsxs("div", { className: "jini-page jini-seo-page", children: [_jsxs("div", { className: "jini-page-header", children: [_jsxs("div", { className: "jini-page-header-text", children: [_jsx("p", { className: "jini-page-kicker", children: t({ locale: locale, key: "Marketing" }) }), _jsx("h1", { className: "jini-page-title", children: "SEO" }), _jsx("p", { className: "jini-page-description", children: t({ locale: locale, key: "Site-wide defaults for meta titles, descriptions,
|
|
233
|
+
return (_jsxs("div", { className: "jini-page jini-seo-page", children: [_jsxs("div", { className: "jini-page-header", children: [_jsxs("div", { className: "jini-page-header-text", children: [_jsx("p", { className: "jini-page-kicker", children: t({ locale: locale, key: "Marketing" }) }), _jsx("h1", { className: "jini-page-title", children: "SEO" }), _jsx("p", { className: "jini-page-description", children: t({ locale: locale, key: "Site-wide defaults for meta titles, descriptions, social previews, and robots directives." }) })] }), _jsx("div", { className: "jini-page-actions", children: headerActions })] }), error ? _jsx("div", { className: "jini-notice jini-notice-error", children: error }) : null, notice ? _jsx("div", { className: "jini-notice", children: notice }) : null, _jsx(TabBar, { ariaLabel: "SEO", tabs: tabs, activeId: activeTabId, onChange: changeTab, containerHandle: "seo-tab-bar" }), seoTabPanel({ activeTabId, controller, components: { SeoDefaultsTab, SeoSitemapTab, SeoEntrySection } }), sitemapModalOpen ? (_jsx(SitemapModal, { locale: locale, sitemapEnabled: settings.sitemapEnabled, regenerating: saving, onRegenerate: regenerateSitemap, onClose: closeSitemapModal })) : null] }));
|
|
212
234
|
}
|
|
213
235
|
export { Seo as SeoPage };
|
|
214
236
|
export default Seo;
|
package/dist/seo/rules.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { AdminSeoIssue as SeoIssue, AdminSeoSettingsPatch as SeoSettingsPatch } from "../core/ports/seo.js";
|
|
2
2
|
import type { SeoTranslator } from './models.js';
|
|
3
|
+
import type { AdminSeoEntryChoice, AdminSeoMediaSummary, AdminSeoMeta } from '../core/ports/seo.js';
|
|
3
4
|
/**
|
|
4
5
|
* Orders issues error-first, then warning, then info, matching the severities' natural urgency.
|
|
5
6
|
* An unrecognized severity sorts last (falls back to `9`) rather than throwing, since this reads
|
|
@@ -37,6 +38,36 @@ export declare function overrideOrClear<V>({ value }: {
|
|
|
37
38
|
export declare function orEmpty({ value }: {
|
|
38
39
|
value: string | undefined;
|
|
39
40
|
}, _optional?: Record<string, never>): string;
|
|
41
|
+
/** The X (twitter) fields the "Customize for X/Twitter" disclosure holds, each paired with the social
|
|
42
|
+
* preview (Open Graph) value it falls back to. */
|
|
43
|
+
export type SeoXFieldKey = 'twitterTitle' | 'twitterDescription' | 'twitterImage';
|
|
44
|
+
/** Whether this entry's X card differs from its social preview in any field — i.e. an X override
|
|
45
|
+
* is in effect, so the "Customize for X/Twitter" disclosure opens on load. (2026-10-15, owner: one
|
|
46
|
+
* generic social preview, X only as an optional customization.)
|
|
47
|
+
* @complexity O(1). */
|
|
48
|
+
export declare function xIsCustomized({ meta }: {
|
|
49
|
+
meta: AdminSeoMeta;
|
|
50
|
+
}, _optional?: Record<string, never>): boolean;
|
|
51
|
+
/** An X field's untouched box value, handed to `fieldValue` as its resolved value (so a pending
|
|
52
|
+
* edit or clear still wins there). An X value equal to the social preview's is inherited, so the
|
|
53
|
+
* box stays empty and its placeholder shows the live social value — typing there is what creates
|
|
54
|
+
* an X override.
|
|
55
|
+
* @complexity O(1). */
|
|
56
|
+
export declare function xResolvedValue({ key, meta }: {
|
|
57
|
+
key: SeoXFieldKey;
|
|
58
|
+
meta: AdminSeoMeta;
|
|
59
|
+
}, _optional?: Record<string, never>): string;
|
|
60
|
+
/** The X field an edit to a social preview field should carry along: when X still shows the
|
|
61
|
+
* preview's value and has no pending edit of its own, the edit also clears X's stored override so
|
|
62
|
+
* X keeps following the preview after save. Without it, an X override saved equal to the preview
|
|
63
|
+
* (indistinguishable here, the screen sees only effective values) would silently pin X to the old
|
|
64
|
+
* text. Clearing an override that does not exist is a no-op on the server.
|
|
65
|
+
* @complexity O(1). */
|
|
66
|
+
export declare function inheritedXFor({ key, meta, touched }: {
|
|
67
|
+
key: string;
|
|
68
|
+
meta: AdminSeoMeta;
|
|
69
|
+
touched: Readonly<Record<string, unknown>>;
|
|
70
|
+
}, _optional?: Record<string, never>): SeoXFieldKey | null;
|
|
40
71
|
/** A pending-action button's label — `react/pages/SeoPage.tsx` uses this for both the save-settings button
|
|
41
72
|
* ("Saving…"/"Save settings") and the regenerate-sitemap button ("Working…"/"Regenerate
|
|
42
73
|
* sitemap"), same `pending ? … : …` shape, different copy. Also reused by `SitemapModal.tsx`'s
|
|
@@ -85,6 +116,39 @@ export declare function filterSitemapEntries({ entries, query }: {
|
|
|
85
116
|
export declare function isStoredMediaRef({ value }: {
|
|
86
117
|
value: string;
|
|
87
118
|
}, _optional?: Record<string, never>): boolean;
|
|
119
|
+
/** What an image field shows for its value — the file's name and pixel size — in place of the raw
|
|
120
|
+
* `{slug}:public` reference or URL (owner, 2026-10-15: a read-only "Selected image" box "looks
|
|
121
|
+
* useless"). The library row wins when the host can list media; otherwise a readable slug or the
|
|
122
|
+
* URL's file name stands in, and an opaque id falls back to the translated "Image".
|
|
123
|
+
* Size prefers the stored dimensions, then the decoded preview's natural size.
|
|
124
|
+
* @example describeMediaRef({ value: 'sunset:public', media: [{ id: 'a1', slug: 'sunset', title: 'Sunset', width: 1200, height: 630 }] }) // { name: 'Sunset', size: '1200 × 630' }
|
|
125
|
+
* @complexity O(m) over the media rows; no I/O.
|
|
126
|
+
*/
|
|
127
|
+
export declare function describeMediaRef({ value, media }: {
|
|
128
|
+
value: string;
|
|
129
|
+
media?: readonly AdminSeoMediaSummary[] | null | undefined;
|
|
130
|
+
}, { width, height, t, extensionFor }?: {
|
|
131
|
+
width?: number | undefined;
|
|
132
|
+
height?: number | undefined;
|
|
133
|
+
t?: SeoTranslator | undefined;
|
|
134
|
+
/** `image/png` → `.png`; injected (the host UI kit owns the table) so this file stays pure. */
|
|
135
|
+
extensionFor?: ((contentType: string) => string) | undefined;
|
|
136
|
+
}): {
|
|
137
|
+
name: string;
|
|
138
|
+
size: string | null;
|
|
139
|
+
} | null;
|
|
140
|
+
/** Whether an entry's social preview image comes from content or the site default, with no override
|
|
141
|
+
* of its own, so the field shows its source muted. "Using site default" is the original copy (owner,
|
|
142
|
+
* 2026-10-15). A pending edit is the entry's own, and so is a stored override the host flags
|
|
143
|
+
* (`imageOverridden`) even when it equals the default. A host without that flag can't tell such an
|
|
144
|
+
* override apart, so it reads as inherited (same image either way). Returns 'content' for an image
|
|
145
|
+
* the host identifies as content, 'default' for the site default, and false for an own/absent image.
|
|
146
|
+
* @complexity O(1). */
|
|
147
|
+
export declare function ogImageInherited({ meta, defaultOgImage, touched }: {
|
|
148
|
+
meta: AdminSeoMeta;
|
|
149
|
+
defaultOgImage: string | undefined;
|
|
150
|
+
touched: Readonly<Record<string, unknown>>;
|
|
151
|
+
}, _optional?: Record<string, never>): false | 'default' | 'content';
|
|
88
152
|
/** Builds the `{slug}:public` reference `MediaRefField` writes into the field on selection —
|
|
89
153
|
* prefers the asset's readable slug over its id (readable-slugs S5b, 2026-09-23), matching
|
|
90
154
|
* `resolveSeoImageRef`'s own id-or-slug lookup server-side (`findMediaByIdOrSlug`,
|
|
@@ -159,4 +223,31 @@ export type SeoTabId = (typeof SEO_TAB_IDS)[number];
|
|
|
159
223
|
export declare function resolveSeoTabId({ tabId }: {
|
|
160
224
|
tabId?: string | null | undefined;
|
|
161
225
|
}, _optional?: Record<string, never>): SeoTabId;
|
|
226
|
+
/** Colour band for the analysis score pill: the same success/warning/failure tones the issue pills
|
|
227
|
+
* use, so a 100 reads green and a failing page reads red at a glance.
|
|
228
|
+
* @example scoreTone({ score: 100 }) // 'success'
|
|
229
|
+
*/
|
|
230
|
+
export declare function scoreTone({ score }: {
|
|
231
|
+
score: number;
|
|
232
|
+
}, _optional?: Record<string, never>): 'success' | 'warning' | 'failure';
|
|
233
|
+
/** One row of the searchable entry picker: grouped Pages then Posts, status only when not live. */
|
|
234
|
+
export interface SeoEntryPickerOption {
|
|
235
|
+
value: string;
|
|
236
|
+
label: string;
|
|
237
|
+
group: string;
|
|
238
|
+
badge?: string;
|
|
239
|
+
detail?: string;
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Picker rows for the Pages & posts tab. Pages lead because they are fewer and are the usual SEO
|
|
243
|
+
* targets (home, about, pricing); a published entry carries no pill, so only the exceptions
|
|
244
|
+
* (draft, scheduled, …) draw the eye. An untitled entry stays pickable by its id.
|
|
245
|
+
* @example entryPickerOptions({ posts, pages }, { t }) // [{ value: 'p1', label: 'About', group: 'Pages' }, …]
|
|
246
|
+
*/
|
|
247
|
+
export declare function entryPickerOptions({ posts, pages }: {
|
|
248
|
+
posts: readonly AdminSeoEntryChoice[];
|
|
249
|
+
pages: readonly AdminSeoEntryChoice[];
|
|
250
|
+
}, { t }?: {
|
|
251
|
+
t?: SeoTranslator | undefined;
|
|
252
|
+
}): SeoEntryPickerOption[];
|
|
162
253
|
//# sourceMappingURL=rules.d.ts.map
|
package/dist/seo/rules.js
CHANGED
|
@@ -46,6 +46,43 @@ export function overrideOrClear({ value }, _optional = {}) {
|
|
|
46
46
|
export function orEmpty({ value }, _optional = {}) {
|
|
47
47
|
return value ?? "";
|
|
48
48
|
}
|
|
49
|
+
/** One entry's effective X value next to its effective social preview value, per field. Images
|
|
50
|
+
* compare by source ref (the resolved URL is resized), falling back to the URL for pasted ones. */
|
|
51
|
+
function xAndSocial({ meta }) {
|
|
52
|
+
return {
|
|
53
|
+
twitterTitle: [meta.twitter.title, meta.openGraph.title],
|
|
54
|
+
twitterDescription: [meta.twitter.description, meta.openGraph.description],
|
|
55
|
+
twitterImage: [meta.twitter.imageRef ?? meta.twitter.image, meta.openGraph.imageRef ?? meta.openGraph.image],
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/** Whether this entry's X card differs from its social preview in any field — i.e. an X override
|
|
59
|
+
* is in effect, so the "Customize for X/Twitter" disclosure opens on load. (2026-10-15, owner: one
|
|
60
|
+
* generic social preview, X only as an optional customization.)
|
|
61
|
+
* @complexity O(1). */
|
|
62
|
+
export function xIsCustomized({ meta }, _optional = {}) {
|
|
63
|
+
return Object.values(xAndSocial({ meta })).some(([x, social]) => x !== social);
|
|
64
|
+
}
|
|
65
|
+
/** An X field's untouched box value, handed to `fieldValue` as its resolved value (so a pending
|
|
66
|
+
* edit or clear still wins there). An X value equal to the social preview's is inherited, so the
|
|
67
|
+
* box stays empty and its placeholder shows the live social value — typing there is what creates
|
|
68
|
+
* an X override.
|
|
69
|
+
* @complexity O(1). */
|
|
70
|
+
export function xResolvedValue({ key, meta }, _optional = {}) {
|
|
71
|
+
const [x, social] = xAndSocial({ meta })[key];
|
|
72
|
+
return x === social ? '' : x ?? '';
|
|
73
|
+
}
|
|
74
|
+
/** The X field an edit to a social preview field should carry along: when X still shows the
|
|
75
|
+
* preview's value and has no pending edit of its own, the edit also clears X's stored override so
|
|
76
|
+
* X keeps following the preview after save. Without it, an X override saved equal to the preview
|
|
77
|
+
* (indistinguishable here, the screen sees only effective values) would silently pin X to the old
|
|
78
|
+
* text. Clearing an override that does not exist is a no-op on the server.
|
|
79
|
+
* @complexity O(1). */
|
|
80
|
+
export function inheritedXFor({ key, meta, touched }, _optional = {}) {
|
|
81
|
+
const xKey = { ogTitle: 'twitterTitle', ogDescription: 'twitterDescription', ogImage: 'twitterImage' }[key];
|
|
82
|
+
if (!xKey || xKey in touched)
|
|
83
|
+
return null;
|
|
84
|
+
return xResolvedValue({ key: xKey, meta }) === '' ? xKey : null;
|
|
85
|
+
}
|
|
49
86
|
/** A pending-action button's label — `react/pages/SeoPage.tsx` uses this for both the save-settings button
|
|
50
87
|
* ("Saving…"/"Save settings") and the regenerate-sitemap button ("Working…"/"Regenerate
|
|
51
88
|
* sitemap"), same `pending ? … : …` shape, different copy. Also reused by `SitemapModal.tsx`'s
|
|
@@ -122,10 +159,84 @@ function parseMediaRefAssetId(ref) {
|
|
|
122
159
|
* URLs and incomplete input remain editable. @complexity O(n) in value length; no I/O.
|
|
123
160
|
*/
|
|
124
161
|
export function isStoredMediaRef({ value }, _optional = {}) {
|
|
125
|
-
const trimmed =
|
|
162
|
+
const trimmed = trimmedText(value);
|
|
126
163
|
// A URL typed one character at a time reaches "https:/" before it reaches "https://".
|
|
127
164
|
return !trimmed.includes(":/") && parseMediaRefAssetId(trimmed) !== null;
|
|
128
165
|
}
|
|
166
|
+
/** A field value trimmed, or `""` for a non-string — the `value` props arrive through `??` fallback
|
|
167
|
+
* chains that do not replace a wrong-typed result, and these rules never throw over one bad value. */
|
|
168
|
+
function trimmedText(value) {
|
|
169
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
170
|
+
}
|
|
171
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
172
|
+
/** What an image field shows for its value — the file's name and pixel size — in place of the raw
|
|
173
|
+
* `{slug}:public` reference or URL (owner, 2026-10-15: a read-only "Selected image" box "looks
|
|
174
|
+
* useless"). The library row wins when the host can list media; otherwise a readable slug or the
|
|
175
|
+
* URL's file name stands in, and an opaque id falls back to the translated "Image".
|
|
176
|
+
* Size prefers the stored dimensions, then the decoded preview's natural size.
|
|
177
|
+
* @example describeMediaRef({ value: 'sunset:public', media: [{ id: 'a1', slug: 'sunset', title: 'Sunset', width: 1200, height: 630 }] }) // { name: 'Sunset', size: '1200 × 630' }
|
|
178
|
+
* @complexity O(m) over the media rows; no I/O.
|
|
179
|
+
*/
|
|
180
|
+
export function describeMediaRef({ value, media }, { width, height, t = key => key, extensionFor } = {}) {
|
|
181
|
+
const trimmed = trimmedText(value);
|
|
182
|
+
if (!trimmed)
|
|
183
|
+
return null;
|
|
184
|
+
const key = parseMediaRefAssetId(trimmed);
|
|
185
|
+
const row = findMediaRow({ key, media });
|
|
186
|
+
const name = libraryFileName({ row }, { extensionFor }) ?? fallbackMediaName({ key, value: trimmed, t });
|
|
187
|
+
return { name, size: pixelSize(row?.width ?? width, row?.height ?? height) };
|
|
188
|
+
}
|
|
189
|
+
/** The library row an id-or-slug reference names, when the host could list media at all. */
|
|
190
|
+
function findMediaRow({ key, media }, _optional = {}) {
|
|
191
|
+
return key ? media?.find(item => item.id === key || item.slug === key) : undefined;
|
|
192
|
+
}
|
|
193
|
+
/** A titled library row's display name with its stored type's extension; `null` for no row or an
|
|
194
|
+
* untitled one, so the caller's fallback name applies. */
|
|
195
|
+
function libraryFileName({ row }, { extensionFor } = {}) {
|
|
196
|
+
if (!row?.title)
|
|
197
|
+
return null;
|
|
198
|
+
return withExtension({ name: row.title, extension: row.contentType && extensionFor ? extensionFor(row.contentType) : '' });
|
|
199
|
+
}
|
|
200
|
+
/** A library title reads as a file name (owner, 2026-10-15: "it should be testing-page.png or
|
|
201
|
+
* whatever the extension is"). No original upload name is stored, so the extension comes from the
|
|
202
|
+
* stored bytes' type; a title that already ends in it is left alone. */
|
|
203
|
+
function withExtension({ name, extension }) {
|
|
204
|
+
return extension && !name.toLowerCase().endsWith(extension) ? `${name}${extension}` : name;
|
|
205
|
+
}
|
|
206
|
+
/** Whether an entry's social preview image comes from content or the site default, with no override
|
|
207
|
+
* of its own, so the field shows its source muted. "Using site default" is the original copy (owner,
|
|
208
|
+
* 2026-10-15). A pending edit is the entry's own, and so is a stored override the host flags
|
|
209
|
+
* (`imageOverridden`) even when it equals the default. A host without that flag can't tell such an
|
|
210
|
+
* override apart, so it reads as inherited (same image either way). Returns 'content' for an image
|
|
211
|
+
* the host identifies as content, 'default' for the site default, and false for an own/absent image.
|
|
212
|
+
* @complexity O(1). */
|
|
213
|
+
export function ogImageInherited({ meta, defaultOgImage, touched }, _optional = {}) {
|
|
214
|
+
if ('ogImage' in touched || meta.openGraph.imageOverridden)
|
|
215
|
+
return false;
|
|
216
|
+
const image = meta.openGraph.imageRef ?? meta.openGraph.image;
|
|
217
|
+
if (meta.openGraph.imageSource === 'content' && image)
|
|
218
|
+
return 'content';
|
|
219
|
+
return defaultOgImage && image === defaultOgImage ? 'default' : false;
|
|
220
|
+
}
|
|
221
|
+
function fallbackMediaName({ key, value, t }) {
|
|
222
|
+
if (!key)
|
|
223
|
+
return urlFileName(value);
|
|
224
|
+
return UUID_PATTERN.test(key) ? t('Image') : key;
|
|
225
|
+
}
|
|
226
|
+
function pixelSize(width, height) {
|
|
227
|
+
return width && height ? `${width} × ${height}` : null;
|
|
228
|
+
}
|
|
229
|
+
/** The last path segment of an image URL ("https://cdn.example/a/pic.png" → "pic.png"). */
|
|
230
|
+
function urlFileName(value) {
|
|
231
|
+
try {
|
|
232
|
+
const url = new URL(value, 'https://placeholder.invalid');
|
|
233
|
+
const last = url.pathname.split('/').filter(Boolean).pop();
|
|
234
|
+
return last ? decodeURIComponent(last) : url.hostname || value;
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
return value;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
129
240
|
/** Builds the `{slug}:public` reference `MediaRefField` writes into the field on selection —
|
|
130
241
|
* prefers the asset's readable slug over its id (readable-slugs S5b, 2026-09-23), matching
|
|
131
242
|
* `resolveSeoImageRef`'s own id-or-slug lookup server-side (`findMediaByIdOrSlug`,
|
|
@@ -154,7 +265,7 @@ export function buildMediaRef({ item }, _optional = {}) {
|
|
|
154
265
|
* @complexity O(1) — string parsing only, no I/O (the returned URL is a template; the browser
|
|
155
266
|
* performs the actual fetch only once it is used as an `<img src>`). */
|
|
156
267
|
export function resolveMediaRefPreviewUrl({ value, mediaOriginalUrl }, { siteOrigin } = {}) {
|
|
157
|
-
const trimmed =
|
|
268
|
+
const trimmed = trimmedText(value);
|
|
158
269
|
if (!trimmed)
|
|
159
270
|
return null;
|
|
160
271
|
if (isAbsoluteMediaRef(trimmed)) {
|
|
@@ -185,14 +296,26 @@ function localRenditionAssetKey({ value, siteOrigin }, _optional = {}) {
|
|
|
185
296
|
* @complexity O(n) in value/error length; fixed-count checks, no I/O.
|
|
186
297
|
*/
|
|
187
298
|
export function shareImageWarning({ value, previewUrl, width, height, byteSize, error, previewFailed }, { t = key => key } = {}) {
|
|
188
|
-
|
|
299
|
+
const blocker = shareImageBlocker({ value, previewUrl, error, previewFailed }, { t });
|
|
300
|
+
if (blocker !== null)
|
|
301
|
+
return blocker;
|
|
302
|
+
const reasons = shareImageShortfalls({ width, height, byteSize }, { t });
|
|
303
|
+
return reasons.length ? reasons.join(' ') : null;
|
|
304
|
+
}
|
|
305
|
+
/** Why there is nothing to measure: no value, an unparseable one, a metadata error, or a failed
|
|
306
|
+
* decode — checked in that order. `null` when the image loaded and can be measured. */
|
|
307
|
+
function shareImageBlocker({ value, previewUrl, error, previewFailed }, { t }) {
|
|
308
|
+
if (!trimmedText(value))
|
|
189
309
|
return t('No share image is selected. Link previews may show no picture.');
|
|
190
310
|
if (!previewUrl)
|
|
191
311
|
return t('The share image reference is invalid. Choose an image or enter an absolute image URL.');
|
|
192
312
|
if (error)
|
|
193
313
|
return error;
|
|
194
|
-
|
|
195
|
-
|
|
314
|
+
return previewFailed ? t('The share image could not be loaded. Choose an available image.') : null;
|
|
315
|
+
}
|
|
316
|
+
/** Each measured dimension or size below the share-card minimum, as its own sentence. An unknown
|
|
317
|
+
* measurement (`undefined`, or a `null` byte size) is never reported. */
|
|
318
|
+
function shareImageShortfalls({ width, height, byteSize }, { t }) {
|
|
196
319
|
const reasons = [];
|
|
197
320
|
if (width !== undefined && width < 1200)
|
|
198
321
|
reasons.push(interpolate({ template: t('The share image is only {width} px wide; use an image at least 1200 px wide.', { width }), vars: { width } }));
|
|
@@ -200,7 +323,7 @@ export function shareImageWarning({ value, previewUrl, width, height, byteSize,
|
|
|
200
323
|
reasons.push(interpolate({ template: t('The share image is only {height} px tall; use an image at least 630 px tall.', { height }), vars: { height } }));
|
|
201
324
|
if (byteSize !== undefined && byteSize !== null && byteSize < 5120)
|
|
202
325
|
reasons.push(interpolate({ template: t('The share image is only {byteSize} bytes; use an image of at least 5 KB (5120 bytes).', { byteSize }), vars: { byteSize } }));
|
|
203
|
-
return reasons
|
|
326
|
+
return reasons;
|
|
204
327
|
}
|
|
205
328
|
/** One `FormData` field as a string, with the caller's fallback for an absent field. Its own named
|
|
206
329
|
* function purely so {@link buildSeoSettingsPatch} below stays readable at a glance: written
|
|
@@ -259,4 +382,36 @@ export const SEO_TAB_IDS = ['defaults', 'sitemap', 'entries'];
|
|
|
259
382
|
export function resolveSeoTabId({ tabId }, _optional = {}) {
|
|
260
383
|
return SEO_TAB_IDS.find(id => id === tabId) ?? 'defaults';
|
|
261
384
|
}
|
|
385
|
+
/** Colour band for the analysis score pill: the same success/warning/failure tones the issue pills
|
|
386
|
+
* use, so a 100 reads green and a failing page reads red at a glance.
|
|
387
|
+
* @example scoreTone({ score: 100 }) // 'success'
|
|
388
|
+
*/
|
|
389
|
+
export function scoreTone({ score }, _optional = {}) {
|
|
390
|
+
if (score >= 80)
|
|
391
|
+
return 'success';
|
|
392
|
+
return score >= 50 ? 'warning' : 'failure';
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Picker rows for the Pages & posts tab. Pages lead because they are fewer and are the usual SEO
|
|
396
|
+
* targets (home, about, pricing); a published entry carries no pill, so only the exceptions
|
|
397
|
+
* (draft, scheduled, …) draw the eye. An untitled entry stays pickable by its id.
|
|
398
|
+
* @example entryPickerOptions({ posts, pages }, { t }) // [{ value: 'p1', label: 'About', group: 'Pages' }, …]
|
|
399
|
+
*/
|
|
400
|
+
export function entryPickerOptions({ posts, pages }, { t = key => key } = {}) {
|
|
401
|
+
// Two entries can share a title ("Landing Page" twice, owner 2026-10-15); both stay listed and
|
|
402
|
+
// each duplicate shows its slug as muted secondary text so the operator can tell them apart.
|
|
403
|
+
const rows = (entries, group) => {
|
|
404
|
+
const labelOf = (entry) => entry.title.trim() || entry.id;
|
|
405
|
+
const counts = new Map();
|
|
406
|
+
for (const entry of entries)
|
|
407
|
+
counts.set(labelOf(entry), (counts.get(labelOf(entry)) ?? 0) + 1);
|
|
408
|
+
return entries.map((entry) => ({
|
|
409
|
+
value: entry.id, label: labelOf(entry), group,
|
|
410
|
+
...(entry.status && entry.status !== 'published' ? { badge: t(entry.status) } : {}),
|
|
411
|
+
// Every label was counted above, so its count is at least 1 and "not exactly one" means a duplicate.
|
|
412
|
+
...(counts.get(labelOf(entry)) !== 1 ? { detail: entry.slug || entry.id } : {}),
|
|
413
|
+
}));
|
|
414
|
+
};
|
|
415
|
+
return [...rows(pages, t('Pages')), ...rows(posts, t('Posts'))];
|
|
416
|
+
}
|
|
262
417
|
//# sourceMappingURL=rules.js.map
|
package/dist/seo/seo.module.d.ts
CHANGED
|
@@ -29,21 +29,24 @@ export declare const seoModule: {
|
|
|
29
29
|
readonly "Canonical URL": "Canonical URL";
|
|
30
30
|
readonly Noindex: "Noindex";
|
|
31
31
|
readonly Nofollow: "Nofollow";
|
|
32
|
-
readonly "
|
|
33
|
-
readonly "
|
|
34
|
-
readonly "
|
|
35
|
-
readonly "Twitter title": "Twitter title";
|
|
36
|
-
readonly "Twitter description": "Twitter description";
|
|
37
|
-
readonly "Twitter image": "Twitter image";
|
|
32
|
+
readonly "Preview title": "Preview title";
|
|
33
|
+
readonly "Preview description": "Preview description";
|
|
34
|
+
readonly "Preview image": "Preview image";
|
|
35
|
+
readonly "X/Twitter title": "X/Twitter title";
|
|
36
|
+
readonly "X/Twitter description": "X/Twitter description";
|
|
37
|
+
readonly "X/Twitter image": "X/Twitter image";
|
|
38
|
+
readonly "Customize for X/Twitter (optional)": "Customize for X/Twitter (optional)";
|
|
39
|
+
readonly "Same as the preview image": "Same as the preview image";
|
|
40
|
+
readonly "Used by Facebook, LinkedIn, Slack, Discord, iMessage, WhatsApp and X/Twitter": "Used by Facebook, LinkedIn, Slack, Discord, iMessage, WhatsApp and X/Twitter";
|
|
38
41
|
readonly "Saving\u2026": "Saving…";
|
|
39
42
|
readonly "Save overrides": "Save overrides";
|
|
40
43
|
readonly "Per-entry SEO": "Per-entry SEO";
|
|
41
44
|
readonly "Search appearance": "Search appearance";
|
|
42
45
|
readonly "Title template (must contain %s)": "Title template (must contain %s)";
|
|
43
46
|
readonly "Default meta description": "Default meta description";
|
|
44
|
-
readonly "Social
|
|
45
|
-
readonly "Default
|
|
46
|
-
readonly "Twitter @
|
|
47
|
+
readonly "Social preview": "Social preview";
|
|
48
|
+
readonly "Default preview image": "Default preview image";
|
|
49
|
+
readonly "X/Twitter @handle": "X/Twitter @handle";
|
|
47
50
|
readonly Crawling: "Crawling";
|
|
48
51
|
readonly "Default noindex": "Default noindex";
|
|
49
52
|
readonly "Default nofollow": "Default nofollow";
|
|
@@ -56,7 +59,7 @@ export declare const seoModule: {
|
|
|
56
59
|
readonly "View sitemap": "View sitemap";
|
|
57
60
|
readonly "Loading SEO settings\u2026": "Loading SEO settings…";
|
|
58
61
|
readonly Marketing: "Marketing";
|
|
59
|
-
readonly "Site-wide defaults for meta titles, descriptions,
|
|
62
|
+
readonly "Site-wide defaults for meta titles, descriptions, social previews, and robots directives.": "Site-wide defaults for meta titles, descriptions, social previews, and robots directives.";
|
|
60
63
|
readonly "Choose image": "Choose image";
|
|
61
64
|
readonly Remove: "Remove";
|
|
62
65
|
readonly "Sitemap \u00B7 {count} URLs": "Sitemap · {count} URLs";
|
|
@@ -84,6 +87,8 @@ export declare const seoModule: {
|
|
|
84
87
|
readonly "failed to load the sitemap": "failed to load the sitemap";
|
|
85
88
|
readonly "Could not load the sitemap (HTTP {status}).": "Could not load the sitemap (HTTP {status}).";
|
|
86
89
|
readonly "Selected image": "Selected image";
|
|
90
|
+
readonly "Using site default: {name}": "Using site default: {name}";
|
|
91
|
+
readonly "Using image from page content: {name}": "Using image from page content: {name}";
|
|
87
92
|
};
|
|
88
93
|
readonly pages: {
|
|
89
94
|
readonly settings: {
|