@decocms/start 1.6.3 → 2.0.0

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/.releaserc.json CHANGED
@@ -4,6 +4,7 @@
4
4
  ["@semantic-release/commit-analyzer", {
5
5
  "preset": "angular",
6
6
  "releaseRules": [
7
+ { "breaking": true, "release": "major" },
7
8
  { "type": "feat", "release": "minor" },
8
9
  { "type": "fix", "release": "patch" },
9
10
  { "type": "perf", "release": "patch" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/start",
3
- "version": "1.6.3",
3
+ "version": "2.0.0",
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",
@@ -5,23 +5,31 @@
5
5
  *
6
6
  * Each loader/action file that exports a default function gets a generated
7
7
  * entry like:
8
- * "site/loaders/SAP/getUser": async (props) => {
8
+ * "site/loaders/SAP/getUser": async (props, request) => {
9
9
  * const mod = await import("../../loaders/SAP/getUser");
10
- * return mod.default(props);
10
+ * return mod.default(props, request);
11
11
  * },
12
12
  *
13
13
  * Both keyed with and without `.ts` suffix for CMS block compatibility.
14
14
  *
15
15
  * Files listed in --exclude are skipped (they need custom wiring in setup.ts).
16
16
  *
17
+ * CMS-aware filtering (`--decofile-dir`): when supplied, the script walks
18
+ * every JSON file in the directory and collects the set of `__resolveType`
19
+ * references. Only loaders whose key appears in that set are emitted —
20
+ * keeping the registry to what the site actually uses and avoiding the
21
+ * "200 dead passthroughs" pattern.
22
+ *
17
23
  * Usage (from site root):
18
24
  * npx tsx node_modules/@decocms/start/scripts/generate-loaders.ts
25
+ * npx tsx node_modules/@decocms/start/scripts/generate-loaders.ts --decofile-dir .deco/blocks
19
26
  *
20
27
  * CLI:
21
- * --loaders-dir override loaders input (default: src/loaders)
22
- * --actions-dir override actions input (default: src/actions)
23
- * --out-file override output (default: src/server/cms/loaders.gen.ts)
24
- * --exclude comma-separated list of loader keys to skip (they have custom wiring)
28
+ * --loaders-dir override loaders input (default: src/loaders)
29
+ * --actions-dir override actions input (default: src/actions)
30
+ * --out-file override output (default: src/server/cms/loaders.gen.ts)
31
+ * --exclude comma-separated list of loader keys to skip (they have custom wiring)
32
+ * --decofile-dir if provided, only emit entries whose key appears as `__resolveType` in any JSON
25
33
  */
26
34
  import fs from "node:fs";
27
35
  import path from "node:path";
@@ -37,6 +45,8 @@ const actionsDir = path.resolve(process.cwd(), arg("actions-dir", "src/actions")
37
45
  const outFile = path.resolve(process.cwd(), arg("out-file", "src/server/cms/loaders.gen.ts"));
38
46
  const excludeRaw = arg("exclude", "");
39
47
  const excludeSet = new Set(excludeRaw.split(",").map((s) => s.trim()).filter(Boolean));
48
+ const decofileDirRaw = arg("decofile-dir", "");
49
+ const decofileDir = decofileDirRaw ? path.resolve(process.cwd(), decofileDirRaw) : null;
40
50
 
41
51
  function walkDir(dir: string): string[] {
42
52
  const results: string[] = [];
@@ -68,6 +78,48 @@ function hasDefaultExport(content: string): boolean {
68
78
  return /export\s+default\b/.test(content) || /export\s*\{[^}]*\bdefault\b/.test(content);
69
79
  }
70
80
 
81
+ // ---------------------------------------------------------------------------
82
+ // CMS-referenced loader discovery
83
+ //
84
+ // Walk every JSON file under decofileDir and collect the set of strings that
85
+ // appear as `__resolveType` values. The migration script + generators emit
86
+ // pass-throughs for every loader/action file on disk; without this filter,
87
+ // 90%+ of those entries are dead code (the CMS never references them) and
88
+ // they pollute the type system and bundle.
89
+ // ---------------------------------------------------------------------------
90
+
91
+ function collectResolveTypes(dir: string): Set<string> {
92
+ const found = new Set<string>();
93
+ if (!fs.existsSync(dir)) return found;
94
+
95
+ const RESOLVE_RE = /"__resolveType"\s*:\s*"([^"]+)"/g;
96
+
97
+ function visit(d: string) {
98
+ for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
99
+ const fullPath = path.join(d, entry.name);
100
+ if (entry.isDirectory()) {
101
+ visit(fullPath);
102
+ } else if (entry.name.endsWith(".json")) {
103
+ const content = fs.readFileSync(fullPath, "utf-8");
104
+ let m: RegExpExecArray | null;
105
+ while ((m = RESOLVE_RE.exec(content)) !== null) {
106
+ found.add(m[1]);
107
+ }
108
+ }
109
+ }
110
+ }
111
+
112
+ visit(dir);
113
+ return found;
114
+ }
115
+
116
+ const cmsReferences = decofileDir ? collectResolveTypes(decofileDir) : null;
117
+
118
+ function isReferenced(key: string): boolean {
119
+ if (!cmsReferences) return true;
120
+ return cmsReferences.has(key) || cmsReferences.has(`${key}.ts`);
121
+ }
122
+
71
123
  // ---------------------------------------------------------------------------
72
124
 
73
125
  interface LoaderEntry {
@@ -76,12 +128,17 @@ interface LoaderEntry {
76
128
  }
77
129
 
78
130
  const entries: LoaderEntry[] = [];
131
+ let prunedCount = 0;
79
132
 
80
133
  for (const filePath of walkDir(loadersDir)) {
81
134
  const content = fs.readFileSync(filePath, "utf-8");
82
135
  if (!hasDefaultExport(content)) continue;
83
136
  const key = fileToKey(filePath, loadersDir, "site/loaders");
84
137
  if (excludeSet.has(key) || excludeSet.has(`${key}.ts`)) continue;
138
+ if (!isReferenced(key)) {
139
+ prunedCount++;
140
+ continue;
141
+ }
85
142
  entries.push({
86
143
  key,
87
144
  importPath: relativeImportPath(outFile, filePath),
@@ -93,6 +150,10 @@ for (const filePath of walkDir(actionsDir)) {
93
150
  if (!hasDefaultExport(content)) continue;
94
151
  const key = fileToKey(filePath, actionsDir, "site/actions");
95
152
  if (excludeSet.has(key) || excludeSet.has(`${key}.ts`)) continue;
153
+ if (!isReferenced(key)) {
154
+ prunedCount++;
155
+ continue;
156
+ }
96
157
  entries.push({
97
158
  key,
98
159
  importPath: relativeImportPath(outFile, filePath),
@@ -108,17 +169,20 @@ const lines: string[] = [
108
169
  "// Pass-through loader/action entries for COMMERCE_LOADERS.",
109
170
  "// Custom-wired entries should be excluded via --exclude and added manually in setup.ts.",
110
171
  "",
111
- "export const siteLoaders: Record<string, (props: any) => Promise<any>> = {",
172
+ "export const siteLoaders: Record<string, (props: any, request?: Request) => Promise<any>> = {",
112
173
  ];
113
174
 
175
+ // Cast the dynamic-import default to `any` so legacy 3-arg
176
+ // `(props, req, ctx)` Fresh/Deno loaders still type-check. Any ctx-dependent
177
+ // path in the loader body throws at runtime and must be refactored.
114
178
  for (const entry of entries) {
115
- lines.push(` "${entry.key}": async (props: any) => {`);
179
+ lines.push(` "${entry.key}": async (props: any, request?: Request) => {`);
116
180
  lines.push(` const mod = await import("${entry.importPath}");`);
117
- lines.push(" return mod.default(props);");
181
+ lines.push(" return (mod.default as any)(props, request);");
118
182
  lines.push(" },");
119
- lines.push(` "${entry.key}.ts": async (props: any) => {`);
183
+ lines.push(` "${entry.key}.ts": async (props: any, request?: Request) => {`);
120
184
  lines.push(` const mod = await import("${entry.importPath}");`);
121
- lines.push(" return mod.default(props);");
185
+ lines.push(" return (mod.default as any)(props, request);");
122
186
  lines.push(" },");
123
187
  }
124
188
 
@@ -128,6 +192,9 @@ lines.push("");
128
192
  fs.mkdirSync(path.dirname(outFile), { recursive: true });
129
193
  fs.writeFileSync(outFile, lines.join("\n"));
130
194
 
195
+ const filterNote = cmsReferences
196
+ ? ` (filtered against ${cmsReferences.size} CMS __resolveType references; pruned ${prunedCount} dead entries)`
197
+ : "";
131
198
  console.log(
132
- `Generated ${entries.length} loader entries (${entries.length * 2} with .ts aliases) → ${path.relative(process.cwd(), outFile)}`,
199
+ `Generated ${entries.length} loader entries (${entries.length * 2} with .ts aliases) → ${path.relative(process.cwd(), outFile)}${filterNote}`,
133
200
  );
@@ -32,6 +32,10 @@ const PATTERN_DETECTORS: Array<[DetectedPattern, RegExp]> = [
32
32
  ["head-component", /<Head[\s>]/],
33
33
  ["define-app", /defineApp\(/],
34
34
  ["invoke-proxy", /proxy<Manifest/],
35
+ // Bagaggio-style HTMX dynamic-section loader. Both the source file and
36
+ // every call site need manual conversion to React state / createServerFn.
37
+ ["sections-component-loader", /sections\/Component\.tsx?$/m],
38
+ ["use-component", /import\s*\{[^}]*\buseComponent\b[^}]*\}\s*from\s*["'][^"']*sections\/Component(?:\.tsx?)?["']/],
35
39
  ];
36
40
 
37
41
  /** Files/dirs that should be completely skipped during scanning */
@@ -312,6 +316,25 @@ function decideAction(
312
316
  };
313
317
  }
314
318
 
319
+ // Bagaggio-style HTMX dynamic-section loader → delete and flag.
320
+ // The file uses Deno-only APIs (`toFileUrl(Deno.cwd())`, `import.meta.resolve`)
321
+ // and the `useComponent(component, props)` HTMX render-and-swap pattern, none
322
+ // of which work on TanStack Start / Cloudflare Workers. The site author must
323
+ // refactor every `useComponent(...)` call site to React state, `createServerFn`
324
+ // + `useMutation`, or a direct `~/server/invoke` call BEFORE this file is
325
+ // safe to remove.
326
+ if (
327
+ relPath === "sections/Component.tsx" ||
328
+ relPath === "sections/Component.ts"
329
+ ) {
330
+ return {
331
+ action: "delete",
332
+ notes:
333
+ "HTMX dynamic-section loader (useComponent) — incompatible with TanStack Start. " +
334
+ "Migrate every useComponent(...) call site to React state / createServerFn before deploy.",
335
+ };
336
+ }
337
+
315
338
  // Static code/tooling files → delete
316
339
  if (STATIC_DELETE.has(relPath)) {
317
340
  return { action: "delete", notes: "Code/tooling file, not an asset" };
@@ -637,6 +660,37 @@ export function analyze(ctx: MigrationContext): void {
637
660
  console.log(` By category: ${JSON.stringify(byCategory)}`);
638
661
  console.log(` By action: ${JSON.stringify(byAction)}`);
639
662
 
663
+ // Surface the HTMX dynamic-section loader and every `useComponent` call site
664
+ // up-front. These need manual conversion to React state / createServerFn before
665
+ // the migrated site will run on Cloudflare Workers — the analyzer cannot do
666
+ // it automatically, so it must be loud enough to land in the report.
667
+ const useComponentSites = ctx.files.filter(
668
+ (f) => f.patterns.includes("use-component"),
669
+ );
670
+ const componentLoaderFile = ctx.files.find(
671
+ (f) =>
672
+ f.path === "sections/Component.tsx" ||
673
+ f.path === "sections/Component.ts",
674
+ );
675
+ if (componentLoaderFile || useComponentSites.length > 0) {
676
+ console.log("\n ⚠ HTMX dynamic-section loader detected (Bagaggio-style)");
677
+ if (componentLoaderFile) {
678
+ console.log(` • ${componentLoaderFile.path} (will be deleted)`);
679
+ }
680
+ if (useComponentSites.length > 0) {
681
+ console.log(` • ${useComponentSites.length} useComponent(...) call site(s) need manual conversion:`);
682
+ for (const f of useComponentSites.slice(0, 10)) {
683
+ console.log(` - ${f.path}`);
684
+ }
685
+ if (useComponentSites.length > 10) {
686
+ console.log(` ... and ${useComponentSites.length - 10} more`);
687
+ }
688
+ }
689
+ console.log(
690
+ " See: deco-to-tanstack-migration skill, 'useComponent / partial sections' section",
691
+ );
692
+ }
693
+
640
694
  // Run analyzers
641
695
  extractSectionMetadata(ctx);
642
696
  classifyIslands(ctx);
@@ -20,7 +20,10 @@ const ROOT_FILES_TO_DELETE = [
20
20
  "tailwind.css",
21
21
  "tailwind.config.ts",
22
22
  "runtime.ts",
23
- "constants.ts",
23
+ // NOTE: `constants.ts` is intentionally NOT deleted here — it holds
24
+ // site-specific UI constants (form/drawer IDs, header heights, etc.)
25
+ // that components reference via `~/constants` or `../../constants`.
26
+ // We move it to `src/constants.ts` instead — see `moveRootConstantsToSrc`.
24
27
  "fresh.gen.ts",
25
28
  "manifest.gen.ts",
26
29
  "fresh.config.ts",
@@ -1251,6 +1254,94 @@ function normalizeImportCasing(ctx: MigrationContext) {
1251
1254
  });
1252
1255
  }
1253
1256
 
1257
+ /**
1258
+ * Move root-level `constants.ts` → `src/constants.ts`.
1259
+ *
1260
+ * Old stack: a root-level `constants.ts` exporting site-wide UI constants
1261
+ * (MINICART_FORM_ID, SIDEMENU_DRAWER_ID, HEADER_HEIGHT, USER_ID, etc.) that
1262
+ * components reference via `../../constants` or `~/constants`.
1263
+ *
1264
+ * Without this step, `phase-cleanup` deletes the file and the build fails
1265
+ * with `Could not resolve "../../constants"` from many components. The CMS
1266
+ * doesn't reference these IDs, so a 1:1 file move is sufficient.
1267
+ *
1268
+ * If `src/constants.ts` already exists (rare — usually means the migration
1269
+ * was re-run), we leave it alone.
1270
+ */
1271
+ function moveRootConstantsToSrc(ctx: MigrationContext) {
1272
+ const rootPath = path.join(ctx.sourceDir, "constants.ts");
1273
+ const srcPath = path.join(ctx.sourceDir, "src", "constants.ts");
1274
+
1275
+ if (!fs.existsSync(rootPath)) return;
1276
+ if (fs.existsSync(srcPath)) {
1277
+ log(ctx, `Skipped move: src/constants.ts already exists; deleting root constants.ts`);
1278
+ if (!ctx.dryRun) fs.unlinkSync(rootPath);
1279
+ ctx.deletedFiles.push("constants.ts");
1280
+ return;
1281
+ }
1282
+
1283
+ if (ctx.dryRun) {
1284
+ log(ctx, `[DRY] Would move: constants.ts → src/constants.ts`);
1285
+ ctx.movedFiles.push({ from: "constants.ts", to: "src/constants.ts" });
1286
+ return;
1287
+ }
1288
+
1289
+ fs.mkdirSync(path.dirname(srcPath), { recursive: true });
1290
+ fs.renameSync(rootPath, srcPath);
1291
+ ctx.movedFiles.push({ from: "constants.ts", to: "src/constants.ts" });
1292
+ log(ctx, `Moved: constants.ts → src/constants.ts`);
1293
+ }
1294
+
1295
+ /**
1296
+ * Rewrite the legacy multi-platform `loaders/minicart.ts` file.
1297
+ *
1298
+ * Old stack: `loaders/minicart.ts` runtime-dispatches on `usePlatform()` to
1299
+ * platform-specific loaders under `sdk/cart/{vtex,vnda,wake,linx,shopify,nuvemshop}/loader.ts`.
1300
+ * The cleanup phase already deletes `sdk/cart/` entirely, leaving the loader
1301
+ * with broken imports.
1302
+ *
1303
+ * New stack: the canonical Minicart contract + VTEX transform live in
1304
+ * `@decocms/apps/vtex/inline-loaders/minicart`. We replace the loader with a
1305
+ * thin VTEX-only re-export. Sites on Shopify/VNDA/Wake/Linx/Nuvemshop are
1306
+ * not currently in production on the new stack — when one is, swap this for
1307
+ * a runtime dispatcher again or add a platform-flagged rewrite.
1308
+ */
1309
+ function rewriteMinicartLoader(ctx: MigrationContext) {
1310
+ const candidates = [
1311
+ path.join(ctx.sourceDir, "src", "loaders", "minicart.ts"),
1312
+ path.join(ctx.sourceDir, "loaders", "minicart.ts"),
1313
+ ];
1314
+
1315
+ for (const filePath of candidates) {
1316
+ if (!fs.existsSync(filePath)) continue;
1317
+
1318
+ const content = fs.readFileSync(filePath, "utf-8");
1319
+ // Only rewrite if it actually imports the legacy multi-platform sdk/cart layout.
1320
+ const isLegacyLoader = /from\s+["'](?:~|\.\.?)\/sdk\/cart\/(?:vtex|vnda|wake|linx|shopify|nuvemshop)\/loader["']/
1321
+ .test(content);
1322
+ if (!isLegacyLoader) continue;
1323
+
1324
+ const newContent = `// VTEX-only minicart loader.
1325
+ //
1326
+ // The legacy site shipped per-platform loaders behind a \`usePlatform()\`
1327
+ // switch (vnda, wake, linx, shopify, nuvemshop). The canonical minicart
1328
+ // contract now lives in \`@decocms/apps\`. Until a non-VTEX customer comes
1329
+ // online on the new stack, we re-export the framework loader directly.
1330
+ // TODO: when adding another platform, replace this with a runtime
1331
+ // dispatcher and import the matching framework loader.
1332
+ export { default } from "@decocms/apps/vtex/inline-loaders/minicart";
1333
+ export type { MinicartProps } from "@decocms/apps/vtex/inline-loaders/minicart";
1334
+ `;
1335
+
1336
+ if (ctx.dryRun) {
1337
+ log(ctx, `[DRY] Would rewrite: ${path.relative(ctx.sourceDir, filePath)} (VTEX-only re-export)`);
1338
+ } else {
1339
+ fs.writeFileSync(filePath, newContent);
1340
+ log(ctx, `Rewrote: ${path.relative(ctx.sourceDir, filePath)} (VTEX-only re-export of @decocms/apps/vtex/inline-loaders/minicart)`);
1341
+ }
1342
+ }
1343
+ }
1344
+
1254
1345
  /**
1255
1346
  * Fix APIs that don't exist in Cloudflare Workers:
1256
1347
  * - window.setTimeout → setTimeout
@@ -1367,6 +1458,19 @@ export function cleanup(ctx: MigrationContext): void {
1367
1458
  console.log(" Rewriting VTEX utility imports → ~/lib/ wrappers...");
1368
1459
  rewriteVtexUtilImports(ctx);
1369
1460
 
1461
+ // 11a. Preserve root-level constants.ts (site-wide UI IDs/heights) by
1462
+ // moving it to src/constants.ts. The cleanup phase used to delete it
1463
+ // unconditionally, breaking every component that imports `~/constants`.
1464
+ console.log(" Moving root constants.ts → src/constants.ts...");
1465
+ moveRootConstantsToSrc(ctx);
1466
+
1467
+ // 11b. Rewrite legacy multi-platform minicart loader → VTEX-only re-export.
1468
+ // `sdk/cart/` is deleted by DIRS_TO_DELETE, leaving loaders/minicart.ts
1469
+ // with broken imports. Replace it with a thin re-export of the
1470
+ // framework's @decocms/apps/vtex/inline-loaders/minicart loader.
1471
+ console.log(" Rewriting loaders/minicart.ts → VTEX-only re-export...");
1472
+ rewriteMinicartLoader(ctx);
1473
+
1370
1474
  // 12. Fix useVariantPossiblities omit set
1371
1475
  console.log(" Fixing useVariantPossiblities omit set...");
1372
1476
  fixVariantOmitSet(ctx);
@@ -126,6 +126,48 @@ export function transform(ctx: MigrationContext): void {
126
126
  });
127
127
  }
128
128
 
129
+ // Flag the legacy sections/Component.tsx dynamic-section loader.
130
+ // This file uses Deno-specific APIs (toFileUrl, import.meta.resolve)
131
+ // and the HTMX-driven `useComponent(component, props)` pattern, which
132
+ // do not run on Cloudflare Workers and have no equivalent in
133
+ // @decocms/start. The whole file must be deleted.
134
+ if (
135
+ /sections\/Component\.tsx?$/.test(record.path) ||
136
+ /sections\/Component\.tsx?$/.test(targetPath)
137
+ ) {
138
+ ctx.manualReviewItems.push({
139
+ file: targetPath,
140
+ reason:
141
+ "sections/Component.tsx (Deno HTMX dynamic-section loader) is incompatible with TanStack Start / Cloudflare Workers. " +
142
+ "DELETE this file and migrate every `useComponent(...)` call site to one of: " +
143
+ "(a) local React state for client-side toggles, " +
144
+ "(b) `createServerFn` + `useMutation` for server actions, or " +
145
+ "(c) a direct `invoke` call (`~/server/invoke`) for ad-hoc loaders. " +
146
+ "See: deco-to-tanstack-migration skill, 'useComponent / partial sections' section.",
147
+ severity: "error",
148
+ });
149
+ }
150
+
151
+ // Flag any import of useComponent — typically `import { useComponent } from "site/sections/Component.tsx"`.
152
+ // We also catch `from "../../sections/Component"` and similar relative variants.
153
+ if (
154
+ /\buseComponent\b/.test(result.content) &&
155
+ /from\s+["'][^"']*sections\/Component(?:\.tsx?)?["']/.test(result.content)
156
+ ) {
157
+ ctx.manualReviewItems.push({
158
+ file: targetPath,
159
+ reason:
160
+ "useComponent({ ... }) call site detected. This is the HTMX-style dynamic-section render pattern " +
161
+ "that ships HTML fragments and swaps them client-side. It does not work on TanStack Start. " +
162
+ "Recipes: " +
163
+ "(1) Self-contained UI toggles → keep state in React (`useState` + event handlers); " +
164
+ "(2) Form submissions / mutations → `createServerFn` + `useMutation` (see casaevideo-storefront for canonical examples); " +
165
+ "(3) Ad-hoc data fetches → call the loader/action via `~/server/invoke` and store results in `useState`. " +
166
+ "Remove the import after refactoring, then delete `src/sections/Component.tsx`.",
167
+ severity: "error",
168
+ });
169
+ }
170
+
129
171
  if (ctx.dryRun) {
130
172
  if (result.changed) {
131
173
  log(ctx, `[DRY] Would transform: ${record.path} → ${targetPath}`);
@@ -83,7 +83,7 @@ export function generateCommerceLoaders(ctx: MigrationContext): string {
83
83
  lines.push(``);
84
84
  }
85
85
 
86
- lines.push(`export const COMMERCE_LOADERS: Record<string, (props: any) => Promise<any>> = {`);
86
+ lines.push(`export const COMMERCE_LOADERS: Record<string, (props: any, request?: Request) => Promise<any>> = {`);
87
87
 
88
88
  if (ctx.platform === "vtex") {
89
89
  lines.push(` ...vtexLoaders,`);
@@ -156,7 +156,10 @@ export function generateSectionLoaders(ctx: MigrationContext): string {
156
156
  const importPath = `~/` + meta.path.replace(/\.tsx?$/, "");
157
157
  entries.push(` "${sectionKey}": async (props: any, req: Request) => {`);
158
158
  entries.push(` const mod = await import("${importPath}");`);
159
- entries.push(` if (typeof mod.loader === "function") return mod.loader(props, req);`);
159
+ // Cast to any: legacy Fresh/Deno section loaders are typed `(props, req, ctx)`.
160
+ // We invoke with 2 args; any ctx-dependent code path inside the loader will throw
161
+ // at runtime and must be refactored — the migration phase-transform flags these.
162
+ entries.push(` if (typeof mod.loader === "function") return (mod.loader as any)(props, req);`);
160
163
  entries.push(` return props;`);
161
164
  entries.push(` },`);
162
165
  }
@@ -61,7 +61,9 @@ export type DetectedPattern =
61
61
  | "asset-function"
62
62
  | "head-component"
63
63
  | "define-app"
64
- | "invoke-proxy";
64
+ | "invoke-proxy"
65
+ | "use-component"
66
+ | "sections-component-loader";
65
67
 
66
68
  /** Metadata extracted from a section file during analysis */
67
69
  export interface SectionMeta {
@@ -228,7 +228,18 @@ function isBot(userAgent?: string): boolean {
228
228
  return botPatterns.some((re) => re.test(userAgent));
229
229
  }
230
230
 
231
- export type CommerceLoader = (props: any) => Promise<any>;
231
+ /**
232
+ * A loader registered against a `__resolveType` key. The runtime invokes it
233
+ * through two paths:
234
+ *
235
+ * 1. CMS resolution (`commerceLoader(resolvedProps)`) — 1-arg call.
236
+ * 2. `/deco/invoke/...` endpoint — `(props, request)` 2-arg call.
237
+ *
238
+ * Loaders that need the `Request` (cookies, geo, headers) declare the second
239
+ * parameter; pure loaders ignore it. This shape lets a single registry serve
240
+ * both invocation paths without `as any` casts at every wrapper.
241
+ */
242
+ export type CommerceLoader = (props: any, request?: Request) => Promise<any>;
232
243
 
233
244
  /**
234
245
  * Context passed through the resolution pipeline.
@@ -137,13 +137,34 @@ export function inlineScript(js: string) {
137
137
  }
138
138
 
139
139
  /**
140
- * Stub -- Deco partial sections don't apply in TanStack Start.
141
- * Returns the provided props as-is.
140
+ * @deprecated Removed in TanStack Start.
141
+ *
142
+ * The Fresh/Deno HTMX-based partial-section pattern (`useSection` /
143
+ * `usePartialSection` + `sections/Component.tsx`) does not apply on
144
+ * Cloudflare Workers and React. Replace call-sites with one of:
145
+ *
146
+ * 1. Local React state (`useState` + event handlers) for client-side toggles.
147
+ * 2. `createServerFn` + `useMutation` for server actions.
148
+ * 3. Direct `invoke` calls (`~/server/invoke`) for ad-hoc loaders.
149
+ *
150
+ * See: deco-to-tanstack-migration skill, "useComponent / partial sections"
151
+ * section, for the per-pattern recipes.
152
+ *
153
+ * Both stubs throw at runtime (and at import time, if you call them at
154
+ * module top level) so legacy code surfaces a clear error instead of a
155
+ * silent no-op.
142
156
  */
143
- export function usePartialSection(props?: Record<string, unknown>) {
144
- return props || {};
157
+ const DEPRECATION_MESSAGE =
158
+ "[@decocms/start] useSection / usePartialSection were removed. " +
159
+ "The Fresh/Deno HTMX partial-section pattern does not apply on " +
160
+ "TanStack Start / Cloudflare Workers. Replace call-sites with " +
161
+ "createServerFn + useMutation, or local React state. See the " +
162
+ "deco-to-tanstack-migration skill for per-pattern recipes.";
163
+
164
+ export function usePartialSection(_props?: Record<string, unknown>): never {
165
+ throw new Error(DEPRECATION_MESSAGE);
145
166
  }
146
167
 
147
- export function useSection(_props?: Record<string, unknown>) {
148
- return "";
168
+ export function useSection(_props?: Record<string, unknown>): never {
169
+ throw new Error(DEPRECATION_MESSAGE);
149
170
  }
@@ -34,7 +34,7 @@ import {
34
34
  } from "./cacheHeaders";
35
35
  import { buildHtmlShell } from "./htmlShell";
36
36
  import { cleanPathForCacheKey } from "./urlUtils";
37
- import { isMobileUA } from "./useDevice";
37
+ import { type Device, isMobileUA } from "./useDevice";
38
38
  import { getRenderShellConfig } from "../admin/setup";
39
39
  import { RequestContext } from "./requestContext";
40
40
  import { getAppMiddleware } from "./setupApps";
@@ -88,7 +88,16 @@ interface ServerEntry {
88
88
  * cache entry; different segments get different cached responses.
89
89
  */
90
90
  export interface SegmentKey {
91
- device: "mobile" | "desktop";
91
+ /**
92
+ * Device class derived from the request User-Agent.
93
+ *
94
+ * Accepts the full `Device` union (`"mobile" | "desktop" | "tablet"`) so
95
+ * that callers can pass `detectDevice(...)` directly without manual
96
+ * narrowing. Sites that want to share cache entries between mobile and
97
+ * tablet can collapse the value at the call site (e.g.
98
+ * `device === "tablet" ? "mobile" : device`).
99
+ */
100
+ device: Device;
92
101
  /** Whether the user is logged in (e.g., has a valid auth cookie). */
93
102
  loggedIn?: boolean;
94
103
  /** Commerce sales channel / price list. */
package/src/setup.ts CHANGED
@@ -96,7 +96,7 @@ export interface SiteSetupOptions {
96
96
  * { getCommerceLoaders: () => COMMERCE_LOADERS }
97
97
  * ```
98
98
  */
99
- getCommerceLoaders?: () => Record<string, (props: any) => Promise<any>>;
99
+ getCommerceLoaders?: () => Record<string, (props: any, request?: Request) => Promise<any>>;
100
100
  }
101
101
 
102
102
  /**