remirror/remirror

View on GitHub
packages/remirror__core/src/builtins/schema-extension.ts

Summary

Maintainability
A
0 mins
Test Coverage
A
91%
import {
  ErrorConstant,
  ExtensionPriority,
  ExtensionTag,
  ExtensionTagType,
} from '@remirror/core-constants';
import {
  assertGet,
  entries,
  invariant,
  isArray,
  isFunction,
  isNullOrUndefined,
  isPlainObject,
  isString,
  object,
  toString,
} from '@remirror/core-helpers';
import type {
  ApplySchemaAttributes,
  DynamicAttributeCreator,
  EditorSchema,
  JsonPrimitive,
  Mark,
  MarkExtensionSpec,
  MarkSpecOverride,
  NodeExtensionSpec,
  NodeMarkOptions,
  NodeSpecOverride,
  ProsemirrorAttributes,
  ProsemirrorNode,
  SchemaAttributes,
  SchemaAttributesObject,
  Static,
  Transaction,
} from '@remirror/core-types';
import {
  getDefaultBlockNode,
  getMarkRange,
  isElementDomNode,
  isProsemirrorMark,
  isProsemirrorNode,
} from '@remirror/core-utils';
import { MarkSpec, NodeSpec, Schema } from '@remirror/pm/model';
import { ignoreUpdateForSuggest } from '@remirror/pm/suggest';

import {
  AnyExtension,
  extension,
  GetMarkNameUnion,
  GetNodeNameUnion,
  isMarkExtension,
  isNodeExtension,
  PlainExtension,
} from '../extension';
import type { CreateExtensionPlugin } from '../types';
import type { CombinedTags } from './tags-extension';

/**
 * This is the schema extension which creates the schema and provides extra
 * attributes as defined in the manager or the extension settings.
 *
 * @remarks
 *
 * The schema is the most important part of the remirror editor. This is the
 * extension responsible for creating it, injecting extra attributes and
 * managing the plugin which is responsible for making sure dynamically created
 * attributes are updated.
 *
 * In order to add extra attributes the following would work.
 *
 * ```ts
 * import { RemirrorManager } from 'remirror';
 * import uuid from 'uuid';
 * import hash from 'made-up-hasher';
 *
 * const manager = RemirrorManager.create([], {
 *   extraAttributes: [
 *     {
 *       identifiers: 'nodes',
 *       attributes: {
 *         awesome: {
 *           default: 'awesome',
 *           parseDOM: (domNode) => domNode.getAttribute('data-awesome'),
 *           toDOM: (attrs) => ([ 'data-awesome', attrs.awesome ])
 *         },
 *       },
 *     },
 *     { identifiers: ['paragraph'], attributes: { id: { default: () => uuid() } } },
 *     { identifiers: ['bold'], attributes: { hash: (mark) => hash(JSON.stringify(mark.attrs)) } },
 *   ],
 * })
 * ```
 *
 * It is an array of identifiers and attributes. Setting the default to a
 * function allows you to set up a dynamic attribute which is updated with the
 * synchronous function that you provide to it.
 *
 * @category Builtin Extension
 */
@extension({ defaultPriority: ExtensionPriority.Highest })
export class SchemaExtension extends PlainExtension {
  get name() {
    return 'schema' as const;
  }

  /**
   * The dynamic attributes for each node and mark extension.
   *
   * The structure will look like the following.
   *
   * ```ts
   * {
   *   paragraph: { id: () => uid(), hash: (node) => hash(node) },
   *   bold: { random: () => Math.random(), created: () => Date.now() },
   * };
   * ```
   *
   * This object is used by the created plugin to listen for changes to the doc,
   * and check for new nodes and marks which haven't yet applied the dynamic
   * attribute and add the attribute.
   */
  private readonly dynamicAttributes: DynamicSchemaAttributeCreators = {
    marks: object(),
    nodes: object(),
  };

