@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,52 @@
1
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import { loadDecofileDirectory } from "./loadDecofileDirectory";
6
+
7
+ describe("loadDecofileDirectory", () => {
8
+ let dir: string;
9
+
10
+ beforeEach(() => {
11
+ dir = mkdtempSync(join(tmpdir(), "decofile-test-"));
12
+ });
13
+
14
+ afterEach(() => {
15
+ rmSync(dir, { recursive: true, force: true });
16
+ });
17
+
18
+ it("loads every .json file in the directory into a keyed map", async () => {
19
+ writeFileSync(
20
+ join(dir, "pages-home-123.json"),
21
+ JSON.stringify({ __resolveType: "website/pages/Page.tsx", path: "/" }),
22
+ );
23
+ writeFileSync(
24
+ join(dir, "Banner Grid - 01.json"),
25
+ JSON.stringify({ __resolveType: "site/sections/BannerGrid.tsx" }),
26
+ );
27
+
28
+ const blocks = await loadDecofileDirectory(dir);
29
+
30
+ expect(Object.keys(blocks)).toHaveLength(2);
31
+ expect(blocks["pages-home-123"]).toEqual({
32
+ __resolveType: "website/pages/Page.tsx",
33
+ path: "/",
34
+ });
35
+ expect(blocks["Banner Grid - 01"]).toEqual({
36
+ __resolveType: "site/sections/BannerGrid.tsx",
37
+ });
38
+ });
39
+
40
+ it("ignores non-.json files", async () => {
41
+ writeFileSync(join(dir, "readme.md"), "not a block");
42
+ writeFileSync(join(dir, "a.json"), JSON.stringify({ x: 1 }));
43
+
44
+ const blocks = await loadDecofileDirectory(dir);
45
+ expect(Object.keys(blocks)).toEqual(["a"]);
46
+ });
47
+
48
+ it("throws with the filename on invalid JSON", async () => {
49
+ writeFileSync(join(dir, "broken.json"), "{not valid json");
50
+ await expect(loadDecofileDirectory(dir)).rejects.toThrow(/broken\.json/);
51
+ });
52
+ });
@@ -0,0 +1,48 @@
1
+ import * as fs from "node:fs/promises";
2
+ import { join } from "node:path";
3
+
4
+ /**
5
+ * Loads a directory of legacy per-block JSON snapshot files (the format
6
+ * produced by the pre-v2 Deco admin, e.g. `.deco/blocks/<name>.json`) into
7
+ * a single blocks map suitable for setBlocks(). Each file becomes one
8
+ * entry keyed by its filename minus the .json extension.
9
+ *
10
+ * The key is not renamed or normalized beyond stripping the extension:
11
+ * whatever prefix convention a block's filename already carries (e.g. real
12
+ * page block snapshots are named `pages-<slug>-<id>.json`) is preserved
13
+ * verbatim, because getAllPages() in loader.ts filters blocks by
14
+ * `key.startsWith("pages-")` before findPageByPath() matches on each page's
15
+ * own `.path` field. So the key format IS load-bearing for page blocks —
16
+ * it must keep whatever prefix its source filename had — but this loader
17
+ * never needs to invent or choose that prefix itself, since it simply
18
+ * passes the on-disk filename through.
19
+ *
20
+ * Replaces the abandoned @decocms/start/node tier's loadAllDecofileBlocks,
21
+ * which no longer exists on any reachable deco-start version — ported
22
+ * fresh rather than resurrected. Namespace-imported node:fs/promises to
23
+ * match this codebase's existing pattern for Node-only code that must not
24
+ * break Vite/webpack client bundle analysis (see cms/loader.ts's
25
+ * AsyncLocalStorage import for the established precedent).
26
+ */
27
+ export async function loadDecofileDirectory(dir: string): Promise<Record<string, unknown>> {
28
+ const entries = await fs.readdir(dir, { withFileTypes: true });
29
+ const blocks: Record<string, unknown> = {};
30
+
31
+ await Promise.all(
32
+ entries
33
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
34
+ .map(async (entry) => {
35
+ const key = entry.name.slice(0, -".json".length);
36
+ const content = await fs.readFile(join(dir, entry.name), "utf8");
37
+ try {
38
+ blocks[key] = JSON.parse(content);
39
+ } catch (error) {
40
+ throw new Error(
41
+ `loadDecofileDirectory: failed to parse ${entry.name}: ${(error as Error).message}`,
42
+ );
43
+ }
44
+ }),
45
+ );
46
+
47
+ return blocks;
48
+ }
@@ -0,0 +1,184 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
2
+ import { findPageByPath, matchPath, setBlocks } from "./loader";
3
+
4
+ // Mirrors the behavior of the original deco-cx/deco Fresh framework
5
+ // (runtime/features/render.tsx), which uses native `URLPattern` directly
6
+ // and returns `result.pathname.groups`. Splats become numbered groups
7
+ // ("0", "1", …) — there is no `_splat` rename.
8
+
9
+ describe("matchPath", () => {
10
+ describe("literal segments", () => {
11
+ it("matches the root path", () => {
12
+ expect(matchPath("/", "/")).toEqual({});
13
+ });
14
+
15
+ it("matches exact literal paths", () => {
16
+ expect(matchPath("/foo/bar", "/foo/bar")).toEqual({});
17
+ });
18
+
19
+ it("returns null when literals differ", () => {
20
+ expect(matchPath("/foo/bar", "/foo/baz")).toBeNull();
21
+ });
22
+
23
+ it("returns null when literal-only pattern does not span the whole URL", () => {
24
+ expect(matchPath("/foo", "/foo/bar")).toBeNull();
25
+ });
26
+ });
27
+
28
+ describe("named params (:slug)", () => {
29
+ it("captures a single param", () => {
30
+ expect(matchPath("/foo/:slug", "/foo/sabonete")).toEqual({ slug: "sabonete" });
31
+ });
32
+
33
+ it("captures a param sandwiched between literals (VTEX PDP)", () => {
34
+ expect(matchPath("/produto/:slug/p", "/produto/sabonete/p")).toEqual({
35
+ slug: "sabonete",
36
+ });
37
+ });
38
+
39
+ it("returns null when the URL is shorter than the pattern", () => {
40
+ expect(matchPath("/foo/:slug", "/foo")).toBeNull();
41
+ });
42
+ });
43
+
44
+ describe("trailing splat (*)", () => {
45
+ it("captures the rest as group '0'", () => {
46
+ expect(matchPath("/*", "/foo/bar")).toEqual({ "0": "foo/bar" });
47
+ });
48
+
49
+ it("matches root with empty splat", () => {
50
+ expect(matchPath("/*", "/")).toEqual({ "0": "" });
51
+ });
52
+
53
+ it("captures the remainder under a prefix", () => {
54
+ expect(matchPath("/foo/*", "/foo/bar/baz")).toEqual({ "0": "bar/baz" });
55
+ });
56
+
57
+ // Intentional bug fix: the previous custom matchPath accidentally matched
58
+ // `/foo` against `/foo/*` due to its naive split("/") logic, which also
59
+ // mis-handled trailing slashes. Native URLPattern (and the Fresh original)
60
+ // require at least one segment after `/foo/`.
61
+ it("does NOT match the bare prefix without a trailing segment", () => {
62
+ expect(matchPath("/foo/*", "/foo")).toBeNull();
63
+ });
64
+ });
65
+
66
+ describe("URLPattern optional groups ({...}?)", () => {
67
+ // Patterns emitted by the deco-cx admin / present in production CMS data.
68
+ // These are the cases that issue #213 documents as broken.
69
+
70
+ it("matches with the optional group present", () => {
71
+ expect(matchPath("/{granado/}?*", "/granado/perfumaria")).toEqual({
72
+ "0": "perfumaria",
73
+ });
74
+ });
75
+
76
+ it("matches with the optional group absent", () => {
77
+ expect(matchPath("/{granado/}?*", "/perfumaria")).toEqual({ "0": "perfumaria" });
78
+ });
79
+
80
+ it("matches root when optional prefix and splat collapse to empty", () => {
81
+ expect(matchPath("/{granado/}?*", "/")).toEqual({ "0": "" });
82
+ });
83
+
84
+ it("matches with an optional prefix before a literal segment", () => {
85
+ expect(
86
+ matchPath("/{granado/}?campanhas/*", "/granado/campanhas/destaques-2023"),
87
+ ).toEqual({ "0": "destaques-2023" });
88
+ expect(
89
+ matchPath("/{granado/}?campanhas/*", "/campanhas/destaques-2023"),
90
+ ).toEqual({ "0": "destaques-2023" });
91
+ });
92
+
93
+ it("matches an optional suffix group present and absent", () => {
94
+ expect(matchPath("/black-friday{/70-off}?", "/black-friday")).toEqual({});
95
+ expect(matchPath("/black-friday{/70-off}?", "/black-friday/70-off")).toEqual({});
96
+ });
97
+ });
98
+
99
+ describe("error tolerance", () => {
100
+ it("returns null for malformed patterns instead of throwing", () => {
101
+ expect(() => matchPath("/[invalid", "/anything")).not.toThrow();
102
+ expect(matchPath("/[invalid", "/anything")).toBeNull();
103
+ });
104
+ });
105
+ });
106
+
107
+ describe("findPageByPath specificity", () => {
108
+ beforeEach(() => {
109
+ setBlocks({
110
+ "pages-bf": {
111
+ name: "Black Friday",
112
+ path: "/black-friday",
113
+ sections: [],
114
+ },
115
+ "pages-bf-splat": {
116
+ name: "Black Friday with optional suffix",
117
+ path: "/black-friday{/70-off}?",
118
+ sections: [],
119
+ },
120
+ "pages-pdp-plp": {
121
+ name: "PDP & PLP",
122
+ path: "/{granado/}?*",
123
+ sections: [],
124
+ },
125
+ "pages-product": {
126
+ name: "Product",
127
+ path: "/produto/:slug/p",
128
+ sections: [],
129
+ },
130
+ });
131
+ });
132
+
133
+ afterEach(() => {
134
+ setBlocks({});
135
+ });
136
+
137
+ it("prefers an exact literal over an optional-group splat", () => {
138
+ const match = findPageByPath("/black-friday");
139
+ expect(match?.blockKey).toBe("pages-bf");
140
+ });
141
+
142
+ it("prefers the home page over an optional-group splat catch-all", () => {
143
+ // Regression: /{granado/}?* matches "/" and was out-ranking the home
144
+ // because the `{granado` segment counted as a param. The home block
145
+ // is a literal-only `/` path and must always win.
146
+ setBlocks({
147
+ "pages-home": {
148
+ name: "Home",
149
+ path: "/",
150
+ sections: [],
151
+ },
152
+ "pages-pdp-plp": {
153
+ name: "PDP & PLP",
154
+ path: "/{granado/}?*",
155
+ sections: [],
156
+ },
157
+ });
158
+ const match = findPageByPath("/");
159
+ expect(match?.blockKey).toBe("pages-home");
160
+ });
161
+
162
+ it("falls back to the splat page for unknown URLs", () => {
163
+ const match = findPageByPath("/perfumaria");
164
+ expect(match?.blockKey).toBe("pages-pdp-plp");
165
+ expect(match?.params).toEqual({ "0": "perfumaria" });
166
+ });
167
+
168
+ it("matches the param-bearing route ahead of the splat catch-all", () => {
169
+ const match = findPageByPath("/produto/sabonete/p");
170
+ expect(match?.blockKey).toBe("pages-product");
171
+ expect(match?.params).toEqual({ slug: "sabonete" });
172
+ });
173
+
174
+ it("returns null when no page matches", () => {
175
+ setBlocks({
176
+ "pages-only-bf": {
177
+ name: "Black Friday",
178
+ path: "/black-friday",
179
+ sections: [],
180
+ },
181
+ });
182
+ expect(findPageByPath("/nope")).toBeNull();
183
+ });
184
+ });
@@ -0,0 +1,284 @@
1
+ import * as asyncHooks from "node:async_hooks";
2
+ import { djb2Hex } from "../sdk/djb2";
3
+
4
+ export type Resolvable = {
5
+ __resolveType?: string;
6
+ [key: string]: unknown;
7
+ };
8
+
9
+ export type DecoPage = {
10
+ name: string;
11
+ path?: string;
12
+ sections: Resolvable[] | Resolvable;
13
+ seo?: Record<string, unknown>;
14
+ };
15
+
16
+ // globalThis-backed storage: TanStack Start server function split modules
17
+ // may get isolated module instances. globalThis ensures shared state.
18
+ const G = globalThis as any;
19
+ if (!G.__deco) G.__deco = {};
20
+
21
+ let blockData: Record<string, unknown> = G.__deco.blockData ?? {};
22
+ let revision: string | null = G.__deco.revision ?? null;
23
+
24
+ interface ALSLike<T> {
25
+ getStore(): T | undefined;
26
+ run<R>(store: T, fn: () => R): R;
27
+ }
28
+
29
+ // AsyncLocalStorage might not be available in client builds (Vite replaces
30
+ // node:async_hooks with an empty shim). The namespace import avoids Rollup's
31
+ // named-export validation, and the runtime check prevents construction errors.
32
+ const ALS = (asyncHooks as any).AsyncLocalStorage;
33
+ const blocksOverrideStorage: ALSLike<Record<string, unknown>> = ALS
34
+ ? new ALS()
35
+ : { getStore: () => undefined, run: (_s: any, fn: any) => fn() };
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Change listeners
39
+ // ---------------------------------------------------------------------------
40
+
41
+ type ChangeListener = (blocks: Record<string, unknown>, revision: string) => void;
42
+ const changeListeners: ChangeListener[] = [];
43
+
44
+ /** Register a callback invoked whenever setBlocks() changes the decofile. */
45
+ export function onChange(listener: ChangeListener) {
46
+ changeListeners.push(listener);
47
+ return () => {
48
+ const idx = changeListeners.indexOf(listener);
49
+ if (idx >= 0) changeListeners.splice(idx, 1);
50
+ };
51
+ }
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Revision hashing
55
+ // ---------------------------------------------------------------------------
56
+
57
+ function computeRevision(blocks: Record<string, unknown>): string {
58
+ return djb2Hex(JSON.stringify(blocks));
59
+ }
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Block management
63
+ // ---------------------------------------------------------------------------
64
+
65
+ /**
66
+ * Set the blocks data. Called at startup with generated blocks,
67
+ * and by the admin on hot-reload.
68
+ * Notifies all onChange listeners and updates the revision.
69
+ */
70
+ export function setBlocks(blocks: Record<string, unknown>) {
71
+ blockData = blocks;
72
+ revision = computeRevision(blocks);
73
+
74
+ // Persist to globalThis so other module instances see them
75
+ G.__deco.blockData = blockData;
76
+ G.__deco.revision = revision;
77
+
78
+ for (const listener of [...changeListeners]) {
79
+ try {
80
+ listener(blocks, revision);
81
+ } catch (e) {
82
+ console.error("[CMS] onChange listener error:", e);
83
+ }
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Load the current blocks. If running inside a `withBlocksOverride` scope
89
+ * (admin preview), the override is merged on top of the base blocks.
90
+ */
91
+ export function loadBlocks(): Record<string, unknown> {
92
+ // Re-sync from globalThis in case setBlocks was called in another module instance
93
+ if (G.__deco.blockData && G.__deco.blockData !== blockData) {
94
+ blockData = G.__deco.blockData;
95
+ revision = G.__deco.revision ?? null;
96
+ }
97
+
98
+ const override = blocksOverrideStorage.getStore();
99
+ if (override) {
100
+ const merged = { ...blockData };
101
+ for (const [key, value] of Object.entries(override)) {
102
+ if (value === null || value === undefined) {
103
+ delete merged[key];
104
+ } else {
105
+ merged[key] = value;
106
+ }
107
+ }
108
+ return merged;
109
+ }
110
+ return blockData;
111
+ }
112
+
113
+ /** Get the current decofile revision hash. Changes on each setBlocks(). */
114
+ export function getRevision(): string | null {
115
+ return revision;
116
+ }
117
+
118
+ /**
119
+ * Run a function with a temporary blocks overlay.
120
+ *
121
+ * Used by admin preview: the admin sends a partial decofile (only the
122
+ * blocks that changed), and `loadBlocks()` returns the merged result
123
+ * for the duration of the render. Other concurrent requests are not
124
+ * affected (AsyncLocalStorage is per-request scoped).
125
+ */
126
+ export function withBlocksOverride<T>(override: Record<string, unknown>, fn: () => T): T {
127
+ return blocksOverrideStorage.run(override, fn);
128
+ }
129
+
130
+ // Higher key wins. Compared lexicographically:
131
+ // [hasNoWildcard, literalSegments, paramSegments]
132
+ //
133
+ // `hasNoWildcard` is the top key so a literal-only path always beats any
134
+ // pattern that contains `*` or `{group}?` — including the empty-parts case
135
+ // `/` (literals=0) vs the catch-all `/{prefix/}?*` (literals=0, params=1).
136
+ // Without this, the URLPattern fix (#213/#214) inadvertently lets a
137
+ // `/{group/}?*` catch-all out-rank an exact `/` home page because the
138
+ // `{group` segment counted as a param. See deco-sites/granadobr-tanstack
139
+ // where `/` was being routed to the granado PDP/PLP block's NotFound
140
+ // fallback.
141
+ //
142
+ // Order produced:
143
+ // /foo/bar (no wildcard, literals=2) > /foo/:x (no wildcard, lit=1, param=1)
144
+ // /foo (no wildcard) > /{granado/}?* (has wildcard) > /*
145
+ function pathSpecificityKey(path: string): [number, number, number] {
146
+ const parts = path.split("/").filter(Boolean);
147
+ let literals = 0;
148
+ let params = 0;
149
+ let hasWildcard = false;
150
+ for (const part of parts) {
151
+ // A wildcard is any `*`, optional group `{...}?`, or any segment
152
+ // bearing `?` — these all make the pattern match strictly more URLs
153
+ // than a plain literal/`:param`/`:slug([\w-]+)` segment, so they
154
+ // are demoted to "least specific" together regardless of count.
155
+ if (part.includes("*") || /[{}?]/.test(part)) {
156
+ hasWildcard = true;
157
+ } else if (part.startsWith(":") || part.startsWith("$")) {
158
+ params++;
159
+ } else {
160
+ literals++;
161
+ }
162
+ }
163
+ return [hasWildcard ? 0 : 1, literals, params];
164
+ }
165
+
166
+ export function getAllPages(): Array<{ key: string; page: DecoPage }> {
167
+ const blocks = loadBlocks();
168
+ const pages: Array<{ key: string; page: DecoPage; key2: [number, number, number] }> = [];
169
+
170
+ for (const [key, block] of Object.entries(blocks)) {
171
+ if (!key.startsWith("pages-")) continue;
172
+ const page = block as DecoPage;
173
+ if (!page.sections) continue;
174
+ if (!page.path) continue;
175
+
176
+ pages.push({ key, page, key2: pathSpecificityKey(page.path) });
177
+ }
178
+
179
+ return pages
180
+ .sort((a, b) => {
181
+ for (let i = 0; i < a.key2.length; i++) {
182
+ if (a.key2[i] !== b.key2[i]) return b.key2[i] - a.key2[i];
183
+ }
184
+ return 0;
185
+ })
186
+ .map(({ key, page }) => ({ key, page }));
187
+ }
188
+
189
+ // Module-scoped (NOT `declare global`) ambient declaration for the
190
+ // `URLPattern` Web API. It's natively available at runtime in browsers,
191
+ // Cloudflare Workers, Deno, and Node 24+, but its type declarations aren't
192
+ // consistently present: TypeScript's bundled lib.dom.d.ts doesn't ship it
193
+ // yet, and @types/node only started shipping a global augmentation for it
194
+ // in newer major versions (via `node/web-globals/url.d.ts`).
195
+ //
196
+ // A `declare global` shim here would merge with @types/node's own global
197
+ // `URLPattern`/`URLPatternResult` augmentation whenever a new-enough
198
+ // @types/node wins the workspace's package hoist — and merging two
199
+ // independently-authored global interfaces under the same name throws
200
+ // TS2430 ("incorrectly extends") the moment their shapes don't line up
201
+ // structurally. Declaring `URLPattern` and its result shape as file-local
202
+ // bindings instead (no `declare global`) means this file typechecks the
203
+ // same regardless of whether @types/node has its own `URLPattern` global or
204
+ // not — there's nothing for a local declaration to collide with.
205
+ type MatchPatternResult = {
206
+ pathname: { groups: Record<string, string | undefined> };
207
+ };
208
+ declare const URLPattern: {
209
+ new (init: { pathname: string }): {
210
+ exec(input: { pathname: string }): MatchPatternResult | null;
211
+ };
212
+ };
213
+
214
+ /**
215
+ * Match a CMS page path pattern against a URL path.
216
+ *
217
+ * Mirrors the original deco-cx/deco Fresh framework
218
+ * (`runtime/features/render.tsx`) by delegating to the platform's native
219
+ * `URLPattern`. Supports the full URLPattern syntax that the admin emits:
220
+ * `:slug`, `:slug([\w-]+)`, optional groups `{...}?`, and trailing `*`
221
+ * splats. Splats are exposed as the standard numbered groups (`"0"`, `"1"`,
222
+ * …), matching the Fresh shape.
223
+ *
224
+ * Malformed patterns return `null` instead of throwing — bad CMS data must
225
+ * never take down the worker.
226
+ */
227
+ export function matchPath(
228
+ pattern: string,
229
+ urlPath: string,
230
+ ): Record<string, string> | null {
231
+ let result: MatchPatternResult | null;
232
+ try {
233
+ result = new URLPattern({ pathname: pattern }).exec({ pathname: urlPath });
234
+ } catch {
235
+ return null;
236
+ }
237
+ if (!result) return null;
238
+
239
+ const groups = result.pathname.groups as Record<string, string | undefined>;
240
+ const out: Record<string, string> = {};
241
+ for (const [k, v] of Object.entries(groups)) {
242
+ if (v !== undefined) out[k] = v;
243
+ }
244
+ return out;
245
+ }
246
+
247
+ /**
248
+ * Extract the site-wide SEO config from the "Site" app block.
249
+ *
250
+ * In the original deco-cx/deco framework this is `ctx.seo` — the app-level
251
+ * SEO configuration that provides fallback title, description, and templates
252
+ * when page-level seo blocks don't supply them.
253
+ */
254
+ export function getSiteSeo(): {
255
+ title?: string;
256
+ description?: string;
257
+ titleTemplate?: string;
258
+ descriptionTemplate?: string;
259
+ image?: string;
260
+ favicon?: string;
261
+ themeColor?: string;
262
+ noIndexing?: boolean;
263
+ } {
264
+ const blocks = loadBlocks();
265
+ const site = blocks["Site"] as Record<string, unknown> | undefined;
266
+ if (!site) return {};
267
+ const seo = site.seo as Record<string, unknown> | undefined;
268
+ if (!seo) return {};
269
+ return seo as ReturnType<typeof getSiteSeo>;
270
+ }
271
+
272
+ export function findPageByPath(
273
+ targetPath: string,
274
+ ): { page: DecoPage; params: Record<string, string>; blockKey: string } | null {
275
+ const allPages = getAllPages();
276
+
277
+ for (const { key, page } of allPages) {
278
+ if (!page.path) continue;
279
+ const params = matchPath(page.path, targetPath);
280
+ if (params !== null) return { page, params, blockKey: key };
281
+ }
282
+
283
+ return null;
284
+ }
@@ -0,0 +1,118 @@
1
+ import { describe, expect, it, beforeEach } from "vitest";
2
+ import {
3
+ registerSection,
4
+ registerSectionsSync,
5
+ getSection,
6
+ getSectionOptions,
7
+ getSyncComponent,
8
+ getResolvedComponent,
9
+ } from "./registry";
10
+
11
+ // Reset globalThis.__deco between tests to avoid cross-test pollution
12
+ beforeEach(() => {
13
+ const G = globalThis as any;
14
+ G.__deco.sectionRegistry = {};
15
+ G.__deco.sectionOptions = {};
16
+ G.__deco.resolvedComponents = {};
17
+ G.__deco.syncComponents = {};
18
+ });
19
+
20
+ describe("registerSection + getSection", () => {
21
+ it("registers and retrieves a section loader", () => {
22
+ const loader = async () => ({ default: () => null });
23
+ registerSection("site/sections/Hero.tsx", loader);
24
+
25
+ const retrieved = getSection("site/sections/Hero.tsx");
26
+ expect(retrieved).toBe(loader);
27
+ });
28
+
29
+ it("returns undefined for unregistered section", () => {
30
+ expect(getSection("nonexistent")).toBeUndefined();
31
+ });
32
+ });
33
+
34
+ describe("registerSection with options", () => {
35
+ it("stores loadingFallback in section options", () => {
36
+ const fallback = () => null;
37
+ registerSection(
38
+ "site/sections/Shelf.tsx",
39
+ async () => ({ default: () => null }),
40
+ { loadingFallback: fallback },
41
+ );
42
+
43
+ const opts = getSectionOptions("site/sections/Shelf.tsx");
44
+ expect(opts?.loadingFallback).toBe(fallback);
45
+ });
46
+
47
+ it("stores clientOnly flag in section options", () => {
48
+ registerSection(
49
+ "site/sections/Analytics.tsx",
50
+ async () => ({ default: () => null }),
51
+ { clientOnly: true },
52
+ );
53
+
54
+ const opts = getSectionOptions("site/sections/Analytics.tsx");
55
+ expect(opts?.clientOnly).toBe(true);
56
+ });
57
+
58
+ it("clientOnly defaults to undefined when not set", () => {
59
+ registerSection(
60
+ "site/sections/Normal.tsx",
61
+ async () => ({ default: () => null }),
62
+ { loadingFallback: () => null },
63
+ );
64
+
65
+ const opts = getSectionOptions("site/sections/Normal.tsx");
66
+ expect(opts?.clientOnly).toBeUndefined();
67
+ });
68
+
69
+ it("clientOnly false is preserved", () => {
70
+ registerSection(
71
+ "site/sections/Explicit.tsx",
72
+ async () => ({ default: () => null }),
73
+ { clientOnly: false },
74
+ );
75
+
76
+ const opts = getSectionOptions("site/sections/Explicit.tsx");
77
+ expect(opts?.clientOnly).toBe(false);
78
+ });
79
+
80
+ it("returns undefined options for section without options", () => {
81
+ registerSection("site/sections/Plain.tsx", async () => ({ default: () => null }));
82
+ expect(getSectionOptions("site/sections/Plain.tsx")).toBeUndefined();
83
+ });
84
+ });
85
+
86
+ describe("registerSectionsSync", () => {
87
+ it("registers component as sync and resolved", () => {
88
+ const MyComponent = () => null;
89
+ registerSectionsSync({ "site/sections/Header.tsx": MyComponent });
90
+
91
+ expect(getSyncComponent("site/sections/Header.tsx")).toBe(MyComponent);
92
+ expect(getResolvedComponent("site/sections/Header.tsx")).toBe(MyComponent);
93
+ });
94
+
95
+ it("accepts module objects with LoadingFallback", () => {
96
+ const MyComponent = () => null;
97
+ const MyFallback = () => null;
98
+
99
+ registerSectionsSync({
100
+ "site/sections/Footer.tsx": {
101
+ default: MyComponent,
102
+ LoadingFallback: MyFallback,
103
+ },
104
+ });
105
+
106
+ expect(getSyncComponent("site/sections/Footer.tsx")).toBe(MyComponent);
107
+ const opts = getSectionOptions("site/sections/Footer.tsx");
108
+ expect(opts?.loadingFallback).toBe(MyFallback);
109
+ });
110
+
111
+ it("skips entries without callable default export", () => {
112
+ registerSectionsSync({
113
+ "site/sections/Bad.tsx": { default: "not a function" } as any,
114
+ });
115
+
116
+ expect(getSyncComponent("site/sections/Bad.tsx")).toBeUndefined();
117
+ });
118
+ });