diff --git a/apps/web/app/(main)/docs/api/core/page.mdx b/apps/web/app/(main)/docs/api/core/page.mdx index c0198778..affaf419 100644 --- a/apps/web/app/(main)/docs/api/core/page.mdx +++ b/apps/web/app/(main)/docs/api/core/page.mdx @@ -77,6 +77,8 @@ interface PromptOptions { customRules?: string[]; // Additional rules to append mode?: "standalone" | "inline" | "generate" | "chat"; // Output mode (default: "standalone") editModes?: EditMode[]; // Edit modes to document in prompt (default: ["patch"]) + include?: string[]; // Only document these components in the prompt + exclude?: string[]; // Skip these components (ignored if include is set) } interface SpecValidationResult { diff --git a/packages/core/src/schema.test.ts b/packages/core/src/schema.test.ts index 94a57baf..c049fba7 100644 --- a/packages/core/src/schema.test.ts +++ b/packages/core/src/schema.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { z } from "zod"; import { defineSchema, defineCatalog } from "./schema"; @@ -539,6 +539,109 @@ describe("catalog.prompt", () => { expect(prompt).toContain("DYNAMIC PROPS:"); expect(prompt).toContain("RULES:"); }); + + describe("include / exclude filtering", () => { + const filterCatalog = defineCatalog(testSchema, { + components: { + Card: { + props: z.object({ title: z.string() }), + description: "Card container", + slots: ["default"], + }, + Button: { + props: z.object({ label: z.string() }), + description: "Clickable button", + slots: [], + }, + Metric: { + props: z.object({ value: z.number() }), + description: "Metric display", + slots: [], + }, + }, + actions: {}, + }); + + it("only documents components listed in `include`", () => { + const prompt = filterCatalog.prompt({ include: ["Card", "Metric"] }); + expect(prompt).toContain("AVAILABLE COMPONENTS (2):"); + expect(prompt).toContain("- Card:"); + expect(prompt).toContain("- Metric:"); + expect(prompt).not.toMatch(/^- Button:/m); + }); + + it("omits components listed in `exclude`", () => { + const prompt = filterCatalog.prompt({ exclude: ["Button"] }); + expect(prompt).toContain("AVAILABLE COMPONENTS (2):"); + expect(prompt).toContain("- Card:"); + expect(prompt).toContain("- Metric:"); + expect(prompt).not.toMatch(/^- Button:/m); + }); + + it("treats `include` as authoritative when both are provided", () => { + const prompt = filterCatalog.prompt({ + include: ["Button"], + exclude: ["Button"], + }); + expect(prompt).toContain("AVAILABLE COMPONENTS (1):"); + expect(prompt).toContain("- Button:"); + expect(prompt).not.toMatch(/^- Card:/m); + }); + + it("uses filtered components in the streaming example output", () => { + const prompt = filterCatalog.prompt({ include: ["Metric"] }); + expect(prompt).toContain('"type":"Metric"'); + expect(prompt).not.toContain('"type":"Card"'); + expect(prompt).not.toContain('"type":"Button"'); + }); + + it("warns when `include` references an unknown component", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + filterCatalog.prompt({ include: ["Card", "DoesNotExist"] }); + expect(warn).toHaveBeenCalledWith( + '[json-render] catalog.prompt(): include references unknown component "DoesNotExist"', + ); + warn.mockRestore(); + }); + + it("warns when `exclude` references an unknown component", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + filterCatalog.prompt({ exclude: ["Ghost"] }); + expect(warn).toHaveBeenCalledWith( + '[json-render] catalog.prompt(): exclude references unknown component "Ghost"', + ); + warn.mockRestore(); + }); + + it("forwards filtered names to a custom promptTemplate", () => { + const customSchema = defineSchema( + (s) => ({ + spec: s.object({ root: s.string() }), + catalog: s.object({ + components: s.map({ props: s.zod(), description: s.string() }), + }), + }), + { + promptTemplate: (ctx) => ctx.componentNames.join(","), + }, + ); + const customCatalog = customSchema.createCatalog({ + components: { + Alpha: { props: z.object({}), description: "" }, + Beta: { props: z.object({}), description: "" }, + Gamma: { props: z.object({}), description: "" }, + }, + }); + expect(customCatalog.prompt({ include: ["Alpha", "Gamma"] })).toBe( + "Alpha,Gamma", + ); + expect(customCatalog.prompt({ exclude: ["Beta"] })).toBe("Alpha,Gamma"); + }); + + it("leaves the prompt unchanged when neither option is provided", () => { + expect(filterCatalog.prompt()).toBe(filterCatalog.prompt({})); + }); + }); }); // ============================================================================= diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index a57cfd14..7f6bbf14 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -146,6 +146,17 @@ export interface PromptOptions { mode?: "standalone" | "inline" | "generate" | "chat"; /** Edit modes to document in the system prompt. Default: `["patch"]`. */ editModes?: EditMode[]; + /** + * Only document components whose names appear in this list. Useful when the + * catalog is large and only a subset is relevant for the current request. + * Unknown names log a `console.warn`. + */ + include?: string[]; + /** + * Skip components whose names appear in this list. Unknown names log a + * `console.warn`. Ignored when `include` is also set. + */ + exclude?: string[]; } /** @@ -569,6 +580,54 @@ function getPropsFromPath(path: string, catalogData: unknown): z.ZodType[] { return []; } +/** + * Apply `include` / `exclude` to the catalog's components, preserving the + * original declaration order. `include` wins when both are set. + */ +function resolveFilteredComponents( + components: Record | undefined, + componentNames: string[], + options: PromptOptions, +): { + components: Record | undefined; + names: string[]; +} { + if (!components) return { components, names: componentNames }; + + const known = new Set(componentNames); + const warnUnknown = (kind: "include" | "exclude", name: string): void => { + console.warn( + `[json-render] catalog.prompt(): ${kind} references unknown component "${name}"`, + ); + }; + + if (options.include) { + for (const name of options.include) { + if (!known.has(name)) warnUnknown("include", name); + } + const allowed = new Set(options.include); + const filtered: Record = {}; + for (const name of componentNames) { + if (allowed.has(name)) filtered[name] = components[name]!; + } + return { components: filtered, names: Object.keys(filtered) }; + } + + if (options.exclude && options.exclude.length > 0) { + for (const name of options.exclude) { + if (!known.has(name)) warnUnknown("exclude", name); + } + const denied = new Set(options.exclude); + const filtered: Record = {}; + for (const name of componentNames) { + if (!denied.has(name)) filtered[name] = components[name]!; + } + return { components: filtered, names: Object.keys(filtered) }; + } + + return { components, names: componentNames }; +} + /** * Generate system prompt from catalog */ @@ -576,11 +635,17 @@ function generatePrompt( catalog: Catalog, options: PromptOptions, ): string { + const rawComponents = (catalog.data as Record).components as + | Record + | undefined; + const { components: filteredComponents, names: filteredNames } = + resolveFilteredComponents(rawComponents, catalog.componentNames, options); + // Check if schema has a custom prompt template if (catalog.schema.promptTemplate) { const context: PromptContext = { catalog: catalog.data, - componentNames: catalog.componentNames, + componentNames: filteredNames, actionNames: catalog.actionNames, options, formatZodType, @@ -644,10 +709,8 @@ function generatePrompt( lines.push(""); // Build example using actual catalog component names and props to avoid hallucinations - const allComponents = (catalog.data as Record).components as - | Record - | undefined; - const cn = catalog.componentNames; + const allComponents = filteredComponents; + const cn = filteredNames; const comp1 = cn[0] || "Component"; const comp2 = cn.length > 1 ? cn[1]! : comp1; const comp1Def = allComponents?.[comp1]; @@ -791,7 +854,7 @@ Note: state patches appear right after the elements that use them, so the UI fil const components = allComponents; if (components) { - lines.push(`AVAILABLE COMPONENTS (${catalog.componentNames.length}):`); + lines.push(`AVAILABLE COMPONENTS (${filteredNames.length}):`); lines.push(""); for (const [name, def] of Object.entries(components)) {