  /**
   * This method is responsible for creating, configuring and adding the
   * `schema` to the editor. `Schema` is a special type in ProseMirror editors
   * and with `remirror` it's all just handled for you.
   */
  onCreate(): void {
    const { managerSettings, tags, markNames, nodeNames, extensions } = this.store;
    const { defaultBlockNode, disableExtraAttributes, nodeOverride, markOverride } =
      managerSettings;

    // True when the `defaultBlockNode` exists for this editor.
    const isValidDefaultBlockNode = (name: string | undefined): name is string =>
      !!(name && tags[ExtensionTag.Block].includes(name));

    // The user can override the whole schema creation process by providing
    // their own version. In that case we can exit early.
    if (managerSettings.schema) {
      const { nodes, marks } = getSpecFromSchema(managerSettings.schema);
      this.addSchema(managerSettings.schema, nodes, marks);

      // Exit early! 🙌
      return;
    }

    // This nodes object is built up for each extension and then at the end it
    // will be passed to the `Schema` constructor to create a new `schema`.
    const nodes: Record<string, NodeSpec> = isValidDefaultBlockNode(defaultBlockNode)
      ? {
          doc: object(),
          // Ensure that this is the highest positioned block node by adding it
          // to the object early. Later on it will be overwritten but maintain
          // it's position.
          [defaultBlockNode]: object(),
        }
      : object();

    // Similar to the `nodes` object above this is passed to the `Schema`.
    const marks: Record<string, MarkSpec> = object();

    // Get the named extra attributes from the manager. This allows each extra
    // attribute group added to the manager to be applied to the individual
    // extensions which specified.
    const namedExtraAttributes = getNamedSchemaAttributes({
      settings: managerSettings,
      gatheredSchemaAttributes: this.gatherExtraAttributes(extensions),
      nodeNames: nodeNames,
      markNames: markNames,
      tags: tags,
    });

    for (const extension of extensions) {
      // Pick the current attributes from the named attributes and merge them
      // with the extra attributes which were added to the extension. Extra
      // attributes added to the extension are prioritized.
      namedExtraAttributes[extension.name] = {
        ...namedExtraAttributes[extension.name],
        ...extension.options.extraAttributes,
      };

      // There are several places that extra attributes can be ignored. This
      // checks them all.
      const ignoreExtraAttributes =
        disableExtraAttributes === true ||
        extension.options.disableExtraAttributes === true ||
        extension.constructor.disableExtraAttributes === true;

      if (isNodeExtension(extension)) {
        // Create the spec and gather dynamic attributes for this node
        // extension.
        const { spec, dynamic } = createSpec({
          createExtensionSpec: (extra, override) => extension.createNodeSpec(extra, override),
          extraAttributes: assertGet(namedExtraAttributes, extension.name),

          // Todo add support for setting overrides via the manager.
          override: { ...nodeOverride, ...extension.options.nodeOverride },
          ignoreExtraAttributes,
          name: extension.constructorName,
          tags: extension.tags,
        });

        // Store the node spec on the extension for future reference.
        extension.spec = spec;

        // Add the spec to the `nodes` object which is used to create the schema
        // with the same name as the extension name.
        nodes[extension.name] = spec as NodeSpec;

        // Keep track of the dynamic attributes. The `extension.name` is the
        // same name of the `NodeType` and is used by the plugin in this
        // extension to dynamically generate attributes for the correct nodes.
        if (Object.keys(dynamic).length > 0) {
          this.dynamicAttributes.nodes[extension.name] = dynamic;
        }
      }

      // Very similar to the previous conditional block except for marks rather
      // than nodes.
      if (isMarkExtension(extension)) {
        // Create the spec and gather dynamic attributes for this mark
        // extension.
        const { spec, dynamic } = createSpec({
          createExtensionSpec: (extra, override) => extension.createMarkSpec(extra, override),
          extraAttributes: assertGet(namedExtraAttributes, extension.name),
          // Todo add support for setting overrides via the manager.
          override: { ...markOverride, ...extension.options.markOverride },
          ignoreExtraAttributes,
          name: extension.constructorName,
          tags: extension.tags ?? [],
        });

        // Store the mark spec on the extension for future reference.
        extension.spec = spec;

        // Add the spec to the `marks` object which is used to create the schema
        // with the same name as the extension name.
        marks[extension.name] = spec as MarkSpec;

        // Keep track of the dynamic attributes. The `extension.name` is the
        // same name of the `MarkType` and is used by the plugin in this
        // extension to dynamically generate attributes for the correct marks.
        if (Object.keys(dynamic).length > 0) {
          this.dynamicAttributes.marks[extension.name] = dynamic;
        }
      }
    }

    // Create the schema from the gathered nodes and marks.
    const schema = new Schema({ nodes, marks, topNode: 'doc' });

    // Add the schema and nodes marks to the store.
    this.addSchema(
      schema,
      nodes as Record<string, NodeExtensionSpec>,
      marks as Record<string, MarkExtensionSpec>,
    );
  }

