Contract-first admin panels — Payload-style field types and a role-aware dashboard, projected from ONE OpenAPI v4 document. No config DSL, no DB coupling.
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/panel
Peer-depends on hono (the panel is a Hono app you mount on your router).
components.schemas JSON-Schema is mapped to the right widget — text / textarea / richtext / number / boolean / select / date / datetime / email / url / media / json / relationship — including enum options, required/nullable flags, and relationships (a categoryId whose Category is itself an entity). The contract IS the config; there's no separate collection DSL.headHtml).fetch GET/POST/PATCH/DELETE) — there is no DB adapter, so there's no config drift between the spec and the panel.Reach for @suluk/panel when you want a working admin UI derived from the contract — a Payload-equivalent that you didn't have to configure, mounted in-process on the same Hono app that serves your API.
@suluk/admin — that's the Suluk cockpit (/superadmin): the exhaustive raw-CRUD console for the spec author. @suluk/panel is the product-facing, role-projected dashboard you ship to end users.@suluk/shadcn — that generates owned React/shadcn TSX you commit and edit. @suluk/panel renders a complete server-side HTML panel at runtime; reach for shadcn when you want owned components in your own frontend instead.panelApp returns a Hono instance. Mount it on your router and gate it with authorize:
import { Hono } from "hono";
import { panelApp } from "@suluk/panel";
import { document } from "./contract"; // your OpenAPI v4 document
const app = new Hono();
app.route(
"/",
panelApp({
document, // the v4 doc (a value, or a per-request function of `c`)
basePath: "/panel", // default "/panel"
title: "Acme", // brand shown in the sidebar + titles
authorize: (c) => isSignedIn(c), // default: deny everything
}),
);
That mounts, under /panel: a card-grid home, and a list + create/edit form for every CRUD-managed entity the document exposes. The forms and tables call your contract's REST endpoints directly.
document (and stats / groups / sections / home / homeHeading) may each be a function of the request context, so one mount serves every audience off a role-projected document:
panelApp({
document: (c) => projectDocument(doc, viewerOf(c)), // per-role projection → per-role panel
basePath: "/panel",
authorize: (c) => isSignedIn(c),
homeHeading: (c) => (isAdmin(c) ? "Owner dashboard" : "Your account"),
hideEntities: ["AuthToken"], // omit some entities entirely
groups: (c) => (isAdmin(c) ? [...userGroups, ...adminGroups] : userGroups),
sections: (c) => (isAdmin(c) ? [...userSections, ...adminSections] : userSections),
stats: (c) => (isAdmin(c) ? adminStats() : userStats(c)),
home: (c) => renderOverview({ admin: isAdmin(c) }), // replaces the auto card-grid
});
A StatCard is { label, value, hint?, href? }. A PanelSection is a custom non-CRUD page mounted at ${basePath}/s/<id>, rendered inside the panel shell:
import type { PanelSection, StatCard } from "@suluk/panel";
const sections: PanelSection[] = [
{
id: "billing",
label: "Billing",
summary: "Plan & invoices",
render: (c) => `<div class="pf-section">…your billing HTML…</div>`,
},
];
Media fields (image / cover / avatar / logo / …) degrade to paste-a-URL unless you point them at an upload endpoint that accepts a multipart/form-data file and returns { url }:
panelApp({ document, authorize, uploadPath: "/upload" }); // e.g. an R2-backed worker route
The inference + render helpers are exported standalone, so you can compose your own surface without panelApp:
import { entityModels, renderList, renderForm, renderShell, fieldsOf } from "@suluk/panel";
const models = entityModels(document); // EntityModel[] — name, REST path, fields, title, access
const product = models.find((m) => m.name === "Product")!;
const list = renderList(product, { basePath: "/panel" });
const form = renderForm(product, { basePath: "/panel", relPaths: { Category: "/category" }, canDelete: true });
const html = renderShell({
title: "Acme", brand: "Acme", basePath: "/panel",
entities: models.map((m) => ({ name: m.name })),
active: "Product", heading: "Product", body: list,
});
// Or just infer the field set from a raw schema:
const fields = fieldsOf(document.components.schemas.Product, new Set(["Product", "Category"]));
| Export | What it does |
|---|---|
panelApp(opts) |
Build a Hono admin/dashboard app. Options: document, basePath, title, authorize, headHtml, hide, hideEntities, uploadPath, stats, groups, sections, home, homeHeading, homeLabel. |
entityModels(doc, opts?) |
Read the v4 doc → an EntityModel[] (REST path, inferred fields, title field, CRUD access derived from present ops). |
fieldsOf(schema, entities?, opts?) |
Infer the ordered Field[] for one entity schema (widget type, required/nullable/readOnly, enum options, relationships). |
titleField(fields) |
The entity's best "title" field — for list columns + relationship labels. |
humanize(name) |
"coverImageUrl" → "Cover Image URL", "categoryId" → "Category". |
renderList(model, { basePath }) |
Data-table HTML (client-side search / sort / paginate, per-row edit·delete) over the entity's REST list endpoint. |
renderForm(model, { basePath, relPaths, canDelete, uploadPath? }) |
Create/edit form HTML (loads the record on edit, coerces by type, POSTs/PATCHes to REST). |
renderInput(field, value?) / renderFieldRow(field, value?) |
One widget / one labelled field row for a Field. |
renderShell(opts) |
The two-pane panel chrome (sidebar nav + content); accepts a flat entities list or grouped nav. |
richtextEditor / richtextScript / RICHTEXT_CSS |
The dependency-free markdown rich-text editor (toolbar + Write/Preview) and its client init + styles. |
mediaEditor / mediaScript / MEDIA_CSS |
The media/upload widget (URL input + preview + optional file picker) and its client init + styles. |
PANEL_CSS |
The panel's theme stylesheet (CSS-variable vocabulary). |
Types: PanelOptions, StatCard, PanelSection, PanelGroup, EntityModel, Field, FieldType, FieldsOptions, ListOptions, FormOptions, ShellOptions, NavGroup, NavItem.
@suluk/panel renders; it does not host or persist (the Suluk L3 line — render/generate, never host). It emits HTML + client JS that drive your contract's REST endpoints; the database, the auth/session, and file storage stay app-side:
authorize is yours; the panel defaults to deny-all. CRUD availability is read off the (projected) operations — projection is how you scope per role.uploadPath (e.g. an R2-backed route) that returns { url }. Without one, media fields are paste-a-URL. Storage is the host's concern.fetch calls to the contract's own endpoints — so there's no second source of truth to drift.Apache-2.0