From 54a13ce95d4519528b86f86e660470cf4ec4979c Mon Sep 17 00:00:00 2001 From: Emanuel Schiavoni Date: Fri, 3 Apr 2026 05:39:51 -0300 Subject: [PATCH 1/5] Add `beforeSave` and `beforeDelete` properties to Collection and Singleton configs to enable callback hooks. These callback hooks allow devs to perform actions on saving or deleting, even letting you decide whether to proceed with saving or deleting, respectively, through the function call received as an argument. --- packages/keystatic/src/app/ItemPage.tsx | 2 + packages/keystatic/src/app/SingletonPage.tsx | 2 + packages/keystatic/src/app/create-item.tsx | 2 + packages/keystatic/src/app/updating.tsx | 261 +++++++++++++------ packages/keystatic/src/config.tsx | 43 +++ 5 files changed, 228 insertions(+), 82 deletions(-) diff --git a/packages/keystatic/src/app/ItemPage.tsx b/packages/keystatic/src/app/ItemPage.tsx index 7da56b782..316122e60 100644 --- a/packages/keystatic/src/app/ItemPage.tsx +++ b/packages/keystatic/src/app/ItemPage.tsx @@ -154,6 +154,7 @@ function ItemPageInner( initialFiles: props.initialFiles, storage: config.storage, basePath: currentBasePath, + beforeDelete: collectionConfig.beforeDelete, }); const onDelete = useEventCallback(async () => { @@ -432,6 +433,7 @@ function LocalItemPage( format: formatInfo, currentLocalTreeKey: localTreeKey, slug: { field: collectionConfig.slugField, value: slug }, + beforeSave: collectionConfig.beforeSave, }); useEffect(() => { diff --git a/packages/keystatic/src/app/SingletonPage.tsx b/packages/keystatic/src/app/SingletonPage.tsx index e64eef51e..d37f487c3 100644 --- a/packages/keystatic/src/app/SingletonPage.tsx +++ b/packages/keystatic/src/app/SingletonPage.tsx @@ -436,6 +436,7 @@ function LocalSingletonPage( format: formatInfo, currentLocalTreeKey: localTreeKey, slug: undefined, + beforeSave: singletonConfig.beforeSave, }); const update = useEventCallback(_update); @@ -490,6 +491,7 @@ function CollabSingletonPage( format: formatInfo, currentLocalTreeKey: localTreeKey, slug: undefined, + beforeSave: singletonConfig.beforeSave, }); const update = useEventCallback(_update); diff --git a/packages/keystatic/src/app/create-item.tsx b/packages/keystatic/src/app/create-item.tsx index 0c2a9d8a8..3417b347f 100644 --- a/packages/keystatic/src/app/create-item.tsx +++ b/packages/keystatic/src/app/create-item.tsx @@ -289,6 +289,7 @@ function CreateItemLocal(props: { format: formatInfo, currentLocalTreeKey: undefined, slug: { field: collectionConfig.slugField, value: slug }, + beforeSave: collectionConfig.beforeSave, }); const createItem = useEventCallback(_createItem); @@ -382,6 +383,7 @@ function CreateItemCollab(props: { format: formatInfo, currentLocalTreeKey: undefined, slug: { field: collectionConfig.slugField, value: slug }, + beforeSave: collectionConfig.beforeSave, }); const createItem = useEventCallback(_createItem); diff --git a/packages/keystatic/src/app/updating.tsx b/packages/keystatic/src/app/updating.tsx index 4ca1ee7b9..91ee2a82c 100644 --- a/packages/keystatic/src/app/updating.tsx +++ b/packages/keystatic/src/app/updating.tsx @@ -1,6 +1,12 @@ import { gql } from '@ts-gql/tag/no-transform'; import { assert } from 'emery'; -import { useContext, useState } from 'react'; +import { + useCallback, + useContext, + useEffect, + useRef, + useState, +} from 'react'; import { ComponentSchema, fields } from '../form/api'; import { dump } from 'js-yaml'; @@ -29,6 +35,7 @@ import { createUrqlClient } from './provider'; import { serializeProps } from '../form/serialize-props'; import { scopeEntriesWithPathPrefix } from './shell/path-prefix'; import { base64Encode } from '#base64'; +import { BeforeDeleteCallback, BeforeSaveCallback } from '../config'; const textEncoder = new TextEncoder(); @@ -115,6 +122,7 @@ export function useUpsertItem(args: { currentLocalTreeKey: string | undefined; basePath: string; slug: { value: string; field: string } | undefined; + beforeSave?: BeforeSaveCallback; }) { const [state, setState] = useState< | { kind: 'idle' } @@ -126,6 +134,7 @@ export function useUpsertItem(args: { >({ kind: 'idle', }); + const stateRef = useRef(state); const baseCommit = useBaseCommit(); const currentBranch = useCurrentBranch(); const setTreeSha = useSetTreeSha(); @@ -134,8 +143,11 @@ export function useUpsertItem(args: { const appSlug = useContext(AppSlugContext); const unscopedTreeData = useCurrentUnscopedTree(); - return [ - state, + useEffect(() => { + stateRef.current = state; + }, [state]); + + const doSave = useCallback( async (override?: { sha: string; branch: string }): Promise => { try { const unscopedTree = @@ -237,8 +249,6 @@ export function useUpsertItem(args: { return false; } if (gqlError.type === 'STALE_DATA') { - // we don't want this to go into the cache yet - // so we create a new client just for this const refData = await createUrqlClient(args.config) .query(FetchRef, { owner: repoInfo.owner, @@ -332,6 +342,50 @@ export function useUpsertItem(args: { return false; } }, + [ + args.config, + args.schema, + args.format, + args.state, + args.slug, + args.basePath, + args.initialFiles, + args.currentLocalTreeKey, + unscopedTreeData, + repoInfo, + appSlug, + currentBranch, + baseCommit, + mutate, + setTreeSha, + ] + ); + + return [ + state, + async (override?: { sha: string; branch: string }): Promise => { + if (args.beforeSave) { + try { + setState({ kind: 'loading' }); + const hasUpdated = await args.beforeSave({ + item: args.state as Record, + action: args.initialFiles === undefined ? 'create' : 'update', + keystaticSave: async () => doSave(override), + }); + if (hasUpdated) { + stateRef.current.kind === 'loading' && setState({ kind: 'updated' }); + return true; + } else { + stateRef.current.kind === 'loading' && setState({ kind: 'error', error: new Error('Save failed') }); + return false; + } + } catch (error) { + stateRef.current.kind === 'loading' && setState({ kind: 'error', error: error as Error }); + return false; + } + } + return doSave(override); + }, () => { setState({ kind: 'idle' }); }, @@ -362,6 +416,8 @@ export function useDeleteItem(args: { basePath: string; initialFiles: string[]; storage: Config['storage']; + beforeDelete?: BeforeDeleteCallback; + collectionName?: string; }) { const [state, setState] = useState< | { kind: 'idle' } @@ -372,6 +428,7 @@ export function useDeleteItem(args: { >({ kind: 'idle', }); + const stateRef = useRef(state); const baseCommit = useBaseCommit(); const currentBranch = useCurrentBranch(); @@ -380,92 +437,132 @@ export function useDeleteItem(args: { const repoInfo = useRepoInfo(); const appSlug = useContext(AppSlugContext); const unscopedTreeData = useCurrentUnscopedTree(); + + useEffect(() => { + stateRef.current = state; + }, [state]); - return [ - state, - async () => { - try { - const unscopedTree = - unscopedTreeData.kind === 'loaded' - ? unscopedTreeData.data.tree - : undefined; - if (!unscopedTree) return false; + const doDelete = useCallback(async (): Promise => { + try { + const unscopedTree = + unscopedTreeData.kind === 'loaded' + ? unscopedTreeData.data.tree + : undefined; + if (!unscopedTree) return false; + if ( + args.storage.kind === 'github' && + repoInfo && + !repoInfo.hasWritePermission && + appSlug?.value + ) { + setState({ kind: 'needs-fork' }); + return false; + } + setState({ kind: 'loading' }); + const deletions = args.initialFiles.map( + x => (getPathPrefix(args.storage) ?? '') + x + ); + const updatedTree = await updateTreeWithChanges(unscopedTree, { + additions: [], + deletions, + }); + await hydrateTreeCacheWithEntries(updatedTree.entries); + if (args.storage.kind === 'github' || args.storage.kind === 'cloud') { + if (!repoInfo) { + throw new Error('Repo info not loaded'); + } + const { error } = await mutate({ + input: { + branch: { + repositoryNameWithOwner: `${repoInfo.owner}/${repoInfo.name}`, + branchName: currentBranch, + }, + message: { headline: `Delete ${args.basePath}` }, + expectedHeadOid: baseCommit, + fileChanges: { + deletions: deletions.map(path => ({ path })), + }, + }, + }); if ( - args.storage.kind === 'github' && - repoInfo && - !repoInfo.hasWritePermission && - appSlug?.value + error?.graphQLErrors.some( + err => + 'type' in err && + err.type === 'FORBIDDEN' && + err.message === 'Resource not accessible by integration' + ) ) { - setState({ kind: 'needs-fork' }); - return false; + throw new Error( + `The GitHub App is unable to commit to the repository. Please ensure that the Keystatic GitHub App is installed in the GitHub repository ${repoInfo.owner}/${repoInfo.name}` + ); } - setState({ kind: 'loading' }); - const deletions = args.initialFiles.map( - x => (getPathPrefix(args.storage) ?? '') + x - ); - const updatedTree = await updateTreeWithChanges(unscopedTree, { - additions: [], - deletions, + if (error) { + throw error; + } + setState({ kind: 'updated' }); + return true; + } else { + const res = await fetch('/api/keystatic/update', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'no-cors': '1', + }, + body: JSON.stringify({ + additions: [], + deletions: deletions.map(path => ({ path })), + }), }); - await hydrateTreeCacheWithEntries(updatedTree.entries); - if (args.storage.kind === 'github' || args.storage.kind === 'cloud') { - if (!repoInfo) { - throw new Error('Repo info not loaded'); - } - const { error } = await mutate({ - input: { - branch: { - repositoryNameWithOwner: `${repoInfo.owner}/${repoInfo.name}`, - branchName: currentBranch, - }, - message: { headline: `Delete ${args.basePath}` }, - expectedHeadOid: baseCommit, - fileChanges: { - deletions: deletions.map(path => ({ path })), - }, - }, - }); - if ( - error?.graphQLErrors.some( - err => - 'type' in err && - err.type === 'FORBIDDEN' && - err.message === 'Resource not accessible by integration' - ) - ) { - throw new Error( - `The GitHub App is unable to commit to the repository. Please ensure that the Keystatic GitHub App is installed in the GitHub repository ${repoInfo.owner}/${repoInfo.name}` - ); - } - if (error) { - throw error; - } - setState({ kind: 'updated' }); - return true; - } else { - const res = await fetch('/api/keystatic/update', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'no-cors': '1', - }, - body: JSON.stringify({ - additions: [], - deletions: deletions.map(path => ({ path })), - }), + if (!res.ok) { + throw new Error(await res.text()); + } + const newTree: TreeEntry[] = await res.json(); + const { tree } = await hydrateTreeCacheWithEntries(newTree); + setTreeSha(await treeSha(tree)); + setState({ kind: 'updated' }); + return true; + } + } catch (err) { + setState({ kind: 'error', error: err as Error }); + return false; + } + }, [ + args.storage, + args.basePath, + args.initialFiles, + unscopedTreeData, + repoInfo, + appSlug, + currentBranch, + baseCommit, + mutate, + setTreeSha, + ]); + + return [ + state, + async () => { + if (args.beforeDelete) { + try { + setState({ kind: 'loading' }); + const wasDeleted = await args.beforeDelete({ + basePath: args.basePath, + initialFiles: args.initialFiles, + keystaticDelete: async () => doDelete(), }); - if (!res.ok) { - throw new Error(await res.text()); + if (wasDeleted) { + stateRef.current.kind === 'loading' && setState({ kind: 'updated' }); + return true; + } else { + stateRef.current.kind === 'loading' && setState({ kind: 'error', error: new Error('Delete failed') }); + return false; } - const newTree: TreeEntry[] = await res.json(); - const { tree } = await hydrateTreeCacheWithEntries(newTree); - setTreeSha(await treeSha(tree)); - setState({ kind: 'updated' }); - return true; + } catch (error) { + stateRef.current.kind === 'loading' && setState({ kind: 'error', error: error as Error }); + return false; } - } catch (err) { - setState({ kind: 'error', error: err as Error }); } + return doDelete(); }, () => { setState({ kind: 'idle' }); diff --git a/packages/keystatic/src/config.tsx b/packages/keystatic/src/config.tsx index 4c95368f3..b1af51301 100644 --- a/packages/keystatic/src/config.tsx +++ b/packages/keystatic/src/config.tsx @@ -17,6 +17,46 @@ export type Format = }; export type EntryLayout = 'content' | 'form'; export type Glob = '*' | '**'; + +export type UseUpsertItemArgs = { + state: unknown; + initialFiles: string[] | undefined; + schema: Record; + config: Config; + format: FormatInfo; + currentLocalTreeKey: string | undefined; + basePath: string; + slug: { value: string; field: string } | undefined; +}; + +export type FormatInfo = { + data: DataFormat; + contentField?: { + path: string[]; + contentExtension: string; + }; +}; + +export type BeforeSaveCallbackArgs = { + item: Record; + action: 'create' | 'update'; + keystaticSave: () => Promise; +}; + +export type BeforeDeleteCallbackArgs = { + basePath: string; + initialFiles: string[]; + keystaticDelete: () => Promise; +}; + +export type BeforeSaveCallback = ( + args: BeforeSaveCallbackArgs +) => Promise; + +export type BeforeDeleteCallback = ( + args: BeforeDeleteCallbackArgs +) => Promise; + export type Collection< Schema extends Record, SlugField extends string, @@ -31,6 +71,8 @@ export type Collection< parseSlugForSort?: (slug: string) => string | number; slugField: SlugField; schema: Schema; + beforeSave?: BeforeSaveCallback; + beforeDelete?: BeforeDeleteCallback; }; export type Singleton> = { @@ -40,6 +82,7 @@ export type Singleton> = { format?: Format; previewUrl?: string; schema: Schema; + beforeSave?: BeforeSaveCallback; }; type CommonConfig = { From 4e8412c0470d24d44e849a4bb297ffee59837304 Mon Sep 17 00:00:00 2001 From: Emanuel Schiavoni Date: Sat, 4 Apr 2026 18:50:26 -0300 Subject: [PATCH 2/5] Add `beforeSave` and `beforeDelete` hooks docs to collections and singletons doc pages --- docs/src/content/pages/collections.mdoc | 149 ++++++++++++++++++++++++ docs/src/content/pages/singletons.mdoc | 58 +++++++++ 2 files changed, 207 insertions(+) diff --git a/docs/src/content/pages/collections.mdoc b/docs/src/content/pages/collections.mdoc index 24adc2bed..f63b5f4b8 100644 --- a/docs/src/content/pages/collections.mdoc +++ b/docs/src/content/pages/collections.mdoc @@ -107,6 +107,155 @@ testimonials: collection({ `template` — the path to a content file (existing collection entry or "template") to use as a starting point for new entries. +### Hook: before save + +`beforeSave` — allows you to execute a function before an entry is saved by Keystatic. This hook can be useful when you need +to perform complex validations, transformations, or other logic before the data is stored. + +```typescript +beforeSave(args: BeforeSaveCallbackArgs): Promise; +``` + +**Parameters** + +The hook receives a single object of type BeforeSaveCallbackArgs that contains the following information: + +```typescript +type BeforeSaveCallbackArgs = { + item: Record; + action: 'create' | 'update'; + keystaticSave: () => Promise; +}; +``` + +`item`: Contains the information of the entry being saved.\ +`action`: Indicates the type of operation being performed.\ +`keystaticSave`: Function that executes the normal saving of Keystatic. This function handles success and error by +default and returns the result; therefore, it's not necessary to throw an error when `keystaticSave` has been invoked. + +**Return** + +The return value must be of type `Promise`, and will indicate whether the changes were saved or not:\ +`true`: Indicates that the save was successful. It can return true without using `keystaticSave()` to simulate a valid +save without the keystatic actually storing the changes.\ +`false`: Indicates that the save process failed. Ideally, it should only return false if `keystaticSave()` was used and +returned false, since `keystaticSave()` will handle the error internally. In any other case, the correct approach would +be throw an Error instance to display the correct error message to the user, if no error is thrown, the default error message +will be displayed. + +**Example** + +```typescript +testimonials: collection({ + label: 'Testimonials', + schema: { + title: fields.slug({ name: { label: 'Title' } }), + }, + slugField: 'title', + beforeSave: async ({item, keystaticSave, action}: BeforeSaveCallbackArgs) => { + // Send data to external service + const res = await fetch(`https://api.example.com/${action}/testimonial`, { + method: 'POST', + body: JSON.stringify(item), + }); + + if (res.status !== 200) + throw new Error('Save failed: Failed to send data to external service'); + + const saveResult = await keystaticSave(); + + if (!saveResult) { + const rollback_action = action === 'create' ? 'delete' : 'update'; + await fetch(`https://api.example.com/${rollback_action}/testimonial`, { + method: 'POST', + body: JSON.stringify({ + ...item, + action: 'rollback' + }), + }); + // keystaticSave() function already handled the error, + // so is not needed to throw an error here + } + + return saveResult; + }, +}), +``` + +### Hook: before delete + +`beforeDelete` — allows you to execute a function before an entry is deleted by Keystatic. This hook can be useful when you need +to perform validations or other logic before the data is deleted. + +```typescript +beforeDelete(args: BeforeDeleteCallbackArgs): Promise; +``` + +**Parameters** + +The hook receives a single object of type BeforeDeleteCallbackArgs that contains the following information: + +```typescript +type BeforeDeleteCallbackArgs = { + basePath: string; + initialFiles: string[]; + keystaticDelete: () => Promise; +}; +``` + +`basePath`: The base path of the collection.\ +`initialFiles`: An array of file paths that will be deleted.\ +`keystaticDelete`: Function that executes the normal deletion of Keystatic. This function handles success and error +by default and returns the result; therefore, it's not necessary to throw an error when `keystaticDelete` has been invoked. + +**Return** + +The return value must be of type `Promise`, and will indicate whether the deletion was successful or not:\ +`true`: Indicates that the deletion was successful. You can return true without using `keystaticDelete()` to simulate a valid +deletion without the keystatic actually deleting the entry.\ +`false`: Indicates that the deletion process failed. Ideally, it should only return false if `keystaticDelete()` was used and +returned false, since `keystaticDelete()` will handle the error internally. In any other case, the correct approach would +be throw an Error instance to display the correct error message to the user, if no error is thrown, the default error message +will be displayed. + +**Example** + +```typescript +testimonials: collection({ + label: 'Testimonials', + schema: { + title: fields.slug({ name: { label: 'Title' } }), + }, + slugField: 'title', + beforeDelete: async ({basePath, initialFiles, keystaticDelete}: BeforeDeleteCallbackArgs) => { + // Send data to external service + const res = await fetch('https://api.example.com/delete/testimonial', { + method: 'POST', + body: JSON.stringify({basePath}), + }); + + if (res.status !== 200) + throw new Error('Delete failed: Failed to delete data from external service'); + + const deleteResult = await keystaticDelete(); + + if (!deleteResult) { + await fetch('https://api.example.com/delete/testimonial', { + method: 'POST', + body: JSON.stringify({ + basePath, + action: 'rollback' + }), + }); + // keystaticDelete() function already handled the error, + // so is not needed to throw an error here + } + + return deleteResult; + }, +}), +``` + --- ## Type signature diff --git a/docs/src/content/pages/singletons.mdoc b/docs/src/content/pages/singletons.mdoc index 692073c89..8e978064c 100644 --- a/docs/src/content/pages/singletons.mdoc +++ b/docs/src/content/pages/singletons.mdoc @@ -64,6 +64,64 @@ Learn more about the `path` option on the [Content Organisation](/docs/content-o `schema` — defines the fields that the singleton should have. +### Hook: before save + +`beforeSave` — allows you to execute a function before a singleton is saved by Keystatic. This hook can be useful when you need +to perform complex validations, transformations, or other logic before the data is stored. + +```typescript +beforeSave(args: BeforeSaveCallbackArgs): Promise; +``` + +**Parameters** + +The hook receives a single object of type BeforeSaveCallbackArgs that contains the following information: + +```typescript +type BeforeSaveCallbackArgs = { + item: Record; + action: 'create' | 'update'; + keystaticSave: () => Promise; +}; +``` + +`item`: Contains the information of the entry being saved.\ +`action`: Indicates the type of operation being performed.\ +`keystaticSave`: Function that executes the normal saving of Keystatic. This function handles success and error by +default and returns the result; therefore, it's not necessary to throw an error when `keystaticSave` has been invoked. + +**Return** + +The return value must be of type `Promise`, and will indicate whether the changes were saved or not:\ +`true`: Indicates that the save was successful. It can return true without using `keystaticSave()` to simulate a valid +save without the keystatic actually storing the changes.\ +`false`: Indicates that the save process failed. Ideally, it should only return false if `keystaticSave()` was used and +returned false, since `keystaticSave()` will handle the error internally. In any other case, the correct approach would +be throw an Error instance to display the correct error message to the user, if no error is thrown, the default error message +will be displayed. + +**Example** + +```typescript +settings: singleton({ + label: 'Settings', + schema: { + weatherApiKey: fields.text({ label: 'Weather API Key' }), + }, + beforeSave: async ({item, keystaticSave, action}: BeforeSaveCallbackArgs) => { + // Check if the API key is valid + const res = await fetch(`https://api.openweathermap.org/data/2.5/weather?q=London&appid=${item.weatherApiKey}`); + + if (res.status === 401) + throw new Error('Save failed: Invalid API key'); + else if (res.status !== 200) + throw new Error('Save failed: Failed to validate API key'); + + return keystaticSave(); + }, +}), +``` + --- ## Type signature From 5e868bbdcd6d02c10933c843e54c2c0fc31a205e Mon Sep 17 00:00:00 2001 From: Emanuel Schiavoni Date: Fri, 3 Apr 2026 06:21:48 -0300 Subject: [PATCH 3/5] Add `reader` property to Collection and Singleton configs to allow custom readers. --- packages/keystatic/src/app/CollectionPage.tsx | 118 +++++++++++++++++- packages/keystatic/src/app/ItemPage.tsx | 57 +++++++-- packages/keystatic/src/app/SingletonPage.tsx | 43 +++++-- packages/keystatic/src/config.tsx | 3 + packages/keystatic/src/reader/generic.ts | 8 ++ 5 files changed, 206 insertions(+), 23 deletions(-) diff --git a/packages/keystatic/src/app/CollectionPage.tsx b/packages/keystatic/src/app/CollectionPage.tsx index 2d086dd03..3f30e8f0a 100644 --- a/packages/keystatic/src/app/CollectionPage.tsx +++ b/packages/keystatic/src/app/CollectionPage.tsx @@ -63,7 +63,7 @@ import { notFound } from './not-found'; import { fetchBlob } from './useItemData'; import { loadDataFile } from './required-files'; import { parseProps } from '../form/parse-props'; -import { useData } from './useData'; +import { DataState, useData } from './useData'; type CollectionPageProps = { collection: string; @@ -218,6 +218,8 @@ function CollectionPageHeader(props: { type CollectionPageContentProps = CollectionPageProps & { searchTerm: string }; function CollectionPageContent(props: CollectionPageContentProps) { const trees = useTree(); + const collectionConfig = props.config.collections?.[props.collection]; + const hasCustomReader = collectionConfig?.reader != undefined; const tree = trees.merged.kind === 'loaded' @@ -253,7 +255,7 @@ function CollectionPageContent(props: CollectionPageContentProps) { ); } - if (!tree) { + if (!tree && !hasCustomReader) { return ( ; } +function CustomReaderEmptyState({ + basePath, + collection, + readerEntries, +}: { + basePath: string; + collection: string; + readerEntries: DataState<[]>; +}) { + if (readerEntries.kind === 'loading') { + return ( + + + + ); + } + + if ( + readerEntries.kind === 'loaded' && + readerEntries.data && + readerEntries.data.length === 0 + ) { + return ( + + There aren't any entries yet.{' '} + + Create the first entry + {' '} + to see it here. + + } + /> + ); + } + + return ( + + ); +} + const SLUG = '@@slug'; const STATUS = '@@status'; @@ -299,14 +361,41 @@ function CollectionTable( column: SLUG, direction: 'ascending', }); + + const collection = props.config.collections![props.collection]!; + let hideStatusColumn = - isLocalMode || currentBranch === repoInfo?.defaultBranch; + isLocalMode || + currentBranch === repoInfo?.defaultBranch || + !!collection.reader; const baseCommit = useBaseCommit(); - const collection = props.config.collections![props.collection]!; + const readerEntries = useData( + useCallback(async () => { + if (!collection.reader) + return null; + + let entriesSlug: string[] = []; + + try { + entriesSlug = await collection.reader.list(); + } catch { + return null; + } + + return entriesSlug.map((slug: string) => ({ + name: slug, + status: 'Unchanged' as const, + sha: '', + })); + }, [collection.reader]) + ); const entriesWithStatus = useMemo(() => { + if (readerEntries.kind === 'loaded' && readerEntries.data) { + return readerEntries.data; + } const defaultEntries = new Map( getEntriesInCollectionWithTreeKey( props.config, @@ -329,10 +418,18 @@ function CollectionTable( sha: entry.sha, }; }); - }, [props.collection, props.config, props.trees]); + }, [props.collection, props.config, props.trees, readerEntries]); const mainFiles = useData( useCallback(async () => { + if (collection.reader) { + const entries = await collection.reader.all(); + const parsedEntries = new Map>(); + for (const item of entries) { + parsedEntries.set(item.slug, item.entry as Record); + } + return parsedEntries; + } if (!collection.columns?.length) return undefined; const formatInfo = getCollectionFormat(props.config, props.collection); const entries = await Promise.all( @@ -487,6 +584,17 @@ function CollectionTable( ]; }, [collection, hideStatusColumn]); + if ( + collection.reader && + (readerEntries.kind !== 'loaded' || readerEntries.data?.length === 0) + ) { + return CustomReaderEmptyState({ + readerEntries: readerEntries as DataState<[]>, + basePath: props.basePath, + collection: props.collection, + }); + } + return ( { + let entryData: Record | null = null; + + try { + entryData = await collectionConfig.reader!.read(props.itemSlug); + } catch { + return 'not-found' as const; + } + + if (!entryData) { + return 'not-found' as const; + } + + const initialFiles: string[] = []; + const initialState: Record = {}; + + initialState[collectionConfig.slugField] = { + name: props.itemSlug, + slug: props.itemSlug, + }; + + for (const [key, value] of Object.entries(entryData)) { + if (key !== collectionConfig.slugField) { + initialState[key] = value; + } + } + return { + initialState, + initialFiles, + localTreeKey: '', + }; + }, [collectionConfig, props.itemSlug]) + ) + : useItemData({ + config: props.config, + dirpath: getCollectionItemPath( + props.config, + props.collection, + props.itemSlug + ), + schema: collectionConfig.schema, + format, + slug: slugInfo, + }); const currentBranch = useCurrentBranch(); diff --git a/packages/keystatic/src/app/SingletonPage.tsx b/packages/keystatic/src/app/SingletonPage.tsx index d37f487c3..fc3835cb4 100644 --- a/packages/keystatic/src/app/SingletonPage.tsx +++ b/packages/keystatic/src/app/SingletonPage.tsx @@ -566,13 +566,42 @@ function SingletonPageWrapper(props: { singleton: string; config: Config }) { }, [dirpath, format, props.singleton, singletonConfig.schema]) ); - const itemData = useItemData({ - config: props.config, - dirpath, - schema: singletonConfig.schema, - format, - slug: undefined, - }); + const itemData = singletonConfig.reader + ? useData( + useCallback(async () => { + let data: any; + + try { + data = await singletonConfig.reader!.read(); + } catch { + return 'not-found' as const; + } + + if (!data) { + return 'not-found' as const; + } + + const initialFiles: string[] = []; + const initialState: Record = {}; + + for (const [key, value] of Object.entries(data)) { + initialState[key] = value; + } + + return { + initialState, + initialFiles, + localTreeKey: '', + }; + }, [singletonConfig]) + ) + : useItemData({ + config: props.config, + dirpath, + schema: singletonConfig.schema, + format, + slug: undefined, + }); const currentBranch = useCurrentBranch(); const key = `${currentBranch}/${props.singleton}`; diff --git a/packages/keystatic/src/config.tsx b/packages/keystatic/src/config.tsx index b1af51301..d550e71f8 100644 --- a/packages/keystatic/src/config.tsx +++ b/packages/keystatic/src/config.tsx @@ -4,6 +4,7 @@ import { ReactElement } from 'react'; import { ComponentSchema, FormField, SlugFormField } from './form/api'; import type { Locale } from './app/l10n/locales'; import { RepoConfig } from './app/repo-config'; +import { CollectionReader, SingletonReader } from './reader/generic'; // Common // ---------------------------------------------------------------------------- @@ -73,6 +74,7 @@ export type Collection< schema: Schema; beforeSave?: BeforeSaveCallback; beforeDelete?: BeforeDeleteCallback; + reader?: CollectionReader; }; export type Singleton> = { @@ -83,6 +85,7 @@ export type Singleton> = { previewUrl?: string; schema: Schema; beforeSave?: BeforeSaveCallback; + reader?: SingletonReader; }; type CommonConfig = { diff --git a/packages/keystatic/src/reader/generic.ts b/packages/keystatic/src/reader/generic.ts index 9d6d4ba39..5733d9570 100644 --- a/packages/keystatic/src/reader/generic.ts +++ b/packages/keystatic/src/reader/generic.ts @@ -261,6 +261,10 @@ export function collectionReader( config: Config, fsReader: MinimalFs ): CollectionReader { + if (config.collections![collection].reader) { + return config.collections![collection].reader; + } + const formatInfo = getCollectionFormat(config, collection); const collectionPath = getCollectionPath(config, collection); const collectionConfig = config.collections![collection]; @@ -405,6 +409,10 @@ export function singletonReader( config: Config, fsReader: MinimalFs ): SingletonReader { + if (config.singletons![singleton].reader) { + return config.singletons![singleton].reader; + } + const formatInfo = getSingletonFormat(config, singleton); const singletonPath = getSingletonPath(config, singleton); const schema = fields.object(config.singletons![singleton].schema); From 357e411e75290752a91896ae0223802c745c2ed3 Mon Sep 17 00:00:00 2001 From: Emanuel Schiavoni Date: Sat, 4 Apr 2026 23:08:15 -0300 Subject: [PATCH 4/5] Add `reader` doc to collections and singletons doc pages --- docs/src/content/pages/collections.mdoc | 5 +++++ docs/src/content/pages/singletons.mdoc | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/docs/src/content/pages/collections.mdoc b/docs/src/content/pages/collections.mdoc index f63b5f4b8..6bea82773 100644 --- a/docs/src/content/pages/collections.mdoc +++ b/docs/src/content/pages/collections.mdoc @@ -107,6 +107,11 @@ testimonials: collection({ `template` — the path to a content file (existing collection entry or "template") to use as a starting point for new entries. +### Reader + +`reader` — allows you to provide a custom reader for the collection. +This can be useful when you need to fetch data from an external source or perform custom logic before returning the data. + ### Hook: before save `beforeSave` — allows you to execute a function before an entry is saved by Keystatic. This hook can be useful when you need diff --git a/docs/src/content/pages/singletons.mdoc b/docs/src/content/pages/singletons.mdoc index 8e978064c..2af55e5b4 100644 --- a/docs/src/content/pages/singletons.mdoc +++ b/docs/src/content/pages/singletons.mdoc @@ -64,6 +64,11 @@ Learn more about the `path` option on the [Content Organisation](/docs/content-o `schema` — defines the fields that the singleton should have. +### Reader + +`reader` — allows you to provide a custom reader for the singleton. +This can be useful when you need to fetch data from an external source or perform custom logic before returning the data. + ### Hook: before save `beforeSave` — allows you to execute a function before a singleton is saved by Keystatic. This hook can be useful when you need From ac53445e171209e0f32b83a11f0f30499415d0d9 Mon Sep 17 00:00:00 2001 From: Emanuel Schiavoni Date: Sun, 5 Apr 2026 01:23:44 -0300 Subject: [PATCH 5/5] Add tests for custom reader --- packages/keystatic/test/reader.test.tsx | 134 ++++++++++++++++++++++++ 1 file changed, 134 insertions(+) diff --git a/packages/keystatic/test/reader.test.tsx b/packages/keystatic/test/reader.test.tsx index c5a674ddf..f49a07c55 100644 --- a/packages/keystatic/test/reader.test.tsx +++ b/packages/keystatic/test/reader.test.tsx @@ -307,3 +307,137 @@ test('errors', async () => { text: Must be a string] `); }); + +const customReaderPosts = [ + { + slug: 'custom-post-1', + entry: { + title: 'Custom Post 1', + publishDate: '2024-01-01', + heroImage: 'image1.png', + content: [{ type: 'paragraph', children: [{ text: 'Content 1' }] }], + authors: [ + { + name: 'Author 1', + bio: [{ type: 'paragraph', children: [{ text: 'Bio 1' }] }], + }, + ], + }, + }, + { + slug: 'custom-post-2', + entry: { + title: 'Custom Post 2', + publishDate: '2024-02-01', + heroImage: 'image2.png', + content: [{ type: 'paragraph', children: [{ text: 'Content 2' }] }], + authors: [], + }, + }, +]; + +const customReaderConfig = config({ + storage: { kind: 'local' }, + collections: { + posts: collection({ + label: 'Posts', + slugField: 'title', + path: 'posts/*', + reader: (() => { + const data = customReaderPosts; + return { + read: async (slug: string, ..._: any[]) => { + const entry = data.find((item: any) => item.slug === slug); + return entry?.entry ?? null; + }, + readOrThrow: async (slug: string, ..._: any[]) => { + const entry = data.find((item: any) => item.slug === slug); + if (!entry) { + throw new Error( + `Entry "${slug}" not found in collection "posts"` + ); + } + return entry.entry; + }, + all: async () => data, + list: async () => data.map((item: any) => item.slug), + }; + })() as any, + schema: { + title: fields.slug({ name: { label: 'Title' } }), + publishDate: fields.date({ label: 'Publish Date' }), + heroImage: fields.image({ label: 'Hero Image' }), + content: fields.document({ + label: 'Content', + formatting: true, + dividers: true, + links: true, + }), + authors: fields.array( + fields.object({ + name: fields.text({ label: 'Name' }), + bio: fields.document({ + label: 'Bio', + formatting: true, + dividers: true, + links: true, + }), + }), + { label: 'Authors', itemLabel: props => props.fields.name.value } + ), + }, + }), + }, +}); + +test('custom reader list', async () => { + const reader = createReader( + path.join(pkgDir, 'test-data'), + customReaderConfig + ); + const result = await reader.collections.posts.list(); + expect(result).toEqual(['custom-post-1', 'custom-post-2']); +}); + +test('custom reader read', async () => { + const reader = createReader( + path.join(pkgDir, 'test-data'), + customReaderConfig + ); + const result = await reader.collections.posts.read('custom-post-1'); + expect(result).toEqual({ + title: 'Custom Post 1', + publishDate: '2024-01-01', + heroImage: 'image1.png', + content: [{ type: 'paragraph', children: [{ text: 'Content 1' }] }], + authors: [{ name: 'Author 1', bio: [{ type: 'paragraph', children: [{ text: 'Bio 1' }] }] }], + }); +}); + +test('custom reader readOrThrow throws for non-existent', async () => { + const reader = createReader( + path.join(pkgDir, 'test-data'), + customReaderConfig + ); + await expect( + reader.collections.posts.readOrThrow('non-existent') + ).rejects.toThrow('Entry "non-existent" not found in collection "posts"'); +}); + +test('custom reader all', async () => { + const reader = createReader( + path.join(pkgDir, 'test-data'), + customReaderConfig + ); + const result = await reader.collections.posts.all(); + expect(result).toEqual(customReaderPosts); +}); + +test('custom reader read returns null for non-existent', async () => { + const reader = createReader( + path.join(pkgDir, 'test-data'), + customReaderConfig + ); + const result = await reader.collections.posts.read('non-existent'); + expect(result).toBeNull(); +});