@jini-ai/admin 0.6.15-rc.1 → 0.6.15-rc.2

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.
@@ -1,4 +1,4 @@
1
- import { FIELD_TYPE_REGISTRY } from "@jini-ai/cms/forms";
1
+ import { FIELD_TYPE_REGISTRY, escapeFormHtml } from "@jini-ai/cms/forms";
2
2
  /** Persisted descriptors, including those derived from HTML, supply column headers, choice labels and field types. */
3
3
  export function formAnswerColumns({ fields }, display = {}) {
4
4
  return fields.map((field) => {
@@ -85,9 +85,6 @@ export function formSubmissionRows({ data, answerColumns }, { unknownFieldLabel
85
85
  return { key, label: column?.header.trim() || unknownFieldLabel, value: formatted, file: formAnswerFile({ value }) };
86
86
  });
87
87
  }
88
- function escapeHtml(value) {
89
- return value.replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[char]);
90
- }
91
88
  const kindOf = (type) => Object.hasOwn(FIELD_TYPE_REGISTRY, type) ? FIELD_TYPE_REGISTRY[type] : { control: "input", htmlType: "text" };
92
89
  const isDefault = (field, value) => Array.isArray(field.default) ? field.default.includes(value) : field.default === value;
93
90
  /** Name, required and the stored constraints, as authored HTML attributes the CMS reads back. */
@@ -97,13 +94,13 @@ function starterAttributes(field, kind) {
97
94
  ["pattern", field.pattern], ["min", field.min], ["max", field.max], ["step", field.step], ["accept", field.accept?.join(",")],
98
95
  ...Object.entries(kind.attributes ?? {}),
99
96
  ];
100
- return `name="${escapeHtml(field.id)}"${field.required ? " required" : ""}` +
101
- constraints.filter(([, value]) => value != null && value !== "").map(([name, value]) => ` ${name}="${escapeHtml(String(value))}"`).join("");
97
+ return `name="${escapeFormHtml({ value: field.id })}"${field.required ? " required" : ""}` +
98
+ constraints.filter(([, value]) => value != null && value !== "").map(([name, value]) => ` ${name}="${escapeFormHtml({ value: String(value) })}"`).join("");
102
99
  }
103
100
  /** One field's starter markup; every control comes from the registry kind, as the public render does. */
104
101
  function starterField(field) {
105
102
  const kind = kindOf(field.type);
106
- return starterRendererFor({ kind })({ field, kind, label: escapeHtml(field.label), attrs: starterAttributes(field, kind) });
103
+ return starterRendererFor({ kind })({ field, kind, label: escapeFormHtml({ value: field.label }), attrs: starterAttributes(field, kind) });
107
104
  }
