import * as cp from "node:child_process"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { Project } from "ts-morph"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { EITRI_FORMAT_ALIASES, WIDGET_TYPE_FORMATS, applyWidgetFormat, definitionIdForPath, normalizeFormats, typeToJsonSchema, } from "./generate-schema"; describe("definitionIdForPath", () => { it("is repo-relative, never absolute", () => { const id = definitionIdForPath( "/Users/anyone/code/mysite/src/sections/Hero.tsx", "/Users/anyone/code/mysite", ); expect(Buffer.from(id, "base64").toString()).toBe("src/sections/Hero.tsx"); }); it("normalizes file:// prefixes from ts-morph", () => { const id = definitionIdForPath( "file:///Users/anyone/code/mysite/src/sections/Hero.tsx", "/Users/anyone/code/mysite", ); expect(Buffer.from(id, "base64").toString()).toBe("src/sections/Hero.tsx"); }); }); describe("normalizeFormats (Eitri @format aliases)", () => { it("remaps a known alias in a nested prop schema", () => { const defs = { "abc@Props": { type: "object", properties: { datetime: { type: "string", format: "datetime", title: "Publish date." }, post: { type: "string", format: "textarea" }, }, }, }; normalizeFormats(defs, EITRI_FORMAT_ALIASES); expect(defs["abc@Props"].properties.datetime.format).toBe("date-time"); // textarea is already a valid widget format — left untouched. expect(defs["abc@Props"].properties.post.format).toBe("textarea"); }); it("recurses through arrays and leaves unknown formats alone", () => { const node = { items: [{ format: "datetime" }, { format: "email" }], }; normalizeFormats(node, EITRI_FORMAT_ALIASES); expect(node.items[0].format).toBe("date-time"); expect(node.items[1].format).toBe("email"); }); it("is a no-op on primitives / null", () => { expect(() => normalizeFormats(null, EITRI_FORMAT_ALIASES)).not.toThrow(); expect(() => normalizeFormats("datetime", EITRI_FORMAT_ALIASES)).not.toThrow(); }); }); describe("applyWidgetFormat", () => { it("recovers an unresolved widget alias (empty schema) as string + format", () => { // When a widget alias like `Color` is imported from a module ts-morph can't // resolve (remote/CDN), the type comes through as `any` and typeToJsonSchema // returns {}. The intended widget must still be recovered. const schema: any = {}; applyWidgetFormat(schema, "Color"); expect(schema).toEqual({ type: "string", format: "color" }); }); it.each(Object.entries(WIDGET_TYPE_FORMATS))( "recovers the %s alias to { type: string, format: %s } from an empty schema", (alias, format) => { const schema: any = {}; applyWidgetFormat(schema, alias); expect(schema).toEqual({ type: "string", format }); }, ); it("applies the format to a resolved string schema", () => { const schema: any = { type: "string" }; applyWidgetFormat(schema, "Color"); expect(schema).toEqual({ type: "string", format: "color" }); }); it("does not overwrite a schema that resolved to a $ref", () => { const schema: any = { $ref: "#/definitions/Foo" }; applyWidgetFormat(schema, "Color"); expect(schema).toEqual({ $ref: "#/definitions/Foo" }); }); it("does not touch a schema for a non-widget type hint", () => { const schema: any = {}; applyWidgetFormat(schema, "SomeRandomType"); expect(schema).toEqual({}); }); }); describe("typeToJsonSchema with an unresolvable widget alias import", () => { it("emits { type: string, format: color } for a Color field imported from a CDN", () => { const project = new Project({ useInMemoryFileSystem: true, compilerOptions: { skipLibCheck: true, noResolve: false }, }); // The import target is not resolvable, mirroring apps that import `Color` // from a remote deco-cx/apps CDN URL — `Color` therefore resolves to `any`. const sf = project.createSourceFile( "props.ts", ` import type { Color } from "https://cdn.example.com/admin/widgets.ts"; export interface Props { /** @title Cor do Texto */ textLeftColor?: Color; } `, ); const propsType = sf.getInterfaceOrThrow("Props").getType(); const schema = typeToJsonSchema(propsType); expect(schema.type).toBe("object"); expect(schema.properties.textLeftColor).toEqual({ title: "Cor do Texto", type: "string", format: "color", }); }, 30_000); }); describe("typeToJsonSchema with intersection types", () => { it("keeps a branded primitive (`string & { __brand }`) as a string", () => { const project = new Project({ useInMemoryFileSystem: true, compilerOptions: { skipLibCheck: true }, }); const sf = project.createSourceFile( "props.ts", ` type Slug = string & { readonly __brand: unique symbol }; export interface Props { slug?: Slug } `, ); const schema = typeToJsonSchema(sf.getInterfaceOrThrow("Props").getType()); expect(schema.properties.slug.type).toBe("string"); }, 30_000); it("expands an object intersection into a merged field set (recursive menu shape)", () => { const project = new Project({ useInMemoryFileSystem: true, compilerOptions: { skipLibCheck: true }, }); // Mirrors a production header `SiteNavigationElement` recursive workaround: // nested children written as `Leaf & { children?: Array<…> }`. Before the // intersection branch these collapsed to `children: { items: { type: "string" } }`. const sf = project.createSourceFile( "props.ts", ` interface Leaf { /** The name of the item. */ name?: string; /** URL of the item. */ url?: string; } export interface Props { navItems?: Array< Leaf & { children?: Array; } >; } `, ); const schema = typeToJsonSchema(sf.getInterfaceOrThrow("Props").getType()); const item = schema.properties.navItems.items; expect(item.type).toBe("object"); expect(Object.keys(item.properties).sort()).toEqual(["children", "name", "url"]); const child = item.properties.children.items; expect(child.type).toBe("object"); expect(child.properties.name.type).toBe("string"); expect(child.properties.url.type).toBe("string"); expect(child.properties.children.items.type).toBe("object"); }, 30_000); }); describe("typeToJsonSchema Section-typed props", () => { // The framework's `Section` is opaque (`export type Section = any`), so a // Section-typed prop is a "pick any section" reference emitted as a // __SECTION_REF__ picker. This must keep working. it("emits a __SECTION_REF__ picker for the framework's opaque Section[]", () => { const project = new Project({ useInMemoryFileSystem: true, compilerOptions: { skipLibCheck: true }, }); const sf = project.createSourceFile( "props.ts", ` type Section = any; export interface Props { children?: Section[] | null; } `, ); const schema = typeToJsonSchema(sf.getInterfaceOrThrow("Props").getType()); expect(schema.properties.children).toMatchObject({ type: "array", items: { $ref: "#/definitions/__SECTION_REF__" }, }); }, 30_000); // Regression (a production footer): a component that declares its own local // `type Section = { label; items }` used to collide with the magic name and // collapse into a non-editable __SECTION_REF__ picker — the footer columns // showed only a drag handle, no expand arrow, no form. A concretely-shaped // local type is user data and must render as an inline editable object. it("keeps a user-defined local `type Section` as an inline editable object", () => { const project = new Project({ useInMemoryFileSystem: true, compilerOptions: { skipLibCheck: true }, }); const sf = project.createSourceFile( "props.ts", ` interface Item { label: string; href: string; } type Section = { label: string; items: Item[] }; export interface Props { sections?: Section[]; } `, ); const schema = typeToJsonSchema(sf.getInterfaceOrThrow("Props").getType()); const sections = schema.properties.sections; // Editable array of objects — NOT a section-picker reference. expect(sections.type).toBe("array"); expect(JSON.stringify(sections)).not.toContain("__SECTION_REF__"); const item = sections.items; expect(item.type).toBe("object"); expect(item.properties.label).toMatchObject({ type: "string" }); expect(item.properties.items.type).toBe("array"); expect(item.properties.items.items.type).toBe("object"); expect(item.properties.items.items.properties.href).toMatchObject({ type: "string" }); }, 30_000); }); describe("typeToJsonSchema loader block-ref matching", () => { // Regression: a loader that returns an array whose element type has no // resolvable name (`VNode[]`, `string[]`, …) used to be bucketed under the // generic key "Array". Because an array type's OWN symbol name is also // "Array", EVERY array-of-objects section prop (`Collection[]`, `Tab[]`) // matched that bucket and collapsed into a block-ref picker — so the array // was no longer editable and its item fields (label, nested loaders) all // disappeared from the CMS form. The two guards (drop the "Array" bucket at // registration; never look it up from an array prop's symbol name) keep the // array inline and editable. it("does not collapse an array-of-objects prop into a block-ref via the generic 'Array' bucket", () => { const project = new Project({ useInMemoryFileSystem: true, compilerOptions: { skipLibCheck: true }, }); const sf = project.createSourceFile( "props.ts", ` interface Product { productID: string; name: string; } interface Collection { label: string; products: Product[] | null; } export interface Props { collections: Collection[]; topLevelProducts?: Product[] | null; } `, ); const ctx = { // Mimics a site where List/Sections (VNode[]) and skuList (string[]) // would otherwise land in an "Array" bucket, plus a real Product[] loader. outputTypeToLoaderKeys: new Map([ ["Product[]", ["site/loaders/algolia/products/list.ts"]], ["Array", ["site/loaders/List/Sections.tsx", "site/loaders/skuList.ts"]], ]), }; const schema = typeToJsonSchema(sf.getInterfaceOrThrow("Props").getType(), new Set(), ctx); const collections = schema.properties.collections; // Stays an editable array of objects — NOT a block-ref anyOf. expect(collections.type).toBe("array"); expect(collections.anyOf).toBeUndefined(); const item = collections.items; expect(item.type).toBe("object"); expect(item.properties.label).toMatchObject({ type: "string" }); // The nested Product[] field still resolves to a loader picker (its type // name matches a registered loader output). expect(item.properties.products.anyOf).toEqual([ { $ref: "#/definitions/Resolvable" }, { $ref: "#/definitions/c2l0ZS9sb2FkZXJzL2FsZ29saWEvcHJvZHVjdHMvbGlzdC50cw==" }, ]); // Control: a top-level Product[] prop still resolves to a loader picker. expect(schema.properties.topLevelProducts.anyOf).toEqual([ { $ref: "#/definitions/Resolvable" }, { $ref: "#/definitions/c2l0ZS9sb2FkZXJzL2FsZ29saWEvcHJvZHVjdHMvbGlzdC50cw==" }, ]); }, 30_000); }); // --------------------------------------------------------------------------- // Default output path (.deco/) — subprocess, mirrors the pattern in // generate-sections.test.ts. generate-schema.ts IS guarded by isMainModule(), // but it's still driven as a subprocess here so the CLI's argv-parsed OUT_REL // top-level code runs against a real cwd. // --------------------------------------------------------------------------- const SCRIPT = path.resolve(__dirname, "generate-schema.ts"); function runGenerator( args: string[], opts: { cwd?: string } = {}, ): { stdout: string; stderr: string; code: number } { const r = cp.spawnSync("npx", ["tsx", SCRIPT, ...args], { encoding: "utf8", cwd: opts.cwd }); return { stdout: r.stdout || "", stderr: r.stderr || "", code: r.status ?? 0 }; } describe("generate-schema default output path (.deco/)", () => { let tmpDir: string; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "generate-schema-defaults-")); fs.writeFileSync( path.join(tmpDir, "tsconfig.json"), JSON.stringify({ compilerOptions: { target: "ES2020", module: "ESNext", moduleResolution: "Bundler", jsx: "react-jsx", skipLibCheck: true, strict: true, }, }), ); const sectionsDir = path.join(tmpDir, "src", "sections"); fs.mkdirSync(sectionsDir, { recursive: true }); fs.writeFileSync( path.join(sectionsDir, "Hero.tsx"), [ "export interface Props {", " title: string;", "}", "export default function Hero(props: Props) { return null; }", ].join("\n"), ); }); afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); it("writes to .deco/meta.gen.json when no --out flag is passed", () => { const { code } = runGenerator(["--skip-apps"], { cwd: tmpDir }); expect(code).toBe(0); const newDefault = path.join(tmpDir, ".deco", "meta.gen.json"); expect(fs.existsSync(newDefault)).toBe(true); const meta = JSON.parse(fs.readFileSync(newDefault, "utf-8")); expect(meta.manifest.blocks.sections).toHaveProperty("site/sections/Hero.tsx"); }, 30_000); it("does not warn about a legacy default path", () => { const { code, stderr } = runGenerator(["--skip-apps"], { cwd: tmpDir }); expect(code).toBe(0); expect(stderr).not.toContain("Generator default output moved"); }, 30_000); });