  /**
   * This creates the plugin that is used to automatically create the dynamic
   * attributes defined in the extra attributes object.
   */
  createPlugin(): CreateExtensionPlugin {
    return {
      appendTransaction: (transactions, _, nextState) => {
        // This creates a new transaction which will be used to update the
        // attributes of any node and marks which
        const { tr } = nextState;

        // The dynamic attribute updates only need to be run if the document has
        // been modified in a transaction.
        const documentHasChanged = transactions.some((tr) => tr.docChanged);

        if (!documentHasChanged) {
          // The document has not been changed therefore no updates are
          // required.
          return null;
        }

        // The find children method could potentially be quite expensive. Before
        // committing to that level of work let's check that there user has
        // actually defined some dynamic attributes.
        if (
          Object.keys(this.dynamicAttributes.nodes).length === 0 &&
          Object.keys(this.dynamicAttributes.marks).length === 0
        ) {
          return null;
        }

        // This function loops through every node in the document and add the
        // dynamic attributes when any relevant nodes have been added.
        tr.doc.descendants((child, pos) => {
          this.checkAndUpdateDynamicNodes(child, pos, tr);
          this.checkAndUpdateDynamicMarks(child, pos, tr);

          // This means that all nodes will be checked.
          return true;
        });

        // If the transaction has any `steps` then it has been modified and
        // should be returned i.e. appended to the additional transactions.
        // However, if there are no steps then ignore and return `null`.
        return tr.steps.length > 0 ? tr : null;
      },
    };
  }

  /**
   * Add the schema and nodes to the manager and extension store.
   */
  private addSchema(
    schema: EditorSchema,
    nodes: Record<string, NodeExtensionSpec>,
    marks: Record<string, MarkExtensionSpec>,
  ) {
    // Store the `nodes`, `marks` and `schema` on the manager store. For example
    // the `schema` can be accessed via `manager.store.schema`.
    this.store.setStoreKey('nodes', nodes);
    this.store.setStoreKey('marks', marks);
    this.store.setStoreKey('schema', schema);

    // Add the schema to the extension store, so that all extension from this
    // point have access to the schema via `this.store.schema`.
    this.store.setExtensionStore('schema', schema);
    this.store.setStoreKey('defaultBlockNode', getDefaultBlockNode(schema).name);

    // Set the default block node from the schema.
    for (const type of Object.values(schema.nodes)) {
      if (type.name === 'doc') {
        continue;
      }

      // Break as soon as the first non 'doc' block node is encountered.
      if (type.isBlock || type.isTextblock) {
        break;
      }
    }
  }

  /**
   * Check the dynamic nodes to see if the provided node:
   *
   * - a) is dynamic and therefore can be updated.
   * - b) has just been created and does not yet have a value for the dynamic
   *   node.
   *
   * @param node - the node
   * @param pos - the node's position
   * @param tr - the mutable ProseMirror transaction which is applied to create
   * the next editor state
   */
  private checkAndUpdateDynamicNodes(node: ProsemirrorNode, pos: number, tr: Transaction) {
    // Check for matching nodes.
    for (const [name, dynamic] of entries(this.dynamicAttributes.nodes)) {
      if (node.type.name !== name) {
        continue;
      }

      for (const [attributeName, attributeCreator] of entries(dynamic)) {
        if (!isNullOrUndefined(node.attrs[attributeName])) {
          continue;
        }

        // The new attributes which will be added to the node.
        const attrs = { ...node.attrs, [attributeName]: attributeCreator(node) };

        // Apply the new dynamic attribute to the node via the transaction.
        tr.setNodeMarkup(pos, undefined, attrs);

        // Ignore this update in the `prosemirror-suggest` plugin
        ignoreUpdateForSuggest(tr);
      }
    }
  }

  /**
   * Loop through the dynamic marks to see if the provided node:
   *
   * - a) is wrapped by a matching mark.
   * - b) has just been added and doesn't yet have the dynamic attribute
   *   applied.
   *
   * @param node - the node
   * @param pos - the node's position
   * @param tr - the mutable ProseMirror transaction which is applied to create
   * the next editor state.
   */
  private checkAndUpdateDynamicMarks(node: ProsemirrorNode, pos: number, tr: Transaction) {
    // Check for matching marks.
    for (const [name, dynamic] of entries(this.dynamicAttributes.marks)) {
      // This is needed to create the new mark. Even though a mark may already
      // exist ProseMirror requires that a new one is created and added in
      // order. More details available
      // [here](https://discuss.prosemirror.net/t/updating-mark-attributes/776/2?u=ifi).
      const type = assertGet(this.store.schema.marks, name);

      // Get the attrs from the mark.
      const mark = node.marks.find((mark) => mark.type.name === name);

      // If the mark doesn't exist within the set then move to the next
      // dynamically updated mark.
      if (!mark) {
        continue;
      }

      // Loop through to find if any of the required matches are missing from
      // the dynamic attribute;
      for (const [attributeName, attributeCreator] of entries(dynamic)) {
        // When the attributes for this dynamic attributeName are already
        // defined we should move onto the next item;
        if (!isNullOrUndefined(mark.attrs[attributeName])) {
          continue;
        }

        // Use the starting position of the node to calculate the range range of
        // the current mark.
        const range = getMarkRange(tr.doc.resolve(pos), type);

        if (!range) {
          continue;
        }

        // The { from, to } range which will be used to update the mark id
        // attribute.
        const { from, to } = range;

        // Create the new mark with all the existing dynamic attributes applied.
        const newMark = type.create({
          ...mark.attrs,
          [attributeName]: attributeCreator(mark),
        });

        // Update the value of the mark. The only way to do this right now is to
        // remove and then add it back again.
        tr.removeMark(from, to, type).addMark(from, to, newMark);

        // Ignore this update in the `prosemirror-suggest` plugin
        ignoreUpdateForSuggest(tr);
      }
    }
  }

