@decocms/start 0.23.0 → 0.23.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": "0.23.0",
3
+ "version": "0.23.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",
package/src/admin/cors.ts CHANGED
@@ -1,4 +1,4 @@
1
- const ADMIN_ORIGINS = [
1
+ const ADMIN_ORIGINS = new Set([
2
2
  "https://admin.deco.cx",
3
3
  "https://v0-admin.deco.cx",
4
4
  "https://play.deco.cx",
@@ -6,7 +6,24 @@ const ADMIN_ORIGINS = [
6
6
  "https://deco.chat",
7
7
  "https://admin.decocms.com",
8
8
  "https://decocms.com",
9
- ];
9
+ ]);
10
+
11
+ /**
12
+ * Register additional allowed admin origins.
13
+ * Useful for self-hosted admin UIs or custom dashboards.
14
+ */
15
+ export function registerAdminOrigin(origin: string): void {
16
+ ADMIN_ORIGINS.add(origin);
17
+ }
18
+
19
+ /**
20
+ * Register multiple additional allowed admin origins.
21
+ */
22
+ export function registerAdminOrigins(origins: string[]): void {
23
+ for (const origin of origins) {
24
+ ADMIN_ORIGINS.add(origin);
25
+ }
26
+ }
10
27
 
11
28
  export function isAdminOrLocalhost(request: Request): boolean {
12
29
  const origin = request.headers.get("origin") || request.headers.get("referer") || "";
@@ -15,7 +32,10 @@ export function isAdminOrLocalhost(request: Request): boolean {
15
32
  return true;
16
33
  }
17
34
 
18
- return ADMIN_ORIGINS.some((domain) => origin.startsWith(domain));
35
+ for (const domain of ADMIN_ORIGINS) {
36
+ if (origin.startsWith(domain)) return true;
37
+ }
38
+ return false;
19
39
  }
20
40
 
21
41
  export function corsHeaders(request: Request): Record<string, string> {
@@ -1,4 +1,4 @@
1
- export { corsHeaders, isAdminOrLocalhost } from "./cors";
1
+ export { corsHeaders, isAdminOrLocalhost, registerAdminOrigin, registerAdminOrigins } from "./cors";
2
2
  export { handleDecofileRead, handleDecofileReload } from "./decofile";
3
3
  export {
4
4
  handleInvoke,
package/src/admin/meta.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { djb2Hex } from "../sdk/djb2";
1
2
  import { composeMeta, type MetaResponse } from "./schema";
2
3
 
3
4
  let metaData: MetaResponse | null = null;
@@ -26,18 +27,13 @@ export function setMetaData(data: MetaResponse) {
26
27
 
27
28
  /**
28
29
  * Content-based hash for ETag.
29
- * Uses a simple DJB2-style hash over the serialised JSON so any
30
- * definition change results in a different ETag, forcing admin to
31
- * re-fetch rather than use stale cached meta.
30
+ * Uses DJB2 over the serialised JSON so any definition change
31
+ * results in a different ETag, forcing admin to re-fetch.
32
32
  */
33
33
  function getEtag(): string {
34
34
  if (!cachedEtag) {
35
35
  const str = JSON.stringify(metaData || {});
36
- let hash = 5381;
37
- for (let i = 0; i < str.length; i++) {
38
- hash = ((hash << 5) + hash + str.charCodeAt(i)) >>> 0;
39
- }
40
- cachedEtag = `"meta-${hash.toString(36)}"`;
36
+ cachedEtag = `"meta-${djb2Hex(str)}"`;
41
37
  }
42
38
  return cachedEtag;
43
39
  }
@@ -1,35 +1,14 @@
1
1
  import { createElement } from "react";
2
2
  import { loadBlocks, withBlocksOverride } from "../cms/loader";
3
3
  import { getSection } from "../cms/registry";
4
- import { resolveValue } from "../cms/resolve";
4
+ import { resolveValue, WELL_KNOWN_TYPES } from "../cms/resolve";
5
+ import { buildHtmlShell } from "../sdk/htmlShell";
5
6
  import { LIVE_CONTROLS_SCRIPT } from "./liveControls";
6
- import { getRenderShellConfig } from "./setup";
7
7
 
8
8
  export { setRenderShell } from "./setup";
9
9
 
10
10
  function wrapInHtmlShell(sectionHtml: string): string {
11
- const { cssHref, fontHrefs, themeName, bodyClass, htmlLang } = getRenderShellConfig();
12
- const stylesheets = [
13
- ...fontHrefs.map((href) => `<link rel="stylesheet" href="${href}" />`),
14
- cssHref ? `<link rel="stylesheet" href="${cssHref}" />` : "",
15
- ].join("\n ");
16
-
17
- const themeAttr = themeName ? ` data-theme="${themeName}"` : "";
18
- const langAttr = htmlLang ? ` lang="${htmlLang}"` : "";
19
- const bodyAttr = bodyClass ? ` class="${bodyClass}"` : "";
20
-
21
- return `<!DOCTYPE html>
22
- <html${langAttr}${themeAttr}>
23
- <head>
24
- <meta charset="utf-8" />
25
- <meta name="viewport" content="width=device-width, initial-scale=1" />
26
- ${stylesheets}
27
- <script>${LIVE_CONTROLS_SCRIPT}</script>
28
- </head>
29
- <body${bodyAttr}>
30
- ${sectionHtml}
31
- </body>
32
- </html>`;
11
+ return buildHtmlShell({ body: sectionHtml, script: LIVE_CONTROLS_SCRIPT });
33
12
  }
34
13
 
35
14
  /**
@@ -139,7 +118,7 @@ export async function handleRender(request: Request): Promise<Response> {
139
118
  }
140
119
 
141
120
  // Page compositor: resolve + render all child sections
142
- if (component === "website/pages/Page.tsx") {
121
+ if (component === WELL_KNOWN_TYPES.PAGE) {
143
122
  const rawSections = props.sections;
144
123
  const resolvedSections = await resolveValue(rawSections);
145
124
  const sectionsList = Array.isArray(resolvedSections)
@@ -21,9 +21,9 @@ export {
21
21
 
22
22
  let cssHref: string | null = null;
23
23
  let fontHrefs: string[] = [];
24
- let themeName = "light";
25
- let bodyClass = "bg-base-100 text-base-content";
26
- let htmlLang = "pt-BR";
24
+ let themeName = "";
25
+ let bodyClass = "";
26
+ let htmlLang = "en";
27
27
 
28
28
  export function setRenderShell(opts: {
29
29
  css?: string;
package/src/cms/index.ts CHANGED
@@ -38,6 +38,7 @@ export {
38
38
  evaluateMatcher,
39
39
  getAsyncRenderingConfig,
40
40
  onBeforeResolve,
41
+ registerBotPattern,
41
42
  registerCommerceLoader,
42
43
  registerCommerceLoaders,
43
44
  registerMatcher,
@@ -47,6 +48,7 @@ export {
47
48
  setAsyncRenderingConfig,
48
49
  setDanglingReferenceHandler,
49
50
  setResolveErrorHandler,
51
+ WELL_KNOWN_TYPES,
50
52
  } from "./resolve";
51
53
  export type { SectionLoaderFn } from "./sectionLoaders";
52
54
  export {
package/src/cms/loader.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import * as asyncHooks from "node:async_hooks";
2
+ import { djb2Hex } from "../sdk/djb2";
2
3
 
3
4
  export type Resolvable = {
4
5
  __resolveType?: string;
@@ -54,12 +55,7 @@ export function onChange(listener: ChangeListener) {
54
55
  // ---------------------------------------------------------------------------
55
56
 
56
57
  function computeRevision(blocks: Record<string, unknown>): string {
57
- const str = JSON.stringify(blocks);
58
- let hash = 5381;
59
- for (let i = 0; i < str.length; i++) {
60
- hash = ((hash << 5) + hash + str.charCodeAt(i)) >>> 0;
61
- }
62
- return hash.toString(36);
58
+ return djb2Hex(JSON.stringify(blocks));
63
59
  }
64
60
 
65
61
  // ---------------------------------------------------------------------------
@@ -83,7 +83,8 @@ export async function preloadSectionModule(
83
83
  if (mod.ErrorFallback) opts.errorFallback = mod.ErrorFallback;
84
84
  sectionOptions[resolveType] = opts;
85
85
  return opts;
86
- } catch {
86
+ } catch (e) {
87
+ console.warn(`[Registry] Failed to preload section module "${resolveType}":`, e);
87
88
  return existing;
88
89
  }
89
90
  }
@@ -125,8 +126,8 @@ export async function preloadSectionComponents(keys: string[]): Promise<void> {
125
126
  if (mod.LoadingFallback) opts.loadingFallback = mod.LoadingFallback;
126
127
  if (mod.ErrorFallback) opts.errorFallback = mod.ErrorFallback;
127
128
  sectionOptions[key] = opts;
128
- } catch {
129
- /* ignore will fall back to React.lazy */
129
+ } catch (e) {
130
+ console.warn(`[Registry] Failed to preload component "${key}":`, e);
130
131
  }
131
132
  }),
132
133
  );
