@jini-ai/admin 0.4.11 → 0.4.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/dist/agent-plugins/controllers/agent-plugin-files.controller.js +14 -9
  3. package/dist/agent-plugins/controllers/agent-plugins.controller.js +12 -9
  4. package/dist/agent-plugins/package-file-tree.js +54 -51
  5. package/dist/agent-plugins/react/components/AgentPluginRow.d.ts +1 -1
  6. package/dist/agent-plugins/react/components/AgentPluginRow.js +9 -2
  7. package/dist/agent-plugins/react/hooks/AgentPluginInspector.hooks.d.ts +5 -5
  8. package/dist/agent-plugins/react/hooks/AgentPluginInspector.hooks.js +14 -5
  9. package/dist/agent-plugins/react/hooks/AgentPluginPanel.hooks.d.ts +15 -13
  10. package/dist/agent-plugins/react/hooks/AgentPluginPanel.hooks.js +54 -40
  11. package/dist/agent-plugins/react/hooks/AgentPluginsPage.hooks.d.ts +13 -13
  12. package/dist/agent-plugins/react/hooks/AgentPluginsPage.hooks.js +23 -11
  13. package/dist/browser/navigation.js +44 -27
  14. package/dist/comments/adapters/memory.js +30 -12
  15. package/dist/comments/conformance/comments-api.conformance.js +55 -33
  16. package/dist/comments/controllers/comment-queue.controller.js +13 -4
  17. package/dist/comments/react/hooks/use-comment-queue.hooks.d.ts +17 -2
  18. package/dist/comments/react/hooks/use-comment-queue.hooks.js +79 -30
  19. package/dist/comments/react/hooks/use-comment-settings.hooks.js +9 -5
  20. package/dist/comments/react/index.d.ts +2 -1
  21. package/dist/comments/react/index.js +1 -1
  22. package/dist/consent/react/ConsentFields.d.ts +35 -0
  23. package/dist/consent/react/ConsentFields.js +74 -0
  24. package/dist/consent/react/ConsentPreviewFrame.d.ts +14 -0
  25. package/dist/consent/react/ConsentPreviewFrame.js +14 -0
  26. package/dist/consent/react/ConsentSettingsPanel.d.ts +27 -4
  27. package/dist/consent/react/ConsentSettingsPanel.js +6 -17
  28. package/dist/consent/react/index.d.ts +6 -1
  29. package/dist/consent/react/index.js +2 -0
  30. package/dist/consent/react/settings-panel.hooks.d.ts +28 -1
  31. package/dist/consent/react/settings-panel.hooks.js +26 -0
  32. package/dist/core/entities/rules.js +126 -81
  33. package/dist/core/manifest/rules.js +10 -10
  34. package/dist/core/module/create-admin.js +195 -117
  35. package/dist/core/ports/redirects.d.ts +8 -6
  36. package/dist/core/ports/redirects.js +8 -6
  37. package/dist/core/ports/seo.d.ts +20 -0
  38. package/dist/core/routing/rules.js +9 -4
  39. package/dist/core/transport/http.js +37 -20
  40. package/dist/forms/adapters/memory.js +22 -11
  41. package/dist/forms/conformance/forms-api.conformance.d.ts +6 -4
  42. package/dist/forms/conformance/forms-api.conformance.js +42 -46
  43. package/dist/forms/field-settings.js +150 -115
  44. package/dist/forms/html-rules.js +45 -28
  45. package/dist/forms/react/components/FieldSettingsRows.d.ts +22 -0
  46. package/dist/forms/react/components/FieldSettingsRows.js +29 -0
  47. package/dist/forms/react/components/FieldSettingsSection.d.ts +1 -1
  48. package/dist/forms/react/components/FieldSettingsSection.js +5 -6
  49. package/dist/forms/react/components/FormEditorFieldsBody.d.ts +1 -1
  50. package/dist/forms/react/components/FormEditorFieldsBody.js +4 -15
  51. package/dist/forms/react/components/FormEditorFieldsBodyParts.d.ts +18 -0
  52. package/dist/forms/react/components/FormEditorFieldsBodyParts.js +32 -0
  53. package/dist/forms/react/components/FormSubmissions.d.ts +1 -1
  54. package/dist/forms/react/components/FormSubmissions.js +4 -20
  55. package/dist/forms/react/components/FormSubmissionsTable.d.ts +19 -0
  56. package/dist/forms/react/components/FormSubmissionsTable.js +22 -0
  57. package/dist/media/adapters/memory.js +31 -26
  58. package/dist/media/controllers/edit-media.controller.js +2 -1
  59. package/dist/media/controllers/library.controller.js +48 -35
  60. package/dist/media/controllers/providers.controller.js +2 -1
  61. package/dist/media/html-attributes.js +29 -15
  62. package/dist/media/index.d.ts +1 -1
  63. package/dist/media/index.js +1 -1
  64. package/dist/media/react/components/MediaIcons.d.ts +0 -3
  65. package/dist/media/react/components/MediaIcons.js +0 -9
  66. package/dist/media/react/components/MediaLightbox.d.ts +2 -1
  67. package/dist/media/react/components/MediaLightbox.js +11 -5
  68. package/dist/media/react/hooks/LibraryTab.hooks.d.ts +55 -45
  69. package/dist/media/react/hooks/LibraryTab.hooks.js +107 -70
  70. package/dist/media/react/hooks/MediaCard.hooks.d.ts +27 -31
  71. package/dist/media/react/hooks/MediaCard.hooks.js +52 -26
  72. package/dist/media/react/hooks/MediaGrid.hooks.d.ts +1 -1
  73. package/dist/media/react/hooks/MediaLightbox.hooks.d.ts +10 -14
  74. package/dist/media/react/hooks/MediaLightbox.hooks.js +9 -56
  75. package/dist/media/react/hooks/MediaPage.hooks.d.ts +6 -0
  76. package/dist/media/react/hooks/MediaPage.hooks.js +29 -12
  77. package/dist/media/react/hooks/ProvidersTab.hooks.d.ts +33 -33
  78. package/dist/media/react/hooks/ProvidersTab.hooks.js +119 -79
  79. package/dist/media/react/hooks/UploadButton.hooks.d.ts +7 -5
  80. package/dist/media/react/hooks/UploadButton.hooks.js +36 -24
  81. package/dist/media/react/index.js +4 -0
  82. package/dist/media/react/tabs/LibraryTab.d.ts +1 -1
  83. package/dist/media/react/tabs/LibraryTab.js +2 -2
  84. package/dist/media/rules.d.ts +7 -0
  85. package/dist/media/rules.js +25 -2
  86. package/dist/react/components/ConfirmDialog/ConfirmDialog.js +5 -8
  87. package/dist/react/components/ConfirmDialog/confirm-dialog-attrs.d.ts +20 -0
  88. package/dist/react/components/ConfirmDialog/confirm-dialog-attrs.js +24 -0
  89. package/dist/react/components/InteractiveHtmlEditor/InteractiveHtmlEditor.js +16 -7
  90. package/dist/react/components/RowMenu/RowMenu.js +16 -10
  91. package/dist/react/components/Sidebar.js +32 -26
  92. package/dist/react/entities/EntityEdit.js +17 -14
  93. package/dist/react/entities/EntityField.d.ts +5 -2
  94. package/dist/react/entities/EntityField.js +38 -23
  95. package/dist/react/entities/EntityList.js +6 -5
  96. package/dist/react/entities/RelationTruncationNotice.d.ts +9 -0
  97. package/dist/react/entities/RelationTruncationNotice.js +9 -0
  98. package/dist/react/entities/use-entity-edit.d.ts +4 -2
  99. package/dist/react/entities/use-entity-edit.js +52 -33
  100. package/dist/react/shell/AdminShell.js +40 -23
  101. package/dist/react/shell/model.d.ts +4 -1
  102. package/dist/react/shell/model.js +3 -2
  103. package/dist/react/shell/use-admin-shell-session.js +25 -26
  104. package/dist/redirects/adapters/memory.js +9 -2
  105. package/dist/redirects/conformance/redirects-api.conformance.js +66 -36
  106. package/dist/redirects/react/hooks/use-import-redirects-form.hooks.d.ts +1 -1
  107. package/dist/redirects/react/hooks/use-import-redirects-form.hooks.js +10 -3
  108. package/dist/redirects/react/hooks/wired.hooks.d.ts +1 -1
  109. package/dist/security/adapters/http.js +25 -10
  110. package/dist/security/adapters/memory.js +19 -5
  111. package/dist/security/controllers/access-tokens.controller.js +27 -26
  112. package/dist/security/controllers/other-credentials.controller.js +4 -2
  113. package/dist/security/controllers/root-key.controller.js +17 -9
  114. package/dist/security/react/hooks/AccessTokensTab.hooks.d.ts +40 -38
  115. package/dist/security/react/hooks/AccessTokensTab.hooks.js +36 -9
  116. package/dist/security/react/hooks/RootKeyTab.hooks.d.ts +14 -14
  117. package/dist/security/react/hooks/RootKeyTab.hooks.js +12 -3
  118. package/dist/security/react/tabs/RootKeyTab.d.ts +1 -1
  119. package/dist/security/react/tabs/RootKeyTab.js +12 -3
  120. package/dist/security/rules.js +45 -25
  121. package/dist/seo/adapters/http.js +5 -0
  122. package/dist/seo/controllers/seo.controller.js +4 -2
  123. package/dist/seo/messages.en.d.ts +15 -10
  124. package/dist/seo/messages.en.js +15 -10
  125. package/dist/seo/models.d.ts +11 -0
  126. package/dist/seo/react/components/MediaRefField.d.ts +17 -5
  127. package/dist/seo/react/components/MediaRefField.js +31 -4
  128. package/dist/seo/react/hooks/MediaRefField.hooks.js +12 -2
  129. package/dist/seo/react/hooks/SeoPage.hooks.d.ts +1 -0
  130. package/dist/seo/react/hooks/SeoPage.hooks.js +2 -2
  131. package/dist/seo/react/hooks/SeoQuery.hooks.d.ts +1 -0
  132. package/dist/seo/react/hooks/SeoQuery.hooks.js +1 -0
  133. package/dist/seo/react/hooks/use-entry-picker.hooks.js +7 -2
  134. package/dist/seo/react/hooks/use-seo-entry-panel.hooks.js +13 -2
  135. package/dist/seo/react/hooks/use-share-image-warning.hooks.d.ts +4 -1
  136. package/dist/seo/react/hooks/use-share-image-warning.hooks.js +48 -33
  137. package/dist/seo/react/index.d.ts +45 -30
  138. package/dist/seo/react/pages/SeoPage.d.ts +6 -4
  139. package/dist/seo/react/pages/SeoPage.js +51 -29
  140. package/dist/seo/rules.d.ts +91 -0
  141. package/dist/seo/rules.js +161 -6
  142. package/dist/seo/seo.module.d.ts +15 -10
  143. package/dist/source-control/conformance/source-control-api.conformance.js +68 -38
  144. package/dist/source-control/controllers/source-control.controller.js +20 -8
  145. package/dist/source-control/react/hooks/ProvidersTab.hooks.d.ts +2 -1
  146. package/dist/source-control/react/hooks/ProvidersTab.hooks.js +18 -11
  147. package/dist/widgets/conformance/widgets-api.conformance.js +15 -1
  148. package/dist/widgets/controllers/instance-editor.controller.js +33 -24
  149. package/dist/widgets/controllers/library.controller.js +18 -12
  150. package/dist/widgets/controllers/region-editor.controller.js +6 -5
  151. package/dist/widgets/react/hooks/page-views.hooks.d.ts +1 -0
  152. package/dist/widgets/react/hooks/page-views.hooks.js +3 -2
  153. package/dist/widgets/react/hooks/use-widget-regions.hooks.d.ts +1 -1
  154. package/dist/widgets/react/hooks/use-widget-regions.hooks.js +9 -3
  155. package/dist/widgets/react/hooks/use-widgets-library.hooks.js +9 -2
  156. package/dist/widgets/react/pages/WidgetInstanceEditor.d.ts +1 -1
  157. package/dist/widgets/react/pages/WidgetInstanceEditor.js +7 -8
  158. package/dist/widgets/rules.d.ts +14 -2
  159. package/dist/widgets/rules.js +9 -6
  160. package/package.json +19 -10
  161. package/src/themes/default/admin.css +51 -0
  162. package/src/themes/default/chat.css +21 -0
  163. package/src/themes/default/manifest.json +29 -0
  164. package/dist/media/styles.d.ts +0 -4
  165. package/dist/media/styles.js +0 -5
