Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
68 commits
Select commit Hold shift + click to select a range
7bfaee8
cleanup!: extract hooks and schema types out of lexical index.ts, ren…
AlessioGr May 24, 2026
e254a30
feat: typeStringDefinitions property in configtojsonschema
AlessioGr May 27, 2026
c07ec88
breakage
AlessioGr May 27, 2026
d77a30c
export type
AlessioGr May 27, 2026
8ea07be
make typeStringDefinitions a set
AlessioGr May 28, 2026
bb6c5f2
feat: type-safe lexical schemas
AlessioGr May 28, 2026
f119fbe
types
AlessioGr May 28, 2026
7fdc834
Merge remote-tracking branch 'origin/main' into feat/accurate-lexical…
AlessioGr May 28, 2026
ed64fbb
feat!: remove now-unused modifyJSONSchemas lexical feature property
AlessioGr May 28, 2026
4460089
feat!: rename typescriptSchema => jsonSchema
AlessioGr May 28, 2026
370039c
docs: capitalisation
AlessioGr May 28, 2026
cd440b8
remove re-exports, improve variable names
AlessioGr May 28, 2026
282e697
fix invalid tag schema generation
AlessioGr May 28, 2026
a1a8230
remove re-exports
AlessioGr May 28, 2026
287a80c
feat!: always generate block interface
AlessioGr May 28, 2026
d65e29b
fix: consistent schemas and internal types
AlessioGr May 28, 2026
59b07cf
v4 migration docs
AlessioGr May 28, 2026
30dfd9d
type tests
AlessioGr May 28, 2026
58bd5ef
Merge remote-tracking branch 'origin/main' into feat/accurate-lexical…
AlessioGr May 29, 2026
5c06c97
Merge remote-tracking branch 'origin/main' into feat/accurate-lexical…
AlessioGr May 29, 2026
f5120c1
fix imports
AlessioGr May 29, 2026
b20d937
Merge remote-tracking branch 'origin/main' into feat/accurate-lexical…
AlessioGr Jun 2, 2026
7d7149a
fix build
AlessioGr Jun 2, 2026
4c1f454
matching blockName schema
AlessioGr Jun 2, 2026
3b9df2f
fix build
AlessioGr Jun 2, 2026
b72b277
chore: add more isolated failing test
AlessioGr Jun 2, 2026
73a49f5
Merge remote-tracking branch 'origin/main' into feat/accurate-lexical…
AlessioGr Jun 2, 2026
834f1ac
Merge remote-tracking branch 'origin/main' into feat/accurate-lexical…
AlessioGr Jun 2, 2026
765fd02
chore: generated types
AlessioGr Jun 2, 2026
5106d95
fix import
AlessioGr Jun 2, 2026
5fc1a81
Merge remote-tracking branch 'origin/main' into feat/accurate-lexical…
AlessioGr Jun 3, 2026
20b4696
remove invalid written type tests
AlessioGr Jun 3, 2026
bdc4483
fix: exclude upload collection slugs in SerializedRelationshipNode
AlessioGr Jun 3, 2026
5f0cd13
fix type tests
AlessioGr Jun 3, 2026
8e6fbad
fix collision
AlessioGr Jun 3, 2026
15ea5e8
lint
AlessioGr Jun 3, 2026
58fd9ee
docs: update v4 breaking changes guide
AlessioGr Jun 3, 2026
cb02871
declare $schema
AlessioGr Jun 3, 2026
f57cf35
fix: ensure the json schema we pass to the mcp server is self-contain…
AlessioGr Jun 3, 2026
ff4fd4b
breaking changes doc
AlessioGr Jun 3, 2026
f6e71a2
feat: add description properties to schema to help the llm
AlessioGr Jun 3, 2026
f8b19e2
fix: json schema for tab nodes was to weak
AlessioGr Jun 3, 2026
7b58398
fix: link schema had duplicative required strings
AlessioGr Jun 4, 2026
ee58abc
feat: single relationship field description so that relationTo is def…
AlessioGr Jun 4, 2026
82ca95d
Merge remote-tracking branch 'origin/main' into feat/accurate-lexical…
AlessioGr Jun 4, 2026
e891e3a
fix type tests
AlessioGr Jun 4, 2026
07f946b
Merge remote-tracking branch 'origin/main' into feat/accurate-lexical…
AlessioGr Jun 4, 2026
6119ef4
migrate definitions => $defs merge conflicts
AlessioGr Jun 4, 2026
628218e
fix type tests import, remove unnecessary replaceAll
AlessioGr Jun 4, 2026
7c40bac
test that generated types are assignable to converters
AlessioGr Jun 4, 2026
8d4cf82
feat: make it easier to use buildEditorState with generated types
AlessioGr Jun 4, 2026
34826f7
docs
AlessioGr Jun 4, 2026
0500a45
update incorrect migration docs
AlessioGr Jun 4, 2026
398799a
fix: two identical lexical fields with differing link node fields gen…
AlessioGr Jun 4, 2026
5f595df
fix: two identical interfaceName properties can generate the same con…
AlessioGr Jun 4, 2026
52306b7
types
AlessioGr Jun 4, 2026
8a5a055
fix: simplifyRelationshipFields incorrectly converting oneOf to anyOf
AlessioGr Jun 4, 2026
3862e6b
fix: mismatch: SerializedUploadNode schema had extra fields strongly …
AlessioGr Jun 4, 2026
739c543
Merge remote-tracking branch 'origin/main' into feat/accurate-lexical…
AlessioGr Jun 4, 2026
7bc940c
add $schema property to geberated json schema
AlessioGr Jun 4, 2026
8e6246f
perf: ensure json schema for data does not go through zod, which prod…
AlessioGr Jun 5, 2026
a2ef4a4
perf: minimize json schema size
AlessioGr Jun 5, 2026
c565c5c
Merge remote-tracking branch 'origin/main' into feat/accurate-lexical…
AlessioGr Jun 5, 2026
043c36e
test: regenerate payload types (#16878)
GermanJablo Jun 5, 2026
8934ed1
fix uploadnode types with 2+ upload collections and differing custom …
AlessioGr Jun 5, 2026
5bf4a52
Merge remote-tracking branch 'origin/main' into feat/accurate-lexical…
AlessioGr Jun 5, 2026
ef3f804
regen types
AlessioGr Jun 5, 2026
d96698e
fix unit tests that paul broke
AlessioGr Jun 5, 2026
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
159 changes: 136 additions & 23 deletions docs/migration-guide/v4.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -455,58 +455,171 @@ The deprecated `HTMLConverterFeature`, `lexicalHTML`, and the per-node `converte

Custom nodes that define `converters.html` should export their converters instead.

### Every block now auto-generates a top-level TypeScript interface

Before, a block's fields only became a top-level interface in `payload-types.ts` if you set `interfaceName` on the block. Without it, the fields were inlined wherever the block appeared in your types.
### Field `typescriptSchema` renamed to `jsonSchema`

Now **every block always emits a top-level interface**. The name comes from the slug, converted to PascalCase (`'content-block'` → `ContentBlock`, `'richTextBlock'` → `RichTextBlock`). `interfaceName` still works, but it's now an _override_ for that default name rather than the switch that enables generation.
`typescriptSchema` accepted JSON Schema, not TypeScript, so the name lied. Renamed to `jsonSchema`. Same shape, same array of transforms. The underlying schema is now also consumed by the MCP plugin for runtime validation, not just type generation.

When two **different** blocks resolve to the same auto-derived name (same slug, different fields — e.g. a shared block overridden in one collection), the first keeps the clean name and any later block with a different shape gets a stable content-hash suffix (`Hero_3F2A1B0C`), so the types stay correct and don't silently overwrite one another. Set an explicit `interfaceName` on the block to choose a clean, stable name instead. See [Block interface name collisions](/docs/typescript/generating-types#block-interface-name-collisions).
```diff
{
name: 'tags',
type: 'json',
- typescriptSchema: [() => ({ type: 'array', items: { type: 'string' } })],
+ jsonSchema: [() => ({ type: 'array', items: { type: 'string' } })],
}
```

### RichText adapter `outputSchema` renamed to `jsonSchema`

The optional `outputSchema` function on rich-text editor adapters (`RichTextAdapter`) has been renamed to `jsonSchema` — it returns JSON Schema, not TypeScript, so the old name was misleading. Same arguments, same `JSONSchema4` return value.
Same story on the editor side. The arguments object also gains `typeStringDefinitions: Set<string>` — a sink for raw TS source appended to `payload-types.ts`.

```diff
myAdapter: RichTextAdapter = {
- outputSchema: ({ collectionIDFieldTypes, config, field, i18n, interfaceNameDefinitions, isRequired }) => {
+ jsonSchema: ({ collectionIDFieldTypes, config, field, i18n, interfaceNameDefinitions, isRequired }) => {
+ jsonSchema: ({ collectionIDFieldTypes, config, field, i18n, interfaceNameDefinitions, isRequired, typeStringDefinitions }) => {
// return JSONSchema4
},
}
```

`@payloadcms/richtext-lexical` is updated. Only custom third-party adapters need attention.
`@payloadcms/richtext-lexical` is updated. Only third-party adapters need attention.

### Lexical: feature `generatedTypes.modifyOutputSchema` renamed to `modifyJSONSchema`
### `configToJSONSchema` returns `{ jsonSchema, typeStringDefinitions }`

If you authored a custom lexical feature that hooks into type generation, rename `generatedTypes.modifyOutputSchema` to `modifyJSONSchema` (and its sanitized counterpart `modifyOutputSchemas` to `modifyJSONSchemas`) — it operates on JSON Schema, matching the adapter rename above.
`configToJSONSchema` (from `payload`) used to return a `JSONSchema4` directly. It now returns an object with the schema plus the TS-source sink. Destructure to pull the schema out.

```diff
createServerFeature({
- const schema = configToJSONSchema(sanitizedConfig, 'text')
+ const { jsonSchema: schema, typeStringDefinitions } = configToJSONSchema(sanitizedConfig, 'text')
```

If you run your own type generation, append `[...typeStringDefinitions].join('\n\n')` to the compiled output. Otherwise ignore it.

### `fieldsToJSONSchema` switched to object arguments

`fieldsToJSONSchema` used to take 6 positional arguments. It now takes one object. The new shape also requires `typeStringDefinitions`, and `forceInlineBlocks` is a top-level sibling instead of nested under `opts`.

```diff
- fieldsToJSONSchema(
- collectionIDFieldTypes,
- fields,
- interfaceNameDefinitions,
- config,
- i18n,
- { forceInlineBlocks: true },
- )
+ fieldsToJSONSchema({
+ collectionIDFieldTypes,
+ config,
+ fields,
+ forceInlineBlocks: true,
+ i18n,
+ interfaceNameDefinitions,
+ typeStringDefinitions,
+ })
```

### `entityToJSONSchema` gained a required `typeStringDefinitions` argument

Inserted at position 5 (between `defaultIDType` and `collectionIDFieldTypes`). The old `opts: ConfigToJSONSchemaOptions` is replaced by an optional positional `forceInlineBlocks?: boolean` at the end.

```diff
entityToJSONSchema(
config,
entity,
interfaceNameDefinitions,
defaultIDType,
+ typeStringDefinitions,
collectionIDFieldTypes,
i18n,
- { forceInlineBlocks: true },
+ true,
)
```

### Lexical: `modifyJSONSchema` is gone

`generatedTypes.modifyJSONSchema` on lexical features (and the sanitized `modifyJSONSchemas` array) is removed. Features now contribute per-node JSON Schemas via `createNode({ jsonSchema, node })` instead of mutating a whole-field schema after the fact. The editor composes per-node schemas into the field's union automatically.

If you wrote a custom feature that overrode `modifyJSONSchema`, move per-node generation into `createNode({ jsonSchema: ... })`:

```diff
export const MyFeature = createServerFeature({
feature: () => ({
generatedTypes: {
- modifyOutputSchema: ({ currentSchema, interfaceNameDefinitions }) => currentSchema,
+ modifyJSONSchema: ({ currentSchema, interfaceNameDefinitions }) => currentSchema,
},
- generatedTypes: {
- modifyJSONSchema: ({ currentSchema, interfaceNameDefinitions }) => {
- // ... mutate currentSchema or interfaceNameDefinitions ...
- return currentSchema
- },
- },
nodes: [
createNode({
node: MyNode,
+ jsonSchema: ({ elementNodeSchema, nodeUnionName, typeStringDefinitions }) => {
+ typeStringDefinitions.add(`export interface SerializedMyNode<TChildren> { type: 'my'; /* ... */ }`)
+ return elementNodeSchema({
+ nodeType: 'my',
+ tsType: `SerializedMyNode<${nodeUnionName}>`,
+ })
+ },
}),
],
}),
})
```

### Field `typescriptSchema` renamed to `jsonSchema`
Nodes without a `jsonSchema` function are omitted from the generated node union entirely — they're filtered out, with no fallback shape. Define `jsonSchema` for every custom node you want reflected in the types; otherwise that node won't appear in the field's generated type, and editor state containing it won't be assignable to it.

### Lexical: duplicate node registration throws

`sanitizeServerFeatures` now rejects features that register a node type already present in the editor (matched on `getType()`, or on the replacement target for `LexicalNodeReplacement`). The old behaviour silently let the last write win.

`typescriptSchema` accepted JSON Schema, not TypeScript, so the name lied. Renamed to `jsonSchema`. Same shape, same array of `({ jsonSchema }) => JSONSchema4` transforms.
The built-in lists features coordinate registration through `shouldRegisterListBaseNodes`, so the default editor is unaffected. Custom features that re-registered an existing node to swap in a subclass should use lexical's `LexicalNodeReplacement` shape:

```diff
{
name: 'tags',
type: 'json',
- typescriptSchema: [() => ({ type: 'array', items: { type: 'string' } })],
+ jsonSchema: [() => ({ type: 'array', items: { type: 'string' } })],
createNode({
- node: MyExtendedHeadingNode,
+ node: { replace: HeadingNode, with: (node) => new MyExtendedHeadingNode(node.__tag) },
})
```

### Every block now auto-generates a top-level TypeScript interface

Before, a block's fields only became a top-level interface in `payload-types.ts` if you set `interfaceName` on the block. Without it, the fields were inlined wherever the block appeared in your types.

Now **every block always emits a top-level interface**. The name comes from the slug, converted to PascalCase (`'content-block'` → `ContentBlock`, `'richTextBlock'` → `RichTextBlock`). `interfaceName` still works, but it's now an _override_ for that default name rather than the switch that enables generation.

The same rule applies to lexical block nodes — they now emit as `SerializedBlockNode<…>` / `SerializedInlineBlockNode<…>` referencing the per-block interface, instead of inlining a `{ [k: string]: unknown }` shape.

When two **different** blocks resolve to the same auto-derived name (same slug, different fields — e.g. a shared block overridden in one collection), the first keeps the clean name and any later block with a different shape gets a stable content-hash suffix (`Hero_3F2A1B0C`), so neither is silently mistyped. To choose the name yourself — or to resolve a collision with another type in your generated file (a collection, an array's `interfaceName`, etc.) — set an explicit `interfaceName` on the block. See [Block interface name collisions](/docs/typescript/generating-types#block-interface-name-collisions).

### Lexical: generated `payload-types.ts` shape changed

`richText` fields backed by `lexicalEditor` used to emit a permissive `{ [k: string]: unknown }` per field. They now emit a discriminated union of strict per-node interfaces (`SerializedTextNode`, `SerializedParagraphNode`, `SerializedHeadingNode`, `SerializedBlockNode`, etc.) under a hash-named alias like `LexicalNodes_AB12CD34`.

Block nodes come through as `SerializedBlockNode<TFields>` / `SerializedInlineBlockNode<TFields>` referencing the per-block interface (see the `block.interfaceName` change above). The auto-added `id` is now required (`id: string`), since a stored lexical block always has one.

Code that imported field types from `payload-types.ts` and cast through `unknown` may now type-check tighter and surface real bugs.

The hand-written `TypedEditorState` / `DefaultTypedEditorState` helpers (used to type rich text in converters, custom renderers, and so on) got stricter to match. `TypedEditorState` dropped the `{ [k: string]: unknown }` at the editor-state root, and a node's `children` are now the real node union at any depth — they used to fall back to the loose `SerializedLexicalNode`. Runtime data is unchanged, but narrow by `node.type` where you read nested nodes loosely.

Element nodes are now generic over their `children`, and you pass the node union into them yourself (previously `TypedEditorState` did this for you via an internal `RecursiveNodes` helper):

```diff
import type {
SerializedParagraphNode,
SerializedTextNode,
TypedEditorState,
} from '@payloadcms/richtext-lexical'

- type MyNodes = SerializedParagraphNode | SerializedTextNode
+ type MyNodes = SerializedParagraphNode<MyNodes> | SerializedTextNode

function renderRichText(state: TypedEditorState<MyNodes>) {
// ...
}
```

Rename `typescriptSchema` → `jsonSchema` wherever you set it on a field config.
`SerializedTextNode` is a leaf, so it stays bare. For the built-in nodes plus a few of your own, `DefaultNodeTypesOf` threads the union for you (`type MyNodes = DefaultNodeTypesOf<MyNodes> | SerializedBlockNode<MyBlockData>`), and `DefaultTypedEditorState` with only built-in nodes needs no change.

### Generated JSON Schema uses `$defs` instead of `definitions`

Expand Down
47 changes: 45 additions & 2 deletions docs/rich-text/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,9 @@ You can find more information about creating your own feature in our [building c

Every single piece of saved data is 100% fully typed within lexical. It provides a type for every single node, which can be imported from `@payloadcms/richtext-lexical` - each type is prefixed with `Serialized`, e.g., `SerializedUploadNode`.

To fully type the entire editor JSON, you can use our `TypedEditorState` helper type, which accepts a union of all possible node types as a generic. We don't provide a type that already contains all possible node types because they depend on which features you have enabled in your editor. Here is an example:
If you have [generated types](#automatic-type-generation), each `richText` field is already fully typed - reference it directly (e.g. `Post['richText']`) instead of assembling a union by hand.

To type editor state without generated types, use our `TypedEditorState` helper type, which accepts a union of all possible node types as a generic. We don't provide a type that already contains all possible node types because they depend on which features you have enabled in your editor. Here is an example:

```ts
import type {
Expand Down Expand Up @@ -253,7 +255,48 @@ Make sure to only use types exported from `@payloadcms/richtext-lexical`, not fr

### Automatic type generation

Lexical does not generate accurate type definitions for your richText fields for you yet - this will be improved in the future. Currently, it only outputs the rough shape of the editor JSON, which you can enhance using type assertions.
When you [generate types](../typescript/generating-types), every `richText` field gets an accurate, fully-typed definition based on the exact features enabled on _that_ editor. Each field's nodes are narrowed to what the editor actually allows - including your custom blocks and the specific collections its relationship and upload nodes can point to.

You can reference a field's editor state type straight from your generated types - no manual `TypedEditorState` union, no type assertions:

```ts
import type { Post } from './payload-types'

// Fully typed and narrowed to this editor's nodes - use it wherever you need the field's type
type PostRichText = Post['richText']
```

This data is also directly assignable to the `SerializedEditorState` that every [converter](./converting-html) accepts, so you can pass it straight through:

```ts
import { convertLexicalToHTML } from '@payloadcms/richtext-lexical/html'

const html = convertLexicalToHTML({ data: post.richText })
```

### Building editor state

Use the `buildEditorState` helper to construct editor state JSON from text and/or nodes. Pass your generated field type as the generic and the result is exactly that field's type - with the `nodes` you pass checked against that editor's allowed nodes:

```ts
import { buildEditorState } from '@payloadcms/richtext-lexical'

import type { Post } from './payload-types'

post.richText = buildEditorState<Post['richText']>({ text: 'Hello world' })
```

If you don't have a generated type, you can pass a node union instead (e.g. `buildEditorState<DefaultNodeTypes>({ text: 'Hello world' })`).

To type an individual node you build by hand (for example a single block, or a `.map()` of nodes), use the `RichTextNodes` helper to pull a field's node union out of its generated type:

```ts
import type { RichTextNodes } from '@payloadcms/richtext-lexical'

import type { Post } from './payload-types'

type PostNode = RichTextNodes<Post['richText']>
```

## Admin customization

Expand Down
25 changes: 14 additions & 11 deletions packages/payload/src/admin/RichText.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {
import type { SanitizedGlobalConfig } from '../globals/config/types.js'
import type { RequestContext, TypedFallbackLocale } from '../index.js'
import type { JsonObject, PayloadRequest, PopulateType } from '../types/index.js'
import type { FieldsToJSONSchemaArgs } from '../utilities/configToJSONSchema.js'
import type { RichTextFieldClientProps, RichTextFieldServerProps } from './fields/RichText.js'
import type { FieldDiffClientProps, FieldDiffServerProps, FieldSchemaMap } from './types.js'

Expand Down Expand Up @@ -249,17 +250,19 @@ type RichTextAdapterBase<
* `json-schema-to-typescript` which is used to generate types for this richtext field
* payload-types.ts)
*/
jsonSchema?: (args: {
collectionIDFieldTypes: { [key: string]: 'number' | 'string' }
config?: SanitizedConfig
field: RichTextField<Value, AdapterProps, ExtraFieldProperties>
i18n?: I18n
/**
* Allows you to define new top-level interfaces that can be re-used in the output schema.
*/
interfaceNameDefinitions: Map<string, JSONSchema4>
isRequired: boolean
}) => JSONSchema4
jsonSchema?: (
args: {
field: RichTextField<Value, AdapterProps, ExtraFieldProperties>
isRequired: boolean
} & Pick<
FieldsToJSONSchemaArgs,
| 'collectionIDFieldTypes'
| 'config'
| 'i18n'
| 'interfaceNameDefinitions'
| 'typeStringDefinitions'
>,
) => JSONSchema4
/**
* Provide validation function for the richText field. This function is run the same way
* as other field validation functions.
Expand Down
20 changes: 17 additions & 3 deletions packages/payload/src/bin/generateTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ import { getLogger } from '../utilities/logger.js'

export async function generateTypes(
config: SanitizedConfig,
options?: { log: boolean },
): Promise<void> {
options?: { log?: boolean; returnString?: boolean },
): Promise<string | void> {
const logger = getLogger('payload', 'sync')
const outputFile = process.env.PAYLOAD_TS_OUTPUT_PATH || config.typescript.outputFile

Expand All @@ -29,7 +29,11 @@ export async function generateTypes(

const i18n = await initI18n({ config: config.i18n, context: 'api', language })

const jsonSchema = configToJSONSchema(config, config.db.defaultIDType, i18n)
const { jsonSchema, typeStringDefinitions } = configToJSONSchema(
config,
config.db.defaultIDType,
i18n,
)

const declare = `declare module 'payload' {\n export interface GeneratedTypes extends Config {}\n}`
const declareWithTSIgnoreError = `declare module 'payload' {\n // @ts-ignore \n export interface GeneratedTypes extends Config {}\n}`
Expand All @@ -50,6 +54,11 @@ export async function generateTypes(

compiled = addSelectGenericsToGeneratedTypes({ compiledGeneratedTypes: compiled })

if (typeStringDefinitions.size > 0) {
const block = [...typeStringDefinitions].join('\n\n')
compiled = `${compiled.trimEnd()}\n\n${block}\n`
}

if (config.typescript.postProcess?.length) {
for (const fn of config.typescript.postProcess) {
compiled = fn({ compiledTypes: compiled, config })
Expand All @@ -64,6 +73,11 @@ export async function generateTypes(
}
}

// Return the generated types instead of writing them to disk.
if (options?.returnString) {
return compiled
}

// Diff the compiled types against the existing types file
try {
const existingTypes = await fs.readFile(outputFile, 'utf-8')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,11 @@ type BaseOptions<TSlug extends CollectionSlug, TSelect extends SelectType> = {
user?: Document
} & Pick<FindOptions<TSlug, TSelect>, 'select'>

export type Options<TSlug extends CollectionSlug, TSelect extends SelectType> =
BaseOptions<TSlug, TSelect> & DraftFlagFromCollectionSlug<TSlug>
export type Options<TSlug extends CollectionSlug, TSelect extends SelectType> = BaseOptions<
TSlug,
TSelect
> &
DraftFlagFromCollectionSlug<TSlug>

export async function duplicateLocal<
TSlug extends CollectionSlug,
Expand Down
Loading
Loading