Compose a whole app from one declarative spec — buildApp emits the backend (CRUD routes + a v4 document) AND the frontend (shadcn components + page TSX) at once.
Part of Suluk — one typed OpenAPI v4 contract projecting into every full-stack layer.
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/builder
buildApp a list of entities (each a v4 Schema Object); it returns the
backend (5 CRUD RouteContracts per entity, emitted to a valid v4 document) and the frontend (a shadcn
Form + Table per entity, plus a page TSX that composes them).components → blocks → sections → pages. Each tier hardcodes most of
the tier below and re-publishes a deliberately narrower params. A page can reorder/hide a section's
blocks and set its tone, but it cannot reach into the form's fields — those simply aren't in the
section's contract, so the validator rejects them. The narrowing is the safety surface.SulukModule is a mergeable v4 contract fragment (entities + CRUD + cost + provider slots).
installModule merges one into the app document and refuses on any collision or unmet requirement
(never partially applies). composeModules installs a whole set in dependency order.toShadcnRegistry packages each entity slice (its components + backend routes + v4
schema) as one installable shadcn registry item; signRegistry/verifyRegistrySignature add publisher
provenance for an open marketplace.Everything is pure and synchronous (no I/O, no runtime), so the whole composition is unit-tested.
buildMarketing.Reach past it for the pieces it composes: the underlying schema→contract conversion is @suluk/zod /
@suluk/drizzle, the route → v4 + validation engine is @suluk/hono, and the form/table TSX is
@suluk/shadcn. @suluk/builder orchestrates those; it doesn't replace them.
import * as z from "zod";
import { zodToV4 } from "@suluk/zod";
import { buildApp, type Entity } from "@suluk/builder";
// entities as v4 Schema Objects (Zod → v4 is the cycle's standard path)
const Pet: Entity = {
name: "Pet",
schema: zodToV4(z.object({
id: z.number().int().optional(),
name: z.string().min(1),
status: z.enum(["available", "sold"]),
})).schema,
};
const Category: Entity = {
name: "Category",
schema: zodToV4(z.object({ id: z.number().int().optional(), name: z.string() })).schema,
};
const app = buildApp({ entities: [Pet, Category], info: { title: "Shop", version: "1.0.0" } });
app.backend.routes; // 10 RouteContracts (5 CRUD × 2 entities): listPet, createPet, …
app.backend.document; // a valid OpenAPI v4 document emitted from those routes
app.frontend.components; // [{ name: "PetForm", tsx }, { name: "PetTable", tsx }, …]
app.frontend.pages; // [{ name: "App", tsx }] — page composing every entity's CRUD section
app.errors; // DslError[] — empty ⇒ the composition is contract-sound
crudRoutesFromSchema(name, schema) is the unit behind the backend if you only want the routes for one
entity.
import { buildApp, toShadcnRegistry } from "@suluk/builder";
const reg = toShadcnRegistry(app, { name: "pets-registry" });
reg.index; // registry.json — name + homepage + items[]
reg.items; // one registry:block per entity (Form + Table + backend routes + v4 schema)
// plus one registry:page per generated page
// each block installs the whole vertical via `npx shadcn add pet-crud`
A SulukModule is a mergeable v4 contract fragment. The package ships a curated first-party registry
(AUTH, ECOMMERCE, CRM, BILLING, MARKETING) and composeModules installs a set in dependency order
(providers before requirers) through the same refuse-on-collision gate:
import { composeModules, AUTH, ECOMMERCE, CRM } from "@suluk/builder";
const empty = { openapi: "4.0.0-candidate", info: { title: "App", version: "1.0.0" }, paths: {}, components: { schemas: {} } };
// deliberately out of order — planComposition topologically sorts so AUTH (provides User) installs first
const { doc, steps, ok } = composeModules(empty, [ECOMMERCE, CRM, AUTH]);
ok; // true ⇒ every requirement met, no collision, all installed
Object.keys(doc.components!.schemas!); // Account, Cart, Contact, Deal, Order, Product, Session, User, …
Install one module directly (and preview before committing):
import { installModule, previewInstall, namespaceModule } from "@suluk/builder";
const preview = previewInstall(appDoc, ECOMMERCE);
preview.willInstall; // boolean
preview.conflicts; // why it would be refused (collisions / unmet requires)
preview.addsSchemas; // ["Product", "Order", …]
preview.grade; // { grade: "A" | "B" | "C", costCoverage, warnings, … }
const result = installModule(appDoc, ECOMMERCE);
if (!result.installed) {
// refused — appDoc is UNCHANGED; result.conflicts explains why
// resolve a name clash by namespacing the module, then retry:
installModule(appDoc, namespaceModule(ECOMMERCE, "Shop")); // ⇒ ShopProduct, ShopOrder, …
}
Build a stack from a named template:
import { resolveTemplate, STACK_TEMPLATES, composeModules } from "@suluk/builder";
const saas = STACK_TEMPLATES.find((t) => t.name === "SaaS starter")!; // auth + billing + crm
const { modules, missing } = resolveTemplate(saas); // missing reports any unresolved name
const platform = composeModules(emptyDoc, modules);
A module declares providerSlots (e.g. { payments: "stripe" }), recorded into the document as
x-suluk-providers. Swap one binding for another implementation of the same interface:
import { readProviders, swapProvider, PROVIDER_CATALOG } from "@suluk/builder";
readProviders(doc); // [{ facet: "payments", impl: "stripe", alternatives: […] }]
const { doc: next, error } = swapProvider(doc, "payments", "paddle"); // error set (doc unchanged) on a bad swap
PROVIDER_CATALOG.payments; // stripe, paddle, lemonsqueezy (all the same PaymentProvider interface)
import { buildMarketing, seoMeta, jsonLd, type MarketingSpec } from "@suluk/builder";
const spec: MarketingSpec = {
hero: { titleKey: "home.heroTitle", ctaKey: "home.getStarted", ctaHref: "/signup" },
features: { featureKeys: ["home.f1", "home.f2"] },
pricing: { plans: [{ id: "pro", nameKey: "plans.pro", priceCents: 2900, featureKeys: ["plans.f1"], ctaKey: "plans.cta", stripePriceId: "price_123" }] },
testimonials: {}, // present ⇒ include; default source = approved Reviews (the app resolves the query)
faq: {},
};
const landing = buildMarketing(spec); // { sections, blocks, page, errors } — synchronous, no fetch
const meta = seoMeta({ title: "Acme", description: "The app." }); // og* default from title/description
const ld = jsonLd("Organization", { name: "Acme", url: "https://acme.test" }); // schema.org for <head>
Copy is carried as i18n message keys (the app resolves them per locale) and data-driven sections carry a
declarative source projection — never literal copy or an author-time fetch.
import { parseRegistry, validateModule, verifyRegistrySignature } from "@suluk/builder";
const parsed = parseRegistry(await (await fetch(url)).json()); // rejects malformed/hostile entries (surfaced, not hidden)
parsed.modules; // only the well-formed ModuleEntry[]
parsed.rejected; // [{ title, reason }] — why each was refused
// for signed registries, verify against a pinned publisher public key before trusting:
const trusted = await verifyRegistrySignature(registryValue, signatureB64, publisherPublicKeyJwk);
| Export | What it does |
|---|---|
buildApp(spec) |
The headline: entities → { backend: { routes, document }, frontend: { components, pages }, registry, errors }. |
crudRoutesFromSchema(name, schema, defs?) |
The 5 CRUD RouteContracts for one entity (the unit behind the backend). |
formBlock / tableBlock / crudSection / appPage |
The DSL documents for each tier (blocks → section → page). |
registry / emptyRegistry / findDoc / allowedTypes |
Build and query the document registry the validator/renderer resolves against. |
validateDocument / validateAll |
The contract-narrowing + tier-rule checks → DslError[]. |
resolveParams / resolveList |
A document's effective param values / a list param's ordered selection. |
renderPageTsx / resolveComponents |
Render a page document to frontend TSX / the ordered component names it resolves to. |
toShadcnRegistry(app, opts?) |
Package the built app as a shadcn registry (one block per entity + a page item). |
installModule / previewInstall / namespaceModule / gradeModule |
Install / preview / collision-resolve / grade a SulukModule. |
planComposition / composeModules |
Dependency-order and install a set of modules into one platform contract. |
AUTH / ECOMMERCE / CRM / BILLING / MARKETING |
The curated first-party modules (+ FIRST_PARTY_REGISTRY). |
STACK_TEMPLATES / resolveTemplate |
Named module sets ("SaaS starter", "Storefront", …) and their resolver. |
PREVIEW / PREVIEW_ONLY_MARKER |
The deploy-only role-login fragment (deliberately excluded from the registry/templates). |
buildMarketing / marketingPage / seoMeta / jsonLd |
The marketing section tier + SEO meta + schema.org JSON-LD. |
PROVIDER_CATALOG / readProviders / swapProvider / providerFacets |
Read and swap provider slots (payments/auth/email/storage). |
parseRegistry / validateModule |
Validate an untrusted registry fetched from a URL, surfacing rejected entries. |
signRegistry / verifyRegistrySignature / generateSigningKeypair / isSignedEnvelope |
ECDSA P-256 publisher signatures for the open marketplace. |
The package has a single entry point — @suluk/builder (import { … } from "@suluk/builder"). There are no
sub-path exports and no CLI.
This package renders and generates — it never hosts (the Suluk L3 line). buildApp/renderPageTsx/
toShadcnRegistry emit source and documents you own and can edit (TSX components, a v4 document, a shadcn
registry); the module functions return a merged contract value. Nothing here calls home or stands up a runtime.
Everything is pure over its inputs: buildApp takes entity schemas (you bring the Drizzle/Zod source), the
registry/remote functions take the bytes (you fetch the JSON), and the generated frontend wires to the
@suluk/nano-stores client at a baseUrl you supply. Database access, the actual route handlers, custom
business rules, and the leaf UI components stay app-side — the package composes the contract and the
scaffolding, not the irreducible 20%.
New reusable blocks, sections, and modules belong in the first-party registry here; app-specific compositions stay in your app.
Apache-2.0