#!/usr/bin/env tsx /** * Internal implementation detail of generate.ts (the unified orchestrator) * -- invoke `generate` instead; direct invocation remains possible but * undocumented. */ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; /** * Schema Generator for deco admin compatibility. * * Scans src/sections/, src/loaders/, and src/apps/ for TypeScript 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/blocks-cli/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") * --loaders Loaders directory (default: "src/loaders") * --apps Apps directory (default: "src/apps") * --skip-apps Skip app schema generation * --out Output file (default: ".deco/meta.gen.json") * --platform Platform name (default: "cloudflare") * --framework Value written to the composed meta's `framework` field * (default: "tanstack-start"). * * The output is ALWAYS run through composeMeta() before writing, so * meta.gen.json is self-contained (bakes in Page, matchers, __SECTION_REF__, * Resolvable). This matters for consumers that read the file straight from * disk with no runtime (FS-based Studio / Eitri stack); runtime readers * re-compose idempotently (see composeMeta's `framework` sentinel). */ import { type Symbol as MorphSymbol, Node, Project, type SourceFile, SyntaxKind, type Type, } from "ts-morph"; import { isExcludedCodegenFile } from "./lib/codegenExclusions"; // --------------------------------------------------------------------------- // CLI arg parsing // // Guarded by isMainModule() (defined below — hoisted, so it's callable up // here) so that importing this module for its pure exports // (definitionIdForPath, applyWidgetFormat, typeToJsonSchema — see // generate-schema.test.ts) never reads argv or touches the filesystem. // generateMeta() (below) and the final write are themselves only reached // inside `if (isMainModule())`, so these vars only need real values in that // same case. // --------------------------------------------------------------------------- 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 NEW_DEFAULT_OUT_REL = ".deco/meta.gen.json"; let SITE_NAMESPACE = "site"; let SITE_NAME = "storefront"; let FRAMEWORK_VERSION = "1.0.0"; let SECTIONS_REL = "src/sections"; let LOADERS_REL = "src/loaders"; let APPS_REL = "src/apps"; let SKIP_APPS = false; let OUT_REL = NEW_DEFAULT_OUT_REL; let PLATFORM = "cloudflare"; // Value written to the composed meta's `framework` field. Defaults to // composeMeta's historical "tanstack-start". composeMeta ALWAYS runs before // writing (see the write block below), so the output file is SELF-CONTAINED — // it carries the framework block types (Page, matchers, __SECTION_REF__, // Resolvable). Required by consumers that read meta.gen.json straight from the // filesystem with no runtime (e.g. the FS-based Studio / Eitri stack). let FRAMEWORK = "tanstack-start"; if (isMainModule()) { SITE_NAMESPACE = arg("namespace", SITE_NAMESPACE); SITE_NAME = arg("site", SITE_NAME); FRAMEWORK_VERSION = arg("version", FRAMEWORK_VERSION); SECTIONS_REL = arg("sections", SECTIONS_REL); LOADERS_REL = arg("loaders", LOADERS_REL); APPS_REL = arg("apps", APPS_REL); SKIP_APPS = argv.includes("--skip-apps"); OUT_REL = arg("out", NEW_DEFAULT_OUT_REL); PLATFORM = arg("platform", PLATFORM); FRAMEWORK = arg("framework", FRAMEWORK); } // --------------------------------------------------------------------------- // 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"); } /** * Definition IDs must be stable across machines: the raw ts-morph path is * absolute (`file:///Users//...`), which made meta.gen.json differ * per machine and destabilized the /live/_meta ETag. IDs are opaque to the * admin — only internal $ref consistency matters — so relativize to root. */ export function definitionIdForPath(filePath: string, rootDir: string): string { const cleaned = filePath.replace(/^file:\/+/, "/"); const rel = path.relative(rootDir, cleaned).replaceAll("\\", "/"); return toBase64(rel.startsWith("..") ? cleaned : rel); } /** * 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; } } export 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", }; /** * Eitri authors annotate fields with JSDoc `@format` using Eitri's own vocab * (e.g. `@format datetime`). Map those onto the JSON-Schema `format` values the * Studio widget layer understands. `textarea` already matches, so only the * divergent ones need remapping. Applied only for --platform eitri. */ export const EITRI_FORMAT_ALIASES: Record = { datetime: "date-time", }; /** * Recursively remap `format` string values in a JSON-Schema tree using the * given alias map. Mutates in place; only touches `format` fields whose value * is a known alias, so unrelated schema is untouched. */ export function normalizeFormats(node: unknown, aliases: Record): void { if (Array.isArray(node)) { for (const item of node) normalizeFormats(item, aliases); return; } if (node && typeof node === "object") { const obj = node as Record; if (typeof obj.format === "string" && aliases[obj.format]) { obj.format = aliases[obj.format]; } for (const key of Object.keys(obj)) normalizeFormats(obj[key], aliases); } } /** * 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. */ export 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; } } } else if (!schema.type && !schema.$ref) { // Widget alias (e.g. `Color`) not resolvable by ts-morph (remote/CDN import) // → it came through as `any`, so typeToJsonSchema returned an empty schema. // Every widget alias is string-based, so recover the intended widget here. schema.type = "string"; schema.format = matchedFormat; } } // Well-known definition key for Section type references resolved by composeMeta const SECTION_REF_DEF_KEY = "__SECTION_REF__"; // Well-known definition key for Resolvable (saved blocks picker) const RESOLVABLE_KEY = "Resolvable"; /** * Whether a prop annotated `Section` / `Section[]` is the framework's opaque * Section type (a "pick any section" reference) rather than a user-defined type * that merely happens to be named `Section`. * * The framework's `Section` is opaque — `export type Section = any` in the * scaffolded `~/types/deco.ts` — so it resolves to `any` (or `unknown`). A * component that declares its own local `type Section = { label; items }` * (e.g. a footer column list) resolves to a concrete object with properties; * that is user data and must render as an inline editable object, NOT a picker. * * Discriminates by the resolved shape after stripping `| null | undefined` and * the array wrapper: only the opaque type is a section reference. */ function isOpaqueSectionType(propType: Type): boolean { let type = propType; if (type.isUnion()) { const nonNull = type.getUnionTypes().filter((u) => !u.isNull() && !u.isUndefined()); if (nonNull.length === 1) type = nonNull[0]; } if (type.isArray()) { const el = type.getArrayElementType(); if (el) type = el; } return type.isAny() || type.isUnknown(); } // Only truly React-internal props that are never user-defined. // Do NOT include "children", "type", "props", or "key" — those are commonly // used as legitimate property names in data interfaces (e.g. SelectedFacet // uses { key: string; value: string }). // Note: React's JSX `key` is a special attribute, not a TypeScript interface // property — it never appears in Props/data interfaces and must not be filtered. const REACT_INTERNAL_PROPS = new Set([ "ref", "then", "catch", "finally", "$$typeof", "_owner", "_store", ]); // Platform types injected at runtime, never configured through the CMS form. // Collapsed to a hidden object in typeToJsonSchema (guarded by "is it really // the lib declaration" so same-named site types still expand). const RUNTIME_INJECTED_TYPES = new Set([ "URL", "URLSearchParams", "Request", "Response", "Headers", "AbortSignal", "ReadableStream", "WritableStream", "Blob", "File", "FormData", ]); interface GenerationContext { outputTypeToLoaderKeys: Map; } function namedLoaderType(type: Type | undefined): string | null { if (!type) return null; const name = type.getSymbol()?.getName(); if ( name && name !== "__type" && name !== "__object" && name !== "Array" && name !== "ReadonlyArray" ) { return name; } // Mapped/object aliases have a synthetic symbol but retain their own name. // Bare Omit/Generic must not share a bucket across instantiations. const alias = type.getAliasSymbol(); return alias && type.getAliasTypeArguments().length === 0 ? alias.getName() : null; } /** Same bounded name lookup for loader outputs and section props; no shape matching. */ function loaderTypeName(type: Type): string | null { if (type.isUnion()) { const nonNull = type.getUnionTypes().filter((t) => !t.isNull() && !t.isUndefined()); if (nonNull.length === 1) type = nonNull[0]; } if (type.isArray()) { const name = namedLoaderType(type.getArrayElementType()); return name ? `${name}[]` : null; } return namedLoaderType(type); } /** * Extract the return type name of a loader's default export. * Unwraps Promise and T | null wrappers. */ function extractLoaderOutputTypeName(sourceFile: SourceFile): string | null { const sym = sourceFile.getDefaultExportSymbol(); if (!sym) return null; const callSigs = sym.getTypeAtLocation(sourceFile).getCallSignatures(); if (!callSigs.length) return null; let ret = callSigs[0].getReturnType(); if (ret.getSymbol()?.getName() === "Promise") { const args = ret.getTypeArguments(); if (args.length) ret = args[0]; } return loaderTypeName(ret); } export function typeToJsonSchema(type: Type, visited = new Set(), ctx?: GenerationContext): 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), ctx) } : { 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), ctx); 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), ctx); 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; } // Intersection (A & B). TypeScript merges the members, but ts-morph reports // the type as neither object nor interface, so without this branch it falls // through to the `{ type: "string" }` fallback — which is why recursive // menu/navigation types written as `Leaf & { children?: Array }` // lost every field and rendered as a bare string/block-ref in the CMS. // Two shapes matter: // - Branded primitives (`string & { __brand }`) → keep the primitive. // - Object intersections → merge every member's properties into one object. if (type.isIntersection()) { const parts = type.getIntersectionTypes(); const primitive = parts.find((t) => t.isString() || t.isNumber() || t.isBoolean()); if (primitive) return typeToJsonSchema(primitive, new Set(visited), ctx); const merged: any = { type: "object", properties: {} }; const required = new Set(); for (const part of parts) { const sub = typeToJsonSchema(part, new Set(visited), ctx); if (sub?.type !== "object" || !sub.properties) continue; Object.assign(merged.properties, sub.properties); for (const r of sub.required ?? []) required.add(r); // Carry object-level annotations (title, @titleBy, …) from members, // first member wins so an earlier explicit value is never clobbered. for (const [k, v] of Object.entries(sub)) { if (k === "type" || k === "properties" || k === "required") continue; if (!(k in merged)) merged[k] = v; } } if (required.size > 0) merged.required = [...required]; return merged; } if (type.isObject() || type.isInterface()) { // Runtime-injected platform types (loader `url: URL`, `req: Request`, …) // are not CMS-configurable — hide instead of expanding the whole DOM // interface into the form. Only collapse the real lib types: a site // interface merely NAMED `Request` should still be expanded. const symName = type.getSymbol()?.getName(); if (symName && RUNTIME_INJECTED_TYPES.has(symName)) { const declFile = type.getSymbol()?.getDeclarations()?.[0]?.getSourceFile().getFilePath(); if (declFile?.includes("typescript/lib/") || declFile?.includes("@types/")) { return { type: "object", hide: "true" }; } } // 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; // getValueDeclaration() returns undefined for computed/mapped-type // properties (e.g. `Omit`). Fall back to the first // available declaration, or skip if none exists at all. const decl = prop.getValueDeclaration() ?? prop.getDeclarations()[0]; if (!decl) continue; const propType = prop.getTypeAtLocation(decl); // Methods (e.g. URL#toString on platform-ish objects) aren't data — // a pure function type has no place in a props form. if (propType.getCallSignatures().length > 0 && propType.getProperties().length === 0) { continue; } 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). // Guard on the resolved shape: the name `Section` is not reserved, so a // component may declare its own local `type Section = { label; items }` // (e.g. a footer column list). Only the framework's opaque Section type // becomes a picker; a concretely-shaped local type falls through to the // normal inline-object handling below (`isOpaqueSectionType`). const baseHint = typeHint.replace(/\s*\|\s*(null|undefined)/g, "").trim(); const isSectionName = baseHint === "Section" || baseHint === "Section[]" || baseHint === "Section[] | null"; if (isSectionName && isOpaqueSectionType(propType)) { 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; } // Loader output type → block-ref: emit anyOf [Resolvable, ...matchingLoaders] // baseHint strips "| null | undefined" so "ProductListingPage | null" → "ProductListingPage" if (ctx?.outputTypeToLoaderKeys) { const outputTypeName = loaderTypeName(propType); const matchingLoaders = (outputTypeName ? ctx.outputTypeToLoaderKeys.get(outputTypeName) : undefined) ?? (outputTypeName !== baseHint ? ctx.outputTypeToLoaderKeys.get(baseHint) : undefined); if (matchingLoaders?.length) { const blockRefSchema: any = { anyOf: [ { $ref: `#/definitions/${RESOLVABLE_KEY}` }, ...matchingLoaders.map((k) => ({ $ref: `#/definitions/${toBase64(k)}` })), ], title: name.charAt(0).toUpperCase() + name.slice(1), }; if (prop.isOptional() || typeHint.includes("null") || typeHint.includes("undefined")) { blockRefSchema.nullable = true; } applyJsDocToSchema(blockRefSchema, tags); properties[name] = blockRefSchema; if (!prop.isOptional()) required.push(name); continue; } } const schema = typeToJsonSchema(propType, new Set(visited), ctx); applyJsDocToSchema(schema, tags); applyWidgetFormat(schema, typeHint); if (typeHint.includes("Secret")) { schema.type = schema.type ?? "string"; schema.format = "password"; } if (!schema.title) schema.title = name.charAt(0).toUpperCase() + name.slice(1); properties[name] = schema; // A hidden prop (runtime-injected type, ReactNode, @hide) can never be // filled in by the CMS user — requiring it would deadlock the form. if (!prop.isOptional() && schema.hide !== "true") required.push(name); } const result: any = { type: "object", properties }; if (required.length > 0) result.required = required; const ifaceSym = type.getAliasSymbol() ?? type.getSymbol(); if (ifaceSym) { const ifaceTags = getJsDocTags(ifaceSym); applyJsDocToSchema(result, ifaceTags); } return result; } return { type: "string" }; } finally { visited.delete(typeText); } } export 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; } /** * Extract the first parameter type of an exported `loader` function. * When a section file co-exports a loader, the loader's input type defines * the CMS schema (what the user configures), NOT the component's Props. */ function extractLoaderInputType(sourceFile: import("ts-morph").SourceFile): Type | null { // Check for `export const loader = ...` or `export function loader(...)` for (const sym of sourceFile.getExportSymbols()) { if (sym.getName() !== "loader") continue; const decls = sym.getDeclarations(); for (const decl of decls) { let loaderType: Type | null = null; if (Node.isVariableDeclaration(decl)) { loaderType = decl.getType(); } else if (Node.isFunctionDeclaration(decl)) { loaderType = decl.getType(); } else if (Node.isExportSpecifier(decl)) { // Re-exported: `export { loader } from "..."` loaderType = decl.getType(); } if (!loaderType) continue; const callSigs = loaderType.getCallSignatures(); if (callSigs.length === 0) continue; const params = callSigs[0].getParameters(); if (params.length === 0) continue; const paramType = params[0].getTypeAtLocation(sourceFile); if (paramType.isAny() || paramType.getText() === "{}") continue; return paramType; } } return null; } /** * 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, ctx?: GenerationContext, ): 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); // When the target file exports a loader, the loader's first parameter // type defines the CMS input schema. This takes priority over a named // Props interface, which may be a sub-type used internally. const loaderInputType = extractLoaderInputType(targetFile); if (loaderInputType) { const schema = typeToJsonSchema(loaderInputType, undefined, ctx); propsSchemaCache.set(targetPath, schema); return schema; } const targetProps = targetFile.getInterface("Props"); if (targetProps) { const schema = typeToJsonSchema(targetProps.getType(), undefined, ctx); propsSchemaCache.set(targetPath, schema); return schema; } const targetAlias = targetFile.getTypeAlias("Props"); if (targetAlias) { const schema = typeToJsonSchema(targetAlias.getType(), undefined, ctx); 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, undefined, ctx); 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, ctx, ); if (deeper) { propsSchemaCache.set(targetPath, deeper); return deeper; } } catch { // Target file couldn't be parsed } } return null; } // Default scan extensions. Sections may widen this to also include .jsx/.js // on stacks whose sections can be plain JavaScript (e.g. Eitri) — see // SECTION_EXTS. Loaders/apps stay TS-only (their input types come from real // TypeScript signatures, which JS files cannot express). const DEFAULT_EXTS = [".tsx", ".ts"] as const; function findTsxFiles(dir: string, exts: readonly string[] = DEFAULT_EXTS): 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()) { // The exclusion predicate targets generated/test *files* — a directory // named e.g. `foo.gen.ts` is a real path segment and must still be walked. results.push(...findTsxFiles(full, exts)); } else if ( !isExcludedCodegenFile(entry.name) && exts.some((e) => entry.name.endsWith(e)) ) { results.push(full); } } return results; } // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- function generateMeta(): MetaResponse { const root = process.cwd(); const sectionsDir = path.resolve(root, SECTIONS_REL); const loadersDir = path.resolve(root, LOADERS_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 loaderBlocks: Record = {}; const sectionRootAnyOf: any[] = []; const loaderRootAnyOf: any[] = [{ $ref: `#/definitions/${RESOLVABLE_KEY}` }]; const outputTypeToLoaderKeys = new Map(); 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 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}` }); // --------------------------------------------------------------------------- // First pass: scan loaders — build input schemas + outputTypeToLoaderKeys map // --------------------------------------------------------------------------- const loaderFiles = fs.existsSync(loadersDir) ? findTsxFiles(loadersDir) : []; console.log(`Found ${loaderFiles.length} loader files`); for (const filePath of loaderFiles) { getSourceFile(project, filePath, sourceFileCache); } for (const filePath of loaderFiles) { const relativePath = path.relative(srcDir, filePath).replaceAll("\\", "/"); const loaderKey = `${SITE_NAMESPACE}/${relativePath}`; try { const sourceFile = getSourceFile(project, filePath, sourceFileCache); // Extract Props (input schema) let propsSchema: any = null; const propsInterface = sourceFile.getInterface("Props"); if (propsInterface) propsSchema = typeToJsonSchema(propsInterface.getType()); const propsTypeAlias = sourceFile.getTypeAlias("Props"); if (!propsSchema && propsTypeAlias) propsSchema = typeToJsonSchema(propsTypeAlias.getType()); if (!propsSchema) { const localPropsType = extractDefaultExportPropsType(sourceFile); if (localPropsType) propsSchema = typeToJsonSchema(localPropsType); } if (!propsSchema) propsSchema = { type: "object", properties: {} }; // Register flat loader definition (Bug #2: spread props, not nested under "props:") const loaderDefKey = toBase64(loaderKey); definitions[loaderDefKey] = { title: loaderKey, type: "object", required: ["__resolveType", ...(propsSchema?.required || [])], properties: { __resolveType: { type: "string", enum: [loaderKey], default: loaderKey }, ...(propsSchema?.properties || {}), }, }; loaderBlocks[loaderKey] = { $ref: `#/definitions/${loaderDefKey}`, namespace: SITE_NAMESPACE, }; loaderRootAnyOf.push({ $ref: `#/definitions/${loaderDefKey}` }); // Extract return type name for block-ref detection in sections (Bug #3) const outputTypeName = extractLoaderOutputTypeName(sourceFile); if (outputTypeName) { const existing = outputTypeToLoaderKeys.get(outputTypeName) ?? []; existing.push(loaderKey); outputTypeToLoaderKeys.set(outputTypeName, existing); } const propCount = Object.keys(propsSchema.properties || {}).length; console.log( ` ✓ loader ${loaderKey} (${propCount} props${outputTypeName ? ` → ${outputTypeName}` : ""})`, ); } catch (e) { console.warn(` ✗ loader ${loaderKey}: ${(e as Error).message}`); } } // --------------------------------------------------------------------------- // App loaders pass: walk each @decocms/apps- package's src/loaders/ // directory and register each .ts file as a CMS loader. CMS keys are derived // from the file path (e.g. "vtex/loaders/intelligentSearch/productList.ts") — // this key format is a CMS decofile convention, independent of the npm // package layout, so it stays "/loaders/..." even though the // namespace now maps to its own "@decocms/apps-" package rather // than a subdirectory of one monolithic "@decocms/apps" package. // // Only apps installed in src/apps/ are scanned. An app bridge file that // re-exports from "@decocms/apps-{namespace}/mod" signals the namespace. // --------------------------------------------------------------------------- /** Absolute path to the installed @decocms/apps- package, if present. */ function getAppPkgDir(namespace: string): string { return path.resolve(root, `node_modules/@decocms/apps-${namespace}`); } /** Detect installed app namespaces from src/apps/ bridge files. */ function detectInstalledAppNamespaces(): Set { const namespaces = new Set(); const siteAppsDir = path.resolve(root, APPS_REL); if (!fs.existsSync(siteAppsDir)) return namespaces; const appFiles = findTsxFiles(siteAppsDir); const re = /["']@decocms\/apps-([^/]+)\/mod["']/; for (const filePath of appFiles) { try { const content = fs.readFileSync(filePath, "utf-8"); const match = content.match(re); if (match) namespaces.add(match[1]); } catch { /* skip unreadable files */ } } return namespaces; } // Discover app loader files via filesystem walk (scoped to installed apps) function discoverAppLoaders(): Array<{ cmsKey: string; sourceFile: string; namespace: string }> { const result: Array<{ cmsKey: string; sourceFile: string; namespace: string }> = []; const installed = detectInstalledAppNamespaces(); if (installed.size === 0) return result; for (const namespace of installed) { const pkgDir = getAppPkgDir(namespace); const loadersDir = path.join(pkgDir, "src", "loaders"); if (!fs.existsSync(loadersDir)) continue; const files = fs.readdirSync(loadersDir, { recursive: true }) as string[]; for (const relFile of files) { const rel = String(relFile); if (!rel.endsWith(".ts") && !rel.endsWith(".tsx")) continue; // Skip index/barrel files, tests, and internal files const basename = path.basename(rel); if (basename === "index.ts" || basename.startsWith("_")) continue; if (rel.includes("__tests__") || rel.includes("__test__") || rel.endsWith(".test.ts")) continue; const cmsKey = `${namespace}/loaders/${rel.replace(/\\/g, "/")}`; // Absolute — each namespace now resolves against its own package dir, // not a single shared "appsPkgDir" (see absSourceFile below). const sourceFile = path.join(loadersDir, rel); result.push({ cmsKey, sourceFile, namespace }); } } return result.sort((a, b) => a.cmsKey.localeCompare(b.cmsKey)); } const appLoaders = discoverAppLoaders(); // De-duplicate: when re-export files point to the same source, parse once const appLoaderCache = new Map(); const installedNs = detectInstalledAppNamespaces(); console.log(`Installed app namespaces: ${[...installedNs].join(", ") || "(none)"}`); console.log(`Scanning app loaders from ${[...installedNs].map((ns) => `@decocms/apps-${ns}`).join(", ") || "(none)"} (${appLoaders.length} files)...`); for (const appLoader of appLoaders) { const absSourceFile = appLoader.sourceFile; try { // Resolve the real path to de-duplicate re-exports pointing to the same file const realPath = fs.realpathSync(absSourceFile); let cached = appLoaderCache.get(realPath); if (!cached) { const sourceFile = getSourceFile(project, absSourceFile, sourceFileCache); // Skip files without a default export (barrel files, utility modules) const defaultSym = sourceFile.getDefaultExportSymbol(); if (defaultSym == null) continue; // A loader may control how it appears in the admin picker via JSDoc tags // on its default export: // @title — overrides the picker label. This is how compat re-export // aliases (which would otherwise all beautify to the same // name from their path) disambiguate themselves. Only a // slash-free title is honored; paths fall back to the key. // @ignore — hides the loader from the pickers (redundant path aliases) // while still emitting its definition so pre-existing blocks // that reference it keep resolving and rendering. const loaderTags = getJsDocTags(defaultSym); const titleTag = loaderTags.title; const title = titleTag && !titleTag.includes("/") ? titleTag : null; const hidden = !!loaderTags.ignore; // Extract Props (input schema) let propsSchema: any = null; const propsInterface = sourceFile.getInterface("Props"); if (propsInterface) propsSchema = typeToJsonSchema(propsInterface.getType()); const propsTypeAlias = sourceFile.getTypeAlias("Props"); if (!propsSchema && propsTypeAlias) propsSchema = typeToJsonSchema(propsTypeAlias.getType()); if (!propsSchema) { const localPropsType = extractDefaultExportPropsType(sourceFile); if (localPropsType) propsSchema = typeToJsonSchema(localPropsType); } if (!propsSchema) propsSchema = { type: "object", properties: {} }; const outputTypeName = extractLoaderOutputTypeName(sourceFile); cached = { propsSchema, outputTypeName, title, hidden }; appLoaderCache.set(realPath, cached); } const { propsSchema, outputTypeName, title, hidden } = cached; const loaderDefKey = toBase64(appLoader.cmsKey); definitions[loaderDefKey] = { title: title ?? appLoader.cmsKey, type: "object", required: ["__resolveType", ...(propsSchema?.required || [])], properties: { __resolveType: { type: "string", enum: [appLoader.cmsKey], default: appLoader.cmsKey }, ...(propsSchema?.properties || {}), }, }; loaderBlocks[appLoader.cmsKey] = { $ref: `#/definitions/${loaderDefKey}`, namespace: appLoader.namespace, }; // `@ignore`d loaders keep their definition (so existing blocks resolve) // but are withheld from both the root loader union and the per-output-type // pickers, so users can't pick them for new blocks. if (!hidden) { loaderRootAnyOf.push({ $ref: `#/definitions/${loaderDefKey}` }); // Register output type for block-ref resolution in sections if (outputTypeName) { const existing = outputTypeToLoaderKeys.get(outputTypeName) ?? []; existing.push(appLoader.cmsKey); outputTypeToLoaderKeys.set(outputTypeName, existing); } } const propCount = Object.keys(propsSchema.properties || {}).length; console.log( ` ${hidden ? "·" : "✓"} app loader ${appLoader.cmsKey} (${propCount} props${outputTypeName ? ` → ${outputTypeName}` : ""}${hidden ? ", hidden" : ""})`, ); } catch (e) { console.warn(` ✗ app loader ${appLoader.cmsKey}: ${(e as Error).message}`); } } const ctx: GenerationContext = { outputTypeToLoaderKeys }; // --------------------------------------------------------------------------- // Commerce "extension wrapper" loaders (deco-cx parity). // // deco-cx/apps ships `commerce/loaders/product/extensions/{listingPage,detailsPage}.ts` // which wrap a base loader: `{ data: , extensions: ExtensionOf[] }`. // These wrappers live in @decocms/apps (node_modules), so they are never scanned // from `src/loaders/` — without emitting their schema the admin renders an empty // config for any page whose `page` prop uses the wrapper. We emit them here using // the output-type → loader map so `data` becomes a picker of the site's matching // loaders (e.g. DeliveryPromiseProductListingPage), exactly like the old admin // ("Extend your product" → "Data" → "The data Extensions"). const COMMERCE_EXTENSION_WRAPPERS = [ { key: "commerce/loaders/product/extensions/listingPage.ts", outputType: "ProductListingPage", }, { key: "commerce/loaders/product/extensions/detailsPage.ts", outputType: "ProductDetailsPage", }, ]; for (const wrapper of COMMERCE_EXTENSION_WRAPPERS) { const matchingLoaders = outputTypeToLoaderKeys.get(wrapper.outputType) ?? []; // A wrapper whose base loader type has no matching loaders in the site is // useless (its `data` picker would only offer Resolvable). Non-commerce // sites (e.g. Eitri) have none, so skip it instead of injecting a phantom // commerce loader into the picker. if (matchingLoaders.length === 0) continue; const wrapperDefKey = toBase64(wrapper.key); definitions[wrapperDefKey] = { title: wrapper.key, type: "object", required: ["__resolveType"], properties: { __resolveType: { type: "string", enum: [wrapper.key], default: wrapper.key }, data: { title: "Data", description: "Here comes your products or anything that can be extensible.", anyOf: [ { $ref: `#/definitions/${RESOLVABLE_KEY}` }, ...matchingLoaders.map((k) => ({ $ref: `#/definitions/${toBase64(k)}` })), ], }, extensions: { type: "array", title: "The data Extensions", items: { anyOf: [{ $ref: `#/definitions/${RESOLVABLE_KEY}` }] }, }, }, }; loaderBlocks[wrapper.key] = { $ref: `#/definitions/${wrapperDefKey}`, namespace: "commerce", }; loaderRootAnyOf.push({ $ref: `#/definitions/${wrapperDefKey}` }); console.log( ` ✓ commerce extension wrapper ${wrapper.key} (data → ${matchingLoaders.length} loader(s))`, ); } // --------------------------------------------------------------------------- // Second pass: scan sections // --------------------------------------------------------------------------- if (!fs.existsSync(sectionsDir)) { console.error(`Sections directory not found: ${sectionsDir}`); process.exit(1); } // Eitri sections can be plain JavaScript (.js/.jsx) as well as TS; other // stacks stay TS-only so a stray .js helper in src/sections isn't mistaken // for a section. A JS file with no extractable Props still registers as a // (prop-less) section rather than being silently skipped. const sectionExts = PLATFORM === "eitri" ? [".tsx", ".ts", ".jsx", ".js"] : DEFAULT_EXTS; const sectionFiles = findTsxFiles(sectionsDir, sectionExts); 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).replaceAll("\\", "/"); const blockKey = `${SITE_NAMESPACE}/${relativePath}`; try { const sourceFile = getSourceFile(project, filePath, sourceFileCache); let propsSchema: any = null; // Strategy 0: If the section file exports a loader (directly or via // re-export), the loader's first parameter type defines the CMS input. // This takes priority over a named Props interface which may be a // sub-type used internally by the component. const loaderInputType = extractLoaderInputType(sourceFile); if (loaderInputType) { propsSchema = typeToJsonSchema(loaderInputType, undefined, ctx); } // Strategy 1: Local Props interface/type alias in the section file const propsInterface = sourceFile.getInterface("Props"); if (!propsSchema && propsInterface) propsSchema = typeToJsonSchema(propsInterface.getType(), undefined, ctx); const propsTypeAlias = sourceFile.getTypeAlias("Props"); if (!propsSchema && propsTypeAlias) propsSchema = typeToJsonSchema(propsTypeAlias.getType(), undefined, ctx); // 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, ctx, ); } // Strategy 4: Default export call signature in the section file via type checker if (!propsSchema) { const localPropsType = extractDefaultExportPropsType(sourceFile); if (localPropsType) { propsSchema = typeToJsonSchema(localPropsType, undefined, ctx); } } if (!propsSchema) propsSchema = { type: "object", properties: {} }; const propCount = Object.keys(propsSchema.properties || {}).length; const propsDefKey = definitionIdForPath(filePath, root) + "@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}`); } } // --------------------------------------------------------------------------- // Third pass: scan installed app bridges (src/apps/) // --------------------------------------------------------------------------- const appBlocks: Record = {}; const appRootAnyOf: any[] = []; if (!SKIP_APPS) { const appsDir = path.resolve(root, APPS_REL); const appFiles = fs.existsSync(appsDir) ? findTsxFiles(appsDir) : []; console.log(`Found ${appFiles.length} app files`); for (const filePath of appFiles) { getSourceFile(project, filePath, sourceFileCache); } for (const filePath of appFiles) { const relativePath = path.relative(srcDir, filePath).replaceAll("\\", "/"); const blockKey = `${SITE_NAMESPACE}/${relativePath}`; if ( !blockKey.startsWith(`${SITE_NAMESPACE}/apps/`) || !blockKey.endsWith(".ts") || blockKey.includes("/_") ) { continue; } try { const sourceFile = getSourceFile(project, filePath, sourceFileCache); let propsSchema: any = null; const propsInterface = sourceFile.getInterface("Props"); if (propsInterface) propsSchema = typeToJsonSchema(propsInterface.getType(), undefined, ctx); const propsTypeAlias = sourceFile.getTypeAlias("Props"); if (!propsSchema && propsTypeAlias) propsSchema = typeToJsonSchema(propsTypeAlias.getType(), undefined, ctx); if (!propsSchema) { propsSchema = resolvePropsViaReExport( project, sourceFile, filePath, root, 3, sourceFileCache, moduleResolutionCache, propsSchemaCache, ctx, ); } if (!propsSchema) { const localPropsType = extractDefaultExportPropsType(sourceFile); if (localPropsType) { propsSchema = typeToJsonSchema(localPropsType, undefined, ctx); } } if (!propsSchema) propsSchema = { type: "object", properties: {} }; const propCount = Object.keys(propsSchema.properties || {}).length; const propsDefKey = definitionIdForPath(filePath, root) + "@Props"; definitions[propsDefKey] = propsSchema; const appDefKey = toBase64(blockKey); definitions[appDefKey] = { title: blockKey, type: "object", allOf: [{ $ref: `#/definitions/${propsDefKey}` }], required: ["__resolveType"], properties: { __resolveType: { type: "string", enum: [blockKey], default: blockKey }, }, }; appBlocks[blockKey] = { $ref: `#/definitions/${appDefKey}`, namespace: SITE_NAMESPACE, }; appRootAnyOf.push({ $ref: `#/definitions/${appDefKey}`, inputSchema: `#/definitions/${propsDefKey}`, }); console.log(` ${propCount > 0 ? "✓" : "○"} ${blockKey} (${propCount} props)`); } catch (e) { console.warn(` ✗ ${blockKey}: ${(e as Error).message}`); } } } // Eitri @format aliases → JSON-Schema formats (e.g. datetime → date-time). // Done as a final pass over the generated definitions so every prop schema, // however deeply nested, is normalized before it reaches Studio. if (PLATFORM === "eitri") { normalizeFormats(definitions, EITRI_FORMAT_ALIASES); } // Pages, matchers, etc. are injected at runtime by composeMeta() in src/admin/schema.ts. // Site-level loaders are generated here (first pass above). const emptyAnyOf = { anyOf: [] as any[] }; return { major: 1, version: FRAMEWORK_VERSION, namespace: SITE_NAMESPACE, site: SITE_NAME, manifest: { blocks: { sections: sectionBlocks, loaders: loaderBlocks, apps: appBlocks } }, schema: { definitions, root: { sections: { anyOf: sectionRootAnyOf }, loaders: { anyOf: loaderRootAnyOf }, actions: emptyAnyOf, pages: emptyAnyOf, handlers: emptyAnyOf, matchers: emptyAnyOf, flags: emptyAnyOf, functions: emptyAnyOf, apps: { anyOf: appRootAnyOf }, }, }, platform: PLATFORM, cloudProvider: PLATFORM, }; } function isMainModule(): boolean { // True when this file is the process entrypoint (invoked directly), false when // it's imported (e.g. from tests) — the guard keeps the module importable // without triggering a full filesystem scan + write. // // Compare the *realpath* of both sides rather than the raw URL: under tsx / // pnpm the entrypoint is reached through symlinks (pnpm's node_modules, // macOS /tmp → /private/tmp), so argv[1] and import.meta.url spell the same // file differently. A raw string compare would silently return false and skip // generation. realpathSync collapses symlinks so both resolve identically. const entry = process.argv[1]; if (!entry) return false; try { const entryPath = fs.realpathSync(path.resolve(entry)); const selfPath = fs.realpathSync(fileURLToPath(import.meta.url)); return entryPath === selfPath; } catch { return false; } } if (isMainModule()) { // Wrapped in an async IIFE so the composeMeta dynamic import happens only // when this module is actually run (any test importing this module's pure // exports never reaches here, so it never pulls in the @decocms/blocks/cms // barrel). composeMeta always runs, making the written file self-contained. void (async () => { const rawMeta = generateMeta(); const { composeMeta } = await import("@decocms/blocks/cms"); // composeMeta returns @decocms/blocks' MetaResponse (platform optional); // this file's local MetaResponse requires platform. It's always present // (composeMeta spreads siteMeta, which set it), so the cast is safe. const meta = composeMeta(rawMeta, { framework: FRAMEWORK }) as MetaResponse; 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; const ldrCount = Object.keys(meta.manifest.blocks.loaders || {}).length; const appCount = Object.keys(meta.manifest.blocks.apps || {}).length; console.log( `\nGenerated schema (self-contained): ${defCount} definitions, ${secCount} sections, ${ldrCount} loaders, ${appCount} apps → ${path.relative(process.cwd(), outPath)}`, ); })(); }