QUESTPIE
SchemaFields

Rich text field

The TipTap editor field. It stores a structured document in jsonb, or a markdown string in text, and renders a configurable WYSIWYG editor in the admin.

View markdown
CallColumnValueValidation
f.richText()jsonbTipTapDocumentrecursive node schema rooted at type: doc
f.richText({ mode: "json" })jsonbTipTapDocumentthe same
f.richText({ mode: "markdown" })textstringz.string()

mode is the only option the factory takes, and it defaults to "json". The mode is recorded on the field metadata as outputMode, which is how the editor knows whether to emit a document or a string.

src/questpie/server/collections/posts.ts
import { collection } from "#questpie/factories";

export const posts = collection("posts").fields(({ f }) => ({
	title: f.text(255).required(),
	body: f.richText().label("Body").required(),
	notes: f.richText({ mode: "markdown" }).label("Internal notes"),
}));

`f.richText` needs the admin module

@questpie/admin contributes this type, so it appears on f only when that module is enabled and you import collection from #questpie/factories. Reading f.richText without it throws Unknown field type: "richText".

Chain

richText declares no methods of its own. The first four rows are base field methods that work on any type. .admin() is not one of them. The admin plugin registers it as a field extension and codegen wraps every factory with it.

MethodEffect here
.required()NOT NULL, and required on insert
.default(value)A TipTapDocument in json mode, a string in markdown. A factory or a raw SQL also works
.localized()One document per locale
.label(text) / .description(text)Label and helper text on the admin form, each an I18nText
.admin(config)Editor options, listed below

Editor options

.admin() config travels to the browser with the collection schema, so the editor reads plain data. These are the keys it acts on.

OptionTypeDefaultEffect
preset"minimal" | "simple" | "standard" | "advanced"nonePicks a feature set
featuresbooleans keyed by featurenoneOverrides single features on top of the preset, or of the defaults
placeholderstringStart writing...Shown while the document is empty
showCharacterCountbooleanfalsePrints the word and character readout, needs the characterCount feature
maxCharactersnumbernoneHard limit in the editor, needs the characterCount feature
enableImagesbooleantrueSet false to hide image insertion, the extension stays on
imageCollectionstringnoneWhich upload collection inline images go to
enableMediaLibrarybooleantrueSet false to drop the media picker from the image popover
content: f.richText().label("Content").admin({
	preset: "simple",
	features: { image: true },
	showCharacterCount: true,
	maxCharacters: 5000,
	imageCollection: "media",
}),

Inline image uploads go through the collection you name. Without imageCollection the editor falls back to the admin's default upload collection, or to the only one your app has. Several collections and no default raises an error naming them.

Field `.admin()` takes `unknown`

The generated .admin() on a field is typed unknown, so a misspelled key compiles and then does nothing. Collection-level .admin() is typed. Read the table above as the contract for this field.

Presets

PresetWhat it enables
no presetEvery feature except toolbar
minimalOnly bubbleMenu, history, bold, italic, underline and link
simpleEverything except codeBlock, align, image, table, tableControls
standardEvery feature
advancedThe same set as standard

The feature keys are toolbar, bubbleMenu, slashCommands, history, heading, bold, italic, underline, strike, code, codeBlock, blockquote, bulletList, orderedList, horizontalRule, align, link, image, table, tableControls, characterCount.

`toolbar` is inert

Twenty of those twenty-one keys reach the editor. Nothing reads toolbar, so no preset renders one. Formatting runs through the bubble menu and slash commands. toolbar is also the only key separating no preset from standard, which leaves those two identical.

Filtering

A json-mode field defines five operators of its own.

OperatorOperandMatches
containsstringAny text node in the document, case-insensitive
isEmptybooleanNull, a doc with no content, or no non-whitespace text
isNotEmptybooleanA document with non-whitespace text
isNullbooleanThe column is NULL
isNotNullbooleanThe column is not NULL
const { docs } = await app.collections.posts.find({
	where: { body: { contains: "changelog", isNotEmpty: true } },
});

contains reads the words an editor typed, not node types or attribute values. For which section types a page holds, reach for f.blocks() and its hasBlockType operator.

In json mode the four flag operators ignore their operand. { isEmpty: false } asserts the same condition as { isEmpty: true }, so negate by using the opposite operator or a NOT group.

A markdown-mode field stores plain text, so it filters with the standard string operators instead: eq, ne, contains, startsWith, endsWith, like, ilike, in, isNull, isNotNull and the rest of that set, matching against the raw markdown. isEmpty and isNotEmpty belong to json mode.

The `where` slot is not narrowed

A text column rejects a bad operator name at compile time. A rich text column does not, because its generated where type is a broad record. An operator this field lacks is dropped from the query rather than rejected, so check names against the table.

Types

The document types ship from @questpie/admin/fields.

interface TipTapDocument {
	type: "doc";
	content?: TipTapNode[];
}

interface TipTapNode {
	type: string;
	attrs?: Record<string, any>;
	content?: TipTapNode[];
	marks?: Array<{ type: string; attrs?: Record<string, any> }>;
	text?: string;
}
import type { TipTapDocument } from "@questpie/admin/fields";

type Post = typeof posts.$infer.select;
//   ^? { ...; body: TipTapDocument; notes: string | null }

That subpath also exports the richText factory itself, richTextFieldType, RichTextFieldState, the augmentable RichTextFieldMeta and RichTextFeature. Ignore that last one, it is a stale union nothing reads and it is not the feature list above. RichTextMode and RichTextOptions never leave the package.

  • Fields, every type and the column it produces
  • Blocks, stacked and reorderable sections rather than one prose document
  • Textarea field, a plain multi-line string
  • Validation, tuning the generated Zod schemas

On this page