content
import { defineCollection, defineContentConfig, createLocalApi, renderRichText, rule,} from "louise-toolkit/content";The content subpath is the structured content engine: define collections, generate
Drizzle schema from them, and read/write documents through an access-controlled,
validated Local API. Peer dependency: drizzle-orm.
Defining collections
Section titled “Defining collections”import type { EditorSession } from "louise-toolkit/auth";import { defineCollection, defineContentConfig } from "louise-toolkit/content";
export interface Context { session: EditorSession | null;}
export const artworks = defineCollection({ slug: "artworks", fields: { title: { type: "text", required: true, validation: (r) => r.required().min(2) }, slug: { type: "text", required: true, validation: (r) => r.slug().unique() }, year: { type: "number", validation: (r) => r.integer().positive() }, body: { type: "richText" }, }, // The Local API passes its `context` argument to these. No function means allowed. access: { create: ({ session }: Context) => session !== null, update: ({ session }: Context) => session !== null, publish: ({ session }: Context) => session?.role === "owner", }, // Adds draft history, for createVersionedLocalApi. versions: { drafts: true },});
export const content = defineContentConfig({ collections: [artworks] });defineCollection / defineContentConfig are identity helpers (Sanity’s
defineType analogue)—they return the config unchanged but give you
autocomplete and a single greppable call site.
Context is your own type. Louise never reads it: it passes whatever each
Local API call receives straight to the collection’s access
functions.
Codegen—schema from config
Section titled “Codegen—schema from config”import { contentConfigToSchema, generateSchemaSource } from "louise-toolkit/content";contentConfigToSchema(config)builds Drizzle table objects from aContentConfigat runtime.generateSchemaSource(config)emits.tssource for a committed schema file (import-sorted so your formatter never flags it).
A generated table has an id primary key only when the collection declares
one, as id: { type: "number", autoIncrement: true }. The
Local API needs it.
Related builders: collectionToTable, collectionVersionsTable,
relationshipJoinTables, and full-text search helpers
(collectionSearchTableSQL, extractSearchText).
The Local API
Section titled “The Local API”import { gte } from "drizzle-orm";import { createLocalApi, createVersionedLocalApi, toPageId, toVersionId,} from "louise-toolkit/content";import { db } from "louise-toolkit/db";import { artworks, type Context } from "./content.config";import * as schema from "./schema"; // your Drizzle tables: artworks, artworksVersions
const orm = db(env.DB);const context: Context = { session };
// Every method takes the context first, then its own arguments.const api = createLocalApi<typeof schema.artworks, Context>(orm, schema.artworks, artworks);const artwork = await api.create(context, { title: "Untitled", slug: "untitled", year: 2026 });const recent = await api.find(context, { where: gte(schema.artworks.year, 2020), limit: 10 });await api.update(context, artwork.id, { title: "Still life" });
// The same methods, plus drafts, for a collection with `versions: { drafts: true }`.// A draft holds the whole document, and publishing validates all of it.const versioned = createVersionedLocalApi< typeof schema.artworks, typeof schema.artworksVersions, Context>(orm, schema.artworks, schema.artworksVersions, artworks);const snapshot = { title: "Still life, revised", slug: "still-life", year: 2026 };const draft = await versioned.saveDraft(context, toPageId(artwork.id), snapshot);const live = await versioned.publish(context, toVersionId(draft.id));Here env.DB is your D1 binding and session is the signed-in editor’s
session, or null. The signatures:
createLocalApi(db, table, config, registry?, options?);createVersionedLocalApi(db, table, versionsTable, config, registry?, options?);tableis the collection’s Drizzle table, andversionsTableis its companion fromcollectionVersionsTable(config).tableneeds anidcolumn, because the Local API finds, updates, and deletes rows by it. A table fromcollectionToTableorgenerateSchemaSourcehas one only when the collection declaresid: { type: "number", autoIncrement: true }in its fields. A table you write by hand, likeschema.artworksabove, needsid: integer("id").primaryKey({ autoIncrement: true }). Without it,createLocalApiandcreateVersionedLocalApithrowLouiseContentErrorand name what to add.registryis aContentRegistry.findandfindByIDneed one to resolve relationship fields withdepth: 1.options.deferReindexmoves full-text index updates off the write path.- The type parameters are the table types and your context type. Without them,
contextisunknownand accepts anything.
Every method takes the context first and runs the matching access function
(read for find/findByID/count/search, create for create, …)
before touching the database, and validates writes with the collection’s
rules—throwing LouiseAccessDeniedError (→ 403) or
LouiseValidationError (→ 422) so a routing layer can branch by instanceof.
createVersionedLocalApi adds draft/version history for collections that opt in
with versions: saveDraft, scheduleDraft, prepareDraft, publish,
publishScheduled, unpublish, findVersions, diffVersions, and
discardVersion. A draft stores its input as the whole snapshot rather than
merging it into the live row, so pass every required field. The draft methods
check the update access function. publish, publishScheduled, and
unpublish check publish, and findVersions and diffVersions check read.
can(config, operation, context) evaluates access without performing the
operation.
Page and version IDs
Section titled “Page and version IDs”A page ID and a version ID are both integers, so the versioned methods take
branded types that keep them apart: saveDraft, scheduleDraft, unpublish,
and findVersions take a PageId, and publish, discardVersion, and
diffVersions take a VersionId. Passing one where the other belongs, or a
plain number, is a type error. At runtime both are ordinary numbers.
import { parsePageId, toPageId, toVersionId } from "louise-toolkit/content";
const page = toPageId(row.id); // a number you trust; throws unless it's a positive integerconst [latest] = await api.findVersions(context, page);await api.publish(context, toVersionId(latest.id));
const fromUrl = parsePageId(params.id); // untrusted input: undefined unless validif (fromUrl === undefined) return new Response("Bad id", { status: 400 });parsePageId and parseVersionId accept a positive integer or its decimal
string, such as a route parameter or an agent’s tool argument.
Validation
Section titled “Validation”A chainable, immutable, Sanity-style rule builder:
import { rule } from "louise-toolkit/content";
rule().required().min(2).max(80);rule().slug().unique();rule().email().warning("Double-check this address");rule().custom((value, ctx) => value !== "forbidden" || "Not allowed");Pure checks (min/max/length/regex/email/slug/integer/positive/
custom) run anywhere; unique and reference are DB-backed and skipped in a
pure client pass. validateDocument(...) returns all violations;
assertValid(...) throws LouiseValidationError on any "error"-severity one
while returning warnings.
Patches & rich text
Section titled “Patches & rich text”diffDocuments/computePatch/applyPatch—structural document diffs for optimistic updates and version history.renderRichText(content)—render stored rich-text content to HTML (the server never runs ProseMirror; see Rich text).
Webhooks, migrations, visual editing
Section titled “Webhooks, migrations, visual editing”createWebhookHook/deliverWebhookMessage—afterChange-style outbound webhooks. Delivery goes throughfetchPublicUrl: the endpoint must be https on the default port, with a hostname rather than an IP address, and every redirect is checked the same way. Pass a policy as the second argument to add your own site’s host toblockHosts.defineMigration/runMigration—content migrations over collections.buildEditorStructure,getCollectionsMeta—drive the Louise Editor UI.mountVisualEditing/mountPreviewSync/editAttr—live preview and click-to-edit references.