v4 "Suluk" Schema Objects → shadcn/ui form, table, and detail TSX you own — plus the shadcn theme files. Codegen, no runtime UI deps.
CANDIDATE tooling — not official OpenAPI. Suluk is a single-contributor candidate for OpenAPI Specification v4.0 ("Moonwalk"), unaffiliated with the OpenAPI Initiative and unable to ratify anything on the SIG's behalf.
bun add @suluk/shadcn
Reads a v4 Schema Object (which is JSON Schema 2020-12) and projects it onto shadcn/ui component source — as strings. The chain is Zod → v4 Schema Object (@suluk/zod) → descriptor (formSpec / tableSpec, here) → shadcn TSX (here).
formSpec / tableSpec inspect a schema and produce a UI-shaped model: which control per property (text / number / select / switch / date / email / file / richtext / relation / …), required-ness, enum options, bounds, relations.renderFormTsx (react-hook-form + zodResolver), renderTableTsx, renderDetailTsx (read-only show view), and matching loading skeletons emit idiomatic, self-contained shadcn component source against the conventional @/components/ui/* import paths.renderShadcnTheme turns a @suluk/theme TokenSpec into the two files a shadcn project needs: globals.css (token CSS vars + Tailwind v4 @theme inline) and components.json (cssVariables: true).$ref, boolean schema, non-object root) is enumerated in the spec's warnings: string[], and the renderers surface those warnings as a leading comment block. Nothing is dropped silently.Reach for @suluk/shadcn when you have a React/shadcn frontend and want generated, owned CRUD components scaffolded from your contract — code you then check in and edit by hand.
@suluk/shadcn when you want shadcn/ui TSX you keep and customize.@suluk/panel instead when you want a contract-first admin UI (Payload-style) that infers forms + tables for you, rather than emitting component source to own.@suluk/zod's v4ToZod corner. renderFormTsx references the schema by name (import { FormSchema } from "./schema") so the file is drop-in once you wire that import.import * as z from "zod";
import { zodToV4 } from "@suluk/zod";
import { formSpec, tableSpec, renderFormTsx, renderTableTsx } from "@suluk/shadcn";
// Zod is the source of truth → a v4 Schema Object (JSON Schema 2020-12)
const Pet = z.object({
name: z.string(),
status: z.enum(["available", "pending", "sold"]),
age: z.number(),
active: z.boolean(),
});
const petSchema = zodToV4(Pet).schema;
// Descriptor pass: inspect the schema
const fSpec = formSpec(petSchema); // → { fields: [...], warnings: [] }
const tSpec = tableSpec(petSchema); // → { columns: [...], warnings: [] }
// Codegen pass: emit shadcn TSX as strings
const formTsx = renderFormTsx(fSpec, { componentName: "PetForm", schemaName: "PetSchema" });
const tableTsx = renderTableTsx(tSpec, { componentName: "PetTable" });
await Bun.write("src/components/pet-form.tsx", formTsx);
await Bun.write("src/components/pet-table.tsx", tableTsx);
formSpec picks the control per property: status (an enum) becomes a <Select> carrying the enum options, active (boolean) a <Switch>, age a number <Input>, name a text <Input>. Formats and x-suluk-* facets refine the pick — format: "email"/"uri"/"date"/"date-time" map to the matching input; contentEncoding: "base64" / format: "binary" → a file input; contentMediaType: "text/html" → a richtext editor; an x-suluk-relation: "User" property → an entity-picker; and an explicit x-suluk-widget override always wins.
The generated form is hardened shadcn: mode: "onSubmit" with reValidateMode: "onChange", a root-error alert banner (role="alert"), a submit <Button> disabled while submitting, and reset-on-success (toggle with resetOnSuccess: false).
import {
formSpec, tableSpec,
renderDetailTsx,
renderFormSkeletonTsx, renderTableSkeletonTsx, renderDetailSkeletonTsx,
} from "@suluk/shadcn";
const fSpec = formSpec(petSchema);
const tSpec = tableSpec(petSchema);
renderDetailTsx(fSpec, { componentName: "PetDetail" }); // a read-only <dl> of label → value
renderFormSkeletonTsx(fSpec); // a placeholder per field + submit
renderTableSkeletonTsx(tSpec, { rows: 8 }); // header + N body rows of placeholders
renderDetailSkeletonTsx(fSpec); // a placeholder row per field
Together these complete the CRUD UI set: form (create/edit), table (list), detail (show), and a loading skeleton mirroring each shape so the layout doesn't jump when data lands.
$ref resolutionPass a defs map so a top-level or property-level Reference resolves by name (works for bare names and full #/components/schemas/<name> JSON-Pointers alike):
formSpec({ $ref: "#/components/schemas/Pet" }, { defs: { Pet: petSchema } });
import { renderShadcnTheme, renderGlobalsCss, renderComponentsJson } from "@suluk/shadcn";
import { terracotta } from "@suluk/theme";
// the whole theme: both files keyed by their on-disk paths
const files = renderShadcnTheme(terracotta, { cssPath: "src/app/globals.css" });
// → { "src/app/globals.css": "<css>", "components.json": "<json>" }
for (const [path, content] of Object.entries(files)) await Bun.write(path, content);
// or each file on its own
const css = renderGlobalsCss(terracotta, { darkSelector: ".dark" });
const json = renderComponentsJson({ style: "new-york", baseColor: "neutral" });
One @suluk/theme TokenSpec (light) — or a full ThemeSpec ({ light, dark }) — drives globals.css and components.json together, instead of hand-syncing the token CSS against the component library. A bare TokenSpec has its dark scheme derived; a full ThemeSpec is used as-is.
| Export | What it does |
|---|---|
formSpec(schema, opts?) |
Schema Object → FormSpec ({ fields, warnings }); one FieldSpec per object property. |
tableSpec(schema, opts?) |
Schema Object (object or array) → TableSpec ({ columns, warnings }). |
renderFormTsx(spec, opts?) |
FormSpec → shadcn <Form> TSX (react-hook-form + zodResolver) as a string. |
renderTableTsx(spec, opts?) |
TableSpec → shadcn <Table> TSX as a string. |
renderDetailTsx(spec, opts?) |
FormSpec → read-only detail (<dl> show) view TSX. |
renderFormSkeletonTsx / renderTableSkeletonTsx / renderDetailSkeletonTsx |
Loading-skeleton TSX mirroring each view's shape. |
renderShadcnTheme(theme, opts?) |
TokenSpec/ThemeSpec → { [cssPath]: globals.css, "components.json": … }. |
renderGlobalsCss(theme, opts?) |
Just the globals.css string. |
renderComponentsJson(opts?) |
Just the components.json string. |
Types: FieldWidget, FieldSpec, FormSpec, ColumnSpec, TableSpec, SpecOptions, RenderFormOptions, RenderTableOptions, RenderDetailOptions, RenderSkeletonOptions, ShadcnThemeOptions.
This package has a single entry point (@suluk/shadcn); there are no sub-path exports and no CLI.
This is the L3 line — render/generate, never host. @suluk/shadcn hands you component source you own and can edit; it imports no React/shadcn packages at runtime. The seams it leaves app-side:
@/components/ui/*); you supply those primitives (bunx shadcn add …). The form references its Zod schema by name (./schema) — you wire that import.richtext widget emits <RichTextEditor> and a relation widget emits <EntitySelect> — you provide those (a Lexical editor, an async entity picker).rows prop you supply; the detail view reads a record prop; the form's onSubmit is a stubbed handler you fill in.@suluk/zod (zodToV4) and the Zod schema for the resolver from its v4ToZod corner.Apache-2.0