  /**
   * Gather all the extra attributes that have been added by extensions.
   */
  private gatherExtraAttributes(extensions: readonly AnyExtension[]) {
    const extraSchemaAttributes: IdentifierSchemaAttributes[] = [];

    for (const extension of extensions) {
      if (!extension.createSchemaAttributes) {
        continue;
      }

      extraSchemaAttributes.push(...extension.createSchemaAttributes());
    }

    return extraSchemaAttributes;
  }
}

/**
 * With tags, you can select a specific sub selection of marks and nodes. This
 * will be the basis for adding advanced formatting to remirror.
 *
 * ```ts
 * import { ExtensionTag } from 'remirror';
 * import { createCoreManager, CorePreset } from 'remirror/extensions';
 * import { WysiwygPreset } from 'remirror/extensions';
 *
 * const manager = createCoreManager(() => [new WysiwygPreset(), new CorePreset()], {
 *   extraAttributes: [
 *     {
 *       identifiers: {
 *         tags: [ExtensionTag.NodeBlock],
 *         type: 'node',
 *       },
 *       attributes: { role: 'presentation' },
 *     },
 *   ],
 * });
 * ```
 *
 * Each item in the tags array should be read as an `OR` so the following would
 * match `Tag1` OR `Tag2` OR `Tag3`.
 *
 * ```json
 * { tags: ["Tag1", "Tag2", "Tag3"] }
 * ```
 *
 * The `type` property (`mark | node`) is exclusive and limits the type of
 * extension names that will be matched. When `mark` is set it only matches with
 * marks.
 */
export interface IdentifiersObject {
  /**
   * Determines how the array of tags are combined:
   *
   * - `all` - the extension only matches when all tags are present.
   * - `any` - the extension will match if it includes any of the specified
   *   tags.
   *
   * This only affects the `tags` property.
   *
   * The saddest part about this property is that, as a UK resident, I've
   * succumbed to using the Americanized spelling instead of the Oxford
   * Dictionary defined spelling of `behaviour` 😢
   *
   * @defaultValue 'any'
   */
  behavior?: 'all' | 'any';

  /**
   * Will find relevant names based on the defined `behaviour`.
   */
  tags?: ExtensionTagType[];

  /**
   * Additional names to include. These will still be added even if the
   * extension name matches with `excludeTags` member.
   */
  names?: string[];

  /**
   * Whether to restrict by whether this is a [[`ProsemirrorNode`]] or a
   * [[`Mark`]]. Leave blank to accept all types.
   */
  type?: 'node' | 'mark';

  /**
   * Exclude these names from being matched.
   */
  excludeNames?: string[];

  /**
   * Exclude these tags from being matched. Will always exclude if any of the
   * tags
   */
  excludeTags?: string[];
}

/**
 * The extra identifiers that can be used.
 *
 * - `nodes` - match all nodes
 * - `marks` - match all marks
 * - `all` - match everything in the editor
 * - `string[]` - match the selected node and mark names
 * - [[`IdentifiersObject`]] - match by `ExtensionTag` and type name.
 */
export type Identifiers = 'nodes' | 'marks' | 'all' | readonly string[] | IdentifiersObject;

/**
 * The interface for adding extra attributes to multiple node and mark
 * extensions.
 */
export interface IdentifierSchemaAttributes {
  /**
   * The nodes or marks to add extra attributes to.
   *
   * This can either be an array of the strings or the following specific
   * identifiers:
   *
   * - 'nodes' for all nodes
   * - 'marks' for all marks
   * - 'all' for all extensions which touch the schema.
   */
  identifiers: Identifiers;

  /**
   * The attributes to be added.
   */
  attributes: SchemaAttributes;
}

/**
 * An object of `mark` and `node` dynamic schema attribute creators.
 */
interface DynamicSchemaAttributeCreators {
  /**
   * The dynamic schema attribute creators for all marks in the editor.
   */
  marks: Record<string, Record<string, DynamicAttributeCreator>>;