108
105
  function starterRendererFor({ kind }) {
109
106
  if (kind.control === "group")
@@ -113,7 +110,7 @@ function starterRendererFor({ kind }) {
113
110
  if (kind.control === "textarea")
114
111
  return ({ label, attrs }) => `<label>${label}\n <textarea ${attrs}></textarea>\n</label>`;
115
112
  if (kind.htmlType === "hidden")
116
- return ({ field, label, attrs }) => `<input type="hidden" ${attrs} value="${escapeHtml(String(field.default ?? ""))}" aria-label="${label}">`;
113
+ return ({ field, label, attrs }) => `<input type="hidden" ${attrs} value="${escapeFormHtml({ value: String(field.default ?? "") })}" aria-label="${label}">`;
117
114
  if (kind.htmlType === "checkbox")
118
115
  return starterCheckbox;
119
116
  return ({ label, attrs }) => `<label>${label}\n <input type="${kind.htmlType}" ${attrs}>\n</label>`;
@@ -123,16 +120,16 @@ function starterGroup({ field, kind, label, attrs }) {
123
120
  // Native `required` on every checkbox would demand ALL of them, so a checkbox group leaves it
124
121
  // off (the server still enforces one-or-more); a radio group's `required` means "pick one".
125
122
  const boxAttrs = kind.multiple ? starterAttributes({ ...field, required: false }, kind) : attrs;
126
- const boxes = (field.options ?? []).map((option) => ` <label><input type="${kind.htmlType}" ${boxAttrs} value="${escapeHtml(option.value)}"${isDefault(field, option.value) ? " checked" : ""}> ${escapeHtml(option.label)}</label>`);
123
+ const boxes = (field.options ?? []).map((option) => ` <label><input type="${kind.htmlType}" ${boxAttrs} value="${escapeFormHtml({ value: option.value })}"${isDefault(field, option.value) ? " checked" : ""}> ${escapeFormHtml({ value: option.label })}</label>`);
127
124
  return `<fieldset>\n <legend>${label}</legend>\n${boxes.join("\n")}\n</fieldset>`;
128
125
  }
129
126
  function starterSelect({ field, kind, label, attrs }) {
130
- const empty = kind.multiple ? [] : [` <option value="">${escapeHtml(field.placeholder ?? "Choose an option")}</option>`];
131
- const choices = (field.options ?? []).map((option) => ` <option value="${escapeHtml(option.value)}"${isDefault(field, option.value) ? " selected" : ""}>${escapeHtml(option.label)}</option>`);
127
+ const empty = kind.multiple ? [] : [` <option value="">${escapeFormHtml({ value: field.placeholder ?? "Choose an option" })}</option>`];
128
+ const choices = (field.options ?? []).map((option) => ` <option value="${escapeFormHtml({ value: option.value })}"${isDefault(field, option.value) ? " selected" : ""}>${escapeFormHtml({ value: option.label })}</option>`);
132
129
  return `<label>${label}\n <select ${attrs}${kind.multiple ? " multiple" : ""}>\n${[...empty, ...choices].join("\n")}\n </select>\n</label>`;
133
130
  }
134
131
  function starterCheckbox({ field, label, attrs }) {
135
- const value = field.checkboxValue !== undefined ? ` value="${escapeHtml(field.checkboxValue)}"` : "";
132
+ const value = field.checkboxValue !== undefined ? ` value="${escapeFormHtml({ value: field.checkboxValue })}"` : "";
136
133
  const checked = field.default === true || (field.checkboxValue !== undefined && field.default === field.checkboxValue) ? " checked" : "";
137
134
  return `<label><input type="checkbox"${field.type === "toggle" ? ' role="switch"' : ""} ${attrs}${value}${checked}> ${label}</label>`;
138
135
  }
@@ -144,7 +141,7 @@ function starterCheckbox({ field, label, attrs }) {
144
141
  * @complexity O(f + o) for fields and their options; no I/O.
145
142
  */
146
143
  export function formHtmlStarter({ fields }, { submitLabel = "Send" } = {}) {
147
- return fields.map(starterField).join("\n\n") + `\n\n<button type="submit">${escapeHtml(submitLabel)}</button>`;
144
+ return fields.map(starterField).join("\n\n") + `\n\n<button type="submit">${escapeFormHtml({ value: submitLabel })}</button>`;
148
145
  }
149
146
  export function formHtmlEmbed({ slug }, _optional = {}) {
150
147
  const config = JSON.stringify({ type: "form", id: slug, mode: "html" }).replace(/'/g, "&#39;");
@@ -1,13 +1,6 @@
1
- /** Browser-only live metadata hint, separate from server validation across the browser/Node
2
- * boundary. The server must enforce the same policy on writes and rendering independently.
3
- * Hints never gate Save: an invalid attribute once silently blocked saving unrelated title/alt/
4
- * caption/credit/class changes. Rejections must instead reach the authoritative atomic write.
5
- * One flat allowlist includes global and image/video extras: per-element name filtering is
6
- * unnecessary because an unused loading attribute on a wrapper is harmless. Extend the list,
7
- * not the tokenizer, when adding an allowed name. data-/aria- families remain open-ended. */
8
1
  export declare const MEDIA_HTML_ATTRIBUTE_ALLOWED_NAMES: readonly ["class", "id", "style", "title", "role", "tabindex", "lang", "dir", "hidden", "translate", "draggable", "loading", "decoding", "playsinline", "muted", "loop", "autoplay", "poster"];
9
2
  /** Whether `name` is on the allowlist — a well-formed attribute name
10
- * ({@link MEDIA_HTML_ATTRIBUTE_NAME_PATTERN}) that is either an exact match against
3
+ * (`/^[a-z][a-z0-9-]*$/`) that is either an exact match against
11
4
  * {@link MEDIA_HTML_ATTRIBUTE_ALLOWED_NAMES} or carries a `data-`/`aria-` prefix (both open-ended
12
5
  * families with no fixed suffix list). Case-insensitive: HTML attribute names are themselves
13
6
  * case-insensitive, and an operator typing `DATA-FOO` should not slip past a lowercase-only check.
@@ -5,44 +5,10 @@
5
5
  * One flat allowlist includes global and image/video extras: per-element name filtering is
6
6
  * unnecessary because an unused loading attribute on a wrapper is harmless. Extend the list,
7
7
  * not the tokenizer, when adding an allowed name. data-/aria- families remain open-ended. */
8
- export const MEDIA_HTML_ATTRIBUTE_ALLOWED_NAMES = [
9
- // Global attributes (2026-09-23).
10
- "class",
11
- "id",
12
- "style",
13
- "title",
14
- "role",
15
- "tabindex",
16
- "lang",
17
- "dir",
18
- "hidden",
19
- "translate",
20
- "draggable",
21
- // Pre-existing <img>/<video> extras, kept everywhere (one list, not one per element).
22
- "loading",
23
- "decoding",
24
- "playsinline",
25
- "muted",
26
- "loop",
27
- "autoplay",
28
- "poster",
29
- ];
30
- /**
31
- * The shape EVERY accepted attribute name must have, checked before allowlist membership
32
- * (2026-09-07 stored-XSS fix; keep identical in `@jini-ai/cms/media`'s `html-attributes.ts` — see
33
- * that file's copy of this comment for the full incident).
34
- *
35
- * The `data-`/`aria-` families below are open-ended by design — no fixed suffix list — so the
36
- * prefix check alone accepted whatever characters {@link HTML_ATTRIBUTE_TOKEN}'s name class
37
- * (`[^\s="']+`, which excludes only whitespace, `=` and quotes) let through. `<`, `>` and `/` are
38
- * all legal in that class, and the server renderer templating a name into ` name="value"` escapes
39
- * only the VALUE — so a stored `data-x><svg/onload=alert(1)` closed the `<img>` and opened a live
40
- * `<svg onload>` on the public page. Constraining the NAME to the characters a real HTML attribute
41
- * name can contain is what makes "the value is escaped" sufficient.
42
- */
43
- const MEDIA_HTML_ATTRIBUTE_NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
8
+ import { EMBED_HTML_ATTRIBUTE_ALLOWED_NAMES, isAllowedEmbedHtmlAttributeName, parseEmbedHtmlAttributes, } from "@jini-ai/cms/widgets/markers";
9
+ export const MEDIA_HTML_ATTRIBUTE_ALLOWED_NAMES = EMBED_HTML_ATTRIBUTE_ALLOWED_NAMES;
44
10
  /** Whether `name` is on the allowlist — a well-formed attribute name
45
- * ({@link MEDIA_HTML_ATTRIBUTE_NAME_PATTERN}) that is either an exact match against
11
+ * (`/^[a-z][a-z0-9-]*$/`) that is either an exact match against
46
12
  * {@link MEDIA_HTML_ATTRIBUTE_ALLOWED_NAMES} or carries a `data-`/`aria-` prefix (both open-ended
47
13
  * families with no fixed suffix list). Case-insensitive: HTML attribute names are themselves
48
14
  * case-insensitive, and an operator typing `DATA-FOO` should not slip past a lowercase-only check.
@@ -51,82 +17,7 @@ const MEDIA_HTML_ATTRIBUTE_NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
51
17
  * one fixed-length array lookup.
52
18
  */
53
19
  export function isAllowedMediaHtmlAttributeName({ name }, _optional = {}) {
54
- const lower = name.toLowerCase();
55
- if (!MEDIA_HTML_ATTRIBUTE_NAME_PATTERN.test(lower))
56
- return false;
57
- if (lower.startsWith("data-") || lower.startsWith("aria-"))
58
- return true;
59
- return MEDIA_HTML_ATTRIBUTE_ALLOWED_NAMES.includes(lower);
60
- }
61
- /** Matches one `name`, or one `name="value"`/`name='value'`/`name=value` pair — the same loose
62
- * shape real HTML attribute syntax allows, since that is the syntax an operator typing this field
63
- * would naturally reach for. */
64
- const HTML_ATTRIBUTE_TOKEN = /([^\s="']+)(?:=(?:"([^"]*)"|'([^']*)'|([^\s"']+)))?/g;
65
- /** The highest ASCII code point {@link isUnsafeMediaUrlValue} strips: every C0 control character
66
- * plus the ordinary space, i.e. code points `0`-`32` inclusive. Named rather than written as an
67
- * inline regex control-character range so the linter never has to reason about a literal control
68
- * character inside a pattern. Ported from the shared server parser, `html-attributes.ts`. */
69
- const ASCII_WHITESPACE_OR_CONTROL_MAX_CODE_POINT = 0x20;
70
- /**
71
- * Whether `value`'s scheme, once every ASCII whitespace/control character (code points `0`-`32`) is
72
- * stripped and the result lowercased, starts with `javascript:`, `vbscript:` or `data:` — the three
73
- * schemes a browser will still execute or navigate to even when the URL carries interior whitespace
74
- * a naive end-trim (the old `.trim()`-only check) would miss, e.g. `java\tscript:x`: browsers strip
75
- * tabs and newlines from inside a URL before parsing its scheme, so this check must too. Ported
76
- * 2026-09-23 from the shared server parser's `isUnsafeUrlValue` (`html-attributes.ts`).
77
- *
78
- * @complexity O(n) in the value's length (one filter pass, one prefix test).
79
- */
80
- function isUnsafeMediaUrlValue(value) {
81
- let stripped = "";
82
- for (const char of value) {
83
- // `char` is never empty, so `charCodeAt(0)` is always a number; for an astral character it is
84
- // the high surrogate (>= 0xD800), which is kept exactly as the full code point would be.
85
- if (char.charCodeAt(0) > ASCII_WHITESPACE_OR_CONTROL_MAX_CODE_POINT)
86
- stripped += char;
87
- }
88
- stripped = stripped.toLowerCase();
89
- return stripped.startsWith("javascript:") || stripped.startsWith("vbscript:") || stripped.startsWith("data:");
90
- }
91
- /**
92
- * Whether a `style` value carries a known CSS injection or CSS-escape-bypass vector:
93
- * `expression(...)` (legacy IE script-in-CSS), a `javascript:`/`vbscript:` value inside a CSS
94
- * `url(...)`, `-moz-binding`/`behavior:` (XBL/HTC script bindings), `@import` (pulls in a second,
95
- * unvetted stylesheet), or ANY backslash — a CSS escape sequence (`\65` is `e`) can respell any of
96
- * the above past a plain substring check, so a backslash anywhere in the value is rejected outright.
97
- * Plain `url(...)` values (background images) are otherwise allowed. Ported 2026-09-23 from the
98
- * shared server parser's `isUnsafeStyleValue` (`html-attributes.ts`).
99
- *
100
- * @complexity O(n) in the value's length (one regex test).
101
- */
102
- const UNSAFE_MEDIA_STYLE_PATTERN = /expression\(|javascript:|vbscript:|-moz-binding|behavior:|@import|\\/i;
103
- function isUnsafeMediaStyleValue(value) {
104
- return UNSAFE_MEDIA_STYLE_PATTERN.test(value);
105
- }
106
- /** Classifies one already-tokenized `name`/`value` pair. Split out of {@link parseMediaHtmlAttributes}
107
- * so each rejection reason is its own directly testable branch, and so the loop below reads as
108
- * "tokenize, then classify" rather than one function doing both.
109
- *
110
- * Order matters (ported 2026-09-23 from the shared server parser's `classifyEmbedHtmlAttributeToken`):
111
- * `on*` is checked FIRST (so an `onerror` always reports as `event-handler`, the more actionable
112
- * reason, never the generic `disallowed-name`), then the URL-scheme check runs against EVERY value
113
- * regardless of name (matching the pre-existing `javascript:` check this is ported from), then
114
- * allowlist membership, then — only for an already-allowed `style` — the CSS-specific unsafe-value
115
- * check.
116
- *
117
- * @complexity O(1) — four fixed checks against one already-extracted token; each check's own O(n) in
118
- * the token's length is linear, not nested.
119
- */
120
- function classifyMediaHtmlAttributeToken(name, value) {
121
- if (name.startsWith("on"))
122
- return { reason: "event-handler", attribute: name };
123
- if (isUnsafeMediaUrlValue(value))
124
- return { reason: "unsafe-url", attribute: name };
125
- if (!isAllowedMediaHtmlAttributeName({ name }))
126
- return { reason: "disallowed-name", attribute: name };
127
- if (name === "style" && isUnsafeMediaStyleValue(value))
128
- return { reason: "unsafe-style", attribute: name };
129
- return null;
20
+ return isAllowedEmbedHtmlAttributeName({ name });
130
21
  }
131
22
  /**
132
23
  * Parses the `HTML attributes` field's free text (`name="value" name2="value2"`, or a bare
@@ -154,54 +45,8 @@ function classifyMediaHtmlAttributeToken(name, value) {
154
45
  * attributes plus O(e) for e rejected tokens.
155
46
  */
156
47
  export function parseMediaHtmlAttributes({ text }, _optional = {}) {
157
- const trimmed = text.trim();
158
- if (trimmed === "")
159
- return { attributes: {}, errors: [], error: null };
160
- const attributes = {};
161
- const errors = [];
162
- HTML_ATTRIBUTE_TOKEN.lastIndex = 0;
163
- let consumed = 0;
164
- // A `for` loop (rather than `while ((match = …exec(trimmed)) !== null)`) keeps every assignment
165
- // to `match` in statement position, never inside the loop's own test expression.
166
- for (let match = HTML_ATTRIBUTE_TOKEN.exec(trimmed); match !== null; match = HTML_ATTRIBUTE_TOKEN.exec(trimmed)) {
167
- // Non-whitespace text between the previous match and this one is a fragment the token pattern
168
- // could not parse as a name (e.g. a stray quote) — the tokenizer has lost sync, so everything
169
- // parsed so far is discarded rather than trusted.
170
- const skipped = trimmed.slice(consumed, match.index).trim();
171
- if (skipped !== "")
172
- return malformedMediaHtmlAttributes({ errors, fragment: skipped });
173
- consumed = match.index + match[0].length;
174
- const { name, value } = readMediaHtmlAttributeToken({ match });
175
- const rejection = classifyMediaHtmlAttributeToken(name, value);
176
- if (rejection) {
177
- errors.push(rejection);
178
- continue;
179
- }
180
- attributes[name] = value;
181
- }
182
- const trailing = trimmed.slice(consumed).trim();
183
- if (trailing !== "")
184
- return malformedMediaHtmlAttributes({ errors, fragment: trailing });
185
- return { attributes, errors, error: errors[0] ?? null };
186
- }
187
- /** The lowercased name and the value of one {@link HTML_ATTRIBUTE_TOKEN} match: whichever of the
188
- * double-quoted, single-quoted or unquoted value groups matched, or `""` for a bare boolean name.
189
- *
190
- * @complexity O(n) in the name's length (one lowercase pass).
191
- */
192
- function readMediaHtmlAttributeToken({ match }, _optional = {}) {
193
- return { name: match[1].toLowerCase(), value: match[2] ?? match[3] ?? match[4] ?? "" };
194
- }
195
- /** The fail-closed result once the tokenizer loses sync: `fragment` (already trimmed) is appended
196
- * to `errors` as the final `malformed` entry, and every accepted attribute is discarded — see
197
- * {@link parseMediaHtmlAttributes}'s header for why. Mutates `errors` (the caller's own list).
198
- *
199
- * @complexity O(1).
200
- */
201
- function malformedMediaHtmlAttributes({ errors, fragment }, _optional = {}) {
202
- const malformed = { reason: "malformed", attribute: fragment };
203
- errors.push(malformed);
204
- return { attributes: {}, errors, error: malformed };
48
+ // Media hints always use inline-attribute policy; stylesheet escape allowances do not apply.
49
+ return parseEmbedHtmlAttributes({ text });
205
50
  }
206
51
  /** The hint names the rejected token so operators can distinguish a typo from a hard no.
207
52
  * This is client feedback only; the server must independently enforce write/render policy. */
@@ -1,4 +1,4 @@
1
- import type { FormEvent, ComponentType } from 'react';
1
+ import type { FormEvent } from 'react';
2
2
  import type { TabBarTab } from '@jini-ai/ui/tab-strip';
3
3
  import type { SeoTranslator, SeoSettingsPatch, SeoSettings, SeoEntryAnalysis } from '../../models.js';
4
4
  export { buildSeoSettingsPatch, resolveSeoTabId } from '../../rules.js';
@@ -47,24 +47,4 @@ export interface SeoTabController {
47
47
  regenerateSitemap: () => void;
48
48
  openSitemapModal: () => void;
49
49
  }
50
- /** Panel selection is presentation dispatch; this never changes the defaults form's field set. */
51
- export declare function seoTabPanel({ activeTabId, controller, components }: {
52
- activeTabId: import('../../rules.js').SeoTabId;
53
- controller: SeoTabController;
54
- components: {
55
- SeoDefaultsTab: ComponentType<{
56
- controller: SeoTabController;
57
- }>;
58
- SeoSitemapTab: ComponentType<{
59
- controller: SeoTabController;
60
- }>;
61
- SeoEntrySection: ComponentType<{
62
- locale: string;
63
- }>;
64
- };
65
- }, _optional?: Record<string, never>): import("react").ReactElement<{
66
- controller: SeoTabController;
67
- }, string | import("react").JSXElementConstructor<any>> | import("react").ReactElement<{
68
- locale: string;
69
- }, string | import("react").JSXElementConstructor<any>>;
70
50
  //# sourceMappingURL=SeoPage.hooks.d.ts.map
@@ -26,12 +26,4 @@ export function useAnalyzePanel({ analysis }, _optional = {}) {
26
26
  export function useSeoDefaults({ save }, _optional = {}) {
27
27
  return { submit: (event) => { event.preventDefault(); void save(buildSeoSettingsPatch({ form: new FormData(event.currentTarget) })); } };
28
28
  }
29
- /** Panel selection is presentation dispatch; this never changes the defaults form's field set. */
30
- export function seoTabPanel({ activeTabId, controller, components }, _optional = {}) {
31
- if (activeTabId === 'sitemap')
32
- return createElement(components.SeoSitemapTab, { controller });
33
- if (activeTabId === 'entries')
34
- return createElement(components.SeoEntrySection, { locale: controller.locale });
35
- return createElement(components.SeoDefaultsTab, { controller });
36
- }
37
29
  //# sourceMappingURL=SeoPage.hooks.js.map
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { useAnalyzePanel, useSeoDefaults, useSeoTabs, seoTabPanel } from "../hooks/SeoPage.hooks.js";
2
+ import { useAnalyzePanel, useSeoDefaults, useSeoTabs } from "../hooks/SeoPage.hooks.js";
3
3
  import { agentHandle } from "@jini-ai/agentic";
4
4
  import { TabBar } from "@jini-ai/ui/tab-strip";
5
5
  import { InfoTip, Select } from "@jini-ai/ui/admin-widgets";
@@ -230,7 +230,15 @@ export function Seo({ tabId, onTabChange, useSeoHook = useWiredSeo } = {}) {
230
230
  regenerateSitemap,
231
231
  openSitemapModal,
232
232
  };
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] }));
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 }), sitemapModalOpen ? (_jsx(SitemapModal, { locale: locale, sitemapEnabled: settings.sitemapEnabled, regenerating: saving, onRegenerate: regenerateSitemap, onClose: closeSitemapModal })) : null] }));
234
+ }
235
+ /** Panel selection is presentation dispatch; this never changes the defaults form's field set. */
236
+ function seoTabPanel({ activeTabId, controller }, _optional = {}) {
237
+ if (activeTabId === 'sitemap')
238
+ return _jsx(SeoSitemapTab, { controller: controller });
239
+ if (activeTabId === 'entries')
240
+ return _jsx(SeoEntrySection, { locale: controller.locale });
241
+ return _jsx(SeoDefaultsTab, { controller: controller });
234
242
  }
