Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/web/app/(main)/docs/api/core/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> {
Expand Down
105 changes: 104 additions & 1 deletion packages/core/src/schema.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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({}));
});
});
});

// =============================================================================
Expand Down
75 changes: 69 additions & 6 deletions packages/core/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
}

/**
Expand Down Expand Up @@ -569,18 +580,72 @@ 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<string, CatalogComponentDef> | undefined,
componentNames: string[],
options: PromptOptions,
): {
components: Record<string, CatalogComponentDef> | 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<string, CatalogComponentDef> = {};
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<string, CatalogComponentDef> = {};
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
*/
function generatePrompt<TDef extends SchemaDefinition, TCatalog>(
catalog: Catalog<TDef, TCatalog>,
options: PromptOptions,
): string {
const rawComponents = (catalog.data as Record<string, unknown>).components as
| Record<string, CatalogComponentDef>
| 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<TCatalog> = {
catalog: catalog.data,
componentNames: catalog.componentNames,
componentNames: filteredNames,
actionNames: catalog.actionNames,
options,
formatZodType,
Expand Down Expand Up @@ -644,10 +709,8 @@ function generatePrompt<TDef extends SchemaDefinition, TCatalog>(
lines.push("");

// Build example using actual catalog component names and props to avoid hallucinations
const allComponents = (catalog.data as Record<string, unknown>).components as
| Record<string, CatalogComponentDef>
| 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];
Expand Down Expand Up @@ -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)) {
Expand Down