From c30af54a3eba5bb6efa5aed40ced88823f0bb46d Mon Sep 17 00:00:00 2001 From: gimenes Date: Mon, 11 May 2026 13:59:27 -0300 Subject: [PATCH] fix(scripts): ship generate-* shims at package root for tsx drop-in Existing storefronts run `tsx node_modules/@decocms/start/scripts/generate-X.ts` from their package.json build script. 5.1.1 only shipped `dist/scripts/*.cjs`, breaking the path. Move the generator sources to `scripts/_impl/` (build-only) and ship 5 thin tsx shims at `scripts/generate-{blocks,sections,loaders,schema,invoke}.ts` that createRequire the bundled CJS. Verified end-to-end against a fake consumer layout. Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 5 + scripts/_impl/generate-blocks.ts | 118 +++++ scripts/_impl/generate-invoke.ts | 430 +++++++++++++++++ scripts/_impl/generate-loaders.ts | 200 ++++++++ scripts/_impl/generate-schema.ts | 741 ++++++++++++++++++++++++++++ scripts/_impl/generate-sections.ts | 219 +++++++++ scripts/generate-blocks.ts | 122 +---- scripts/generate-invoke.ts | 434 +---------------- scripts/generate-loaders.ts | 204 +------- scripts/generate-schema.ts | 745 +---------------------------- scripts/generate-sections.ts | 223 +-------- tsup.config.ts | 28 +- 12 files changed, 1754 insertions(+), 1715 deletions(-) create mode 100644 scripts/_impl/generate-blocks.ts create mode 100644 scripts/_impl/generate-invoke.ts create mode 100644 scripts/_impl/generate-loaders.ts create mode 100644 scripts/_impl/generate-schema.ts create mode 100644 scripts/_impl/generate-sections.ts diff --git a/package.json b/package.json index a7f930dd..24bda168 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,11 @@ "sideEffects": false, "files": [ "dist", + "scripts/generate-blocks.ts", + "scripts/generate-sections.ts", + "scripts/generate-loaders.ts", + "scripts/generate-schema.ts", + "scripts/generate-invoke.ts", "README.md", "LICENSE" ], diff --git a/scripts/_impl/generate-blocks.ts b/scripts/_impl/generate-blocks.ts new file mode 100644 index 00000000..b3f7cce8 --- /dev/null +++ b/scripts/_impl/generate-blocks.ts @@ -0,0 +1,118 @@ +#!/usr/bin/env tsx +/** + * Reads .deco/blocks/*.json and emits: + * 1. blocks.gen.json — compact JSON data (the source of truth) + * 2. blocks.gen.ts — thin TypeScript re-export for editor tooling + * + * At runtime the Vite plugin (src/tanstack/vite/plugin.js) intercepts `blocks.gen.ts` + * imports and replaces them with `JSON.parse(...)` of the .json file. This + * avoids Vite's SSR module runner hanging on large (13MB+) JS object literals + * and lets V8 use its fast JSON parser instead of the full JS parser. + * + * Usage (from site root): + * npx tsx node_modules/@decocms/start/scripts/generate-blocks.ts + * + * Env / CLI: + * --blocks-dir override input (default: .deco/blocks) + * --out-file override output (default: src/server/cms/blocks.gen.ts) + */ +import fs from "node:fs"; +import path from "node:path"; +import { + blockHasPath, + type Candidate, + decodeBlockNameWithPasses, + mergeCandidates, +} from "../lib/blocks-dedupe"; + +const args = process.argv.slice(2); +function arg(name: string, fallback: string): string { + const idx = args.indexOf(`--${name}`); + return idx !== -1 && args[idx + 1] ? args[idx + 1] : fallback; +} + +const blocksDir = path.resolve(process.cwd(), arg("blocks-dir", ".deco/blocks")); +const outFile = path.resolve(process.cwd(), arg("out-file", "src/server/cms/blocks.gen.ts")); +const jsonFile = outFile.replace(/\.ts$/, ".json"); + +const TS_STUB = [ + "// Auto-generated — thin wrapper around blocks.gen.json.", + "// The Vite plugin replaces this at load time with JSON.parse(...).", + "// Do not edit manually.", + "", + "export const blocks: Record = {};", + "", +].join("\n"); + +if (!fs.existsSync(blocksDir)) { + console.warn(`Blocks directory not found: ${blocksDir} — generating empty barrel.`); + fs.mkdirSync(path.dirname(outFile), { recursive: true }); + fs.writeFileSync(jsonFile, "{}"); + fs.writeFileSync(outFile, TS_STUB); + process.exit(0); +} + +const files = fs.readdirSync(blocksDir).filter((f) => f.endsWith(".json")); + +// Read each file into a Candidate, then let the dedupe lib pick the winner +// per decoded key and report any collisions. See `lib/blocks-dedupe.ts` for +// the priority order and the rationale behind it (TL;DR: never use file size, +// don't trust mtime alone in CI clones). +const candidatesWithKeys: Array<{ candidate: Candidate; key: string }> = []; +for (const file of files) { + const { name, passes } = decodeBlockNameWithPasses(file); + const fp = path.join(blocksDir, file); + let parsed: unknown; + try { + parsed = JSON.parse(fs.readFileSync(fp, "utf-8")); + } catch (e) { + console.warn(`Failed to parse ${file}:`, e); + continue; + } + candidatesWithKeys.push({ + key: name, + candidate: { + file, + passes, + mtimeMs: fs.statSync(fp).mtimeMs, + hasPath: blockHasPath(parsed), + parsed, + }, + }); +} + +const { winners, collisions } = mergeCandidates(candidatesWithKeys); + +if (collisions.length > 0) { + console.warn( + `Detected ${collisions.length} filename collision(s) in ${path.relative(process.cwd(), blocksDir)}:`, + ); + for (const c of collisions) { + const losers = c.files.filter((f) => f !== c.winner); + console.warn(` - ${c.key}`); + console.warn(` winner: ${c.winner}`); + for (const l of losers) console.warn(` ignore: ${l}`); + } + console.warn(" Cause: multiple writers (manual sync vs deco-sync-bot) producing"); + console.warn(" different filename encodings for the same logical key. Delete the"); + console.warn(" stale file(s) listed under 'ignore' to silence this warning."); +} + +const blocks: Record = {}; +for (const [name, c] of Object.entries(winners)) { + blocks[name] = c.parsed; +} + +fs.mkdirSync(path.dirname(outFile), { recursive: true }); + +// 1. Compact JSON — the real data (no pretty-printing to save ~40% size) +const jsonStr = JSON.stringify(blocks); +fs.writeFileSync(jsonFile, jsonStr); + +// 2. Thin TS wrapper — just for TypeScript tooling and as a Vite load target +fs.writeFileSync(outFile, TS_STUB); + +const jsonSizeMB = (Buffer.byteLength(jsonStr) / 1_048_576).toFixed(1); +console.log( + `Generated ${Object.keys(blocks).length} blocks → ${path.relative(process.cwd(), jsonFile)} (${jsonSizeMB} MB)`, +); diff --git a/scripts/_impl/generate-invoke.ts b/scripts/_impl/generate-invoke.ts new file mode 100644 index 00000000..b5efd475 --- /dev/null +++ b/scripts/_impl/generate-invoke.ts @@ -0,0 +1,430 @@ +#!/usr/bin/env tsx +/** + * Scans @decocms/apps vtex/invoke.ts and generates a site-local invoke file + * with top-level createServerFn declarations. + * + * TanStack Start's compiler only transforms createServerFn().handler() when + * the call is at module top-level (assigned to a const). The factory pattern + * used in @decocms/apps/vtex/invoke.ts causes the "fast path" in the compiler + * to skip the .handler() calls because they're inside a function body. + * + * This script generates an equivalent file where each server function is a + * top-level const, which the compiler can correctly transform into RPC stubs. + * + * Usage (from site root): + * npx tsx node_modules/@decocms/start/scripts/generate-invoke.ts + * + * Env / CLI: + * --out-file override output (default: src/server/invoke.gen.ts) + * --apps-dir override @decocms/apps location (default: auto-resolve from node_modules) + */ +import fs from "node:fs"; +import path from "node:path"; +import { Project, type PropertyAssignment, SyntaxKind } from "ts-morph"; + +const args = process.argv.slice(2); +function arg(name: string, fallback: string): string { + const idx = args.indexOf(`--${name}`); + return idx !== -1 && args[idx + 1] ? args[idx + 1] : fallback; +} + +const cwd = process.cwd(); +const outFile = path.resolve(cwd, arg("out-file", "src/server/invoke.gen.ts")); + +function resolveAppsDir(): string { + const explicit = arg("apps-dir", ""); + if (explicit) return path.resolve(cwd, explicit); + + // Walk up from CWD collecting every node_modules/@decocms/apps along the way + // (handles npm/bun hoisting in monorepos), plus a couple of dev fallbacks. + const candidates: string[] = []; + let dir = cwd; + while (true) { + candidates.push(path.join(dir, "node_modules/@decocms/apps")); + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + candidates.push(path.resolve(cwd, "../apps-start")); + candidates.push(path.resolve(cwd, "../decocms-apps")); + + // First pass: a candidate that has vtex/invoke.ts is fully usable. + for (const c of candidates) { + if (fs.existsSync(path.join(c, "vtex/invoke.ts"))) return c; + } + + // Second pass: a candidate exists as @decocms/apps but the source file we + // need to parse is missing. The published @decocms/apps tarball does not + // include vtex/invoke.ts — it is a dev-time source-of-truth file. Surface a + // distinct, actionable error so consumers don't chase a non-existent + // "package not installed" bug. + for (const c of candidates) { + if (fs.existsSync(path.join(c, "package.json"))) { + throw new Error( + `Found @decocms/apps at ${c} but it is missing vtex/invoke.ts.\n` + + `generate-invoke parses the TS source-of-truth, which is not shipped\n` + + `in the published npm tarball. Point --apps-dir at a local checkout of\n` + + `the decocms/apps-start repo (e.g. --apps-dir ../apps-start), or skip\n` + + `regeneration and use the existing src/server/invoke.gen.ts.`, + ); + } + } + + throw new Error( + "Could not find @decocms/apps in node_modules (walked up from " + + `${cwd}). Install it, or pass --apps-dir .`, + ); +} + +const appsDir = resolveAppsDir(); +const invokeFile = path.join(appsDir, "vtex/invoke.ts"); + +if (!fs.existsSync(invokeFile)) { + console.error(`invoke.ts not found at: ${invokeFile}`); + process.exit(1); +} + +// --------------------------------------------------------------------------- +// Parse the source invoke.ts to extract action definitions +// --------------------------------------------------------------------------- + +interface ActionDef { + name: string; + /** The import source for the action function (e.g., "@decocms/apps/vtex/actions/checkout") */ + importSource: string; + /** The imported function name (e.g., "addItemsToCart") */ + importedFn: string; + /** The input type as a string (e.g., "{ orderFormId: string; ... }") */ + inputType: string; + /** The return type as a string (e.g., "OrderForm") */ + returnType: string; + /** Whether to unwrap VtexFetchResult */ + unwrap: boolean; + /** The body of the action call (e.g., "addItemsToCart(input.orderFormId, input.orderItems)") */ + callBody: string; +} + +const project = new Project({ compilerOptions: { strict: true } }); +const sourceFile = project.addSourceFileAtPath(invokeFile); + +// Collect all imports to know which functions come from where +const importMap = new Map(); +for (const imp of sourceFile.getImportDeclarations()) { + const source = imp.getModuleSpecifierValue(); + for (const named of imp.getNamedImports()) { + const localName = named.getName(); + const importedName = named.getAliasNode()?.getText() || localName; + importMap.set(localName, { + source: source.startsWith("./") ? `@decocms/apps/vtex/${source.slice(2)}` : source, + importedName: localName, + }); + } +} + +// Collect type imports +const typeImportMap = new Map(); +for (const imp of sourceFile.getImportDeclarations()) { + if (!imp.isTypeOnly()) { + for (const named of imp.getNamedImports()) { + if (named.isTypeOnly()) { + const localName = named.getName(); + const source = imp.getModuleSpecifierValue(); + typeImportMap.set(localName, { + source: source.startsWith("./") ? `@decocms/apps/vtex/${source.slice(2)}` : source, + importedName: localName, + }); + } + } + } + if (imp.isTypeOnly()) { + const source = imp.getModuleSpecifierValue(); + for (const named of imp.getNamedImports()) { + const localName = named.getName(); + typeImportMap.set(localName, { + source: source.startsWith("./") ? `@decocms/apps/vtex/${source.slice(2)}` : source, + importedName: localName, + }); + } + } +} + +// Find the invoke const and extract actions +const invokeVar = sourceFile.getVariableDeclaration("invoke"); +if (!invokeVar) { + console.error("Could not find 'export const invoke' in invoke.ts"); + process.exit(1); +} + +const actions: ActionDef[] = []; +const invokeInit = invokeVar.getInitializer(); +if (!invokeInit) { + console.error("invoke variable has no initializer"); + process.exit(1); +} + +// Navigate: invoke → .vtex → .actions → each property +const vtexProp = invokeInit + .asKindOrThrow(SyntaxKind.AsExpression) + .getExpression() + .asKindOrThrow(SyntaxKind.ObjectLiteralExpression) + .getProperty("vtex"); + +if (!vtexProp) { + console.error("Could not find 'vtex' property in invoke object"); + process.exit(1); +} + +const vtexObj = (vtexProp as PropertyAssignment) + .getInitializer()! + .asKindOrThrow(SyntaxKind.ObjectLiteralExpression); + +const actionsProp = vtexObj.getProperty("actions"); +if (!actionsProp) { + console.error("Could not find 'actions' property in vtex object"); + process.exit(1); +} + +const actionsObj = (actionsProp as PropertyAssignment) + .getInitializer()! + .asKindOrThrow(SyntaxKind.ObjectLiteralExpression); + +for (const prop of actionsObj.getProperties()) { + if (prop.getKind() !== SyntaxKind.PropertyAssignment) continue; + const pa = prop as PropertyAssignment; + const name = pa.getName(); + const initText = pa.getInitializer()!.getText(); + + // Check if it uses createInvokeFn with unwrap + const unwrap = initText.includes("unwrap: true"); + + // Extract the arrow function body from createInvokeFn((input: ...) => ...) + // We'll parse the call expression to get the action call + const callExpr = pa.getInitializer()!; + let inputType = "any"; + let callBody = ""; + + // Recursively unwrap AsExpression chains (e.g. `expr as unknown as Type`) + let createInvokeFnCall = callExpr; + while (createInvokeFnCall.getKind() === SyntaxKind.AsExpression) { + createInvokeFnCall = createInvokeFnCall.asKindOrThrow(SyntaxKind.AsExpression).getExpression(); + } + + // Now we have createInvokeFn(...) call + if (createInvokeFnCall.getKind() === SyntaxKind.CallExpression) { + const callArgs = createInvokeFnCall.asKindOrThrow(SyntaxKind.CallExpression).getArguments(); + if (callArgs.length >= 1) { + const arrowFn = callArgs[0]; + if (arrowFn.getKind() === SyntaxKind.ArrowFunction) { + const arrow = arrowFn.asKindOrThrow(SyntaxKind.ArrowFunction); + const params = arrow.getParameters(); + if (params.length >= 1) { + const paramType = params[0].getTypeNode()?.getText() || "any"; + inputType = paramType; + } + // Get the body (the actual action call) + const body = arrow.getBody(); + callBody = body.getText(); + + // If body is a block, extract the expression + if (callBody.startsWith("{")) { + // It's a block body — skip for now, use simplified version + callBody = ""; + } + } + } + } + + // Determine which function is being called + let importedFn = ""; + let importSource = ""; + for (const [fnName, info] of importMap.entries()) { + if (callBody.includes(`${fnName}(`)) { + importedFn = fnName; + importSource = info.source; + break; + } + } + + // Extract the return type from the outermost "as" assertion. + // For `expr as unknown as (ctx: ...) => Promise`, the outermost + // AsExpression has the function type with Promise. + let returnType = "any"; + if (callExpr.getKind() === SyntaxKind.AsExpression) { + const asExpr = callExpr.asKindOrThrow(SyntaxKind.AsExpression); + const typeText = asExpr.getTypeNode()?.getText() || ""; + if (typeText !== "unknown") { + const promiseMatch = typeText.match(/Promise<(.+)>$/s); + if (promiseMatch) { + returnType = promiseMatch[1].trim(); + } + } + } + + actions.push({ + name, + importSource, + importedFn, + inputType, + returnType, + unwrap, + callBody, + }); +} + +// --------------------------------------------------------------------------- +// Generate the output file +// --------------------------------------------------------------------------- + +// Collect unique imports needed +const fnImports = new Map>(); +const typeImports = new Map>(); + +for (const action of actions) { + if (action.importSource && action.importedFn) { + if (!fnImports.has(action.importSource)) { + fnImports.set(action.importSource, new Set()); + } + fnImports.get(action.importSource)!.add(action.importedFn); + } +} + +// Add type imports referenced in inputType or returnType +for (const action of actions) { + const allText = action.inputType + action.returnType + action.callBody; + for (const [typeName, info] of typeImportMap.entries()) { + if (allText.includes(typeName)) { + if (!typeImports.has(info.source)) { + typeImports.set(info.source, new Set()); + } + typeImports.get(info.source)!.add(typeName); + } + } + // Also check value imports that appear in the types (like SimulationItem) + for (const [fnName, info] of importMap.entries()) { + if (action.inputType.includes(fnName) && !fnImports.get(info.source)?.has(fnName)) { + if (!typeImports.has(info.source)) { + typeImports.set(info.source, new Set()); + } + typeImports.get(info.source)!.add(fnName); + } + } +} + +// Count how many actually parsed vs. stubbed +const parsed = actions.filter((a) => a.importedFn).length; +const stubbed = actions.length - parsed; +if (stubbed > 0) { + console.warn(`⚠ ${stubbed} action(s) could not be parsed — generated as stubs:`); + for (const a of actions) { + if (!a.importedFn) console.warn(` - ${a.name}`); + } +} + +// Build output +let out = `// Auto-generated by @decocms/start/scripts/generate-invoke.ts +// Do not edit manually. Re-run the generator to update. +// +// Each server function is a top-level const so TanStack Start's compiler +// can transform createServerFn().handler() into RPC stubs on the client. +// +// Site-specific extensions: import { vtexActions } from this file and merge +// with your own actions in a separate invoke.ts. +import { createServerFn } from "@tanstack/react-start"; +`; + +// Add function imports +for (const [source, fns] of fnImports) { + out += `import { ${[...fns].join(", ")} } from "${source}";\n`; +} + +// Add type imports +for (const [source, types] of typeImports) { + // Don't duplicate if already imported as value + const valueImports = fnImports.get(source); + const onlyTypes = [...types].filter((t) => !valueImports?.has(t)); + if (onlyTypes.length > 0) { + out += `import type { ${onlyTypes.join(", ")} } from "${source}";\n`; + } +} + +out += ` +function unwrapResult(result: unknown): T { + if (result && typeof result === "object" && "data" in result) { + return (result as { data: T }).data; + } + return result as T; +} + +// --------------------------------------------------------------------------- +// Top-level server function declarations +// --------------------------------------------------------------------------- +`; + +for (const action of actions) { + const varName = `$${action.name}`; + + if (action.importedFn) { + // All @decocms/apps action functions take a single props object. + // Pass the validated `data` object directly — never destructure into + // positional arguments, which breaks when function signatures change. + if (action.unwrap) { + out += `\nconst ${varName} = createServerFn({ method: "POST" }) + .inputValidator((data: ${action.inputType}) => data) + .handler(async ({ data }): Promise => { + const result = await ${action.importedFn}(data); + return unwrapResult(result); + });\n`; + } else { + out += `\nconst ${varName} = createServerFn({ method: "POST" }) + .inputValidator((data: ${action.inputType}) => data) + .handler(async ({ data }): Promise => { + return ${action.importedFn}(data); + });\n`; + } + } else { + // Fallback: couldn't parse — generate a stub + out += `\n// TODO: could not auto-generate ${action.name} — add manually\nconst ${varName} = createServerFn({ method: "POST" }) + .handler(async () => { + throw new Error("${action.name}: not implemented — regenerate invoke"); + });\n`; + } +} + +// Generate the vtexActions object (for composability with site-specific actions) +out += ` +// --------------------------------------------------------------------------- +// Typed VTEX actions map — merge with site-specific actions in your invoke.ts +// --------------------------------------------------------------------------- + +export const vtexActions = { +`; + +for (const action of actions) { + const varName = `$${action.name}`; + if (action.returnType !== "any") { + out += ` ${action.name}: ${varName} as unknown as (ctx: { data: ${action.inputType} }) => Promise<${action.returnType}>,\n`; + } else { + out += ` ${action.name}: ${varName},\n`; + } +} + +out += `} as const; + +// Re-export OrderForm type (commonly imported from invoke by site components) +export type { OrderForm } from "@decocms/apps/vtex/types"; + +// --------------------------------------------------------------------------- +// Default invoke object — import this if you don't need site extensions +// --------------------------------------------------------------------------- + +export const invoke = { + vtex: { + actions: vtexActions, + }, +} as const; +`; + +// Write output +fs.mkdirSync(path.dirname(outFile), { recursive: true }); +fs.writeFileSync(outFile, out); +console.log(`Generated ${actions.length} server functions → ${path.relative(cwd, outFile)}`); diff --git a/scripts/_impl/generate-loaders.ts b/scripts/_impl/generate-loaders.ts new file mode 100644 index 00000000..9214ac2c --- /dev/null +++ b/scripts/_impl/generate-loaders.ts @@ -0,0 +1,200 @@ +#!/usr/bin/env tsx +/** + * Scans site loader and action files and generates a registry map + * for COMMERCE_LOADERS pass-through entries. + * + * Each loader/action file that exports a default function gets a generated + * entry like: + * "site/loaders/SAP/getUser": async (props, request) => { + * const mod = await import("../../loaders/SAP/getUser"); + * return mod.default(props, request); + * }, + * + * Both keyed with and without `.ts` suffix for CMS block compatibility. + * + * Files listed in --exclude are skipped (they need custom wiring in setup.ts). + * + * CMS-aware filtering (`--decofile-dir`): when supplied, the script walks + * every JSON file in the directory and collects the set of `__resolveType` + * references. Only loaders whose key appears in that set are emitted — + * keeping the registry to what the site actually uses and avoiding the + * "200 dead passthroughs" pattern. + * + * Usage (from site root): + * npx tsx node_modules/@decocms/start/scripts/generate-loaders.ts + * npx tsx node_modules/@decocms/start/scripts/generate-loaders.ts --decofile-dir .deco/blocks + * + * CLI: + * --loaders-dir override loaders input (default: src/loaders) + * --actions-dir override actions input (default: src/actions) + * --out-file override output (default: src/server/cms/loaders.gen.ts) + * --exclude comma-separated list of loader keys to skip (they have custom wiring) + * --decofile-dir if provided, only emit entries whose key appears as `__resolveType` in any JSON + */ +import fs from "node:fs"; +import path from "node:path"; + +const args = process.argv.slice(2); +function arg(name: string, fallback: string): string { + const idx = args.indexOf(`--${name}`); + return idx !== -1 && args[idx + 1] ? args[idx + 1] : fallback; +} + +const loadersDir = path.resolve(process.cwd(), arg("loaders-dir", "src/loaders")); +const actionsDir = path.resolve(process.cwd(), arg("actions-dir", "src/actions")); +const outFile = path.resolve(process.cwd(), arg("out-file", "src/server/cms/loaders.gen.ts")); +const excludeRaw = arg("exclude", ""); +const excludeSet = new Set(excludeRaw.split(",").map((s) => s.trim()).filter(Boolean)); +const decofileDirRaw = arg("decofile-dir", ""); +const decofileDir = decofileDirRaw ? path.resolve(process.cwd(), decofileDirRaw) : null; + +function walkDir(dir: string): string[] { + const results: string[] = []; + if (!fs.existsSync(dir)) return results; + + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + results.push(...walkDir(fullPath)); + } else if (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) { + results.push(fullPath); + } + } + return results; +} + +function fileToKey(filePath: string, baseDir: string, prefix: string): string { + const rel = path.relative(baseDir, filePath).replace(/\\/g, "/").replace(/\.tsx?$/, ""); + return `${prefix}/${rel}`; +} + +function relativeImportPath(from: string, to: string): string { + let rel = path.relative(path.dirname(from), to).replace(/\\/g, "/"); + if (!rel.startsWith(".")) rel = `./${rel}`; + return rel.replace(/\.tsx?$/, ""); +} + +function hasDefaultExport(content: string): boolean { + return /export\s+default\b/.test(content) || /export\s*\{[^}]*\bdefault\b/.test(content); +} + +// --------------------------------------------------------------------------- +// CMS-referenced loader discovery +// +// Walk every JSON file under decofileDir and collect the set of strings that +// appear as `__resolveType` values. The migration script + generators emit +// pass-throughs for every loader/action file on disk; without this filter, +// 90%+ of those entries are dead code (the CMS never references them) and +// they pollute the type system and bundle. +// --------------------------------------------------------------------------- + +function collectResolveTypes(dir: string): Set { + const found = new Set(); + if (!fs.existsSync(dir)) return found; + + const RESOLVE_RE = /"__resolveType"\s*:\s*"([^"]+)"/g; + + function visit(d: string) { + for (const entry of fs.readdirSync(d, { withFileTypes: true })) { + const fullPath = path.join(d, entry.name); + if (entry.isDirectory()) { + visit(fullPath); + } else if (entry.name.endsWith(".json")) { + const content = fs.readFileSync(fullPath, "utf-8"); + let m: RegExpExecArray | null; + while ((m = RESOLVE_RE.exec(content)) !== null) { + found.add(m[1]); + } + } + } + } + + visit(dir); + return found; +} + +const cmsReferences = decofileDir ? collectResolveTypes(decofileDir) : null; + +function isReferenced(key: string): boolean { + if (!cmsReferences) return true; + return cmsReferences.has(key) || cmsReferences.has(`${key}.ts`); +} + +// --------------------------------------------------------------------------- + +interface LoaderEntry { + key: string; + importPath: string; +} + +const entries: LoaderEntry[] = []; +let prunedCount = 0; + +for (const filePath of walkDir(loadersDir)) { + const content = fs.readFileSync(filePath, "utf-8"); + if (!hasDefaultExport(content)) continue; + const key = fileToKey(filePath, loadersDir, "site/loaders"); + if (excludeSet.has(key) || excludeSet.has(`${key}.ts`)) continue; + if (!isReferenced(key)) { + prunedCount++; + continue; + } + entries.push({ + key, + importPath: relativeImportPath(outFile, filePath), + }); +} + +for (const filePath of walkDir(actionsDir)) { + const content = fs.readFileSync(filePath, "utf-8"); + if (!hasDefaultExport(content)) continue; + const key = fileToKey(filePath, actionsDir, "site/actions"); + if (excludeSet.has(key) || excludeSet.has(`${key}.ts`)) continue; + if (!isReferenced(key)) { + prunedCount++; + continue; + } + entries.push({ + key, + importPath: relativeImportPath(outFile, filePath), + }); +} + +entries.sort((a, b) => a.key.localeCompare(b.key)); + +const lines: string[] = [ + "// Auto-generated by @decocms/start/scripts/generate-loaders.ts", + "// Do not edit manually. Run `npm run generate:loaders` to update.", + "//", + "// Pass-through loader/action entries for COMMERCE_LOADERS.", + "// Custom-wired entries should be excluded via --exclude and added manually in setup.ts.", + "", + "export const siteLoaders: Record Promise> = {", +]; + +// Cast the dynamic-import default to `any` so legacy 3-arg +// `(props, req, ctx)` Fresh/Deno loaders still type-check. Any ctx-dependent +// path in the loader body throws at runtime and must be refactored. +for (const entry of entries) { + lines.push(` "${entry.key}": async (props: any, request?: Request) => {`); + lines.push(` const mod = await import("${entry.importPath}");`); + lines.push(" return (mod.default as any)(props, request);"); + lines.push(" },"); + lines.push(` "${entry.key}.ts": async (props: any, request?: Request) => {`); + lines.push(` const mod = await import("${entry.importPath}");`); + lines.push(" return (mod.default as any)(props, request);"); + lines.push(" },"); +} + +lines.push("};"); +lines.push(""); + +fs.mkdirSync(path.dirname(outFile), { recursive: true }); +fs.writeFileSync(outFile, lines.join("\n")); + +const filterNote = cmsReferences + ? ` (filtered against ${cmsReferences.size} CMS __resolveType references; pruned ${prunedCount} dead entries)` + : ""; +console.log( + `Generated ${entries.length} loader entries (${entries.length * 2} with .ts aliases) → ${path.relative(process.cwd(), outFile)}${filterNote}`, +); diff --git a/scripts/_impl/generate-schema.ts b/scripts/_impl/generate-schema.ts new file mode 100644 index 00000000..f6c493e8 --- /dev/null +++ b/scripts/_impl/generate-schema.ts @@ -0,0 +1,741 @@ +#!/usr/bin/env tsx +import fs from "node:fs"; +import path from "node:path"; +/** + * Schema Generator for deco admin compatibility. + * + * Scans src/sections/ for .tsx files, parses their Props interfaces, + * and generates JSON Schema 7 definitions in the format expected by + * the deco admin (/deco/meta endpoint). + * + * Usage (from site root): + * npx tsx node_modules/@decocms/start/scripts/generate-schema.ts [options] + * + * Options: + * --namespace Section namespace (default: "site") + * --site Site name (default: "storefront") + * --version Framework version (default: "1.0.0") + * --sections Sections directory (default: "src/sections") + * --out Output file (default: "src/server/admin/meta.gen.json") + * --platform Platform name (default: "cloudflare") + */ +import { + type Symbol as MorphSymbol, + Node, + Project, + type SourceFile, + SyntaxKind, + type Type, +} from "ts-morph"; + +// --------------------------------------------------------------------------- +// CLI arg parsing +// --------------------------------------------------------------------------- +const argv = process.argv.slice(2); +function arg(name: string, fallback: string): string { + const idx = argv.indexOf(`--${name}`); + return idx !== -1 && argv[idx + 1] ? argv[idx + 1] : fallback; +} + +const SITE_NAMESPACE = arg("namespace", "site"); +const SITE_NAME = arg("site", "storefront"); +const FRAMEWORK_VERSION = arg("version", "1.0.0"); +const SECTIONS_REL = arg("sections", "src/sections"); +const OUT_REL = arg("out", "src/server/admin/meta.gen.json"); +const PLATFORM = arg("platform", "cloudflare"); + +// --------------------------------------------------------------------------- +// Interfaces +// --------------------------------------------------------------------------- +interface MetaResponse { + major: number; + version: string; + namespace: string; + site: string; + manifest: { blocks: Record> }; + schema: { definitions: Record; root: Record }; + platform: string; + cloudProvider: string; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- +function toBase64(str: string): string { + return Buffer.from(str).toString("base64"); +} + +/** + * Map JSDoc tags to JSON Schema 7 keywords. + * Supports all 20+ tags from deco-cx/deco. + */ +/** + * Tags that receive special type coercion (not just string passthrough). + * Matches the original deco-cx/deco parseJSDocAttribute behaviour. + */ +const NUMERIC_TAGS = new Set([ + "maximum", + "minimum", + "exclusiveMaximum", + "exclusiveMinimum", + "multipleOf", + "maxLength", + "minLength", + "maxItems", + "minItems", + "maxProperties", + "minProperties", +]); +const BOOLEAN_TAGS = new Set(["readOnly", "writeOnly", "deprecated", "uniqueItems", "ignore"]); + +function applyJsDocToSchema(schema: any, tags: Record): void { + for (const [tag, value] of Object.entries(tags)) { + if (tag === "ignore") continue; + + // Tags with special coercion + if (tag === "hide") { + schema.hide = "true"; + continue; + } + + if (tag === "default") { + if (value === "true") schema.default = true; + else if (value === "false") schema.default = false; + else if (value === "null") schema.default = null; + else if (!isNaN(Number(value)) && value.trim() !== "") schema.default = Number(value); + else { + try { + schema.default = JSON.parse(value); + } catch { + schema.default = value; + } + } + continue; + } + + if (tag === "examples") { + const lines = value + .split("\n") + .map((l) => l.trim()) + .filter(Boolean); + schema.examples = + lines.length > 1 + ? lines + : (() => { + try { + return JSON.parse(value); + } catch { + return [value]; + } + })(); + continue; + } + + if (NUMERIC_TAGS.has(tag)) { + schema[tag] = Number(value); + continue; + } + if (BOOLEAN_TAGS.has(tag)) { + schema[tag] = value === "true"; + continue; + } + + // Everything else: pass through as-is (matching original deco behaviour) + // Covers: title, description, format, widget, icon, titleBy, mode, + // hideOption, label, options, pattern, section, group, placeholder, etc. + schema[tag] = value; + } +} + +const WIDGET_TYPE_FORMATS: Record = { + ImageWidget: "image-uri", + VideoWidget: "video-uri", + HTMLWidget: "html", + RichText: "rich-text", + Color: "color", + Secret: "password", + TextArea: "textarea", + Code: "code", + DateTimeWidget: "date-time", +}; + +/** + * Detect known widget types and set the appropriate format. + */ +function applyWidgetDetection(schema: any, typeText: string): void { + if (schema.format) return; + + for (const [widgetType, format] of Object.entries(WIDGET_TYPE_FORMATS)) { + if (typeText === widgetType || typeText.includes(widgetType)) { + schema.format = format; + return; + } + } +} + +/** + * Smart widget format application that handles arrays, nullable types, + * and union types by applying the format to the correct inner schema. + */ +function applyWidgetFormat(schema: any, typeHint: string): void { + const matchedFormat = Object.entries(WIDGET_TYPE_FORMATS).find( + ([wt]) => typeHint === wt || typeHint.includes(wt), + )?.[1]; + + if (!matchedFormat) { + applyWidgetDetection(schema, typeHint); + return; + } + + if (schema.type === "string" && !schema.format) { + schema.format = matchedFormat; + } else if (schema.type === "array" && schema.items) { + if (schema.items.type === "string" && !schema.items.format) { + schema.items.format = matchedFormat; + } + } else if (schema.anyOf) { + for (const variant of schema.anyOf) { + if (variant.type === "string" && !variant.format) { + variant.format = matchedFormat; + } + } + } +} + +// Well-known definition key for Section type references resolved by composeMeta +const SECTION_REF_DEF_KEY = "__SECTION_REF__"; + +// Only truly React-internal props that are never user-defined. +// Do NOT include "children", "type", or "props" — those are commonly used +// as legitimate section property names. +const REACT_INTERNAL_PROPS = new Set([ + "key", + "ref", + "then", + "catch", + "finally", + "$$typeof", + "_owner", + "_store", +]); + +function typeToJsonSchema(type: Type, visited = new Set()): any { + const typeText = type.getText(); + if (visited.has(typeText)) return { type: "object" }; + visited.add(typeText); + + try { + // any / unknown → accept anything + if (type.isAny() || type.isUnknown()) return {}; + + // ReactNode, JSX.Element, VNode → hide from form + if ( + /\bReactNode\b|\bJSX\.Element\b|\bReactElement\b|\bVNode\b|\bComponentChildren\b/.test( + typeText, + ) + ) { + return { type: "object", hide: "true" }; + } + + if (type.isString() || type.isStringLiteral()) { + return type.isStringLiteral() + ? { type: "string", const: type.getLiteralValue() } + : { type: "string" }; + } + if (type.isNumber() || type.isNumberLiteral()) return { type: "number" }; + if (type.isBoolean() || type.isBooleanLiteral()) return { type: "boolean" }; + if (type.isNull() || type.isUndefined()) return { type: "null" }; + + if (type.isArray()) { + const el = type.getArrayElementType(); + return el + ? { type: "array", items: typeToJsonSchema(el, new Set(visited)) } + : { type: "array" }; + } + + if (type.isUnion()) { + const parts = type.getUnionTypes(); + const nonNull = parts.filter((t) => !t.isNull() && !t.isUndefined()); + const isNullable = nonNull.length < parts.length; + + if (nonNull.length === 1) { + const inner = typeToJsonSchema(nonNull[0], new Set(visited)); + return isNullable ? { ...inner, nullable: true } : inner; + } + + // boolean? → true | false | undefined → collapse to { type: "boolean" } + if (nonNull.every((t) => t.isBooleanLiteral())) { + const result: any = { type: "boolean" }; + if (isNullable) result.nullable = true; + return result; + } + + if (nonNull.every((t) => t.isStringLiteral())) { + const result: any = { type: "string", enum: nonNull.map((t) => t.getLiteralValue()) }; + if (isNullable) result.nullable = true; + return result; + } + + // 1 | 2 | 3 → { type: "number", enum: [1, 2, 3] } + if (nonNull.every((t) => t.isNumberLiteral())) { + const result: any = { type: "number", enum: nonNull.map((t) => t.getLiteralValue()) }; + if (isNullable) result.nullable = true; + return result; + } + + // General anyOf — try to add title to each variant for discriminated unions + const anyOf = nonNull.map((t) => { + const schema = typeToJsonSchema(t, new Set(visited)); + if (!schema.title && schema.type === "object") { + const sym = t.getAliasSymbol() ?? t.getSymbol(); + const symName = sym?.getName(); + if (symName && symName !== "__type" && symName !== "default") { + schema.title = symName; + } + // Fallback: use a const discriminator field value as title + if (!schema.title && schema.properties) { + for (const v of Object.values(schema.properties) as any[]) { + if (v?.const !== undefined) { + schema.title = String(v.const); + break; + } + } + } + } + return schema; + }); + + const result: any = { anyOf }; + if (isNullable) result.nullable = true; + return result; + } + + if (type.isObject() || type.isInterface()) { + // Record → { type: "object", additionalProperties: V-schema } + const stringIdx = type.getStringIndexType(); + const numberIdx = type.getNumberIndexType(); + if ((stringIdx || numberIdx) && type.getProperties().length === 0) { + const valType = (stringIdx || numberIdx)!; + return { + type: "object", + additionalProperties: typeToJsonSchema(valType, new Set(visited)), + }; + } + + const properties: Record = {}; + const required: string[] = []; + + for (const prop of type.getProperties()) { + const name = prop.getName(); + if (name.startsWith("_") || name.startsWith("$") || name === "@type") continue; + if (REACT_INTERNAL_PROPS.has(name)) continue; + + const decl = prop.getValueDeclaration(); + if (!decl) continue; + const propType = prop.getTypeAtLocation(decl); + + const tags = getJsDocTags(prop); + if (tags.ignore) continue; + + // Get AST type-annotation text before resolving + let typeHint = propType.getText(); + const typeNode = + decl.getChildrenOfKind(SyntaxKind.TypeReference)[0] ?? + decl.getChildAtIndex(decl.getChildCount() - 1); + if (typeNode && Node.isTypeReference(typeNode)) { + typeHint = typeNode.getText(); + } else if (Node.isPropertySignature(decl) || Node.isPropertyDeclaration(decl)) { + const tn = (decl as any).getTypeNode?.(); + if (tn) typeHint = tn.getText(); + } + + // Section type → section picker reference (resolved by composeMeta) + const baseHint = typeHint.replace(/\s*\|\s*(null|undefined)/g, "").trim(); + if (baseHint === "Section" || baseHint === "Section[]" || baseHint === "Section[] | null") { + const isArray = baseHint.includes("[]"); + const sectionSchema: any = isArray + ? { + type: "array", + items: { $ref: `#/definitions/${SECTION_REF_DEF_KEY}` }, + title: name.charAt(0).toUpperCase() + name.slice(1), + } + : { + $ref: `#/definitions/${SECTION_REF_DEF_KEY}`, + title: name.charAt(0).toUpperCase() + name.slice(1), + }; + if (prop.isOptional() || typeHint.includes("null") || typeHint.includes("undefined")) { + sectionSchema.nullable = true; + } + applyJsDocToSchema(sectionSchema, tags); + properties[name] = sectionSchema; + if (!prop.isOptional()) required.push(name); + continue; + } + + const schema = typeToJsonSchema(propType, new Set(visited)); + + applyJsDocToSchema(schema, tags); + applyWidgetFormat(schema, typeHint); + + if (!schema.title) schema.title = name.charAt(0).toUpperCase() + name.slice(1); + + properties[name] = schema; + if (!prop.isOptional()) required.push(name); + } + + const result: any = { type: "object", properties }; + if (required.length > 0) result.required = required; + return result; + } + + return { type: "string" }; + } finally { + visited.delete(typeText); + } +} + +function getJsDocTags(symbol: MorphSymbol): Record { + const tags: Record = {}; + for (const decl of symbol.getDeclarations()) { + const jsDocs = Node.isJSDocable(decl) ? decl.getJsDocs() : []; + for (const doc of jsDocs) { + const desc = doc.getDescription().trim(); + if (desc) tags.description = desc; + for (const tag of doc.getTags()) { + tags[tag.getTagName()] = tag.getCommentText()?.trim() || "true"; + } + } + } + return tags; +} + +/** + * Extract the first parameter's type from a component's default export + * using the type checker. Works regardless of whether the export is a + * function declaration, arrow function, const assignment, or re-export. + */ +function extractDefaultExportPropsType(sourceFile: import("ts-morph").SourceFile): Type | null { + const symbol = sourceFile.getDefaultExportSymbol(); + if (!symbol) return null; + + const exportType = symbol.getTypeAtLocation(sourceFile); + const callSigs = exportType.getCallSignatures(); + if (callSigs.length === 0) return null; + + const params = callSigs[0].getParameters(); + if (params.length === 0) return null; + + const paramType = params[0].getTypeAtLocation(sourceFile); + if (paramType.isAny() || paramType.getText() === "{}") return null; + + return paramType; +} + +/** + * Resolve a module specifier to an absolute file path. + */ +function resolveModulePath( + moduleSpec: string, + fromFile: string, + projectRoot: string, +): string | null { + let target = moduleSpec; + if (target.startsWith("~/")) { + target = path.resolve(projectRoot, "src", target.slice(2)); + } else if (target.startsWith("./") || target.startsWith("../")) { + target = path.resolve(path.dirname(fromFile), target); + } + if (!target.match(/\.(tsx?|jsx?)$/)) { + for (const ext of [".tsx", ".ts", ".jsx", ".js"]) { + if (fs.existsSync(target + ext)) return target + ext; + } + if (fs.existsSync(path.join(target, "index.tsx"))) return path.join(target, "index.tsx"); + if (fs.existsSync(path.join(target, "index.ts"))) return path.join(target, "index.ts"); + } + return fs.existsSync(target) ? target : null; +} + +type SourceFileCache = Map; +type ModuleResolutionCache = Map; +type PropsSchemaCache = Map; + +function getSourceFile( + project: import("ts-morph").Project, + filePath: string, + cache: SourceFileCache, +): SourceFile { + const normalizedPath = path.resolve(filePath); + const cached = cache.get(normalizedPath); + if (cached) return cached; + + const sourceFile = + project.getSourceFile(normalizedPath) ?? project.addSourceFileAtPath(normalizedPath); + cache.set(normalizedPath, sourceFile); + return sourceFile; +} + +function resolveModulePathCached( + moduleSpec: string, + fromFile: string, + projectRoot: string, + cache: ModuleResolutionCache, +): string | null { + const key = `${fromFile}\0${moduleSpec}`; + if (cache.has(key)) return cache.get(key) ?? null; + + const resolved = resolveModulePath(moduleSpec, fromFile, projectRoot); + cache.set(key, resolved); + return resolved; +} + +/** + * Recursively follow `export { default } from "..."` chains (up to maxDepth hops) + * and try to extract Props from each target file. + */ +function resolvePropsViaReExport( + project: import("ts-morph").Project, + sourceFile: import("ts-morph").SourceFile, + filePath: string, + projectRoot: string, + maxDepth: number, + sourceFileCache: SourceFileCache, + moduleResolutionCache: ModuleResolutionCache, + propsSchemaCache: PropsSchemaCache, +): any | null { + if (maxDepth <= 0) return null; + + for (const exportDecl of sourceFile.getExportDeclarations()) { + const moduleSpec = exportDecl.getModuleSpecifierValue(); + if (!moduleSpec) continue; + const hasDefault = exportDecl.getNamedExports().some((n) => { + const name = n.getName(); + const alias = n.getAliasNode()?.getText(); + return name === "default" || alias === "default"; + }); + if (!hasDefault) continue; + + const targetPath = resolveModulePathCached( + moduleSpec, + filePath, + projectRoot, + moduleResolutionCache, + ); + if (!targetPath) continue; + + const cachedProps = propsSchemaCache.get(targetPath); + if (cachedProps) return cachedProps; + + try { + const targetFile = getSourceFile(project, targetPath, sourceFileCache); + + const targetProps = targetFile.getInterface("Props"); + if (targetProps) { + const schema = typeToJsonSchema(targetProps.getType()); + propsSchemaCache.set(targetPath, schema); + return schema; + } + + const targetAlias = targetFile.getTypeAlias("Props"); + if (targetAlias) { + const schema = typeToJsonSchema(targetAlias.getType()); + propsSchemaCache.set(targetPath, schema); + return schema; + } + + // Type-checker approach: extract from default export call signature + const propsType = extractDefaultExportPropsType(targetFile); + if (propsType) { + const schema = typeToJsonSchema(propsType); + propsSchemaCache.set(targetPath, schema); + return schema; + } + + // Recurse: target might also re-export from another file + const deeper = resolvePropsViaReExport( + project, + targetFile, + targetPath, + projectRoot, + maxDepth - 1, + sourceFileCache, + moduleResolutionCache, + propsSchemaCache, + ); + if (deeper) { + propsSchemaCache.set(targetPath, deeper); + return deeper; + } + } catch { + // Target file couldn't be parsed + } + } + return null; +} + +function findTsxFiles(dir: string): string[] { + const results: string[] = []; + if (!fs.existsSync(dir)) return results; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) results.push(...findTsxFiles(full)); + else if (entry.name.endsWith(".tsx") || entry.name.endsWith(".ts")) results.push(full); + } + return results; +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- +function generateMeta(): MetaResponse { + const root = process.cwd(); + const sectionsDir = path.resolve(root, SECTIONS_REL); + const srcDir = path.join(root, "src"); + + const project = new Project({ + tsConfigFilePath: path.join(root, "tsconfig.json"), + skipAddingFilesFromTsConfig: true, + }); + + const definitions: Record = {}; + const sectionBlocks: Record = {}; + const sectionRootAnyOf: any[] = []; + const sourceFileCache: SourceFileCache = new Map(); + const moduleResolutionCache: ModuleResolutionCache = new Map(); + const propsSchemaCache: PropsSchemaCache = new Map(); + + // Resolvable: the admin's deRefUntil expects the LITERAL key "Resolvable", + // not a base64-encoded version. We store both for compatibility. + const RESOLVABLE_KEY = "Resolvable"; + const resolvableB64Key = toBase64("Resolvable"); + const resolvableDef = { + title: "Select from saved", + type: "object", + required: ["__resolveType"], + additionalProperties: true, + properties: { __resolveType: { type: "string" } }, + }; + definitions[RESOLVABLE_KEY] = resolvableDef; + definitions[resolvableB64Key] = resolvableDef; + sectionRootAnyOf.push({ $ref: `#/definitions/${RESOLVABLE_KEY}` }); + + if (!fs.existsSync(sectionsDir)) { + console.error(`Sections directory not found: ${sectionsDir}`); + process.exit(1); + } + + const sectionFiles = findTsxFiles(sectionsDir); + console.log(`Found ${sectionFiles.length} section files`); + for (const filePath of sectionFiles) { + getSourceFile(project, filePath, sourceFileCache); + } + + for (const filePath of sectionFiles) { + const relativePath = path.relative(srcDir, filePath); + const blockKey = `${SITE_NAMESPACE}/${relativePath}`; + + try { + const sourceFile = getSourceFile(project, filePath, sourceFileCache); + + let propsSchema: any = null; + + // Strategy 1: Local Props interface/type alias in the section file + const propsInterface = sourceFile.getInterface("Props"); + if (propsInterface) propsSchema = typeToJsonSchema(propsInterface.getType()); + + const propsTypeAlias = sourceFile.getTypeAlias("Props"); + if (!propsSchema && propsTypeAlias) propsSchema = typeToJsonSchema(propsTypeAlias.getType()); + + // Strategy 2: Follow re-exports recursively (up to 3 hops) + // Handles: section → island → component chains + if (!propsSchema) { + propsSchema = resolvePropsViaReExport( + project, + sourceFile, + filePath, + root, + 3, + sourceFileCache, + moduleResolutionCache, + propsSchemaCache, + ); + } + + // Strategy 4: Default export call signature in the section file via type checker + if (!propsSchema) { + const localPropsType = extractDefaultExportPropsType(sourceFile); + if (localPropsType) { + propsSchema = typeToJsonSchema(localPropsType); + } + } + + if (!propsSchema) propsSchema = { type: "object", properties: {} }; + + const propCount = Object.keys(propsSchema.properties || {}).length; + + const propsDefKey = toBase64(`file:///${filePath}`) + "@Props"; + definitions[propsDefKey] = propsSchema; + + const sectionDefKey = toBase64(blockKey); + definitions[sectionDefKey] = { + title: blockKey, + type: "object", + allOf: [{ $ref: `#/definitions/${propsDefKey}` }], + required: ["__resolveType"], + properties: { + __resolveType: { type: "string", enum: [blockKey], default: blockKey }, + }, + }; + + sectionBlocks[blockKey] = { + $ref: `#/definitions/${sectionDefKey}`, + namespace: SITE_NAMESPACE, + }; + sectionRootAnyOf.push({ + $ref: `#/definitions/${sectionDefKey}`, + inputSchema: `#/definitions/${propsDefKey}`, + }); + + console.log(` ${propCount > 0 ? "✓" : "○"} ${blockKey} (${propCount} props)`); + } catch (e) { + console.warn(` ✗ ${blockKey}: ${(e as Error).message}`); + } + } + + // Pages, loaders, matchers, etc. are injected at runtime by composeMeta() + // in src/core/admin/schema.ts -- the generator only handles site sections. + const emptyAnyOf = { anyOf: [] as any[] }; + return { + major: 1, + version: FRAMEWORK_VERSION, + namespace: SITE_NAMESPACE, + site: SITE_NAME, + manifest: { blocks: { sections: sectionBlocks } }, + schema: { + definitions, + root: { + sections: { anyOf: sectionRootAnyOf }, + loaders: emptyAnyOf, + actions: emptyAnyOf, + pages: emptyAnyOf, + handlers: emptyAnyOf, + matchers: emptyAnyOf, + flags: emptyAnyOf, + functions: emptyAnyOf, + apps: emptyAnyOf, + }, + }, + platform: PLATFORM, + cloudProvider: PLATFORM, + }; +} + +const meta = generateMeta(); +const outPath = path.resolve(process.cwd(), OUT_REL); +fs.mkdirSync(path.dirname(outPath), { recursive: true }); +fs.writeFileSync(outPath, JSON.stringify(meta, null, 2)); + +const defCount = Object.keys(meta.schema.definitions).length; +const secCount = Object.keys(meta.manifest.blocks.sections || {}).length; +console.log( + `\nGenerated schema: ${defCount} definitions, ${secCount} sections → ${path.relative(process.cwd(), outPath)}`, +); diff --git a/scripts/_impl/generate-sections.ts b/scripts/_impl/generate-sections.ts new file mode 100644 index 00000000..ba1a9dcb --- /dev/null +++ b/scripts/_impl/generate-sections.ts @@ -0,0 +1,219 @@ +#!/usr/bin/env tsx +/** + * Scans site section files and extracts convention-based metadata: + * - export const eager = true → alwaysEager + * - export const cache = "listing" → registerCacheableSections + * - export const layout = true → registerLayoutSections + * - export const sync = true → registerSectionsSync (bundled, not lazy) + * - export const clientOnly = true → registerSection with clientOnly + * - export const seo = true → registerSeoSections + * - export function LoadingFallback → registerSection with loadingFallback + * + * Emits sections.gen.ts with metadata + sync imports for sections marked sync=true. + * + * Usage (from site root): + * npx tsx node_modules/@decocms/start/scripts/generate-sections.ts + * + * CLI: + * --sections-dir override input (default: src/sections) + * --out-file override output (default: src/server/cms/sections.gen.ts) + */ +import fs from "node:fs"; +import path from "node:path"; + +const args = process.argv.slice(2); +function arg(name: string, fallback: string): string { + const idx = args.indexOf(`--${name}`); + return idx !== -1 && args[idx + 1] ? args[idx + 1] : fallback; +} + +const sectionsDir = path.resolve(process.cwd(), arg("sections-dir", "src/sections")); +const outFile = path.resolve(process.cwd(), arg("out-file", "src/server/cms/sections.gen.ts")); + +interface SectionMeta { + eager?: boolean; + cache?: string; + layout?: boolean; + sync?: boolean; + clientOnly?: boolean; + seo?: boolean; + hasLoadingFallback?: boolean; +} + +const EXPORT_CONST_RE = /export\s+const\s+(eager|cache|layout|sync|clientOnly|seo)\s*=\s*(.+?)(?:;|\n)/g; +const LOADING_FALLBACK_RE = /export\s+(?:function|const)\s+LoadingFallback\b/; + +function extractMeta(content: string): SectionMeta | null { + const meta: SectionMeta = {}; + let found = false; + + for (const match of content.matchAll(EXPORT_CONST_RE)) { + const key = match[1] as keyof SectionMeta; + const rawValue = match[2].trim().replace(/['"]/g, ""); + found = true; + + if (key === "cache") { + meta.cache = rawValue; + } else if (rawValue === "true") { + (meta as any)[key] = true; + } + } + + if (LOADING_FALLBACK_RE.test(content)) { + meta.hasLoadingFallback = true; + found = true; + } + + return found ? meta : null; +} + +function walkDir(dir: string, base: string = dir): string[] { + const results: string[] = []; + if (!fs.existsSync(dir)) return results; + + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + results.push(...walkDir(fullPath, base)); + } else if (entry.name.endsWith(".tsx") || entry.name.endsWith(".ts")) { + results.push(fullPath); + } + } + return results; +} + +function fileToSectionKey(filePath: string, _sectionsDir: string): string { + const rel = path.relative(_sectionsDir, filePath).replace(/\\/g, "/"); + return `site/sections/${rel}`; +} + +function relativeImportPath(from: string, to: string): string { + let rel = path.relative(path.dirname(from), to).replace(/\\/g, "/"); + if (!rel.startsWith(".")) rel = `./${rel}`; + return rel.replace(/\.tsx?$/, ""); +} + +// --------------------------------------------------------------------------- + +if (!fs.existsSync(sectionsDir)) { + console.warn(`Sections directory not found: ${sectionsDir} — generating empty output.`); + fs.mkdirSync(path.dirname(outFile), { recursive: true }); + fs.writeFileSync(outFile, [ + "// Auto-generated — no sections found.", + "export const sectionMeta = {};", + "", + ].join("\n")); + process.exit(0); +} + +const sectionFiles = walkDir(sectionsDir); +const entries: Array<{ key: string; meta: SectionMeta; filePath: string }> = []; + +for (const filePath of sectionFiles) { + const content = fs.readFileSync(filePath, "utf-8"); + const meta = extractMeta(content); + if (!meta) continue; + const key = fileToSectionKey(filePath, sectionsDir); + entries.push({ key, meta, filePath }); +} + +const syncEntries = entries.filter((e) => e.meta.sync); +const fallbackEntries = entries.filter((e) => e.meta.hasLoadingFallback); + +const lines: string[] = [ + "// Auto-generated by @decocms/start/scripts/generate-sections.ts", + "// Do not edit manually. Add convention exports to your section files instead.", + "//", + "// Supported conventions:", + "// export const eager = true → always SSR'd (never deferred)", + "// export const cache = \"listing\" → SWR-cached section loader results", + "// export const layout = true → cached as layout (Header, Footer, Theme)", + "// export const sync = true → bundled synchronously (not lazy-loaded)", + "// export const clientOnly = true → skip SSR (client-only rendering)", + "// export const seo = true → SEO section (provides page head data)", + "// export function LoadingFallback → skeleton shown while section loads", + "", +]; + +// Sync imports — sections marked sync=true get static imports for registerSectionsSync +for (let i = 0; i < syncEntries.length; i++) { + const e = syncEntries[i]; + const importPath = relativeImportPath(outFile, e.filePath); + const varName = `_sync${i}`; + lines.push(`import * as ${varName} from "${importPath}";`); +} + +// LoadingFallback imports — sections with LoadingFallback that aren't sync-imported +const nonSyncFallbacks = fallbackEntries.filter((e) => !e.meta.sync); +for (let i = 0; i < nonSyncFallbacks.length; i++) { + const e = nonSyncFallbacks[i]; + const importPath = relativeImportPath(outFile, e.filePath); + lines.push(`import { LoadingFallback as _fb${i} } from "${importPath}";`); +} + +lines.push(""); + +// Metadata map +lines.push("export interface SectionMetaEntry {"); +lines.push(" eager?: boolean;"); +lines.push(" cache?: string;"); +lines.push(" layout?: boolean;"); +lines.push(" sync?: boolean;"); +lines.push(" clientOnly?: boolean;"); +lines.push(" seo?: boolean;"); +lines.push(" hasLoadingFallback?: boolean;"); +lines.push("}"); +lines.push(""); +lines.push("export const sectionMeta: Record = {"); +for (const e of entries) { + const props = Object.entries(e.meta) + .map(([k, v]) => `${k}: ${typeof v === "string" ? `"${v}"` : v}`) + .join(", "); + lines.push(` "${e.key}": { ${props} },`); +} +lines.push("};"); +lines.push(""); + +// Sync components map +if (syncEntries.length > 0) { + lines.push("export const syncComponents: Record = {"); + for (let i = 0; i < syncEntries.length; i++) { + lines.push(` "${syncEntries[i].key}": _sync${i},`); + } + lines.push("};"); +} else { + lines.push("export const syncComponents: Record = {};"); +} +lines.push(""); + +// LoadingFallback components map +const allFallbacks = entries.filter((e) => e.meta.hasLoadingFallback); +if (allFallbacks.length > 0) { + lines.push("export const loadingFallbacks: Record> = {"); + for (const e of allFallbacks) { + if (e.meta.sync) { + const syncIdx = syncEntries.indexOf(e); + lines.push(` "${e.key}": _sync${syncIdx}.LoadingFallback,`); + } else { + const fbIdx = nonSyncFallbacks.indexOf(e); + lines.push(` "${e.key}": _fb${fbIdx},`); + } + } + lines.push("};"); +} else { + lines.push("export const loadingFallbacks: Record> = {};"); +} +lines.push(""); + +fs.mkdirSync(path.dirname(outFile), { recursive: true }); +fs.writeFileSync(outFile, lines.join("\n")); + +console.log( + `Generated section metadata for ${entries.length} sections → ${path.relative(process.cwd(), outFile)}`, +); +console.log( + ` ${syncEntries.length} sync, ${allFallbacks.length} with LoadingFallback, ` + + `${entries.filter((e) => e.meta.eager).length} eager, ` + + `${entries.filter((e) => e.meta.layout).length} layout, ` + + `${entries.filter((e) => e.meta.cache).length} cached`, +); diff --git a/scripts/generate-blocks.ts b/scripts/generate-blocks.ts index d1087886..165333d0 100644 --- a/scripts/generate-blocks.ts +++ b/scripts/generate-blocks.ts @@ -1,118 +1,6 @@ #!/usr/bin/env tsx -/** - * Reads .deco/blocks/*.json and emits: - * 1. blocks.gen.json — compact JSON data (the source of truth) - * 2. blocks.gen.ts — thin TypeScript re-export for editor tooling - * - * At runtime the Vite plugin (src/tanstack/vite/plugin.js) intercepts `blocks.gen.ts` - * imports and replaces them with `JSON.parse(...)` of the .json file. This - * avoids Vite's SSR module runner hanging on large (13MB+) JS object literals - * and lets V8 use its fast JSON parser instead of the full JS parser. - * - * Usage (from site root): - * npx tsx node_modules/@decocms/start/scripts/generate-blocks.ts - * - * Env / CLI: - * --blocks-dir override input (default: .deco/blocks) - * --out-file override output (default: src/server/cms/blocks.gen.ts) - */ -import fs from "node:fs"; -import path from "node:path"; -import { - blockHasPath, - type Candidate, - decodeBlockNameWithPasses, - mergeCandidates, -} from "./lib/blocks-dedupe"; - -const args = process.argv.slice(2); -function arg(name: string, fallback: string): string { - const idx = args.indexOf(`--${name}`); - return idx !== -1 && args[idx + 1] ? args[idx + 1] : fallback; -} - -const blocksDir = path.resolve(process.cwd(), arg("blocks-dir", ".deco/blocks")); -const outFile = path.resolve(process.cwd(), arg("out-file", "src/server/cms/blocks.gen.ts")); -const jsonFile = outFile.replace(/\.ts$/, ".json"); - -const TS_STUB = [ - "// Auto-generated — thin wrapper around blocks.gen.json.", - "// The Vite plugin replaces this at load time with JSON.parse(...).", - "// Do not edit manually.", - "", - "export const blocks: Record = {};", - "", -].join("\n"); - -if (!fs.existsSync(blocksDir)) { - console.warn(`Blocks directory not found: ${blocksDir} — generating empty barrel.`); - fs.mkdirSync(path.dirname(outFile), { recursive: true }); - fs.writeFileSync(jsonFile, "{}"); - fs.writeFileSync(outFile, TS_STUB); - process.exit(0); -} - -const files = fs.readdirSync(blocksDir).filter((f) => f.endsWith(".json")); - -// Read each file into a Candidate, then let the dedupe lib pick the winner -// per decoded key and report any collisions. See `lib/blocks-dedupe.ts` for -// the priority order and the rationale behind it (TL;DR: never use file size, -// don't trust mtime alone in CI clones). -const candidatesWithKeys: Array<{ candidate: Candidate; key: string }> = []; -for (const file of files) { - const { name, passes } = decodeBlockNameWithPasses(file); - const fp = path.join(blocksDir, file); - let parsed: unknown; - try { - parsed = JSON.parse(fs.readFileSync(fp, "utf-8")); - } catch (e) { - console.warn(`Failed to parse ${file}:`, e); - continue; - } - candidatesWithKeys.push({ - key: name, - candidate: { - file, - passes, - mtimeMs: fs.statSync(fp).mtimeMs, - hasPath: blockHasPath(parsed), - parsed, - }, - }); -} - -const { winners, collisions } = mergeCandidates(candidatesWithKeys); - -if (collisions.length > 0) { - console.warn( - `Detected ${collisions.length} filename collision(s) in ${path.relative(process.cwd(), blocksDir)}:`, - ); - for (const c of collisions) { - const losers = c.files.filter((f) => f !== c.winner); - console.warn(` - ${c.key}`); - console.warn(` winner: ${c.winner}`); - for (const l of losers) console.warn(` ignore: ${l}`); - } - console.warn(" Cause: multiple writers (manual sync vs deco-sync-bot) producing"); - console.warn(" different filename encodings for the same logical key. Delete the"); - console.warn(" stale file(s) listed under 'ignore' to silence this warning."); -} - -const blocks: Record = {}; -for (const [name, c] of Object.entries(winners)) { - blocks[name] = c.parsed; -} - -fs.mkdirSync(path.dirname(outFile), { recursive: true }); - -// 1. Compact JSON — the real data (no pretty-printing to save ~40% size) -const jsonStr = JSON.stringify(blocks); -fs.writeFileSync(jsonFile, jsonStr); - -// 2. Thin TS wrapper — just for TypeScript tooling and as a Vite load target -fs.writeFileSync(outFile, TS_STUB); - -const jsonSizeMB = (Buffer.byteLength(jsonStr) / 1_048_576).toFixed(1); -console.log( - `Generated ${Object.keys(blocks).length} blocks → ${path.relative(process.cwd(), jsonFile)} (${jsonSizeMB} MB)`, -); +// Drop-in shim for `tsx node_modules/@decocms/start/scripts/generate-blocks.ts`. +// Real implementation is bundled to dist/scripts/generate-blocks.cjs by tsup; +// the source lives in scripts/_impl/ (kept out of the published tarball). +import { createRequire } from "node:module"; +createRequire(import.meta.url)("../dist/scripts/generate-blocks.cjs"); diff --git a/scripts/generate-invoke.ts b/scripts/generate-invoke.ts index b5efd475..c3c86397 100644 --- a/scripts/generate-invoke.ts +++ b/scripts/generate-invoke.ts @@ -1,430 +1,6 @@ #!/usr/bin/env tsx -/** - * Scans @decocms/apps vtex/invoke.ts and generates a site-local invoke file - * with top-level createServerFn declarations. - * - * TanStack Start's compiler only transforms createServerFn().handler() when - * the call is at module top-level (assigned to a const). The factory pattern - * used in @decocms/apps/vtex/invoke.ts causes the "fast path" in the compiler - * to skip the .handler() calls because they're inside a function body. - * - * This script generates an equivalent file where each server function is a - * top-level const, which the compiler can correctly transform into RPC stubs. - * - * Usage (from site root): - * npx tsx node_modules/@decocms/start/scripts/generate-invoke.ts - * - * Env / CLI: - * --out-file override output (default: src/server/invoke.gen.ts) - * --apps-dir override @decocms/apps location (default: auto-resolve from node_modules) - */ -import fs from "node:fs"; -import path from "node:path"; -import { Project, type PropertyAssignment, SyntaxKind } from "ts-morph"; - -const args = process.argv.slice(2); -function arg(name: string, fallback: string): string { - const idx = args.indexOf(`--${name}`); - return idx !== -1 && args[idx + 1] ? args[idx + 1] : fallback; -} - -const cwd = process.cwd(); -const outFile = path.resolve(cwd, arg("out-file", "src/server/invoke.gen.ts")); - -function resolveAppsDir(): string { - const explicit = arg("apps-dir", ""); - if (explicit) return path.resolve(cwd, explicit); - - // Walk up from CWD collecting every node_modules/@decocms/apps along the way - // (handles npm/bun hoisting in monorepos), plus a couple of dev fallbacks. - const candidates: string[] = []; - let dir = cwd; - while (true) { - candidates.push(path.join(dir, "node_modules/@decocms/apps")); - const parent = path.dirname(dir); - if (parent === dir) break; - dir = parent; - } - candidates.push(path.resolve(cwd, "../apps-start")); - candidates.push(path.resolve(cwd, "../decocms-apps")); - - // First pass: a candidate that has vtex/invoke.ts is fully usable. - for (const c of candidates) { - if (fs.existsSync(path.join(c, "vtex/invoke.ts"))) return c; - } - - // Second pass: a candidate exists as @decocms/apps but the source file we - // need to parse is missing. The published @decocms/apps tarball does not - // include vtex/invoke.ts — it is a dev-time source-of-truth file. Surface a - // distinct, actionable error so consumers don't chase a non-existent - // "package not installed" bug. - for (const c of candidates) { - if (fs.existsSync(path.join(c, "package.json"))) { - throw new Error( - `Found @decocms/apps at ${c} but it is missing vtex/invoke.ts.\n` + - `generate-invoke parses the TS source-of-truth, which is not shipped\n` + - `in the published npm tarball. Point --apps-dir at a local checkout of\n` + - `the decocms/apps-start repo (e.g. --apps-dir ../apps-start), or skip\n` + - `regeneration and use the existing src/server/invoke.gen.ts.`, - ); - } - } - - throw new Error( - "Could not find @decocms/apps in node_modules (walked up from " + - `${cwd}). Install it, or pass --apps-dir .`, - ); -} - -const appsDir = resolveAppsDir(); -const invokeFile = path.join(appsDir, "vtex/invoke.ts"); - -if (!fs.existsSync(invokeFile)) { - console.error(`invoke.ts not found at: ${invokeFile}`); - process.exit(1); -} - -// --------------------------------------------------------------------------- -// Parse the source invoke.ts to extract action definitions -// --------------------------------------------------------------------------- - -interface ActionDef { - name: string; - /** The import source for the action function (e.g., "@decocms/apps/vtex/actions/checkout") */ - importSource: string; - /** The imported function name (e.g., "addItemsToCart") */ - importedFn: string; - /** The input type as a string (e.g., "{ orderFormId: string; ... }") */ - inputType: string; - /** The return type as a string (e.g., "OrderForm") */ - returnType: string; - /** Whether to unwrap VtexFetchResult */ - unwrap: boolean; - /** The body of the action call (e.g., "addItemsToCart(input.orderFormId, input.orderItems)") */ - callBody: string; -} - -const project = new Project({ compilerOptions: { strict: true } }); -const sourceFile = project.addSourceFileAtPath(invokeFile); - -// Collect all imports to know which functions come from where -const importMap = new Map(); -for (const imp of sourceFile.getImportDeclarations()) { - const source = imp.getModuleSpecifierValue(); - for (const named of imp.getNamedImports()) { - const localName = named.getName(); - const importedName = named.getAliasNode()?.getText() || localName; - importMap.set(localName, { - source: source.startsWith("./") ? `@decocms/apps/vtex/${source.slice(2)}` : source, - importedName: localName, - }); - } -} - -// Collect type imports -const typeImportMap = new Map(); -for (const imp of sourceFile.getImportDeclarations()) { - if (!imp.isTypeOnly()) { - for (const named of imp.getNamedImports()) { - if (named.isTypeOnly()) { - const localName = named.getName(); - const source = imp.getModuleSpecifierValue(); - typeImportMap.set(localName, { - source: source.startsWith("./") ? `@decocms/apps/vtex/${source.slice(2)}` : source, - importedName: localName, - }); - } - } - } - if (imp.isTypeOnly()) { - const source = imp.getModuleSpecifierValue(); - for (const named of imp.getNamedImports()) { - const localName = named.getName(); - typeImportMap.set(localName, { - source: source.startsWith("./") ? `@decocms/apps/vtex/${source.slice(2)}` : source, - importedName: localName, - }); - } - } -} - -// Find the invoke const and extract actions -const invokeVar = sourceFile.getVariableDeclaration("invoke"); -if (!invokeVar) { - console.error("Could not find 'export const invoke' in invoke.ts"); - process.exit(1); -} - -const actions: ActionDef[] = []; -const invokeInit = invokeVar.getInitializer(); -if (!invokeInit) { - console.error("invoke variable has no initializer"); - process.exit(1); -} - -// Navigate: invoke → .vtex → .actions → each property -const vtexProp = invokeInit - .asKindOrThrow(SyntaxKind.AsExpression) - .getExpression() - .asKindOrThrow(SyntaxKind.ObjectLiteralExpression) - .getProperty("vtex"); - -if (!vtexProp) { - console.error("Could not find 'vtex' property in invoke object"); - process.exit(1); -} - -const vtexObj = (vtexProp as PropertyAssignment) - .getInitializer()! - .asKindOrThrow(SyntaxKind.ObjectLiteralExpression); - -const actionsProp = vtexObj.getProperty("actions"); -if (!actionsProp) { - console.error("Could not find 'actions' property in vtex object"); - process.exit(1); -} - -const actionsObj = (actionsProp as PropertyAssignment) - .getInitializer()! - .asKindOrThrow(SyntaxKind.ObjectLiteralExpression); - -for (const prop of actionsObj.getProperties()) { - if (prop.getKind() !== SyntaxKind.PropertyAssignment) continue; - const pa = prop as PropertyAssignment; - const name = pa.getName(); - const initText = pa.getInitializer()!.getText(); - - // Check if it uses createInvokeFn with unwrap - const unwrap = initText.includes("unwrap: true"); - - // Extract the arrow function body from createInvokeFn((input: ...) => ...) - // We'll parse the call expression to get the action call - const callExpr = pa.getInitializer()!; - let inputType = "any"; - let callBody = ""; - - // Recursively unwrap AsExpression chains (e.g. `expr as unknown as Type`) - let createInvokeFnCall = callExpr; - while (createInvokeFnCall.getKind() === SyntaxKind.AsExpression) { - createInvokeFnCall = createInvokeFnCall.asKindOrThrow(SyntaxKind.AsExpression).getExpression(); - } - - // Now we have createInvokeFn(...) call - if (createInvokeFnCall.getKind() === SyntaxKind.CallExpression) { - const callArgs = createInvokeFnCall.asKindOrThrow(SyntaxKind.CallExpression).getArguments(); - if (callArgs.length >= 1) { - const arrowFn = callArgs[0]; - if (arrowFn.getKind() === SyntaxKind.ArrowFunction) { - const arrow = arrowFn.asKindOrThrow(SyntaxKind.ArrowFunction); - const params = arrow.getParameters(); - if (params.length >= 1) { - const paramType = params[0].getTypeNode()?.getText() || "any"; - inputType = paramType; - } - // Get the body (the actual action call) - const body = arrow.getBody(); - callBody = body.getText(); - - // If body is a block, extract the expression - if (callBody.startsWith("{")) { - // It's a block body — skip for now, use simplified version - callBody = ""; - } - } - } - } - - // Determine which function is being called - let importedFn = ""; - let importSource = ""; - for (const [fnName, info] of importMap.entries()) { - if (callBody.includes(`${fnName}(`)) { - importedFn = fnName; - importSource = info.source; - break; - } - } - - // Extract the return type from the outermost "as" assertion. - // For `expr as unknown as (ctx: ...) => Promise`, the outermost - // AsExpression has the function type with Promise. - let returnType = "any"; - if (callExpr.getKind() === SyntaxKind.AsExpression) { - const asExpr = callExpr.asKindOrThrow(SyntaxKind.AsExpression); - const typeText = asExpr.getTypeNode()?.getText() || ""; - if (typeText !== "unknown") { - const promiseMatch = typeText.match(/Promise<(.+)>$/s); - if (promiseMatch) { - returnType = promiseMatch[1].trim(); - } - } - } - - actions.push({ - name, - importSource, - importedFn, - inputType, - returnType, - unwrap, - callBody, - }); -} - -// --------------------------------------------------------------------------- -// Generate the output file -// --------------------------------------------------------------------------- - -// Collect unique imports needed -const fnImports = new Map>(); -const typeImports = new Map>(); - -for (const action of actions) { - if (action.importSource && action.importedFn) { - if (!fnImports.has(action.importSource)) { - fnImports.set(action.importSource, new Set()); - } - fnImports.get(action.importSource)!.add(action.importedFn); - } -} - -// Add type imports referenced in inputType or returnType -for (const action of actions) { - const allText = action.inputType + action.returnType + action.callBody; - for (const [typeName, info] of typeImportMap.entries()) { - if (allText.includes(typeName)) { - if (!typeImports.has(info.source)) { - typeImports.set(info.source, new Set()); - } - typeImports.get(info.source)!.add(typeName); - } - } - // Also check value imports that appear in the types (like SimulationItem) - for (const [fnName, info] of importMap.entries()) { - if (action.inputType.includes(fnName) && !fnImports.get(info.source)?.has(fnName)) { - if (!typeImports.has(info.source)) { - typeImports.set(info.source, new Set()); - } - typeImports.get(info.source)!.add(fnName); - } - } -} - -// Count how many actually parsed vs. stubbed -const parsed = actions.filter((a) => a.importedFn).length; -const stubbed = actions.length - parsed; -if (stubbed > 0) { - console.warn(`⚠ ${stubbed} action(s) could not be parsed — generated as stubs:`); - for (const a of actions) { - if (!a.importedFn) console.warn(` - ${a.name}`); - } -} - -// Build output -let out = `// Auto-generated by @decocms/start/scripts/generate-invoke.ts -// Do not edit manually. Re-run the generator to update. -// -// Each server function is a top-level const so TanStack Start's compiler -// can transform createServerFn().handler() into RPC stubs on the client. -// -// Site-specific extensions: import { vtexActions } from this file and merge -// with your own actions in a separate invoke.ts. -import { createServerFn } from "@tanstack/react-start"; -`; - -// Add function imports -for (const [source, fns] of fnImports) { - out += `import { ${[...fns].join(", ")} } from "${source}";\n`; -} - -// Add type imports -for (const [source, types] of typeImports) { - // Don't duplicate if already imported as value - const valueImports = fnImports.get(source); - const onlyTypes = [...types].filter((t) => !valueImports?.has(t)); - if (onlyTypes.length > 0) { - out += `import type { ${onlyTypes.join(", ")} } from "${source}";\n`; - } -} - -out += ` -function unwrapResult(result: unknown): T { - if (result && typeof result === "object" && "data" in result) { - return (result as { data: T }).data; - } - return result as T; -} - -// --------------------------------------------------------------------------- -// Top-level server function declarations -// --------------------------------------------------------------------------- -`; - -for (const action of actions) { - const varName = `$${action.name}`; - - if (action.importedFn) { - // All @decocms/apps action functions take a single props object. - // Pass the validated `data` object directly — never destructure into - // positional arguments, which breaks when function signatures change. - if (action.unwrap) { - out += `\nconst ${varName} = createServerFn({ method: "POST" }) - .inputValidator((data: ${action.inputType}) => data) - .handler(async ({ data }): Promise => { - const result = await ${action.importedFn}(data); - return unwrapResult(result); - });\n`; - } else { - out += `\nconst ${varName} = createServerFn({ method: "POST" }) - .inputValidator((data: ${action.inputType}) => data) - .handler(async ({ data }): Promise => { - return ${action.importedFn}(data); - });\n`; - } - } else { - // Fallback: couldn't parse — generate a stub - out += `\n// TODO: could not auto-generate ${action.name} — add manually\nconst ${varName} = createServerFn({ method: "POST" }) - .handler(async () => { - throw new Error("${action.name}: not implemented — regenerate invoke"); - });\n`; - } -} - -// Generate the vtexActions object (for composability with site-specific actions) -out += ` -// --------------------------------------------------------------------------- -// Typed VTEX actions map — merge with site-specific actions in your invoke.ts -// --------------------------------------------------------------------------- - -export const vtexActions = { -`; - -for (const action of actions) { - const varName = `$${action.name}`; - if (action.returnType !== "any") { - out += ` ${action.name}: ${varName} as unknown as (ctx: { data: ${action.inputType} }) => Promise<${action.returnType}>,\n`; - } else { - out += ` ${action.name}: ${varName},\n`; - } -} - -out += `} as const; - -// Re-export OrderForm type (commonly imported from invoke by site components) -export type { OrderForm } from "@decocms/apps/vtex/types"; - -// --------------------------------------------------------------------------- -// Default invoke object — import this if you don't need site extensions -// --------------------------------------------------------------------------- - -export const invoke = { - vtex: { - actions: vtexActions, - }, -} as const; -`; - -// Write output -fs.mkdirSync(path.dirname(outFile), { recursive: true }); -fs.writeFileSync(outFile, out); -console.log(`Generated ${actions.length} server functions → ${path.relative(cwd, outFile)}`); +// Drop-in shim for `tsx node_modules/@decocms/start/scripts/generate-invoke.ts`. +// Real implementation is bundled to dist/scripts/generate-invoke.cjs by tsup; +// the source lives in scripts/_impl/ (kept out of the published tarball). +import { createRequire } from "node:module"; +createRequire(import.meta.url)("../dist/scripts/generate-invoke.cjs"); diff --git a/scripts/generate-loaders.ts b/scripts/generate-loaders.ts index 9214ac2c..376869cc 100644 --- a/scripts/generate-loaders.ts +++ b/scripts/generate-loaders.ts @@ -1,200 +1,6 @@ #!/usr/bin/env tsx -/** - * Scans site loader and action files and generates a registry map - * for COMMERCE_LOADERS pass-through entries. - * - * Each loader/action file that exports a default function gets a generated - * entry like: - * "site/loaders/SAP/getUser": async (props, request) => { - * const mod = await import("../../loaders/SAP/getUser"); - * return mod.default(props, request); - * }, - * - * Both keyed with and without `.ts` suffix for CMS block compatibility. - * - * Files listed in --exclude are skipped (they need custom wiring in setup.ts). - * - * CMS-aware filtering (`--decofile-dir`): when supplied, the script walks - * every JSON file in the directory and collects the set of `__resolveType` - * references. Only loaders whose key appears in that set are emitted — - * keeping the registry to what the site actually uses and avoiding the - * "200 dead passthroughs" pattern. - * - * Usage (from site root): - * npx tsx node_modules/@decocms/start/scripts/generate-loaders.ts - * npx tsx node_modules/@decocms/start/scripts/generate-loaders.ts --decofile-dir .deco/blocks - * - * CLI: - * --loaders-dir override loaders input (default: src/loaders) - * --actions-dir override actions input (default: src/actions) - * --out-file override output (default: src/server/cms/loaders.gen.ts) - * --exclude comma-separated list of loader keys to skip (they have custom wiring) - * --decofile-dir if provided, only emit entries whose key appears as `__resolveType` in any JSON - */ -import fs from "node:fs"; -import path from "node:path"; - -const args = process.argv.slice(2); -function arg(name: string, fallback: string): string { - const idx = args.indexOf(`--${name}`); - return idx !== -1 && args[idx + 1] ? args[idx + 1] : fallback; -} - -const loadersDir = path.resolve(process.cwd(), arg("loaders-dir", "src/loaders")); -const actionsDir = path.resolve(process.cwd(), arg("actions-dir", "src/actions")); -const outFile = path.resolve(process.cwd(), arg("out-file", "src/server/cms/loaders.gen.ts")); -const excludeRaw = arg("exclude", ""); -const excludeSet = new Set(excludeRaw.split(",").map((s) => s.trim()).filter(Boolean)); -const decofileDirRaw = arg("decofile-dir", ""); -const decofileDir = decofileDirRaw ? path.resolve(process.cwd(), decofileDirRaw) : null; - -function walkDir(dir: string): string[] { - const results: string[] = []; - if (!fs.existsSync(dir)) return results; - - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - results.push(...walkDir(fullPath)); - } else if (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) { - results.push(fullPath); - } - } - return results; -} - -function fileToKey(filePath: string, baseDir: string, prefix: string): string { - const rel = path.relative(baseDir, filePath).replace(/\\/g, "/").replace(/\.tsx?$/, ""); - return `${prefix}/${rel}`; -} - -function relativeImportPath(from: string, to: string): string { - let rel = path.relative(path.dirname(from), to).replace(/\\/g, "/"); - if (!rel.startsWith(".")) rel = `./${rel}`; - return rel.replace(/\.tsx?$/, ""); -} - -function hasDefaultExport(content: string): boolean { - return /export\s+default\b/.test(content) || /export\s*\{[^}]*\bdefault\b/.test(content); -} - -// --------------------------------------------------------------------------- -// CMS-referenced loader discovery -// -// Walk every JSON file under decofileDir and collect the set of strings that -// appear as `__resolveType` values. The migration script + generators emit -// pass-throughs for every loader/action file on disk; without this filter, -// 90%+ of those entries are dead code (the CMS never references them) and -// they pollute the type system and bundle. -// --------------------------------------------------------------------------- - -function collectResolveTypes(dir: string): Set { - const found = new Set(); - if (!fs.existsSync(dir)) return found; - - const RESOLVE_RE = /"__resolveType"\s*:\s*"([^"]+)"/g; - - function visit(d: string) { - for (const entry of fs.readdirSync(d, { withFileTypes: true })) { - const fullPath = path.join(d, entry.name); - if (entry.isDirectory()) { - visit(fullPath); - } else if (entry.name.endsWith(".json")) { - const content = fs.readFileSync(fullPath, "utf-8"); - let m: RegExpExecArray | null; - while ((m = RESOLVE_RE.exec(content)) !== null) { - found.add(m[1]); - } - } - } - } - - visit(dir); - return found; -} - -const cmsReferences = decofileDir ? collectResolveTypes(decofileDir) : null; - -function isReferenced(key: string): boolean { - if (!cmsReferences) return true; - return cmsReferences.has(key) || cmsReferences.has(`${key}.ts`); -} - -// --------------------------------------------------------------------------- - -interface LoaderEntry { - key: string; - importPath: string; -} - -const entries: LoaderEntry[] = []; -let prunedCount = 0; - -for (const filePath of walkDir(loadersDir)) { - const content = fs.readFileSync(filePath, "utf-8"); - if (!hasDefaultExport(content)) continue; - const key = fileToKey(filePath, loadersDir, "site/loaders"); - if (excludeSet.has(key) || excludeSet.has(`${key}.ts`)) continue; - if (!isReferenced(key)) { - prunedCount++; - continue; - } - entries.push({ - key, - importPath: relativeImportPath(outFile, filePath), - }); -} - -for (const filePath of walkDir(actionsDir)) { - const content = fs.readFileSync(filePath, "utf-8"); - if (!hasDefaultExport(content)) continue; - const key = fileToKey(filePath, actionsDir, "site/actions"); - if (excludeSet.has(key) || excludeSet.has(`${key}.ts`)) continue; - if (!isReferenced(key)) { - prunedCount++; - continue; - } - entries.push({ - key, - importPath: relativeImportPath(outFile, filePath), - }); -} - -entries.sort((a, b) => a.key.localeCompare(b.key)); - -const lines: string[] = [ - "// Auto-generated by @decocms/start/scripts/generate-loaders.ts", - "// Do not edit manually. Run `npm run generate:loaders` to update.", - "//", - "// Pass-through loader/action entries for COMMERCE_LOADERS.", - "// Custom-wired entries should be excluded via --exclude and added manually in setup.ts.", - "", - "export const siteLoaders: Record Promise> = {", -]; - -// Cast the dynamic-import default to `any` so legacy 3-arg -// `(props, req, ctx)` Fresh/Deno loaders still type-check. Any ctx-dependent -// path in the loader body throws at runtime and must be refactored. -for (const entry of entries) { - lines.push(` "${entry.key}": async (props: any, request?: Request) => {`); - lines.push(` const mod = await import("${entry.importPath}");`); - lines.push(" return (mod.default as any)(props, request);"); - lines.push(" },"); - lines.push(` "${entry.key}.ts": async (props: any, request?: Request) => {`); - lines.push(` const mod = await import("${entry.importPath}");`); - lines.push(" return (mod.default as any)(props, request);"); - lines.push(" },"); -} - -lines.push("};"); -lines.push(""); - -fs.mkdirSync(path.dirname(outFile), { recursive: true }); -fs.writeFileSync(outFile, lines.join("\n")); - -const filterNote = cmsReferences - ? ` (filtered against ${cmsReferences.size} CMS __resolveType references; pruned ${prunedCount} dead entries)` - : ""; -console.log( - `Generated ${entries.length} loader entries (${entries.length * 2} with .ts aliases) → ${path.relative(process.cwd(), outFile)}${filterNote}`, -); +// Drop-in shim for `tsx node_modules/@decocms/start/scripts/generate-loaders.ts`. +// Real implementation is bundled to dist/scripts/generate-loaders.cjs by tsup; +// the source lives in scripts/_impl/ (kept out of the published tarball). +import { createRequire } from "node:module"; +createRequire(import.meta.url)("../dist/scripts/generate-loaders.cjs"); diff --git a/scripts/generate-schema.ts b/scripts/generate-schema.ts index f6c493e8..a86c2874 100644 --- a/scripts/generate-schema.ts +++ b/scripts/generate-schema.ts @@ -1,741 +1,6 @@ #!/usr/bin/env tsx -import fs from "node:fs"; -import path from "node:path"; -/** - * Schema Generator for deco admin compatibility. - * - * Scans src/sections/ for .tsx files, parses their Props interfaces, - * and generates JSON Schema 7 definitions in the format expected by - * the deco admin (/deco/meta endpoint). - * - * Usage (from site root): - * npx tsx node_modules/@decocms/start/scripts/generate-schema.ts [options] - * - * Options: - * --namespace Section namespace (default: "site") - * --site Site name (default: "storefront") - * --version Framework version (default: "1.0.0") - * --sections Sections directory (default: "src/sections") - * --out Output file (default: "src/server/admin/meta.gen.json") - * --platform Platform name (default: "cloudflare") - */ -import { - type Symbol as MorphSymbol, - Node, - Project, - type SourceFile, - SyntaxKind, - type Type, -} from "ts-morph"; - -// --------------------------------------------------------------------------- -// CLI arg parsing -// --------------------------------------------------------------------------- -const argv = process.argv.slice(2); -function arg(name: string, fallback: string): string { - const idx = argv.indexOf(`--${name}`); - return idx !== -1 && argv[idx + 1] ? argv[idx + 1] : fallback; -} - -const SITE_NAMESPACE = arg("namespace", "site"); -const SITE_NAME = arg("site", "storefront"); -const FRAMEWORK_VERSION = arg("version", "1.0.0"); -const SECTIONS_REL = arg("sections", "src/sections"); -const OUT_REL = arg("out", "src/server/admin/meta.gen.json"); -const PLATFORM = arg("platform", "cloudflare"); - -// --------------------------------------------------------------------------- -// Interfaces -// --------------------------------------------------------------------------- -interface MetaResponse { - major: number; - version: string; - namespace: string; - site: string; - manifest: { blocks: Record> }; - schema: { definitions: Record; root: Record }; - platform: string; - cloudProvider: string; -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- -function toBase64(str: string): string { - return Buffer.from(str).toString("base64"); -} - -/** - * Map JSDoc tags to JSON Schema 7 keywords. - * Supports all 20+ tags from deco-cx/deco. - */ -/** - * Tags that receive special type coercion (not just string passthrough). - * Matches the original deco-cx/deco parseJSDocAttribute behaviour. - */ -const NUMERIC_TAGS = new Set([ - "maximum", - "minimum", - "exclusiveMaximum", - "exclusiveMinimum", - "multipleOf", - "maxLength", - "minLength", - "maxItems", - "minItems", - "maxProperties", - "minProperties", -]); -const BOOLEAN_TAGS = new Set(["readOnly", "writeOnly", "deprecated", "uniqueItems", "ignore"]); - -function applyJsDocToSchema(schema: any, tags: Record): void { - for (const [tag, value] of Object.entries(tags)) { - if (tag === "ignore") continue; - - // Tags with special coercion - if (tag === "hide") { - schema.hide = "true"; - continue; - } - - if (tag === "default") { - if (value === "true") schema.default = true; - else if (value === "false") schema.default = false; - else if (value === "null") schema.default = null; - else if (!isNaN(Number(value)) && value.trim() !== "") schema.default = Number(value); - else { - try { - schema.default = JSON.parse(value); - } catch { - schema.default = value; - } - } - continue; - } - - if (tag === "examples") { - const lines = value - .split("\n") - .map((l) => l.trim()) - .filter(Boolean); - schema.examples = - lines.length > 1 - ? lines - : (() => { - try { - return JSON.parse(value); - } catch { - return [value]; - } - })(); - continue; - } - - if (NUMERIC_TAGS.has(tag)) { - schema[tag] = Number(value); - continue; - } - if (BOOLEAN_TAGS.has(tag)) { - schema[tag] = value === "true"; - continue; - } - - // Everything else: pass through as-is (matching original deco behaviour) - // Covers: title, description, format, widget, icon, titleBy, mode, - // hideOption, label, options, pattern, section, group, placeholder, etc. - schema[tag] = value; - } -} - -const WIDGET_TYPE_FORMATS: Record = { - ImageWidget: "image-uri", - VideoWidget: "video-uri", - HTMLWidget: "html", - RichText: "rich-text", - Color: "color", - Secret: "password", - TextArea: "textarea", - Code: "code", - DateTimeWidget: "date-time", -}; - -/** - * Detect known widget types and set the appropriate format. - */ -function applyWidgetDetection(schema: any, typeText: string): void { - if (schema.format) return; - - for (const [widgetType, format] of Object.entries(WIDGET_TYPE_FORMATS)) { - if (typeText === widgetType || typeText.includes(widgetType)) { - schema.format = format; - return; - } - } -} - -/** - * Smart widget format application that handles arrays, nullable types, - * and union types by applying the format to the correct inner schema. - */ -function applyWidgetFormat(schema: any, typeHint: string): void { - const matchedFormat = Object.entries(WIDGET_TYPE_FORMATS).find( - ([wt]) => typeHint === wt || typeHint.includes(wt), - )?.[1]; - - if (!matchedFormat) { - applyWidgetDetection(schema, typeHint); - return; - } - - if (schema.type === "string" && !schema.format) { - schema.format = matchedFormat; - } else if (schema.type === "array" && schema.items) { - if (schema.items.type === "string" && !schema.items.format) { - schema.items.format = matchedFormat; - } - } else if (schema.anyOf) { - for (const variant of schema.anyOf) { - if (variant.type === "string" && !variant.format) { - variant.format = matchedFormat; - } - } - } -} - -// Well-known definition key for Section type references resolved by composeMeta -const SECTION_REF_DEF_KEY = "__SECTION_REF__"; - -// Only truly React-internal props that are never user-defined. -// Do NOT include "children", "type", or "props" — those are commonly used -// as legitimate section property names. -const REACT_INTERNAL_PROPS = new Set([ - "key", - "ref", - "then", - "catch", - "finally", - "$$typeof", - "_owner", - "_store", -]); - -function typeToJsonSchema(type: Type, visited = new Set()): any { - const typeText = type.getText(); - if (visited.has(typeText)) return { type: "object" }; - visited.add(typeText); - - try { - // any / unknown → accept anything - if (type.isAny() || type.isUnknown()) return {}; - - // ReactNode, JSX.Element, VNode → hide from form - if ( - /\bReactNode\b|\bJSX\.Element\b|\bReactElement\b|\bVNode\b|\bComponentChildren\b/.test( - typeText, - ) - ) { - return { type: "object", hide: "true" }; - } - - if (type.isString() || type.isStringLiteral()) { - return type.isStringLiteral() - ? { type: "string", const: type.getLiteralValue() } - : { type: "string" }; - } - if (type.isNumber() || type.isNumberLiteral()) return { type: "number" }; - if (type.isBoolean() || type.isBooleanLiteral()) return { type: "boolean" }; - if (type.isNull() || type.isUndefined()) return { type: "null" }; - - if (type.isArray()) { - const el = type.getArrayElementType(); - return el - ? { type: "array", items: typeToJsonSchema(el, new Set(visited)) } - : { type: "array" }; - } - - if (type.isUnion()) { - const parts = type.getUnionTypes(); - const nonNull = parts.filter((t) => !t.isNull() && !t.isUndefined()); - const isNullable = nonNull.length < parts.length; - - if (nonNull.length === 1) { - const inner = typeToJsonSchema(nonNull[0], new Set(visited)); - return isNullable ? { ...inner, nullable: true } : inner; - } - - // boolean? → true | false | undefined → collapse to { type: "boolean" } - if (nonNull.every((t) => t.isBooleanLiteral())) { - const result: any = { type: "boolean" }; - if (isNullable) result.nullable = true; - return result; - } - - if (nonNull.every((t) => t.isStringLiteral())) { - const result: any = { type: "string", enum: nonNull.map((t) => t.getLiteralValue()) }; - if (isNullable) result.nullable = true; - return result; - } - - // 1 | 2 | 3 → { type: "number", enum: [1, 2, 3] } - if (nonNull.every((t) => t.isNumberLiteral())) { - const result: any = { type: "number", enum: nonNull.map((t) => t.getLiteralValue()) }; - if (isNullable) result.nullable = true; - return result; - } - - // General anyOf — try to add title to each variant for discriminated unions - const anyOf = nonNull.map((t) => { - const schema = typeToJsonSchema(t, new Set(visited)); - if (!schema.title && schema.type === "object") { - const sym = t.getAliasSymbol() ?? t.getSymbol(); - const symName = sym?.getName(); - if (symName && symName !== "__type" && symName !== "default") { - schema.title = symName; - } - // Fallback: use a const discriminator field value as title - if (!schema.title && schema.properties) { - for (const v of Object.values(schema.properties) as any[]) { - if (v?.const !== undefined) { - schema.title = String(v.const); - break; - } - } - } - } - return schema; - }); - - const result: any = { anyOf }; - if (isNullable) result.nullable = true; - return result; - } - - if (type.isObject() || type.isInterface()) { - // Record → { type: "object", additionalProperties: V-schema } - const stringIdx = type.getStringIndexType(); - const numberIdx = type.getNumberIndexType(); - if ((stringIdx || numberIdx) && type.getProperties().length === 0) { - const valType = (stringIdx || numberIdx)!; - return { - type: "object", - additionalProperties: typeToJsonSchema(valType, new Set(visited)), - }; - } - - const properties: Record = {}; - const required: string[] = []; - - for (const prop of type.getProperties()) { - const name = prop.getName(); - if (name.startsWith("_") || name.startsWith("$") || name === "@type") continue; - if (REACT_INTERNAL_PROPS.has(name)) continue; - - const decl = prop.getValueDeclaration(); - if (!decl) continue; - const propType = prop.getTypeAtLocation(decl); - - const tags = getJsDocTags(prop); - if (tags.ignore) continue; - - // Get AST type-annotation text before resolving - let typeHint = propType.getText(); - const typeNode = - decl.getChildrenOfKind(SyntaxKind.TypeReference)[0] ?? - decl.getChildAtIndex(decl.getChildCount() - 1); - if (typeNode && Node.isTypeReference(typeNode)) { - typeHint = typeNode.getText(); - } else if (Node.isPropertySignature(decl) || Node.isPropertyDeclaration(decl)) { - const tn = (decl as any).getTypeNode?.(); - if (tn) typeHint = tn.getText(); - } - - // Section type → section picker reference (resolved by composeMeta) - const baseHint = typeHint.replace(/\s*\|\s*(null|undefined)/g, "").trim(); - if (baseHint === "Section" || baseHint === "Section[]" || baseHint === "Section[] | null") { - const isArray = baseHint.includes("[]"); - const sectionSchema: any = isArray - ? { - type: "array", - items: { $ref: `#/definitions/${SECTION_REF_DEF_KEY}` }, - title: name.charAt(0).toUpperCase() + name.slice(1), - } - : { - $ref: `#/definitions/${SECTION_REF_DEF_KEY}`, - title: name.charAt(0).toUpperCase() + name.slice(1), - }; - if (prop.isOptional() || typeHint.includes("null") || typeHint.includes("undefined")) { - sectionSchema.nullable = true; - } - applyJsDocToSchema(sectionSchema, tags); - properties[name] = sectionSchema; - if (!prop.isOptional()) required.push(name); - continue; - } - - const schema = typeToJsonSchema(propType, new Set(visited)); - - applyJsDocToSchema(schema, tags); - applyWidgetFormat(schema, typeHint); - - if (!schema.title) schema.title = name.charAt(0).toUpperCase() + name.slice(1); - - properties[name] = schema; - if (!prop.isOptional()) required.push(name); - } - - const result: any = { type: "object", properties }; - if (required.length > 0) result.required = required; - return result; - } - - return { type: "string" }; - } finally { - visited.delete(typeText); - } -} - -function getJsDocTags(symbol: MorphSymbol): Record { - const tags: Record = {}; - for (const decl of symbol.getDeclarations()) { - const jsDocs = Node.isJSDocable(decl) ? decl.getJsDocs() : []; - for (const doc of jsDocs) { - const desc = doc.getDescription().trim(); - if (desc) tags.description = desc; - for (const tag of doc.getTags()) { - tags[tag.getTagName()] = tag.getCommentText()?.trim() || "true"; - } - } - } - return tags; -} - -/** - * Extract the first parameter's type from a component's default export - * using the type checker. Works regardless of whether the export is a - * function declaration, arrow function, const assignment, or re-export. - */ -function extractDefaultExportPropsType(sourceFile: import("ts-morph").SourceFile): Type | null { - const symbol = sourceFile.getDefaultExportSymbol(); - if (!symbol) return null; - - const exportType = symbol.getTypeAtLocation(sourceFile); - const callSigs = exportType.getCallSignatures(); - if (callSigs.length === 0) return null; - - const params = callSigs[0].getParameters(); - if (params.length === 0) return null; - - const paramType = params[0].getTypeAtLocation(sourceFile); - if (paramType.isAny() || paramType.getText() === "{}") return null; - - return paramType; -} - -/** - * Resolve a module specifier to an absolute file path. - */ -function resolveModulePath( - moduleSpec: string, - fromFile: string, - projectRoot: string, -): string | null { - let target = moduleSpec; - if (target.startsWith("~/")) { - target = path.resolve(projectRoot, "src", target.slice(2)); - } else if (target.startsWith("./") || target.startsWith("../")) { - target = path.resolve(path.dirname(fromFile), target); - } - if (!target.match(/\.(tsx?|jsx?)$/)) { - for (const ext of [".tsx", ".ts", ".jsx", ".js"]) { - if (fs.existsSync(target + ext)) return target + ext; - } - if (fs.existsSync(path.join(target, "index.tsx"))) return path.join(target, "index.tsx"); - if (fs.existsSync(path.join(target, "index.ts"))) return path.join(target, "index.ts"); - } - return fs.existsSync(target) ? target : null; -} - -type SourceFileCache = Map; -type ModuleResolutionCache = Map; -type PropsSchemaCache = Map; - -function getSourceFile( - project: import("ts-morph").Project, - filePath: string, - cache: SourceFileCache, -): SourceFile { - const normalizedPath = path.resolve(filePath); - const cached = cache.get(normalizedPath); - if (cached) return cached; - - const sourceFile = - project.getSourceFile(normalizedPath) ?? project.addSourceFileAtPath(normalizedPath); - cache.set(normalizedPath, sourceFile); - return sourceFile; -} - -function resolveModulePathCached( - moduleSpec: string, - fromFile: string, - projectRoot: string, - cache: ModuleResolutionCache, -): string | null { - const key = `${fromFile}\0${moduleSpec}`; - if (cache.has(key)) return cache.get(key) ?? null; - - const resolved = resolveModulePath(moduleSpec, fromFile, projectRoot); - cache.set(key, resolved); - return resolved; -} - -/** - * Recursively follow `export { default } from "..."` chains (up to maxDepth hops) - * and try to extract Props from each target file. - */ -function resolvePropsViaReExport( - project: import("ts-morph").Project, - sourceFile: import("ts-morph").SourceFile, - filePath: string, - projectRoot: string, - maxDepth: number, - sourceFileCache: SourceFileCache, - moduleResolutionCache: ModuleResolutionCache, - propsSchemaCache: PropsSchemaCache, -): any | null { - if (maxDepth <= 0) return null; - - for (const exportDecl of sourceFile.getExportDeclarations()) { - const moduleSpec = exportDecl.getModuleSpecifierValue(); - if (!moduleSpec) continue; - const hasDefault = exportDecl.getNamedExports().some((n) => { - const name = n.getName(); - const alias = n.getAliasNode()?.getText(); - return name === "default" || alias === "default"; - }); - if (!hasDefault) continue; - - const targetPath = resolveModulePathCached( - moduleSpec, - filePath, - projectRoot, - moduleResolutionCache, - ); - if (!targetPath) continue; - - const cachedProps = propsSchemaCache.get(targetPath); - if (cachedProps) return cachedProps; - - try { - const targetFile = getSourceFile(project, targetPath, sourceFileCache); - - const targetProps = targetFile.getInterface("Props"); - if (targetProps) { - const schema = typeToJsonSchema(targetProps.getType()); - propsSchemaCache.set(targetPath, schema); - return schema; - } - - const targetAlias = targetFile.getTypeAlias("Props"); - if (targetAlias) { - const schema = typeToJsonSchema(targetAlias.getType()); - propsSchemaCache.set(targetPath, schema); - return schema; - } - - // Type-checker approach: extract from default export call signature - const propsType = extractDefaultExportPropsType(targetFile); - if (propsType) { - const schema = typeToJsonSchema(propsType); - propsSchemaCache.set(targetPath, schema); - return schema; - } - - // Recurse: target might also re-export from another file - const deeper = resolvePropsViaReExport( - project, - targetFile, - targetPath, - projectRoot, - maxDepth - 1, - sourceFileCache, - moduleResolutionCache, - propsSchemaCache, - ); - if (deeper) { - propsSchemaCache.set(targetPath, deeper); - return deeper; - } - } catch { - // Target file couldn't be parsed - } - } - return null; -} - -function findTsxFiles(dir: string): string[] { - const results: string[] = []; - if (!fs.existsSync(dir)) return results; - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const full = path.join(dir, entry.name); - if (entry.isDirectory()) results.push(...findTsxFiles(full)); - else if (entry.name.endsWith(".tsx") || entry.name.endsWith(".ts")) results.push(full); - } - return results; -} - -// --------------------------------------------------------------------------- -// Main -// --------------------------------------------------------------------------- -function generateMeta(): MetaResponse { - const root = process.cwd(); - const sectionsDir = path.resolve(root, SECTIONS_REL); - const srcDir = path.join(root, "src"); - - const project = new Project({ - tsConfigFilePath: path.join(root, "tsconfig.json"), - skipAddingFilesFromTsConfig: true, - }); - - const definitions: Record = {}; - const sectionBlocks: Record = {}; - const sectionRootAnyOf: any[] = []; - const sourceFileCache: SourceFileCache = new Map(); - const moduleResolutionCache: ModuleResolutionCache = new Map(); - const propsSchemaCache: PropsSchemaCache = new Map(); - - // Resolvable: the admin's deRefUntil expects the LITERAL key "Resolvable", - // not a base64-encoded version. We store both for compatibility. - const RESOLVABLE_KEY = "Resolvable"; - const resolvableB64Key = toBase64("Resolvable"); - const resolvableDef = { - title: "Select from saved", - type: "object", - required: ["__resolveType"], - additionalProperties: true, - properties: { __resolveType: { type: "string" } }, - }; - definitions[RESOLVABLE_KEY] = resolvableDef; - definitions[resolvableB64Key] = resolvableDef; - sectionRootAnyOf.push({ $ref: `#/definitions/${RESOLVABLE_KEY}` }); - - if (!fs.existsSync(sectionsDir)) { - console.error(`Sections directory not found: ${sectionsDir}`); - process.exit(1); - } - - const sectionFiles = findTsxFiles(sectionsDir); - console.log(`Found ${sectionFiles.length} section files`); - for (const filePath of sectionFiles) { - getSourceFile(project, filePath, sourceFileCache); - } - - for (const filePath of sectionFiles) { - const relativePath = path.relative(srcDir, filePath); - const blockKey = `${SITE_NAMESPACE}/${relativePath}`; - - try { - const sourceFile = getSourceFile(project, filePath, sourceFileCache); - - let propsSchema: any = null; - - // Strategy 1: Local Props interface/type alias in the section file - const propsInterface = sourceFile.getInterface("Props"); - if (propsInterface) propsSchema = typeToJsonSchema(propsInterface.getType()); - - const propsTypeAlias = sourceFile.getTypeAlias("Props"); - if (!propsSchema && propsTypeAlias) propsSchema = typeToJsonSchema(propsTypeAlias.getType()); - - // Strategy 2: Follow re-exports recursively (up to 3 hops) - // Handles: section → island → component chains - if (!propsSchema) { - propsSchema = resolvePropsViaReExport( - project, - sourceFile, - filePath, - root, - 3, - sourceFileCache, - moduleResolutionCache, - propsSchemaCache, - ); - } - - // Strategy 4: Default export call signature in the section file via type checker - if (!propsSchema) { - const localPropsType = extractDefaultExportPropsType(sourceFile); - if (localPropsType) { - propsSchema = typeToJsonSchema(localPropsType); - } - } - - if (!propsSchema) propsSchema = { type: "object", properties: {} }; - - const propCount = Object.keys(propsSchema.properties || {}).length; - - const propsDefKey = toBase64(`file:///${filePath}`) + "@Props"; - definitions[propsDefKey] = propsSchema; - - const sectionDefKey = toBase64(blockKey); - definitions[sectionDefKey] = { - title: blockKey, - type: "object", - allOf: [{ $ref: `#/definitions/${propsDefKey}` }], - required: ["__resolveType"], - properties: { - __resolveType: { type: "string", enum: [blockKey], default: blockKey }, - }, - }; - - sectionBlocks[blockKey] = { - $ref: `#/definitions/${sectionDefKey}`, - namespace: SITE_NAMESPACE, - }; - sectionRootAnyOf.push({ - $ref: `#/definitions/${sectionDefKey}`, - inputSchema: `#/definitions/${propsDefKey}`, - }); - - console.log(` ${propCount > 0 ? "✓" : "○"} ${blockKey} (${propCount} props)`); - } catch (e) { - console.warn(` ✗ ${blockKey}: ${(e as Error).message}`); - } - } - - // Pages, loaders, matchers, etc. are injected at runtime by composeMeta() - // in src/core/admin/schema.ts -- the generator only handles site sections. - const emptyAnyOf = { anyOf: [] as any[] }; - return { - major: 1, - version: FRAMEWORK_VERSION, - namespace: SITE_NAMESPACE, - site: SITE_NAME, - manifest: { blocks: { sections: sectionBlocks } }, - schema: { - definitions, - root: { - sections: { anyOf: sectionRootAnyOf }, - loaders: emptyAnyOf, - actions: emptyAnyOf, - pages: emptyAnyOf, - handlers: emptyAnyOf, - matchers: emptyAnyOf, - flags: emptyAnyOf, - functions: emptyAnyOf, - apps: emptyAnyOf, - }, - }, - platform: PLATFORM, - cloudProvider: PLATFORM, - }; -} - -const meta = generateMeta(); -const outPath = path.resolve(process.cwd(), OUT_REL); -fs.mkdirSync(path.dirname(outPath), { recursive: true }); -fs.writeFileSync(outPath, JSON.stringify(meta, null, 2)); - -const defCount = Object.keys(meta.schema.definitions).length; -const secCount = Object.keys(meta.manifest.blocks.sections || {}).length; -console.log( - `\nGenerated schema: ${defCount} definitions, ${secCount} sections → ${path.relative(process.cwd(), outPath)}`, -); +// Drop-in shim for `tsx node_modules/@decocms/start/scripts/generate-schema.ts`. +// Real implementation is bundled to dist/scripts/generate-schema.cjs by tsup; +// the source lives in scripts/_impl/ (kept out of the published tarball). +import { createRequire } from "node:module"; +createRequire(import.meta.url)("../dist/scripts/generate-schema.cjs"); diff --git a/scripts/generate-sections.ts b/scripts/generate-sections.ts index ba1a9dcb..2bad34fd 100644 --- a/scripts/generate-sections.ts +++ b/scripts/generate-sections.ts @@ -1,219 +1,6 @@ #!/usr/bin/env tsx -/** - * Scans site section files and extracts convention-based metadata: - * - export const eager = true → alwaysEager - * - export const cache = "listing" → registerCacheableSections - * - export const layout = true → registerLayoutSections - * - export const sync = true → registerSectionsSync (bundled, not lazy) - * - export const clientOnly = true → registerSection with clientOnly - * - export const seo = true → registerSeoSections - * - export function LoadingFallback → registerSection with loadingFallback - * - * Emits sections.gen.ts with metadata + sync imports for sections marked sync=true. - * - * Usage (from site root): - * npx tsx node_modules/@decocms/start/scripts/generate-sections.ts - * - * CLI: - * --sections-dir override input (default: src/sections) - * --out-file override output (default: src/server/cms/sections.gen.ts) - */ -import fs from "node:fs"; -import path from "node:path"; - -const args = process.argv.slice(2); -function arg(name: string, fallback: string): string { - const idx = args.indexOf(`--${name}`); - return idx !== -1 && args[idx + 1] ? args[idx + 1] : fallback; -} - -const sectionsDir = path.resolve(process.cwd(), arg("sections-dir", "src/sections")); -const outFile = path.resolve(process.cwd(), arg("out-file", "src/server/cms/sections.gen.ts")); - -interface SectionMeta { - eager?: boolean; - cache?: string; - layout?: boolean; - sync?: boolean; - clientOnly?: boolean; - seo?: boolean; - hasLoadingFallback?: boolean; -} - -const EXPORT_CONST_RE = /export\s+const\s+(eager|cache|layout|sync|clientOnly|seo)\s*=\s*(.+?)(?:;|\n)/g; -const LOADING_FALLBACK_RE = /export\s+(?:function|const)\s+LoadingFallback\b/; - -function extractMeta(content: string): SectionMeta | null { - const meta: SectionMeta = {}; - let found = false; - - for (const match of content.matchAll(EXPORT_CONST_RE)) { - const key = match[1] as keyof SectionMeta; - const rawValue = match[2].trim().replace(/['"]/g, ""); - found = true; - - if (key === "cache") { - meta.cache = rawValue; - } else if (rawValue === "true") { - (meta as any)[key] = true; - } - } - - if (LOADING_FALLBACK_RE.test(content)) { - meta.hasLoadingFallback = true; - found = true; - } - - return found ? meta : null; -} - -function walkDir(dir: string, base: string = dir): string[] { - const results: string[] = []; - if (!fs.existsSync(dir)) return results; - - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - results.push(...walkDir(fullPath, base)); - } else if (entry.name.endsWith(".tsx") || entry.name.endsWith(".ts")) { - results.push(fullPath); - } - } - return results; -} - -function fileToSectionKey(filePath: string, _sectionsDir: string): string { - const rel = path.relative(_sectionsDir, filePath).replace(/\\/g, "/"); - return `site/sections/${rel}`; -} - -function relativeImportPath(from: string, to: string): string { - let rel = path.relative(path.dirname(from), to).replace(/\\/g, "/"); - if (!rel.startsWith(".")) rel = `./${rel}`; - return rel.replace(/\.tsx?$/, ""); -} - -// --------------------------------------------------------------------------- - -if (!fs.existsSync(sectionsDir)) { - console.warn(`Sections directory not found: ${sectionsDir} — generating empty output.`); - fs.mkdirSync(path.dirname(outFile), { recursive: true }); - fs.writeFileSync(outFile, [ - "// Auto-generated — no sections found.", - "export const sectionMeta = {};", - "", - ].join("\n")); - process.exit(0); -} - -const sectionFiles = walkDir(sectionsDir); -const entries: Array<{ key: string; meta: SectionMeta; filePath: string }> = []; - -for (const filePath of sectionFiles) { - const content = fs.readFileSync(filePath, "utf-8"); - const meta = extractMeta(content); - if (!meta) continue; - const key = fileToSectionKey(filePath, sectionsDir); - entries.push({ key, meta, filePath }); -} - -const syncEntries = entries.filter((e) => e.meta.sync); -const fallbackEntries = entries.filter((e) => e.meta.hasLoadingFallback); - -const lines: string[] = [ - "// Auto-generated by @decocms/start/scripts/generate-sections.ts", - "// Do not edit manually. Add convention exports to your section files instead.", - "//", - "// Supported conventions:", - "// export const eager = true → always SSR'd (never deferred)", - "// export const cache = \"listing\" → SWR-cached section loader results", - "// export const layout = true → cached as layout (Header, Footer, Theme)", - "// export const sync = true → bundled synchronously (not lazy-loaded)", - "// export const clientOnly = true → skip SSR (client-only rendering)", - "// export const seo = true → SEO section (provides page head data)", - "// export function LoadingFallback → skeleton shown while section loads", - "", -]; - -// Sync imports — sections marked sync=true get static imports for registerSectionsSync -for (let i = 0; i < syncEntries.length; i++) { - const e = syncEntries[i]; - const importPath = relativeImportPath(outFile, e.filePath); - const varName = `_sync${i}`; - lines.push(`import * as ${varName} from "${importPath}";`); -} - -// LoadingFallback imports — sections with LoadingFallback that aren't sync-imported -const nonSyncFallbacks = fallbackEntries.filter((e) => !e.meta.sync); -for (let i = 0; i < nonSyncFallbacks.length; i++) { - const e = nonSyncFallbacks[i]; - const importPath = relativeImportPath(outFile, e.filePath); - lines.push(`import { LoadingFallback as _fb${i} } from "${importPath}";`); -} - -lines.push(""); - -// Metadata map -lines.push("export interface SectionMetaEntry {"); -lines.push(" eager?: boolean;"); -lines.push(" cache?: string;"); -lines.push(" layout?: boolean;"); -lines.push(" sync?: boolean;"); -lines.push(" clientOnly?: boolean;"); -lines.push(" seo?: boolean;"); -lines.push(" hasLoadingFallback?: boolean;"); -lines.push("}"); -lines.push(""); -lines.push("export const sectionMeta: Record = {"); -for (const e of entries) { - const props = Object.entries(e.meta) - .map(([k, v]) => `${k}: ${typeof v === "string" ? `"${v}"` : v}`) - .join(", "); - lines.push(` "${e.key}": { ${props} },`); -} -lines.push("};"); -lines.push(""); - -// Sync components map -if (syncEntries.length > 0) { - lines.push("export const syncComponents: Record = {"); - for (let i = 0; i < syncEntries.length; i++) { - lines.push(` "${syncEntries[i].key}": _sync${i},`); - } - lines.push("};"); -} else { - lines.push("export const syncComponents: Record = {};"); -} -lines.push(""); - -// LoadingFallback components map -const allFallbacks = entries.filter((e) => e.meta.hasLoadingFallback); -if (allFallbacks.length > 0) { - lines.push("export const loadingFallbacks: Record> = {"); - for (const e of allFallbacks) { - if (e.meta.sync) { - const syncIdx = syncEntries.indexOf(e); - lines.push(` "${e.key}": _sync${syncIdx}.LoadingFallback,`); - } else { - const fbIdx = nonSyncFallbacks.indexOf(e); - lines.push(` "${e.key}": _fb${fbIdx},`); - } - } - lines.push("};"); -} else { - lines.push("export const loadingFallbacks: Record> = {};"); -} -lines.push(""); - -fs.mkdirSync(path.dirname(outFile), { recursive: true }); -fs.writeFileSync(outFile, lines.join("\n")); - -console.log( - `Generated section metadata for ${entries.length} sections → ${path.relative(process.cwd(), outFile)}`, -); -console.log( - ` ${syncEntries.length} sync, ${allFallbacks.length} with LoadingFallback, ` + - `${entries.filter((e) => e.meta.eager).length} eager, ` + - `${entries.filter((e) => e.meta.layout).length} layout, ` + - `${entries.filter((e) => e.meta.cache).length} cached`, -); +// Drop-in shim for `tsx node_modules/@decocms/start/scripts/generate-sections.ts`. +// Real implementation is bundled to dist/scripts/generate-sections.cjs by tsup; +// the source lives in scripts/_impl/ (kept out of the published tarball). +import { createRequire } from "node:module"; +createRequire(import.meta.url)("../dist/scripts/generate-sections.cjs"); diff --git a/tsup.config.ts b/tsup.config.ts index 67af9d94..d5db497b 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -129,18 +129,22 @@ export default defineConfig([ }, { name: "scripts", - entry: [ - "scripts/generate-blocks.ts", - "scripts/generate-schema.ts", - "scripts/generate-invoke.ts", - "scripts/generate-sections.ts", - "scripts/generate-loaders.ts", - "scripts/migrate.ts", - "scripts/migrate-post-cleanup.ts", - "scripts/migrate-to-cf-observability.ts", - "scripts/htmx-analyze.ts", - "scripts/tailwind-lint.ts", - ], + // The `generate-*` source files live in scripts/_impl/ so that the + // scripts/generate-*.ts paths at the package root can be thin shims + // shipped in the tarball (see `files` in package.json). Named entries + // keep output paths at dist/scripts/.cjs regardless of source dir. + entry: { + "generate-blocks": "scripts/_impl/generate-blocks.ts", + "generate-schema": "scripts/_impl/generate-schema.ts", + "generate-invoke": "scripts/_impl/generate-invoke.ts", + "generate-sections": "scripts/_impl/generate-sections.ts", + "generate-loaders": "scripts/_impl/generate-loaders.ts", + migrate: "scripts/migrate.ts", + "migrate-post-cleanup": "scripts/migrate-post-cleanup.ts", + "migrate-to-cf-observability": "scripts/migrate-to-cf-observability.ts", + "htmx-analyze": "scripts/htmx-analyze.ts", + "tailwind-lint": "scripts/tailwind-lint.ts", + }, // Scripts are CLI tools invoked via `node …/foo.cjs` — CJS only. ESM bundles // of ts-morph (which inlines TypeScript) leave `require("fs")` callsites // intact; in an ESM context those go through a __require shim that throws