Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions docs/src/content/navigation.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,21 @@ navGroups:
discriminant: page
value: content-components
status: default
- label: Hooks
link:
discriminant: page
value: hooks
status: new
- label: Actions
link:
discriminant: page
value: actions
status: new
- label: Workflows
link:
discriminant: page
value: workflows
status: new
- groupName: Recipes
items:
- label: Use Astro's Image component
Expand Down
142 changes: 142 additions & 0 deletions docs/src/content/pages/actions.mdoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
---
title: Actions
summary: >-
Add custom action buttons to the Keystatic editor toolbar that content editors
can trigger manually.
---

Actions are developer-defined operations that appear as a dropdown menu in the item editor toolbar. They let content editors trigger workflows, run validations, or perform tasks without leaving the Keystatic dashboard.

## Registering actions

Use `registerActions` to attach actions to a collection or singleton:

```typescript
import { registerActions } from '@keystatic/core';

registerActions({ collection: 'posts' }, [
{
label: 'Run Content Audit',
description: 'Check SEO, readability, and publishing readiness',
handler: async (ctx) => {
// Perform the action
const issues = await auditContent(ctx.data);
return { message: `Audit complete: ${issues.length} issues found` };
},
},
]);
```

## Action definition

Each action has the following shape:

```typescript
type Action = {
label: string; // Button/menu label
description?: string; // Shown as secondary text in the dropdown
icon?: ReactElement; // Optional icon (from @keystar/ui/icon)
handler: (ctx) => Promise<ActionResult | void>;
when?: { // Conditional visibility
match?: (ctx: { slug?: string; data: Record<string, unknown> }) => boolean;
};
};
```

## Action context

The handler receives a context object:

```typescript
type ActionContext = {
trigger: 'manual';
collection?: string;
singleton?: string;
slug?: string;
data: Record<string, unknown>; // current field values
storage: { kind: 'local' | 'github' | 'cloud' };
update(data: Partial<Record<string, unknown>>): Promise<void>;
};
```

The `update` function lets your action write back to the current entry:

```typescript
handler: async (ctx) => {
const summary = await generateSummary(ctx.data.content);
await ctx.update({ summary });
return { message: 'Summary generated and saved' };
},
```

## Action results

The handler can return a result that controls the toast notification shown to the editor:

```typescript
// Success toast
return { message: 'Action completed successfully' };

// Error toast
return { error: 'Something went wrong' };

// No toast (silent)
return;
```

## Conditional actions

Use the `when.match` function to show actions only when certain conditions are met:

```typescript
registerActions({ collection: 'posts' }, [
{
label: 'Translate to Spanish',
when: {
match: (ctx) => (ctx.data.language as string) === 'en',
},
handler: async (ctx) => {
// Only shown for English posts
},
},
]);
```

## UI placement

Actions appear as a **dropdown menu** triggered by a zap icon button in the editor toolbar, next to the existing action icons (reset, delete, copy, paste) and the Save button.

- The button only appears when at least one action is registered for the current collection or singleton
- All registered (and visible) actions appear in the dropdown
- While an action is running, the button is disabled to prevent double-execution

## Multiple actions

Register multiple actions in a single call:

```typescript
registerActions({ collection: 'posts' }, [
{
label: 'Preview on Site',
handler: async (ctx) => {
window.open(`/posts/${ctx.slug}`, '_blank');
return { message: 'Preview opened' };
},
},
{
label: 'Export as Markdown',
handler: async (ctx) => {
// Export logic
return { message: 'Exported' };
},
},
{
label: 'Run SEO Check',
description: 'Validate title length, slug format, and meta fields',
handler: async (ctx) => {
// SEO validation logic
return { message: 'SEO check passed' };
},
},
]);
```
144 changes: 144 additions & 0 deletions docs/src/content/pages/hooks.mdoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
---
title: Hooks
summary: >-
React to content lifecycle events with before and after hooks on collections
and singletons.
---

Hooks let you run custom logic when content is created, saved, or deleted. They are registered at runtime using the `registerHooks` function from `@keystatic/core`.

## Hook events

Six lifecycle events are available:

| Event | When it fires | Can cancel? |
|---|---|---|
| `beforeCreate` | Before a new entry is created | Yes |
| `afterCreate` | After a new entry is created | No |
| `beforeSave` | Before an existing entry is saved | Yes |
| `afterSave` | After an existing entry is saved | No |
| `beforeDelete` | Before an entry is deleted | Yes |
| `afterDelete` | After an entry is deleted | No |

## Registering hooks

Use `registerHooks` to attach hooks to a collection or singleton:

```typescript
import { registerHooks } from '@keystatic/core';

registerHooks({ collection: 'posts' }, {
beforeSave: [
async (ctx) => {
console.log(`Saving post: ${ctx.slug}`);
},
],
afterSave: [
async (ctx) => {
console.log(`Post saved: ${ctx.slug}`);
},
],
});
```

For singletons:

```typescript
registerHooks({ singleton: 'settings' }, {
afterSave: [
async (ctx) => {
console.log('Settings updated');
},
],
});
```

## Hook context

Every hook receives a context object with information about the operation:

```typescript
type HookContext = {
event: HookEvent; // which event triggered this hook
trigger: 'event' | 'manual';
collection?: string; // collection name (if applicable)
singleton?: string; // singleton name (if applicable)
slug?: string; // entry slug (collections only)
data: Record<string, unknown>; // current field values
previousData?: Record<string, unknown>; // previous values (for updates)
storage: { kind: 'local' | 'github' | 'cloud' };
};
```

After hooks also receive an `update` function for writing back to the entry:

```typescript
afterSave: [
async (ctx) => {
// Update a field on the entry after save
await ctx.update({ lastModified: new Date().toISOString() });
},
],
```

## Cancelling operations

`before*` hooks can cancel the operation by returning `{ cancel: true }`:

```typescript
beforeSave: [
async (ctx) => {
const title = ctx.data.title as string;
if (title.length < 3) {
return { cancel: true, reason: 'Title must be at least 3 characters' };
}
},
],
```

When a hook cancels, subsequent hooks do not run and a toast notification displays the reason to the editor.

## Modifying data

`before*` hooks can also modify the data before it is saved:

```typescript
beforeSave: [
async (ctx) => {
return {
data: {
...ctx.data,
updatedAt: new Date().toISOString(),
},
};
},
],
```

Modified data is passed to subsequent hooks and to the save operation.

## Execution order

1. `before*` hooks run **sequentially** — first cancellation wins
2. The actual operation (create/save/delete) runs
3. `after*` hooks run **in parallel** — errors are logged but don't block

When both global and resource-level hooks exist, global hooks run first.

## Global hooks

Register hooks that run for all collections and singletons using `registerGlobalHooks`:

```typescript
import { registerGlobalHooks } from '@keystatic/core';

registerGlobalHooks({
afterSave: [
async (ctx) => {
console.log(`Content saved: ${ctx.collection || ctx.singleton}`);
},
],
});
```

Global hooks execute before resource-level hooks.
Loading