@kywi-software/core
v0.24.0
Published
Kywi CMS core — schema generator, REST API, and admin UI for Next.js.
Maintainers
Readme
@kywi-software/core
The engine behind Kywi CMS: it turns a kywi.config.ts file (content types,
sites, themes, auth providers) into a Postgres/MySQL schema (via Drizzle), a
REST API, and the building blocks for the admin UI — all inside a Next.js
app. See the root README for
an orientation to the whole project, and packages/core/src/config/types.ts
for the full config shape.
For agents building with Kywi
AGENT-PATTERNS.md (shipped with this package) is Kywi's
official guidance for agents building Kywi sites. The core principle: if a
non-developer might ever want to change it, model it in the CMS instead of
hardcoding it — content, collections, forms, and page sections belong in Kywi
so the site owner can maintain them without a developer. A scaffolded
create-kywi-app project embeds this doc in its AGENTS.md. It is a verbatim
copy of docs/agents/AGENT-PATTERNS.md in the monorepo (kept in sync by
scripts/sync-agent-patterns.mjs); edit the canonical there, not this copy.
Install
npm install @kywi-software/corePeer dependencies: react, react-dom (>=19), next (>=15), and
drizzle-kit (for migrations via @kywi-software/cli). Most projects don't
install @kywi-software/core directly — use npx create-kywi-app to
scaffold a project with it already wired up.
Minimal usage
kywi.config.ts — the single source of truth for a project:
import { defineKywiConfig, defineSite, defineTheme } from '@kywi-software/core/config'
export default defineKywiConfig({
mode: 'coupled', // or 'headless' / 'decoupled' — see the config types
db: { provider: 'postgresql', url: process.env.DATABASE_URL! },
auth: { secret: process.env.AUTH_SECRET!, providers: ['credentials'] },
sites: [defineSite({ id: 'default', name: 'My Site', domain: 'localhost', defaultLocale: 'en', theme: 'default' })],
themes: [defineTheme({ name: 'default', regions: [{ name: 'main', label: 'Main Content' }] })],
contentTypes: [],
})auth takes one optional flag beyond secret/providers:
strictSiteMembership (default false). It makes per-site authorization
deny-by-default for users with no kywi_site_memberships rows, instead of
treating them as unscoped — safe to turn on once every user has real
memberships, and documented in full in DEVELOPMENT.md.
app/api/v1/[...kywi]/route.ts — mounts the generated REST API:
import {
createDb,
createKywiApiHandler,
createStorageProvider,
resolveDatabaseUrl,
resolveAuthSecret,
} from '@kywi-software/core/server'
import config from '../../../../kywi.config'
const { url } = resolveDatabaseUrl(config.db.url)
const db = createDb(url, config.db.provider)
const authSecret = resolveAuthSecret(config.auth.secret)
const storage = createStorageProvider(config.media ?? { provider: 'local', localPath: './uploads' })
const handler = await createKywiApiHandler({ config, db, authSecret, storage })
export { handler as GET, handler as POST, handler as PATCH, handler as DELETE }Then kywi migrate --push && kywi seed (via @kywi-software/cli) applies
the schema and creates a superadmin user.
GraphQL API (read-only)
The same [...kywi] catch-all route also mounts a read-only GraphQL
endpoint at POST /api/v1/graphql — no separate wiring required. It
exposes content(id), contentBySlug(slug, siteId),
contentList(contentType, siteId, limit, page, status),
search(query, siteId, contentType, limit), categories(siteId),
nav(siteId), and media(id). There is no mutation type.
Auth. The endpoint reads the same Authorization: Bearer <token> header
as the REST API. A missing/invalid header resolves to an anonymous caller
rather than a 401 — anonymous access to published content is legal, matching
GET /search?status=published.
There are two tiers. Members of the query's siteId get what they ask
for, drafts included. Everyone else — anonymous callers and authenticated
callers who are not members of that site alike — gets the public tier: an
absent status narrows silently to published, and an EXPLICIT
non-published status is a GraphQL error raised before any row is
fetched. contentBySlug and search expose no status arg, so they always
land on one of those two tiers; a non-member simply sees published content,
the same as an anonymous caller (an API key scoped to site B can search site
A's published content, matching REST). content(id) differs only in
mechanism: it takes no siteId to check up front, so it fetches the row and
post-filters — a non-published row resolves to null for a caller without
draft access, rather than erroring.
siteId arguments accept either the config site id (slug) or the site's DB
UUID; they are normalized before any membership check or query, exactly as
the REST routes do.
"Member" means the same thing here as on the REST routes: superAdmin, or a
row in kywi_site_memberships for that site. A user with zero rows counts
as a member of every site unless the install opts into
auth.strictSiteMembership: true, which makes zero rows mean zero sites — see
DEVELOPMENT.md. Membership is read from the access token's sites claim, so
a change to it takes effect within the 15-minute access-token TTL.
media(id) is the one field with no public tier. Media rows carry no
publication status, so there is nothing to fall back to — the row is returned
only to a caller with access to the row's own site, and null to everyone
else, anonymous callers included. The public way to reach a file is the REST
serve route GET /media/:id/file, which is scoped to the site the request
addresses rather than to a caller-supplied UUID.
Per-content-type opt-in (default-deny). A content type is only
queryable through GraphQL when its config sets graphql: true:
contentTypes: [
{ name: 'article', label: 'Article', baseType: 'content', fields: [...], graphql: true },
{ name: 'internalNote', label: 'Internal Note', baseType: 'content', fields: [...] }, // graphql omitted → denied
]Omitting the flag (or setting it false) denies GraphQL access to that
type — this is default-deny, not default-allow. contentList for a
non-opted-in type returns a GraphQL error. content/contentBySlug
return null when the resolved row's content type isn't opted in.
search rejects an explicit contentType argument naming a non-opted-in
type, and otherwise silently filters results down to opted-in types only.
categories, nav, and media aren't content-type-scoped, so this flag
doesn't apply to them.
What's in the package
.— DB schema generation, REST API handler, hooks, media, forms, multisite, versioning, i18n, cache, and the audience/experiment engine (ax)../config—defineKywiConfig,defineSite,defineTheme../server—createKywiApiHandler,createDb,createStorageProvider,resolveDatabaseUrl,resolveAuthSecret../scope/./scope-client— the server- and client-side data-access layer used by generated pages and the admin UI../admin— React building blocks for the admin app (content tree, editors, layout editor, plugin manager, ...);apps/reference/app/admin/*in this repo shows how they're wired into Next.js App Router pages../admin/styles.css— the admin UI's stylesheet../layout,./nav— page layout rendering and navigation trees../audiences,./audiences/types,./evaluator— the audience targeting engine../experiments,./experiments/types— A/B testing../ax— the Agent Experience layer:llms.txt/llms-full.txt/sitemap.xml/robots.txt/JSON-LD/single-page-markdown generators (see below).
Content types: config + admin UI
Content types aren't config-only. Declare them in kywi.config.ts
(contentTypes: ContentTypeConfig[], source: 'config'), and/or create and
edit them visually in the admin at Settings → Content Types (source:
'admin') — the two compose at runtime. The admin UI
(packages/core/src/admin/surfaces/contentTypes/) is a type list plus a
field designer: add a type, then add/edit/delete/reorder its fields and apply
the resulting schema changes, without hand-writing config or migrations.
Front-of-site overlay editor (?kywi-edit=1)
Any content route a host app wires up can be edited in place: appending
?kywi-edit=1 to its public URL (or clicking Edit this page in the
admin toolbar) turns the live page into an editing surface for a signed-in
user with edit permission — no separate admin route to hunt down the page in.
The editor (OverlayShell,
packages/core/src/admin/layout-editor/overlay-shell.tsx) is in place, not
modal: the page keeps its own document flow, real width and stylesheet — the
canvas emits the public renderer's own markup (.kywi-region / .kywi-section
/ .kywi-column / .kywi-module-wrapper) — while the editing chrome docks to
the viewport edges as a top edit bar plus collapsible module / properties
rails. Hovering outlines section → container → module, clicking selects and
opens the properties rail, double-clicking a text-bearing module (heading, rich
text, button …) types straight on the page (committed on blur through the same
UPDATE_PROPS action the properties rail writes, so undo works), Esc
deselects, and a persistent Done leaves — confirming first if there are
unsaved changes.
Sticky site headers and the editor's bars
Two fixed bars can appear at the top of a public page for a signed-in editor:
the browse-mode toolbar (.kywi-edit-toolbar, 44px) and the edit-mode
bar (.kywi-overlay-toolbar, 46px). Each publishes its height as a token on
:root, so a theme never has to hard-code either number:
| Token | Bar | Default |
| --- | --- | --- |
| --kywi-browse-bar-height | browse-mode toolbar | 44px |
| --kywi-overlay-bar-height | edit-mode bar | 46px |
Both are declared on :root by ./site/styles.css, which every public page
loads — and again, identically, by the layout editor's editor.css. The second
sheet alone is not enough to rely on: a generated app pulls the admin design
system in behind next/dynamic, so editor.css is absent from the page until
someone opens the overlay editor, and browse mode would resolve neither token.
Give every var() on these tokens the matching px fallback anyway (44px /
46px) — a var() whose token is undefined falls back to the property's
initial value, and top: auto un-sticks an element outright rather than
merely mis-offsetting it.
While a bar is up, the document is offset by its height, which is enough for a
header in normal flow. It is not enough for position: sticky; top: 0 (or
position: fixed; top: 0): document padding moves an element's flow position,
not the line it pins to once it sticks — so a sticky nav would pin to viewport
y=0 underneath the bar as soon as the reader scrolled. The editor therefore
detects top-anchored elements when a bar mounts and patches each one's inline
top to the bar's token for the session, restoring the original exactly on the
way out (packages/core/src/scope/sticky-offset.ts). Nothing is required of a
theme for this to work.
Two things are worth knowing if you are writing one:
A sticky element that mounts after the editor opens is not offset. The scan runs once, when the bar mounts; a cookie bar on a timer or a menu that is
display: noneat that moment keeps its owntop. Anchor those yourself off the class the bar puts on<html>—kywi-frontend-edit--browsingorkywi-frontend-edit--editing, which are also how you special-case anything else for an editing session:.my-late-sticky { top: 0; } .kywi-frontend-edit--browsing .my-late-sticky { top: var(--kywi-browse-bar-height, 44px); } .kywi-frontend-edit--editing .my-late-sticky { top: var(--kywi-overlay-bar-height, 46px); }A second tier keeps its own offset on top of the bar:
.my-late-subnav { top: 64px; } .kywi-frontend-edit--browsing .my-late-subnav { top: calc(var(--kywi-browse-bar-height, 44px) + 64px); } .kywi-frontend-edit--editing .my-late-subnav { top: calc(var(--kywi-overlay-bar-height, 46px) + 64px); }A whole sticky STACK moves, not just its first tier. Anything with a computed
topwithin 4px of zero is offset by one bar height; anything between there and 200px — a sub-nav parked under the header attop: 64px, the docs-site pattern — is offset by the bar plus its own original value, so the gaps a theme built survive the session. Moving only the first tier would make the header cover the sub-nav, an overlap the offset itself would have created.Further down than that, and inside a scroll pane, is left alone. A sticky filter panel at
top: 320pxis anchored to page content rather than to the top of the viewport, and a sticky table head inside anoverflow: autopane pins to that pane, not to the window — the bar never covers either, so neither moves.
A host must therefore NOT wrap the editor in .kywi-admin-shell: that class is
the admin design system's base + reset and would re-typeset the page being
edited. Pass the page's own body-wrapper class as pageClassName instead, so
the site's page-level CSS still applies.
@kywi-software/core ships the editor component and the
data-kywi-editable/data-kywi-type/data-kywi-content-id markup protocol
(./scope) it uses to find editable fields on the page; a host app is
responsible for mounting the overlay and resolving the viewer's edit
permission on its content routes (see packages/create-kywi-app and
docs/hosting.md in the monorepo for the reference wiring).
Theming: config tokens → --kywi-* CSS variables
Beyond defineTheme({ name, regions }) (page structure) and
./admin/styles.css (the admin's stylesheet), a theme can declare tokens
across five categories — colors, spacing, typography, borderRadius,
shadow (ThemeTokens, packages/core/src/config/types.ts) — that style
the public site a visitor sees. The pipeline
(packages/core/src/layout/theme-css.ts) turns each token into a
--kywi-{category}-{key} custom property (e.g. typography.familySans →
--kywi-font-family-sans), emitted as a :root { ... } block plus a
per-breakpoint @media override for any key@breakpoint token. The layout
renderer (packages/core/src/layout/renderer.tsx) also tags every
structural element with data-kywi-* hooks (data-kywi-region,
data-kywi-section, data-kywi-column, data-kywi-module, ...) so a
front-end developer can target generated markup without relying on internal
class names.
Agent Experience (AX) layer
./ax is a server-only, deterministic set of generators that project
published content for AI agents and crawlers: llms.txt, llms-full.txt,
sitemap.xml, robots.txt, per-page JSON-LD, and single-page markdown
negotiation (packages/core/src/ax/*). Nothing in this module calls a
model provider — missing data falls back deterministically, so output stays
byte-stable across crawls. In the admin, Settings → Agent Experience is a
read-only status dashboard (per-surface enabled/disabled, URLs, publish
counts), not an authoring UI — the surfaces themselves are generated, not
hand-edited.
For anything not covered here, packages/core/src/ is the source of truth —
this README summarizes it, not the other way around.
