@decocms/tanstack 7.22.0 → 7.22.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 +4 -4
- package/src/routes/cmsRoute.test.ts +38 -1
- package/src/routes/cmsRoute.ts +112 -56
- package/src/sdk/workerEntry.test.ts +50 -0
- package/src/sdk/workerEntry.ts +29 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/tanstack",
|
|
3
|
-
"version": "7.22.
|
|
3
|
+
"version": "7.22.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Deco framework binding for TanStack Start + Cloudflare Workers",
|
|
6
6
|
"repository": {
|
|
@@ -24,9 +24,9 @@
|
|
|
24
24
|
"lint:unused": "knip"
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@decocms/blocks": "7.22.
|
|
28
|
-
"@decocms/blocks-admin": "7.22.
|
|
29
|
-
"@decocms/blocks-cli": "7.22.
|
|
27
|
+
"@decocms/blocks": "7.22.2",
|
|
28
|
+
"@decocms/blocks-admin": "7.22.2",
|
|
29
|
+
"@decocms/blocks-cli": "7.22.2",
|
|
30
30
|
"@deco-cx/warp-node": "^0.3.16",
|
|
31
31
|
"fast-json-patch": "^3.1.0",
|
|
32
32
|
"ws": "^8.18.0"
|
|
@@ -1,7 +1,44 @@
|
|
|
1
1
|
import { registerCacheableSections, registerSectionLoader } from "@decocms/blocks/cms";
|
|
2
2
|
import type { ResolvedSection } from "@decocms/blocks/cms";
|
|
3
3
|
import { describe, expect, it } from "vitest";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
parseLoadCmsHomePageInput,
|
|
6
|
+
parseLoadCmsPageInput,
|
|
7
|
+
runSectionLoadersWithSeo,
|
|
8
|
+
} from "./cmsRoute";
|
|
9
|
+
|
|
10
|
+
describe("parseLoadCmsPageInput (#292)", () => {
|
|
11
|
+
it("treats a bare string as the path with resolveGlobals defaulting to true (back-compat)", () => {
|
|
12
|
+
expect(parseLoadCmsPageInput("/produto/tenis")).toEqual({
|
|
13
|
+
path: "/produto/tenis",
|
|
14
|
+
resolveGlobals: true,
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("accepts { path, resolveGlobals } and preserves an explicit false", () => {
|
|
19
|
+
expect(parseLoadCmsPageInput({ path: "/produto/tenis", resolveGlobals: false })).toEqual({
|
|
20
|
+
path: "/produto/tenis",
|
|
21
|
+
resolveGlobals: false,
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("defaults resolveGlobals to true when omitted from the object form", () => {
|
|
26
|
+
expect(parseLoadCmsPageInput({ path: "/produto/tenis" })).toEqual({
|
|
27
|
+
path: "/produto/tenis",
|
|
28
|
+
resolveGlobals: true,
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("parseLoadCmsHomePageInput (#292)", () => {
|
|
34
|
+
it("defaults resolveGlobals to true when called with no data (back-compat)", () => {
|
|
35
|
+
expect(parseLoadCmsHomePageInput(undefined)).toEqual({ resolveGlobals: true });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("preserves an explicit resolveGlobals: false", () => {
|
|
39
|
+
expect(parseLoadCmsHomePageInput({ resolveGlobals: false })).toEqual({ resolveGlobals: false });
|
|
40
|
+
});
|
|
41
|
+
});
|
|
5
42
|
|
|
6
43
|
/**
|
|
7
44
|
* Regression guard for #355: body sections and the SEO section used to be
|
package/src/routes/cmsRoute.ts
CHANGED
|
@@ -62,6 +62,7 @@ import { normalizeUrlsInObject } from "@decocms/blocks/sdk/normalizeUrls";
|
|
|
62
62
|
import { type Device, detectDevice } from "@decocms/blocks/sdk/useDevice";
|
|
63
63
|
import { derivePageUrl, isClientNavigation } from "./pageUrl";
|
|
64
64
|
import { dedupeGlobals, resolveSiteGlobals } from "./withSiteGlobals";
|
|
65
|
+
import type { SiteGlobalRef } from "./withSiteGlobals";
|
|
65
66
|
|
|
66
67
|
const isServer = typeof document === "undefined";
|
|
67
68
|
|
|
@@ -85,6 +86,12 @@ export function setSectionChunkMap(map: Record<string, string>): void {
|
|
|
85
86
|
type PageResult = Awaited<ReturnType<typeof loadCmsPageInternal>>;
|
|
86
87
|
const pageInflight = new Map<string, Promise<PageResult>>();
|
|
87
88
|
|
|
89
|
+
/** Same shape as `resolveSiteGlobals()`'s empty result — used when `resolveGlobals: false`. */
|
|
90
|
+
const EMPTY_GLOBALS: { resolvedSections: ResolvedSection[]; rawRefs: SiteGlobalRef[] } = {
|
|
91
|
+
resolvedSections: [],
|
|
92
|
+
rawRefs: [],
|
|
93
|
+
};
|
|
94
|
+
|
|
88
95
|
/** One year — the cohort is sticky; the `pct` fingerprint handles re-rolls. */
|
|
89
96
|
const SEGMENT_COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
|
|
90
97
|
|
|
@@ -154,7 +161,7 @@ export async function runSectionLoadersWithSeo(
|
|
|
154
161
|
};
|
|
155
162
|
}
|
|
156
163
|
|
|
157
|
-
async function loadCmsPageInternal(fullPath: string) {
|
|
164
|
+
async function loadCmsPageInternal(fullPath: string, resolveGlobals: boolean) {
|
|
158
165
|
const [basePath] = fullPath.split("?");
|
|
159
166
|
// On client-side navigation getRequestUrl() is the /_serverFn/... endpoint,
|
|
160
167
|
// not the page being loaded — derivePageUrl rebuilds the real page URL so
|
|
@@ -188,7 +195,7 @@ async function loadCmsPageInternal(fullPath: string) {
|
|
|
188
195
|
// empty client-bundled `blocks.gen.ts`.
|
|
189
196
|
const [{ enrichedSections, enrichedSeoSection }, globals] = await Promise.all([
|
|
190
197
|
runSectionLoadersWithSeo(page.resolvedSections, page.seoSection, request),
|
|
191
|
-
resolveSiteGlobals(),
|
|
198
|
+
resolveGlobals ? resolveSiteGlobals() : Promise.resolve(EMPTY_GLOBALS),
|
|
192
199
|
]);
|
|
193
200
|
|
|
194
201
|
// Page sections take precedence over globals — dedupe drops any global
|
|
@@ -228,10 +235,20 @@ async function loadCmsPageInternal(fullPath: string) {
|
|
|
228
235
|
};
|
|
229
236
|
}
|
|
230
237
|
|
|
238
|
+
/** Accepted by `loadCmsPage` — a bare path (back-compat) or `{ path, resolveGlobals }`. */
|
|
239
|
+
export type LoadCmsPageInput = string | { path: string; resolveGlobals?: boolean };
|
|
240
|
+
|
|
241
|
+
/** @internal exported for tests */
|
|
242
|
+
export function parseLoadCmsPageInput(data: unknown): { path: string; resolveGlobals: boolean } {
|
|
243
|
+
if (typeof data === "string") return { path: data, resolveGlobals: true };
|
|
244
|
+
const obj = data as { path: string; resolveGlobals?: boolean };
|
|
245
|
+
return { path: obj.path, resolveGlobals: obj.resolveGlobals ?? true };
|
|
246
|
+
}
|
|
247
|
+
|
|
231
248
|
export const loadCmsPage = createServerFn({ method: "GET" })
|
|
232
|
-
.inputValidator((data: unknown) => data as
|
|
249
|
+
.inputValidator((data: unknown) => data as LoadCmsPageInput)
|
|
233
250
|
.handler(async (ctx) => {
|
|
234
|
-
const fullPath = ctx.data;
|
|
251
|
+
const { path: fullPath, resolveGlobals } = parseLoadCmsPageInput(ctx.data);
|
|
235
252
|
|
|
236
253
|
// Use the full path (including query string) as the dedup key.
|
|
237
254
|
// Using basePath only caused /s?q=a and /s?q=b to share one promise,
|
|
@@ -241,67 +258,84 @@ export const loadCmsPage = createServerFn({ method: "GET" })
|
|
|
241
258
|
// for the same path may carry deferredSections; sharing that promise with a
|
|
242
259
|
// concurrent SPA transition would smuggle those deferred sections in, causing
|
|
243
260
|
// loadDeferredSection to run without the per-request commerce app context.
|
|
244
|
-
// Use separate dedup buckets for SSR vs client-nav.
|
|
261
|
+
// Use separate dedup buckets for SSR vs client-nav. `resolveGlobals` is also
|
|
262
|
+
// part of the key — otherwise a `resolveGlobals: false` request could return
|
|
263
|
+
// (or share a promise with) a `resolveGlobals: true` response for the same path.
|
|
245
264
|
const clientNav = isClientNavigation(fullPath, getRequestUrl());
|
|
246
|
-
const inflightKey =
|
|
265
|
+
const inflightKey =
|
|
266
|
+
(clientNav ? `__nav:${fullPath}` : fullPath) + (resolveGlobals ? "" : "|noGlobals");
|
|
247
267
|
const existing = pageInflight.get(inflightKey);
|
|
248
268
|
if (existing) return existing;
|
|
249
269
|
|
|
250
|
-
const promise = loadCmsPageInternal(fullPath).finally(() =>
|
|
270
|
+
const promise = loadCmsPageInternal(fullPath, resolveGlobals).finally(() =>
|
|
271
|
+
pageInflight.delete(inflightKey),
|
|
272
|
+
);
|
|
251
273
|
pageInflight.set(inflightKey, promise);
|
|
252
274
|
return promise;
|
|
253
275
|
});
|
|
254
276
|
|
|
277
|
+
/** Accepted by `loadCmsHomePage` — optional so existing no-arg callers keep working. */
|
|
278
|
+
export type LoadCmsHomePageInput = { resolveGlobals?: boolean } | undefined;
|
|
279
|
+
|
|
280
|
+
/** @internal exported for tests */
|
|
281
|
+
export function parseLoadCmsHomePageInput(data: unknown): { resolveGlobals: boolean } {
|
|
282
|
+
const obj = (data ?? {}) as { resolveGlobals?: boolean };
|
|
283
|
+
return { resolveGlobals: obj.resolveGlobals ?? true };
|
|
284
|
+
}
|
|
285
|
+
|
|
255
286
|
/**
|
|
256
287
|
* Same as loadCmsPage but hardcoded to "/" path.
|
|
257
288
|
* Avoids passing data through the server function for the homepage.
|
|
258
289
|
*/
|
|
259
|
-
export const loadCmsHomePage = createServerFn({ method: "GET" })
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
...pageData
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
290
|
+
export const loadCmsHomePage = createServerFn({ method: "GET" })
|
|
291
|
+
.inputValidator((data: unknown) => data as LoadCmsHomePageInput)
|
|
292
|
+
.handler(async (ctx) => {
|
|
293
|
+
const { resolveGlobals } = parseLoadCmsHomePageInput(ctx.data);
|
|
294
|
+
const request = getRequest();
|
|
295
|
+
const ua = getRequestHeader("user-agent") ?? "";
|
|
296
|
+
const serverUrl = getRequestUrl();
|
|
297
|
+
const matcherCtx: MatcherContext = {
|
|
298
|
+
userAgent: ua,
|
|
299
|
+
url: serverUrl.toString(),
|
|
300
|
+
path: "/",
|
|
301
|
+
cookies: getCookies(),
|
|
302
|
+
request,
|
|
303
|
+
isClientNavigation: isClientNavigation("/", serverUrl),
|
|
304
|
+
flags: [],
|
|
305
|
+
};
|
|
306
|
+
const page = await resolveDecoPage("/", matcherCtx);
|
|
307
|
+
if (!page) return null;
|
|
308
|
+
const flags = persistFlags(matcherCtx);
|
|
309
|
+
const [{ enrichedSections, enrichedSeoSection }, globals] = await Promise.all([
|
|
310
|
+
runSectionLoadersWithSeo(page.resolvedSections, page.seoSection, request),
|
|
311
|
+
resolveGlobals ? resolveSiteGlobals() : Promise.resolve(EMPTY_GLOBALS),
|
|
312
|
+
]);
|
|
313
|
+
|
|
314
|
+
const mergedSections: ResolvedSection[] = [
|
|
315
|
+
...dedupeGlobals(globals.resolvedSections, enrichedSections),
|
|
316
|
+
...enrichedSections,
|
|
317
|
+
];
|
|
318
|
+
|
|
319
|
+
const eagerKeys = mergedSections.map((s) => s.component);
|
|
320
|
+
await preloadSectionComponents(eagerKeys);
|
|
321
|
+
|
|
322
|
+
const device = detectDevice(ua);
|
|
323
|
+
const seo = buildPageSeo(page.seoSection, enrichedSeoSection, mergedSections);
|
|
324
|
+
|
|
325
|
+
const { seoSection: _seo, ...pageData } = page;
|
|
326
|
+
|
|
327
|
+
return {
|
|
328
|
+
...pageData,
|
|
329
|
+
resolvedSections: normalizeUrlsInObject(mergedSections),
|
|
330
|
+
deferredSections: normalizeUrlsInObject(page.deferredSections),
|
|
331
|
+
pagePath: "/",
|
|
332
|
+
pageUrl: serverUrl.toString(),
|
|
333
|
+
seo,
|
|
334
|
+
device,
|
|
335
|
+
flags,
|
|
336
|
+
siteGlobals: { rawRefs: globals.rawRefs },
|
|
337
|
+
};
|
|
338
|
+
});
|
|
305
339
|
|
|
306
340
|
// ---------------------------------------------------------------------------
|
|
307
341
|
// Deferred section loader — resolves + enriches a single section on demand
|
|
@@ -448,6 +482,16 @@ export interface CmsRouteOptions {
|
|
|
448
482
|
* - `false`: Everything runs on client only.
|
|
449
483
|
*/
|
|
450
484
|
ssr?: boolean | "data-only";
|
|
485
|
+
/**
|
|
486
|
+
* Resolve `site.theme + site.global + site.pageSections` (the CMS `Site`
|
|
487
|
+
* block) and merge them into every page's sections.
|
|
488
|
+
*
|
|
489
|
+
* Default: `true`. Set to `false` to restore pre-6.7.4 behavior for sites
|
|
490
|
+
* that don't use the `Site` block and don't want it silently activated
|
|
491
|
+
* (see #292) — this also skips the `Theme` section's `<style>` injection,
|
|
492
|
+
* so the site's own `:root` CSS fallbacks apply instead.
|
|
493
|
+
*/
|
|
494
|
+
resolveGlobals?: boolean;
|
|
451
495
|
}
|
|
452
496
|
|
|
453
497
|
type CmsPageLoaderData = {
|
|
@@ -658,6 +702,7 @@ export function cmsRouteConfig(options: CmsRouteOptions) {
|
|
|
658
702
|
pendingMs = 200,
|
|
659
703
|
pendingMinMs = 300,
|
|
660
704
|
ssr: ssrMode,
|
|
705
|
+
resolveGlobals,
|
|
661
706
|
} = options;
|
|
662
707
|
|
|
663
708
|
const ignoreSet = new Set(ignoreSearchParams);
|
|
@@ -689,7 +734,9 @@ export function cmsRouteConfig(options: CmsRouteOptions) {
|
|
|
689
734
|
const searchStr = deps.search
|
|
690
735
|
? "?" + new URLSearchParams(deps.search as Record<string, string>).toString()
|
|
691
736
|
: "";
|
|
692
|
-
const page = await loadCmsPage({
|
|
737
|
+
const page = await loadCmsPage({
|
|
738
|
+
data: { path: basePath + searchStr, resolveGlobals },
|
|
739
|
+
});
|
|
693
740
|
if (!page) return page;
|
|
694
741
|
|
|
695
742
|
if (!isServer && page.resolvedSections) {
|
|
@@ -746,12 +793,21 @@ export function cmsHomeRouteConfig(options: {
|
|
|
746
793
|
pendingMs?: number;
|
|
747
794
|
/** Minimum display time (ms) for pending component. Default: 300. */
|
|
748
795
|
pendingMinMs?: number;
|
|
796
|
+
/** See `CmsRouteOptions.resolveGlobals` — default `true`. */
|
|
797
|
+
resolveGlobals?: boolean;
|
|
749
798
|
}) {
|
|
750
|
-
const {
|
|
799
|
+
const {
|
|
800
|
+
defaultTitle,
|
|
801
|
+
defaultDescription,
|
|
802
|
+
siteName,
|
|
803
|
+
pendingMs = 200,
|
|
804
|
+
pendingMinMs = 300,
|
|
805
|
+
resolveGlobals,
|
|
806
|
+
} = options;
|
|
751
807
|
|
|
752
808
|
return {
|
|
753
809
|
loader: async () => {
|
|
754
|
-
const page = await loadCmsHomePage();
|
|
810
|
+
const page = await loadCmsHomePage({ data: { resolveGlobals } });
|
|
755
811
|
if (!page) return page;
|
|
756
812
|
|
|
757
813
|
if (!isServer && page.resolvedSections) {
|
|
@@ -430,3 +430,53 @@ describe("POST /_cache/purge-loaders", () => {
|
|
|
430
430
|
expect(res.status).toBe(404);
|
|
431
431
|
});
|
|
432
432
|
});
|
|
433
|
+
|
|
434
|
+
describe("X-Cache-Segment — custom SegmentKey fields (#284)", () => {
|
|
435
|
+
afterEach(() => {
|
|
436
|
+
__resetKvHydrationStateForTests();
|
|
437
|
+
setBlocks({});
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
const HTML_SERVER_ENTRY = {
|
|
441
|
+
fetch: async (_req: Request) =>
|
|
442
|
+
new Response("<html>hi</html>", {
|
|
443
|
+
status: 200,
|
|
444
|
+
headers: { "content-type": "text/html; charset=utf-8" },
|
|
445
|
+
}),
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
it("includes a custom segment field in X-Cache-Segment instead of dropping it", async () => {
|
|
449
|
+
const worker = createDecoWorkerEntry(HTML_SERVER_ENTRY, {
|
|
450
|
+
observability: false,
|
|
451
|
+
buildSegment: () => ({ device: "desktop", regionId: "RJ", delivery: "store-42" }),
|
|
452
|
+
});
|
|
453
|
+
const res = await worker.fetch(new Request("https://example.com/"), EMPTY_ENV, MOCK_CTX);
|
|
454
|
+
expect(res.headers.get("X-Cache-Segment")).toBe("desktop|r=RJ|delivery=store-42");
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it("produces different X-Cache-Segment hashes for requests that only differ by a custom field", async () => {
|
|
458
|
+
let delivery = "store-1";
|
|
459
|
+
const worker = createDecoWorkerEntry(HTML_SERVER_ENTRY, {
|
|
460
|
+
observability: false,
|
|
461
|
+
buildSegment: () => ({ device: "desktop", delivery }),
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
const first = await worker.fetch(new Request("https://example.com/"), EMPTY_ENV, MOCK_CTX);
|
|
465
|
+
const firstSegment = first.headers.get("X-Cache-Segment");
|
|
466
|
+
|
|
467
|
+
delivery = "store-2";
|
|
468
|
+
const second = await worker.fetch(new Request("https://example.com/"), EMPTY_ENV, MOCK_CTX);
|
|
469
|
+
const secondSegment = second.headers.get("X-Cache-Segment");
|
|
470
|
+
|
|
471
|
+
expect(firstSegment).not.toBe(secondSegment);
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it("keeps the existing hash format for segments using only known fields (back-compat)", async () => {
|
|
475
|
+
const worker = createDecoWorkerEntry(HTML_SERVER_ENTRY, {
|
|
476
|
+
observability: false,
|
|
477
|
+
buildSegment: () => ({ device: "desktop", salesChannel: "1", flags: ["b", "a"] }),
|
|
478
|
+
});
|
|
479
|
+
const res = await worker.fetch(new Request("https://example.com/"), EMPTY_ENV, MOCK_CTX);
|
|
480
|
+
expect(res.headers.get("X-Cache-Segment")).toBe("desktop|sc=1|f=a,b");
|
|
481
|
+
});
|
|
482
|
+
});
|
package/src/sdk/workerEntry.ts
CHANGED
|
@@ -172,6 +172,13 @@ export interface SegmentKey {
|
|
|
172
172
|
regionId?: string;
|
|
173
173
|
/** Sorted list of active A/B flag names for cache cohort splitting. */
|
|
174
174
|
flags?: string[];
|
|
175
|
+
/**
|
|
176
|
+
* Custom cache dimensions beyond the known fields above (e.g. a
|
|
177
|
+
* store/CEP-based delivery selector). `hashSegment` serializes these
|
|
178
|
+
* automatically — unlike the known fields, there's no dedicated shorthand
|
|
179
|
+
* key, so don't reuse a name already declared above.
|
|
180
|
+
*/
|
|
181
|
+
[customField: string]: string | boolean | string[] | undefined;
|
|
175
182
|
}
|
|
176
183
|
|
|
177
184
|
/**
|
|
@@ -983,12 +990,34 @@ export function createDecoWorkerEntry(
|
|
|
983
990
|
return detectCacheProfile(target);
|
|
984
991
|
}
|
|
985
992
|
|
|
993
|
+
const KNOWN_SEGMENT_FIELDS = new Set([
|
|
994
|
+
"device",
|
|
995
|
+
"loggedIn",
|
|
996
|
+
"salesChannel",
|
|
997
|
+
"regionId",
|
|
998
|
+
"flags",
|
|
999
|
+
]);
|
|
1000
|
+
|
|
986
1001
|
function hashSegment(seg: SegmentKey): string {
|
|
987
1002
|
const parts: string[] = [seg.device];
|
|
988
1003
|
if (seg.loggedIn) parts.push("auth");
|
|
989
1004
|
if (seg.salesChannel) parts.push(`sc=${seg.salesChannel}`);
|
|
990
1005
|
if (seg.regionId) parts.push(`r=${seg.regionId}`);
|
|
991
1006
|
if (seg.flags?.length) parts.push(`f=${seg.flags.sort().join(",")}`);
|
|
1007
|
+
// Custom dimensions (anything beyond the known fields above) are
|
|
1008
|
+
// serialized generically so they aren't silently dropped from the
|
|
1009
|
+
// cache key — see #284.
|
|
1010
|
+
const customKeys = Object.keys(seg)
|
|
1011
|
+
.filter((k) => !KNOWN_SEGMENT_FIELDS.has(k) && seg[k] !== undefined)
|
|
1012
|
+
.sort();
|
|
1013
|
+
for (const k of customKeys) {
|
|
1014
|
+
const v = seg[k];
|
|
1015
|
+
if (v === "" || v === false || (Array.isArray(v) && v.length === 0)) {
|
|
1016
|
+
continue;
|
|
1017
|
+
}
|
|
1018
|
+
const serialized = Array.isArray(v) ? [...v].sort().join(",") : v === true ? "" : String(v);
|
|
1019
|
+
parts.push(serialized ? `${k}=${serialized}` : k);
|
|
1020
|
+
}
|
|
992
1021
|
return parts.join("|");
|
|
993
1022
|
}
|
|
994
1023
|
|