  /**
   * The dynamic schema attribute creators for all nodes in the editor.
   */
  nodes: Record<string, Record<string, DynamicAttributeCreator>>;
}

/**
 * The schema attributes mapped to the names of the extension they belong to.
 */
type NamedSchemaAttributes = Record<string, SchemaAttributes>;

interface TransformSchemaAttributesProps {
  /**
   * The manager settings at the point of creation.
   */
  settings: Remirror.ManagerSettings;

  /**
   * The schema attributes which were added to the `manager`.
   */
  gatheredSchemaAttributes: IdentifierSchemaAttributes[];

  /**
   * The names of all the nodes within the editor.
   */
  nodeNames: readonly string[];

  /**
   * The names of all the marks within the editor.
   */
  markNames: readonly string[];

  /**
   * The tags that are being used by active extension right now.
   */
  tags: CombinedTags;
}

/**
 * Get the extension extra attributes created via the manager and convert into a
 * named object which can be added to each node and mark spec.
 */
function getNamedSchemaAttributes(props: TransformSchemaAttributesProps): NamedSchemaAttributes {
  const { settings, gatheredSchemaAttributes, nodeNames, markNames, tags } = props;
  const extraAttributes: NamedSchemaAttributes = object();

  if (settings.disableExtraAttributes) {
    return extraAttributes;
  }

  const extraSchemaAttributes: IdentifierSchemaAttributes[] = [
    ...gatheredSchemaAttributes,
    ...(settings.extraAttributes ?? []),
  ];

  for (const attributeGroup of extraSchemaAttributes ?? []) {
    const identifiers = getIdentifiers({
      identifiers: attributeGroup.identifiers,
      nodeNames,
      markNames,
      tags,
    });

    for (const identifier of identifiers) {
      const currentValue = extraAttributes[identifier] ?? {};
      extraAttributes[identifier] = { ...currentValue, ...attributeGroup.attributes };
    }
  }

  return extraAttributes;
}

interface GetIdentifiersProps {
  identifiers: Identifiers;
  nodeNames: readonly string[];
  markNames: readonly string[];
  tags: CombinedTags;
}

/**
 * A predicate for checking if the passed in value is an `IdentifiersObject`.
 */
function isIdentifiersObject(value: Identifiers): value is IdentifiersObject {
  return isPlainObject(value) && isArray(value.tags);
}

/**
 * Get the array of names from the identifier that the extra attributes should
 * be applied to.
 */
function getIdentifiers(props: GetIdentifiersProps): readonly string[] {
  const { identifiers, nodeNames, markNames, tags } = props;

  if (identifiers === 'nodes') {
    return nodeNames;
  }

  if (identifiers === 'marks') {
    return markNames;
  }

  if (identifiers === 'all') {
    return [...nodeNames, ...markNames];
  }

  // This is already an array of names to apply the attributes to.
  if (isArray(identifiers)) {
    return identifiers;
  }

  // Make sure the object provides is valid.
  invariant(isIdentifiersObject(identifiers), {
    code: ErrorConstant.EXTENSION_EXTRA_ATTRIBUTES,
    message: `Invalid value passed as an identifier when creating \`extraAttributes\`.`,
  });

  // Provide type aliases for easier readability.
  type Name = string; // `type` Alias for the extension name.
  type Tag = string; // The tag for this extension.
  type TagSet = Set<Tag>; // The set of tags.
  type TaggedNamesMap = Map<Name, TagSet>;

  const {
    tags: extensionTags = [],
    names: extensionNames = [],
    behavior = 'any',
    excludeNames,
    excludeTags,
    type,
  } = identifiers;

  // Keep track of the set of stored names.
  const names: Set<Name> = new Set();

  // Collect the array of names that are supported.
  const acceptableNames =
    type === 'mark' ? markNames : type === 'node' ? nodeNames : [...markNames, ...nodeNames];

  // Check if the name is valid
  const isNameValid = (name: string) =>
    acceptableNames.includes(name) && !excludeNames?.includes(name);

  for (const name of extensionNames) {
    if (isNameValid(name)) {
      names.add(name);
    }
  }

  // Create a map of extension names to their set of included tags. Then check
  // that the length of the `TagSet` for each extension name is equal to the
  // provided extension tags in this identifier.
  const taggedNamesMap: TaggedNamesMap = new Map();

  // Loop through every extension
  for (const tag of extensionTags) {
    if (excludeTags?.includes(tag)) {
      continue;
    }

    for (const name of tags[tag]) {
      if (!isNameValid(name)) {
        continue;
      }

      // When any tag can be an identifier simply add the name to names.
      if (behavior === 'any') {
        names.add(name);
        continue;
      }

      const tagSet: TagSet = taggedNamesMap.get(name) ?? new Set();
      tagSet.add(tag);
      taggedNamesMap.set(name, tagSet);
    }
  }

  // Only add the names that have a `TagSet` where `size` is equal to the number
  // of `extensionTags`
  for (const [name, tagSet] of taggedNamesMap) {
    if (tagSet.size === extensionTags.length) {
      names.add(name);
    }
  }

  return [...names];
}

