@decocms/start 1.5.0 → 1.6.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": "1.5.0",
3
+ "version": "1.6.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",
@@ -48,6 +48,7 @@
48
48
  "./sdk/createInvoke": "./src/sdk/createInvoke.ts",
49
49
  "./sdk/router": "./src/sdk/router.ts",
50
50
  "./matchers/posthog": "./src/matchers/posthog.ts",
51
+ "./apps": "./src/apps/index.ts",
51
52
  "./apps/autoconfig": "./src/apps/autoconfig.ts",
52
53
  "./sdk/setupApps": "./src/sdk/setupApps.ts",
53
54
  "./matchers/builtins": "./src/matchers/builtins.ts",
@@ -15,6 +15,16 @@ import { getPreviewWrapper } from "./setup";
15
15
 
16
16
  export { setRenderShell, setPreviewWrapper } from "./setup";
17
17
 
18
+ /** Escape user-controlled strings before interpolating into HTML. */
19
+ function escapeHtml(str: string): string {
20
+ return str
21
+ .replace(/&/g, "&")
22
+ .replace(/</g, "&lt;")
23
+ .replace(/>/g, "&gt;")
24
+ .replace(/"/g, "&quot;")
25
+ .replace(/'/g, "&#39;");
26
+ }
27
+
18
28
  // Cache the dynamic import — avoids re-importing per section render
19
29
  let _renderToString: ((element: any) => string) | null = null;
20
30
  async function getRenderToString() {
@@ -36,7 +46,7 @@ function wrapInHtmlShell(sectionHtml: string): string {
36
46
  async function renderResolvedSection(section: ResolvedSection): Promise<string> {
37
47
  const sectionLoader = getSection(section.component);
38
48
  if (!sectionLoader) {
39
- return `<div style="padding:8px;color:orange;font-size:12px;border:1px dashed orange;margin:4px 0;">Unsupported: ${section.component}</div>`;
49
+ return `<div style="padding:8px;color:orange;font-size:12px;border:1px dashed orange;margin:4px 0;">Unsupported: ${escapeHtml(section.component)}</div>`;
40
50
  }
41
51
 
42
52
  try {
@@ -47,7 +57,7 @@ async function renderResolvedSection(section: ResolvedSection): Promise<string>
47
57
  const wrapped = Wrapper ? createElement(Wrapper, null, element) : element;
48
58
  return renderToString(wrapped);
49
59
  } catch (error) {
50
- return `<div style="padding:8px;color:red;font-size:12px;">Error rendering ${section.component}: ${(error as Error).message}</div>`;
60
+ return `<div style="padding:8px;color:red;font-size:12px;">Error rendering ${escapeHtml(section.component)}: ${escapeHtml((error as Error).message)}</div>`;
51
61
  }
52
62
  }
53
63
 
@@ -62,7 +72,7 @@ async function renderOneSection(section: Record<string, unknown>): Promise<strin
62
72
 
63
73
  const sectionLoader = getSection(resolveType);
64
74
  if (!sectionLoader) {
65
- return `<div style="padding:8px;color:orange;font-size:12px;border:1px dashed orange;margin:4px 0;">Unsupported: ${resolveType}</div>`;
75
+ return `<div style="padding:8px;color:orange;font-size:12px;border:1px dashed orange;margin:4px 0;">Unsupported: ${escapeHtml(resolveType)}</div>`;
66
76
  }
67
77
 
68
78
  try {
@@ -74,7 +84,7 @@ async function renderOneSection(section: Record<string, unknown>): Promise<strin
74
84
  const wrapped = Wrapper ? createElement(Wrapper, null, element) : element;
75
85
  return renderToString(wrapped);
76
86
  } catch (error) {
77
- return `<div style="padding:8px;color:red;font-size:12px;">Error rendering ${resolveType}: ${(error as Error).message}</div>`;
87
+ return `<div style="padding:8px;color:red;font-size:12px;">Error rendering ${escapeHtml(resolveType)}: ${escapeHtml((error as Error).message)}</div>`;
78
88
  }
79
89
  }
80
90
 
@@ -234,7 +244,7 @@ export async function handleRender(request: Request): Promise<Response> {
234
244
  const sectionLoader = getSection(component);
235
245
  if (!sectionLoader) {
236
246
  const unknownHtml = wrapInHtmlShell(
237
- `<div style="padding:20px;color:red;">Unknown section: ${component}</div>`,
247
+ `<div style="padding:20px;color:red;">Unknown section: ${escapeHtml(component)}</div>`,
238
248
  );
239
249
  return new Response(unknownHtml, {
240
250
  status: 200,
@@ -257,7 +267,7 @@ export async function handleRender(request: Request): Promise<Response> {
257
267
  });
258
268
  } catch (error) {
259
269
  const errorHtml = wrapInHtmlShell(
260
- `<div style="padding:20px;color:red;">Render error: ${(error as Error).message}</div>`,
270
+ `<div style="padding:20px;color:red;">Render error: ${escapeHtml((error as Error).message)}</div>`,
261
271
  );
262
272
  return new Response(errorHtml, {
263
273
  status: 200,
@@ -1,89 +1,135 @@
1
1
  /**
2
- * Auto-configures known apps from CMS blocks using AppModContract.
2
+ * Registry-driven auto-configuration of known apps from CMS blocks.
3
3
  *
4
- * Scans the decofile for known app block keys (e.g. "deco-vtex", "deco-resend")
5
- * and calls each app's `configure()` function from its mod.ts.
6
- * Then delegates to `setupApps()` for invoke handler registration, middleware, etc.
4
+ * A site passes an `AppRegistry` (declarative list of known app blockKeys +
5
+ * lazy module imports). For every registry entry whose `blockKey` exists in
6
+ * the decofile, this calls `mod.configure(block, resolveSecret)` and then
7
+ * hands the resulting AppDefinitions off to `setupApps()`.
8
+ *
9
+ * The canonical registry lives in `@decocms/apps/registry` so the framework
10
+ * itself has zero knowledge of which apps exist.
7
11
  *
8
12
  * Usage in setup.ts:
9
13
  * import { autoconfigApps } from "@decocms/start/apps/autoconfig";
10
- * setBlocks(generatedBlocks);
11
- * await autoconfigApps(generatedBlocks);
14
+ * import { APP_REGISTRY } from "@decocms/apps/registry";
15
+ * await autoconfigApps(generatedBlocks, APP_REGISTRY);
12
16
  */
13
17
 
14
18
  import { onChange } from "../cms/loader";
15
19
  import { resolveSecret } from "../sdk/crypto";
16
20
  import {
17
- setupApps,
18
- type AppDefinition,
19
- type AppDefinitionWithHandlers,
21
+ setupApps,
22
+ type AppDefinition,
23
+ type AppDefinitionWithHandlers,
20
24
  } from "../sdk/setupApps";
21
25
 
22
- // ---------------------------------------------------------------------------
23
- // Known app block keys dynamic import of their mod.ts
24
- // ---------------------------------------------------------------------------
26
+ /**
27
+ * Shape of the secret resolver passed to each app's `configure()`. Matches
28
+ * `resolveSecret` in `src/sdk/crypto.ts`. Apps typically narrow the return
29
+ * type inside their own `configure()` by throwing on null.
30
+ */
31
+ export type ResolveSecret = (
32
+ value: unknown,
33
+ envVarName?: string,
34
+ ) => Promise<string | null>;
35
+
36
+ /**
37
+ * One entry in the app registry — describes a single installable app.
38
+ * Sites pass an array of these to `autoconfigApps()` (typically imported
39
+ * from `@decocms/apps/registry`).
40
+ */
41
+ export interface AppRegistryEntry {
42
+ /** Block key in the decofile, e.g. "deco-shopify". */
43
+ blockKey: string;
44
+
45
+ /**
46
+ * Lazy import of the app's mod module. Must return an object exposing
47
+ * `configure(block, resolveSecret)` and optionally `handlers`.
48
+ *
49
+ * Use a string-literal dynamic import so bundlers (Vite/Rollup) can
50
+ * statically trace the chunk. E.g.
51
+ * () => import("@decocms/apps/shopify/mod")
52
+ */
53
+ module: () => Promise<{
54
+ configure: (
55
+ block: unknown,
56
+ resolveSecret: ResolveSecret,
57
+ ) => Promise<AppDefinition | null>;
58
+ handlers?: Record<string, (props: any, req: Request) => Promise<any>>;
59
+ }>;
25
60
 
26
- const APP_MODS: Record<string, () => Promise<any>> = {
27
- "deco-vtex": () => import("@decocms/apps/vtex/mod" as string),
28
- "deco-shopify": () => import("@decocms/apps/shopify/mod" as string),
29
- "deco-resend": () => import("@decocms/apps/resend/mod" as string),
30
- };
61
+ /** Human-readable name shown in admin install UI. */
62
+ displayName?: string;
63
+ /** Icon URL (absolute or site-relative) shown in admin install UI. */
64
+ icon?: string;
65
+ /** Grouping label, e.g. "commerce", "email", "analytics". */
66
+ category?: string;
67
+ /** Short summary (one sentence) shown in admin install UI. */
68
+ description?: string;
69
+ }
31
70
 
32
- // ---------------------------------------------------------------------------
33
- // Main
34
- // ---------------------------------------------------------------------------
71
+ export type AppRegistry = readonly AppRegistryEntry[];
35
72
 
36
73
  async function configureAllApps(
37
- blocks: Record<string, unknown>,
74
+ blocks: Record<string, unknown>,
75
+ registry: AppRegistry,
38
76
  ): Promise<AppDefinitionWithHandlers[]> {
39
- const apps: AppDefinitionWithHandlers[] = [];
77
+ const apps: AppDefinitionWithHandlers[] = [];
40
78
 
41
- for (const [blockKey, importMod] of Object.entries(APP_MODS)) {
42
- const block = blocks[blockKey];
43
- if (!block) continue;
79
+ for (const entry of registry) {
80
+ const block = blocks[entry.blockKey];
81
+ if (!block) continue;
44
82
 
45
- try {
46
- const mod = await importMod();
47
- if (typeof mod.configure !== "function") continue;
83
+ try {
84
+ const mod = await entry.module();
85
+ if (typeof mod?.configure !== "function") continue;
48
86
 
49
- const appDef: AppDefinition | null = await mod.configure(
50
- block,
51
- resolveSecret,
52
- );
53
- if (!appDef) continue;
87
+ const appDef: AppDefinition | null = await mod.configure(
88
+ block,
89
+ resolveSecret,
90
+ );
91
+ if (!appDef) continue;
54
92
 
55
- // Attach explicit handlers from mod.ts (e.g. resend's pre-wrapped handlers)
56
- const withHandlers: AppDefinitionWithHandlers = {
57
- ...appDef,
58
- handlers: mod.handlers,
59
- };
60
- apps.push(withHandlers);
61
- } catch {
62
- // App not installed or configure failed — skip silently
63
- }
64
- }
93
+ const withHandlers: AppDefinitionWithHandlers = {
94
+ ...appDef,
95
+ handlers: mod.handlers,
96
+ };
97
+ apps.push(withHandlers);
98
+ } catch {
99
+ // App module missing, configure threw, or block was malformed — skip.
100
+ }
101
+ }
65
102
 
66
- return apps;
103
+ return apps;
67
104
  }
68
105
 
69
106
  /**
70
- * Auto-configure apps from CMS blocks.
71
- * Call in setup.ts after setBlocks(). Also re-runs on admin hot-reload.
107
+ * Auto-configure apps from CMS blocks against a declarative registry.
108
+ *
109
+ * Call once in setup.ts after setBlocks(). Re-runs on admin hot-reload.
110
+ *
111
+ * @param blocks Decofile blocks (from blocks.gen or loadBlocks()).
112
+ * @param registry List of installable apps — typically
113
+ * `import { APP_REGISTRY } from "@decocms/apps/registry"`.
72
114
  */
73
- export async function autoconfigApps(blocks: Record<string, unknown>) {
74
- if (typeof document !== "undefined") return; // server-only
115
+ export async function autoconfigApps(
116
+ blocks: Record<string, unknown>,
117
+ registry: AppRegistry,
118
+ ): Promise<void> {
119
+ if (typeof document !== "undefined") return; // server-only
120
+ if (!registry || registry.length === 0) return;
75
121
 
76
- const apps = await configureAllApps(blocks);
77
- if (apps.length > 0) {
78
- await setupApps(apps);
79
- }
122
+ const apps = await configureAllApps(blocks, registry);
123
+ if (apps.length > 0) {
124
+ await setupApps(apps);
125
+ }
80
126
 
81
- // Re-configure on admin hot-reload
82
- onChange(async (newBlocks) => {
83
- if (typeof document !== "undefined") return;
84
- const updatedApps = await configureAllApps(newBlocks);
85
- if (updatedApps.length > 0) {
86
- await setupApps(updatedApps);
87
- }
88
- });
127
+ // Re-configure on admin hot-reload
128
+ onChange(async (newBlocks) => {
129
+ if (typeof document !== "undefined") return;
130
+ const updatedApps = await configureAllApps(newBlocks, registry);
131
+ if (updatedApps.length > 0) {
132
+ await setupApps(updatedApps);
133
+ }
134
+ });
89
135
  }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Public entry point for app-install primitives.
3
+ *
4
+ * Sites import from `@decocms/start/apps`:
5
+ * - `autoconfigApps(blocks, registry)` — main bootstrap call
6
+ * - `AppRegistry`, `AppRegistryEntry` — registry types
7
+ * - `setupApps`, `AppDefinition` — lower-level primitives when composing custom registries
8
+ */
9
+
10
+ export {
11
+ autoconfigApps,
12
+ type AppRegistry,
13
+ type AppRegistryEntry,
14
+ } from "./autoconfig";
15
+
16
+ export {
17
+ setupApps,
18
+ registerAppMiddleware,
19
+ getAppMiddleware,
20
+ type AppDefinition,
21
+ type AppDefinitionWithHandlers,
22
+ type AppManifest,
23
+ type AppMiddleware,
24
+ } from "../sdk/setupApps";
@@ -11,10 +11,12 @@ import {
11
11
  registerSectionsSync,
12
12
  } from "./registry";
13
13
  import {
14
+ type CacheableSectionInput,
14
15
  registerCacheableSections,
15
16
  registerLayoutSections,
16
17
  } from "./sectionLoaders";
17
18
  import {
19
+ type AsyncRenderingConfig,
18
20
  registerEagerSections,
19
21
  registerSeoSections,
20
22
  setAsyncRenderingConfig,
@@ -48,13 +50,13 @@ export function applySectionConventions(input: ApplySectionConventionsInput): vo
48
50
  const eagerSections: string[] = [];
49
51
  const layoutSections: string[] = [];
50
52
  const seoSections: string[] = [];
51
- const cacheableSections: Record<string, string> = {};
53
+ const cacheableSections: Record<string, CacheableSectionInput> = {};
52
54
 
53
55
  for (const [key, entry] of Object.entries(meta)) {
54
56
  if (entry.eager) eagerSections.push(key);
55
57
  if (entry.layout) layoutSections.push(key);
56
58
  if (entry.seo) seoSections.push(key);
57
- if (entry.cache) cacheableSections[key] = entry.cache;
59
+ if (entry.cache) cacheableSections[key] = entry.cache as CacheableSectionInput;
58
60
 
59
61
  if (entry.clientOnly && sectionGlob) {
60
62
  const globKey = sectionGlobKey(key, sectionGlob);
@@ -81,7 +83,8 @@ export function applySectionConventions(input: ApplySectionConventionsInput): vo
81
83
  // Permanent registry — survives subsequent setAsyncRenderingConfig() calls
82
84
  registerEagerSections(eagerSections);
83
85
  // Also add to alwaysEager for backward compat with code that reads the config
84
- const existing = getAsyncRenderingConfig() ?? {};
86
+ const existing: Partial<AsyncRenderingConfig> =
87
+ getAsyncRenderingConfig() ?? {};
85
88
  setAsyncRenderingConfig({
86
89
  ...existing,
87
90
  alwaysEager: [...(existing.alwaysEager ?? []), ...eagerSections],
package/src/cms/index.ts CHANGED
@@ -49,6 +49,8 @@ export {
49
49
  registerBotPattern,
50
50
  registerCommerceLoader,
51
51
  registerCommerceLoaders,
52
+ unregisterCommerceLoader,
53
+ clearCommerceLoaders,
52
54
  registerMatcher,
53
55
  registerEagerSections,
54
56
  registerSeoSections,
@@ -298,6 +298,16 @@ export function registerCommerceLoaders(loaders: Record<string, CommerceLoader>)
298
298
  Object.assign(commerceLoaders, loaders);
299
299
  }
300
300
 
301
+ /** Delete a single commerce loader by key. No-op if key is absent. */
302
+ export function unregisterCommerceLoader(key: string): void {
303
+ delete commerceLoaders[key];
304
+ }
305
+
306
+ /** Clear all commerce loaders. Use with care — wipes site-registered entries too. */
307
+ export function clearCommerceLoaders(): void {
308
+ for (const key of Object.keys(commerceLoaders)) delete commerceLoaders[key];
309
+ }
310
+
301
311
  // ---------------------------------------------------------------------------
302
312
  // Custom matchers
303
313
  // ---------------------------------------------------------------------------
@@ -34,7 +34,7 @@ interface CacheableSectionConfig {
34
34
  maxAge: number;
35
35
  }
36
36
 
37
- type CacheableSectionInput = CacheableSectionConfig | import("../sdk/cacheHeaders").CacheProfileName;
37
+ export type CacheableSectionInput = CacheableSectionConfig | import("../sdk/cacheHeaders").CacheProfileName;
38
38
 
39
39
  function resolveSectionCacheConfig(input: CacheableSectionInput): CacheableSectionConfig {
40
40
  if (typeof input === "string") {
@@ -9,7 +9,10 @@ import type { ReactNode } from "react";
9
9
  const rootRoute = createRootRoute();
10
10
 
11
11
  const previewRouter = createRouter({
12
- routeTree: rootRoute,
12
+ // TanStack Router's RootRoute/Route generic inference rejects bare rootRoute
13
+ // at the type level (works at runtime). `as any` avoids leaking the mismatch
14
+ // into consumer sites that typecheck framework source via npm link.
15
+ routeTree: rootRoute as any,
13
16
  history: createMemoryHistory({ initialEntries: ["/"] }),
14
17
  });
15
18
 
@@ -21,6 +21,10 @@
21
21
 
22
22
  import { clearInvokeHandlers, registerInvokeHandlers } from "../admin/invoke";
23
23
  import { registerSections } from "../cms/registry";
24
+ import {
25
+ registerCommerceLoaders,
26
+ unregisterCommerceLoader,
27
+ } from "../cms/resolve";
24
28
  import { RequestContext } from "./requestContext";
25
29
 
26
30
  // ---------------------------------------------------------------------------
@@ -68,6 +72,17 @@ const appMiddlewares: Array<{
68
72
  middleware: AppMiddleware;
69
73
  }> = [];
70
74
 
75
+ /**
76
+ * Keys this module wrote into the commerce-loaders map. Tracked so hot-reload
77
+ * can wipe app-owned entries without touching site-local registrations.
78
+ */
79
+ const appCommerceLoaderKeys = new Set<string>();
80
+
81
+ function clearAppCommerceLoaders() {
82
+ for (const key of appCommerceLoaderKeys) unregisterCommerceLoader(key);
83
+ appCommerceLoaderKeys.clear();
84
+ }
85
+
71
86
  function registerAppState(name: string, state: unknown) {
72
87
  appStates.push({ name, state });
73
88
  }
@@ -143,6 +158,26 @@ function flattenDependencies(apps: AppDefinition[]): AppDefinition[] {
143
158
  return result;
144
159
  }
145
160
 
161
+ /**
162
+ * Register handlers into the commerce-loaders map used by the CMS resolve path
163
+ * (src/cms/resolve.ts). Tracks the keys so clearAppCommerceLoaders() can revert
164
+ * them on hot-reload without clobbering site-registered loaders.
165
+ *
166
+ * CommerceLoader receives `(props)` only. The admin invoke path passes
167
+ * `(props, req)`; here `req` is undefined. Handlers that need `req` should
168
+ * rely on RequestContext instead of a positional argument.
169
+ */
170
+ function registerAppCommerceHandlers(
171
+ handlers: Record<string, (props: any, req: Request) => Promise<any>>,
172
+ ) {
173
+ const entries: Record<string, (props: any) => Promise<any>> = {};
174
+ for (const [key, handler] of Object.entries(handlers)) {
175
+ entries[key] = (props: any) => handler(props, undefined as unknown as Request);
176
+ appCommerceLoaderKeys.add(key);
177
+ }
178
+ registerCommerceLoaders(entries);
179
+ }
180
+
146
181
  // ---------------------------------------------------------------------------
147
182
  // Main pipeline
148
183
  // ---------------------------------------------------------------------------
@@ -161,34 +196,51 @@ export async function setupApps(
161
196
  // Clear previous registrations (safe for hot-reload via onChange)
162
197
  clearRegistrations();
163
198
  clearInvokeHandlers();
199
+ clearAppCommerceLoaders();
164
200
 
165
201
  for (const app of flattenDependencies(apps as AppDefinition[])) {
166
202
  const appWithHandlers = app as AppDefinitionWithHandlers;
167
203
 
168
204
  // 1. Register explicit handlers (pre-unwrapped by the app, e.g. resend)
205
+ // These also go into the commerce-loaders map so the CMS resolve path
206
+ // (src/cms/resolve.ts) can dispatch to them by __resolveType.
169
207
  if (appWithHandlers.handlers) {
170
208
  registerInvokeHandlers(appWithHandlers.handlers);
209
+ registerAppCommerceHandlers(appWithHandlers.handlers);
171
210
  }
172
211
 
173
- // 2. Flatten manifest modules → individual invoke handlers
174
- // manifest.actions["vtex/actions/checkout"] = { getOrCreateCart, addItemsToCart, ... }
175
- // register "vtex/actions/checkout/getOrCreateCart" as handler
212
+ // 2. Flatten manifest modules → individual invoke handlers.
213
+ //
214
+ // Convention (mirrors legacy deco-cx/apps):
215
+ // - `default` export is registered at the moduleKey itself.
216
+ // shopify/loaders/ProductList.ts (default) → key "shopify/loaders/ProductList"
217
+ // - Named function exports are registered at `${moduleKey}/${fnName}`.
218
+ // vtex/actions/checkout.ts ({ getOrCreateCart, addItemsToCart })
219
+ // → keys "vtex/actions/checkout/getOrCreateCart", ".../addItemsToCart"
220
+ // - Each key also gets a `.ts` sibling for callers that include the
221
+ // extension (admin invoke path, some __resolveType producers).
176
222
  for (const category of ["loaders", "actions"] as const) {
177
223
  const modules = app.manifest[category];
178
224
  if (!modules) continue;
179
225
 
180
226
  for (const [moduleKey, moduleExports] of Object.entries(modules)) {
181
- for (const [fnName, fn] of Object.entries(
182
- moduleExports as Record<string, unknown>,
183
- )) {
227
+ const exports = moduleExports as Record<string, unknown>;
228
+
229
+ for (const [fnName, fn] of Object.entries(exports)) {
184
230
  if (typeof fn !== "function") continue;
185
- const key = `${moduleKey}/${fnName}`;
231
+ const key = fnName === "default"
232
+ ? moduleKey
233
+ : `${moduleKey}/${fnName}`;
186
234
  const handler = (props: any, req: Request) =>
187
235
  (fn as Function)(props, req);
188
236
  registerInvokeHandlers({
189
237
  [key]: handler,
190
238
  [`${key}.ts`]: handler,
191
239
  });
240
+ registerAppCommerceHandlers({
241
+ [key]: handler,
242
+ [`${key}.ts`]: handler,
243
+ });
192
244
  }
193
245
  }
194
246
  }