Repository: Thinkmill/keystatic
Pull request #202 (2023-03-30 01:12:37)

Image block schema
This lets you add a title field(serialised to the normal markdown title syntax) to the built-in image block and also customise the alt text field to e.g. have a custom description. If a `title` field isn't provided, there won't be a title field in the UI (though a title written in the markdoc file will be valid and will be kept upon saving). If an `alt` field isn't provided, a default one will be used. Currently this doesn't allow arbitrary fields on images, though that will likely exist in the future.

changeset-bot[bot]: ###  🦋  Changeset detected

Latest commit: 01346a45507ad817d23fbe3d5358901502f2d614

**The changes in this PR will be included in the next version bump.**

<details><summary>This PR includes changesets to release 6 packages</summary>

  | Name                           | Type  |
| ------------------------------ | ----- |
| @keystatic/core                | Patch |
| @keystatic/test-next-app       | Patch |
| @keystatic/test-astro          | Patch |
| localization                   | Patch |
| @keystatic/test-next-pages-dir | Patch |
| @keystatic/remix-test-app      | Patch |

</details>

Not sure what this means? [Click here  to learn what changesets are](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add another changeset to this PR](https://github.com/Thinkmill/keystatic/new/image-block-schema?filename=.changeset/stupid-spies-agree.md&value=---%0A%22%40keystatic%2Ftest-next-app%22%3A%20patch%0A%22%40keystatic%2Fcore%22%3A%20patch%0A---%0A%0AImage%20block%20schema%0A)



Patch:
diff --git a/.changeset/popular-actors-deliver.md b/.changeset/popular-actors-deliver.md
new file mode 100644
index 00000000..65146dad
--- /dev/null
+++ b/.changeset/popular-actors-deliver.md
@@ -0,0 +1,5 @@
+---
+'@keystatic/core': patch
+---
+
+Add `images.schema` option to `fields.document` to allow changing the schema for 
diff --git a/keystatic/local-config.tsx b/keystatic/local-config.tsx
index 39b0b9bb..9d7920d6 100644
--- a/keystatic/local-config.tsx
+++ b/keystatic/local-config.tsx
@@ -54,6 +54,9 @@ export default config({
           images: {
             directory: 'public/images/posts',
             publicPath: '/images/posts/',
+            schema: {
+              title: fields.text({ label: 'Title' }),
+            },
           },
           componentBlocks: {
             blockChild: component({
diff --git a/package.json b/package.json
index 63e8e8f1..bf951a7b 100644
--- a/package.json
+++ b/package.json
@@ -42,7 +42,7 @@
     "@changesets/changelog-github": "^0.4.5",
     "@changesets/cli": "^2.23.0",
     "@manypkg/cli": "^0.19.1",
-    "@preconstruct/cli": "^2.4.1",
+    "@preconstruct/cli": "^2.4.2",
     "@testing-library/dom": "^8.20.0",
     "@testing-library/jest-dom": "^5.16.5",
     "@testing-library/react": "^13.4.0",
diff --git a/packages/keystatic/DocumentEditor/Toolbar.tsx b/packages/keystatic/DocumentEditor/Toolbar.tsx
index 01967191..16b6b887 100644
--- a/packages/keystatic/DocumentEditor/Toolbar.tsx
+++ b/packages/keystatic/DocumentEditor/Toolbar.tsx
@@ -1,4 +1,4 @@
-import { ReactNode, useMemo, useContext } from 'react';
+import { ReactNode, useMemo } from 'react';
 import { Editor, Transforms } from 'slate';
 import { ReactEditor } from 'slate-react';
 
@@ -27,17 +27,14 @@ import { Tooltip, TooltipTrigger } from '@voussoir/tooltip';
 import { TextAlignMenu } from './alignment';
 import { blockquoteButton } from './blockquote';
 import { codeButton } from './code-block';
-import {
-  ComponentBlockContext,
-  insertComponentBlock,
-} from './component-blocks';
+import { insertComponentBlock } from './component-blocks';
 import { dividerButton } from './divider';
 import { DocumentFeatures } from './document-features';
 import { linkButton } from './link';
 import { LayoutsButton } from './layouts';
 import { ListButtons } from './lists';
 import { ToolbarSeparator } from './primitives';
-import { useToolbarState } from './toolbar-state';
+import { useDocumentEditorConfig, useToolbarState } from './toolbar-state';
 import { clearFormatting, useStaticEditor } from './utils';
 import { Picker } from '@voussoir/picker';
 import { imageButton } from './image';
@@ -50,7 +47,7 @@ export function Toolbar({
   documentFeatures: DocumentFeatures;
   viewState?: { expanded: boolean; toggle: () => void };
 }) {
-  const blockComponent = useContext(ComponentBlockContext);
+  const blockComponent = useDocumentEditorConfig().componentBlocks;
   const hasBlockItems = Object.keys(blockComponent).length;
   const hasMarks = Object.values(documentFeatures.formatting.inlineMarks).some(
     x => x
@@ -265,7 +262,7 @@ const HeadingMenu = ({
 
 function InsertBlockMenu() {
   const editor = useStaticEditor();
-  const componentBlocks = useContext(ComponentBlockContext)!;
+  const componentBlocks = useDocumentEditorConfig().componentBlocks;
 
   return (
     <MenuTrigger align="end">
diff --git a/packages/keystatic/DocumentEditor/component-blocks/fields/document.tsx b/packages/keystatic/DocumentEditor/component-blocks/fields/document.tsx
index 9dfffc48..e9c3911f 100644
--- a/packages/keystatic/DocumentEditor/component-blocks/fields/document.tsx
+++ b/packages/keystatic/DocumentEditor/component-blocks/fields/document.tsx
@@ -15,11 +15,14 @@ import {
 import { ElementFromValidation } from '../../../structure-validation';
 import { collectDirectoriesUsedInSchema } from '../../../app/tree-key';
 import {
+  BasicFormField,
   ComponentBlock,
   DocumentElement,
   fields,
   FormFieldWithFileRequiringContentsForReader,
+  SlugFormField,
 } from '../api';
+import { text } from './text';
 
 const textEncoder = new TextEncoder();
 const textDecoder = new TextDecoder();
@@ -59,16 +62,48 @@ type FormattingConfig = {
   softBreaks?: true;
 };
 
+export type BasicStringFormField =
+  | BasicFormField<string, unknown>
+  | SlugFormField<string, undefined, undefined>;
+
 type DocumentFeaturesConfig = {
   formatting?: true | FormattingConfig;
   links?: true;
   dividers?: true;
-  images?: true | { directory?: string; publicPath?: string };
+  images?:
+    | true
+    | {
+        directory?: string;
+        publicPath?: string;
+        schema?: {
+          alt?: BasicStringFormField;
+          title?: BasicStringFormField;
+        };
+      };
   layouts?: readonly (readonly [number, ...number[]])[];
   tables?: true;
 };
 
-function normaliseDocumentFeatures(config: DocumentFeaturesConfig) {
+const defaultAltField: SlugFormField<string, undefined, undefined> = text({
+  label: 'Alt text',
+  description: 'This text will be used by screen readers and search engines.',
+});
+
+const emptyTitleField: BasicFormField<string, undefined> = {
+  kind: 'form',
+  Input() {
+    return null;
+  },
+  defaultValue: '',
+  options: undefined,
+  validate(value) {
+    return typeof value === 'string';
+  },
+};
+
+export function normaliseDocumentFeatures(
+  config: DocumentFeaturesConfig
+): DocumentFeatures {
   const formatting: FormattingConfig =
     config.formatting === true
       ? {
@@ -80,7 +115,8 @@ function normaliseDocumentFeatures(config: DocumentFeaturesConfig) {
           softBreaks: true,
         }
       : config.formatting ?? {};
-  const documentFeatures: DocumentFeatures = {
+  const imagesConfig = config.images === true ? {} : config.images;
+  return {
     formatting: {
       alignment:
         formatting.alignment === true
@@ -139,10 +175,18 @@ function normaliseDocumentFeatures(config: DocumentFeaturesConfig) {
       ...new Set((config.layouts || []).map(x => JSON.stringify(x))),
     ].map(x => JSON.parse(x)),
     dividers: !!config.dividers,
-    images: config.images === undefined ? false : config.images,
+    images:
+      imagesConfig === undefined
+        ? false
+        : {
+            ...imagesConfig,
+            schema: {
+              alt: imagesConfig.schema?.alt ?? defaultAltField,
+              title: imagesConfig.schema?.title ?? emptyTitleField,
+            },
+          },
     tables: !!config.tables,
   };
-  return documentFeatures;
 }
 
 export function document({
diff --git a/packages/keystatic/DocumentEditor/component-blocks/form-from-preview.tsx b/packages/keystatic/DocumentEditor/component-blocks/form-from-preview.tsx
index 6aa7c3c0..ddbabd33 100644
--- a/packages/keystatic/DocumentEditor/component-blocks/form-from-preview.tsx
+++ b/packages/keystatic/DocumentEditor/component-blocks/form-from-preview.tsx
@@ -402,7 +402,7 @@ function ObjectFieldPreview({
     ? findFocusableObjectFieldKey(schema)
     : undefined;
   return (
-    <Flex gap="xlarge" direction="column">
+    <Flex gap="large" direction="column">
       {Object.entries(fields).map(
         ([key, propVal]) =>
           isNonChildFieldPreviewProps(propVal) && (
@@ -432,7 +432,7 @@ function ConditionalFieldPreview({
     unknown
   >;
   return (
-    <Flex gap="xlarge" direction="column">
+    <Flex gap="large" direction="column">
       {useMemo(
         () => (
           <AddToPathProvider part="discriminant">
diff --git a/packages/keystatic/DocumentEditor/component-blocks/index.tsx b/packages/keystatic/DocumentEditor/component-blocks/index.tsx
index 65ae8c14..af94e957 100644
--- a/packages/keystatic/DocumentEditor/component-blocks/index.tsx
+++ b/packages/keystatic/DocumentEditor/component-blocks/index.tsx
@@ -1,11 +1,4 @@
-import {
-  createContext,
-  useCallback,
-  useContext,
-  useEffect,
-  useMemo,
-  useRef,
-} from 'react';
+import { useCallback, useEffect, useMemo, useRef } from 'react';
 import { ReactEditor, RenderElementProps } from 'slate-react';
 import { Editor, Transforms } from 'slate';
 
@@ -22,13 +15,10 @@ import { updateComponentBlockElementProps } from './update-element';
 import { ComponentBlockRender } from './component-block-render';
 import { ChromefulComponentBlockElement } from './chromeful-element';
 import { ChromelessComponentBlockElement } from './chromeless-element';
+import { useDocumentEditorConfig } from '../toolbar-state';
 
 export { withComponentBlocks } from './with-component-blocks';
 
-export const ComponentBlockContext = createContext<
-  Record<string, ComponentBlock>
->({});
-
 export function ComponentInlineProp(props: RenderElementProps) {
   return <span {...props.attributes}>{props.children}</span>;
 }
@@ -61,7 +51,7 @@ export const ComponentBlocksElement = ({
     editor,
     __elementToGetPath
   );
-  const blockComponents = useContext(ComponentBlockContext)!;
+  const blockComponents = useDocumentEditorConfig().componentBlocks;
   const componentBlock = blockComponents[currentElement.component] as
     | ComponentBlock
     | undefined;
diff --git a/packages/keystatic/DocumentEditor/component-blocks/utils.ts b/packages/keystatic/DocumentEditor/component-blocks/utils.ts
index 1ae4d4f3..90b11f59 100644
--- a/packages/keystatic/DocumentEditor/component-blocks/utils.ts
+++ b/packages/keystatic/DocumentEditor/component-blocks/utils.ts
@@ -164,7 +164,8 @@ export function getDocumentFeaturesForChildField(
               },
       },
       links: options.links === 'inherit',
-      images: options.images === 'inherit',
+      images:
+        options.images === 'inherit' ? editorDocumentFeatures.images : false,
       tables: options.tables === 'inherit',
     },
     componentBlocks: options.componentBlocks === 'inherit',
diff --git a/packages/keystatic/DocumentEditor/document-features.ts b/packages/keystatic/DocumentEditor/document-features.ts
index fec9fb7d..e8400a0f 100644
--- a/packages/keystatic/DocumentEditor/document-features.ts
+++ b/packages/keystatic/DocumentEditor/document-features.ts
@@ -1,3 +1,5 @@
+import { BasicStringFormField } from './component-blocks/fields/document';
+
 export type DocumentFeatures = {
   formatting: {
     inlineMarks: {
@@ -26,7 +28,16 @@ export type DocumentFeatures = {
     softBreaks: boolean;
   };
   links: boolean;
-  images: boolean | { directory?: string; publicPath?: string };
+  images:
+    | false
+    | {
+        directory?: string;
+        publicPath?: string;
+        schema: {
+          alt: BasicStringFormField;
+          title: BasicStringFormField;
+        };
+      };
   dividers: boolean;
   layouts: [number, ...number[]][];
   tables: boolean;
diff --git a/packages/keystatic/DocumentEditor/image.tsx b/packages/keystatic/DocumentEditor/image.tsx
index 2ac84617..03b77d2d 100644
--- a/packages/keystatic/DocumentEditor/image.tsx
+++ b/packages/keystatic/DocumentEditor/image.tsx
@@ -1,5 +1,5 @@
 import { useLocalizedStringFormatter } from '@react-aria/i18n';
-import { useState } from 'react';
+import { useMemo, useState } from 'react';
 import { ReactEditor, RenderElementProps } from 'slate-react';
 import { Editor, Transforms } from 'slate';
 
@@ -35,6 +35,11 @@ import {
   NotEditable,
   useActiveBlockPopover,
 } from './primitives';
+import { createGetPreviewProps } from './component-blocks/preview-props';
+import { fields } from '../src';
+import { useDocumentEditorConfig } from './toolbar-state';
+import { FormValueContentFromPreviewProps } from './component-blocks/form-from-preview';
+import { clientSideValidateProp } from './component-blocks/utils';
 
 export const ImageElement = ({
   attributes,
@@ -149,10 +154,12 @@ export const ImageElement = ({
         {dialogOpen && (
           <ImageDialog
             alt={currentElement.alt}
+            title={currentElement.title}
             filename={currentElement.src.filename}
-            onSubmit={({ alt, filename }) => {
+            onSubmit={({ alt, filename, title }) => {
               setNode({
                 alt,
+                title,
                 src: {
                   content: currentElement.src.content,
                   filename,
@@ -166,19 +173,28 @@ export const ImageElement = ({
   );
 };
 
-function ImageDialog({
-  onSubmit,
-  ...props
-}: {
-  alt?: string;
+function ImageDialog(props: {
+  alt: string;
+  title: string;
   filename: string;
-  onSubmit: (value: { alt: string; filename: string }) => void;
+  onSubmit: (value: { alt: string; filename: string; title: string }) => void;
 }) {
+  const { images } = useDocumentEditorConfig().documentFeatures;
+  if (!images) {
+    throw new Error('unexpected image rendered when images are disabled');
+  }
+  const schema = useMemo(() => fields.object(images.schema), [images]);
+  const [state, setState] = useState({ alt: props.alt, title: props.title });
+  const previewProps = useMemo(
+    () => createGetPreviewProps(schema, setState, () => undefined),
+    [schema]
+  )(state);
+
   const [filenameWithoutExtension, filenameExtension] = splitFilename(
     props.filename
   );
-  let [altText, setAltText] = useState(props.alt || '');
-  let [fileName, setFileName] = useState(filenameWithoutExtension || '');
+  const [forceValidation, setForceValidation] = useState(false);
+  let [fileName, setFileName] = useState(filenameWithoutExtension);
   let [fileNameTouched, setFileNameTouched] = useState(false);
 
   let { dismiss } = useDialogContainer();
@@ -191,10 +207,12 @@ function ImageDialog({
         onSubmit={event => {
           if (event.target !== event.currentTarget) return;
           event.preventDefault();
-          if (fileName) {
+          setForceValidation(true);
+          if (fileName && clientSideValidateProp(schema, state, undefined)) {
             dismiss();
-            onSubmit({
-              alt: altText,
+            props.onSubmit({
+              alt: state.alt,
+              title: state.title,
               filename: [fileName, filenameExtension].join('.'),
             });
           }
@@ -210,7 +228,7 @@ function ImageDialog({
               value={fileName}
               isRequired
               errorMessage={
-                fileNameTouched && !fileName
+                (fileNameTouched || forceValidation) && !fileName
                   ? 'Please provide a file name.'
                   : undefined
               }
@@ -226,12 +244,10 @@ function ImageDialog({
                 ) : null
               }
             />
-            <TextField
+            <FormValueContentFromPreviewProps
+              forceValidation={forceValidation}
               autoFocus
-              label="Alt text"
-              description="This text will be used by screen readers and search engines."
-              onChange={setAltText}
-              value={altText}
+              {...previewProps}
             />
           </Flex>
         </Content>
diff --git a/packages/keystatic/DocumentEditor/index.tsx b/packages/keystatic/DocumentEditor/index.tsx
index 7b3222ea..d433fc84 100644
--- a/packages/keystatic/DocumentEditor/index.tsx
+++ b/packages/keystatic/DocumentEditor/index.tsx
@@ -37,7 +37,7 @@ import { Toolbar } from './Toolbar';
 import { renderElement } from './render-element';
 import { withHeading } from './heading';
 import { nestList, unnestList, withList } from './lists';
-import { ComponentBlockContext, withComponentBlocks } from './component-blocks';
+import { withComponentBlocks } from './component-blocks';
 import { getPlaceholderTextForPropPath } from './component-blocks/utils';
 import { withBlockquote } from './blockquote';
 import { withDivider } from './divider';
@@ -47,7 +47,7 @@ import { renderLeaf } from './leaf';
 import { withSoftBreaks } from './soft-breaks';
 import { withShortcuts } from './shortcuts';
 import { withDocumentFeaturesNormalization } from './document-features-normalization';
-import { ToolbarStateProvider, useToolbarState } from './toolbar-state';
+import { ToolbarStateProvider, useDocumentEditorConfig } from './toolbar-state';
 import { withInsertMenu } from './insert-menu';
 import { withBlockMarkdownShortcuts } from './block-markdown-shortcuts';
 import { withPasting } from './pasting';
@@ -476,12 +476,11 @@ function getPrismTokenLength(token: Prism.Token | string): number {
 
 export function DocumentEditorEditable(props: EditableProps) {
   const editor = useSlate();
-  const componentBlocks = useContext(ComponentBlockContext);
-  const toolbarState = useToolbarState();
+  const { componentBlocks, documentFeatures } = useDocumentEditorConfig();
 
   const onKeyDown = useMemo(
-    () => getKeyDownHandler(editor, toolbarState.editorDocumentFeatures),
-    [editor, toolbarState.editorDocumentFeatures]
+    () => getKeyDownHandler(editor, documentFeatures),
+    [editor, documentFeatures]
   );
 
   return (
diff --git a/packages/keystatic/DocumentEditor/insert-menu.tsx b/packages/keystatic/DocumentEditor/insert-menu.tsx
index 3634b22a..4089532a 100644
--- a/packages/keystatic/DocumentEditor/insert-menu.tsx
+++ b/packages/keystatic/DocumentEditor/insert-menu.tsx
@@ -1,4 +1,4 @@
-import { Fragment, ReactNode, useContext, useEffect, useRef } from 'react';
+import { Fragment, ReactNode, useEffect, useRef } from 'react';
 import { Transforms, Text, Editor, Path, Point, Node } from 'slate';
 import { ReactEditor } from 'slate-react';
 import { matchSorter } from 'match-sorter';
@@ -11,13 +11,14 @@ import { Item, ListBoxBase, useListBoxLayout } from '@voussoir/listbox';
 import { Popover } from '@voussoir/overlays';
 import { css, tokenSchema } from '@voussoir/style';
 
-import {
-  ComponentBlockContext,
-  insertComponentBlock,
-} from './component-blocks';
+import { insertComponentBlock } from './component-blocks';
 import { ComponentBlock } from './component-blocks/api';
 import { insertLayout } from './layouts';
-import { ToolbarState, useToolbarState } from './toolbar-state';
+import {
+  ToolbarState,
+  useDocumentEditorConfig,
+  useToolbarState,
+} from './toolbar-state';
 import { insertNodesButReplaceIfSelectionIsAtEmptyParagraphOrHeading } from './utils';
 import { getUploadedImage } from './component-blocks/fields/image';
 import { isBlock } from '.';
@@ -157,7 +158,7 @@ export function InsertMenu({
 }) {
   const toolbarState = useToolbarState();
   const { editor } = toolbarState;
-  const componentBlocks = useContext(ComponentBlockContext);
+  const { componentBlocks } = useDocumentEditorConfig();
   const options = matchSorter(
     getOptions(toolbarState, componentBlocks),
     text.text.slice(1),
diff --git a/packages/keystatic/DocumentEditor/layouts.tsx b/packages/keystatic/DocumentEditor/layouts.tsx
index cee99a3c..2de39b79 100644
--- a/packages/keystatic/DocumentEditor/layouts.tsx
+++ b/packages/keystatic/DocumentEditor/layouts.tsx
@@ -1,4 +1,4 @@
-import { createContext, useContext, useMemo } from 'react';
+import { useMemo } from 'react';
 import { Editor, Element, Node, Transforms, Range, Point } from 'slate';
 import { ReactEditor, RenderElementProps } from 'slate-react';
 
@@ -24,11 +24,7 @@ import {
   moveChildren,
   useStaticEditor,
 } from './utils';
-import { useToolbarState } from './toolbar-state';
-
-const LayoutOptionsContext = createContext<[number, ...number[]][]>([]);
-
-export const LayoutOptionsProvider = LayoutOptionsContext.Provider;
+import { useDocumentEditorConfig, useToolbarState } from './toolbar-state';
 
 // UI Components
 export const LayoutContainer = ({
@@ -39,7 +35,7 @@ export const LayoutContainer = ({
   const editor = useStaticEditor();
 
   const layout = element.layout;
-  const layoutOptions = useContext(LayoutOptionsContext);
+  const layoutOptions = useDocumentEditorConfig().documentFeatures.layouts;
   const currentLayoutIndex = layoutOptions.findIndex(
     x => x.toString() === layout.toString()
   );
diff --git a/packages/keystatic/DocumentEditor/toolbar-state.tsx b/packages/keystatic/DocumentEditor/toolbar-state.tsx
index 20dd321d..0b0f723f 100644
--- a/packages/keystatic/DocumentEditor/toolbar-state.tsx
+++ b/packages/keystatic/DocumentEditor/toolbar-state.tsx
@@ -1,15 +1,13 @@
-import React, { ReactNode, useContext } from 'react';
+import React, { createContext, ReactNode, useContext, useMemo } from 'react';
 import { Editor, Range, Text } from 'slate';
 import { useSlate } from 'slate-react';
 import { DocumentFeatures } from './document-features';
-import { ComponentBlockContext } from './component-blocks';
 import { ComponentBlock } from './component-blocks/api';
 import {
   DocumentFeaturesForChildField,
   getSchemaAtPropPath,
   getDocumentFeaturesForChildField,
 } from './component-blocks/utils';
-import { LayoutOptionsProvider } from './layouts';
 import { isListNode } from './lists';
 
 import { allMarks, isElementActive, Mark, nodeTypeMatcher } from './utils';
@@ -115,7 +113,7 @@ export const createToolbarState = (
         },
         layouts: editorDocumentFeatures.layouts,
         links: true,
-        images: true,
+        images: editorDocumentFeatures.images,
         tables: true,
       },
       softBreaks: true,
@@ -294,6 +292,21 @@ export function getListTypeAbove(
   return listAbove[0].type;
 }
 
+export const DocumentEditorConfigContext = createContext<null | {
+  documentFeatures: DocumentFeatures;
+  componentBlocks: Record<string, ComponentBlock>;
+}>(null);
+
+export function useDocumentEditorConfig() {
+  const context = useContext(DocumentEditorConfigContext);
+  if (!context) {
+    throw new Error(
+      'useDocumentEditorConfig must be used within a DocumentEditorConfigContext.Provider'
+    );
+  }
+  return context;
+}
+
 export const ToolbarStateProvider = ({
   children,
   componentBlocks,
@@ -306,18 +319,21 @@ export const ToolbarStateProvider = ({
   const editor = useSlate();
 
   return (
-    <LayoutOptionsProvider value={editorDocumentFeatures.layouts}>
-      <ComponentBlockContext.Provider value={componentBlocks}>
-        <ToolbarStateContext.Provider
-          value={createToolbarState(
-            editor,
-            componentBlocks,
-            editorDocumentFeatures
-          )}
-        >
-          {children}
-        </ToolbarStateContext.Provider>
-      </ComponentBlockContext.Provider>
-    </LayoutOptionsProvider>
+    <DocumentEditorConfigContext.Provider
+      value={useMemo(
+        () => ({ componentBlocks, documentFeatures: editorDocumentFeatures }),
+        [componentBlocks, editorDocumentFeatures]
+      )}
+    >
+      <ToolbarStateContext.Provider
+        value={createToolbarState(
+          editor,
+          componentBlocks,
+          editorDocumentFeatures
+        )}
+      >
+        {children}
+      </ToolbarStateContext.Provider>
+    </DocumentEditorConfigContext.Provider>
   );
 };
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b7bd7d11..b679dfb0 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -12,7 +12,7 @@ importers:
       '@changesets/changelog-github': ^0.4.5
       '@changesets/cli': ^2.23.0
       '@manypkg/cli': ^0.19.1
-      '@preconstruct/cli': ^2.4.1
+      '@preconstruct/cli': ^2.4.2
       '@testing-library/dom': ^8.20.0
       '@testing-library/jest-dom': ^5.16.5
       '@testing-library/react': ^13.4.0
@@ -48,7 +48,7 @@ importers:
       '@changesets/changelog-github': 0.4.8
       '@changesets/cli': 2.26.0
       '@manypkg/cli': 0.19.2
-      '@preconstruct/cli': 2.4.1
+      '@preconstruct/cli': 2.4.2
       '@testing-library/dom': 8.20.0
       '@testing-library/jest-dom': 5.16.5
       '@testing-library/react': 13.4.0_biqbaboplfbrettd7655fr4n2y
@@ -5591,8 +5591,8 @@ packages:
       webpack: 5.75.0_esbuild@0.14.54
     dev: true
 
-  /@preconstruct/cli/2.4.1:
-    resolution: {integrity: sha512-5/UCz6JwFjDpJsZrONsmsD++xIZ0pLYG0heh8DVXs9/F2vxmmZaKwBrswH78+Js5ZHkjDiWNH6vQlWVMxRhYow==}
+  /@preconstruct/cli/2.4.2:
+    resolution: {integrity: sha512-fhdzbDF58Gu+t+CqQedx2J21jhVL7xE9f+RLuOLtS1lJhD8jyDJ+O7ny+6nVSB87NK5FaBM+dDErCcZcWjbJQA==}
     hasBin: true
     dependencies:
       '@babel/code-frame': 7.18.6


Test patch:
diff --git a/packages/keystatic/DocumentEditor/tests/utils.tsx b/packages/keystatic/DocumentEditor/tests/utils.tsx
index f2c7360e..3077a158 100644
--- a/packages/keystatic/DocumentEditor/tests/utils.tsx
+++ b/packages/keystatic/DocumentEditor/tests/utils.tsx
@@ -12,6 +12,7 @@ import { createToolbarState, ToolbarStateProvider } from '../toolbar-state';
 import { validateDocumentStructure } from '../../structure-validation';
 import { validateAndNormalizeDocument } from '../../validation';
 import { VoussoirProvider } from '@voussoir/core';
+import { normaliseDocumentFeatures } from '../component-blocks/fields/document';
 
 export { __jsx as jsx } from './jsx/namespace';
 
@@ -136,30 +137,15 @@ expect.extend({
     return { actual: received, message, pass };
   },
 });
-export const defaultDocumentFeatures: DocumentFeatures = {
-  formatting: {
-    alignment: { center: true, end: true },
-    blockTypes: { blockquote: true, code: true },
-    headingLevels: [1, 2, 3, 4, 5, 6],
-    inlineMarks: {
-      bold: true,
-      code: true,
-      italic: true,
-      keyboard: true,
-      strikethrough: true,
-      subscript: true,
-      superscript: true,
-      underline: true,
-    },
-    listTypes: { ordered: true, unordered: true },
-    softBreaks: true,
-  },
+
+export const defaultDocumentFeatures = normaliseDocumentFeatures({
   dividers: true,
-  links: true,
-  layouts: [[1], [1, 1], [1, 1, 1], [1, 2, 1]],
+  formatting: true,
   images: true,
+  layouts: [[1], [1, 1], [1, 1, 1], [1, 2, 1]],
+  links: true,
   tables: true,
-};
+});
 
 function EditorComp({
   editor,