interface CreateSpecProps<Spec extends { group?: string | null }, Override extends object> {
  /**
   * The node or mark creating function.
   */
  createExtensionSpec: (extra: ApplySchemaAttributes, override: Override) => Spec;

  /**
   * The extra attributes object which has been passed through for this
   * extension.
   */
  extraAttributes: SchemaAttributes;

  /**
   * The overrides provided to the schema.
   */
  override: Override;

  /**
   * This is true when the extension is set to ignore extra attributes.
   */
  ignoreExtraAttributes: boolean;

  /**
   * The name for displaying in an error message. The name of the constructor is
   * used since it's more descriptive and easier to debug the error that may be
   * thrown if extra attributes are not applied correctly.
   */
  name: string;

  /**
   * The tags that were used to create this extension. These are added to the
   * node and mark groups.
   */
  tags: ExtensionTagType[];
}

interface CreateSpecReturn<Type extends { group?: string | null }> {
  /** The created spec. */
  spec: Type;

  /** The dynamic attribute creators for this spec */
  dynamic: Record<string, DynamicAttributeCreator>;
}

/**
 * Create the scheme spec for a node or mark extension.
 *
 * @typeParam Type - either a [[Mark]] or a [[ProsemirrorNode]]
 * @param props - the options object [[CreateSpecProps]]
 */
function createSpec<Type extends { group?: string | null }, Override extends object>(
  props: CreateSpecProps<Type, Override>,
): CreateSpecReturn<Type> {
  const { createExtensionSpec, extraAttributes, ignoreExtraAttributes, name, tags, override } =
    props;

  // Keep track of the dynamic attributes which are a part of this spec.
  const dynamic: Record<string, DynamicAttributeCreator> = object();

  /** Called for every dynamic creator to track the dynamic attributes */
  function addDynamic(attributeName: string, creator: DynamicAttributeCreator) {
    dynamic[attributeName] = creator;
  }

  // Used to track whether the method has been called. If not called when the
  // extension spec is being set up then an error is thrown.
  let defaultsCalled = false;

  /** Called by createDefaults to track when the `defaults` has been called. */
  function onDefaultsCalled() {
    defaultsCalled = true;
  }

  const defaults = createDefaults(
    extraAttributes,
    ignoreExtraAttributes,
    onDefaultsCalled,
    addDynamic,
  );

  const parse = createParseDOM(extraAttributes, ignoreExtraAttributes);
  const dom = createToDOM(extraAttributes, ignoreExtraAttributes);
  const spec = createExtensionSpec({ defaults, parse, dom }, override);

  invariant(ignoreExtraAttributes || defaultsCalled, {
    code: ErrorConstant.EXTENSION_SPEC,
    message: `When creating a node specification you must call the 'defaults', and parse, and 'dom' methods. To avoid this error you can set the static property 'disableExtraAttributes' of '${name}' to 'true'.`,
  });

  // Add the tags to the group of the created spec.
  spec.group = [...(spec.group?.split(' ') ?? []), ...tags].join(' ') || undefined;

  return { spec, dynamic };
}

/**
 * Get the value of the extra attribute as an object.
 *
 * This is needed because the SchemaAttributes object can be configured as a
 * string or as an object.
 */
function getExtraAttributesObject(
  value: DynamicAttributeCreator | string | SchemaAttributesObject,
): SchemaAttributesObject {
  if (isString(value) || isFunction(value)) {
    return { default: value };
  }

  invariant(value, {
    message: `${toString(value)} is not supported`,
    code: ErrorConstant.EXTENSION_EXTRA_ATTRIBUTES,
  });

  return value;
}

/**
 * Create the `defaults()` method which is used for setting the property.
 *
 * @param extraAttributes - the extra attributes for this particular node
 * @param shouldIgnore - whether this attribute should be ignored
 * @param onCalled - the function which is called when this is run, to check
 * that it has been added to the attrs
 * @param addDynamic - A function called to add the dynamic creator and name to
 * the store
 */
