@decocms/blocks 7.54.0 → 7.55.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/blocks",
3
- "version": "7.54.0",
3
+ "version": "7.55.0",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -0,0 +1,112 @@
1
+ // The gate and the tag. There is no client behaviour to test — the collector's bundle owns
2
+ // pageviews, SPA navigation and DECO events, and it is tested where it lives. What can break
3
+ // here is what this component actually decides: whether to render at all, where it points, and
4
+ // which attributes it emits.
5
+ //
6
+ // `renderToStaticMarkup` rather than a DOM render: the component has no effects and no state, so
7
+ // mounting it would test React rather than this file.
8
+ import { afterEach, describe, expect, it, vi } from "vitest";
9
+
10
+ const ENV = { ...process.env };
11
+
12
+ afterEach(() => {
13
+ process.env = { ...ENV };
14
+ vi.resetModules();
15
+ });
16
+
17
+ /** Re-imported per test, because the gate is read at MODULE LOAD. A test that sets the variable
18
+ * after importing would be asserting against the value the previous test left behind — and it
19
+ * would pass or fail depending on file order, which is the worst kind of green. */
20
+ async function render(props: Record<string, unknown> = {}) {
21
+ const { renderToStaticMarkup } = await import("react-dom/server");
22
+ const { default: Stats } = await import("./Stats");
23
+ const { createElement } = await import("react");
24
+ return renderToStaticMarkup(createElement(Stats, props));
25
+ }
26
+
27
+ describe("Stats", () => {
28
+ it("is mounted by the framework, so rendering it unconditionally must be inert", async () => {
29
+ // DecoRootLayout renders <Stats /> for every site, enabled or not. That is only safe
30
+ // because the gate returns null rather than, say, rendering a script pointing at nothing
31
+ // -- a site that never opted in must emit no tag, no preconnect and no request.
32
+ delete process.env.DECO_ANALYTICS_ENABLED;
33
+ expect(await render()).toBe("");
34
+ });
35
+
36
+ it("renders nothing unless explicitly enabled", async () => {
37
+ delete process.env.DECO_ANALYTICS_ENABLED;
38
+ expect(await render()).toBe("");
39
+
40
+ // Not "any truthy value". `ONEDOLLAR_ENABLED` defaults to ON and is disabled with
41
+ // "false"; this one defaults to OFF and needs "true". A loose check here would make
42
+ // `DECO_ANALYTICS_ENABLED=0` turn analytics on, which is the opposite of what anyone
43
+ // setting it to 0 intends.
44
+ process.env.DECO_ANALYTICS_ENABLED = "1";
45
+ vi.resetModules();
46
+ expect(await render()).toBe("");
47
+ });
48
+
49
+ it("points at the same origin by default, and preconnects only when it does not", async () => {
50
+ process.env.DECO_ANALYTICS_ENABLED = "true";
51
+ const same = await render();
52
+ expect(same).toContain('src="/_dq/a.js"');
53
+ // A preconnect to the page's own origin is a wasted hint, and on some browsers a
54
+ // second connection opened for nothing.
55
+ expect(same).not.toContain("preconnect");
56
+
57
+ vi.resetModules();
58
+ process.env.DECO_ANALYTICS_ORIGIN = "https://analytics.example.com";
59
+ const cross = await render();
60
+ expect(cross).toContain('src="https://analytics.example.com/_dq/a.js"');
61
+ expect(cross).toContain("preconnect");
62
+ });
63
+
64
+ it("carries dev and debug as attributes, and omits them when off", async () => {
65
+ process.env.DECO_ANALYTICS_ENABLED = "true";
66
+ // The whole reason these are attributes: TanStack hoists `<script async>` into `<head>`
67
+ // above any inline config block, so a global set alongside the tag loses the race and the
68
+ // collector boots into silence with no error.
69
+ const on = await render({ dev: true, debug: true });
70
+ expect(on).toContain('data-dev="true"');
71
+ expect(on).toContain('data-debug="true"');
72
+
73
+ vi.resetModules();
74
+ const off = await render();
75
+ // Absent, not `="false"`. The collector treats them the same; a reader of the page source
76
+ // does not, and `data-dev="false"` looks like someone decided something.
77
+ expect(off).not.toContain("data-dev");
78
+ expect(off).not.toContain("data-debug");
79
+ });
80
+
81
+ it("puts the site key in the URL, because that is where the collector reads it", async () => {
82
+ process.env.DECO_ANALYTICS_ENABLED = "true";
83
+ // Sites behind our edge are identified by the Host header, which a visitor cannot forge.
84
+ // Emitting an empty key would put a `tag`-sourced identity on a site that has a
85
+ // trustworthy one, and `tag` is the source that must never reach an invoice.
86
+ const none = await render();
87
+ expect(none).toContain('src="/_dq/a.js"');
88
+ expect(none).not.toContain("?k=");
89
+
90
+ vi.resetModules();
91
+ process.env.DECO_ANALYTICS_SITE_KEY = "dq_abc123";
92
+ const keyed = await render();
93
+ // IN THE QUERY STRING. The collector resolves the site while rendering the bundle, from
94
+ // `?k=` -- a key on the element is read by nothing and arrives after the decision. As
95
+ // `data-site` this rendered fine, resolved nothing, served the `s:"unknown"` fallback and
96
+ // collected zero without an error anywhere.
97
+ expect(keyed).toContain("/_dq/a.js?k=dq_abc123");
98
+ expect(keyed).not.toContain("data-site");
99
+ });
100
+
101
+ it("uses defer only when asked, async otherwise", async () => {
102
+ process.env.DECO_ANALYTICS_ENABLED = "true";
103
+ // Nothing visual may depend on this script. `async` is what keeps a slow or failed
104
+ // collector from becoming a slow or broken page.
105
+ expect(await render()).toContain("async");
106
+
107
+ vi.resetModules();
108
+ const deferred = await render({ defer: true });
109
+ expect(deferred).toContain("defer");
110
+ expect(deferred).not.toContain("async");
111
+ });
112
+ });
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Stats — deco's first-party analytics collector.
3
+ *
4
+ * MOUNTED BY THE FRAMEWORK. `DecoRootLayout` renders it, so a site turns analytics on with an
5
+ * environment variable and no code change — the same ergonomics the Fresh side already has,
6
+ * where `website/pages/Page.tsx` renders it for every page.
7
+ *
8
+ * It lives in `@decocms/blocks` rather than in `@decocms/apps-website` because of what it is:
9
+ * twenty lines of a `<link>` and a `<script>` driven by environment variables, pointing at our
10
+ * own collector. `OneDollarStats` belongs among the apps because it is a VENDOR integration —
11
+ * it wraps `history`, decodes a cookie and feeds a third party\'s SDK. This is framework
12
+ * infrastructure, and putting it here is also what lets `@decocms/tanstack` mount it without
13
+ * importing from an apps package, which would be a new edge in a dependency graph the repo
14
+ * keeps one-way on purpose.
15
+ *
16
+ * `@decocms/apps-website/components/Stats` re-exports it, so anything importing the old path
17
+ * keeps working.
18
+ *
19
+ * ## Why this is twenty lines and OneDollarStats is three hundred
20
+ *
21
+ * Not because it does less — because the work is on the other side. The lilstts SDK
22
+ * has no notion of SPA navigation the way this app routes, no notion of the
23
+ * `deco_segment` cookie, and no notion of `window.DECO.events`, so the component has
24
+ * to wrap `history.pushState`, poll for globals, read and decode the cookie, and
25
+ * forward every commerce event by hand.
26
+ *
27
+ * The deco collector's own bundle already does all of it, and is tested doing it:
28
+ * the core module takes the first pageview through the prerender guard, wraps
29
+ * `pushState`, `replaceState` and `popstate`, and flushes on `pagehide` and
30
+ * `visibilitychange`; the deco module reads `deco_segment` into experiment
31
+ * assignments and subscribes to `window.DECO.events`, mapping the commerce
32
+ * vocabulary. None of that belongs in a component that would then be a second
33
+ * implementation of it, drifting from the first.
34
+ *
35
+ * So there is no `useEffect` here, and that is the point. Nothing to hydrate, no
36
+ * readiness polling, no module-level guard against StrictMode double-mounting —
37
+ * because there is no client state to guard.
38
+ *
39
+ * ## data- attributes, not a global
40
+ *
41
+ * `dev` and `debug` are read off the tag rather than from `window.__dq`, and this is
42
+ * load-bearing on exactly this framework. TanStack hoists `<script async>` into
43
+ * `<head>` ABOVE any inline configuration block — measured at byte 190 against byte
44
+ * 1108 on a real site. A component that set a global and expected the collector to
45
+ * find it would boot into silence here, with no error: the collector would see a
46
+ * development host, skip, and say nothing. Attributes cannot lose that race because
47
+ * they are on the element that is executing.
48
+ *
49
+ * ## Off by default
50
+ *
51
+ * `DECO_ANALYTICS_ENABLED` must be set to `true`. This is the inverse of
52
+ * `ONEDOLLAR_ENABLED`, which defaults to on, and the asymmetry is deliberate: one is
53
+ * the incumbent and the other is being introduced. The two gates are also
54
+ * independent, so a site can run both during a shadow comparison and neither gate
55
+ * can turn the other off.
56
+ */
57
+
58
+ export interface Props {
59
+ /**
60
+ * Where the collector is published. Empty means same-origin, which is the
61
+ * intended deployment: the script and the beacon are served from the site's own
62
+ * hostname so no third-party request is involved and nothing is blocked.
63
+ */
64
+ origin?: string;
65
+ /**
66
+ * The site's public key, for sites NOT served through our CDN.
67
+ *
68
+ * Sites behind our edge are identified by the `Host` header, which a visitor
69
+ * cannot forge; those must leave this unset. A key travels in the page source
70
+ * where anyone can read and reuse it, so a key-identified site is recorded with
71
+ * `site_id_source = tag` and is never billed from.
72
+ */
73
+ siteKey?: string;
74
+ /** `defer` instead of `async`. Only for a page that needs strict ordering. */
75
+ defer?: boolean;
76
+ /**
77
+ * Collect from localhost. The collector refuses local and private hostnames by
78
+ * default, which is why a developer sees nothing until this is on.
79
+ */
80
+ dev?: boolean;
81
+ /** Log every queued and flushed batch to the console. */
82
+ debug?: boolean;
83
+ }
84
+
85
+ /** Same-origin. See {@link Props.origin}. */
86
+ export const DEFAULT_ORIGIN = "";
87
+
88
+ /**
89
+ * Opt-in, and independent of `ONEDOLLAR_ENABLED` so both can run at once.
90
+ */
91
+ const DECO_ANALYTICS_ENABLED = process.env.DECO_ANALYTICS_ENABLED === "true";
92
+ const DECO_ANALYTICS_ORIGIN = process.env.DECO_ANALYTICS_ORIGIN;
93
+ const DECO_ANALYTICS_SITE_KEY = process.env.DECO_ANALYTICS_SITE_KEY;
94
+
95
+ function Stats({ origin, siteKey, defer, dev, debug }: Props) {
96
+ if (!DECO_ANALYTICS_ENABLED) return null;
97
+
98
+ const base = origin ?? DECO_ANALYTICS_ORIGIN ?? DEFAULT_ORIGIN;
99
+ const key = siteKey ?? DECO_ANALYTICS_SITE_KEY;
100
+
101
+ return (
102
+ <>
103
+ {/*
104
+ * Only when the collector is on another origin. A `preconnect` to the page's
105
+ * own origin is a wasted hint at best, and on some browsers it is a second
106
+ * connection opened for nothing.
107
+ */}
108
+ {base ? <link rel="preconnect" href={base} crossOrigin="anonymous" /> : null}
109
+ <script
110
+ id="deco-analytics"
111
+ async={!defer}
112
+ defer={defer}
113
+ // THE KEY GOES IN THE URL, not in a `data-` attribute. The collector resolves the
114
+ // site server-side while RENDERING the bundle -- it reads `?k=` and writes the
115
+ // resolved config into the script it returns -- so a key on the element arrives
116
+ // far too late to matter. It is also never read: the bundle only looks at
117
+ // `data-dev` and `data-debug`.
118
+ //
119
+ // This was `data-site` and it would have failed the way this project's failures
120
+ // always do: the collector resolves nothing, serves the `s:"unknown"` fallback,
121
+ // and the site collects exactly zero with no error anywhere. Same shape as the
122
+ // bug that once made the entire self-serve tier silent.
123
+ src={`${base}/_dq/a.js${key ? `?k=${encodeURIComponent(key)}` : ""}`}
124
+ // Rendered only when true. `data-dev="false"` and an absent attribute mean
125
+ // the same thing to the collector, and the absent one cannot be mistaken
126
+ // for a deliberate setting by someone reading the page source.
127
+ data-dev={dev ? "true" : undefined}
128
+ data-debug={debug ? "true" : undefined}
129
+ />
130
+ </>
131
+ );
132
+ }
133
+
134
+ export default Stats;
@@ -1,5 +1,6 @@
1
1
  export { isBelowFold, LazySection, type LazySectionProps } from "./LazySection";
2
2
  export { LiveControls } from "./LiveControls";
3
+ export { default as Stats, DEFAULT_ORIGIN as STATS_DEFAULT_ORIGIN, type Props as StatsProps } from "./Stats";
3
4
  export { SectionErrorBoundary } from "./SectionErrorFallback";
4
5
  export { default as RenderSection } from "./RenderSection";
5
6