preact-missing-hooks 3.1.0 → 4.1.0

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 (82) hide show
  1. package/.husky/pre-commit +1 -0
  2. package/.husky/pre-push +1 -0
  3. package/.prettierignore +3 -0
  4. package/.prettierrc +6 -0
  5. package/Readme.md +333 -137
  6. package/dist/entry.cjs +21 -0
  7. package/dist/entry.js +2 -0
  8. package/dist/entry.js.map +1 -0
  9. package/dist/entry.modern.mjs +2 -0
  10. package/dist/entry.modern.mjs.map +1 -0
  11. package/dist/entry.module.js +2 -0
  12. package/dist/entry.module.js.map +1 -0
  13. package/dist/entry.umd.js +2 -0
  14. package/dist/entry.umd.js.map +1 -0
  15. package/dist/index.d.ts +14 -13
  16. package/dist/index.js +1 -1
  17. package/dist/index.js.map +1 -1
  18. package/dist/index.modern.mjs +2 -0
  19. package/dist/index.modern.mjs.map +1 -0
  20. package/dist/index.module.js +1 -1
  21. package/dist/index.module.js.map +1 -1
  22. package/dist/index.umd.js +1 -1
  23. package/dist/index.umd.js.map +1 -1
  24. package/dist/indexedDB/dbController.d.ts +2 -2
  25. package/dist/indexedDB/index.d.ts +6 -6
  26. package/dist/indexedDB/openDB.d.ts +1 -1
  27. package/dist/indexedDB/tableController.d.ts +1 -1
  28. package/dist/indexedDB/types.d.ts +1 -2
  29. package/dist/react.js +1 -0
  30. package/dist/react.modern.mjs +1 -0
  31. package/dist/react.module.js +1 -0
  32. package/dist/react.umd.js +1 -0
  33. package/dist/useEventBus.d.ts +1 -1
  34. package/dist/useIndexedDB.d.ts +3 -3
  35. package/dist/useLLMMetadata.d.ts +71 -0
  36. package/dist/useMutationObserver.d.ts +1 -1
  37. package/dist/useNetworkState.d.ts +3 -3
  38. package/dist/usePreferredTheme.d.ts +1 -1
  39. package/dist/useRageClick.d.ts +1 -1
  40. package/dist/useThreadedWorker.d.ts +1 -1
  41. package/dist/useTransition.d.ts +4 -1
  42. package/dist/useWorkerNotifications.d.ts +1 -1
  43. package/dist/useWrappedChildren.d.ts +3 -3
  44. package/docs/README.md +111 -0
  45. package/docs/index.html +58 -20
  46. package/docs/main.js +49 -0
  47. package/eslint.config.mjs +10 -0
  48. package/package.json +60 -6
  49. package/scripts/generate-entry.cjs +34 -0
  50. package/src/index.ts +14 -13
  51. package/src/indexedDB/dbController.ts +101 -92
  52. package/src/indexedDB/index.ts +16 -11
  53. package/src/indexedDB/openDB.ts +49 -49
  54. package/src/indexedDB/requestToPromise.ts +17 -16
  55. package/src/indexedDB/tableController.ts +331 -257
  56. package/src/indexedDB/types.ts +35 -35
  57. package/src/useClipboard.ts +99 -97
  58. package/src/useEventBus.ts +39 -36
  59. package/src/useIndexedDB.ts +111 -111
  60. package/src/useLLMMetadata.ts +418 -0
  61. package/src/useMutationObserver.ts +26 -26
  62. package/src/useNetworkState.ts +124 -122
  63. package/src/usePreferredTheme.ts +68 -68
  64. package/src/useRageClick.ts +103 -103
  65. package/src/useThreadedWorker.ts +165 -165
  66. package/src/useTransition.ts +22 -19
  67. package/src/useWasmCompute.ts +209 -204
  68. package/src/useWebRTCIP.ts +181 -176
  69. package/src/useWorkerNotifications.ts +28 -20
  70. package/src/useWrappedChildren.ts +72 -58
  71. package/tests/preact-as-react.ts +5 -0
  72. package/tests/react-adapter.tsx +12 -0
  73. package/tests/setup-react.ts +4 -0
  74. package/tests/useClipboard.test.tsx +4 -2
  75. package/tests/useLLMMetadata.test.tsx +149 -0
  76. package/tests/useThreadedWorker.test.tsx +3 -1
  77. package/tests/useWasmCompute.test.tsx +1 -1
  78. package/tests/useWebRTCIP.test.tsx +3 -1
  79. package/vite.config.ts +11 -4
  80. package/vitest.config.preact.ts +21 -0
  81. package/vitest.config.react.ts +36 -0
  82. package/vitest.workspace.ts +6 -0