function createDefaults(
  extraAttributes: SchemaAttributes,
  shouldIgnore: boolean,
  onCalled: () => void,
  addDynamicCreator: (name: string, creator: DynamicAttributeCreator) => void,
) {
  return () => {
    onCalled();
    const attributes: Record<string, { default?: JsonPrimitive }> = object();

    // Extra attributes can be ignored by the extension, check if that's the
    // case here.
    if (shouldIgnore) {
      return attributes;
    }

    // Loop through the extra attributes and attach to the attributes object.
    for (const [name, config] of entries(extraAttributes)) {
      // Make sure this is an object and not a string.
      const attributesObject = getExtraAttributesObject(config);
      let defaultValue = attributesObject.default;

      // When true this is a dynamic attribute creator.
      if (isFunction(defaultValue)) {
        // Store the name and method of the dynamic creator.
        addDynamicCreator(name, defaultValue);

        // Set the attributes for this dynamic creator to be null by default.
        defaultValue = null;
      }

      // When the `defaultValue` is set to `undefined`, it is set as an empty
      // object in order for ProseMirror to set it as a required attribute.
      attributes[name] = defaultValue === undefined ? {} : { default: defaultValue };
    }

    return attributes;
  };
}

/**
 * Create the parseDOM method to be applied to the extension `createNodeSpec`.
 */
function createParseDOM(extraAttributes: SchemaAttributes, shouldIgnore: boolean) {
  return (domNode: string | Node) => {
    const attributes: ProsemirrorAttributes = object();

    if (shouldIgnore) {
      return attributes;
    }

    for (const [name, config] of entries(extraAttributes)) {
      const { parseDOM, ...other } = getExtraAttributesObject(config);

      if (!isElementDomNode(domNode)) {
        continue;
      }

      if (isNullOrUndefined(parseDOM)) {
        attributes[name] = domNode.getAttribute(name) ?? other.default;
        continue;
      }

      if (isFunction(parseDOM)) {
        attributes[name] = parseDOM(domNode) ?? other.default;
        continue;
      }

      attributes[name] = domNode.getAttribute(parseDOM) ?? other.default;
    }

    return attributes;
  };
}

/**
 * Create the `toDOM` method to be applied to the extension `createNodeSpec`.
 */
function createToDOM(extraAttributes: SchemaAttributes, shouldIgnore: boolean) {
  return (item: ProsemirrorNode | Mark) => {
    const domAttributes: Record<string, string> = object();

    if (shouldIgnore) {
      return domAttributes;
    }

    function updateDomAttributes(
      value: string | [string, string?] | Record<string, string> | undefined | null,
      name: string,
    ) {
      if (!value) {
        return;
      }

      if (isString(value)) {
        domAttributes[name] = value;
        return;
      }

      if (isArray(value)) {
        const [attr, val] = value;
        domAttributes[attr] = val ?? (item.attrs[name] as string);
        return;
      }

      for (const [attr, val] of entries(value)) {
        domAttributes[attr] = val;
      }
    }

    for (const [name, config] of entries(extraAttributes)) {
      const { toDOM, parseDOM } = getExtraAttributesObject(config);

      if (isNullOrUndefined(toDOM)) {
        const key = isString(parseDOM) ? parseDOM : name;
        domAttributes[key] = item.attrs[name] as string;

        continue;
      }

      if (isFunction(toDOM)) {
        updateDomAttributes(toDOM(item.attrs, getNodeMarkOptions(item)), name);

        continue;
      }

      updateDomAttributes(toDOM, name);
    }

    return domAttributes;
  };
}

/**
 * Get the options object which applies should be used to obtain the node or
 * mark type.
 */
function getNodeMarkOptions(item: ProsemirrorNode | Mark): NodeMarkOptions {
  if (isProsemirrorNode(item)) {
    return { node: item };
  }

  if (isProsemirrorMark(item)) {
    return { mark: item };
  }

  return {};
}

/**
 * Get the mark and node specs from provided schema.
 *
 * This is used when the user provides their own custom schema.
 */
function getSpecFromSchema(schema: EditorSchema) {
  const nodes: Record<string, NodeExtensionSpec> = object();
  const marks: Record<string, MarkExtensionSpec> = object();

  for (const [name, type] of Object.entries(schema.nodes)) {
    nodes[name] = type.spec as NodeExtensionSpec;
  }

  for (const [name, type] of Object.entries(schema.marks)) {
    marks[name] = type.spec as MarkExtensionSpec;
  }

  return { nodes, marks };
}

