@suluk/builder
v0.1.16
Published
Tiered contract-narrowing DSL (components→blocks→sections→pages) bound to the Suluk cycle. A page composes sections; buildApp emits backend (routes+v4) AND frontend (page TSX) at once. Inspired by multivendorbuilder's DSL, rebuilt with the Suluk disciplin
Readme
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.
Install
bun add @suluk/builderWhat it does
- One spec → both ends. Give
buildAppa list of entities (each a v4 Schema Object); it returns the backend (5 CRUDRouteContracts per entity, emitted to a valid v4 document) and the frontend (a shadcn Form + Table per entity, plus a page TSX that composes them). - A tiered contract-narrowing DSL —
components → blocks → sections → pages. Each tier hardcodes most of the tier below and re-publishes a deliberately narrowerparams. 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. - Modules — a
SulukModuleis a mergeable v4 contract fragment (entities + CRUD + cost + provider slots).installModulemerges one into the app document and refuses on any collision or unmet requirement (never partially applies).composeModulesinstalls a whole set in dependency order. - Distribution —
toShadcnRegistrypackages each entity slice (its components + backend routes + v4 schema) as one installable shadcn registry item;signRegistry/verifyRegistrySignatureadd publisher provenance for an open marketplace.
Everything is pure and synchronous (no I/O, no runtime), so the whole composition is unit-tested.
When to reach for it
- You want to compose a whole app declaratively from first-party building blocks rather than hand-wiring routes, forms, and tables for every entity.
- You're building a module/registry surface — installable, composable contract fragments with collision-refusal, dependency ordering, swappable provider slots, and signed provenance.
- You're authoring a landing page as a projection (hero/features/pricing/testimonials/faq/cta/footer) with
i18n-key copy + SEO meta + JSON-LD — see
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.
Usage
Build a whole app from entities (the common case)
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-soundcrudRoutesFromSchema(name, schema) is the unit behind the backend if you only want the routes for one
entity.
Package each slice as an installable shadcn registry
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`Compose a platform from modules
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);Swap a provider slot
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)Build a marketing landing (landing-as-projection)
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.
Trusting a remote registry
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);API
| 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.
Boundary
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.
License
Apache-2.0
