@decocms/start 6.4.0 → 6.4.2
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 +1 -1
- package/src/cms/loader.test.ts +164 -0
- package/src/cms/loader.ts +38 -25
- package/src/vite/plugin.js +4 -1
package/package.json
CHANGED
|
@@ -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
|
|
140
|
-
else if (
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
/**
|
package/src/vite/plugin.js
CHANGED
|
@@ -363,7 +363,10 @@ export function decoVitePlugin() {
|
|
|
363
363
|
site: siteName,
|
|
364
364
|
env: envName,
|
|
365
365
|
port,
|
|
366
|
-
|
|
366
|
+
// Default to the .deco.host relay (matches startTunnel's documented
|
|
367
|
+
// default). Set DECO_HOST=false to opt back into the legacy
|
|
368
|
+
// simpletunnel.deco.site relay.
|
|
369
|
+
decoHost: process.env.DECO_HOST !== "false",
|
|
367
370
|
});
|
|
368
371
|
server.httpServer?.on("close", () => tunnel.close());
|
|
369
372
|
} catch (err) {
|