@decocms/start 6.3.1 → 6.4.1

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/start",
3
- "version": "6.3.1",
3
+ "version": "6.4.1",
4
4
  "type": "module",
5
5
  "description": "Deco framework for TanStack Start - CMS bridge, admin protocol, hooks, schema generation",
6
6
  "main": "./src/index.ts",
@@ -0,0 +1,164 @@
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("falls back to the splat page for unknown URLs", () => {
143
+ const match = findPageByPath("/perfumaria");
144
+ expect(match?.blockKey).toBe("pages-pdp-plp");
145
+ expect(match?.params).toEqual({ "0": "perfumaria" });
146
+ });
147
+
148
+ it("matches the param-bearing route ahead of the splat catch-all", () => {
149
+ const match = findPageByPath("/produto/sabonete/p");
150
+ expect(match?.blockKey).toBe("pages-product");
151
+ expect(match?.params).toEqual({ slug: "sabonete" });
152
+ });
153
+
154
+ it("returns null when no page matches", () => {
155
+ setBlocks({
156
+ "pages-only-bf": {
157
+ name: "Black Friday",
158
+ path: "/black-friday",
159
+ sections: [],
160
+ },
161
+ });
162
+ expect(findPageByPath("/nope")).toBeNull();
163
+ });
164
+ });
package/src/cms/loader.ts CHANGED
@@ -130,14 +130,23 @@ export function withBlocksOverride<T>(override: Record<string, unknown>, fn: ()
130
130
  // Higher key wins. Compared lexicographically:
131
131
  // [literalSegments, paramSegments, hasNoSplat]
132
132
  // So `/foo/bar` > `/foo/:x` > `/foo/*` > `/*`, and `/my-account/*` > `/*`.
133
+ //
134
+ // URLPattern syntax (`{group}?`, `:slug([\w-]+)`, trailing `*`) is supported:
135
+ // any segment containing `{`, `}`, or `?` counts as a param, and a segment
136
+ // containing `*` flips the splat bit. This keeps optional-group patterns
137
+ // (e.g. `/{granado/}?*`) from out-ranking real literal pages.
133
138
  function pathSpecificityKey(path: string): [number, number, number] {
134
139
  const parts = path.split("/").filter(Boolean);
135
140
  let literals = 0;
136
141
  let params = 0;
137
142
  let hasSplat = false;
138
143
  for (const part of parts) {
139
- if (part === "*") hasSplat = true;
140
- else if (part.startsWith(":") || part.startsWith("$")) params++;
144
+ if (part.includes("*")) hasSplat = true;
145
+ else if (
146
+ part.startsWith(":") ||
147
+ part.startsWith("$") ||
148
+ /[{}?]/.test(part)
149
+ ) params++;
141
150
  else literals++;
142
151
  }
143
152
  return [literals, params, hasSplat ? 0 : 1];
@@ -166,33 +175,37 @@ export function getAllPages(): Array<{ key: string; page: DecoPage }> {
166
175
  .map(({ key, page }) => ({ key, page }));
167
176
  }
168
177
 
169
- function matchPath(pattern: string, urlPath: string): Record<string, string> | null {
170
- if (pattern === "/*") return { _splat: urlPath };
171
-
172
- const patternParts = pattern.split("/").filter(Boolean);
173
- const urlParts = urlPath.split("/").filter(Boolean);
174
-
175
- // Trailing `*` means "match this prefix and any remaining segments".
176
- const hasSplat = patternParts[patternParts.length - 1] === "*";
177
- const fixedLen = hasSplat ? patternParts.length - 1 : patternParts.length;
178
-
179
- if (hasSplat) {
180
- if (urlParts.length < fixedLen) return null;
181
- } else if (urlParts.length !== fixedLen) {
178
+ /**
179
+ * Match a CMS page path pattern against a URL path.
180
+ *
181
+ * Mirrors the original deco-cx/deco Fresh framework
182
+ * (`runtime/features/render.tsx`) by delegating to the platform's native
183
+ * `URLPattern`. Supports the full URLPattern syntax that the admin emits:
184
+ * `:slug`, `:slug([\w-]+)`, optional groups `{...}?`, and trailing `*`
185
+ * splats. Splats are exposed as the standard numbered groups (`"0"`, `"1"`,
186
+ * …), matching the Fresh shape.
187
+ *
188
+ * Malformed patterns return `null` instead of throwing — bad CMS data must
189
+ * never take down the worker.
190
+ */
191
+ export function matchPath(
192
+ pattern: string,
193
+ urlPath: string,
194
+ ): Record<string, string> | null {
195
+ let result: URLPatternResult | null;
196
+ try {
197
+ result = new URLPattern({ pathname: pattern }).exec({ pathname: urlPath });
198
+ } catch {
182
199
  return null;
183
200
  }
201
+ if (!result) return null;
184
202
 
185
- const params: Record<string, string> = {};
186
- for (let i = 0; i < fixedLen; i++) {
187
- const pp = patternParts[i];
188
- const up = urlParts[i];
189
- if (pp.startsWith(":")) params[pp.slice(1)] = up;
190
- else if (pp !== up) return null;
203
+ const groups = result.pathname.groups as Record<string, string | undefined>;
204
+ const out: Record<string, string> = {};
205
+ for (const [k, v] of Object.entries(groups)) {
206
+ if (v !== undefined) out[k] = v;
191
207
  }
192
-
193
- if (hasSplat) params._splat = urlParts.slice(fixedLen).join("/");
194
-
195
- return params;
208
+ return out;
196
209
  }
197
210
 
198
211
  /**
@@ -662,7 +662,7 @@ export function createDecoWorkerEntry(
662
662
  admin,
663
663
  detectProfile: customDetect,
664
664
  deviceSpecificKeys = true,
665
- buildSegment,
665
+ buildSegment: rawBuildSegment,
666
666
  purgeTokenEnv = "PURGE_TOKEN",
667
667
  bypassPaths,
668
668
  extraBypassPaths = [],
@@ -678,6 +678,31 @@ export function createDecoWorkerEntry(
678
678
  cdnCacheControl: cdnCacheControlOpt = "no-store",
679
679
  } = options;
680
680
 
681
+ // Backfill `regionId` from Cloudflare geo when the consumer's buildSegment
682
+ // doesn't set one. Without this, sites using website/matchers/location.ts
683
+ // get a single cached response per device that leaks across regions: the
684
+ // first visitor's resolved variant gets served to everyone. With this,
685
+ // existing sites get region-segmented cache "for free" on bump — no
686
+ // worker-entry.ts edit required.
687
+ function readRegionFromRequest(request: Request): string | undefined {
688
+ // Trust the Cloudflare-injected `request.cf` first — it can't be spoofed
689
+ // by clients. Fall back to the `cf-region-code` header for environments
690
+ // that surface geo only via headers (e.g. tests, non-CF proxies).
691
+ const cf = (request as unknown as { cf?: { regionCode?: string } }).cf;
692
+ if (cf?.regionCode) return cf.regionCode;
693
+ const fromHeader = request.headers.get("cf-region-code");
694
+ return fromHeader || undefined;
695
+ }
696
+
697
+ const buildSegment = rawBuildSegment
698
+ ? (request: Request): SegmentKey => {
699
+ const seg = rawBuildSegment(request);
700
+ if (seg.regionId) return seg;
701
+ const region = readRegionFromRequest(request);
702
+ return region ? { ...seg, regionId: region } : seg;
703
+ }
704
+ : undefined;
705
+
681
706
  const safeCookieSet = new Set(safeCookiesOpt);
682
707
 
683
708
  // Build the final security headers map (merged defaults + custom + CSP)