Repository: Thinkmill/keystatic
Pull request #239 (2023-04-12 03:36:26)

Abitrary attributes on headings and code blocks
This lets you add things like ids on headings or filenames on code blocks

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

Latest commit: c089a8f82e45a9640990dc7a6e1573cc7a1877a3

**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/arbitrary-attributes-on-headings-and-code-blocks?filename=.changeset/early-ads-smell.md&value=---%0A%22%40keystatic%2Ftest-next-app%22%3A%20patch%0A%22%40keystatic%2Fcore%22%3A%20patch%0A%22%40keystatic%2Fnext%22%3A%20patch%0A---%0A%0AAbitrary%20attributes%20on%20headings%20and%20code%20blocks%0A)


vercel[bot]: [vc]: #DzVRt4d6/D+XD7eM9ilcFKg3FYZWn7Lc4F1ck5n1cj0=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJrZXlzdGF0aWMiLCJyb290RGlyZWN0b3J5Ijoia2V5c3RhdGljIiwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL3RoaW5rbWlsbC1sYWJzL2tleXN0YXRpYy9KODlLTWRNRG5rdzJoUWREYTIxdUdNU0dUdFZ6IiwibmV4dENvbW1pdFN0YXR1cyI6IlBFTkRJTkcifSx7Im5hbWUiOiJ2b3Vzc29pciIsInJvb3REaXJlY3RvcnkiOiJkZXNpZ24tc3lzdGVtL2RvY3MiLCJpbnNwZWN0b3JVcmwiOiJodHRwczovL3ZlcmNlbC5jb20vdGhpbmttaWxsLWxhYnMvdm91c3NvaXIvSkQ4U3k2d0Via1hkOFZ1a0trQ1hNSzhycTNrTCIsIm5leHRDb21taXRTdGF0dXMiOiJQRU5ESU5HIn1dfQ==
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Updated (UTC) |
| :--- | :----- | :------ | :------ |
| **keystatic** | 🔄 Building ([Inspect](https://vercel.com/thinkmill-labs/keystatic/J89KMdMDnkw2hQdDa21uGMSGTtVz)) |  | Apr 12, 2023 3:36am |
| **voussoir** | 🔄 Building ([Inspect](https://vercel.com/thinkmill-labs/voussoir/JD8Sy6wEbkXd8VukKkCXMK8rq3kL)) |  | Apr 12, 2023 3:36am |




Patch:
diff --git a/.changeset/pink-ears-sparkle.md b/.changeset/pink-ears-sparkle.md
new file mode 100644
index 00000000..89694250
--- /dev/null
+++ b/.changeset/pink-ears-sparkle.md
@@ -0,0 +1,5 @@
+---
+'@keystatic/core': patch
+---
+
+Add `formatting.blockTypes.code.schema` and `formatting.headingLevels.schema` options to `fields.document` to allow adding arbitrary attributes to code blocks and headings.
diff --git a/keystatic/local-config.tsx b/keystatic/local-config.tsx
index e32a1d70..222be7fa 100644
--- a/keystatic/local-config.tsx
+++ b/keystatic/local-config.tsx
@@ -29,7 +29,26 @@ export default config({
         heroImage: fields.image({ label: 'Hero Image' }),
         content: fields.document({
           label: 'Content',
-          formatting: true,
+          formatting: {
+            alignment: true,
+            blockTypes: {
+              blockquote: true,
+              code: {
+                schema: {
+                  filename: fields.text({ label: 'Filename' }),
+                },
+              },
+            },
+            headingLevels: {
+              levels: true,
+              schema: {
+                id: fields.text({ label: 'ID' }),
+              },
+            },
+            inlineMarks: true,
+            listTypes: true,
+            softBreaks: true,
+          },
           layouts: [
             [1, 1],
             [1, 2],
diff --git a/packages/keystatic/DocumentEditor/Toolbar.tsx b/packages/keystatic/DocumentEditor/Toolbar.tsx
index 32a8eb8e..334f5086 100644
--- a/packages/keystatic/DocumentEditor/Toolbar.tsx
+++ b/packages/keystatic/DocumentEditor/Toolbar.tsx
@@ -63,9 +63,9 @@ export function Toolbar({
   return (
     <ToolbarContainer>
       <ToolbarScrollArea>
-        {!!documentFeatures.formatting.headingLevels.length && (
+        {!!documentFeatures.formatting.headings.levels.length && (
           <HeadingMenu
-            headingLevels={documentFeatures.formatting.headingLevels}
+            headingLevels={documentFeatures.formatting.headings.levels}
           />
         )}
         {hasMarks && (
@@ -204,7 +204,7 @@ type HeadingItem = { name: string; id: string | number };
 const HeadingMenu = ({
   headingLevels,
 }: {
-  headingLevels: DocumentFeatures['formatting']['headingLevels'];
+  headingLevels: DocumentFeatures['formatting']['headings']['levels'];
 }) => {
   const { editor, textStyles } = useToolbarState();
   const isDisabled = textStyles.allowedHeadingLevels.length === 0;
diff --git a/packages/keystatic/DocumentEditor/block-markdown-shortcuts.ts b/packages/keystatic/DocumentEditor/block-markdown-shortcuts.ts
index 974c8247..fd284920 100644
--- a/packages/keystatic/DocumentEditor/block-markdown-shortcuts.ts
+++ b/packages/keystatic/DocumentEditor/block-markdown-shortcuts.ts
@@ -89,7 +89,7 @@ export function withBlockMarkdownShortcuts(
     features => features.formatting.listTypes.unordered
   );
 
-  documentFeatures.formatting.headingLevels.forEach(level => {
+  documentFeatures.formatting.headings.levels.forEach(level => {
     addShortcut(
       '#'.repeat(level) + ' ',
       () => {
@@ -101,7 +101,7 @@ export function withBlockMarkdownShortcuts(
           }
         );
       },
-      features => features.formatting.headingLevels.includes(level),
+      features => features.formatting.headings.levels.includes(level),
       'heading-or-paragraph'
     );
   });
diff --git a/packages/keystatic/DocumentEditor/code-block/code-block-ui.tsx b/packages/keystatic/DocumentEditor/code-block/code-block-ui.tsx
index 55419a4d..aacf594d 100644
--- a/packages/keystatic/DocumentEditor/code-block/code-block-ui.tsx
+++ b/packages/keystatic/DocumentEditor/code-block/code-block-ui.tsx
@@ -14,7 +14,7 @@ import { css, tokenSchema } from '@voussoir/style';
 import { Tooltip, TooltipTrigger } from '@voussoir/tooltip';
 import { Kbd, Text } from '@voussoir/typography';
 
-import { useToolbarState } from '../toolbar-state';
+import { useDocumentEditorConfig, useToolbarState } from '../toolbar-state';
 import {
   BlockPopover,
   BlockPopoverTrigger,
@@ -28,6 +28,10 @@ import {
   labelToCanonicalName,
   languagesWithAliases,
 } from './languages';
+import {
+  CustomAttributesDialog,
+  CustomAttributesEditButton,
+} from '../custom-attributes';
 
 function CodeButton() {
   const {
@@ -84,147 +88,175 @@ export function CodeElement({
       ? aliasesToLabel.get(element.language) ?? element.language
       : 'Plain text'
   );
+
+  const [dialogOpen, setDialogOpen] = useState(false);
+  const { documentFeatures } = useDocumentEditorConfig();
+  const customAttributesSchema =
+    documentFeatures.formatting.blockTypes.code &&
+    Object.keys(documentFeatures.formatting.blockTypes.code.schema.fields)
+      .length
+      ? documentFeatures.formatting.blockTypes.code.schema
+      : undefined;
+
   return (
-    <BlockWrapper>
-      <BlockPopoverTrigger element={element}>
-        <pre
-          spellCheck="false"
-          className={css({
-            backgroundColor: tokenSchema.color.background.surface,
-            borderRadius: tokenSchema.size.radius.medium,
-            color: tokenSchema.color.foreground.neutralEmphasis,
-            fontFamily: tokenSchema.typography.fontFamily.code,
-            fontSize: '0.85em',
-            lineHeight: tokenSchema.typography.lineheight.medium,
-            maxWidth: '100%',
-            overflow: 'auto',
-            padding: tokenSchema.size.space.medium,
+    <>
+      <BlockWrapper>
+        <BlockPopoverTrigger element={element}>
+          <pre
+            spellCheck="false"
+            className={css({
+              backgroundColor: tokenSchema.color.background.surface,
+              borderRadius: tokenSchema.size.radius.medium,
+              color: tokenSchema.color.foreground.neutralEmphasis,
+              fontFamily: tokenSchema.typography.fontFamily.code,
+              fontSize: '0.85em',
+              lineHeight: tokenSchema.typography.lineheight.medium,
+              maxWidth: '100%',
+              overflow: 'auto',
+              padding: tokenSchema.size.space.medium,
 
-            code: {
-              fontFamily: 'inherit',
-            },
-          })}
-          ref={triggerRef}
-        >
-          <code {...attributes}>{children}</code>
-        </pre>
-        <BlockPopover>
-          <Flex gap="regular" padding="regular">
-            <Combobox
-              aria-label="Language"
-              width="scale.2000"
-              allowsCustomValue // allow consumers to support other languages
-              inputValue={inputValue}
-              onInputChange={setInputValue}
-              onBlur={() => {
-                const path = ReactEditor.findPath(editor, element);
-                const canonicalName = aliasesToCanonicalName.get(inputValue);
-                if (canonicalName !== undefined) {
-                  if (canonicalName === 'plain') {
-                    Transforms.unsetNodes(editor, 'language', { at: path });
+              code: {
+                fontFamily: 'inherit',
+              },
+            })}
+            ref={triggerRef}
+          >
+            <code {...attributes}>{children}</code>
+          </pre>
+          <BlockPopover>
+            <Flex gap="regular" padding="regular">
+              <Combobox
+                aria-label="Language"
+                width="scale.2000"
+                allowsCustomValue // allow consumers to support other languages
+                inputValue={inputValue}
+                onInputChange={setInputValue}
+                onBlur={() => {
+                  const path = ReactEditor.findPath(editor, element);
+                  const canonicalName = aliasesToCanonicalName.get(inputValue);
+                  if (canonicalName !== undefined) {
+                    if (canonicalName === 'plain') {
+                      Transforms.unsetNodes(editor, 'language', { at: path });
+                      return;
+                    }
+                    setInputValue(canonicalNameToLabel.get(canonicalName)!);
+                    Transforms.setNodes(
+                      editor,
+                      { language: canonicalName },
+                      { at: path }
+                    );
                     return;
                   }
-                  setInputValue(canonicalNameToLabel.get(canonicalName)!);
-                  Transforms.setNodes(
-                    editor,
-                    { language: canonicalName },
-                    { at: path }
-                  );
-                  return;
-                }
-                const nameFromLabel = labelToCanonicalName.get(inputValue);
-                if (nameFromLabel !== undefined) {
-                  if (nameFromLabel === 'plain') {
-                    Transforms.unsetNodes(editor, 'language', { at: path });
+                  const nameFromLabel = labelToCanonicalName.get(inputValue);
+                  if (nameFromLabel !== undefined) {
+                    if (nameFromLabel === 'plain') {
+                      Transforms.unsetNodes(editor, 'language', { at: path });
+                      return;
+                    }
+                    Transforms.setNodes(
+                      editor,
+                      { language: nameFromLabel },
+                      { at: path }
+                    );
                     return;
                   }
-                  Transforms.setNodes(
-                    editor,
-                    { language: nameFromLabel },
-                    { at: path }
-                  );
-                  return;
-                }
-                if (inputValue === '') {
-                  Transforms.unsetNodes(editor, 'language', { at: path });
-                  setInputValue('Plain text');
-                  return;
-                }
-                if (inputValue !== element.language) {
-                  Transforms.setNodes(
-                    editor,
-                    { language: inputValue },
-                    { at: path }
-                  );
-                }
-              }}
-              onSelectionChange={selection => {
-                const path = ReactEditor.findPath(editor, element);
-                if (aliasesToCanonicalName.has(inputValue)) {
-                  selection = aliasesToCanonicalName.get(inputValue)!;
-                }
-                if (selection === null) {
                   if (inputValue === '') {
                     Transforms.unsetNodes(editor, 'language', { at: path });
-                  } else {
+                    setInputValue('Plain text');
+                    return;
+                  }
+                  if (inputValue !== element.language) {
                     Transforms.setNodes(
                       editor,
                       { language: inputValue },
                       { at: path }
                     );
                   }
-                } else if (typeof selection === 'string') {
-                  if (selection === 'plain') {
-                    Transforms.unsetNodes(editor, 'language', { at: path });
-                    setInputValue('Plain text');
-                    return;
+                }}
+                onSelectionChange={selection => {
+                  const path = ReactEditor.findPath(editor, element);
+                  if (aliasesToCanonicalName.has(inputValue)) {
+                    selection = aliasesToCanonicalName.get(inputValue)!;
                   }
-                  Transforms.setNodes(
-                    editor,
-                    { language: selection as string },
-                    { at: path }
-                  );
-                  const label = canonicalNameToLabel.get(selection);
-                  if (label) {
-                    setInputValue(label);
+                  if (selection === null) {
+                    if (inputValue === '') {
+                      Transforms.unsetNodes(editor, 'language', { at: path });
+                    } else {
+                      Transforms.setNodes(
+                        editor,
+                        { language: inputValue },
+                        { at: path }
+                      );
+                    }
+                  } else if (typeof selection === 'string') {
+                    if (selection === 'plain') {
+                      Transforms.unsetNodes(editor, 'language', { at: path });
+                      setInputValue('Plain text');
+                      return;
+                    }
+                    Transforms.setNodes(
+                      editor,
+                      { language: selection as string },
+                      { at: path }
+                    );
+                    const label = canonicalNameToLabel.get(selection);
+                    if (label) {
+                      setInputValue(label);
+                    }
                   }
-                }
-              }}
-              selectedKey={
-                element.language
-                  ? aliasesToCanonicalName.get(element.language)
-                  : 'plain'
-              }
-              items={useMemo(
-                () =>
-                  inputValue === 'Plain text' ||
-                  labelToCanonicalName.has(inputValue)
-                    ? languagesWithAliases
-                    : matchSorter(languagesWithAliases, inputValue, {
-                        keys: ['label', 'value', 'aliases'],
-                      }),
-                [inputValue]
-              )}
-            >
-              {item => <Item key={item.value}>{item.label}</Item>}
-            </Combobox>
-            <ToolbarSeparator />
-            <TooltipTrigger>
-              <ActionButton
-                prominence="low"
-                onPress={() => {
-                  Transforms.removeNodes(editor, {
-                    at: ReactEditor.findPath(editor, element),
-                  });
                 }}
+                selectedKey={
+                  element.language
+                    ? aliasesToCanonicalName.get(element.language)
+                    : 'plain'
+                }
+                items={useMemo(
+                  () =>
+                    inputValue === 'Plain text' ||
+                    labelToCanonicalName.has(inputValue)
+                      ? languagesWithAliases
+                      : matchSorter(languagesWithAliases, inputValue, {
+                          keys: ['label', 'value', 'aliases'],
+                        }),
+                  [inputValue]
+                )}
               >
-                <Icon src={trash2Icon} />
-              </ActionButton>
-              <Tooltip tone="critical">Remove</Tooltip>
-            </TooltipTrigger>
-          </Flex>
-        </BlockPopover>
-      </BlockPopoverTrigger>
-    </BlockWrapper>
+                {item => <Item key={item.value}>{item.label}</Item>}
+              </Combobox>
+              <ToolbarSeparator />
+              {customAttributesSchema !== undefined && (
+                <CustomAttributesEditButton
+                  onPress={() => setDialogOpen(true)}
+                />
+              )}
+              <TooltipTrigger>
+                <ActionButton
+                  prominence="low"
+                  onPress={() => {
+                    Transforms.removeNodes(editor, {
+                      at: ReactEditor.findPath(editor, element),
+                    });
+                  }}
+                >
+                  <Icon src={trash2Icon} />
+                </ActionButton>
+                <Tooltip tone="critical">Remove</Tooltip>
+              </TooltipTrigger>
+            </Flex>
+          </BlockPopover>
+        </BlockPopoverTrigger>
+      </BlockWrapper>
+      {customAttributesSchema !== undefined && (
+        <CustomAttributesDialog
+          element={element}
+          isOpen={dialogOpen}
+          nodeLabel="Code block"
+          schema={customAttributesSchema}
+          onDismiss={() => {
+            setDialogOpen(false);
+          }}
+        />
+      )}
+    </>
   );
 }
diff --git a/packages/keystatic/DocumentEditor/component-blocks/fields/document/index.tsx b/packages/keystatic/DocumentEditor/component-blocks/fields/document/index.tsx
index a6785a9e..482a13d5 100644
--- a/packages/keystatic/DocumentEditor/component-blocks/fields/document/index.tsx
+++ b/packages/keystatic/DocumentEditor/component-blocks/fields/document/index.tsx
@@ -15,6 +15,7 @@ import { collectDirectoriesUsedInSchema } from '../../../../app/tree-key';
 import {
   BasicFormField,
   ComponentBlock,
+  ComponentSchema,
   DocumentElement,
   FormFieldWithFileRequiringContentsForReader,
   SlugFormField,
@@ -27,6 +28,8 @@ import { object } from '../object';
 const textEncoder = new TextEncoder();
 const textDecoder = new TextDecoder();
 
+type HeadingLevels = true | readonly (1 | 2 | 3 | 4 | 5 | 6)[];
+
 type FormattingConfig = {
   inlineMarks?:
     | true
@@ -52,12 +55,17 @@ type FormattingConfig = {
         center?: true;
         end?: true;
       };
-  headingLevels?: true | readonly (1 | 2 | 3 | 4 | 5 | 6)[];
+  headingLevels?:
+    | HeadingLevels
+    | {
+        levels: HeadingLevels;
+        schema?: Record<string, ComponentSchema>;
+      };
   blockTypes?:
     | true
     | {
         blockquote?: true;
-        code?: true;
+        code?: true | { schema?: Record<string, ComponentSchema> };
       };
   softBreaks?: true;
 };
@@ -129,15 +137,53 @@ export function normaliseDocumentFeatures(
             },
       blockTypes:
         formatting?.blockTypes === true
-          ? { blockquote: true, code: true }
+          ? { blockquote: true, code: { schema: object({}) } }
           : {
               blockquote: !!formatting.blockTypes?.blockquote,
-              code: !!formatting.blockTypes?.code,
+              code: (() => {
+                if (formatting.blockTypes?.code === undefined) {
+                  return false as const;
+                }
+                if (
+                  formatting.blockTypes.code === true ||
+                  !formatting.blockTypes.code.schema
+                ) {
+                  return { schema: object({}) };
+                }
+                for (const key of ['type', 'children', 'language']) {
+                  if (key in formatting.blockTypes.code.schema) {
+                    throw new Error(
+                      `"${key}" cannot be a key in the schema for code blocks`
+                    );
+                  }
+                }
+                return { schema: object(formatting.blockTypes.code.schema) };
+              })(),
             },
-      headingLevels:
-        formatting?.headingLevels === true
-          ? [1, 2, 3, 4, 5, 6]
-          : [...new Set(formatting?.headingLevels)].sort(),
+      headings: (() => {
+        const opt = formatting?.headingLevels;
+        const obj =
+          typeof opt === 'object' && 'levels' in opt
+            ? opt
+            : { levels: opt, schema: undefined };
+        if (obj.schema) {
+          for (const key of ['type', 'children', 'level', 'textAlign']) {
+            if (key in obj.schema) {
+              throw new Error(
+                `"${key}" cannot be a key in the schema for headings`
+              );
+            }
+          }
+        }
+        return {
+          levels: [
+            ...new Set(
+              obj.levels === true ? ([1, 2, 3, 4, 5, 6] as const) : obj.levels
+            ),
+          ],
+          schema: object(obj.schema ?? {}),
+        };
+      })(),
       inlineMarks:
         formatting.inlineMarks === true
           ? {
@@ -294,10 +340,10 @@ export function document({
               Markdoc.format(
                 Markdoc.parse(
                   Markdoc.format(
-                    toMarkdocDocument(
-                      transformed as ElementFromValidation[],
-                      componentBlocks
-                    )
+                    toMarkdocDocument(transformed as ElementFromValidation[], {
+                      componentBlocks,
+                      documentFeatures,
+                    })
                   )
                 )
               )
diff --git a/packages/keystatic/DocumentEditor/component-blocks/utils.ts b/packages/keystatic/DocumentEditor/component-blocks/utils.ts
index 43d99ed8..29c79502 100644
--- a/packages/keystatic/DocumentEditor/component-blocks/utils.ts
+++ b/packages/keystatic/DocumentEditor/component-blocks/utils.ts
@@ -59,6 +59,7 @@ export function getDocumentFeaturesForChildField(
       softBreaks: options.formatting?.softBreaks === 'inherit',
     };
   }
+  const headingLevels = options.formatting?.headingLevels;
   return {
     kind: 'block',
     inlineMarks,
@@ -84,10 +85,17 @@ export function getDocumentFeaturesForChildField(
                 blockquote: false,
                 code: false,
               },
-        headingLevels:
-          options.formatting?.headingLevels === 'inherit'
-            ? editorDocumentFeatures.formatting.headingLevels
-            : options.formatting?.headingLevels || [],
+        headings:
+          headingLevels === 'inherit'
+            ? editorDocumentFeatures.formatting.headings
+            : {
+                levels: headingLevels
+                  ? editorDocumentFeatures.formatting.headings.levels.filter(
+                      level => headingLevels.includes(level)
+                    )
+                  : [],
+                schema: editorDocumentFeatures.formatting.headings.schema,
+              },
         listTypes:
           options.formatting?.listTypes === 'inherit'
             ? editorDocumentFeatures.formatting.listTypes
diff --git a/packages/keystatic/DocumentEditor/custom-attributes.tsx b/packages/keystatic/DocumentEditor/custom-attributes.tsx
new file mode 100644
index 00000000..5a562c17
--- /dev/null
+++ b/packages/keystatic/DocumentEditor/custom-attributes.tsx
@@ -0,0 +1,118 @@
+import { createGetPreviewProps } from './component-blocks/preview-props';
+import { ObjectField } from './component-blocks/api';
+import { Dialog, DialogContainer, useDialogContainer } from '@voussoir/dialog';
+import { useLocalizedStringFormatter } from '@react-aria/i18n';
+import { clientSideValidateProp } from './component-blocks/utils';
+import { Content } from '@voussoir/slots';
+import { FormValueContentFromPreviewProps } from './component-blocks/form-from-preview';
+import { ButtonGroup, Button, ActionButton } from '@voussoir/button';
+import { Heading } from '@voussoir/typography';
+import { useState, useMemo } from 'react';
+import l10nMessages from '../app/l10n/index.json';
+import { focusWithPreviousSelection } from './ui-utils';
+import { ReactEditor, useSlateStatic } from 'slate-react';
+import { Element, Transforms } from 'slate';
+import { Icon } from '@voussoir/icon';
+import { editIcon } from '@voussoir/icon/icons/editIcon';
+import { TooltipTrigger, Tooltip } from '@voussoir/tooltip';
+import { getInitialPropsValueFromInitializer } from './component-blocks/initial-values';
+
+function CustomAttributesDialogInner(props: {
+  element: Element;
+  schema: ObjectField;
+  nodeLabel: string;
+}) {
+  const editor = useSlateStatic();
+  const [state, setState] = useState(() => {
+    return getInitialPropsValueFromInitializer(
+      props.schema,
+      Object.fromEntries(
+        Object.keys(props.schema.fields).map(key => [
+          key,
+          (props.element as any)[key],
+        ])
+      )
+    );
+  });
+  const [forceValidation, setForceValidation] = useState(false);
+
+  const previewProps = useMemo(
+    () => createGetPreviewProps(props.schema, setState, () => undefined),
+    [props.schema]
+  )(state);
+
+  let { dismiss } = useDialogContainer();
+  const stringFormatter = useLocalizedStringFormatter(l10nMessages);
+
+  return (
+    <Dialog size="small">
+      <form
+        style={{ display: 'contents' }}
+        onSubmit={event => {
+          if (event.target !== event.currentTarget) return;
+          event.preventDefault();
+          setForceValidation(true);
+          if (clientSideValidateProp(props.schema, state, undefined)) {
+            dismiss();
+            const path = ReactEditor.findPath(editor, props.element);
+            console.log(state);
+            Transforms.setNodes(editor, state, { at: path });
+          }
+        }}
+      >
+        <Heading>{props.nodeLabel} details</Heading>
+        <Content>
+          <FormValueContentFromPreviewProps
+            forceValidation={forceValidation}
+            autoFocus
+            {...previewProps}
+          />
+        </Content>
+        <ButtonGroup>
+          <Button onPress={dismiss}>{stringFormatter.format('cancel')}</Button>
+          <Button prominence="high" type="submit">
+            {stringFormatter.format('save')}
+          </Button>
+        </ButtonGroup>
+      </form>
+    </Dialog>
+  );
+}
+
+export function CustomAttributesEditButton(props: { onPress: () => void }) {
+  const stringFormatter = useLocalizedStringFormatter(l10nMessages);
+  return (
+    <TooltipTrigger>
+      <ActionButton prominence="low" onPress={props.onPress}>
+        <Icon src={editIcon} />
+      </ActionButton>
+      <Tooltip>{stringFormatter.format('edit')}</Tooltip>
+    </TooltipTrigger>
+  );
+}
+
+export function CustomAttributesDialog(props: {
+  onDismiss: () => void;
+  isOpen: boolean;
+  schema: ObjectField;
+  nodeLabel: string;
+  element: Element;
+}) {
+  const editor = useSlateStatic();
+  return (
+    <DialogContainer
+      onDismiss={() => {
+        props.onDismiss();
+        focusWithPreviousSelection(editor);
+      }}
+    >
+      {props.isOpen && (
+        <CustomAttributesDialogInner
+          element={props.element}
+          nodeLabel={props.nodeLabel}
+          schema={props.schema}
+        />
+      )}
+    </DialogContainer>
+  );
+}
diff --git a/packages/keystatic/DocumentEditor/document-features-normalization.ts b/packages/keystatic/DocumentEditor/document-features-normalization.ts
index c13e256d..715d16c8 100644
--- a/packages/keystatic/DocumentEditor/document-features-normalization.ts
+++ b/packages/keystatic/DocumentEditor/document-features-normalization.ts
@@ -78,8 +78,8 @@ export function normalizeElementBasedOnDocumentFeatures(
 ): boolean {
   if (
     (node.type === 'heading' &&
-      (!formatting.headingLevels.length ||
-        !formatting.headingLevels.includes(node.level))) ||
+      (!formatting.headings.levels.length ||
+        !formatting.headings.levels.includes(node.level))) ||
     (node.type === 'ordered-list' && !formatting.listTypes.ordered) ||
     (node.type === 'unordered-list' && !formatting.listTypes.unordered) ||
     (node.type === 'code' && !formatting.blockTypes.code) ||
diff --git a/packages/keystatic/DocumentEditor/document-features.ts b/packages/keystatic/DocumentEditor/document-features.ts
index e8400a0f..5a154d56 100644
--- a/packages/keystatic/DocumentEditor/document-features.ts
+++ b/packages/keystatic/DocumentEditor/document-features.ts
@@ -1,3 +1,4 @@
+import { ObjectField } from '../src';
 import { BasicStringFormField } from './component-blocks/fields/document';
 
 export type DocumentFeatures = {
@@ -20,10 +21,13 @@ export type DocumentFeatures = {
       center: boolean;
       end: boolean;
     };
-    headingLevels: (1 | 2 | 3 | 4 | 5 | 6)[];
+    headings: {
+      schema: ObjectField;
+      levels: (1 | 2 | 3 | 4 | 5 | 6)[];
+    };
     blockTypes: {
       blockquote: boolean;
-      code: boolean;
+      code: false | { schema: ObjectField };
     };
     softBreaks: boolean;
   };
diff --git a/packages/keystatic/DocumentEditor/heading/heading.tsx b/packages/keystatic/DocumentEditor/heading/heading.tsx
index 65b36585..b6cfe950 100644
--- a/packages/keystatic/DocumentEditor/heading/heading.tsx
+++ b/packages/keystatic/DocumentEditor/heading/heading.tsx
@@ -1,6 +1,20 @@
-import { RenderElementProps } from 'slate-react';
+import { ReactEditor, RenderElementProps, useSlateStatic } from 'slate-react';
 import { tokenSchema } from '@voussoir/style';
 import { blockElementSpacing } from '../ui-utils';
+import { BlockPopover, BlockPopoverTrigger } from '../primitives';
+import { ActionButton } from '@voussoir/button';
+import { Icon } from '@voussoir/icon';
+import { trash2Icon } from '@voussoir/icon/icons/trash2Icon';
+import { Flex } from '@voussoir/layout';
+import { TooltipTrigger, Tooltip } from '@voussoir/tooltip';
+
+import { Transforms } from 'slate';
+import {
+  CustomAttributesDialog,
+  CustomAttributesEditButton,
+} from '../custom-attributes';
+import { useState } from 'react';
+import { useDocumentEditorConfig } from '../toolbar-state';
 
 export const HeadingElement = ({
   attributes,
@@ -8,16 +22,63 @@ export const HeadingElement = ({
   element,
 }: RenderElementProps & { element: { type: 'heading' } }) => {
   const ElementType = `h${element.level}` as const;
+  const editor = useSlateStatic();
+  const { documentFeatures } = useDocumentEditorConfig();
+  const [dialogOpen, setDialogOpen] = useState(false);
+
+  if (Object.keys(documentFeatures.formatting.headings.schema).length === 0) {
+    return (
+      <ElementType
+        {...attributes}
+        className={blockElementSpacing}
+        style={{
+          color: tokenSchema.color.foreground.neutralEmphasis,
+          textAlign: element.textAlign,
+        }}
+      >
+        {children}
+      </ElementType>
+    );
+  }
+
   return (
-    <ElementType
-      {...attributes}
-      className={blockElementSpacing}
-      style={{
-        color: tokenSchema.color.foreground.neutralEmphasis,
-        textAlign: element.textAlign,
-      }}
-    >
-      {children}
-    </ElementType>
+    <>
+      <ElementType
+        className={blockElementSpacing}
+        style={{
+          color: tokenSchema.color.foreground.neutralEmphasis,
+          textAlign: element.textAlign,
+        }}
+      >
+        <BlockPopoverTrigger element={element}>
+          <div>{children}</div>
+          <BlockPopover>
+            <Flex gap="regular" padding="regular">
+              <CustomAttributesEditButton onPress={() => setDialogOpen(true)} />
+              <TooltipTrigger>
+                <ActionButton
+                  prominence="low"
+                  onPress={() => {
+                    Transforms.removeNodes(editor, {
+                      at: ReactEditor.findPath(editor, element),
+                    });
+                  }}
+                >
+                  <Icon src={trash2Icon} />
+                </ActionButton>
+                <Tooltip tone="critical">Remove</Tooltip>
+              </TooltipTrigger>
+            </Flex>
+          </BlockPopover>
+        </BlockPopoverTrigger>
+      </ElementType>
+      <CustomAttributesDialog
+        element={element}
+        schema={documentFeatures.formatting.headings.schema}
+        isOpen={dialogOpen}
+        nodeLabel="Heading"
+        onDismiss={() => setDialogOpen(false)}
+      />
+    </>
   );
 };
diff --git a/packages/keystatic/DocumentEditor/insert-menu.tsx b/packages/keystatic/DocumentEditor/insert-menu.tsx
index b314f706..2c55b2a1 100644
--- a/packages/keystatic/DocumentEditor/insert-menu.tsx
+++ b/packages/keystatic/DocumentEditor/insert-menu.tsx
@@ -43,7 +43,9 @@ function getOptions(
     })),
     ...toolbarState.textStyles.allowedHeadingLevels
       .filter(a =>
-        toolbarState.editorDocumentFeatures.formatting.headingLevels.includes(a)
+        toolbarState.editorDocumentFeatures.formatting.headings.levels.includes(
+          a
+        )
       )
       .map(level => ({
         label: `Heading ${level}`,
diff --git a/packages/keystatic/DocumentEditor/primitives/BlockPopover.tsx b/packages/keystatic/DocumentEditor/primitives/BlockPopover.tsx
index fbdf9f8e..2593fb03 100644
--- a/packages/keystatic/DocumentEditor/primitives/BlockPopover.tsx
+++ b/packages/keystatic/DocumentEditor/primitives/BlockPopover.tsx
@@ -58,7 +58,8 @@ const typeMatcher = nodeTypeMatcher(
   'image',
   'layout',
   'link',
-  'table'
+  'table',
+  'heading'
 );
 
 const ActiveBlockPopoverContext = createContext<undefined | Element>(undefined);
diff --git a/packages/keystatic/DocumentEditor/toolbar-state.tsx b/packages/keystatic/DocumentEditor/toolbar-state.tsx
index f0cb62dd..ee86b37a 100644
--- a/packages/keystatic/DocumentEditor/toolbar-state.tsx
+++ b/packages/keystatic/DocumentEditor/toolbar-state.tsx
@@ -77,8 +77,11 @@ export const createToolbarState = (
         dividers: true,
         formatting: {
           alignment: { center: true, end: true },
-          blockTypes: { blockquote: true, code: true },
-          headingLevels: [1, 2, 3, 4, 5, 6],
+          blockTypes: {
+            blockquote: true,
+            code: editorDocumentFeatures.formatting.blockTypes.code,
+          },
+          headings: editorDocumentFeatures.formatting.headings,
           listTypes: { ordered: true, unordered: true },
         },
         layouts: editorDocumentFeatures.layouts,
@@ -167,7 +170,7 @@ export const createToolbarState = (
       selected: headingEntry ? headingEntry[0].level : 'normal',
       allowedHeadingLevels:
         locationDocumentFeatures.kind === 'block' && !listEntry
-          ? locationDocumentFeatures.documentFeatures.formatting.headingLevels
+          ? locationDocumentFeatures.documentFeatures.formatting.headings.levels
           : [],
     },
     code: {
diff --git a/packages/keystatic/markdoc/from-markdoc.ts b/packages/keystatic/markdoc/from-markdoc.ts
index 98de15c4..79b49639 100644
--- a/packages/keystatic/markdoc/from-markdoc.ts
+++ b/packages/keystatic/markdoc/from-markdoc.ts
@@ -143,22 +143,20 @@ function fromMarkdocNode(
     };
   }
   if (node.type === 'fence') {
+    const { language, content, ...rest } = node.attributes;
     return {
       type: 'code',
-      children: [
-        { text: node.attributes.content.replace(/\n$/, '') } as TextWithMarks,
-      ],
-      ...(typeof node.attributes.language === 'string'
-        ? { language: node.attributes.language }
-        : {}),
+      children: [{ text: content.replace(/\n$/, '') } as TextWithMarks],
+      ...(typeof language === 'string' ? { language } : {}),
+      ...rest,
     };
   }
   if (node.type === 'heading') {
     return {
-      type: 'heading',
+      ...node.attributes,
       level: node.attributes.level,
+      type: 'heading',
       children: inlineFromMarkdoc(node.children),
-      textAlign: node.attributes.textAlign,
     };
   }
   if (node.type === 'list') {
@@ -298,13 +296,6 @@ function fromMarkdocNode(
           fields: componentBlock.schema,
         });
         if (singleChildField) {
-          // [
-          //     {
-          //       type: `component-${singleChildField.options.kind}-prop`,
-          //       children: node.children.map(x => fromMarkdocNode(x, componentBlocks)),
-          //       propPath: singleChildField.path,
-          //     },
-          //   ]
           const newAttributes = JSON.parse(JSON.stringify(node.attributes));
           const children: Descendant[] = [];
           toChildrenAndProps(
diff --git a/packages/keystatic/markdoc/to-markdoc.tsx b/packages/keystatic/markdoc/to-markdoc.tsx
index 9ae1cfcc..b2af1afa 100644
--- a/packages/keystatic/markdoc/to-markdoc.tsx
+++ b/packages/keystatic/markdoc/to-markdoc.tsx
@@ -9,6 +9,8 @@ import {
   findSingleChildField,
   PathToChildFieldWithOption,
 } from './find-children';
+import { DocumentFeatures } from '../DocumentEditor/document-features';
+import { getInitialPropsValueFromInitializer } from '../DocumentEditor/component-blocks/initial-values';
 
 function toInline(nodes: ElementFromValidation[]): Node {
   return new Ast.Node('inline', {}, nodes.flatMap(toMarkdocInline));
@@ -55,21 +57,28 @@ function toMarkdocInline(node: ElementFromValidation): Node | Node[] {
 
 export function toMarkdocDocument(
   nodes: ElementFromValidation[],
-  componentBlocks: Record<string, ComponentBlock>
+  config: {
+    componentBlocks: Record<string, ComponentBlock>;
+    documentFeatures: DocumentFeatures;
+  }
 ) {
   return new Ast.Node(
     'document',
     {},
-    nodes.flatMap(x => toMarkdoc(x, componentBlocks))
+    nodes.flatMap(x => toMarkdoc(x, config))
   );
 }
 
-function removeUnnecessaryChildFieldValues(
+function removeUnnecessaryFieldValues(
   schema: ComponentSchema,
   parent: ComponentSchema | undefined,
   value: unknown
 ): unknown {
   if (schema.kind === 'form') {
+    // i am very unsure about this magic not writing defaults
+    if (value === schema.defaultValue && parent?.kind === 'object') {
+      return undefined;
+    }
     return value;
   }
   if (schema.kind === 'child') {
@@ -80,7 +89,7 @@ function removeUnnecessaryChildFieldValues(
   }
   if (schema.kind === 'array') {
     return (value as unknown[]).map(x =>
-      removeUnnecessaryChildFieldValues(schema.element, schema, x)
+      removeUnnecessaryFieldValues(schema.element, schema, x)
     );
   }
   if (schema.kind === 'object') {
@@ -88,7 +97,7 @@ function removeUnnecessaryChildFieldValues(
       Object.entries(schema.fields)
         .map(([key, innerSchema]) => [
           key,
-          removeUnnecessaryChildFieldValues(
+          removeUnnecessaryFieldValues(
             innerSchema,
             schema,
             (value as any)[key]
@@ -100,7 +109,7 @@ function removeUnnecessaryChildFieldValues(
   if (schema.kind === 'conditional') {
     return {
       discriminant: (value as any).discriminant,
-      value: removeUnnecessaryChildFieldValues(
+      value: removeUnnecessaryFieldValues(
         schema.values[(value as any).discriminant],
         schema,
         (value as any).value
@@ -179,7 +188,10 @@ function toChildrenAndProps(
 
 function toMarkdoc(
   node: ElementFromValidation,
-  componentBlocks: Record<string, ComponentBlock>
+  config: {
+    componentBlocks: Record<string, ComponentBlock>;
+    documentFeatures: DocumentFeatures;
+  }
 ): Node {
   if (node.type === 'paragraph') {
     const markdocNode = new Ast.Node(
@@ -208,13 +220,42 @@ function toMarkdoc(
     ]);
   }
   if (node.type === 'code') {
-    let content = (node.children[0] as { text: string }).text + '\n';
-    return new Ast.Node('fence', { content, language: node.language }, [
-      new Ast.Node('text', { content }),
-    ]);
+    const extraAttributes: Record<string, unknown> = {};
+    const { children, language, type, ...rest } = node;
+    const schema =
+      typeof config.documentFeatures.formatting.blockTypes.code === 'object'
+        ? config.documentFeatures.formatting.blockTypes.code.schema
+        : undefined;
+    if (schema && Object.keys(schema.fields).length > 0) {
+      Object.assign(
+        extraAttributes,
+        removeUnnecessaryFieldValues(
+          schema,
+          undefined,
+          getInitialPropsValueFromInitializer(schema, rest)
+        )
+      );
+    }
+
+    let content = (children[0] as { text: string }).text + '\n';
+
+    const markdocNode = new Ast.Node(
+      'fence',
+      { content, language, ...extraAttributes },
+      [new Ast.Node('text', { content })]
+    );
+
+    for (const [key, value] of Object.entries(extraAttributes)) {
+      markdocNode.annotations.push({
+        name: key,
+        value,
+        type: 'attribute',
+      });
+    }
+
+    return markdocNode;
   }
-  const _toMarkdoc = (node: ElementFromValidation) =>
-    toMarkdoc(node, componentBlocks);
+  const _toMarkdoc = (node: ElementFromValidation) => toMarkdoc(node, config);
   if (node.type === 'blockquote') {
     return new Ast.Node('blockquote', {}, node.children.map(_toMarkdoc));
   }
@@ -251,21 +292,35 @@ function toMarkdoc(
     );
   }
   if (node.type === 'heading') {
+    const extraAttributes: Record<string, unknown> = {};
+    if (node.textAlign) {
+      extraAttributes.textAlign = node.textAlign;
+    }
+    const { children, level, textAlign, type, ...rest } = node;
+    const schema = config.documentFeatures.formatting.headings.schema;
+    if (Object.keys(schema.fields).length > 0) {
+      Object.assign(
+        extraAttributes,
+        removeUnnecessaryFieldValues(
+          schema,
+          undefined,
+          getInitialPropsValueFromInitializer(schema, rest)
+        )
+      );
+    }
     const markdocNode = new Ast.Node(
       'heading',
-      {
-        level: node.level,
-        ...(node.textAlign ? { textAlign: node.textAlign } : {}),
-      },
+      { level: node.level, ...extraAttributes },
       [toInline(node.children)]
     );
-    if (node.textAlign) {
+    for (const [key, value] of Object.entries(extraAttributes)) {
       markdocNode.annotations.push({
-        name: 'textAlign',
-        value: node.textAlign,
+        name: key,
+        value,
         type: 'attribute',
       });
     }
+
     return markdocNode;
   }
   if (node.type === 'ordered-list') {
@@ -303,7 +358,7 @@ function toMarkdoc(
       node.children.length === 1 &&
       node.children[0].type === 'component-inline-prop' &&
       node.children[0].propPath === undefined;
-    const componentBlock = componentBlocks[node.component] as
+    const componentBlock = config.componentBlocks[node.component] as
       | ComponentBlock
       | undefined;
 
@@ -331,7 +386,7 @@ function toMarkdoc(
     }
 
     const attributes = componentBlock
-      ? (removeUnnecessaryChildFieldValues(
+      ? (removeUnnecessaryFieldValues(
           { kind: 'object' as const, fields: componentBlock.schema },
           undefined,
           node.props
@@ -383,7 +438,7 @@ function toMarkdoc(
     const children = [inline];
     const nestedList = node.children[1];
     if (nestedList) {
-      children.push(toMarkdoc(nestedList, componentBlocks));
+      children.push(toMarkdoc(nestedList, config));
     }
     return new Ast.Node('item', {}, children);
   }
diff --git a/packages/next/src/api.tsx b/packages/next/src/api.tsx
index 32d8f8d4..955eb981 100644
--- a/packages/next/src/api.tsx
+++ b/packages/next/src/api.tsx
@@ -35,7 +35,11 @@ export function makeAPIRouteHandler(_config: APIRouteConfig) {
         return;
       }
 
-      const { watch } = require('chokidar');
+      const a = require;
+
+      const b = 'chokidar';
+
+      const { watch } = a(b);
 
       const watcher = watch(directories, { ignored: [/node_modules/] });
       const waitForNextEvent = createWatcher(watcher);


Test patch:
diff --git a/packages/keystatic/markdoc/test-utils.ts b/packages/keystatic/markdoc/test-utils.ts
index 7509db0a..9389d084 100644
--- a/packages/keystatic/markdoc/test-utils.ts
+++ b/packages/keystatic/markdoc/test-utils.ts
@@ -1,6 +1,9 @@
 import Markdoc from '@markdoc/markdoc';
 import { Node } from 'slate';
-import { makeEditor } from '../DocumentEditor/tests/utils';
+import {
+  defaultDocumentFeatures,
+  makeEditor,
+} from '../DocumentEditor/tests/utils';
 import { ComponentBlock } from '../src';
 import { ElementFromValidation } from '../structure-validation';
 import { toMarkdocDocument } from './to-markdoc';
@@ -13,7 +16,10 @@ export function toMarkdoc(
   return Markdoc.format(
     Markdoc.parse(
       Markdoc.format(
-        toMarkdocDocument(children as ElementFromValidation[], componentBlocks)
+        toMarkdocDocument(children as ElementFromValidation[], {
+          componentBlocks,
+          documentFeatures: defaultDocumentFeatures,
+        })
       )
     )
   );