@@ -0,0 +1,74 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useId } from 'react';
3
+ import { pixelInputValue, problemsFor, useConsentSettingsPanel, usePolicyVersionInput } from './settings-panel.hooks.js';
4
+ const BANNER_FIELDS = [
5
+ { key: 'title', label: 'Banner title' }, { key: 'message', label: 'Banner message', multiline: true },
6
+ { key: 'acceptLabel', label: 'Accept button text' }, { key: 'rejectLabel', label: 'Reject button text' },
7
+ { key: 'preferencesLabel', label: 'Preferences label' }, { key: 'privacyPolicyUrl', label: 'Privacy policy page URL' },
8
+ { key: 'secondaryLinkLabel', label: 'Second link text' }, { key: 'secondaryLinkUrl', label: 'Second link URL' },
9
+ ];
10
+ const REGION_LABELS = {
11
+ auto: 'Automatic (unknown region: opt-in)', 'opt-in': 'Opt-in everywhere (EU / UK)', 'opt-out': 'Opt-out everywhere (California)',
12
+ };
13
+ const PLACEMENT_LABELS = { 'bottom-bar': 'Bottom bar', 'bottom-left': 'Bottom left', 'bottom-right': 'Bottom right', 'top-bar': 'Top bar' };
14
+ const COLOR_TOKENS = [
15
+ { key: 'backgroundColor', label: 'Background', fallback: '#ffffff' }, { key: 'textColor', label: 'Text', fallback: '#222222' }, { key: 'borderColor', label: 'Border', fallback: '#222222' },
16
+ ];
17
+ /** The engine's own pixel bounds (`compileConsentCss`); out-of-range values come back as problems. */
18
+ const PIXEL_TOKENS = [
19
+ { key: 'borderRadius', label: 'Corner radius (px)', min: 0, max: 32 }, { key: 'padding', label: 'Padding (px)', min: 8, max: 32 },
20
+ { key: 'fontSize', label: 'Text size (px)', min: 16, max: 24 }, { key: 'maxWidth', label: 'Max width (px)', min: 256, max: 960 },
21
+ ];
22
+ function translator(dictionary = {}) {
23
+ return (text) => dictionary[text] ?? text;
24
+ }
25
+ /** One field's problems, linked to its control through `aria-describedby`. */
26
+ function FieldProblems({ id, problems }) {
27
+ if (!problems.length)
28
+ return null;
29
+ return _jsx("span", { id: id, className: "jini-consent-field-problems", children: problems.map(problem => _jsx("span", { className: "jini-hint jini-hint-error", children: problem.message }, `${problem.code ?? ''}${problem.message}`)) });
30
+ }
31
+ /** Label + control + problems. Problems sit outside the label so they never join the control's
32
+ * name; they are its description and mark it invalid instead. */
33
+ function Field({ label, field, problems, children }) {
34
+ const id = useId();
35
+ const own = problemsFor({ problems, field });
36
+ const attributes = own.length ? { id, 'aria-invalid': true, 'aria-describedby': `${id}-problems` } : { id };
37
+ return _jsxs("div", { className: "jini-consent-settings-field", children: [_jsx("label", { htmlFor: id, className: "jini-field-label", children: label }), children(attributes), _jsx(FieldProblems, { id: `${id}-problems`, problems: own })] });
38
+ }
39
+ /** Banner copy and its links. Text is always plain text; the engine never renders it as HTML. */
40
+ export function ConsentBannerFields({ value, onChange, disabled = false, problems, dictionary, limits = {} }) {
41
+ const t = translator(dictionary);
42
+ const handlers = useConsentSettingsPanel({ value, onChange });
43
+ return _jsxs("fieldset", { disabled: disabled, className: "jini-consent-settings-fields", children: [_jsx("legend", { children: t('Banner text') }), BANNER_FIELDS.map(({ key, label, multiline }) => _jsx(Field, { label: t(label), field: key, problems: problems, children: aria => multiline
44
+ ? _jsx("textarea", { className: "jini-input", value: value[key] ?? '', maxLength: limits[key] ?? 2000, rows: 3, onChange: handlers.text(key), ...aria })
45
+ : _jsx("input", { className: "jini-input", type: "text", value: value[key] ?? '', maxLength: limits[key] ?? 2000, onChange: handlers.text(key), ...aria }) }, key)), _jsx("p", { className: "jini-hint", children: t('Link a published privacy page, such as /privacy. The second link is optional.') })] });
46
+ }
47
+ /** On/off, region behavior and the policy version that re-asks visitors when it changes. */
48
+ export function ConsentRulesFields({ value, onChange, disabled = false, problems, dictionary, limits = {}, regionModes = ['auto', 'opt-in', 'opt-out'] }) {
49
+ const id = useId();
50
+ const t = translator(dictionary);
51
+ const handlers = useConsentSettingsPanel({ value, onChange });
52
+ const modes = regionModes.includes(value.regionMode) ? regionModes : [value.regionMode, ...regionModes];
53
+ return _jsxs("fieldset", { disabled: disabled, className: "jini-consent-settings-fields", children: [_jsx("legend", { children: t('Rules') }), _jsxs("button", { type: "button", className: value.enabled ? 'jini-toggle-row on' : 'jini-toggle-row', role: "switch", "aria-checked": value.enabled, "aria-labelledby": `${id}-enabled`, "aria-describedby": `${id}-hint`, onClick: handlers.toggle, children: [_jsxs("span", { className: "jini-toggle-row-text", children: [_jsx("span", { id: `${id}-enabled`, className: "jini-toggle-row-label", children: t('Enable cookie consent on this site') }), _jsx("span", { id: `${id}-hint`, className: "jini-toggle-row-hint", children: t('Applies to published site pages.') })] }), _jsx("span", { className: "jini-toggle-row-switch", "aria-hidden": "true" })] }), _jsx(Field, { label: t('Region mode'), field: "regionMode", problems: problems, children: aria => _jsx("select", { className: "jini-input", value: value.regionMode, onChange: handlers.region, ...aria, children: modes.map(mode => _jsx("option", { value: mode, children: t(REGION_LABELS[mode]) }, mode)) }) }), _jsx("p", { className: "jini-hint", children: t('California permits optional categories until opt-out. Global Privacy Control always disables analytics and marketing.') }), _jsx(Field, { label: t('Policy version'), field: "policyVersion", problems: problems, children: aria => _jsx(PolicyVersionInput, { value: value.policyVersion, onChange: onChange, maxLength: limits.policyVersion ?? 128, aria: aria }) }), _jsx("p", { className: "jini-hint", children: t('Change the policy version to ask visitors to choose again.') })] });
54
+ }
55
+ /** Placement, bounded appearance tokens and allowlisted custom CSS. The engine checks contrast and
56
+ * CSS safety; its problems arrive on the `appearance` field and show under the custom CSS. */
57
+ export function ConsentLookFields({ value, onChange, disabled = false, problems, dictionary, placements = Object.keys(PLACEMENT_LABELS) }) {
58
+ const t = translator(dictionary);
59
+ const handlers = useConsentSettingsPanel({ value, onChange });
60
+ const appearance = value.appearance ?? {};
61
+ return _jsxs("fieldset", { disabled: disabled, className: "jini-consent-settings-fields", children: [_jsx("legend", { children: t('Look & position') }), _jsx(Field, { label: t('Position'), field: "placement", problems: problems, children: aria => _jsx("select", { className: "jini-input", value: value.placement ?? placements[0], onChange: handlers.placement, ...aria, children: placements.map(placement => _jsx("option", { value: placement, children: t(PLACEMENT_LABELS[placement] ?? placement) }, placement)) }) }), _jsxs("div", { className: "jini-consent-token-grid", children: [COLOR_TOKENS.map(token => _jsx(ColorToken, { label: t(token.label), value: appearance[token.key], fallback: token.fallback, resetLabel: t('Default'), onChange: handlers.token(token.key) }, token.key)), PIXEL_TOKENS.map(token => _jsx(Field, { label: t(token.label), field: token.key, children: attributes => _jsx("input", { className: "jini-input", type: "number", inputMode: "numeric", min: token.min, max: token.max, step: 1, placeholder: t('Default'), value: pixelInputValue({ value: appearance[token.key] }), onChange: event => handlers.token(token.key)(event.target.value === '' ? '' : `${event.target.value}px`), ...attributes }) }, token.key))] }), _jsx(Field, { label: t('Custom CSS'), field: "appearance", problems: problems, children: aria => _jsx("textarea", { className: "jini-input jini-consent-css", value: value.customCss ?? '', rows: 6, spellCheck: false, onChange: handlers.text('customCss'), ...aria }) }), _jsx("p", { className: "jini-hint", children: t('Only banner selectors and safe properties are allowed. Text needs 4.5:1 contrast.') })] });
62
+ }
63
+ /** A native color picker; unset shows the engine default and offers no reset. */
64
+ function ColorToken({ label, value, fallback, resetLabel, onChange }) {
65
+ const id = useId();
66
+ return _jsxs("div", { className: "jini-consent-settings-field", children: [_jsx("label", { htmlFor: id, className: "jini-field-label", children: label }), _jsxs("span", { className: "jini-consent-color-row", children: [_jsx("input", { id: id, className: "jini-consent-color", type: "color", value: value ?? fallback, onChange: event => onChange(event.target.value) }), _jsx("code", { className: "jini-hint", children: value ?? resetLabel }), value !== undefined && _jsx("button", { type: "button", className: "jini-btn-link", "aria-label": `${resetLabel}: ${label}`, onClick: () => onChange(''), children: resetLabel })] })] });
67
+ }
68
+ /** Keep transient empty edits local; only a valid policy version reaches autosave.
69
+ * Native validity gives keyboard users an actionable error. @complexity O(version length), <=128. */
70
+ function PolicyVersionInput({ value, onChange, maxLength, aria }) {
71
+ const { draft, change, blur } = usePolicyVersionInput({ value, onChange });
72
+ return _jsx("input", { className: "jini-input", type: "text", value: draft, required: true, pattern: String.raw `.*\S.*`, maxLength: maxLength, onChange: change, onBlur: blur, ...aria });
73
+ }
74
+ //# sourceMappingURL=ConsentFields.js.map
@@ -0,0 +1,14 @@
1
+ import { type ConsentPreviewWidth } from './settings-panel.hooks.js';
2
+ export interface ConsentPreviewFrameProps {
3
+ /** The engine-gated page; `null` while it loads or when the host could not render it. */
4
+ html: string | null;
5
+ /** Shown in place of the frame while `html` is null. */
6
+ status?: string;
7
+ initialWidth?: ConsentPreviewWidth;
8
+ dictionary?: Readonly<Record<string, string>>;
9
+ }
10
+ /** The real rendered page with the banner, inert. The HTML is only ever the `srcdoc` of an iframe
11
+ * with an empty `sandbox`: no scripts, forms, popups or same-origin access, and it never enters the
12
+ * host DOM. Phone is 320px, the narrowest width the banner must still work at. */
13
+ export declare function ConsentPreviewFrame({ html, status, initialWidth, dictionary }: ConsentPreviewFrameProps): import("react").JSX.Element;
14
+ //# sourceMappingURL=ConsentPreviewFrame.d.ts.map
@@ -0,0 +1,14 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useConsentPreviewWidth } from './settings-panel.hooks.js';
3
+ const WIDTHS = [{ id: 'phone', label: 'Phone' }, { id: 'desktop', label: 'Desktop' }];
4
+ /** The real rendered page with the banner, inert. The HTML is only ever the `srcdoc` of an iframe
5
+ * with an empty `sandbox`: no scripts, forms, popups or same-origin access, and it never enters the
6
+ * host DOM. Phone is 320px, the narrowest width the banner must still work at. */
7
+ export function ConsentPreviewFrame({ html, status = '', initialWidth = 'desktop', dictionary = {} }) {
8
+ const t = (text) => dictionary[text] ?? text;
9
+ const { width, setWidth } = useConsentPreviewWidth({ initial: initialWidth });
10
+ return _jsxs("section", { className: "jini-consent-preview", "aria-label": t('Banner preview'), children: [_jsx("div", { className: "jini-consent-preview-widths", role: "group", "aria-label": t('Preview width'), children: WIDTHS.map(option => _jsx("button", { type: "button", className: "jini-btn-secondary", "aria-pressed": width === option.id, onClick: () => setWidth(option.id), children: t(option.label) }, option.id)) }), html === null
11
+ ? _jsx("p", { className: "jini-hint jini-consent-preview-empty", children: status })
12
+ : _jsx("iframe", { className: `jini-consent-preview-frame is-${width}`, title: t('Banner preview'), sandbox: "", srcDoc: html, style: { width: width === 'phone' ? '320px' : '100%' } })] });
13
+ }
14
+ //# sourceMappingURL=ConsentPreviewFrame.js.map
@@ -1,4 +1,15 @@
1
- /** The host's settings controller uses its existing ledger lifecycle. */
1
+ import { type ConsentFieldsProps } from './ConsentFields.js';
2
+ /** Bounded appearance tokens, as the engine accepts them: opaque hex colors and `Npx` lengths. */
3
+ export interface ConsentAppearanceView {
4
+ backgroundColor?: string;
5
+ textColor?: string;
6
+ borderColor?: string;
7
+ borderRadius?: string;
8
+ padding?: string;
9
+ fontSize?: string;
10
+ maxWidth?: string;
11
+ }
12
+ /** The host's consent configuration. The look fields are optional so older hosts stay valid. */
2
13
  export interface ConsentSettingsView {
3
14
  enabled: boolean;
4
15
  regionMode: 'auto' | 'opt-in' | 'opt-out';
@@ -9,8 +20,19 @@ export interface ConsentSettingsView {
9
20
  acceptLabel: string;
10
21
  rejectLabel: string;
11
22
  preferencesLabel: string;
23
+ placement?: string;
24
+ secondaryLinkLabel?: string;
25
+ secondaryLinkUrl?: string;
26
+ appearance?: ConsentAppearanceView;
27
+ customCss?: string;
28
+ }
29
+ /** One validation result. `field` names the setting it belongs to; field-less problems are the host's to show. */
30
+ export interface ConsentFieldProblem {
31
+ message: string;
32
+ field?: string;
33
+ code?: string;
12
34
  }
13
- export interface ConsentSettingsPanelProps {
35
+ export interface ConsentSettingsPanelProps extends Pick<ConsentFieldsProps, 'problems' | 'limits'> {
14
36
  value: ConsentSettingsView;
15
37
  onChange(required: {
16
38
  patch: Partial<ConsentSettingsView>;
@@ -21,7 +43,8 @@ export interface ConsentSettingsPanelProps {
21
43
  /** Host translations keyed by the exact fallback copy; unspecified entries use English. */
22
44
  dictionary?: Readonly<Record<string, string>>;
23
45
  }
24
- /** Shared jini settings classes keep the panel in the host's design language.
46
+ /** The composed default for hosts that want one screen: Rules, Banner and Look stacked.
47
+ * Shared jini settings classes keep the panel in the host's design language.
25
48
  * Native labels/selects/checkboxes support keyboard use at desktop and narrow widths. */
26
- export declare function ConsentSettingsPanel({ value, onChange, loading, error, status, dictionary }: ConsentSettingsPanelProps): import("react").JSX.Element;
49
+ export declare function ConsentSettingsPanel({ value, onChange, loading, error, status, dictionary, problems, limits }: ConsentSettingsPanelProps): import("react").JSX.Element;
27
50
  //# sourceMappingURL=ConsentSettingsPanel.d.ts.map
@@ -1,24 +1,13 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useId } from 'react';
3
- import { useConsentSettingsPanel, usePolicyVersionInput } from './settings-panel.hooks.js';
4
- const TEXT_FIELDS = [
5
- ['title', 'Banner title', 2000], ['message', 'Banner message', 2000],
6
- ['acceptLabel', 'Accept button text', 2000], ['rejectLabel', 'Reject button text', 2000],
7
- ['preferencesLabel', 'Preferences label', 2000], ['policyVersion', 'Policy version', 128],
8
- ['privacyPolicyUrl', 'Privacy policy page URL', 2000],
9
- ];
10
- /** Shared jini settings classes keep the panel in the host's design language.
3
+ import { ConsentBannerFields, ConsentLookFields, ConsentRulesFields } from './ConsentFields.js';
4
+ /** The composed default for hosts that want one screen: Rules, Banner and Look stacked.
5
+ * Shared jini settings classes keep the panel in the host's design language.
11
6
  * Native labels/selects/checkboxes support keyboard use at desktop and narrow widths. */
12
- export function ConsentSettingsPanel({ value, onChange, loading, error, status, dictionary = {} }) {
7
+ export function ConsentSettingsPanel({ value, onChange, loading, error, status, dictionary = {}, problems, limits }) {
13
8
  const id = useId();
14
9
  const t = (text) => dictionary[text] ?? text;
15
- const handlers = useConsentSettingsPanel({ value, onChange });
16
- return _jsxs("section", { className: "jini-settings-section", "aria-labelledby": `${id}-heading`, children: [_jsx("div", { className: "jini-section-head", children: _jsxs("div", { children: [_jsx("h4", { id: `${id}-heading`, children: t('Site cookie consent') }), _jsx("p", { className: "jini-hint", children: t('Provides the consent mechanism, not legal advice. Review your texts, categories, and privacy policy for your site.') })] }) }), _jsx("p", { className: "jini-hint", children: t('Off by default. In opt-in regions, optional scripts stay paused until a visitor chooses. Unclassified third-party scripts use the marketing category. Global Privacy Control disables analytics and marketing.') }), _jsxs("button", { type: "button", className: value.enabled ? 'jini-toggle-row on' : 'jini-toggle-row', role: "switch", "aria-checked": value.enabled, "aria-labelledby": `${id}-enabled`, "aria-describedby": `${id}-hint`, disabled: loading, onClick: handlers.toggle, children: [_jsxs("span", { className: "jini-toggle-row-text", children: [_jsx("span", { id: `${id}-enabled`, className: "jini-toggle-row-label", children: t('Enable cookie consent on this site') }), _jsx("span", { id: `${id}-hint`, className: "jini-toggle-row-hint", children: t('Applies to published site pages.') })] }), _jsx("span", { className: "jini-toggle-row-switch", "aria-hidden": "true" })] }), _jsxs("fieldset", { disabled: loading, className: "jini-consent-settings-fields", children: [_jsx("legend", { children: t('Banner settings') }), _jsxs("label", { className: "jini-consent-settings-field", children: [_jsx("span", { className: "jini-field-label", children: t('Region mode') }), _jsxs("select", { className: "jini-input", value: value.regionMode, onChange: handlers.region, children: [_jsx("option", { value: "auto", children: t('Automatic (unknown region: opt-in)') }), _jsx("option", { value: "opt-in", children: t('Opt-in everywhere (EU / UK)') }), _jsx("option", { value: "opt-out", children: t('Opt-out everywhere (California)') })] })] }), _jsx("p", { className: "jini-hint", children: t('Automatic uses a trusted request-region header when configured. California permits optional categories until opt-out; Global Privacy Control always disables analytics and marketing.') }), TEXT_FIELDS.map(([key, label, maxLength]) => _jsxs("label", { className: "jini-consent-settings-field", children: [_jsx("span", { className: "jini-field-label", children: t(label) }), key === 'policyVersion' ? _jsx(PolicyVersionInput, { value: value.policyVersion, onChange: onChange }) : key === 'message' ? _jsx("textarea", { className: "jini-input", value: value[key], maxLength: maxLength, rows: 3, onChange: handlers.text(key) }) : _jsx("input", { className: "jini-input", type: "text", value: value[key], maxLength: maxLength, onChange: handlers.text(key) })] }, key)), _jsx("p", { className: "jini-hint", children: t('Link a published privacy policy page with its URL, such as /privacy. Change the policy version to ask visitors to choose again.') })] }), error && _jsx("p", { className: "jini-hint jini-hint-error", role: "alert", children: error }), _jsx("p", { className: "jini-hint", role: "status", children: status })] });
17
- }
18
- /** Keep transient empty edits local; only a valid policy version reaches autosave.
19
- * Native validity gives keyboard users an actionable error. @complexity O(version length), <=128. */
20
- function PolicyVersionInput({ value, onChange }) {
21
- const { draft, change, blur } = usePolicyVersionInput({ value, onChange });
22
- return _jsx("input", { className: "jini-input", type: "text", value: draft, required: true, pattern: String.raw `.*\S.*`, maxLength: 128, onChange: change, onBlur: blur });
10
+ const fields = { value, onChange, disabled: loading, problems, dictionary, limits };
11
+ return _jsxs("section", { className: "jini-settings-section", "aria-labelledby": `${id}-heading`, children: [_jsx("div", { className: "jini-section-head", children: _jsxs("div", { children: [_jsx("h4", { id: `${id}-heading`, children: t('Site cookie consent') }), _jsx("p", { className: "jini-hint", children: t('Provides the consent mechanism, not legal advice. Review your texts, categories, and privacy policy for your site.') })] }) }), _jsx("p", { className: "jini-hint", children: t('Off by default. In opt-in regions, optional scripts stay paused until a visitor chooses. Unclassified third-party scripts use the marketing category. Global Privacy Control disables analytics and marketing.') }), _jsx(ConsentRulesFields, { ...fields }), _jsx(ConsentBannerFields, { ...fields }), _jsx(ConsentLookFields, { ...fields }), error && _jsx("p", { className: "jini-hint jini-hint-error", role: "alert", children: error }), _jsx("p", { className: "jini-hint", role: "status", children: status })] });
23
12
  }
24
13
  //# sourceMappingURL=ConsentSettingsPanel.js.map
@@ -1,3 +1,8 @@
1
1
  export { ConsentSettingsPanel } from './ConsentSettingsPanel.js';
2
- export type { ConsentSettingsPanelProps, ConsentSettingsView } from './ConsentSettingsPanel.js';
2
+ export type { ConsentAppearanceView, ConsentFieldProblem, ConsentSettingsPanelProps, ConsentSettingsView } from './ConsentSettingsPanel.js';
3
+ export { ConsentBannerFields, ConsentLookFields, ConsentRulesFields } from './ConsentFields.js';
4
+ export type { ConsentFieldsProps } from './ConsentFields.js';
5
+ export { ConsentPreviewFrame } from './ConsentPreviewFrame.js';
6
+ export type { ConsentPreviewFrameProps } from './ConsentPreviewFrame.js';
7
+ export type { ConsentPreviewWidth } from './settings-panel.hooks.js';
3
8
  //# sourceMappingURL=index.d.ts.map
@@ -1,2 +1,4 @@
1
1
  export { ConsentSettingsPanel } from './ConsentSettingsPanel.js';
2
+ export { ConsentBannerFields, ConsentLookFields, ConsentRulesFields } from './ConsentFields.js';
3
+ export { ConsentPreviewFrame } from './ConsentPreviewFrame.js';
2
4
  //# sourceMappingURL=index.js.map
@@ -1,11 +1,30 @@
1
1
  import { type ChangeEvent, type FocusEvent } from 'react';
2
- import type { ConsentSettingsPanelProps, ConsentSettingsView } from './ConsentSettingsPanel.js';
2
+ import type { ConsentAppearanceView, ConsentFieldProblem, ConsentSettingsPanelProps, ConsentSettingsView } from './ConsentSettingsPanel.js';
3
3
  /** Keep value conversions and toggle behavior out of the view. React owns event signatures. */
4
4
  export declare function useConsentSettingsPanel({ value, onChange }: Pick<ConsentSettingsPanelProps, 'value' | 'onChange'>, _options?: {}): {
5
5
  toggle: () => void;
6
6
  region: (event: ChangeEvent<HTMLSelectElement>) => void;
7
+ placement: (event: ChangeEvent<HTMLSelectElement>) => void;
7
8
  text: (key: keyof ConsentSettingsView) => (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
9
+ /** Empty input unsets the token so the engine default applies again. */
10
+ token: (key: keyof ConsentAppearanceView) => (next: string) => void;
8
11
  };
12
+ /** A new token object with `key` set, or removed when `value` is empty. Never mutates the input.
13
+ * @complexity O(t), t <= 7 tokens. */
14
+ export declare function withAppearanceToken({ appearance, key, value }: {
15
+ appearance?: ConsentAppearanceView | undefined;
16
+ key: keyof ConsentAppearanceView;
17
+ value: string;
18
+ }, _options?: {}): ConsentAppearanceView;
19
+ /** `"12px"` → `"12"` for a number input; unset or hand-written values read empty. */
20
+ export declare function pixelInputValue({ value }: {
21
+ value: string | undefined;
22
+ }, _options?: {}): string;
23
+ /** The problems a host attached to one field, in their original order. O(p). */
24
+ export declare function problemsFor({ problems, field }: {
25
+ problems?: readonly ConsentFieldProblem[] | undefined;
26
+ field: string;
27
+ }, _options?: {}): ConsentFieldProblem[];
9
28
  /** Keep transient empty edits local; only a valid policy version reaches autosave.
10
29
  * Native validity gives keyboard users an actionable error. Valid edits save before navigation.
11
30
  * @complexity O(version length), <=128. */
@@ -16,4 +35,12 @@ export declare function usePolicyVersionInput({ value, onChange }: Pick<ConsentS
16
35
  change: (event: ChangeEvent<HTMLInputElement>) => void;
17
36
  blur: (event: FocusEvent<HTMLInputElement>) => void;
18
37
  };
38
+ export type ConsentPreviewWidth = 'phone' | 'desktop';
39
+ /** The preview's own phone/desktop switch; the host never needs to own it. */
40
+ export declare function useConsentPreviewWidth({ initial }?: {
41
+ initial?: ConsentPreviewWidth;
42
+ }, _options?: {}): {
43
+ width: ConsentPreviewWidth;
44
+ setWidth: import("react").Dispatch<import("react").SetStateAction<ConsentPreviewWidth>>;
45
+ };
19
46
  //# sourceMappingURL=settings-panel.hooks.d.ts.map
@@ -4,9 +4,30 @@ export function useConsentSettingsPanel({ value, onChange }, _options = {}) {
4
4
  return {
5
5
  toggle: () => onChange({ patch: { enabled: !value.enabled } }),
6
6
  region: (event) => onChange({ patch: { regionMode: event.target.value } }),
7
+ placement: (event) => onChange({ patch: { placement: event.target.value } }),
7
8
  text: (key) => (event) => onChange({ patch: { [key]: event.target.value } }),
9
+ /** Empty input unsets the token so the engine default applies again. */
10
+ token: (key) => (next) => onChange({ patch: { appearance: withAppearanceToken({ appearance: value.appearance, key, value: next }) } }),
8
11
  };
9
12
  }
13
+ /** A new token object with `key` set, or removed when `value` is empty. Never mutates the input.
14
+ * @complexity O(t), t <= 7 tokens. */
15
+ export function withAppearanceToken({ appearance = {}, key, value }, _options = {}) {
16
+ const next = { ...appearance };
17
+ if (value === '')
18
+ delete next[key];
19
+ else
20
+ next[key] = value;
21
+ return next;
22
+ }
23
+ /** `"12px"` → `"12"` for a number input; unset or hand-written values read empty. */
24
+ export function pixelInputValue({ value }, _options = {}) {
25
+ return /^(\d+(?:\.\d+)?)px$/.exec(value ?? '')?.[1] ?? '';
26
+ }
27
+ /** The problems a host attached to one field, in their original order. O(p). */
28
+ export function problemsFor({ problems = [], field }, _options = {}) {
29
+ return problems.filter(problem => problem.field === field);
30
+ }
10
31
  /** Keep transient empty edits local; only a valid policy version reaches autosave.
11
32
  * Native validity gives keyboard users an actionable error. Valid edits save before navigation.
12
33
  * @complexity O(version length), <=128. */
@@ -27,4 +48,9 @@ export function usePolicyVersionInput({ value, onChange }, _options = {}) {
27
48
  },
28
49
  };
29
50
  }
51
+ /** The preview's own phone/desktop switch; the host never needs to own it. */
52
+ export function useConsentPreviewWidth({ initial = 'desktop' } = {}, _options = {}) {
53
+ const [width, setWidth] = useState(initial);
54
+ return { width, setWidth };
55
+ }
30
56
  //# sourceMappingURL=settings-panel.hooks.js.map
@@ -42,40 +42,59 @@ const MAX_JSON_NODES = 10_000;
42
42
  * {@link MAX_JSON_DEPTH}.
43
43
  * @overallScore 100
44
44
  */
45
- function describeNonJsonValue(value, depth = 0, budget = { nodes: 0 }) {
45
+ function describeNonJsonValue({ value, depth, budget }, _optional = {}) {
46
46
  if (depth > MAX_JSON_DEPTH)
47
47
  return `nests deeper than ${MAX_JSON_DEPTH} levels`;
48
48
  if (++budget.nodes > MAX_JSON_NODES)
49
49
  return `holds more than ${MAX_JSON_NODES} values`;
50
- if (value === null)
51
- return null;
52
- const type = typeof value;
53
- if (type === 'string' || type === 'boolean')
54
- return null;
55
- if (type === 'number')
56
- return Number.isFinite(value) ? null : 'is a non-finite number';
57
- if (type !== 'object')
58
- return `is a ${type}`;
59
- if (Array.isArray(value)) {
60
- for (const item of value) {
61
- const reason = describeNonJsonValue(item, depth + 1, budget);
62
- if (reason !== null)
63
- return reason;
64
- }
65
- return null;
66
- }
50
+ if (value === null || typeof value !== 'object')
51
+ return describeNonJsonScalar(value);
67
52
  // Rejects class instances, `Map`, `Date`, and anything else that survives `typeof === 'object'`
68
53
  // but does not round-trip through JSON as itself.
69
- const proto = Object.getPrototypeOf(value);
70
- if (proto !== Object.prototype && proto !== null)
54
+ if (!Array.isArray(value) && !isPlainObject(value))
71
55
  return 'is a non-plain object';
72
56
  for (const item of Object.values(value)) {
73
- const reason = describeNonJsonValue(item, depth + 1, budget);
57
+ const reason = describeNonJsonValue({ value: item, depth: depth + 1, budget });
74
58
  if (reason !== null)
75
59
  return reason;
76
60
  }
77
61
  return null;
78
62
  }
63
+ /** The leaf half of {@link describeNonJsonValue}: `null` or a non-object `value`. */
64
+ function describeNonJsonScalar(value) {
65
+ if (value === null)
66
+ return null;
67
+ const type = typeof value;
68
+ if (type === 'string' || type === 'boolean')
69
+ return null;
70
+ if (type === 'number')
71
+ return Number.isFinite(value) ? null : 'is a non-finite number';
72
+ return `is a ${type}`;
73
+ }
74
+ function isPlainObject(value) {
75
+ const proto = Object.getPrototypeOf(value);
76
+ return proto === Object.prototype || proto === null;
77
+ }
78
+ /** `null` when `typeof value` is `type`, otherwise "expected <label>, got <typeof value>". */
79
+ function describeWrongType({ value, type, label }, _optional = {}) {
80
+ return typeof value === type ? null : `expected ${label}, got ${typeof value}`;
81
+ }
82
+ function describeNonDatetime(value) {
83
+ if (typeof value !== 'string')
84
+ return `expected an ISO-8601 string, got ${typeof value}`;
85
+ // A `datetime` that is not parseable renders fine and orders wrongly — the exact class of
86
+ // fault this whole check exists for, so it is a mismatch rather than a pass.
87
+ return Number.isNaN(Date.parse(value)) ? 'is not a parseable ISO-8601 timestamp' : null;
88
+ }
89
+ function describeNonNumber({ value, accepts, reason }, _optional = {}) {
90
+ if (typeof value !== 'number')
91
+ return `expected a number, got ${typeof value}`;
92
+ return accepts(value) ? null : reason;
93
+ }
94
+ function describeNonJson(value) {
95
+ const reason = describeNonJsonValue({ value, depth: 0, budget: { nodes: 0 } });
96
+ return reason === null ? null : `is not a JSON value: it ${reason}`;
97
+ }
79
98
  /**
80
99
  * Whether `value` is a legal runtime value for `field`.
81
100
  *
@@ -98,27 +117,21 @@ export function describeAdminEntityFieldMismatch(field, value) {
98
117
  switch (field.kind) {
99
118
  case 'text':
100
119
  case 'relation':
101
- return typeof value === 'string' ? null : `expected a string, got ${typeof value}`;
120
+ return describeWrongType({ value, type: 'string', label: 'a string' });
102
121
  case 'datetime':
103
- if (typeof value !== 'string')
104
- return `expected an ISO-8601 string, got ${typeof value}`;
105
- // A `datetime` that is not parseable renders fine and orders wrongly — the exact class of
106
- // fault this whole check exists for, so it is a mismatch rather than a pass.
107
- return Number.isNaN(Date.parse(value)) ? 'is not a parseable ISO-8601 timestamp' : null;
122
+ return describeNonDatetime(value);
108
123
  case 'integer':
109
- if (typeof value !== 'number')
110
- return `expected a number, got ${typeof value}`;
111
- return Number.isInteger(value) ? null : 'is not an integer';
124
+ return describeNonNumber({ value, accepts: Number.isInteger, reason: 'is not an integer' });
112
125
  case 'real':
113
- if (typeof value !== 'number')
114
- return `expected a number, got ${typeof value}`;
115
- return Number.isFinite(value) ? null : 'is not a finite number';
126
+ return describeNonNumber({
127
+ value,
128
+ accepts: Number.isFinite,
129
+ reason: 'is not a finite number',
130
+ });
116
131
  case 'boolean':
117
- return typeof value === 'boolean' ? null : `expected a boolean, got ${typeof value}`;
118
- case 'json': {
119
- const reason = describeNonJsonValue(value);
120
- return reason === null ? null : `is not a JSON value: it ${reason}`;
121
- }
132
+ return describeWrongType({ value, type: 'boolean', label: 'a boolean' });
133
+ case 'json':
134
+ return describeNonJson(value);
122
135
  }
123
136
  }
124
137
  /**
@@ -132,49 +145,63 @@ export function describeAdminEntityFieldMismatch(field, value) {
132
145
  * @complexity Time O(fields + row keys) plus the `json` walk; space O(violations).
133
146
  * @overallScore 100
134
147
  */
135
- function findRowViolations(descriptor, operation, row) {
148
+ function findRowViolations({ descriptor, operation, row, }, _optional = {}) {
149
+ const hasId = typeof row.id === 'string' && row.id !== '';
150
+ const rowId = hasId ? row.id : '<no id>';
151
+ const at = { entity: descriptor.name, operation, rowId };
136
152
  const found = [];
137
- const rowId = typeof row.id === 'string' && row.id !== '' ? row.id : '<no id>';
138
- const push = (field, problem, detail) => {
139
- found.push({ entity: descriptor.name, operation, rowId, field, problem, detail });
140
- };
141
- if (typeof row.id !== 'string' || row.id === '') {
142
- push(IMPLICIT_ROW_KEY, 'wrong-type', 'every row must carry a non-empty string `id`');
153
+ if (!hasId) {
154
+ found.push({
155
+ ...at,
156
+ field: IMPLICIT_ROW_KEY,
157
+ problem: 'wrong-type',
158
+ detail: 'every row must carry a non-empty string `id`',
159
+ });
143
160
  }
144
- const declared = new Set([IMPLICIT_ROW_KEY]);
145
161
  for (const field of descriptor.fields) {
146
- declared.add(field.name);
147
- const value = row[field.name];
148
- if (value === undefined) {
149
- if (field.required === true) {
150
- push(field.name, 'missing-required', 'declared `required: true` but absent from the row');
151
- }
152
- continue;
153
- }
154
- const mismatch = describeAdminEntityFieldMismatch(field, value);
155
- if (mismatch !== null)
156
- push(field.name, 'wrong-type', `declared \`${field.kind}\`; ${mismatch}`);
162
+ const problem = describeFieldProblem({ field, value: row[field.name] });
163
+ if (problem !== null)
164
+ found.push({ ...at, field: field.name, ...problem });
157
165
  }
166
+ const declared = new Set([IMPLICIT_ROW_KEY, ...descriptor.fields.map((field) => field.name)]);
158
167
  for (const key of Object.keys(row)) {
159
168
  if (declared.has(key))
160
169
  continue;
161
- push(key, 'undeclared-field', 'present on the row but not declared on the descriptor');
170
+ found.push({
171
+ ...at,
172
+ field: key,
173
+ problem: 'undeclared-field',
174
+ detail: 'present on the row but not declared on the descriptor',
175
+ });
162
176
  }
163
177
  return found;
164
178
  }
179
+ /** What is wrong with one declared field's cell, or `null` when it conforms or is legally absent. */
180
+ function describeFieldProblem({ field, value }, _optional = {}) {
181
+ if (value === undefined) {
182
+ return field.required === true
183
+ ? { problem: 'missing-required', detail: 'declared `required: true` but absent from the row' }
184
+ : null;
185
+ }
186
+ const mismatch = describeAdminEntityFieldMismatch(field, value);
187
+ return mismatch === null
188
+ ? null
189
+ : { problem: 'wrong-type', detail: `declared \`${field.kind}\`; ${mismatch}` };
190
+ }
165
191
  /**
166
192
  * Runs `row` past its descriptor and reports anything wrong, then returns it unchanged.
167
193
  *
168
194
  * Deliberately identity-returning: this is an observation point, never a repair point. Coercing a
169
195
  * bad value here would hide the adapter bug it exists to expose.
170
196
  *
171
- * @complexity Time O(rows × fields); no-op and O(1) when `onViolation` is absent.
197
+ * Only {@link eraseEntityPort}'s checking wrapper calls this, and that wrapper exists only when
198
+ * `onViolation` is set — so the sink is required here rather than re-checked.
199
+ *
200
+ * @complexity Time O(rows × fields).
172
201
  * @overallScore 100
173
202
  */
174
- function checkRow(descriptor, operation, row, onViolation) {
175
- if (onViolation === undefined)
176
- return row;
177
- for (const violation of findRowViolations(descriptor, operation, row))
203
+ function checkRow({ descriptor, operation, row, onViolation, }, _optional = {}) {
204
+ for (const violation of findRowViolations({ descriptor, operation, row }))
178
205
  onViolation(violation);
179
206
  return row;
180
207
  }
@@ -208,18 +235,20 @@ export function eraseEntityPort(port, options = {}) {
208
235
  async list(query) {
209
236
  const page = await erased.list(query);
210
237
  for (const row of page.items)
211
- checkRow(descriptor, 'list', row, onViolation);
238
+ checkRow({ descriptor, operation: 'list', row, onViolation });
212
239
  return page;
213
240
  },
214
241
  async get(id) {
215
242
  const row = await erased.get(id);
216
- return row === null ? null : checkRow(descriptor, 'get', row, onViolation);
243
+ return row === null ? null : checkRow({ descriptor, operation: 'get', row, onViolation });
217
244
  },
218
245
  async create(input) {
219
- return checkRow(descriptor, 'create', await erased.create(input), onViolation);
246
+ const row = await erased.create(input);
247
+ return checkRow({ descriptor, operation: 'create', row, onViolation });
220
248
  },
221
249
  async update(id, patch) {
222
- return checkRow(descriptor, 'update', await erased.update(id, patch), onViolation);
250
+ const row = await erased.update(id, patch);
251
+ return checkRow({ descriptor, operation: 'update', row, onViolation });
223
252
  },
224
253
  };
225
254
  // Preserved by presence, not by a forwarding stub: a screen decides whether to render a delete
@@ -241,44 +270,60 @@ export function eraseEntityPort(port, options = {}) {
241
270
  * @complexity Time O(fields); space O(problems).
242
271
  * @overallScore 100
243
272
  */
244
- function findDescriptorProblems(descriptor, registryKey, registeredNames) {
245
- const problems = [];
273
+ function findDescriptorProblems({ descriptor, registryKey, registeredNames, }, _optional = {}) {
246
274
  const at = `'${registryKey}'`;
275
+ return [
276
+ ...findHeaderProblems({ descriptor, registryKey }),
277
+ ...findFieldProblems({ descriptor, registeredNames }),
278
+ ...findTitleFieldProblems({ descriptor }),
279
+ ].map((problem) => `${at}: ${problem}`);
280
+ }
281
+ function findHeaderProblems({ descriptor, registryKey }, _optional = {}) {
282
+ const problems = [];
247
283
  if (descriptor.name !== registryKey) {
248
284
  // One entity, one name. Allowing these to differ would leave the URL segment, the relation
249
285
  // target, and the registry key as three identifiers with no stated relationship between them.
250
- problems.push(`${at}: registered under '${registryKey}' but its descriptor is named '${descriptor.name}'`);
286
+ problems.push(`registered under '${registryKey}' but its descriptor is named '${descriptor.name}'`);
251
287
  }
252
288
  if (descriptor.labelSingular === '' || descriptor.labelPlural === '') {
253
- problems.push(`${at}: labelSingular and labelPlural must both be non-empty`);
289
+ problems.push('labelSingular and labelPlural must both be non-empty');
254
290
  }
255
291
  if (descriptor.fields.length === 0) {
256
- problems.push(`${at}: declares no fields, so nothing but its id could ever be shown or edited`);
292
+ problems.push('declares no fields, so nothing but its id could ever be shown or edited');
257
293
  }
294
+ return problems;
295
+ }
296
+ function findFieldProblems({ descriptor, registeredNames, }, _optional = {}) {
297
+ const problems = [];
258
298
  const seen = new Set();
259
299
  for (const field of descriptor.fields) {
260
300
  if (field.name === '') {
261
- problems.push(`${at}: a field has an empty name`);
301
+ problems.push('a field has an empty name');
262
302
  continue;
263
303
  }
264
304
  if (field.name === IMPLICIT_ROW_KEY) {
265
- problems.push(`${at}: '${IMPLICIT_ROW_KEY}' is implicit on every row and must not be declared as a field`);
305
+ problems.push(`'${IMPLICIT_ROW_KEY}' is implicit on every row and must not be declared as a field`);
266
306
  }
267
307
  if (seen.has(field.name))
268
- problems.push(`${at}: field '${field.name}' is declared more than once`);
308
+ problems.push(`field '${field.name}' is declared more than once`);
269
309
  seen.add(field.name);
270
310
  if (field.kind === 'relation' && !registeredNames.has(field.target)) {
271
- problems.push(`${at}: field '${field.name}' targets '${field.target}', which is not a registered entity`);
311
+ problems.push(`field '${field.name}' targets '${field.target}', which is not a registered entity`);
272
312
  }
273
313
  }
314
+ return problems;
315
+ }
316
+ function findTitleFieldProblems({ descriptor }, _optional = {}) {
274
317
  const titleField = descriptor.fields.find((field) => field.name === descriptor.titleField);
275
318
  if (titleField === undefined) {
276
- problems.push(`${at}: titleField '${descriptor.titleField}' does not name a declared field`);
319
+ return [`titleField '${descriptor.titleField}' does not name a declared field`];
277
320
  }
278
- else if (titleField.kind !== 'text') {
279
- problems.push(`${at}: titleField '${descriptor.titleField}' is kind '${titleField.kind}'; a title must be 'text'`);
321
+ if (titleField.kind !== 'text') {
322
+ return [
323
+ `titleField '${descriptor.titleField}' is kind '${titleField.kind}'; a title must be 'text'`,
324
+ ];
280
325
  }
281
- return problems;
326
+ return [];
282
327
  }
283
328
  /**
284
329
  * Assembles entity ports into a registry, validating every descriptor first.
@@ -306,7 +351,7 @@ function findDescriptorProblems(descriptor, registryKey, registeredNames) {
306
351
  export function createAdminEntityRegistry(ports) {
307
352
  const entries = Object.entries(ports);
308
353
  const registeredNames = new Set(entries.map(([, port]) => port.descriptor.name));
309
- const problems = entries.flatMap(([key, port]) => findDescriptorProblems(port.descriptor, key, registeredNames));
354
+ const problems = entries.flatMap(([key, port]) => findDescriptorProblems({ descriptor: port.descriptor, registryKey: key, registeredNames }));
310
355
  if (problems.length > 0) {
311
356
  throw new Error(`createAdminEntityRegistry: invalid descriptors\n- ${problems.join('\n- ')}`);
312
357
  }