@decocms/blocks 7.0.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 (111) hide show
  1. package/package.json +97 -0
  2. package/src/cms/applySectionConventions.ts +128 -0
  3. package/src/cms/blockSource.test.ts +67 -0
  4. package/src/cms/blockSource.ts +124 -0
  5. package/src/cms/index.ts +104 -0
  6. package/src/cms/layoutCacheRace.test.ts +146 -0
  7. package/src/cms/loadDecofileDirectory.test.ts +52 -0
  8. package/src/cms/loadDecofileDirectory.ts +48 -0
  9. package/src/cms/loader.test.ts +184 -0
  10. package/src/cms/loader.ts +284 -0
  11. package/src/cms/registry.test.ts +118 -0
  12. package/src/cms/registry.ts +252 -0
  13. package/src/cms/resolve.test.ts +547 -0
  14. package/src/cms/resolve.ts +2003 -0
  15. package/src/cms/schema.ts +993 -0
  16. package/src/cms/sectionLoaders.test.ts +409 -0
  17. package/src/cms/sectionLoaders.ts +562 -0
  18. package/src/cms/sectionMixins.test.ts +163 -0
  19. package/src/cms/sectionMixins.ts +153 -0
  20. package/src/hooks/LazySection.tsx +121 -0
  21. package/src/hooks/LiveControls.tsx +122 -0
  22. package/src/hooks/RenderSection.test.tsx +81 -0
  23. package/src/hooks/RenderSection.tsx +91 -0
  24. package/src/hooks/SectionErrorFallback.tsx +97 -0
  25. package/src/hooks/index.ts +4 -0
  26. package/src/index.ts +8 -0
  27. package/src/matchers/builtins.test.ts +251 -0
  28. package/src/matchers/builtins.ts +437 -0
  29. package/src/matchers/countryNames.ts +104 -0
  30. package/src/matchers/override.test.ts +205 -0
  31. package/src/matchers/override.ts +136 -0
  32. package/src/matchers/posthog.ts +154 -0
  33. package/src/middleware/decoState.ts +55 -0
  34. package/src/middleware/healthMetrics.ts +133 -0
  35. package/src/middleware/hydrationContext.test.ts +61 -0
  36. package/src/middleware/hydrationContext.ts +79 -0
  37. package/src/middleware/index.ts +88 -0
  38. package/src/middleware/liveness.ts +21 -0
  39. package/src/middleware/observability.test.ts +238 -0
  40. package/src/middleware/observability.ts +620 -0
  41. package/src/middleware/validateSection.test.ts +147 -0
  42. package/src/middleware/validateSection.ts +100 -0
  43. package/src/sdk/abTesting.test.ts +326 -0
  44. package/src/sdk/abTesting.ts +499 -0
  45. package/src/sdk/analytics.ts +77 -0
  46. package/src/sdk/cacheHeaders.test.ts +115 -0
  47. package/src/sdk/cacheHeaders.ts +424 -0
  48. package/src/sdk/cachedLoader.ts +364 -0
  49. package/src/sdk/clx.ts +5 -0
  50. package/src/sdk/cn.test.ts +34 -0
  51. package/src/sdk/cn.ts +28 -0
  52. package/src/sdk/composite.test.ts +121 -0
  53. package/src/sdk/composite.ts +114 -0
  54. package/src/sdk/cookie.test.ts +108 -0
  55. package/src/sdk/cookie.ts +129 -0
  56. package/src/sdk/crypto.ts +185 -0
  57. package/src/sdk/csp.ts +59 -0
  58. package/src/sdk/djb2.ts +20 -0
  59. package/src/sdk/encoding.test.ts +71 -0
  60. package/src/sdk/encoding.ts +47 -0
  61. package/src/sdk/env.ts +31 -0
  62. package/src/sdk/http.test.ts +71 -0
  63. package/src/sdk/http.ts +124 -0
  64. package/src/sdk/index.ts +90 -0
  65. package/src/sdk/inflightTimeout.test.ts +35 -0
  66. package/src/sdk/inflightTimeout.ts +53 -0
  67. package/src/sdk/instrumentedFetch.test.ts +559 -0
  68. package/src/sdk/instrumentedFetch.ts +339 -0
  69. package/src/sdk/invoke.test.ts +115 -0
  70. package/src/sdk/invoke.ts +260 -0
  71. package/src/sdk/logger.test.ts +432 -0
  72. package/src/sdk/logger.ts +304 -0
  73. package/src/sdk/mergeCacheControl.ts +150 -0
  74. package/src/sdk/normalizeUrls.ts +91 -0
  75. package/src/sdk/observability.ts +109 -0
  76. package/src/sdk/otel.test.ts +526 -0
  77. package/src/sdk/otel.ts +981 -0
  78. package/src/sdk/otelAdapters/clickhouseCollector.ts +65 -0
  79. package/src/sdk/otelAdapters.test.ts +89 -0
  80. package/src/sdk/otelAdapters.ts +144 -0
  81. package/src/sdk/otelHttpLog.test.ts +457 -0
  82. package/src/sdk/otelHttpLog.ts +419 -0
  83. package/src/sdk/otelHttpMeter.test.ts +292 -0
  84. package/src/sdk/otelHttpMeter.ts +506 -0
  85. package/src/sdk/otelHttpTracer.test.ts +474 -0
  86. package/src/sdk/otelHttpTracer.ts +543 -0
  87. package/src/sdk/redirects.ts +225 -0
  88. package/src/sdk/requestContext.ts +281 -0
  89. package/src/sdk/requestContextStorage.browser.test.ts +29 -0
  90. package/src/sdk/requestContextStorage.browser.ts +43 -0
  91. package/src/sdk/requestContextStorage.ts +35 -0
  92. package/src/sdk/retry.ts +45 -0
  93. package/src/sdk/serverTimings.ts +68 -0
  94. package/src/sdk/signal.ts +42 -0
  95. package/src/sdk/sitemap.ts +160 -0
  96. package/src/sdk/urlRedaction.test.ts +73 -0
  97. package/src/sdk/urlRedaction.ts +82 -0
  98. package/src/sdk/urlUtils.ts +134 -0
  99. package/src/sdk/useDevice.test.ts +130 -0
  100. package/src/sdk/useDevice.ts +109 -0
  101. package/src/sdk/useDeviceContext.tsx +108 -0
  102. package/src/sdk/useId.ts +7 -0
  103. package/src/sdk/useScript.test.ts +128 -0
  104. package/src/sdk/useScript.ts +210 -0
  105. package/src/sdk/useSuggestions.test.ts +230 -0
  106. package/src/sdk/useSuggestions.ts +188 -0
  107. package/src/sdk/wrapCaughtErrors.ts +107 -0
  108. package/src/setup.ts +119 -0
  109. package/src/types/index.ts +39 -0
  110. package/src/types/widgets.ts +14 -0
  111. package/tsconfig.json +7 -0
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Server-side device detection via RequestContext.
3
+ *
4
+ * @example
5
+ * ```tsx
6
+ * // In a section loader or server function:
7
+ * import { detectDevice } from "@decocms/start/sdk/useDevice";
8
+ *
9
+ * export function loader(props: Props, req: Request) {
10
+ * const device = detectDevice(req.headers.get("user-agent") ?? "");
11
+ * return { ...props, device };
12
+ * }
13
+ *
14
+ * // Or via RequestContext (no request argument needed):
15
+ * import { useDevice } from "@decocms/start/sdk/useDevice";
16
+ *
17
+ * export function loader(props: Props) {
18
+ * const device = useDevice();
19
+ * return { ...props, isMobile: device === "mobile" };
20
+ * }
21
+ * ```
22
+ */
23
+
24
+ import { RequestContext } from "./requestContext";
25
+
26
+ export type Device = "mobile" | "tablet" | "desktop";
27
+
28
+ // `DeviceContext`, the `useDevice()` hook, and `DeviceProvider` live in
29
+ // `./useDeviceContext` (a `"use client"` file) and are re-exported below for
30
+ // full backward compatibility with this module's public API — see that
31
+ // file's doc comment for why the split exists. Everything that stays in
32
+ // *this* file is a plain, hook-free function, safe to import from
33
+ // server-only code (e.g. `cms/sectionMixins.ts`'s `withDevice()`/
34
+ // `withMobile()` loader mixins) without dragging a client-only hook/context
35
+ // boundary into a Server Component's module graph.
36
+ export { DeviceContext, useDevice, DeviceProvider } from "./useDeviceContext";
37
+
38
+ // Android phones include "Mobile" in their UA; Android tablets do not.
39
+ // Check TABLET_RE first so `android(?!.*mobile)` captures tablets before
40
+ // the MOBILE_RE `android.*mobile` branch matches phones.
41
+ export const MOBILE_RE = /mobile|android.*mobile|iphone|ipod|webos|blackberry|opera mini|iemobile/i;
42
+ export const TABLET_RE = /ipad|tablet|kindle|silk|playbook|android(?!.*mobile)/i;
43
+
44
+ /**
45
+ * Simple mobile-or-not check (mobile + tablet = true).
46
+ * Use this for cache key splitting or any context where you
47
+ * only need a mobile/desktop binary decision.
48
+ */
49
+ export function isMobileUA(userAgent: string): boolean {
50
+ return MOBILE_RE.test(userAgent) || TABLET_RE.test(userAgent);
51
+ }
52
+
53
+ /**
54
+ * Detect device type from a User-Agent string.
55
+ * Pure function — no side effects, works anywhere.
56
+ */
57
+ export function detectDevice(userAgent: string): Device {
58
+ if (TABLET_RE.test(userAgent)) return "tablet";
59
+ if (MOBILE_RE.test(userAgent)) return "mobile";
60
+ return "desktop";
61
+ }
62
+
63
+ /**
64
+ * Resolve the current device from the ambient runtime (RequestContext on the
65
+ * server, `navigator.userAgent` on the client) — no React hooks involved.
66
+ * Used both by the plain `check*()` helpers below and by `useDevice()`/
67
+ * `DeviceProvider` in `./useDeviceContext`.
68
+ */
69
+ export function resolveDeviceFromRuntime(): Device {
70
+ // Server: use RequestContext UA header
71
+ if (typeof document === "undefined") {
72
+ const ctx = RequestContext.current;
73
+ if (!ctx) return "desktop";
74
+ const ua = ctx.request.headers.get("user-agent") ?? "";
75
+ return detectDevice(ua);
76
+ }
77
+ // Client: use navigator.userAgent for consistency with server-side UA detection.
78
+ // Using viewport width would produce different results between SSR and
79
+ // hydration (server sees UA, client sees pixels), causing hydration mismatch.
80
+ const ua = typeof navigator !== "undefined" ? navigator.userAgent : "";
81
+ return detectDevice(ua);
82
+ }
83
+
84
+ /**
85
+ * Check if the current request is from a mobile device.
86
+ */
87
+ export function checkMobile(): boolean {
88
+ const ctx = RequestContext.current;
89
+ if (!ctx) return false;
90
+ return detectDevice(ctx.request.headers.get("user-agent") ?? "") === "mobile";
91
+ }
92
+
93
+ /**
94
+ * Check if the current request is from a tablet device.
95
+ */
96
+ export function checkTablet(): boolean {
97
+ const ctx = RequestContext.current;
98
+ if (!ctx) return false;
99
+ return detectDevice(ctx.request.headers.get("user-agent") ?? "") === "tablet";
100
+ }
101
+
102
+ /**
103
+ * Check if the current request is from a desktop device.
104
+ */
105
+ export function checkDesktop(): boolean {
106
+ const ctx = RequestContext.current;
107
+ if (!ctx) return true;
108
+ return detectDevice(ctx.request.headers.get("user-agent") ?? "") === "desktop";
109
+ }
@@ -0,0 +1,108 @@
1
+ "use client";
2
+
3
+ /**
4
+ * The hook/context half of device detection — split out of `useDevice.ts`
5
+ * because it uses `createContext`/`useContext`, which Next.js's App Router
6
+ * (unlike the Vite-based `@decocms/tanstack` binding, and unlike this
7
+ * package's own Vitest/jsdom unit tests) statically rejects unless the
8
+ * *file* that calls them carries a `"use client"` directive — Next's RSC
9
+ * webpack loader errors ("This React Hook only works in a Client
10
+ * Component") on ANY file reachable from a Server Component's module graph
11
+ * that calls a client-only hook, even if the actual call site is never
12
+ * invoked along that particular path.
13
+ *
14
+ * That's exactly what happened here: `packages/blocks/src/cms/sectionMixins.ts`
15
+ * imports only the plain, hook-free `detectDevice()` from `useDevice.ts` for
16
+ * its `withDevice()`/`withMobile()` section-loader mixins — a 100%
17
+ * server-side call path (loaders receive a `Request`, never render). But
18
+ * because `sectionMixins.ts` is pulled in by `cms/index.ts`, which
19
+ * `createSiteSetup()` (runtime/setup.ts) imports, and a site's `setup.ts` is
20
+ * typically imported for its side effects from `app/layout.tsx` (a Server
21
+ * Component under `@decocms/nextjs`), the *whole* `useDevice.ts` file used to
22
+ * ride along into the server compilation graph — including the
23
+ * `createContext`/`useContext` calls now isolated in this file, which had no
24
+ * "use client" directive of their own. `@decocms/nextjs`'s next-smoke fixture
25
+ * (this plan's Task 9) is the first consumer to hit Next's real RSC
26
+ * compiler, so this split was never needed before.
27
+ *
28
+ * `detectDevice`, `isMobileUA`, `checkMobile/Tablet/Desktop`, and the
29
+ * `Device` type stay in `useDevice.ts` (no directive, safe to import from
30
+ * server-only code); `useDevice.ts` re-exports `DeviceContext`/`useDevice`/
31
+ * `DeviceProvider` from here so the public `@decocms/blocks/sdk/useDevice`
32
+ * entry point (and every existing consumer, e.g.
33
+ * `packages/tanstack/src/hooks/DecoPageRenderer.tsx`) is unchanged.
34
+ */
35
+ import { createContext, createElement, type ReactNode, useContext } from "react";
36
+ import { type Device, resolveDeviceFromRuntime } from "./useDevice";
37
+
38
+ /**
39
+ * React context for the resolved device. Populated by `<DeviceProvider>` at
40
+ * the top of the framework tree (DecoPageRenderer mounts it for sites that
41
+ * use the standard wiring). Once set, `useDevice()` reads from here in
42
+ * preference to `AsyncLocalStorage`, which is known to be unreliable across
43
+ * streaming SSR Suspense boundaries on Cloudflare Workers.
44
+ */
45
+ export const DeviceContext = createContext<Device | null>(null);
46
+
47
+ /**
48
+ * Get the current device type. Works everywhere:
49
+ * - Server (loader, middleware, server function): reads User-Agent from RequestContext.
50
+ * - Client (component, event handler): uses `window.innerWidth` breakpoints.
51
+ *
52
+ * @example
53
+ * ```tsx
54
+ * import { useDevice } from "@decocms/start/sdk/useDevice";
55
+ *
56
+ * // In a component:
57
+ * const device = useDevice(); // "mobile" | "tablet" | "desktop"
58
+ * ```
59
+ */
60
+ export function useDevice(): Device {
61
+ // Prefer the value resolved by <DeviceProvider> at the framework root —
62
+ // safe across streaming-SSR Suspense boundaries where AsyncLocalStorage
63
+ // can lose the request context. The try/catch keeps backward compat with
64
+ // callers outside a React render (loaders, server functions, tests
65
+ // without the framework root), where useContext throws "Invalid hook
66
+ // call" — those callers fall through to the original runtime resolution.
67
+ //
68
+ // Rules-of-hooks note: `useContext` is called *unconditionally* inside the
69
+ // try block — exactly once per `useDevice()` invocation. The catch only
70
+ // fires when there is no React dispatcher at all (outside a render),
71
+ // never *between* hooks in the same render. Hook order within a component
72
+ // therefore remains consistent. Callers that conditionally call
73
+ // `useDevice()` itself were already violating rules of hooks; this PR
74
+ // doesn't change that.
75
+ try {
76
+ // eslint-disable-next-line react-hooks/rules-of-hooks
77
+ const fromContext = useContext(DeviceContext);
78
+ if (fromContext) return fromContext;
79
+ } catch {
80
+ // Not in a React render — fall through.
81
+ }
82
+ return resolveDeviceFromRuntime();
83
+ }
84
+
85
+ /**
86
+ * Wraps children in a `DeviceContext` populated by resolving the device once
87
+ * here, at a point in the React tree where `AsyncLocalStorage` is reliable.
88
+ * Any descendant calling `useDevice()` reads from this context instead of
89
+ * re-resolving through ALS — preventing the "wrong device value cached at
90
+ * the edge" failure mode that produces React #418 hydration mismatches.
91
+ *
92
+ * Mount this near the top of the React tree. `DecoPageRenderer` already
93
+ * mounts it automatically; sites with custom roots can mount it explicitly:
94
+ *
95
+ * @example
96
+ * ```tsx
97
+ * <DeviceProvider>
98
+ * <App />
99
+ * </DeviceProvider>
100
+ * ```
101
+ *
102
+ * Pass an explicit `value` to override detection (useful for tests or
103
+ * admin preview where the runtime UA isn't meaningful).
104
+ */
105
+ export function DeviceProvider(props: { children: ReactNode; value?: Device }): ReactNode {
106
+ const device = props.value ?? resolveDeviceFromRuntime();
107
+ return createElement(DeviceContext.Provider, { value: device }, props.children);
108
+ }
@@ -0,0 +1,7 @@
1
+ import { useId as useReactId } from "react";
2
+
3
+ /** Wraps React's useId, stripping colons for safe DOM usage */
4
+ export const useId = () => {
5
+ const id = useReactId();
6
+ return id.replace(/:/g, "");
7
+ };
@@ -0,0 +1,128 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import {
3
+ HTMX_LEGACY_URL,
4
+ inlineScript,
5
+ usePartialSection,
6
+ useScript,
7
+ useSection,
8
+ } from "./useScript";
9
+
10
+ describe("inlineScript", () => {
11
+ it("returns dangerouslySetInnerHTML with the provided string", () => {
12
+ const result = inlineScript('alert("hello")');
13
+ expect(result).toEqual({
14
+ dangerouslySetInnerHTML: { __html: 'alert("hello")' },
15
+ });
16
+ });
17
+
18
+ it("handles empty string", () => {
19
+ const result = inlineScript("");
20
+ expect(result).toEqual({
21
+ dangerouslySetInnerHTML: { __html: "" },
22
+ });
23
+ });
24
+
25
+ it("handles multiline scripts", () => {
26
+ const js = `
27
+ const el = document.getElementById("btn");
28
+ el.addEventListener("click", () => { console.log("clicked"); });
29
+ `;
30
+ const result = inlineScript(js);
31
+ expect(result.dangerouslySetInnerHTML.__html).toBe(js);
32
+ });
33
+ });
34
+
35
+ describe("useScript", () => {
36
+ it("serializes a function into an IIFE string", () => {
37
+ function greet(name: string) {
38
+ console.log(name);
39
+ }
40
+ const result = useScript(greet, "world");
41
+ expect(result).toContain("(");
42
+ expect(result).toContain('"world"');
43
+ expect(result).toContain("console.log");
44
+ });
45
+
46
+ it("serializes multiple arguments", () => {
47
+ function add(a: number, b: number) {
48
+ return a + b;
49
+ }
50
+ const result = useScript(add, 1, 2);
51
+ expect(result).toContain("1,2");
52
+ });
53
+
54
+ it("handles functions with no arguments", () => {
55
+ function noop() {}
56
+ const result = useScript(noop);
57
+ expect(result).toMatch(/^\(.*\)\(\)$/);
58
+ });
59
+ });
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Legacy HTMX stubs — useSection / usePartialSection
63
+ // ---------------------------------------------------------------------------
64
+
65
+ describe("useSection / usePartialSection (legacy HTMX stubs)", () => {
66
+ let warnSpy: ReturnType<typeof vi.spyOn>;
67
+ let prevNodeEnv: string | undefined;
68
+
69
+ beforeEach(() => {
70
+ // Reset the dedup set so each test sees a fresh warning fire.
71
+ delete (globalThis as any).__DECO_LEGACY_HTMX_WARNED;
72
+ prevNodeEnv = process.env.NODE_ENV;
73
+ process.env.NODE_ENV = "development";
74
+ warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
75
+ });
76
+
77
+ afterEach(() => {
78
+ warnSpy.mockRestore();
79
+ if (prevNodeEnv === undefined) delete process.env.NODE_ENV;
80
+ else process.env.NODE_ENV = prevNodeEnv;
81
+ });
82
+
83
+ it("useSection returns a stable placeholder URL instead of throwing", () => {
84
+ expect(useSection()).toBe(HTMX_LEGACY_URL);
85
+ expect(useSection({ props: { x: 1 } })).toBe(HTMX_LEGACY_URL);
86
+ });
87
+
88
+ it("usePartialSection returns the same placeholder URL", () => {
89
+ expect(usePartialSection()).toBe(HTMX_LEGACY_URL);
90
+ });
91
+
92
+ it("warns on first call (in development)", () => {
93
+ useSection();
94
+ expect(warnSpy).toHaveBeenCalledTimes(1);
95
+ expect(warnSpy.mock.calls[0][0]).toMatch(/useSection/);
96
+ expect(warnSpy.mock.calls[0][0]).toMatch(/were removed/);
97
+ expect(warnSpy.mock.calls[0][0]).toMatch(/htmx-residue/);
98
+ });
99
+
100
+ it("dedups warnings: a second call to the same stub is silent", () => {
101
+ useSection();
102
+ useSection();
103
+ useSection();
104
+ expect(warnSpy).toHaveBeenCalledTimes(1);
105
+ });
106
+
107
+ it("tracks warnings per-stub: useSection and usePartialSection warn independently", () => {
108
+ useSection();
109
+ usePartialSection();
110
+ expect(warnSpy).toHaveBeenCalledTimes(2);
111
+ const messages = warnSpy.mock.calls.map((c) => c[0] as string);
112
+ expect(messages.some((m) => m.includes("useSection"))).toBe(true);
113
+ expect(messages.some((m) => m.includes("usePartialSection"))).toBe(true);
114
+ });
115
+
116
+ it("does NOT warn in production", () => {
117
+ process.env.NODE_ENV = "production";
118
+ useSection();
119
+ usePartialSection();
120
+ expect(warnSpy).not.toHaveBeenCalled();
121
+ });
122
+
123
+ it("returns the placeholder URL even in production (still safe to embed)", () => {
124
+ process.env.NODE_ENV = "production";
125
+ expect(useSection()).toBe(HTMX_LEGACY_URL);
126
+ expect(usePartialSection()).toBe(HTMX_LEGACY_URL);
127
+ });
128
+ });
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Serializes functions into inline script strings for SSR output.
3
+ *
4
+ * Includes lightweight minification (whitespace + comment removal)
5
+ * with an LRU cache to avoid re-processing the same function bodies.
6
+ */
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // LRU cache for minified function bodies
10
+ // ---------------------------------------------------------------------------
11
+
12
+ const MAX_CACHE_SIZE = 200;
13
+ const minifyCache = new Map<string, string>();
14
+
15
+ function cacheGet(key: string): string | undefined {
16
+ const value = minifyCache.get(key);
17
+ if (value !== undefined) {
18
+ // Move to end (most recently used)
19
+ minifyCache.delete(key);
20
+ minifyCache.set(key, value);
21
+ }
22
+ return value;
23
+ }
24
+
25
+ function cacheSet(key: string, value: string) {
26
+ if (minifyCache.size >= MAX_CACHE_SIZE) {
27
+ // Delete oldest entry (first key in insertion order)
28
+ const oldest = minifyCache.keys().next().value;
29
+ if (oldest !== undefined) minifyCache.delete(oldest);
30
+ }
31
+ minifyCache.set(key, value);
32
+ }
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Lightweight minification (no external deps)
36
+ // ---------------------------------------------------------------------------
37
+
38
+ function minifyJs(code: string): string {
39
+ return (
40
+ code
41
+ // Remove block comments only — line-comment stripping and operator-spacing
42
+ // collapse are unsafe: they can corrupt string literals containing "//"
43
+ // or operators, and regex literals whose delimiters look like operators.
44
+ .replace(/\/\*[\s\S]*?\*\//g, "")
45
+ // Collapse runs of whitespace (spaces, tabs, newlines) to a single space.
46
+ // This is safe because whitespace inside string/template literals is
47
+ // preserved as a single space, which is semantically identical.
48
+ .replace(/\s+/g, " ")
49
+ .trim()
50
+ );
51
+ }
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Public API
55
+ // ---------------------------------------------------------------------------
56
+
57
+ /**
58
+ * Serializes a function and its arguments into a self-executing inline script.
59
+ *
60
+ * @deprecated `fn.toString()` produces different output in SSR vs client Vite
61
+ * builds (React Compiler transforms differ), causing hydration mismatches on
62
+ * `dangerouslySetInnerHTML.__html`. Use {@link inlineScript} with a plain
63
+ * string constant instead.
64
+ *
65
+ * @example
66
+ * ```tsx
67
+ * // BEFORE (causes hydration mismatch):
68
+ * <script dangerouslySetInnerHTML={{ __html: useScript(onLoad, elementId) }} />
69
+ *
70
+ * // AFTER (safe):
71
+ * <script {...inlineScript(`(${MY_SCRIPT_STRING})("${elementId}")`)} />
72
+ * ```
73
+ */
74
+ export function useScript<T extends (...args: any[]) => void>(
75
+ fn: T,
76
+ ...args: Parameters<T>
77
+ ): string {
78
+ if (typeof (globalThis as any).__DECO_USE_SCRIPT_WARNED === "undefined") {
79
+ (globalThis as any).__DECO_USE_SCRIPT_WARNED = new Set<string>();
80
+ }
81
+ const warnedSet = (globalThis as any).__DECO_USE_SCRIPT_WARNED as Set<string>;
82
+ const fnName = fn.name || "anonymous";
83
+ if (
84
+ typeof process !== "undefined" &&
85
+ process.env?.NODE_ENV !== "production" &&
86
+ !warnedSet.has(fnName)
87
+ ) {
88
+ warnedSet.add(fnName);
89
+ console.warn(
90
+ `[useScript] Using fn.toString() for "${fnName}". ` +
91
+ `This may produce different output in SSR vs client builds, causing hydration mismatch. ` +
92
+ `Consider using inlineScript() with a plain string constant instead.`,
93
+ );
94
+ }
95
+
96
+ const fnStr = fn.toString();
97
+ let minified = cacheGet(fnStr);
98
+ if (minified === undefined) {
99
+ minified = minifyJs(fnStr);
100
+ cacheSet(fnStr, minified);
101
+ }
102
+
103
+ const serializedArgs = args.map((a) => JSON.stringify(a)).join(",");
104
+ return `(${minified})(${serializedArgs})`;
105
+ }
106
+
107
+ /**
108
+ * Like useScript, but returns a data: URI suitable for `<script src="...">`.
109
+ *
110
+ * @deprecated Same hydration issues as {@link useScript}. Use {@link inlineScript} instead.
111
+ */
112
+ export function useScriptAsDataURI<T extends (...args: any[]) => void>(
113
+ fn: T,
114
+ ...args: Parameters<T>
115
+ ): string {
116
+ const code = useScript(fn, ...args);
117
+ return `data:text/javascript;charset=utf-8,${encodeURIComponent(code)}`;
118
+ }
119
+
120
+ /**
121
+ * Returns props for a `<script>` element with safe inline JavaScript.
122
+ * Unlike {@link useScript}, this accepts a plain string — no `fn.toString()`
123
+ * means no SSR/client divergence and no hydration mismatch.
124
+ *
125
+ * @example
126
+ * ```tsx
127
+ * const SCROLL_SCRIPT = `document.getElementById("btn").addEventListener("click", () => { ... })`;
128
+ * <script {...inlineScript(SCROLL_SCRIPT)} />
129
+ *
130
+ * // With arguments:
131
+ * const INIT_SCRIPT = (id: string) => `document.getElementById("${id}").dataset.ready = "true"`;
132
+ * <script {...inlineScript(INIT_SCRIPT("my-element"))} />
133
+ * ```
134
+ */
135
+ export function inlineScript(js: string) {
136
+ return { dangerouslySetInnerHTML: { __html: js } } as const;
137
+ }
138
+
139
+ /**
140
+ * @deprecated Removed in TanStack Start.
141
+ *
142
+ * The Fresh/Deno HTMX-based partial-section pattern (`useSection` /
143
+ * `usePartialSection` + `sections/Component.tsx`) does not apply on
144
+ * Cloudflare Workers and React. Replace call-sites with one of:
145
+ *
146
+ * 1. Local React state (`useState` + event handlers) for client-side toggles.
147
+ * 2. `createServerFn` + `useMutation` for server actions.
148
+ * 3. Direct `invoke` calls (`~/server/invoke`) for ad-hoc loaders.
149
+ *
150
+ * See: deco-to-tanstack-migration skill, "useComponent / partial sections"
151
+ * section, for the per-pattern recipes.
152
+ *
153
+ * ## SSR-safe stub behavior (since 2.27)
154
+ *
155
+ * Earlier versions threw on call. That broke SSR for the *entire page* if a
156
+ * single section still imported `useSection` — even sections that the user
157
+ * never interacts with (e.g. legacy login form on the homepage). React's
158
+ * error boundary would catch the throw and degrade the whole route to
159
+ * client rendering.
160
+ *
161
+ * The current behavior:
162
+ * - returns a stable placeholder URL (`HTMX_LEGACY_URL`) so it can be
163
+ * embedded in `hx-get` / `hx-post` attributes without crashing the
164
+ * SSR pass,
165
+ * - logs a deduped `console.warn` (in dev only) per call site so the
166
+ * migration signal stays loud,
167
+ * - the `htmx-residue` audit rule still catalogues every call site for
168
+ * systematic rewrite.
169
+ *
170
+ * This is a deliberate trade-off: SSR success > strict-throw enforcement.
171
+ * Audit + skill docs do the enforcement instead.
172
+ */
173
+ const DEPRECATION_MESSAGE =
174
+ "[@decocms/start] useSection / usePartialSection were removed. " +
175
+ "The Fresh/Deno HTMX partial-section pattern does not apply on " +
176
+ "TanStack Start / Cloudflare Workers. Replace call-sites with " +
177
+ "createServerFn + useMutation, or local React state. See the " +
178
+ "deco-to-tanstack-migration skill for per-pattern recipes. " +
179
+ "Run `deco-post-cleanup` and look for rule [9] htmx-residue to find " +
180
+ "every site call-site that still depends on this.";
181
+
182
+ /**
183
+ * Stable placeholder URL returned by the legacy `useSection` /
184
+ * `usePartialSection` stubs. Hitting this URL surfaces a clear error.
185
+ * Exported so frameworks/tests can match against it.
186
+ */
187
+ export const HTMX_LEGACY_URL = "/__deco_legacy_htmx_section__";
188
+
189
+ function warnLegacyHtmx(name: string) {
190
+ if (typeof process !== "undefined" && process.env?.NODE_ENV === "production") {
191
+ return;
192
+ }
193
+ if (typeof (globalThis as any).__DECO_LEGACY_HTMX_WARNED === "undefined") {
194
+ (globalThis as any).__DECO_LEGACY_HTMX_WARNED = new Set<string>();
195
+ }
196
+ const set = (globalThis as any).__DECO_LEGACY_HTMX_WARNED as Set<string>;
197
+ if (set.has(name)) return;
198
+ set.add(name);
199
+ console.warn(`[${name}] ${DEPRECATION_MESSAGE}`);
200
+ }
201
+
202
+ export function usePartialSection(_props?: Record<string, unknown>): string {
203
+ warnLegacyHtmx("usePartialSection");
204
+ return HTMX_LEGACY_URL;
205
+ }
206
+
207
+ export function useSection(_props?: Record<string, unknown>): string {
208
+ warnLegacyHtmx("useSection");
209
+ return HTMX_LEGACY_URL;
210
+ }