235
243
  export { Seo as SeoPage };
236
244
  export default Seo;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jini-ai/admin",
3
- "version": "0.6.15-rc.1",
3
+ "version": "0.6.15-rc.2",
4
4
  "description": "Composable admin surface for Jini-hosted products: framework-free contracts, panel registry, and route model in `/core`; React shell and panels in `/react`. Import only the layers you need.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -448,12 +448,12 @@
448
448
  "peerDependencies": {
449
449
  "react": "^18.3.0 || ^19.0.0",
450
450
  "react-dom": "^18.3.0 || ^19.0.0",
451
- "@jini-ai/agentic": "^0.6.15-rc.1",
452
- "@jini-ai/ui": "^0.6.15-rc.1",
453
- "@jini-ai/ui-kit": "^0.6.15-rc.1",
454
- "@jini-ai/agent-plugins": "^0.6.15-rc.1",
455
- "@jini-ai/cms": "^0.6.15-rc.1",
456
- "@jini-ai/core": "^0.6.15-rc.1"
451
+ "@jini-ai/agentic": "^0.6.15-rc.2",
452
+ "@jini-ai/ui": "^0.6.15-rc.2",
453
+ "@jini-ai/ui-kit": "^0.6.15-rc.2",
454
+ "@jini-ai/agent-plugins": "^0.6.15-rc.2",
455
+ "@jini-ai/cms": "^0.6.15-rc.2",
456
+ "@jini-ai/core": "^0.6.15-rc.2"
457
457
  },
458
458
  "peerDependenciesMeta": {
459
459
  "react": {
@@ -491,12 +491,12 @@
491
491
  "react": "^19.2.0",
492
492
  "react-dom": "^19.2.0",
493
493
  "@testing-library/user-event": "^14.6.1",
494
- "@jini-ai/ui": "^0.6.15-rc.1",
495
- "@jini-ai/ui-kit": "^0.6.15-rc.1",
496
- "@jini-ai/core": "^0.6.15-rc.1",
497
- "@jini-ai/agentic": "^0.6.15-rc.1",
498
- "@jini-ai/cms": "^0.6.15-rc.1",
499
- "@jini-ai/agent-plugins": "^0.6.15-rc.1"
494
+ "@jini-ai/agentic": "^0.6.15-rc.2",
495
+ "@jini-ai/core": "^0.6.15-rc.2",
496
+ "@jini-ai/agent-plugins": "^0.6.15-rc.2",
497
+ "@jini-ai/cms": "^0.6.15-rc.2",
498
+ "@jini-ai/ui": "^0.6.15-rc.2",
499
+ "@jini-ai/ui-kit": "^0.6.15-rc.2"
500
500
  },
501
501
  "typesVersions": {
502
502
  "*": {