@ipxjs/refract 0.3.1

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.
@@ -0,0 +1,40 @@
1
+ import type { VNode, VNodeType, Props } from "./types.js";
2
+
3
+ export const Fragment = Symbol.for("refract.fragment");
4
+
5
+ type Child = VNode | string | number | boolean | null | undefined | Child[];
6
+
7
+ export function createElement(
8
+ type: VNodeType,
9
+ props: Record<string, unknown> | null,
10
+ ...rawChildren: Child[]
11
+ ): VNode {
12
+ const resolvedProps: Props = { ...(props ?? {}) };
13
+
14
+ // Extract key from props
15
+ const key = resolvedProps.key ?? null;
16
+ delete resolvedProps.key;
17
+
18
+ const children = flattenChildren(rawChildren);
19
+ if (children.length > 0) {
20
+ resolvedProps.children = children;
21
+ }
22
+
23
+ // Do NOT call component functions — they are called during reconciliation
24
+ return { type, props: resolvedProps, key };
25
+ }
26
+
27
+ function flattenChildren(raw: Child[]): VNode[] {
28
+ const result: VNode[] = [];
29
+ for (const child of raw) {
30
+ if (child == null || typeof child === "boolean") continue;
31
+ if (Array.isArray(child)) {
32
+ result.push(...flattenChildren(child));
33
+ } else if (typeof child === "string" || typeof child === "number") {
34
+ result.push({ type: "TEXT", props: { nodeValue: String(child) }, key: null });
35
+ } else {
36
+ result.push(child);
37
+ }
38
+ }
39
+ return result;
40
+ }
@@ -0,0 +1,254 @@
1
+ import type { Fiber, Props } from "./types.js";
2
+ import { registerCommitHandler } from "./runtimeExtensions.js";
3
+
4
+ export const DEVTOOLS_GLOBAL_HOOK = "__REFRACT_DEVTOOLS_GLOBAL_HOOK__";
5
+
6
+ export interface RefractDevtoolsRenderer {
7
+ name: "refract";
8
+ }
9
+
10
+ export interface RefractDevtoolsFiberSnapshot {
11
+ id: number;
12
+ type: string;
13
+ key: string | number | null;
14
+ dom: string | null;
15
+ props: Record<string, unknown>;
16
+ hookState: unknown[];
17
+ children: RefractDevtoolsFiberSnapshot[];
18
+ }
19
+
20
+ export interface RefractDevtoolsRootSnapshot {
21
+ id: number;
22
+ container: string;
23
+ current: RefractDevtoolsFiberSnapshot | null;
24
+ }
25
+
26
+ export interface RefractDevtoolsHook {
27
+ inject?: (renderer: RefractDevtoolsRenderer) => number;
28
+ onCommitFiberRoot?: (rendererId: number, root: RefractDevtoolsRootSnapshot) => void;
29
+ onCommitFiberUnmount?: (rendererId: number, fiber: RefractDevtoolsFiberSnapshot) => void;
30
+ }
31
+
32
+ let explicitHook: RefractDevtoolsHook | null | undefined = undefined;
33
+ let activeHook: RefractDevtoolsHook | null = null;
34
+ let activeRendererId = 1;
35
+
36
+ const containerIds = new WeakMap<Node, number>();
37
+ let nextContainerId = 1;
38
+
39
+ const fiberIds = new WeakMap<Fiber, number>();
40
+ let nextFiberId = 1;
41
+
42
+ export function setDevtoolsHook(hook?: RefractDevtoolsHook | null): void {
43
+ explicitHook = hook;
44
+ activeHook = null;
45
+ activeRendererId = 1;
46
+ }
47
+
48
+ export function reportDevtoolsCommit(rootFiber: Fiber, deletions: Fiber[]): void {
49
+ const hook = resolveHook();
50
+ if (!hook) {
51
+ activeHook = null;
52
+ activeRendererId = 1;
53
+ return;
54
+ }
55
+
56
+ const rendererId = ensureRenderer(hook);
57
+
58
+ if (typeof hook.onCommitFiberRoot === "function") {
59
+ safeCall(() => hook.onCommitFiberRoot!(rendererId, snapshotRoot(rootFiber)));
60
+ }
61
+
62
+ if (typeof hook.onCommitFiberUnmount === "function") {
63
+ for (const deleted of deletions) {
64
+ walkFiberSubtree(deleted, (fiber) => {
65
+ safeCall(() => hook.onCommitFiberUnmount!(rendererId, snapshotFiber(fiber)));
66
+ });
67
+ }
68
+ }
69
+ }
70
+
71
+ registerCommitHandler(reportDevtoolsCommit);
72
+
73
+ function resolveHook(): RefractDevtoolsHook | null {
74
+ if (explicitHook !== undefined) return explicitHook;
75
+
76
+ const candidate = (globalThis as Record<string, unknown>)[DEVTOOLS_GLOBAL_HOOK];
77
+ if (!candidate || typeof candidate !== "object") return null;
78
+ return candidate as RefractDevtoolsHook;
79
+ }
80
+
81
+ function ensureRenderer(hook: RefractDevtoolsHook): number {
82
+ if (hook === activeHook) return activeRendererId;
83
+
84
+ activeHook = hook;
85
+ activeRendererId = 1;
86
+
87
+ if (typeof hook.inject === "function") {
88
+ safeCall(() => {
89
+ const id = hook.inject!({ name: "refract" });
90
+ if (typeof id === "number" && Number.isFinite(id)) {
91
+ activeRendererId = id;
92
+ }
93
+ });
94
+ }
95
+
96
+ return activeRendererId;
97
+ }
98
+
99
+ function snapshotRoot(rootFiber: Fiber): RefractDevtoolsRootSnapshot {
100
+ const containerNode = rootFiber.dom ?? rootFiber.parentDom;
101
+ return {
102
+ id: getContainerId(containerNode),
103
+ container: describeNode(containerNode) ?? "unknown",
104
+ current: rootFiber.child ? snapshotFiber(rootFiber.child) : null,
105
+ };
106
+ }
107
+
108
+ function snapshotFiber(fiber: Fiber): RefractDevtoolsFiberSnapshot {
109
+ const children: RefractDevtoolsFiberSnapshot[] = [];
110
+ let child = fiber.child;
111
+ while (child) {
112
+ children.push(snapshotFiber(child));
113
+ child = child.sibling;
114
+ }
115
+
116
+ return {
117
+ id: getFiberId(fiber),
118
+ type: describeFiberType(fiber),
119
+ key: fiber.key,
120
+ dom: describeNode(fiber.dom),
121
+ props: snapshotProps(fiber.props),
122
+ hookState: snapshotHookState(fiber),
123
+ children,
124
+ };
125
+ }
126
+
127
+ function snapshotHookState(fiber: Fiber): unknown[] {
128
+ if (!fiber.hooks || fiber.hooks.length === 0) return [];
129
+ return fiber.hooks.map((hook) => serializeValue(hook.state, new WeakSet<object>(), 0));
130
+ }
131
+
132
+ function snapshotProps(props: Props): Record<string, unknown> {
133
+ const out: Record<string, unknown> = {};
134
+ const keys = Object.keys(props).filter((key) => key !== "children");
135
+
136
+ for (const key of keys.slice(0, 20)) {
137
+ out[key] = serializeValue(props[key], new WeakSet<object>(), 0);
138
+ }
139
+ if (keys.length > 20) {
140
+ out.__truncated = `${keys.length - 20} more keys`;
141
+ }
142
+ return out;
143
+ }
144
+
145
+ function serializeValue(value: unknown, seen: WeakSet<object>, depth: number): unknown {
146
+ if (value == null) return value;
147
+
148
+ switch (typeof value) {
149
+ case "string":
150
+ case "number":
151
+ case "boolean":
152
+ return value;
153
+ case "bigint":
154
+ case "symbol":
155
+ return String(value);
156
+ case "function": {
157
+ const name = value.name || "anonymous";
158
+ return `[function ${name}]`;
159
+ }
160
+ case "object": {
161
+ if (typeof Node !== "undefined" && value instanceof Node) {
162
+ return `[node ${describeNode(value) ?? value.nodeName.toLowerCase()}]`;
163
+ }
164
+ if (depth >= 2) {
165
+ return Array.isArray(value) ? "[array]" : "[object]";
166
+ }
167
+ if (value instanceof Date) return value.toISOString();
168
+ if (value instanceof RegExp) return String(value);
169
+
170
+ if (seen.has(value)) return "[circular]";
171
+ seen.add(value);
172
+
173
+ if (Array.isArray(value)) {
174
+ const items = value.slice(0, 10).map((item) => serializeValue(item, seen, depth + 1));
175
+ if (value.length > 10) {
176
+ items.push(`[+${value.length - 10} more items]`);
177
+ }
178
+ return items;
179
+ }
180
+
181
+ const record = value as Record<string, unknown>;
182
+ const entries = Object.entries(record);
183
+ const out: Record<string, unknown> = {};
184
+ for (const [key, entryValue] of entries.slice(0, 10)) {
185
+ out[key] = serializeValue(entryValue, seen, depth + 1);
186
+ }
187
+ if (entries.length > 10) {
188
+ out.__truncated = `${entries.length - 10} more keys`;
189
+ }
190
+ return out;
191
+ }
192
+ default:
193
+ return String(value);
194
+ }
195
+ }
196
+
197
+ function describeFiberType(fiber: Fiber): string {
198
+ if (fiber.type === "TEXT") return "#text";
199
+ if (typeof fiber.type === "string") return fiber.type;
200
+ if (typeof fiber.type === "symbol") return fiber.type.description ?? "Symbol";
201
+ if (typeof fiber.type === "function") return fiber.type.name || "Anonymous";
202
+ return "Unknown";
203
+ }
204
+
205
+ function describeNode(node: Node | null): string | null {
206
+ if (!node) return null;
207
+
208
+ if (typeof Element !== "undefined" && node instanceof Element) {
209
+ const tag = node.tagName.toLowerCase();
210
+ return node.id ? `${tag}#${node.id}` : tag;
211
+ }
212
+ if (node.nodeType === Node.TEXT_NODE) return "#text";
213
+ return node.nodeName.toLowerCase();
214
+ }
215
+
216
+ function getContainerId(container: Node): number {
217
+ const existing = containerIds.get(container);
218
+ if (existing !== undefined) return existing;
219
+ const id = nextContainerId++;
220
+ containerIds.set(container, id);
221
+ return id;
222
+ }
223
+
224
+ function getFiberId(fiber: Fiber): number {
225
+ const existing = fiberIds.get(fiber);
226
+ if (existing !== undefined) return existing;
227
+
228
+ const alternateId = fiber.alternate ? fiberIds.get(fiber.alternate) : undefined;
229
+ if (alternateId !== undefined) {
230
+ fiberIds.set(fiber, alternateId);
231
+ return alternateId;
232
+ }
233
+
234
+ const id = nextFiberId++;
235
+ fiberIds.set(fiber, id);
236
+ return id;
237
+ }
238
+
239
+ function walkFiberSubtree(fiber: Fiber, visit: (f: Fiber) => void): void {
240
+ visit(fiber);
241
+ let child = fiber.child;
242
+ while (child) {
243
+ walkFiberSubtree(child, visit);
244
+ child = child.sibling;
245
+ }
246
+ }
247
+
248
+ function safeCall(fn: () => void): void {
249
+ try {
250
+ fn();
251
+ } catch {
252
+ // Devtools hooks are optional and should never break app rendering.
253
+ }
254
+ }
@@ -0,0 +1,122 @@
1
+ import type { Fiber } from "./types.js";
2
+
3
+ const SVG_NS = "http://www.w3.org/2000/svg";
4
+ const SVG_TAGS = new Set([
5
+ "svg", "circle", "ellipse", "line", "path", "polygon", "polyline",
6
+ "rect", "g", "defs", "use", "text", "tspan", "image", "clipPath",
7
+ "mask", "pattern", "marker", "linearGradient", "radialGradient", "stop",
8
+ "foreignObject", "symbol", "desc", "title",
9
+ ]);
10
+
11
+ export type HtmlSanitizer = (html: string) => string;
12
+ export type UnsafeUrlPropChecker = (key: string, value: unknown) => boolean;
13
+
14
+ let htmlSanitizer: HtmlSanitizer = identitySanitizer;
15
+ let unsafeUrlPropChecker: UnsafeUrlPropChecker = () => false;
16
+
17
+ export function setHtmlSanitizer(sanitizer: HtmlSanitizer | null): void {
18
+ htmlSanitizer = sanitizer ?? identitySanitizer;
19
+ }
20
+
21
+ export function setUnsafeUrlPropChecker(checker: UnsafeUrlPropChecker | null): void {
22
+ unsafeUrlPropChecker = checker ?? (() => false);
23
+ }
24
+
25
+ function identitySanitizer(html: string): string {
26
+ return html;
27
+ }
28
+
29
+ /** Create a real DOM node from a fiber */
30
+ export function createDom(fiber: Fiber): Node {
31
+ if (fiber.type === "TEXT") {
32
+ return document.createTextNode(fiber.props.nodeValue as string);
33
+ }
34
+ const tag = fiber.type as string;
35
+ const isSvg = SVG_TAGS.has(tag) || isSvgContext(fiber);
36
+ const el = isSvg
37
+ ? document.createElementNS(SVG_NS, tag)
38
+ : document.createElement(tag);
39
+ applyProps(el as HTMLElement, {}, fiber.props);
40
+ return el;
41
+ }
42
+
43
+ /** Check if a fiber is inside an SVG context */
44
+ function isSvgContext(fiber: Fiber): boolean {
45
+ let f = fiber.parent;
46
+ while (f) {
47
+ if (f.type === "svg") return true;
48
+ if (typeof f.type === "string" && f.type !== "svg" && f.dom) return false;
49
+ f = f.parent;
50
+ }
51
+ return false;
52
+ }
53
+
54
+ /** Apply props to a DOM element, diffing against old props */
55
+ export function applyProps(
56
+ el: HTMLElement,
57
+ oldProps: Record<string, unknown>,
58
+ newProps: Record<string, unknown>,
59
+ ): void {
60
+ for (const key of Object.keys(oldProps)) {
61
+ if (key === "children" || key === "key" || key === "ref") continue;
62
+ if (!(key in newProps)) {
63
+ if (key.startsWith("on")) {
64
+ el.removeEventListener(key.slice(2).toLowerCase(), oldProps[key] as EventListener);
65
+ } else {
66
+ el.removeAttribute(key);
67
+ }
68
+ }
69
+ }
70
+
71
+ for (const key of Object.keys(newProps)) {
72
+ if (key === "children" || key === "key" || key === "ref") continue;
73
+ if (oldProps[key] === newProps[key]) continue;
74
+
75
+ switch (key) {
76
+ case "dangerouslySetInnerHTML": {
77
+ const raw = (newProps[key] as { __html?: unknown } | undefined)?.__html;
78
+ if (typeof raw !== "string") {
79
+ throw new TypeError("dangerouslySetInnerHTML expects a string __html value");
80
+ }
81
+ el.innerHTML = htmlSanitizer(raw);
82
+ break;
83
+ }
84
+ case "className":
85
+ el.setAttribute("class", newProps[key] as string);
86
+ break;
87
+ case "style":
88
+ if (typeof newProps[key] === "object" && newProps[key] !== null) {
89
+ const prevStyles = (typeof oldProps[key] === "object" && oldProps[key] !== null)
90
+ ? oldProps[key] as Record<string, unknown>
91
+ : {};
92
+ const styles = newProps[key] as Record<string, unknown>;
93
+ for (const prop of Object.keys(prevStyles)) {
94
+ if (!(prop in styles)) {
95
+ (el.style as unknown as Record<string, string>)[prop] = "";
96
+ }
97
+ }
98
+ for (const [prop, val] of Object.entries(styles)) {
99
+ (el.style as unknown as Record<string, string>)[prop] = val == null ? "" : String(val);
100
+ }
101
+ } else {
102
+ el.removeAttribute("style");
103
+ }
104
+ break;
105
+ default:
106
+ if (key.startsWith("on")) {
107
+ const event = key.slice(2).toLowerCase();
108
+ if (oldProps[key]) {
109
+ el.removeEventListener(event, oldProps[key] as EventListener);
110
+ }
111
+ el.addEventListener(event, newProps[key] as EventListener);
112
+ } else {
113
+ if (unsafeUrlPropChecker(key, newProps[key])) {
114
+ el.removeAttribute(key);
115
+ continue;
116
+ }
117
+ el.setAttribute(key, String(newProps[key]));
118
+ }
119
+ break;
120
+ }
121
+ }
122
+ }
@@ -0,0 +1,40 @@
1
+ import type { VNode, Props } from "../types.js";
2
+ import { currentFiber } from "../coreRenderer.js";
3
+ import { createElement, Fragment } from "../createElement.js";
4
+
5
+ let contextId = 0;
6
+
7
+ export interface Context<T> {
8
+ Provider: (props: Props) => VNode;
9
+ _id: number;
10
+ _defaultValue: T;
11
+ }
12
+
13
+ export function createContext<T>(defaultValue: T): Context<T> {
14
+ const id = contextId++;
15
+
16
+ const Provider = (props: Props) => {
17
+ // Store the context value on the fiber during render
18
+ const fiber = currentFiber!;
19
+ if (!fiber._contexts) fiber._contexts = new Map();
20
+ fiber._contexts.set(id, props.value);
21
+
22
+ const children = props.children ?? [];
23
+ return children.length === 1 ? children[0] : createElement(Fragment, null, ...children);
24
+ };
25
+
26
+ return { Provider, _id: id, _defaultValue: defaultValue };
27
+ }
28
+
29
+ export function useContext<T>(context: Context<T>): T {
30
+ const fiber = currentFiber!;
31
+ // Walk up the fiber tree to find the nearest Provider
32
+ let f = fiber.parent;
33
+ while (f) {
34
+ if (f._contexts?.has(context._id)) {
35
+ return f._contexts.get(context._id) as T;
36
+ }
37
+ f = f.parent;
38
+ }
39
+ return context._defaultValue;
40
+ }
@@ -0,0 +1,145 @@
1
+ import type { Hook } from "../types.js";
2
+ import { currentFiber, scheduleRender } from "../coreRenderer.js";
3
+ import { markPendingEffects } from "../hooksRuntime.js";
4
+
5
+ function getHook(): Hook {
6
+ const fiber = currentFiber!;
7
+ const idx = fiber._hookIndex!;
8
+ fiber._hookIndex = idx + 1;
9
+
10
+ if (idx < fiber.hooks!.length) {
11
+ return fiber.hooks![idx];
12
+ }
13
+ const hook: Hook = { state: undefined };
14
+ fiber.hooks!.push(hook);
15
+ return hook;
16
+ }
17
+
18
+ export function useState<T>(initial: T): [T, (value: T | ((prev: T) => T)) => void] {
19
+ const hook = getHook();
20
+ const fiber = currentFiber!;
21
+
22
+ // Initialize on first render
23
+ if (hook.queue === undefined) {
24
+ hook.state = initial;
25
+ hook.queue = [];
26
+ }
27
+
28
+ // Process queued updates
29
+ for (const action of hook.queue as ((prev: T) => T)[]) {
30
+ hook.state = action(hook.state as T);
31
+ }
32
+ hook.queue = [];
33
+
34
+ const setState = (value: T | ((prev: T) => T)) => {
35
+ const action = typeof value === "function"
36
+ ? value as (prev: T) => T
37
+ : () => value;
38
+ (hook.queue as ((prev: T) => T)[]).push(action);
39
+ scheduleRender(fiber);
40
+ };
41
+
42
+ return [hook.state as T, setState];
43
+ }
44
+
45
+ type EffectCleanup = void | (() => void);
46
+
47
+ interface EffectHook extends Hook {
48
+ state: {
49
+ effect: () => EffectCleanup;
50
+ deps: unknown[] | undefined;
51
+ cleanup?: EffectCleanup;
52
+ pending: boolean;
53
+ };
54
+ }
55
+
56
+ export function useEffect(effect: () => EffectCleanup, deps?: unknown[]): void {
57
+ const hook = getHook() as EffectHook;
58
+ const fiber = currentFiber!;
59
+
60
+ if (hook.state === undefined) {
61
+ hook.state = { effect, deps, cleanup: undefined, pending: true };
62
+ markPendingEffects(fiber);
63
+ } else {
64
+ if (depsChanged(hook.state.deps, deps)) {
65
+ hook.state.effect = effect;
66
+ hook.state.deps = deps;
67
+ hook.state.pending = true;
68
+ markPendingEffects(fiber);
69
+ } else {
70
+ hook.state.pending = false;
71
+ }
72
+ }
73
+ }
74
+
75
+ interface RefHook extends Hook {
76
+ state: { current: unknown };
77
+ }
78
+
79
+ export function useRef<T>(initial: T): { current: T } {
80
+ const hook = getHook() as RefHook;
81
+
82
+ if (hook.state === undefined) {
83
+ hook.state = { current: initial };
84
+ }
85
+
86
+ return hook.state as { current: T };
87
+ }
88
+
89
+ export function useMemo<T>(factory: () => T, deps: unknown[]): T {
90
+ const hook = getHook();
91
+
92
+ if (hook.state === undefined) {
93
+ hook.state = { value: factory(), deps };
94
+ } else {
95
+ const s = hook.state as { value: T; deps: unknown[] };
96
+ if (depsChanged(s.deps, deps)) {
97
+ s.value = factory();
98
+ s.deps = deps;
99
+ }
100
+ }
101
+
102
+ return (hook.state as { value: T }).value;
103
+ }
104
+
105
+ export function useCallback<T extends Function>(cb: T, deps: unknown[]): T {
106
+ return useMemo(() => cb, deps);
107
+ }
108
+
109
+ export function useReducer<S, A>(
110
+ reducer: (state: S, action: A) => S,
111
+ initialState: S,
112
+ ): [S, (action: A) => void] {
113
+ const [state, setState] = useState(initialState);
114
+ const dispatch = (action: A) => {
115
+ setState((prev) => reducer(prev, action));
116
+ };
117
+ return [state, dispatch];
118
+ }
119
+
120
+ export function createRef<T = unknown>(): { current: T | null } {
121
+ return { current: null };
122
+ }
123
+
124
+ export function useErrorBoundary(): [
125
+ unknown,
126
+ () => void,
127
+ ] {
128
+ const [error, setError] = useState<unknown>(null);
129
+ const fiber = currentFiber!;
130
+ fiber._errorHandler = (err: unknown) => setError(err);
131
+ const resetError = () => setError(null);
132
+ return [error, resetError];
133
+ }
134
+
135
+ export function depsChanged(
136
+ oldDeps: unknown[] | undefined,
137
+ newDeps: unknown[] | undefined,
138
+ ): boolean {
139
+ if (!oldDeps || !newDeps) return true;
140
+ if (oldDeps.length !== newDeps.length) return true;
141
+ for (let i = 0; i < oldDeps.length; i++) {
142
+ if (!Object.is(oldDeps[i], newDeps[i])) return true;
143
+ }
144
+ return false;
145
+ }
@@ -0,0 +1,33 @@
1
+ import type { Fiber } from "../types.js";
2
+ import { registerComponentBailoutHandler } from "../runtimeExtensions.js";
3
+ import { isMemoComponent, type MemoComponent } from "../memoMarker.js";
4
+
5
+ let registered = false;
6
+
7
+ export function ensureMemoRuntime(): void {
8
+ if (!registered) {
9
+ registered = true;
10
+ registerComponentBailoutHandler(memoBailoutHandler);
11
+ }
12
+ }
13
+
14
+ function memoBailoutHandler(fiber: Fiber): boolean {
15
+ if (!isMemoComponent(fiber.type)) return false;
16
+ if (!fiber.alternate) return false;
17
+
18
+ const memoComp = fiber.type as MemoComponent;
19
+ if (!memoComp._compare(fiber.alternate.props, fiber.props)) {
20
+ return false;
21
+ }
22
+
23
+ // Reuse entire subtree when memo compare passes.
24
+ fiber.child = fiber.alternate.child;
25
+ fiber.hooks = fiber.alternate.hooks;
26
+
27
+ let child = fiber.child;
28
+ while (child) {
29
+ child.parent = fiber;
30
+ child = child.sibling;
31
+ }
32
+ return true;
33
+ }
@@ -0,0 +1,61 @@
1
+ import { setHtmlSanitizer as setHtmlSanitizerImpl, setUnsafeUrlPropChecker } from "../dom.js";
2
+ import type { HtmlSanitizer } from "../dom.js";
3
+
4
+ const URL_ATTRS = new Set(["href", "src", "action", "formaction", "xlink:href"]);
5
+ const BLOCKED_HTML_TAGS = new Set(["script", "iframe", "object", "embed", "link", "meta", "base"]);
6
+
7
+ function normalizedUrl(value: string): string {
8
+ return value.replace(/[\u0000-\u0020\u007f]+/g, "").toLowerCase();
9
+ }
10
+
11
+ function isUnsafeUrl(value: string): boolean {
12
+ const normalized = normalizedUrl(value);
13
+ return normalized.startsWith("javascript:") || normalized.startsWith("vbscript:");
14
+ }
15
+
16
+ function isUnsafeUrlProp(key: string, value: unknown): boolean {
17
+ if (typeof value !== "string") return false;
18
+ return URL_ATTRS.has(key.toLowerCase()) && isUnsafeUrl(value);
19
+ }
20
+
21
+ function defaultHtmlSanitizer(html: string): string {
22
+ const template = document.createElement("template");
23
+ template.innerHTML = html;
24
+
25
+ const elements = template.content.querySelectorAll("*");
26
+ for (const element of elements) {
27
+ const tagName = element.tagName.toLowerCase();
28
+ if (BLOCKED_HTML_TAGS.has(tagName)) {
29
+ element.remove();
30
+ continue;
31
+ }
32
+
33
+ for (const attr of Array.from(element.attributes)) {
34
+ const attrName = attr.name.toLowerCase();
35
+ if (attrName.startsWith("on")) {
36
+ element.removeAttribute(attr.name);
37
+ continue;
38
+ }
39
+
40
+ if (URL_ATTRS.has(attrName) && isUnsafeUrl(attr.value)) {
41
+ element.removeAttribute(attr.name);
42
+ }
43
+ }
44
+ }
45
+
46
+ return template.innerHTML;
47
+ }
48
+
49
+ export function setHtmlSanitizer(sanitizer: HtmlSanitizer | null): void {
50
+ setHtmlSanitizerImpl(sanitizer ?? defaultHtmlSanitizer);
51
+ }
52
+
53
+ let initialized = false;
54
+
55
+ export function ensureSecurityDefaults(): void {
56
+ if (!initialized) {
57
+ initialized = true;
58
+ setHtmlSanitizerImpl(defaultHtmlSanitizer);
59
+ setUnsafeUrlPropChecker(isUnsafeUrlProp);
60
+ }
61
+ }