@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.
- package/package.json +97 -0
- package/src/cms/applySectionConventions.ts +128 -0
- package/src/cms/blockSource.test.ts +67 -0
- package/src/cms/blockSource.ts +124 -0
- package/src/cms/index.ts +104 -0
- package/src/cms/layoutCacheRace.test.ts +146 -0
- package/src/cms/loadDecofileDirectory.test.ts +52 -0
- package/src/cms/loadDecofileDirectory.ts +48 -0
- package/src/cms/loader.test.ts +184 -0
- package/src/cms/loader.ts +284 -0
- package/src/cms/registry.test.ts +118 -0
- package/src/cms/registry.ts +252 -0
- package/src/cms/resolve.test.ts +547 -0
- package/src/cms/resolve.ts +2003 -0
- package/src/cms/schema.ts +993 -0
- package/src/cms/sectionLoaders.test.ts +409 -0
- package/src/cms/sectionLoaders.ts +562 -0
- package/src/cms/sectionMixins.test.ts +163 -0
- package/src/cms/sectionMixins.ts +153 -0
- package/src/hooks/LazySection.tsx +121 -0
- package/src/hooks/LiveControls.tsx +122 -0
- package/src/hooks/RenderSection.test.tsx +81 -0
- package/src/hooks/RenderSection.tsx +91 -0
- package/src/hooks/SectionErrorFallback.tsx +97 -0
- package/src/hooks/index.ts +4 -0
- package/src/index.ts +8 -0
- package/src/matchers/builtins.test.ts +251 -0
- package/src/matchers/builtins.ts +437 -0
- package/src/matchers/countryNames.ts +104 -0
- package/src/matchers/override.test.ts +205 -0
- package/src/matchers/override.ts +136 -0
- package/src/matchers/posthog.ts +154 -0
- package/src/middleware/decoState.ts +55 -0
- package/src/middleware/healthMetrics.ts +133 -0
- package/src/middleware/hydrationContext.test.ts +61 -0
- package/src/middleware/hydrationContext.ts +79 -0
- package/src/middleware/index.ts +88 -0
- package/src/middleware/liveness.ts +21 -0
- package/src/middleware/observability.test.ts +238 -0
- package/src/middleware/observability.ts +620 -0
- package/src/middleware/validateSection.test.ts +147 -0
- package/src/middleware/validateSection.ts +100 -0
- package/src/sdk/abTesting.test.ts +326 -0
- package/src/sdk/abTesting.ts +499 -0
- package/src/sdk/analytics.ts +77 -0
- package/src/sdk/cacheHeaders.test.ts +115 -0
- package/src/sdk/cacheHeaders.ts +424 -0
- package/src/sdk/cachedLoader.ts +364 -0
- package/src/sdk/clx.ts +5 -0
- package/src/sdk/cn.test.ts +34 -0
- package/src/sdk/cn.ts +28 -0
- package/src/sdk/composite.test.ts +121 -0
- package/src/sdk/composite.ts +114 -0
- package/src/sdk/cookie.test.ts +108 -0
- package/src/sdk/cookie.ts +129 -0
- package/src/sdk/crypto.ts +185 -0
- package/src/sdk/csp.ts +59 -0
- package/src/sdk/djb2.ts +20 -0
- package/src/sdk/encoding.test.ts +71 -0
- package/src/sdk/encoding.ts +47 -0
- package/src/sdk/env.ts +31 -0
- package/src/sdk/http.test.ts +71 -0
- package/src/sdk/http.ts +124 -0
- package/src/sdk/index.ts +90 -0
- package/src/sdk/inflightTimeout.test.ts +35 -0
- package/src/sdk/inflightTimeout.ts +53 -0
- package/src/sdk/instrumentedFetch.test.ts +559 -0
- package/src/sdk/instrumentedFetch.ts +339 -0
- package/src/sdk/invoke.test.ts +115 -0
- package/src/sdk/invoke.ts +260 -0
- package/src/sdk/logger.test.ts +432 -0
- package/src/sdk/logger.ts +304 -0
- package/src/sdk/mergeCacheControl.ts +150 -0
- package/src/sdk/normalizeUrls.ts +91 -0
- package/src/sdk/observability.ts +109 -0
- package/src/sdk/otel.test.ts +526 -0
- package/src/sdk/otel.ts +981 -0
- package/src/sdk/otelAdapters/clickhouseCollector.ts +65 -0
- package/src/sdk/otelAdapters.test.ts +89 -0
- package/src/sdk/otelAdapters.ts +144 -0
- package/src/sdk/otelHttpLog.test.ts +457 -0
- package/src/sdk/otelHttpLog.ts +419 -0
- package/src/sdk/otelHttpMeter.test.ts +292 -0
- package/src/sdk/otelHttpMeter.ts +506 -0
- package/src/sdk/otelHttpTracer.test.ts +474 -0
- package/src/sdk/otelHttpTracer.ts +543 -0
- package/src/sdk/redirects.ts +225 -0
- package/src/sdk/requestContext.ts +281 -0
- package/src/sdk/requestContextStorage.browser.test.ts +29 -0
- package/src/sdk/requestContextStorage.browser.ts +43 -0
- package/src/sdk/requestContextStorage.ts +35 -0
- package/src/sdk/retry.ts +45 -0
- package/src/sdk/serverTimings.ts +68 -0
- package/src/sdk/signal.ts +42 -0
- package/src/sdk/sitemap.ts +160 -0
- package/src/sdk/urlRedaction.test.ts +73 -0
- package/src/sdk/urlRedaction.ts +82 -0
- package/src/sdk/urlUtils.ts +134 -0
- package/src/sdk/useDevice.test.ts +130 -0
- package/src/sdk/useDevice.ts +109 -0
- package/src/sdk/useDeviceContext.tsx +108 -0
- package/src/sdk/useId.ts +7 -0
- package/src/sdk/useScript.test.ts +128 -0
- package/src/sdk/useScript.ts +210 -0
- package/src/sdk/useSuggestions.test.ts +230 -0
- package/src/sdk/useSuggestions.ts +188 -0
- package/src/sdk/wrapCaughtErrors.ts +107 -0
- package/src/setup.ts +119 -0
- package/src/types/index.ts +39 -0
- package/src/types/widgets.ts +14 -0
- 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
|
+
});
|