@@ -8,6 +8,24 @@ if (!G.__deco) G.__deco = {};
8
8
  if (!G.__deco.commerceLoaders) G.__deco.commerceLoaders = {};
9
9
  if (!G.__deco.customMatchers) G.__deco.customMatchers = {};
10
10
 
11
+ // ---------------------------------------------------------------------------
12
+ // Well-known resolve types — extracted as constants so they're searchable
13
+ // and overridable. Consumers migrating from deco-cx/deco may have blocks
14
+ // with these __resolveType strings in their CMS JSON.
15
+ // ---------------------------------------------------------------------------
16
+
17
+ /** @internal */
18
+ export const WELL_KNOWN_TYPES = {
19
+ LAZY: "website/sections/Rendering/Lazy.tsx",
20
+ DEFERRED: "website/sections/Rendering/Deferred.tsx",
21
+ REQUEST_TO_PARAM: "website/functions/requestToParam.ts",
22
+ COMMERCE_EXT_DETAILS: "commerce/loaders/product/extensions/detailsPage.ts",
23
+ COMMERCE_EXT_LISTING: "commerce/loaders/product/extensions/listingPage.ts",
24
+ MULTIVARIATE: "website/flags/multivariate.ts",
25
+ MULTIVARIATE_SECTION: "website/flags/multivariate/section.ts",
26
+ PAGE: "website/pages/Page.tsx",
27
+ } as const;
28
+
11
29
  export type ResolvedSection = {
12
30
  component: string;
13
31
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -93,12 +111,21 @@ export function getAsyncRenderingConfig(): AsyncRenderingConfig | null {
93
111
  // Bot detection — bots always receive fully eager pages for SEO
94
112
  // ---------------------------------------------------------------------------
95
113
 
96
- const BOT_UA_RE =
97
- /bot|crawl|spider|slurp|facebookexternalhit|mediapartners|google|bing|yandex|baidu|duckduck|teoma|ia_archiver|semrush|ahrefs|lighthouse/i;
114
+ const botPatterns: RegExp[] = [
115
+ /bot|crawl|spider|slurp|facebookexternalhit|mediapartners|google|bing|yandex|baidu|duckduck|teoma|ia_archiver|semrush|ahrefs|lighthouse/i,
116
+ ];
117
+
118
+ /**
119
+ * Add a custom bot detection regex.
120
+ * Requests matching any bot pattern receive fully eager pages for SEO.
121
+ */
122
+ export function registerBotPattern(pattern: RegExp): void {
123
+ botPatterns.push(pattern);
124
+ }
98
125
 
99
126
  function isBot(userAgent?: string): boolean {
100
127
  if (!userAgent) return false;
101
- return BOT_UA_RE.test(userAgent);
128
+ return botPatterns.some((re) => re.test(userAgent));
102
129
  }
103
130
 
104
131
  export type CommerceLoader = (props: any) => Promise<any>;
@@ -187,6 +214,44 @@ export function registerMatcher(
187
214
  customMatchers[key] = fn;
188
215
  }
189
216
 
217
+ // ---------------------------------------------------------------------------
218
+ // Built-in matchers — registered through the same API as custom matchers
219
+ // ---------------------------------------------------------------------------
220
+
221
+ if (!G.__deco._builtinMatchersRegistered) {
222
+ G.__deco._builtinMatchersRegistered = true;
223
+
224
+ const builtinMatchers: Record<string, (rule: Record<string, unknown>, ctx: MatcherContext) => boolean> = {
225
+ "website/matchers/always.ts": () => true,
226
+ "$live/matchers/MatchAlways.ts": () => true,
227
+ "website/matchers/never.ts": () => false,
228
+ "website/matchers/device.ts": (rule, ctx) => {
229
+ const ua = (ctx.userAgent || "").toLowerCase();
230
+ const isMobile = /mobile|android|iphone|ipad|ipod|webos|blackberry|opera mini|iemobile/i.test(ua);
231
+ if (rule.mobile) return isMobile;
232
+ if (rule.desktop) return !isMobile;
233
+ return true;
234
+ },
235
+ "website/matchers/random.ts": (rule) => {
236
+ const traffic = typeof rule.traffic === "number" ? rule.traffic : 0.5;
237
+ return Math.random() < traffic;
238
+ },
239
+ "website/matchers/date.ts": (rule) => {
240
+ const now = Date.now();
241
+ const start = typeof rule.start === "string" ? new Date(rule.start).getTime() : 0;
242
+ const end = typeof rule.end === "string" ? new Date(rule.end).getTime() : Infinity;
243
+ return now >= start && now <= end;
244
+ },
245
+ };
246
+
247
+ for (const [key, fn] of Object.entries(builtinMatchers)) {
248
+ // Only register if not already overridden by consumer
249
+ if (!customMatchers[key]) {
250
+ customMatchers[key] = fn;
251
+ }
252
+ }
253
+ }
254
+
190
255
  // ---------------------------------------------------------------------------
191
256
  // Error handling
192
257
  // ---------------------------------------------------------------------------
@@ -253,49 +318,17 @@ export function evaluateMatcher(rule: Record<string, unknown> | undefined, ctx:
253
318
  );
254
319
  }
255
320
 
256
- switch (resolveType) {
257
- case "website/matchers/always.ts":
258
- case "$live/matchers/MatchAlways.ts":
259
- return true;
260
-
261
- case "website/matchers/never.ts":
262
- return false;
263
-
264
- case "website/matchers/device.ts": {
265
- const ua = (ctx.userAgent || "").toLowerCase();
266
- const isMobile = /mobile|android|iphone|ipad|ipod|webos|blackberry|opera mini|iemobile/i.test(
267
- ua,
268
- );
269
- if (rule.mobile) return isMobile;
270
- if (rule.desktop) return !isMobile;
271
- return true;
272
- }
273
-
274
- case "website/matchers/random.ts": {
275
- const traffic = typeof rule.traffic === "number" ? rule.traffic : 0.5;
276
- return Math.random() < traffic;
277
- }
278
-
279
- case "website/matchers/date.ts": {
280
- const now = Date.now();
281
- const start = typeof rule.start === "string" ? new Date(rule.start).getTime() : 0;
282
- const end = typeof rule.end === "string" ? new Date(rule.end).getTime() : Infinity;
283
- return now >= start && now <= end;
284
- }
285
-
286
- default: {
287
- const customMatcher = customMatchers[resolveType];
288
- if (customMatcher) {
289
- try {
290
- return customMatcher(rule, ctx);
291
- } catch {
292
- return false;
293
- }
294
- }
295
- console.warn(`[CMS] Unknown matcher: ${resolveType}, defaulting to false`);
321
+ const matcher = customMatchers[resolveType];
322
+ if (matcher) {
323
+ try {
324
+ return matcher(rule, ctx);
325
+ } catch {
296
326
  return false;
297
327
  }
298
328
  }
329
+
330
+ console.warn(`[CMS] Unknown matcher: ${resolveType}, defaulting to false`);
331
+ return false;
299
332
  }
300
333
 
301
334
  // ---------------------------------------------------------------------------
@@ -357,33 +390,33 @@ async function internalResolve(value: unknown, rctx: ResolveContext): Promise<un
357
390
  if (resolveType === "resolved") return obj.data ?? null;
358
391
 
359
392
  // Lazy section wrapper — unwrap single inner section
360
- if (resolveType === "website/sections/Rendering/Lazy.tsx") {
393
+ if (resolveType === WELL_KNOWN_TYPES.LAZY) {
361
394
  return obj.section ? internalResolve(obj.section, childCtx) : null;
362
395
  }
363
396
 
364
397
  // Deferred section wrapper (legacy Fresh/HTMX) — unwrap inner sections array
365
- if (resolveType === "website/sections/Rendering/Deferred.tsx") {
398
+ if (resolveType === WELL_KNOWN_TYPES.DEFERRED) {
366
399
  return obj.sections ? internalResolve(obj.sections, childCtx) : null;
367
400
  }
368
401
 
369
402
  // Request param extraction
370
- if (resolveType === "website/functions/requestToParam.ts") {
403
+ if (resolveType === WELL_KNOWN_TYPES.REQUEST_TO_PARAM) {
371
404
  const paramName = (obj as any).param as string;
372
405
  return rctx.routeParams?.[paramName] ?? null;
373
406
  }
374
407
 
375
408
  // Commerce extension wrappers — unwrap to inner data
376
409
  if (
377
- resolveType === "commerce/loaders/product/extensions/detailsPage.ts" ||
378
- resolveType === "commerce/loaders/product/extensions/listingPage.ts"
410
+ resolveType === WELL_KNOWN_TYPES.COMMERCE_EXT_DETAILS ||
411
+ resolveType === WELL_KNOWN_TYPES.COMMERCE_EXT_LISTING
379
412
  ) {
380
413
  return obj.data ? internalResolve(obj.data, childCtx) : null;
381
414
  }
382
415
 
383
416
  // Multivariate flags
384
417
  if (
385
- resolveType === "website/flags/multivariate.ts" ||
386
- resolveType === "website/flags/multivariate/section.ts"
418
+ resolveType === WELL_KNOWN_TYPES.MULTIVARIATE ||
419
+ resolveType === WELL_KNOWN_TYPES.MULTIVARIATE_SECTION
387
420
  ) {
388
421
  const variants = obj.variants as Array<{ value: unknown; rule?: unknown }> | undefined;
389
422
  if (!variants || variants.length === 0) return null;
@@ -631,7 +664,7 @@ function resolveFinalSectionKey(
631
664
  if (!rt) return null;
632
665
 
633
666
  // Lazy wrapper — unwrap single inner section
634
- if (rt === "website/sections/Rendering/Lazy.tsx") {
667
+ if (rt === WELL_KNOWN_TYPES.LAZY) {
635
668
  const inner = current.section;
636
669
  if (!inner || typeof inner !== "object") return null;
637
670
  current = inner as Record<string, unknown>;
@@ -655,8 +688,8 @@ function resolveFinalSectionKey(
655
688
  }
656
689
 
657
690
  if (
658
- rt === "website/flags/multivariate.ts" ||
659
- rt === "website/flags/multivariate/section.ts"
691
+ rt === WELL_KNOWN_TYPES.MULTIVARIATE ||
692
+ rt === WELL_KNOWN_TYPES.MULTIVARIATE_SECTION
660
693
  ) {
661
694
  const variants = current.variants as
662
695
  | Array<{ value: unknown; rule?: unknown }>
@@ -708,16 +741,16 @@ function isCmsDeferralWrapped(section: unknown, matcherCtx?: MatcherContext): bo
708
741
  if (!rt) return false;
709
742
 
710
743
  if (
711
- rt === "website/sections/Rendering/Lazy.tsx" ||
712
- rt === "website/sections/Rendering/Deferred.tsx"
744
+ rt === WELL_KNOWN_TYPES.LAZY ||
745
+ rt === WELL_KNOWN_TYPES.DEFERRED
713
746
  ) {
714
747
  return true;
715
748
  }
716
749
 
717
750
  // Walk through multivariate flags to check the matched variant
718
751
  if (
719
- rt === "website/flags/multivariate.ts" ||
720
- rt === "website/flags/multivariate/section.ts"
752
+ rt === WELL_KNOWN_TYPES.MULTIVARIATE ||
753
+ rt === WELL_KNOWN_TYPES.MULTIVARIATE_SECTION
721
754
  ) {
722
755
  const variants = current.variants as
723
756
  | Array<{ value: unknown; rule?: unknown }>
@@ -806,7 +839,7 @@ function resolveSectionShallow(
806
839
  if (SKIP_RESOLVE_TYPES.has(rt)) return null;
807
840
 
808
841
  // Lazy wrapper — unwrap to the inner section
809
- if (rt === "website/sections/Rendering/Lazy.tsx") {
842
+ if (rt === WELL_KNOWN_TYPES.LAZY) {
810
843
  const inner = current.section;
811
844
  if (!inner || typeof inner !== "object") return null;
812
845
  current = inner as Record<string, unknown>;
@@ -832,8 +865,8 @@ function resolveSectionShallow(
832
865
 
833
866
  // Multivariate flags — evaluate matchers and continue with matched variant
834
867
  if (
835
- rt === "website/flags/multivariate.ts" ||
836
- rt === "website/flags/multivariate/section.ts"
868
+ rt === WELL_KNOWN_TYPES.MULTIVARIATE ||
869
+ rt === WELL_KNOWN_TYPES.MULTIVARIATE_SECTION
837
870
  ) {
838
871
  const variants = current.variants as
839
872
  | Array<{ value: unknown; rule?: unknown }>
@@ -897,7 +930,7 @@ async function resolveSectionsList(
897
930
  if (!rt) return [];
898
931
 
899
932
  // Multivariate flags — evaluate matchers and recurse into matched variant
900
- if (rt === "website/flags/multivariate.ts" || rt === "website/flags/multivariate/section.ts") {
933
+ if (rt === WELL_KNOWN_TYPES.MULTIVARIATE || rt === WELL_KNOWN_TYPES.MULTIVARIATE_SECTION) {
901
934
  const variants = obj.variants as Array<{ value: unknown; rule?: unknown }> | undefined;
902
935
  if (!variants?.length) return [];
903
936
  for (const variant of variants) {
@@ -8,6 +8,7 @@
8
8
  * This runs AFTER resolveDecoPage and BEFORE React rendering,
9
9
  * inside the TanStack Start server function.
10
10
  */
11
+ import { djb2 } from "../sdk/djb2";
11
12
  import type { ResolvedSection } from "./resolve";
12
13
 
13
14
  export type SectionLoaderFn = (
@@ -51,16 +52,8 @@ function evictSectionCacheIfNeeded() {
51
52
  for (const [key] of toDelete) sectionLoaderCache.delete(key);
52
53
  }
53
54
 
54
- function djb2Hash(str: string): number {
55
- let hash = 5381;
56
- for (let i = 0; i < str.length; i++) {
57
- hash = ((hash << 5) + hash + str.charCodeAt(i)) | 0;
58
- }
59
- return hash >>> 0;
60
- }
61
-
62
55
  function sectionCacheKey(component: string, props: Record<string, unknown>): string {
63
- return `${component}::${djb2Hash(JSON.stringify(props))}`;
56
+ return `${component}::${djb2(JSON.stringify(props))}`;
64
57
  }
65
58
 
66
59
  /**
@@ -18,6 +18,7 @@ import {
18
18
  setResolvedComponent,
19
19
  } from "../cms/registry";
20
20
  import type { DeferredSection, ResolvedSection } from "../cms/resolve";
21
+ import { djb2Hex } from "../sdk/djb2";
21
22
  import { SectionErrorBoundary } from "./SectionErrorFallback";
22
23
 
23
24
  type LazyComponent = ReturnType<typeof lazy>;
@@ -106,14 +107,6 @@ function getCachedDeferredSection(stableKey: string): ResolvedSection | null {
106
107
  return entry.section;
107
108
  }
108
109
 
109
- /** Fast DJB2 hash for cache key differentiation. */
110
- function djb2(str: string): string {
111
- let hash = 5381;
112
- for (let i = 0; i < str.length; i++) {
113
- hash = ((hash << 5) + hash + str.charCodeAt(i)) | 0;
114
- }
115
- return (hash >>> 0).toString(36);
116
- }
117
110
 
118
111
  const DEFERRED_FADE_CSS = `@keyframes decoFadeIn{from{opacity:0}to{opacity:1}}`;
119
112
 
@@ -228,7 +221,7 @@ function DeferredSectionWrapper({
228
221
  errorFallback,
229
222
  loadFn,
230
223
  }: DeferredSectionWrapperProps) {
231
- const propsHash = djb2(JSON.stringify(deferred.rawProps));
224
+ const propsHash = djb2Hex(JSON.stringify(deferred.rawProps));
232
225
  const stableKey = `${pagePath}::${deferred.component}::${deferred.index}::${propsHash}`;
233
226
  const [section, setSection] = useState<ResolvedSection | null>(() =>
234
227
  typeof document === "undefined" ? null : getCachedDeferredSection(stableKey),
@@ -87,3 +87,18 @@ export function resolveCountryCode(name: string): string {
87
87
  // Assume it's already an ISO code
88
88
  return name.toUpperCase();
89
89
  }
90
+
91
+ /**
92
+ * Register an additional country name → ISO code mapping at runtime.
93
+ * Useful for site-specific CMS values not covered by the built-in list.
94
+ */
95
+ export function registerCountryMapping(name: string, code: string): void {
96
+ COUNTRY_NAME_TO_CODE[name] = code;
97
+ }
98
+
99
+ /**
100
+ * Register multiple country name → ISO code mappings at once.
101
+ */
102
+ export function registerCountryMappings(mappings: Record<string, string>): void {
103
+ Object.assign(COUNTRY_NAME_TO_CODE, mappings);
104
+ }
@@ -128,6 +128,20 @@ export function getCacheProfileConfig(profile: CacheProfile): CacheHeadersConfig
128
128
  return PROFILES[profile];
129
129
  }
130
130
 
131
+ /**
132
+ * Override the default TTLs for a cache profile.
133
+ * Useful when the built-in values don't match your site's
134
+ * freshness requirements (e.g., longer product TTL for low-traffic stores).
135
+ *
136
+ * @example
137
+ * ```ts
138
+ * setCacheProfileConfig("product", { maxAge: 120, sMaxAge: 600, staleWhileRevalidate: 7200, isPublic: true });
139
+ * ```
140
+ */
141
+ export function setCacheProfileConfig(profile: CacheProfile, config: CacheHeadersConfig): void {
142
+ PROFILES[profile] = config;
143
+ }
144
+
131
145
  // ---------------------------------------------------------------------------
132
146
  // Client-side route cache defaults (TanStack Router staleTime / gcTime)
133
147
  // ---------------------------------------------------------------------------
@@ -173,6 +187,18 @@ export function routeCacheDefaults(profile: CacheProfile): RouteCacheDefaults {
173
187
  return ROUTE_CACHE[profile];
174
188
  }
175
189
 
190
+ /**
191
+ * Override the client-side route cache defaults for a profile.
192
+ *
193
+ * @example
194
+ * ```ts
195
+ * setRouteCacheDefaults("product", { staleTime: 120_000, gcTime: 10 * 60_000 });
196
+ * ```
197
+ */
198
+ export function setRouteCacheDefaults(profile: CacheProfile, defaults: RouteCacheDefaults): void {
199
+ ROUTE_CACHE[profile] = defaults;
200
+ }
201
+
176
202
  // ---------------------------------------------------------------------------
177
203
  // URL-based cache profile detection
178
204
  // ---------------------------------------------------------------------------
@@ -0,0 +1,20 @@
1
+ /**
2
+ * DJB2 hash — a fast, non-cryptographic hash function.
3
+ *
4
+ * Used for ETags, cache keys, and content fingerprinting throughout the framework.
5
+ * Produces consistent unsigned 32-bit integers.
6
+ */
7
+
8
+ /** Compute a DJB2 hash and return the raw unsigned 32-bit integer. */
9
+ export function djb2(str: string): number {
10
+ let hash = 5381;
11
+ for (let i = 0; i < str.length; i++) {
12
+ hash = ((hash << 5) + hash + str.charCodeAt(i)) | 0;
13
+ }
14
+ return hash >>> 0;
15
+ }
16
+
17
+ /** Compute a DJB2 hash and return a base-36 string. */
18
+ export function djb2Hex(str: string): string {
19
+ return djb2(str).toString(36);
20
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Shared HTML shell builder for admin preview iframes and render endpoints.
3
+ *
4
+ * Both `workerEntry.ts` (preview shell) and `admin/render.ts` (section render)
5
+ * need to produce an HTML document with the site's CSS, fonts, and theme.
6
+ * This module provides a single implementation to keep them consistent.
7
+ */
8
+
9
+ import { getRenderShellConfig } from "../admin/setup";
10
+
11
+ export interface HtmlShellOptions {
12
+ /** Content to inject into <body>. */
13
+ body?: string;
14
+ /** Inline <script> content to inject in <head>. */
15
+ script?: string;
16
+ }
17
+
18
+ /**
19
+ * Build a complete HTML shell using the current render config
20
+ * (set via `setRenderShell()` in the site's setup.ts).
21
+ */
22
+ export function buildHtmlShell(options: HtmlShellOptions = {}): string {
23
+ const { cssHref, fontHrefs, themeName, bodyClass, htmlLang } = getRenderShellConfig();
24
+
25
+ const themeAttr = themeName ? ` data-theme="${themeName}"` : "";
26
+ const langAttr = htmlLang ? ` lang="${htmlLang}"` : "";
27
+ const bodyAttr = bodyClass ? ` class="${bodyClass}"` : "";
28
+
29
+ const stylesheets = [
30
+ ...fontHrefs.map((href) => `<link rel="stylesheet" href="${href}" />`),
31
+ cssHref ? `<link rel="stylesheet" href="${cssHref}" />` : "",
32
+ ]
33
+ .filter(Boolean)
34
+ .join("\n ");
35
+
36
+ const scriptTag = options.script ? `<script>${options.script}</script>` : "";
37
+
38
+ const bodyContent = options.body ?? `<div id="preview-root" style="display:flex;align-items:center;justify-content:center;min-height:100vh;font-family:system-ui;color:#666;">
39
+ Loading preview...
40
+ </div>`;
41
+
42
+ return `<!DOCTYPE html>
43
+ <html${langAttr}${themeAttr}>
44
+ <head>
45
+ <meta charset="utf-8" />
46
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
47
+ <title>Preview</title>
48
+ ${stylesheets}
49
+ ${scriptTag}
50
+ </head>
51
+ <body${bodyAttr}>
52
+ ${bodyContent}
53
+ </body>
54
+ </html>`;
55
+ }
package/src/sdk/index.ts CHANGED
@@ -14,6 +14,8 @@ export {
14
14
  getCacheProfileConfig,
15
15
  registerCachePattern,
16
16
  routeCacheDefaults,
17
+ setCacheProfileConfig,
18
+ setRouteCacheDefaults,
17
19
  } from "./cacheHeaders";
18
20
  export { clx } from "./clx";
19
21
  export { decodeCookie, deleteCookie, getCookie, getServerSideCookie, setCookie } from "./cookie";
@@ -39,6 +41,7 @@ export {
39
41
  parseRedirectsCsv,
40
42
  type Redirect,
41
43
  type RedirectMap,
44
+ registerRedirectResolveType,
42
45
  } from "./redirects";
43
46
  export { RequestContext, type RequestContextData } from "./requestContext";
44
47
  export { createServerTimings, type ServerTimings } from "./serverTimings";
@@ -47,14 +50,21 @@ export {
47
50
  canonicalUrl,
48
51
  cleanPathForCacheKey,
49
52
  hasTrackingParams,
53
+ registerTrackingParam,
54
+ registerTrackingParams,
50
55
  stripTrackingParams,
51
56
  } from "./urlUtils";
57
+ export { djb2, djb2Hex } from "./djb2";
58
+ export { buildHtmlShell, type HtmlShellOptions } from "./htmlShell";
52
59
  export {
53
60
  checkDesktop,
54
61
  checkMobile,
55
62
  checkTablet,
56
63
  type Device,
57
64
  detectDevice,
65
+ isMobileUA,
66
+ MOBILE_RE,
67
+ TABLET_RE,
58
68
  useDevice,
59
69
  } from "./useDevice";
60
70
  export { useId } from "./useId";
@@ -66,6 +66,14 @@ const REDIRECT_RESOLVE_TYPES = new Set([
66
66
  "deco-sites/std/loaders/x/redirects.ts",
67
67
  ]);
68
68
 
69
+ /**
70
+ * Register additional __resolveType strings that should be treated as redirect blocks.
71
+ * Useful for custom redirect loaders.
72
+ */
73
+ export function registerRedirectResolveType(resolveType: string): void {
74
+ REDIRECT_RESOLVE_TYPES.add(resolveType);
75
+ }
76
+
69
77
  /**
70
78
  * Load all redirect definitions from CMS blocks.
71
79
  *
@@ -52,7 +52,8 @@ export interface RequestContextData {
52
52
 
53
53
  const storage = new AsyncLocalStorage<RequestContextData>();
54
54
 
55
- const MOBILE_RE = /mobile|android|iphone|ipad|ipod|webos|blackberry|opera mini|iemobile/i;
55
+ import { isMobileUA } from "./useDevice";
56
+
56
57
  const BOT_RE =
57
58
  /bot|crawl|spider|slurp|bingpreview|facebookexternalhit|linkedinbot|twitterbot|whatsapp|telegram|googlebot|yandex|baidu|duckduck/i;
58
59
 
@@ -126,7 +127,7 @@ export const RequestContext = {
126
127
  if (!ctx) return "desktop";
127
128
  if (ctx._device) return ctx._device;
128
129
  const ua = ctx.request.headers.get("user-agent") ?? "";
129
- ctx._device = MOBILE_RE.test(ua) ? "mobile" : "desktop";
130
+ ctx._device = isMobileUA(ua) ? "mobile" : "desktop";
130
131
  return ctx._device;
131
132
  },
132
133
 
@@ -47,6 +47,17 @@ export interface SitemapOptions {
47
47
  maxEntries?: number;
48
48
  }
49
49
 
50
+ export interface CMSSitemapOptions {
51
+ /** Default changefreq for non-home pages. @default "weekly" */
52
+ defaultChangefreq?: SitemapEntry["changefreq"];
53
+ /** Default priority for non-home pages (0.0–1.0). @default 0.7 */
54
+ defaultPriority?: number;
55
+ /** Changefreq for the home page. @default "daily" */
56
+ homeChangefreq?: SitemapEntry["changefreq"];
57
+ /** Priority for the home page (0.0–1.0). @default 1.0 */
58
+ homePriority?: number;
59
+ }
60
+
50
61
  // -------------------------------------------------------------------------
51
62
  // CMS page entries
52
63
  // -------------------------------------------------------------------------
@@ -57,22 +68,28 @@ export interface SitemapOptions {
57
68
  * Reads all pages from the block store and generates URLs from their
58
69
  * path patterns (excluding wildcard-only patterns like `/*`).
59
70
  */
60
- export function getCMSSitemapEntries(origin: string): SitemapEntry[] {
71
+ export function getCMSSitemapEntries(origin: string, options?: CMSSitemapOptions): SitemapEntry[] {
61
72
  const pages = getAllPages();
62
73
  const entries: SitemapEntry[] = [];
63
74
  const today = new Date().toISOString().split("T")[0];
64
75
 
76
+ const defaultChangefreq = options?.defaultChangefreq ?? "weekly";
77
+ const defaultPriority = options?.defaultPriority ?? 0.7;
78
+ const homeChangefreq = options?.homeChangefreq ?? "daily";
79
+ const homePriority = options?.homePriority ?? 1.0;
80
+
65
81
  for (const { page } of pages) {
66
82
  if (!page.path) continue;
67
83
 
68
84
  if (page.path.includes("*") || page.path.includes(":")) continue;
69
85
 
70
- const loc = `${origin}${page.path === "/" ? "" : page.path}`;
86
+ const isHome = page.path === "/";
87
+ const loc = `${origin}${isHome ? "" : page.path}`;
71
88
  entries.push({
72
89
  loc: loc || origin,
73
90
  lastmod: today,
74
- changefreq: page.path === "/" ? "daily" : "weekly",
75
- priority: page.path === "/" ? 1.0 : 0.7,
91
+ changefreq: isHome ? homeChangefreq : defaultChangefreq,
92
+ priority: isHome ? homePriority : defaultPriority,
76
93
  });
77
94
  }
78
95
 
@@ -27,6 +27,23 @@ const UTM_PARAMS = new Set([
27
27
  "srsltid",
28
28
  ]);
29
29
 
30
+ /**
31
+ * Register additional tracking parameters to strip from cache keys.
32
+ * Useful for proprietary attribution params (e.g., custom analytics tags).
33
+ */
34
+ export function registerTrackingParam(param: string): void {
35
+ UTM_PARAMS.add(param.toLowerCase());
36
+ }
37
+
38
+ /**
39
+ * Register multiple additional tracking parameters at once.
40
+ */
41
+ export function registerTrackingParams(params: string[]): void {
42
+ for (const param of params) {
43
+ UTM_PARAMS.add(param.toLowerCase());
44
+ }
45
+ }
46
+
30
47
  /**
31
48
  * Strip UTM and tracking parameters from a URL.
32
49
  *
@@ -28,8 +28,17 @@ export type Device = "mobile" | "tablet" | "desktop";
28
28
  // Android phones include "Mobile" in their UA; Android tablets do not.
29
29
  // Check TABLET_RE first so `android(?!.*mobile)` captures tablets before
30
30
  // the MOBILE_RE `android.*mobile` branch matches phones.
31
- const MOBILE_RE = /mobile|android.*mobile|iphone|ipod|webos|blackberry|opera mini|iemobile/i;
32
- const TABLET_RE = /ipad|tablet|kindle|silk|playbook|android(?!.*mobile)/i;
31
+ export const MOBILE_RE = /mobile|android.*mobile|iphone|ipod|webos|blackberry|opera mini|iemobile/i;
32
+ export const TABLET_RE = /ipad|tablet|kindle|silk|playbook|android(?!.*mobile)/i;
33
+
34
+ /**
35
+ * Simple mobile-or-not check (mobile + tablet = true).
36
+ * Use this for cache key splitting or any context where you
37
+ * only need a mobile/desktop binary decision.
38
+ */
39
+ export function isMobileUA(userAgent: string): boolean {
40
+ return MOBILE_RE.test(userAgent) || TABLET_RE.test(userAgent);
41
+ }
33
42
 
34
43
  /**
35
44
  * Detect device type from a User-Agent string.
@@ -25,14 +25,15 @@
25
25
  * ```
26
26
  */
27
27
 
28
- import { getRenderShellConfig } from "../admin/setup";
29
28
  import {
30
29
  type CacheProfile,
31
30
  cacheHeaders,
32
31
  detectCacheProfile,
33
32
  getCacheProfileConfig,
34
33
  } from "./cacheHeaders";
34
+ import { buildHtmlShell } from "./htmlShell";
35
35
  import { cleanPathForCacheKey } from "./urlUtils";
36
+ import { isMobileUA } from "./useDevice";
36
37
 
37
38
  // ---------------------------------------------------------------------------
38
39
  // Types
@@ -244,34 +245,7 @@ const PREVIEW_SHELL_SCRIPT = `(function() {
244
245
  })();`;
245
246
 
246
247
  function buildPreviewShell(): string {
247
- const { cssHref, fontHrefs, themeName, bodyClass, htmlLang } = getRenderShellConfig();
248
-
249
- const themeAttr = themeName ? ` data-theme="${themeName}"` : "";
250
- const langAttr = htmlLang ? ` lang="${htmlLang}"` : "";
251
- const bodyAttr = bodyClass ? ` class="${bodyClass}"` : "";
252
-
253
- const stylesheets = [
254
- ...fontHrefs.map((href) => `<link rel="stylesheet" href="${href}" />`),
255
- cssHref ? `<link rel="stylesheet" href="${cssHref}" />` : "",
256
- ]
257
- .filter(Boolean)
258
- .join("\n ");
259
-
260
- return `<!DOCTYPE html>
261
- <html${langAttr}${themeAttr}>
262
- <head>
263
- <meta charset="utf-8" />
264
- <meta name="viewport" content="width=device-width, initial-scale=1" />
265
- <title>Preview</title>
266
- ${stylesheets}
267
- <script>${PREVIEW_SHELL_SCRIPT}</script>
268
- </head>
269
- <body${bodyAttr}>
270
- <div id="preview-root" style="display:flex;align-items:center;justify-content:center;min-height:100vh;font-family:system-ui;color:#666;">
271
- Loading preview...
272
- </div>
273
- </body>
274
- </html>`;
248
+ return buildHtmlShell({ script: PREVIEW_SHELL_SCRIPT });
275
249
  }
276
250
 
277
251
  // ---------------------------------------------------------------------------
@@ -316,7 +290,6 @@ export function injectGeoCookies(request: Request): Request {
316
290
  return new Request(request, { headers });
317
291
  }
318
292
 
319
- const MOBILE_RE = /mobile|android|iphone|ipad|ipod/i;
320
293
  const ONE_YEAR = 31536000;
321
294
 
322
295
  const DEFAULT_BYPASS_PATHS = ["/_server", "/_build", "/deco/", "/live/", "/.decofile"];
@@ -433,7 +406,7 @@ export function createDecoWorkerEntry(
433
406
  }
434
407
 
435
408
  if (deviceSpecificKeys) {
436
- const device = MOBILE_RE.test(request.headers.get("user-agent") ?? "") ? "mobile" : "desktop";
409
+ const device = isMobileUA(request.headers.get("user-agent") ?? "") ? "mobile" : "desktop";
437
410
  url.searchParams.set("__cf_device", device);
438
411
  }
439
412