npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@kywi-software/core

v0.24.0

Published

Kywi CMS core — schema generator, REST API, and admin UI for Next.js.

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/core

Peer 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: none at that moment keeps its own top. Anchor those yourself off the class the bar puts on <html> — kywi-frontend-edit--browsing or kywi-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 top within 4px of zero is offset by one bar height; anything between there and 200px — a sub-nav parked under the header at top: 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: 320px is anchored to page content rather than to the top of the viewport, and a sticky table head inside an overflow: auto pane 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.