@@ -0,0 +1,418 @@
1
+ import { useEffect, useRef } from "react";
2
+
3
+ const SCRIPT_SELECTOR = 'script[data-llm="true"]';
4
+ const SCRIPT_TYPE = "application/llm+json";
5
+
6
+ /** Max lengths for string fields to avoid huge payloads and XSS surface. */
7
+ const MAX_TITLE = 200;
8
+ const MAX_DESCRIPTION = 2000;
9
+ const MAX_TAG_LENGTH = 100;
10
+ const MAX_TAGS = 50;
11
+ const MAX_OUTLINE_ITEM = 300;
12
+ const MAX_OUTLINE_ITEMS = 50;
13
+ const MAX_URL_LENGTH = 2048;
14
+ const MAX_AUTHOR = 200;
15
+ const MAX_SITE_NAME = 100;
16
+ const MAX_ROBOTS = 100;
17
+
18
+ /** Open Graph / content type for og:type. */
19
+ export type OGType =
20
+ | "website"
21
+ | "article"
22
+ | "profile"
23
+ | "video.other"
24
+ | "product"
25
+ | "music.song"
26
+ | "book";
27
+
28
+ /** Config for the useLLMMetadata hook. All fields optional except route. */
29
+ export interface LLMConfig {
30
+ /** Current route path (e.g. "/blog/ai-hooks"). Changes trigger metadata update. */
31
+ route: string;
32
+ /** "manual" = use title/description/tags from config. "auto-extract" = derive from DOM. */
33
+ mode?: "manual" | "auto-extract";
34
+ /** Page title (manual mode). */
35
+ title?: string;
36
+ /** Page description (manual mode). */
37
+ description?: string;
38
+ /** Tags/keywords (manual mode). */
39
+ tags?: string[];
40
+ /** Canonical URL (absolute). */
41
+ canonicalUrl?: string;
42
+ /** Content language (e.g. "en", "en-US"). */
43
+ language?: string;
44
+ /** Open Graph type (website, article, etc.). */
45
+ ogType?: OGType;
46
+ /** OG image URL (absolute). */
47
+ ogImage?: string;
48
+ /** OG image alt text. */
49
+ ogImageAlt?: string;
50
+ /** Site name (e.g. for social previews). */
51
+ siteName?: string;
52
+ /** Author name (for articles). */
53
+ author?: string;
54
+ /** ISO date string (article publish). */
55
+ publishedTime?: string;
56
+ /** ISO date string (article last modified). */
57
+ modifiedTime?: string;
58
+ /** Robots hint (e.g. "index, follow"). */
59
+ robots?: string;
60
+ /** Extra key-value pairs (e.g. section, category). Keys/values are sanitized. */
61
+ extra?: Record<string, string | number | boolean | string[]>;
62
+ }
63
+
64
+ /** Payload injected as JSON in the LLM script tag. Only includes defined, safe values. */
65
+ export interface LLMPayload {
66
+ route: string;
67
+ title?: string;
68
+ description?: string;
69
+ tags?: string[];
70
+ outline?: string[];
71
+ canonicalUrl?: string;
72
+ language?: string;
73
+ ogType?: string;
74
+ ogImage?: string;
75
+ ogImageAlt?: string;
76
+ siteName?: string;
77
+ author?: string;
78
+ publishedTime?: string;
79
+ modifiedTime?: string;
80
+ robots?: string;
81
+ generatedAt: string;
82
+ extra?: Record<string, string | number | boolean | string[]>;
83
+ }
84
+
85
+ /** Selectors for elements to ignore when auto-extracting. */
86
+ const IGNORE_SELECTOR =
87
+ "nav, footer, [role='navigation'], [role='contentinfo'], script, style, noscript";
88
+
89
+ /**
90
+ * Coerce value to a safe string for JSON. Never throws.
91
+ */
92
+ function safeStr(value: unknown, maxLen: number): string {
93
+ if (value == null) return "";
94
+ const s = typeof value === "string" ? value : String(value);
95
+ const trimmed = s.trim();
96
+ return trimmed.length > maxLen ? trimmed.slice(0, maxLen) : trimmed;
97
+ }
98
+
99
+ /**
100
+ * Coerce to safe string array. Never throws.
101
+ */
102
+ function safeTagList(value: unknown): string[] {
103
+ if (!Array.isArray(value)) return [];
104
+ const out: string[] = [];
105
+ for (let i = 0; i < value.length && out.length < MAX_TAGS; i++) {
106
+ const s = safeStr(value[i], MAX_TAG_LENGTH);
107
+ if (s) out.push(s);
108
+ }
109
+ return out;
110
+ }
111
+
112
+ /**
113
+ * Safe URL: only include if it looks like a valid http(s) URL and within length.
114
+ */
115
+ function safeUrl(value: unknown): string {
116
+ const s = safeStr(value, MAX_URL_LENGTH);
117
+ if (!s) return "";
118
+ try {
119
+ const u = new URL(s);
120
+ if (u.protocol === "http:" || u.protocol === "https:") return s;
121
+ } catch {
122
+ // ignore
123
+ }
124
+ return "";
125
+ }
126
+
127
+ /**
128
+ * Returns true if el is visible. Never throws.
129
+ */
130
+ function isVisible(el: Element): boolean {
131
+ try {
132
+ if (typeof window === "undefined") return false;
133
+ const style = window.getComputedStyle(el);
134
+ return (
135
+ style.display !== "none" &&
136
+ style.visibility !== "hidden" &&
137
+ style.opacity !== "0"
138
+ );
139
+ } catch {
140
+ return false;
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Auto-extract title, outline (h1/h2), and first 3 visible paragraphs.
146
+ * Fully safe: never throws; returns defaults on any error.
147
+ */
148
+ function autoExtract(): {
149
+ title: string;
150
+ outline: string[];
151
+ description: string;
152
+ } {
153
+ const empty = { title: "", outline: [] as string[], description: "" };
154
+ try {
155
+ if (typeof document === "undefined") return empty;
156
+ const title = safeStr(document.title, MAX_TITLE);
157
+ const outline: string[] = [];
158
+ let description = "";
159
+
160
+ try {
161
+ const ignoreRoots = document.querySelectorAll(IGNORE_SELECTOR);
162
+ const isInsideIgnored = (el: Element): boolean => {
163
+ for (const root of ignoreRoots) {
164
+ if (root.contains(el)) return true;
165
+ }
166
+ return false;
167
+ };
168
+
169
+ const headings = document.querySelectorAll("h1, h2");
170
+ for (const h of headings) {
171
+ if (outline.length >= MAX_OUTLINE_ITEMS) break;
172
+ if (isVisible(h) && !isInsideIgnored(h)) {
173
+ const text = safeStr(h.textContent, MAX_OUTLINE_ITEM);
174
+ if (text) outline.push(text);
175
+ }
176
+ }
177
+
178
+ const paragraphs = document.querySelectorAll("p");
179
+ const visibleParagraphs: string[] = [];
180
+ for (const p of paragraphs) {
181
+ if (visibleParagraphs.length >= 3) break;
182
+ if (isVisible(p) && !isInsideIgnored(p)) {
183
+ const text = safeStr(p.textContent, 1000);
184
+ if (text) visibleParagraphs.push(text);
185
+ }
186
+ }
187
+ description =
188
+ visibleParagraphs.join(" ").trim().slice(0, MAX_DESCRIPTION) || "";
189
+ } catch {
190
+ // keep title, empty outline/description
191
+ }
192
+
193
+ return { title, outline, description };
194
+ } catch {
195
+ return empty;
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Normalize config to a safe object. Never throws.
201
+ */
202
+ function normalizeConfig(config: LLMConfig | null | undefined): LLMConfig {
203
+ if (config == null || typeof config !== "object") {
204
+ return { route: "/" };
205
+ }
206
+ return {
207
+ route: safeStr(config.route, 2048) || "/",
208
+ mode: config.mode === "auto-extract" ? "auto-extract" : "manual",
209
+ title:
210
+ config.title !== undefined ? safeStr(config.title, MAX_TITLE) : undefined,
211
+ description:
212
+ config.description !== undefined
213
+ ? safeStr(config.description, MAX_DESCRIPTION)
214
+ : undefined,
215
+ tags: config.tags !== undefined ? safeTagList(config.tags) : undefined,
216
+ canonicalUrl:
217
+ config.canonicalUrl !== undefined
218
+ ? safeUrl(config.canonicalUrl)
219
+ : undefined,
220
+ language:
221
+ config.language !== undefined ? safeStr(config.language, 20) : undefined,
222
+ ogType:
223
+ config.ogType !== undefined
224
+ ? (safeStr(config.ogType, 50) as OGType)
225
+ : undefined,
226
+ ogImage: config.ogImage !== undefined ? safeUrl(config.ogImage) : undefined,
227
+ ogImageAlt:
228
+ config.ogImageAlt !== undefined
229
+ ? safeStr(config.ogImageAlt, 200)
230
+ : undefined,
231
+ siteName:
232
+ config.siteName !== undefined
233
+ ? safeStr(config.siteName, MAX_SITE_NAME)
234
+ : undefined,
235
+ author:
236
+ config.author !== undefined
237
+ ? safeStr(config.author, MAX_AUTHOR)
238
+ : undefined,
239
+ publishedTime:
240
+ config.publishedTime !== undefined
241
+ ? safeStr(config.publishedTime, 50)
242
+ : undefined,
243
+ modifiedTime:
244
+ config.modifiedTime !== undefined
245
+ ? safeStr(config.modifiedTime, 50)
246
+ : undefined,
247
+ robots:
248
+ config.robots !== undefined
249
+ ? safeStr(config.robots, MAX_ROBOTS)
250
+ : undefined,
251
+ extra:
252
+ config.extra !== undefined &&
253
+ typeof config.extra === "object" &&
254
+ !Array.isArray(config.extra)
255
+ ? sanitizeExtra(config.extra)
256
+ : undefined,
257
+ };
258
+ }
259
+
260
+ /**
261
+ * Sanitize extra object: only string/number/boolean/string[] values; keys and strings bounded.
262
+ */
263
+ function sanitizeExtra(
264
+ extra: Record<string, unknown>
265
+ ): Record<string, string | number | boolean | string[]> {
266
+ const out: Record<string, string | number | boolean | string[]> = {};
267
+ try {
268
+ for (const key of Object.keys(extra).slice(0, 20)) {
269
+ const safeKey = safeStr(key, 50);
270
+ if (!safeKey) continue;
271
+ const v = extra[key];
272
+ if (typeof v === "string") out[safeKey] = v.slice(0, 500);
273
+ else if (typeof v === "number" && Number.isFinite(v)) out[safeKey] = v;
274
+ else if (typeof v === "boolean") out[safeKey] = v;
275
+ else if (Array.isArray(v))
276
+ out[safeKey] = v
277
+ .map((x) => safeStr(x, 200))
278
+ .filter(Boolean)
279
+ .slice(0, 20);
280
+ }
281
+ } catch {
282
+ // ignore
283
+ }
284
+ return out;
285
+ }
286
+
287
+ /**
288
+ * Build the LLM payload. Never throws; returns minimal payload on any error.
289
+ */
290
+ function buildPayload(normalized: LLMConfig): LLMPayload {
291
+ try {
292
+ const generatedAt = new Date().toISOString();
293
+ const base: LLMPayload = {
294
+ route: normalized.route,
295
+ generatedAt,
296
+ };
297
+
298
+ if (normalized.mode === "auto-extract") {
299
+ const extracted = autoExtract();
300
+ base.title = (normalized.title ?? extracted.title) || undefined;
301
+ base.description =
302
+ (normalized.description ?? extracted.description) || undefined;
303
+ if (extracted.outline.length > 0) base.outline = extracted.outline;
304
+ } else {
305
+ if (normalized.title !== undefined && normalized.title !== "")
306
+ base.title = normalized.title;
307
+ if (normalized.description !== undefined && normalized.description !== "")
308
+ base.description = normalized.description;
309
+ }
310
+
311
+ if (normalized.tags && normalized.tags.length > 0)
312
+ base.tags = normalized.tags;
313
+ if (normalized.canonicalUrl) base.canonicalUrl = normalized.canonicalUrl;
314
+ if (normalized.language) base.language = normalized.language;
315
+ if (normalized.ogType) base.ogType = normalized.ogType;
316
+ if (normalized.ogImage) base.ogImage = normalized.ogImage;
317
+ if (normalized.ogImageAlt) base.ogImageAlt = normalized.ogImageAlt;
318
+ if (normalized.siteName) base.siteName = normalized.siteName;
319
+ if (normalized.author) base.author = normalized.author;
320
+ if (normalized.publishedTime) base.publishedTime = normalized.publishedTime;
321
+ if (normalized.modifiedTime) base.modifiedTime = normalized.modifiedTime;
322
+ if (normalized.robots) base.robots = normalized.robots;
323
+ if (normalized.extra && Object.keys(normalized.extra).length > 0)
324
+ base.extra = normalized.extra;
325
+
326
+ return base;
327
+ } catch {
328
+ return {
329
+ route: normalized?.route ?? "/",
330
+ generatedAt: new Date().toISOString(),
331
+ };
332
+ }
333
+ }
334
+
335
+ /**
336
+ * Remove any existing LLM script. Never throws.
337
+ */
338
+ function removeExistingScript(): void {
339
+ try {
340
+ if (typeof document === "undefined" || !document.querySelectorAll) return;
341
+ document.querySelectorAll(SCRIPT_SELECTOR).forEach((el) => el.remove());
342
+ } catch {
343
+ // ignore
344
+ }
345
+ }
346
+
347
+ /**
348
+ * Inject a new LLM script. Never throws.
349
+ */
350
+ function injectScript(payload: LLMPayload): void {
351
+ try {
352
+ if (typeof document === "undefined" || !document.head) return;
353
+ const script = document.createElement("script");
354
+ script.type = SCRIPT_TYPE;
355
+ script.setAttribute("data-llm", "true");
356
+ script.textContent = JSON.stringify(payload);
357
+ document.head.appendChild(script);
358
+ } catch {
359
+ // ignore
360
+ }
361
+ }
362
+
363
+ /**
364
+ * Production-ready hook: injects an AI-readable metadata block into the document head
365
+ * when the route changes. Framework-agnostic (React 18+ and Preact 10+ via aliasing).
366
+ *
367
+ * - Rich structure: route, title, description, tags, outline (auto), canonicalUrl, language,
368
+ * ogType, ogImage, ogImageAlt, siteName, author, publishedTime, modifiedTime, robots, extra.
369
+ * - Safe usage: never throws; invalid/missing config is normalized; DOM and JSON are guarded.
370
+ * - Cacheable: if the generated payload is unchanged, the script is not replaced.
371
+ * - SSR-safe: no-op when window/document is undefined.
372
+ * - Cleans up on unmount (removes the script).
373
+ *
374
+ * @param config - Route (required), mode, and optional metadata fields. Can be partial; defaults applied.
375
+ */
376
+ export function useLLMMetadata(config: LLMConfig | null | undefined): void {
377
+ const prevPayloadRef = useRef<string | null>(null);
378
+
379
+ useEffect(() => {
380
+ try {
381
+ if (typeof window === "undefined") return;
382
+
383
+ const normalized = normalizeConfig(config);
384
+ const payload = buildPayload(normalized);
385
+ const payloadStr = JSON.stringify(payload);
386
+
387
+ if (prevPayloadRef.current === payloadStr) return;
388
+ prevPayloadRef.current = payloadStr;
389
+
390
+ removeExistingScript();
391
+ injectScript(payload);
392
+
393
+ return () => {
394
+ removeExistingScript();
395
+ prevPayloadRef.current = null;
396
+ };
397
+ } catch {
398
+ // no-op: never throw from effect
399
+ }
400
+ }, [
401
+ config?.route,
402
+ config?.mode,
403
+ config?.title,
404
+ config?.description,
405
+ config?.tags,
406
+ config?.canonicalUrl,
407
+ config?.language,
408
+ config?.ogType,
409
+ config?.ogImage,
410
+ config?.ogImageAlt,
411
+ config?.siteName,
412
+ config?.author,
413
+ config?.publishedTime,
414
+ config?.modifiedTime,
415
+ config?.robots,
416
+ // extra is read inside effect; change route/title/etc. to force update when only extra changed
417
+ ]);
418
+ }
@@ -1,26 +1,26 @@
1
- import { RefObject } from 'preact'
2
- import { useEffect } from 'preact/hooks'
3
-
4
- export type UseMutationObserverOptions = MutationObserverInit
5
-
6
- /**
7
- * A Preact hook to observe DOM mutations using MutationObserver.
8
- * @param target - The element to observe.
9
- * @param callback - Function to call on mutation.
10
- * @param options - MutationObserver options.
11
- */
12
- export function useMutationObserver(
13
- targetRef: RefObject<HTMLElement | null>,
14
- callback: MutationCallback,
15
- options: MutationObserverInit
16
- ) {
17
- useEffect(() => {
18
- const node = targetRef.current
19
- if (!node) return
20
-
21
- const observer = new MutationObserver(callback)
22
- observer.observe(node, options)
23
-
24
- return () => observer.disconnect()
25
- }, [targetRef, callback, options])
26
- }
1
+ import { RefObject } from "preact";
2
+ import { useEffect } from "preact/hooks";
3
+
4
+ export type UseMutationObserverOptions = MutationObserverInit;
5
+
6
+ /**
7
+ * A Preact hook to observe DOM mutations using MutationObserver.
8
+ * @param target - The element to observe.
9
+ * @param callback - Function to call on mutation.
10
+ * @param options - MutationObserver options.
11
+ */
12
+ export function useMutationObserver(
13
+ targetRef: RefObject<HTMLElement | null>,
14
+ callback: MutationCallback,
15
+ options: MutationObserverInit
16
+ ) {
17
+ useEffect(() => {
18
+ const node = targetRef.current;
19
+ if (!node) return;
20
+
21
+ const observer = new MutationObserver(callback);
22
+ observer.observe(node, options);
23
+
24
+ return () => observer.disconnect();
25
+ }, [targetRef, callback, options]);
26
+ }