declare global {
  namespace Remirror {
    interface BaseExtension {
      /**
       * Allows the extension to create an extra attributes array that will be
       * added to the extra attributes.
       *
       * For example the `@remirror/extension-bidi` adds a `dir` attribute to
       * all node extensions which allows them to automatically infer whether
       * the text direction should be right-to-left, or left-to-right.
       */
      createSchemaAttributes?(): IdentifierSchemaAttributes[];
    }
    interface BaseExtensionOptions {
      /**
       * Inject additional attributes into the defined mark / node schema. This
       * can only be used for `NodeExtensions` and `MarkExtensions`.
       *
       * @remarks
       *
       * Sometimes you need to add additional attributes to a node or mark. This
       * property enables this without needing to create a new extension.
       *
       * This is only applied to the `MarkExtension` and `NodeExtension`.
       *
       * @defaultValue {}
       */
      extraAttributes?: Static<SchemaAttributes>;

      /**
       * When true will disable extra attributes for this instance of the
       * extension.
       *
       * @remarks
       *
       * This is only applied to the `MarkExtension` and `NodeExtension`.
       *
       * @defaultValue undefined
       */
      disableExtraAttributes?: Static<boolean>;

      /**
       * An override for the mark spec object. This only applies for
       * `MarkExtension`.
       */
      markOverride?: Static<MarkSpecOverride>;

      /**
       * An override object for a node spec object. This only applies to the
       * `NodeExtension`.
       */
      nodeOverride?: Static<NodeSpecOverride>;
    }

    interface ManagerSettings {
      /**
       * Allows for setting extra attributes on multiple nodes and marks by
       * their name or constructor. These attributes are automatically added and
       * retrieved from from the dom by prosemirror.
       *
       * @remarks
       *
       * An example is shown below.
       *
       * ```ts
       * import { RemirrorManager } from 'remirror';
       *
       * const managerSettings = {
       *   extraAttributes: [
       *     {
       *       identifiers: ['blockquote', 'heading'],
       *       attributes: { id: 'id', alignment: '0', },
       *     }, {
       *       identifiers: ['mention', 'codeBlock'],
       *       attributes: { 'userId': { default: null } },
       *     },
       *   ]
       * };
       *
       * const manager = RemirrorManager.create([], { extraAttributes })
       * ```
       */
      extraAttributes?: IdentifierSchemaAttributes[];

      /**
       * Overrides for the mark.
       */
      markOverride?: Record<string, MarkSpecOverride>;

      /**
       * Overrides for the nodes.
       */
      nodeOverride?: Record<string, NodeSpecOverride>;

      /**
       * Perhaps you don't need extra attributes at all in the editor. This
       * allows you to disable extra attributes when set to true.
       *
       * @defaultValue undefined
       */
      disableExtraAttributes?: boolean;

      /**
       * Setting this to a value will override the default behaviour of the
       * `RemirrorManager`. It overrides the created schema and ignores the
       * specs created by all extensions within your editor.
       *
       * @remarks
       *
       * This is an advanced option and should only be used in cases where there
       * is a deeper understanding of `Prosemirror`. By setting this, please
       * note that a lot of functionality just won't work which is powered by
       * the `extraAttributes`.
       */
      schema?: EditorSchema;

      /**
       * The name of the default block node. This node will be given a higher
       * priority when being added to the schema.
       *
       * By default this is undefined and the default block node is assigned
       * based on the extension priorities.
       *
       * @defaultValue undefined
       */
      defaultBlockNode?: string;
    }

    interface ManagerStore<Extension extends AnyExtension> {
      /**
       * The nodes to place on the schema.
       */
      nodes: Record<
        GetNodeNameUnion<Extension> extends never ? string : GetNodeNameUnion<Extension>,
        NodeExtensionSpec
      >;

      /**
       * The marks to be added to the schema.
       */
      marks: Record<
        GetMarkNameUnion<Extension> extends never ? string : GetMarkNameUnion<Extension>,
        MarkExtensionSpec
      >;

      /**
       * The schema created by this extension manager.
       */
      schema: EditorSchema;

      /**
       * The name of the default block node. This is used by all internal
       * extension when toggling block nodes. It can also be used in other
       * cases.
       *
       * This can be updated via the manager settings when first creating the
       * editor.
       *
       * @defaultValue 'paragraph'
       */
      defaultBlockNode: string;
    }

    interface MarkExtension {
      /**
       * Provides access to the `MarkExtensionSpec`.
       */
      spec: MarkExtensionSpec;
    }

    interface NodeExtension {
      /**
       * Provides access to the `NodeExtensionSpec`.
       */
      spec: NodeExtensionSpec;
    }

    interface ExtensionStore {
      /**
       * The Prosemirror schema being used for the current editor.
       *
       * @remarks
       *
       * The value is created when the manager initializes. So it can be used in
       * `createCommands`, `createHelpers`, `createKeymap` and most of the
       * creator methods.
       */
      schema: EditorSchema;
    }

    interface StaticExtensionOptions {
      /**
       * When true will disable extra attributes for all instances of this
       * extension.
       *
       * @defaultValue false
       */
      readonly disableExtraAttributes?: boolean;
    }

    interface AllExtensions {
      schema: SchemaExtension;
    }
  }
}