@decocms/start 0.25.0 → 0.25.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/start",
3
- "version": "0.25.0",
3
+ "version": "0.25.2",
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/cms/index.ts CHANGED
@@ -3,6 +3,7 @@ export {
3
3
  findPageByPath,
4
4
  getAllPages,
5
5
  getRevision,
6
+ getSiteSeo,
6
7
  loadBlocks,
7
8
  onChange,
8
9
  setBlocks,
package/src/cms/loader.ts CHANGED
@@ -161,6 +161,31 @@ function matchPath(pattern: string, urlPath: string): Record<string, string> | n
161
161
  return params;
162
162
  }
163
163
 
164
+ /**
165
+ * Extract the site-wide SEO config from the "Site" app block.
166
+ *
167
+ * In the original deco-cx/deco framework this is `ctx.seo` — the app-level
168
+ * SEO configuration that provides fallback title, description, and templates
169
+ * when page-level seo blocks don't supply them.
170
+ */
171
+ export function getSiteSeo(): {
172
+ title?: string;
173
+ description?: string;
174
+ titleTemplate?: string;
175
+ descriptionTemplate?: string;
176
+ image?: string;
177
+ favicon?: string;
178
+ themeColor?: string;
179
+ noIndexing?: boolean;
180
+ } {
181
+ const blocks = loadBlocks();
182
+ const site = blocks["Site"] as Record<string, unknown> | undefined;
183
+ if (!site) return {};
184
+ const seo = site.seo as Record<string, unknown> | undefined;
185
+ if (!seo) return {};
186
+ return seo as ReturnType<typeof getSiteSeo>;
187
+ }
188
+
164
189
  export function findPageByPath(
165
190
  targetPath: string,
166
191
  ): { page: DecoPage; params: Record<string, string> } | null {
@@ -37,6 +37,7 @@ import {
37
37
  resolveDecoPage,
38
38
  resolveDeferredSection,
39
39
  } from "../cms/resolve";
40
+ import { getSiteSeo } from "../cms/loader";
40
41
  import { runSectionLoaders, runSingleSectionLoader } from "../cms/sectionLoaders";
41
42
  import {
42
43
  type CacheProfile,
@@ -269,7 +270,19 @@ async function buildPageSeo(
269
270
  // Secondary source: SEO sections embedded in the sections array
270
271
  const sectionSeo = extractSeoFromSections(enrichedSections);
271
272
 
272
- if (!seoSection) return sectionSeo;
273
+ // Site-wide SEO config from the "Site" app block — mirrors ctx.seo in
274
+ // the original deco-cx/deco framework. Provides fallback title,
275
+ // description, and templates when page-level seo doesn't supply them.
276
+ const siteSeo = getSiteSeo();
277
+
278
+ if (!seoSection) {
279
+ // No page.seo block — use site-wide SEO as primary, section-contributed as secondary
280
+ const merged: PageSeo = { ...sectionSeo };
281
+ if (siteSeo.title && !merged.title) merged.title = siteSeo.title;
282
+ if (siteSeo.description && !merged.description) merged.description = siteSeo.description;
283
+ if (siteSeo.image && !merged.image) merged.image = siteSeo.image;
284
+ return merged;
285
+ }
273
286
 
274
287
  // Run the section loader on the seo section if one is registered
275
288
  // (e.g., SEOPDP loader transforms {jsonLD: ProductDetailsPage} → {title, description, ...})
@@ -283,16 +296,28 @@ async function buildPageSeo(
283
296
 
284
297
  const pageSeo = extractSeoFromProps(enrichedProps);
285
298
 
286
- // Apply title/description templates from the SEO block config.
287
- // SeoV2 blocks carry templates like "%s | CASA & VIDEO" that wrap
288
- // the computed title. Template "%s" is a no-op.
299
+ // Replicate original SeoV2 loader logic: `_title ?? appTitle`
300
+ // When the page's seo block doesn't have a title/description,
301
+ // fall back to the Site app's seo config.
302
+ if (!pageSeo.title && siteSeo.title) pageSeo.title = siteSeo.title;
303
+ if (!pageSeo.description && siteSeo.description) pageSeo.description = siteSeo.description;
304
+ if (!pageSeo.image && siteSeo.image) pageSeo.image = siteSeo.image;
305
+
306
+ // Apply title/description templates.
307
+ // Priority: page-level template → site-level template → no-op.
308
+ // This mirrors the original: `(titleTemplate ?? "").trim().length === 0 ? "%s" : titleTemplate`
289
309
  const rawProps = seoSection.props;
290
- const titleTemplate = rawProps.titleTemplate as string | undefined;
291
- const descTemplate = rawProps.descriptionTemplate as string | undefined;
292
- if (titleTemplate && titleTemplate !== "%s" && pageSeo.title) {
310
+ const titleTemplate =
311
+ effectiveTemplate(rawProps.titleTemplate as string | undefined) ??
312
+ effectiveTemplate(siteSeo.titleTemplate);
313
+ const descTemplate =
314
+ effectiveTemplate(rawProps.descriptionTemplate as string | undefined) ??
315
+ effectiveTemplate(siteSeo.descriptionTemplate);
316
+
317
+ if (titleTemplate && pageSeo.title) {
293
318
  pageSeo.title = titleTemplate.replace("%s", pageSeo.title);
294
319
  }
295
- if (descTemplate && descTemplate !== "%s" && pageSeo.description) {
320
+ if (descTemplate && pageSeo.description) {
296
321
  pageSeo.description = descTemplate.replace("%s", pageSeo.description);
297
322
  }
298
323
 
@@ -301,6 +326,12 @@ async function buildPageSeo(
301
326
  return { ...sectionSeo, ...pageSeo };
302
327
  }
303
328
 
329
+ /** Returns a non-trivial template string, or undefined for "%s" / empty / blank. */
330
+ function effectiveTemplate(tmpl: string | undefined): string | undefined {
331
+ if (!tmpl || tmpl.trim() === "" || tmpl.trim() === "%s") return undefined;
332
+ return tmpl;
333
+ }
334
+
304
335
  // ---------------------------------------------------------------------------
305
336
  // Head metadata builder
306
337
  // ---------------------------------------------------------------------------
@@ -722,6 +722,20 @@ export function createDecoWorkerEntry(
722
722
  const v = (env[cacheVersionEnv] as string) || "";
723
723
  if (v) hit.headers.set("X-Cache-Version", v);
724
724
  }
725
+ // Restore client-facing Cache-Control (the stored version uses sMaxAge
726
+ // as max-age for Cache API TTL, which would leak to the CDN auto-cache
727
+ // and cause stale HTML after deploys — the CDN caches under the raw URL,
728
+ // bypassing BUILD_HASH versioned keys).
729
+ const hitProfile = getProfile(url);
730
+ const hitHeaders = cacheHeaders(hitProfile);
731
+ for (const [k, v] of Object.entries(hitHeaders)) {
732
+ hit.headers.set(k, v);
733
+ }
734
+ // Prevent CDN from auto-caching this response under the raw URL.
735
+ // The Worker manages edge caching via caches.default with versioned keys;
736
+ // CDN auto-caching would bypass that versioning and serve stale HTML
737
+ // after deploys (referencing old CSS/JS fingerprinted filenames).
738
+ hit.headers.set("CDN-Cache-Control", "no-store");
725
739
  return hit;
726
740
  }
727
741
  } catch {
@@ -785,12 +799,21 @@ export function createDecoWorkerEntry(
785
799
  if (v) toReturn.headers.set("X-Cache-Version", v);
786
800
  }
787
801
 
802
+ // Prevent CDN from auto-caching this response under the raw URL.
803
+ // Edge caching is managed by the Worker via caches.default with
804
+ // BUILD_HASH-versioned keys; CDN auto-caching would bypass versioning
805
+ // and serve stale HTML after deploys.
806
+ toReturn.headers.set("CDN-Cache-Control", "no-store");
807
+
788
808
  // For Cache API storage, use sMaxAge as max-age since the Cache API
789
809
  // ignores s-maxage and only respects max-age for TTL decisions.
790
810
  if (cache) {
791
811
  try {
792
812
  const toStore = toReturn.clone();
793
813
  toStore.headers.set("Cache-Control", `public, max-age=${profileConfig.sMaxAge}`);
814
+ // Remove CDN-Cache-Control from stored version — it's only needed
815
+ // on the response to the client to prevent CDN auto-caching.
816
+ toStore.headers.delete("CDN-Cache-Control");
794
817
  ctx.waitUntil(cache.put(cacheKey, toStore));
795
818
  } catch {
796
819
  // Cache API unavailable — skip storing