@jini-ai/admin 0.6.12 → 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.
- package/CHANGELOG.md +12 -0
- package/dist/browser/index.d.ts +1 -0
- package/dist/browser/index.js +1 -0
- package/dist/browser/telemetry.d.ts +45 -0
- package/dist/browser/telemetry.js +108 -0
- package/dist/comments/react/hooks/use-comment-queue.hooks.js +5 -15
- package/dist/consent/react/ConsentFields.d.ts +6 -2
- package/dist/consent/react/ConsentFields.js +16 -4
- package/dist/consent/react/ConsentSettingsPanel.d.ts +2 -0
- package/dist/consent/react/settings-panel.hooks.d.ts +3 -0
- package/dist/consent/react/settings-panel.hooks.js +1 -0
- package/dist/forms/forms.module.d.ts +4 -0
- package/dist/forms/html-rules.js +10 -13
- package/dist/forms/messages.en.d.ts +4 -0
- package/dist/forms/messages.en.js +4 -0
- package/dist/forms/react/components/FormEditorFieldsBody.js +1 -1
- package/dist/forms/react/index.d.ts +12 -0
- package/dist/media/html-attributes.d.ts +1 -8
- package/dist/media/html-attributes.js +6 -161
- package/dist/seo/react/hooks/SeoPage.hooks.d.ts +1 -21
- package/dist/seo/react/hooks/SeoPage.hooks.js +0 -8
- package/dist/seo/react/pages/SeoPage.js +10 -2
- package/package.json +13 -13
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.13
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- fddd084: Allow consent-enabled static HTML exports with native reopening controls and denied visitor defaults. Keep reopened preferences at the banner position and show dynamic-code help through the shared info-tip.
|
|
8
|
+
- 0728914: Engine guard clean-up (`pnpm guard` 106 -> 0): layer, subpath-isolation and module-state findings fixed by moving
|
|
9
|
+
shared contracts to their owners instead of reaching across domains. Additive public API: `@jini-ai/core/filesystem`
|
|
10
|
+
(guarded-reader contracts), `useFetchQueryScope` from `@jini-ai/ui` fetch-query (per-provider query scope),
|
|
11
|
+
`@jini-ai/chat/react` re-exports of the accepted-input, run-state and agent-plugin chat-state helpers. The Claude
|
|
12
|
+
filesystem probe cache in `@jini-ai/agent-runtime` is instance-owned. No behaviour change intended; host-facing
|
|
13
|
+
breaks are listed in `owner-store-to-server` and `guard-host-ports`.
|
|
14
|
+
|
|
3
15
|
## 0.6.12
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/dist/browser/index.d.ts
CHANGED
|
@@ -6,4 +6,5 @@
|
|
|
6
6
|
*/
|
|
7
7
|
export { NAVIGATION_EVENT, installInternalLinkInterceptor, navigate, readRoutePath, subscribeToRoute, } from './navigation.js';
|
|
8
8
|
export { createAdminShellNavigation } from './shell-navigation.js';
|
|
9
|
+
export { createBrowserTelemetry, installBrowserErrorListeners, type BrowserTelemetry, type BrowserTelemetryDeps, type BrowserTelemetryPayload } from './telemetry.js';
|
|
9
10
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/browser/index.js
CHANGED
|
@@ -6,4 +6,5 @@
|
|
|
6
6
|
*/
|
|
7
7
|
export { NAVIGATION_EVENT, installInternalLinkInterceptor, navigate, readRoutePath, subscribeToRoute, } from './navigation.js';
|
|
8
8
|
export { createAdminShellNavigation } from './shell-navigation.js';
|
|
9
|
+
export { createBrowserTelemetry, installBrowserErrorListeners } from './telemetry.js';
|
|
9
10
|
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** Browser telemetry over host-bound transport, timers and session state. Route names stay with the host. */
|
|
2
|
+
export type BrowserTelemetryPayload = {
|
|
3
|
+
type: "error";
|
|
4
|
+
message: string;
|
|
5
|
+
stack: string;
|
|
6
|
+
componentStack?: string | undefined;
|
|
7
|
+
route: string;
|
|
8
|
+
} | {
|
|
9
|
+
type: "pageview";
|
|
10
|
+
route: string;
|
|
11
|
+
};
|
|
12
|
+
export interface BrowserTelemetryDeps {
|
|
13
|
+
send(payload: BrowserTelemetryPayload): Promise<unknown>;
|
|
14
|
+
nowMs(): number;
|
|
15
|
+
setTimer(fn: () => void, ms: number): unknown;
|
|
16
|
+
clearTimer(handle: unknown): void;
|
|
17
|
+
}
|
|
18
|
+
export interface BrowserTelemetry {
|
|
19
|
+
/** Only a signed-in session can post; before sign-in everything is dropped. */
|
|
20
|
+
setSignedIn(signedIn: boolean): void;
|
|
21
|
+
/** The current route template; errors carry it, and a change sends a (debounced) page view. */
|
|
22
|
+
setRoute(route: string): void;
|
|
23
|
+
reportError(input: {
|
|
24
|
+
error: unknown;
|
|
25
|
+
componentStack?: string | undefined;
|
|
26
|
+
}): void;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Builds the telemetry engine over injected I/O so its dedupe, cap and debounce are testable.
|
|
30
|
+
* @complexity O(1) amortized per call; O(f) memory for at most {@link MAX_FINGERPRINTS} fingerprints.
|
|
31
|
+
*/
|
|
32
|
+
export declare function createBrowserTelemetry(deps: BrowserTelemetryDeps, { maxErrorsPerMinute, pageviewDelayMs, initialRoute }?: {
|
|
33
|
+
maxErrorsPerMinute?: number | undefined;
|
|
34
|
+
pageviewDelayMs?: number | undefined;
|
|
35
|
+
initialRoute?: string | undefined;
|
|
36
|
+
}): BrowserTelemetry;
|
|
37
|
+
/**
|
|
38
|
+
* Window-level errors the React root never sees (event handlers, timers, promises).
|
|
39
|
+
* @returns Uninstall.
|
|
40
|
+
*/
|
|
41
|
+
export declare function installBrowserErrorListeners({ target, telemetry }: {
|
|
42
|
+
target: Pick<Window, "addEventListener" | "removeEventListener">;
|
|
43
|
+
telemetry: Pick<BrowserTelemetry, "reportError">;
|
|
44
|
+
}, _options?: {}): () => void;
|
|
45
|
+
//# sourceMappingURL=telemetry.d.ts.map
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/** Mirrors the server's caps (`browser-telemetry.ts`): longer text is cut here, not refused there. */
|
|
2
|
+
const CAPS = { message: 1_000, stack: 8_000, componentStack: 4_000, route: 200 };
|
|
3
|
+
const WINDOW_MS = 60_000;
|
|
4
|
+
const DEDUPE_MS = 5 * 60_000;
|
|
5
|
+
const MAX_FINGERPRINTS = 200;
|
|
6
|
+
/** Message of an Error or a thrown string; any other thrown value is named by type only, never dumped. */
|
|
7
|
+
function messageOf(error) {
|
|
8
|
+
if (error instanceof Error)
|
|
9
|
+
return error.message || error.name;
|
|
10
|
+
if (typeof error === "string")
|
|
11
|
+
return error;
|
|
12
|
+
return `Non-Error thrown (${error === null ? "null" : typeof error})`;
|
|
13
|
+
}
|
|
14
|
+
function errorPayload({ error, componentStack, route }) {
|
|
15
|
+
const stack = error instanceof Error && typeof error.stack === "string" ? error.stack : "";
|
|
16
|
+
const cs = componentStack ? componentStack.slice(0, CAPS.componentStack) : "";
|
|
17
|
+
return { type: "error", message: messageOf(error).slice(0, CAPS.message), stack: stack.slice(0, CAPS.stack), ...(cs ? { componentStack: cs } : {}), route };
|
|
18
|
+
}
|
|
19
|
+
/** Message + first stack frame: the same bug from the same place is one report, not hundreds. */
|
|
20
|
+
function fingerprintOf(payload) {
|
|
21
|
+
const frame = payload.stack.split("\n").find((line) => /\bat\b|@/.test(line)) ?? "";
|
|
22
|
+
return `${payload.message}|${frame.trim()}`;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Builds the telemetry engine over injected I/O so its dedupe, cap and debounce are testable.
|
|
26
|
+
* @complexity O(1) amortized per call; O(f) memory for at most {@link MAX_FINGERPRINTS} fingerprints.
|
|
27
|
+
*/
|
|
28
|
+
export function createBrowserTelemetry(deps, { maxErrorsPerMinute = 10, pageviewDelayMs = 1_000, initialRoute = "" } = {}) {
|
|
29
|
+
let signedIn = false;
|
|
30
|
+
let route = initialRoute;
|
|
31
|
+
let lastPageview = null;
|
|
32
|
+
let timer = null;
|
|
33
|
+
let sentAt = [];
|
|
34
|
+
const seen = new Map();
|
|
35
|
+
const post = (payload) => {
|
|
36
|
+
try {
|
|
37
|
+
void deps.send(payload).catch(() => { });
|
|
38
|
+
}
|
|
39
|
+
catch { /* never throws */ }
|
|
40
|
+
};
|
|
41
|
+
const underCap = (now) => {
|
|
42
|
+
sentAt = sentAt.filter((at) => now - at < WINDOW_MS);
|
|
43
|
+
return sentAt.length < maxErrorsPerMinute;
|
|
44
|
+
};
|
|
45
|
+
const isRepeat = (fingerprint, now) => {
|
|
46
|
+
const last = seen.get(fingerprint);
|
|
47
|
+
if (last !== undefined && now - last < DEDUPE_MS)
|
|
48
|
+
return true;
|
|
49
|
+
seen.delete(fingerprint);
|
|
50
|
+
if (seen.size >= MAX_FINGERPRINTS)
|
|
51
|
+
seen.delete(seen.keys().next().value);
|
|
52
|
+
seen.set(fingerprint, now);
|
|
53
|
+
return false;
|
|
54
|
+
};
|
|
55
|
+
const flushPageview = () => {
|
|
56
|
+
timer = null;
|
|
57
|
+
if (!signedIn || route === lastPageview)
|
|
58
|
+
return;
|
|
59
|
+
lastPageview = route;
|
|
60
|
+
post({ type: "pageview", route });
|
|
61
|
+
};
|
|
62
|
+
return {
|
|
63
|
+
setSignedIn(next) { signedIn = next; if (!next)
|
|
64
|
+
lastPageview = null; },
|
|
65
|
+
setRoute(next) {
|
|
66
|
+
route = next.slice(0, CAPS.route);
|
|
67
|
+
try {
|
|
68
|
+
if (timer !== null)
|
|
69
|
+
deps.clearTimer(timer);
|
|
70
|
+
timer = deps.setTimer(flushPageview, pageviewDelayMs);
|
|
71
|
+
}
|
|
72
|
+
catch { /* never throws */ }
|
|
73
|
+
},
|
|
74
|
+
reportError({ error, componentStack }) {
|
|
75
|
+
try {
|
|
76
|
+
if (!signedIn)
|
|
77
|
+
return;
|
|
78
|
+
const payload = errorPayload({ error, componentStack, route });
|
|
79
|
+
const now = deps.nowMs();
|
|
80
|
+
if (isRepeat(fingerprintOf(payload), now) || !underCap(now))
|
|
81
|
+
return;
|
|
82
|
+
sentAt.push(now);
|
|
83
|
+
post(payload);
|
|
84
|
+
}
|
|
85
|
+
catch { /* never throws */ }
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Window-level errors the React root never sees (event handlers, timers, promises).
|
|
91
|
+
* @returns Uninstall.
|
|
92
|
+
*/
|
|
93
|
+
export function installBrowserErrorListeners({ target, telemetry }, _options = {}) {
|
|
94
|
+
const onError = (event) => {
|
|
95
|
+
// A cross-origin "Script error." carries nothing usable; skip it rather than report noise.
|
|
96
|
+
if (event.error === undefined || event.error === null)
|
|
97
|
+
return;
|
|
98
|
+
telemetry.reportError({ error: event.error });
|
|
99
|
+
};
|
|
100
|
+
const onRejection = (event) => telemetry.reportError({ error: event.reason });
|
|
101
|
+
target.addEventListener("error", onError);
|
|
102
|
+
target.addEventListener("unhandledrejection", onRejection);
|
|
103
|
+
return () => {
|
|
104
|
+
target.removeEventListener("error", onError);
|
|
105
|
+
target.removeEventListener("unhandledrejection", onRejection);
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
//# sourceMappingURL=telemetry.js.map
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createContext, createElement, useContext, useCallback, useEffect, useMemo, useRef } from 'react';
|
|
2
|
-
import { useCachedLoader, useFetchQuery, useInvalidate } from '@jini-ai/ui/fetch-query';
|
|
2
|
+
import { useCachedLoader, useFetchQuery, useInvalidate, useFetchQueryScope } from '@jini-ai/ui/fetch-query';
|
|
3
3
|
import { AdminConfigError } from '../../../core/module/create-admin.js';
|
|
4
4
|
import { useController } from '../../../core/react/use-controller.js';
|
|
5
5
|
import { createCommentQueueController } from '../../controllers/comment-queue.controller.js';
|
|
@@ -18,19 +18,6 @@ const CommentQueueScopeContext = createContext(null);
|
|
|
18
18
|
export function CommentQueueScopeProvider({ scope, children }) {
|
|
19
19
|
return createElement(CommentQueueScopeContext.Provider, { value: scope }, children);
|
|
20
20
|
}
|
|
21
|
-
// Weak API keys preserve sharing between consumers without retaining APIs from previous sessions.
|
|
22
|
-
// Explicit workspace/principal keys below also isolate hosts that retain one API across sessions.
|
|
23
|
-
const queueScopes = new WeakMap();
|
|
24
|
-
let nextQueueScope = 0;
|
|
25
|
-
/** Allocate the API portion of a namespace; workspace/principal identity is keyed separately. */
|
|
26
|
-
function queueScope({ api }, _optional = {}) {
|
|
27
|
-
let scope = queueScopes.get(api);
|
|
28
|
-
if (scope === undefined) {
|
|
29
|
-
scope = ++nextQueueScope;
|
|
30
|
-
queueScopes.set(api, scope);
|
|
31
|
-
}
|
|
32
|
-
return scope;
|
|
33
|
-
}
|
|
34
21
|
/** Preserve the hook's loading defaults while no controller from the current scope is attached. */
|
|
35
22
|
function queueDisplay({ snapshot }, _optional = {}) {
|
|
36
23
|
const { status, items, nextCursor, error, loadingMore, pendingPurge } = snapshot ?? BEFORE_FIRST_SNAPSHOT;
|
|
@@ -73,7 +60,10 @@ function useCursorPageLoader({ api, queueRoot, status, cursor }, _optional = {})
|
|
|
73
60
|
*/
|
|
74
61
|
export function useCommentQueue({ api, scope, permissions = NO_GRANTS }, { t = englishComments, events } = {}) {
|
|
75
62
|
const invalidate = useInvalidate();
|
|
76
|
-
|
|
63
|
+
// Weak API keys preserve sharing between consumers without retaining APIs from previous sessions.
|
|
64
|
+
// Explicit workspace/principal keys below also isolate hosts that retain one API across sessions.
|
|
65
|
+
const apiScope = useFetchQueryScope({ owner: api });
|
|
66
|
+
const queueRoot = useMemo(() => [...KEYS.queueRoot, apiScope, scope.workspaceId, scope.principalId], [apiScope, scope.workspaceId, scope.principalId]);
|
|
77
67
|
const invalidateQueue = useCallback(() => invalidate({ key: queueRoot }), [invalidate, queueRoot]);
|
|
78
68
|
const loadPageRef = useRef(null);
|
|
79
69
|
const { controller: attachedController, snapshot: attachedSnapshot } = useController({
|
|
@@ -20,8 +20,12 @@ export interface ConsentFieldsProps {
|
|
|
20
20
|
/** Host max lengths per text field; the defaults are the engine's loosest bounds. */
|
|
21
21
|
limits?: Readonly<Partial<Record<TextKey | 'policyVersion', number>>> | undefined;
|
|
22
22
|
}
|
|
23
|
-
/** Banner copy and its links.
|
|
24
|
-
|
|
23
|
+
/** Banner copy and its links. Optional host modes report only a mode patch; the host's independent
|
|
24
|
+
* enabled switch stays authoritative. Text is always plain text.
|
|
25
|
+
* @example <ConsentBannerFields value={value} onChange={onChange} bannerModes={['jini', 'headless', 'off']} /> */
|
|
26
|
+
export declare function ConsentBannerFields({ value, onChange, disabled, problems, dictionary, limits, bannerModes }: ConsentFieldsProps & {
|
|
27
|
+
bannerModes?: readonly NonNullable<ConsentSettingsView['bannerMode']>[] | undefined;
|
|
28
|
+
}): import("react").JSX.Element;
|
|
25
29
|
/** On/off, script policy, region behavior and the version that re-asks visitors when it changes. */
|
|
26
30
|
export declare function ConsentRulesFields({ value, onChange, disabled, problems, dictionary, limits, regionModes }: ConsentFieldsProps & {
|
|
27
31
|
regionModes?: readonly ConsentSettingsView['regionMode'][];
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useId } from 'react';
|
|
3
3
|
import { defaultAdminTheme } from '@jini-ai/ui/theme';
|
|
4
|
+
import { InfoTip } from '@jini-ai/ui/admin-widgets';
|
|
4
5
|
import { pixelInputValue, problemsFor, useConsentSettingsPanel, usePolicyVersionInput } from './settings-panel.hooks.js';
|
|
5
6
|
const BANNER_FIELDS = [
|
|
6
7
|
{ key: 'title', label: 'Banner title' }, { key: 'message', label: 'Banner message', multiline: true },
|
|
@@ -8,6 +9,7 @@ const BANNER_FIELDS = [
|
|
|
8
9
|
{ key: 'preferencesLabel', label: 'Preferences label' }, { key: 'privacyPolicyUrl', label: 'Privacy policy page URL' },
|
|
9
10
|
{ key: 'secondaryLinkLabel', label: 'Second link text' }, { key: 'secondaryLinkUrl', label: 'Second link URL' },
|
|
10
11
|
];
|
|
12
|
+
const BANNER_MODE_LABELS = { jini: 'Jini banner', headless: 'My own', off: 'None' };
|
|
11
13
|
const REGION_LABELS = {
|
|
12
14
|
auto: 'Automatic (unknown region: opt-in)', 'opt-in': 'Opt-in everywhere (EU / UK)', 'opt-out': 'Opt-out everywhere (California)',
|
|
13
15
|
};
|
|
@@ -38,11 +40,21 @@ function Field({ label, field, problems, children }) {
|
|
|
38
40
|
const attributes = own.length ? { id, 'aria-invalid': true, 'aria-describedby': `${id}-problems` } : { id };
|
|
39
41
|
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 })] });
|
|
40
42
|
}
|
|
41
|
-
/**
|
|
42
|
-
|
|
43
|
+
/** Host-supplied banner modes. Native radios provide keyboard selection; the hint keeps its space
|
|
44
|
+
* while hidden, and the parent fieldset supplies the disabled state. */
|
|
45
|
+
function BannerModeFields({ value, onChange, dictionary, modes }) {
|
|
46
|
+
const id = useId();
|
|
47
|
+
const t = translator(dictionary);
|
|
48
|
+
const handlers = useConsentSettingsPanel({ value, onChange });
|
|
49
|
+
return _jsxs("fieldset", { className: "jini-consent-banner-modes", "aria-describedby": value.bannerMode === 'headless' ? `${id}-mode-hint` : undefined, children: [_jsx("legend", { children: t('Banner mode') }), _jsx("div", { className: "jini-consent-banner-options", children: modes.map(mode => _jsxs("label", { children: [_jsx("input", { type: "radio", name: `${id}-banner-mode`, value: mode, checked: (value.bannerMode ?? 'jini') === mode, onChange: handlers.bannerMode({ mode }) }), _jsx("span", { children: t(BANNER_MODE_LABELS[mode]) })] }, mode)) }), _jsx("p", { id: `${id}-mode-hint`, className: "jini-hint jini-consent-banner-mode-hint", style: { visibility: value.bannerMode === 'headless' ? 'visible' : 'hidden' }, children: t('Your banner calls window.jiniConsent.set(...)') })] });
|
|
50
|
+
}
|
|
51
|
+
/** Banner copy and its links. Optional host modes report only a mode patch; the host's independent
|
|
52
|
+
* enabled switch stays authoritative. Text is always plain text.
|
|
53
|
+
* @example <ConsentBannerFields value={value} onChange={onChange} bannerModes={['jini', 'headless', 'off']} /> */
|
|
54
|
+
export function ConsentBannerFields({ value, onChange, disabled = false, problems, dictionary, limits = {}, bannerModes }) {
|
|
43
55
|
const t = translator(dictionary);
|
|
44
56
|
const handlers = useConsentSettingsPanel({ value, onChange });
|
|
45
|
-
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
|
|
57
|
+
return _jsxs("fieldset", { disabled: disabled, className: "jini-consent-settings-fields", children: [_jsx("legend", { children: t('Banner text') }), bannerModes && _jsx(BannerModeFields, { value: value, onChange: onChange, dictionary: dictionary, modes: bannerModes }), BANNER_FIELDS.map(({ key, label, multiline }) => _jsx(Field, { label: t(label), field: key, problems: problems, children: aria => multiline
|
|
46
58
|
? _jsx("textarea", { className: "jini-input", value: value[key] ?? '', maxLength: limits[key] ?? 2000, rows: 3, onChange: handlers.text(key), ...aria })
|
|
47
59
|
: _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.') })] });
|
|
48
60
|
}
|
|
@@ -52,7 +64,7 @@ export function ConsentRulesFields({ value, onChange, disabled = false, problems
|
|
|
52
64
|
const t = translator(dictionary);
|
|
53
65
|
const handlers = useConsentSettingsPanel({ value, onChange });
|
|
54
66
|
const modes = regionModes.includes(value.regionMode) ? regionModes : [value.regionMode, ...regionModes];
|
|
55
|
-
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" })] }), value.enabled && _jsxs("button", { type: "button", className: value.allowUnsafeEval === true ? 'jini-toggle-row on' : 'jini-toggle-row', role: "switch", "aria-checked": value.allowUnsafeEval === true, "aria-labelledby": `${id}-eval`, "aria-describedby": `${id}-eval-hint`, onClick: handlers.toggleUnsafeEval, children: [
|
|
67
|
+
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" })] }), value.enabled && _jsxs("div", { style: { display: 'flex', alignItems: 'center', gap: 8 }, children: [_jsxs("button", { type: "button", className: value.allowUnsafeEval === true ? 'jini-toggle-row on' : 'jini-toggle-row', role: "switch", "aria-checked": value.allowUnsafeEval === true, "aria-labelledby": `${id}-eval`, "aria-describedby": `${id}-eval-hint`, onClick: handlers.toggleUnsafeEval, children: [_jsx("span", { className: "jini-toggle-row-text", children: _jsx("span", { id: `${id}-eval`, className: "jini-toggle-row-label", children: t('Allow dynamic code (unsafe-eval)') }) }), _jsx("span", { className: "jini-toggle-row-switch", "aria-hidden": "true" })] }), _jsx(InfoTip, { triggerLabel: t('About dynamic code'), descriptionId: `${id}-eval-hint`, label: t("Some themes need this. It weakens the site's script protection. Optional scripts still wait for consent.") })] }), _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.') })] });
|
|
56
68
|
}
|
|
57
69
|
/** Placement, bounded appearance tokens and allowlisted custom CSS. The engine checks contrast and
|
|
58
70
|
* CSS safety; its problems arrive on the `appearance` field and show under the custom CSS. */
|
|
@@ -14,6 +14,8 @@ export interface ConsentSettingsView {
|
|
|
14
14
|
enabled: boolean;
|
|
15
15
|
/** Optional for older hosts; omitted means the dynamic code exception is off. */
|
|
16
16
|
allowUnsafeEval?: boolean;
|
|
17
|
+
/** Optional for older hosts; omitted uses the Jini banner when consent is enabled. */
|
|
18
|
+
bannerMode?: 'jini' | 'headless' | 'off';
|
|
17
19
|
regionMode: 'auto' | 'opt-in' | 'opt-out';
|
|
18
20
|
policyVersion: string;
|
|
19
21
|
privacyPolicyUrl: string;
|
|
@@ -4,6 +4,9 @@ import type { ConsentAppearanceView, ConsentFieldProblem, ConsentSettingsPanelPr
|
|
|
4
4
|
export declare function useConsentSettingsPanel({ value, onChange }: Pick<ConsentSettingsPanelProps, 'value' | 'onChange'>, _options?: {}): {
|
|
5
5
|
toggle: () => void;
|
|
6
6
|
toggleUnsafeEval: () => void;
|
|
7
|
+
bannerMode: ({ mode }: {
|
|
8
|
+
mode: NonNullable<ConsentSettingsView["bannerMode"]>;
|
|
9
|
+
}, _optional?: {}) => () => void;
|
|
7
10
|
region: (event: ChangeEvent<HTMLSelectElement>) => void;
|
|
8
11
|
placement: (event: ChangeEvent<HTMLSelectElement>) => void;
|
|
9
12
|
text: (key: keyof ConsentSettingsView) => (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
|
|
@@ -4,6 +4,7 @@ export function useConsentSettingsPanel({ value, onChange }, _options = {}) {
|
|
|
4
4
|
return {
|
|
5
5
|
toggle: () => onChange({ patch: { enabled: !value.enabled } }),
|
|
6
6
|
toggleUnsafeEval: () => onChange({ patch: { allowUnsafeEval: value.allowUnsafeEval !== true } }),
|
|
7
|
+
bannerMode: ({ mode }, _optional = {}) => () => onChange({ patch: { bannerMode: mode } }),
|
|
7
8
|
region: (event) => onChange({ patch: { regionMode: event.target.value } }),
|
|
8
9
|
placement: (event) => onChange({ patch: { placement: event.target.value } }),
|
|
9
10
|
text: (key) => (event) => onChange({ patch: { [key]: event.target.value } }),
|
|
@@ -9,6 +9,10 @@ export declare const formsModule: {
|
|
|
9
9
|
readonly formsNavigation: import("../core/module/token.js").AdminPortToken<import("./ports.js").FormsNavigationPort, "admin.forms.navigation">;
|
|
10
10
|
};
|
|
11
11
|
readonly messages: {
|
|
12
|
+
readonly "Basic spam protection is on.": "Basic spam protection is on.";
|
|
13
|
+
readonly "Hidden-field checks, submission timing when available, duplicate suppression, and rate limits.": "Hidden-field checks, submission timing when available, duplicate suppression, and rate limits.";
|
|
14
|
+
readonly "Live server defaults: wait 1 second; up to 5 submissions per minute per IP address and form.": "Live server defaults: wait 1 second; up to 5 submissions per minute per IP address and form.";
|
|
15
|
+
readonly "Exported forms use their destination's protection; timing and duplicate suppression may be unavailable. Custom embeds need the generated hidden controls for hidden-field checks.": "Exported forms use their destination's protection; timing and duplicate suppression may be unavailable. Custom embeds need the generated hidden controls for hidden-field checks.";
|
|
12
16
|
readonly "Created / Updated": "Created / Updated";
|
|
13
17
|
readonly "Created {created} \u00B7 Updated {updated}": "Created {created} · Updated {updated}";
|
|
14
18
|
readonly "Created {date}": "Created {date}";
|
package/dist/forms/html-rules.js
CHANGED
|
@@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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="${
|
|
101
|
-
constraints.filter(([, value]) => value != null && value !== "").map(([name, value]) => ` ${name}="${
|
|
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:
|
|
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="${
|
|
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="${
|
|
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="">${
|
|
131
|
-
const choices = (field.options ?? []).map((option) => ` <option value="${
|
|
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="${
|
|
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">${
|
|
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, "'");
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import type { FormsTranslator } from "./models.js";
|
|
2
2
|
export declare const formsMessagesEn: {
|
|
3
|
+
readonly "Basic spam protection is on.": "Basic spam protection is on.";
|
|
4
|
+
readonly "Hidden-field checks, submission timing when available, duplicate suppression, and rate limits.": "Hidden-field checks, submission timing when available, duplicate suppression, and rate limits.";
|
|
5
|
+
readonly "Live server defaults: wait 1 second; up to 5 submissions per minute per IP address and form.": "Live server defaults: wait 1 second; up to 5 submissions per minute per IP address and form.";
|
|
6
|
+
readonly "Exported forms use their destination's protection; timing and duplicate suppression may be unavailable. Custom embeds need the generated hidden controls for hidden-field checks.": "Exported forms use their destination's protection; timing and duplicate suppression may be unavailable. Custom embeds need the generated hidden controls for hidden-field checks.";
|
|
3
7
|
readonly "Created / Updated": "Created / Updated";
|
|
4
8
|
readonly "Created {created} \u00B7 Updated {updated}": "Created {created} · Updated {updated}";
|
|
5
9
|
readonly "Created {date}": "Created {date}";
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
export const formsMessagesEn = {
|
|
2
|
+
"Basic spam protection is on.": "Basic spam protection is on.",
|
|
3
|
+
"Hidden-field checks, submission timing when available, duplicate suppression, and rate limits.": "Hidden-field checks, submission timing when available, duplicate suppression, and rate limits.",
|
|
4
|
+
"Live server defaults: wait 1 second; up to 5 submissions per minute per IP address and form.": "Live server defaults: wait 1 second; up to 5 submissions per minute per IP address and form.",
|
|
5
|
+
"Exported forms use their destination's protection; timing and duplicate suppression may be unavailable. Custom embeds need the generated hidden controls for hidden-field checks.": "Exported forms use their destination's protection; timing and duplicate suppression may be unavailable. Custom embeds need the generated hidden controls for hidden-field checks.",
|
|
2
6
|
"Created / Updated": "Created / Updated",
|
|
3
7
|
"Created {created} · Updated {updated}": "Created {created} · Updated {updated}",
|
|
4
8
|
"Created {date}": "Created {date}",
|
|
@@ -8,6 +8,6 @@ export function FormEditorFieldsBody(props) {
|
|
|
8
8
|
return (_jsxs(_Fragment, { children: [_jsx("div", { className: "field-group", children: _jsxs("div", { className: "field-row", children: [_jsxs("div", { className: "field", children: [_jsx("label", { className: "field-label", htmlFor: "form-name", children: t("Name") }), _jsx("input", { id: "form-name", value: name, onChange: handlers.onName, ...agentHandle({ handle: "form-editor-name" }, { role: "field", label: "This form's display name" }) })] }), _jsxs("div", { className: "field", children: [_jsx("label", { className: "field-label", htmlFor: "form-slug", children: t("Slug") }), _jsx("input", { id: "form-slug", value: slug, disabled: !isNew, onChange: handlers.onSlug, ...agentHandle({ handle: "form-editor-slug" }, {
|
|
9
9
|
role: "field",
|
|
10
10
|
label: "This form's URL slug — only editable while creating a new form",
|
|
11
|
-
}) })] })] }) }), _jsx("div", { className: "field-group", children: _jsx(FormEditorModeSwitch, { props: props, handlers: handlers }) }), _jsx(FormEditorActions, { props: props })] }));
|
|
11
|
+
}) })] })] }) }), _jsx("div", { className: "field-group", children: _jsx(FormEditorModeSwitch, { props: props, handlers: handlers }) }), _jsxs("div", { className: "field-group", children: [_jsx("p", { children: _jsx("strong", { children: t("Basic spam protection is on.") }) }), _jsx("p", { className: "muted", children: t("Hidden-field checks, submission timing when available, duplicate suppression, and rate limits.") }), _jsx("p", { className: "muted", children: t("Live server defaults: wait 1 second; up to 5 submissions per minute per IP address and form.") }), _jsx("p", { className: "muted", children: t("Exported forms use their destination's protection; timing and duplicate suppression may be unavailable. Custom embeds need the generated hidden controls for hidden-field checks.") })] }), _jsx(FormEditorActions, { props: props })] }));
|
|
12
12
|
}
|
|
13
13
|
//# sourceMappingURL=FormEditorFieldsBody.js.map
|
|
@@ -25,6 +25,10 @@ export declare function forms(_required: Record<string, never>, options?: FormsR
|
|
|
25
25
|
readonly formsNavigation: import("../../core/module/token.js").AdminPortToken<import("../ports.js").FormsNavigationPort, "admin.forms.navigation">;
|
|
26
26
|
};
|
|
27
27
|
messages: {
|
|
28
|
+
readonly "Basic spam protection is on.": "Basic spam protection is on.";
|
|
29
|
+
readonly "Hidden-field checks, submission timing when available, duplicate suppression, and rate limits.": "Hidden-field checks, submission timing when available, duplicate suppression, and rate limits.";
|
|
30
|
+
readonly "Live server defaults: wait 1 second; up to 5 submissions per minute per IP address and form.": "Live server defaults: wait 1 second; up to 5 submissions per minute per IP address and form.";
|
|
31
|
+
readonly "Exported forms use their destination's protection; timing and duplicate suppression may be unavailable. Custom embeds need the generated hidden controls for hidden-field checks.": "Exported forms use their destination's protection; timing and duplicate suppression may be unavailable. Custom embeds need the generated hidden controls for hidden-field checks.";
|
|
28
32
|
readonly "Created / Updated": "Created / Updated";
|
|
29
33
|
readonly "Created {created} \u00B7 Updated {updated}": "Created {created} · Updated {updated}";
|
|
30
34
|
readonly "Created {date}": "Created {date}";
|
|
@@ -243,6 +247,10 @@ export declare function forms(_required: Record<string, never>, options?: FormsR
|
|
|
243
247
|
readonly formsNavigation: import("../../core/module/token.js").AdminPortToken<import("../ports.js").FormsNavigationPort, "admin.forms.navigation">;
|
|
244
248
|
};
|
|
245
249
|
readonly messages: {
|
|
250
|
+
readonly "Basic spam protection is on.": "Basic spam protection is on.";
|
|
251
|
+
readonly "Hidden-field checks, submission timing when available, duplicate suppression, and rate limits.": "Hidden-field checks, submission timing when available, duplicate suppression, and rate limits.";
|
|
252
|
+
readonly "Live server defaults: wait 1 second; up to 5 submissions per minute per IP address and form.": "Live server defaults: wait 1 second; up to 5 submissions per minute per IP address and form.";
|
|
253
|
+
readonly "Exported forms use their destination's protection; timing and duplicate suppression may be unavailable. Custom embeds need the generated hidden controls for hidden-field checks.": "Exported forms use their destination's protection; timing and duplicate suppression may be unavailable. Custom embeds need the generated hidden controls for hidden-field checks.";
|
|
246
254
|
readonly "Created / Updated": "Created / Updated";
|
|
247
255
|
readonly "Created {created} \u00B7 Updated {updated}": "Created {created} · Updated {updated}";
|
|
248
256
|
readonly "Created {date}": "Created {date}";
|
|
@@ -465,6 +473,10 @@ export declare function forms(_required: Record<string, never>, options?: FormsR
|
|
|
465
473
|
readonly formsNavigation: import("../../core/module/token.js").AdminPortToken<import("../ports.js").FormsNavigationPort, "admin.forms.navigation">;
|
|
466
474
|
};
|
|
467
475
|
readonly messages: {
|
|
476
|
+
readonly "Basic spam protection is on.": "Basic spam protection is on.";
|
|
477
|
+
readonly "Hidden-field checks, submission timing when available, duplicate suppression, and rate limits.": "Hidden-field checks, submission timing when available, duplicate suppression, and rate limits.";
|
|
478
|
+
readonly "Live server defaults: wait 1 second; up to 5 submissions per minute per IP address and form.": "Live server defaults: wait 1 second; up to 5 submissions per minute per IP address and form.";
|
|
479
|
+
readonly "Exported forms use their destination's protection; timing and duplicate suppression may be unavailable. Custom embeds need the generated hidden controls for hidden-field checks.": "Exported forms use their destination's protection; timing and duplicate suppression may be unavailable. Custom embeds need the generated hidden controls for hidden-field checks.";
|
|
468
480
|
readonly "Created / Updated": "Created / Updated";
|
|
469
481
|
readonly "Created {created} \u00B7 Updated {updated}": "Created {created} · Updated {updated}";
|
|
470
482
|
readonly "Created {date}": "Created {date}";
|
|
@@ -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
|
-
* (
|
|
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
|
-
|
|
9
|
-
|
|
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
|
-
* (
|
|
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
|
-
|
|
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
|
-
|
|
158
|
-
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
452
|
-
"@jini-ai/ui": "^0.6.
|
|
453
|
-
"@jini-ai/ui-kit": "^0.6.
|
|
454
|
-
"@jini-ai/agent-plugins": "^0.6.
|
|
455
|
-
"@jini-ai/cms": "^0.6.
|
|
456
|
-
"@jini-ai/core": "^0.6.
|
|
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/
|
|
495
|
-
"@jini-ai/
|
|
496
|
-
"@jini-ai/
|
|
497
|
-
"@jini-ai/
|
|
498
|
-
"@jini-ai/
|
|
499
|
-
"@jini-ai/
|
|
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
|
"*": {
|