/src/__mocks__/styleMock.ts',
},
- transformIgnorePatterns: ['node_modules/(?!(url-join|array-move|ol)/)'],
+ transformIgnorePatterns: [],
setupFiles: ['./test/setupEnv.js'],
};
diff --git a/core/package.json b/core/package.json
index 9968187c..82caaa39 100644
--- a/core/package.json
+++ b/core/package.json
@@ -25,6 +25,7 @@
"prepublishOnly": "yarn build",
"start": "run-s clean develop",
"test": "cross-env NODE_ENV=test jest",
+ "test:ci": "cross-env NODE_ENV=test jest --maxWorkers=2",
"type-check": "tsc --watch"
},
"main": "dist/static-cms-core.js",
diff --git a/core/src/__mocks__/@udecode/plate.ts b/core/src/__mocks__/@udecode/plate.ts
new file mode 100644
index 00000000..b5c9da95
--- /dev/null
+++ b/core/src/__mocks__/@udecode/plate.ts
@@ -0,0 +1,45 @@
+import {
+ ELEMENT_BLOCKQUOTE,
+ ELEMENT_CODE_BLOCK,
+ ELEMENT_H1,
+ ELEMENT_H2,
+ ELEMENT_H3,
+ ELEMENT_H4,
+ ELEMENT_H5,
+ ELEMENT_H6,
+ ELEMENT_IMAGE,
+ ELEMENT_LI,
+ ELEMENT_LIC,
+ ELEMENT_LINK,
+ ELEMENT_OL,
+ ELEMENT_PARAGRAPH,
+ ELEMENT_TABLE,
+ ELEMENT_TD,
+ ELEMENT_TH,
+ ELEMENT_TR,
+ ELEMENT_UL,
+} from '@udecode/plate';
+
+export {
+ ELEMENT_BLOCKQUOTE,
+ ELEMENT_CODE_BLOCK,
+ ELEMENT_H1,
+ ELEMENT_H2,
+ ELEMENT_H3,
+ ELEMENT_H4,
+ ELEMENT_H5,
+ ELEMENT_H6,
+ ELEMENT_IMAGE,
+ ELEMENT_LI,
+ ELEMENT_LIC,
+ ELEMENT_LINK,
+ ELEMENT_OL,
+ ELEMENT_PARAGRAPH,
+ ELEMENT_TABLE,
+ ELEMENT_TD,
+ ELEMENT_TH,
+ ELEMENT_TR,
+ ELEMENT_UL,
+};
+
+export default {};
diff --git a/core/src/extensions.ts b/core/src/extensions.ts
index 10887345..7498f813 100644
--- a/core/src/extensions.ts
+++ b/core/src/extensions.ts
@@ -18,6 +18,7 @@ import {
ListWidget,
MapWidget,
MarkdownWidget,
+ MdxWidget,
NumberWidget,
ObjectWidget,
RelationWidget,
@@ -44,6 +45,7 @@ export default function addExtensions() {
ListWidget(),
MapWidget(),
MarkdownWidget(),
+ MdxWidget(),
NumberWidget(),
ObjectWidget(),
RelationWidget(),
diff --git a/core/src/interface.ts b/core/src/interface.ts
index f0d17b07..dfee55d8 100644
--- a/core/src/interface.ts
+++ b/core/src/interface.ts
@@ -889,6 +889,26 @@ export interface MarkdownEditorOptions {
plugins?: MarkdownPluginFactory[];
}
+export type ShortcodeControlProps = P & {
+ onChange: (props: P) => void;
+ controlProps: WidgetControlProps;
+};
+
+export type ShortcodePreviewProps = P & {
+ previewProps: WidgetPreviewProps;
+};
+
+export interface ShortcodeConfig {
+ label?: string;
+ openTag: string;
+ closeTag: string;
+ separator: string;
+ toProps?: (args: string[]) => P;
+ toArgs?: (props: P) => string[];
+ control: ComponentType;
+ preview: ComponentType;
+}
+
export enum CollectionType {
FOLDER,
FILES,
diff --git a/core/src/lib/registry.ts b/core/src/lib/registry.ts
index 6678704e..1eb975e0 100644
--- a/core/src/lib/registry.ts
+++ b/core/src/lib/registry.ts
@@ -14,11 +14,11 @@ import type {
EventListener,
Field,
LocalePhrasesRoot,
- MarkdownEditorOptions,
MediaLibraryExternalLibrary,
MediaLibraryOptions,
PreviewStyle,
PreviewStyleOptions,
+ ShortcodeConfig,
TemplatePreviewComponent,
UnknownField,
Widget,
@@ -48,7 +48,7 @@ interface Registry {
previewStyles: PreviewStyle[];
/** Markdown editor */
- markdownEditorConfig: MarkdownEditorOptions;
+ shortcodes: Record;
}
/**
@@ -65,7 +65,7 @@ const registry: Registry = {
locales: {},
eventHandlers,
previewStyles: [],
- markdownEditorConfig: {},
+ shortcodes: {},
};
export default {
@@ -93,6 +93,9 @@ export default {
getAdditionalLinks,
registerPreviewStyle,
getPreviewStyles,
+ registerShortcode,
+ getShortcode,
+ getShortcodes,
};
/**
@@ -133,7 +136,7 @@ export function registerWidget(
options?: WidgetOptions,
): void;
export function registerWidget(
- name: string | WidgetParam | WidgetParam[],
+ nameOrWidgetOrWidgets: string | WidgetParam | WidgetParam[],
control?: string | Widget['control'],
preview?: Widget['preview'],
{
@@ -143,22 +146,22 @@ export function registerWidget(
getDefaultValue,
}: WidgetOptions = {},
): void {
- if (Array.isArray(name)) {
- name.forEach(widget => {
+ if (Array.isArray(nameOrWidgetOrWidgets)) {
+ nameOrWidgetOrWidgets.forEach(widget => {
if (typeof widget !== 'object') {
console.error(`Cannot register widget: ${widget}`);
} else {
registerWidget(widget);
}
});
- } else if (typeof name === 'string') {
+ } else if (typeof nameOrWidgetOrWidgets === 'string') {
// A registered widget control can be reused by a new widget, allowing
// multiple copies with different previews.
const newControl = (
typeof control === 'string' ? registry.widgets[control]?.control : control
) as Widget['control'];
if (newControl) {
- registry.widgets[name] = {
+ registry.widgets[nameOrWidgetOrWidgets] = {
control: newControl,
preview: preview as Widget['preview'],
validator: validator as Widget['validator'],
@@ -167,7 +170,7 @@ export function registerWidget(
schema,
};
}
- } else if (typeof name === 'object') {
+ } else if (typeof nameOrWidgetOrWidgets === 'object') {
const {
name: widgetName,
controlComponent: control,
@@ -178,7 +181,7 @@ export function registerWidget(
getDefaultValue,
schema,
} = {},
- } = name;
+ } = nameOrWidgetOrWidgets;
if (registry.widgets[widgetName]) {
console.warn(oneLine`
Multiple widgets registered with name "${widgetName}". Only the last widget registered with
@@ -369,12 +372,20 @@ export function getAdditionalLink(id: string): AdditionalLink | undefined {
}
/**
- * Markdown editor options
+ * Markdown editor shortcodes
*/
-export function setMarkdownEditorOptions(options: MarkdownEditorOptions) {
- registry.markdownEditorConfig = options;
+export function registerShortcode(name: string, config: ShortcodeConfig) {
+ if (registry.backends[name]) {
+ console.error(`Shortcode [${name}] already registered. Please choose a different name.`);
+ return;
+ }
+ registry.shortcodes[name] = config;
}
-export function getMarkdownEditorOptions(): MarkdownEditorOptions {
- return registry.markdownEditorConfig;
+export function getShortcode(name: string): ShortcodeConfig {
+ return registry.shortcodes[name];
+}
+
+export function getShortcodes(): Record {
+ return registry.shortcodes;
}
diff --git a/core/src/widgets/index.tsx b/core/src/widgets/index.tsx
index 43b11808..53b2a843 100644
--- a/core/src/widgets/index.tsx
+++ b/core/src/widgets/index.tsx
@@ -16,6 +16,8 @@ export * from './map';
export { default as MapWidget } from './map';
export * from './markdown';
export { default as MarkdownWidget } from './markdown';
+export * from './mdx';
+export { default as MdxWidget } from './mdx';
export * from './number';
export { default as NumberWidget } from './number';
export * from './object';
diff --git a/core/src/widgets/markdown/MarkdownControl.tsx b/core/src/widgets/markdown/MarkdownControl.tsx
deleted file mode 100644
index 894c36d2..00000000
--- a/core/src/widgets/markdown/MarkdownControl.tsx
+++ /dev/null
@@ -1,113 +0,0 @@
-import { styled } from '@mui/material/styles';
-import React, { useCallback, useMemo, useState } from 'react';
-
-import FieldLabel from '@staticcms/core/components/UI/FieldLabel';
-import Outline from '@staticcms/core/components/UI/Outline';
-import useDebounce from '../../lib/hooks/useDebounce';
-import useMarkdownToSlate from './plate/hooks/useMarkdownToSlate';
-import PlateEditor from './plate/PlateEditor';
-import serialize from './plate/serialization/serializerMarkdown';
-
-import type { MarkdownField, WidgetControlProps } from '@staticcms/core/interface';
-import type { FC } from 'react';
-import type { MdValue } from './plate/plateTypes';
-import type { BlockType, LeafType } from './plate/serialization/slate/ast-types';
-
-const StyledEditorWrapper = styled('div')`
- position: relative;
- width: 100%;
-
- .toastui-editor-main .toastui-editor-md-vertical-style .toastui-editor {
- width: 100%;
- }
-
- .toastui-editor-main .toastui-editor-md-splitter {
- display: none;
- }
-
- .toastui-editor-md-preview {
- display: none;
- }
-
- .toastui-editor-defaultUI {
- border: none;
- }
-`;
-
-const MarkdownControl: FC> = ({
- label,
- value,
- onChange,
- hasErrors,
- collection,
- entry,
- field,
-}) => {
- const [internalValue, setInternalValue] = useState(value ?? '');
- const [hasFocus, setHasFocus] = useState(false);
- const debouncedFocus = useDebounce(hasFocus, 150);
-
- const handleOnFocus = useCallback(() => {
- setHasFocus(true);
- }, []);
-
- const handleOnBlur = useCallback(() => {
- setHasFocus(false);
- }, []);
-
- const handleOnChange = useCallback(
- (slateValue: MdValue) => {
- const newValue = slateValue.map(v => serialize(v as BlockType | LeafType)).join('\n');
- // console.log('[Plate] slateValue', slateValue, 'newMarkdownValue', newValue);
- if (newValue !== internalValue) {
- setInternalValue(newValue);
- onChange(newValue);
- }
- },
- [internalValue, onChange],
- );
-
- const handleLabelClick = useCallback(() => {
- // editorRef.current?.getInstance().focus();
- }, []);
-
- const [slateValue, loaded] = useMarkdownToSlate(internalValue);
-
- // console.log('[Plate] slateValue', slateValue);
-
- return useMemo(
- () => (
-
-
- {label}
-
- {loaded ? (
-
- ) : null}
-
-
- ),
- // eslint-disable-next-line react-hooks/exhaustive-deps
- [hasErrors, hasFocus, label, loaded, slateValue],
- );
-};
-
-export default MarkdownControl;
diff --git a/core/src/widgets/markdown/MarkdownPreview.tsx b/core/src/widgets/markdown/MarkdownPreview.tsx
index b82fe833..08de9525 100644
--- a/core/src/widgets/markdown/MarkdownPreview.tsx
+++ b/core/src/widgets/markdown/MarkdownPreview.tsx
@@ -3,10 +3,13 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { VFileMessage } from 'vfile-message';
import WidgetPreviewContainer from '@staticcms/core/components/UI/WidgetPreviewContainer';
+import { getShortcodes } from '../../lib/registry';
+import withShortcodeMdxComponent from './mdx/withShortcodeMdxComponent';
import useMdx from './plate/hooks/useMdx';
+import { processShortcodeConfigToMdx } from './plate/serialization/slate/processShortcodeConfig';
-import type { FC } from 'react';
import type { MarkdownField, WidgetPreviewProps } from '@staticcms/core/interface';
+import type { FC } from 'react';
interface FallbackComponentProps {
error: string;
@@ -22,18 +25,23 @@ function FallbackComponent({ error }: FallbackComponentProps) {
);
}
-const MarkdownPreview: FC> = ({ value }) => {
- useEffect(() => {
- // viewer.current?.getInstance().setMarkdown(value ?? '');
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [value]);
+const MarkdownPreview: FC> = previewProps => {
+ const { value } = previewProps;
+
+ const components = useMemo(
+ () => ({
+ Shortcode: withShortcodeMdxComponent({ previewProps }),
+ }),
+ [previewProps],
+ );
const [state, setValue] = useMdx(value ?? '');
- const [prevValue, setPrevValue] = useState(value);
+ const [prevValue, setPrevValue] = useState('');
useEffect(() => {
if (prevValue !== value) {
- setPrevValue(value ?? '');
- setValue(value ?? '');
+ const parsedValue = processShortcodeConfigToMdx(getShortcodes(), value ?? '');
+ setPrevValue(parsedValue);
+ setValue(parsedValue);
}
}, [prevValue, setValue, value]);
@@ -50,8 +58,6 @@ const MarkdownPreview: FC> = ({ value
}
}, [state.file]);
- const components = useMemo(() => ({}), []);
-
return useMemo(() => {
if (!value) {
return null;
diff --git a/core/src/widgets/markdown/index.ts b/core/src/widgets/markdown/index.ts
index e05fd927..26a565ca 100644
--- a/core/src/widgets/markdown/index.ts
+++ b/core/src/widgets/markdown/index.ts
@@ -1,9 +1,11 @@
-import controlComponent from './MarkdownControl';
+import withMarkdownControl from './withMarkdownControl';
import previewComponent from './MarkdownPreview';
import schema from './schema';
import type { MarkdownField, WidgetParam } from '@staticcms/core/interface';
+const controlComponent = withMarkdownControl({ useMdx: false });
+
const MarkdownWidget = (): WidgetParam => {
return {
name: 'markdown',
diff --git a/core/src/widgets/markdown/mdx/index.ts b/core/src/widgets/markdown/mdx/index.ts
new file mode 100644
index 00000000..644b01f6
--- /dev/null
+++ b/core/src/widgets/markdown/mdx/index.ts
@@ -0,0 +1,2 @@
+export * from './withShortcodeMdxComponent';
+export { default as withShortcodeElement } from './withShortcodeMdxComponent';
diff --git a/core/src/widgets/markdown/mdx/withShortcodeMdxComponent.tsx b/core/src/widgets/markdown/mdx/withShortcodeMdxComponent.tsx
new file mode 100644
index 00000000..5c134567
--- /dev/null
+++ b/core/src/widgets/markdown/mdx/withShortcodeMdxComponent.tsx
@@ -0,0 +1,36 @@
+import React, { useMemo } from 'react';
+
+import { getShortcode } from '../../../lib/registry';
+
+import type { MarkdownField, WidgetPreviewProps } from '@staticcms/core/interface';
+import type { FC } from 'react';
+
+export interface WithShortcodeMdxComponentProps {
+ previewProps: WidgetPreviewProps;
+}
+
+interface ShortcodeMdxComponentProps {
+ shortcode: string;
+ args: string[];
+}
+
+const withShortcodeMdxComponent = ({ previewProps }: WithShortcodeMdxComponentProps) => {
+ const ShortcodeMdxComponent: FC = ({ shortcode, args }) => {
+ const config = useMemo(() => getShortcode(shortcode), [shortcode]);
+
+ const [ShortcodePreview, props] = useMemo(() => {
+ if (!config) {
+ return [null, {}];
+ }
+
+ const props = config.toProps ? config.toProps(args) : {};
+ return [config.preview, props];
+ }, [config, args]);
+
+ return ShortcodePreview ? : null;
+ };
+
+ return ShortcodeMdxComponent;
+};
+
+export default withShortcodeMdxComponent;
diff --git a/core/src/widgets/markdown/plate/PlateEditor.tsx b/core/src/widgets/markdown/plate/PlateEditor.tsx
index 9981a43c..34bd689e 100644
--- a/core/src/widgets/markdown/plate/PlateEditor.tsx
+++ b/core/src/widgets/markdown/plate/PlateEditor.tsx
@@ -51,11 +51,12 @@ import {
withProps,
} from '@udecode/plate';
import { StyledLeaf } from '@udecode/plate-styled-components';
-import React, { useCallback, useMemo, useRef, useState } from 'react';
+import React, { useMemo, useRef } from 'react';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import useUUID from '@staticcms/core/lib/hooks/useUUID';
+import { withShortcodeElement } from './components';
import { BalloonToolbar } from './components/balloon-toolbar';
import { BlockquoteElement } from './components/nodes/blockquote';
import { CodeBlockElement } from './components/nodes/code-block';
@@ -75,11 +76,11 @@ import {
OrderedListElement,
UnorderedListElement,
} from './components/nodes/list';
-import Paragraph from './components/nodes/paragraph/Paragraph';
+import ParagraphElement from './components/nodes/paragraph/ParagraphElement';
import { TableCellElement, TableElement, TableRowElement } from './components/nodes/table';
import { Toolbar } from './components/toolbar';
import editableProps from './editableProps';
-import { createMdPlugins } from './plateTypes';
+import { createMdPlugins, ELEMENT_SHORTCODE } from './plateTypes';
import { alignPlugin } from './plugins/align';
import { autoformatPlugin } from './plugins/autoformat';
import { createCodeBlockPlugin } from './plugins/code-block';
@@ -87,12 +88,18 @@ import { CursorOverlayContainer } from './plugins/cursor-overlay';
import { exitBreakPlugin } from './plugins/exit-break';
import { createListPlugin } from './plugins/list';
import { resetBlockTypePlugin } from './plugins/reset-node';
+import { createShortcodePlugin } from './plugins/shortcode';
import { softBreakPlugin } from './plugins/soft-break';
import { createTablePlugin } from './plugins/table';
import { trailingBlockPlugin } from './plugins/trailing-block';
-import type { Collection, Entry, MarkdownField } from '@staticcms/core/interface';
-import type { AutoformatPlugin } from '@udecode/plate';
+import type {
+ Collection,
+ Entry,
+ MarkdownField,
+ WidgetControlProps,
+} from '@staticcms/core/interface';
+import type { AnyObject, AutoformatPlugin, PlatePlugin } from '@udecode/plate';
import type { CSSProperties, FC } from 'react';
import type { MdEditor, MdValue } from './plateTypes';
@@ -112,6 +119,8 @@ export interface PlateEditorProps {
collection: Collection;
entry: Entry;
field: MarkdownField;
+ useMdx: boolean;
+ controlProps: WidgetControlProps;
onChange: (value: MdValue) => void;
onFocus: () => void;
onBlur: () => void;
@@ -122,6 +131,8 @@ const PlateEditor: FC = ({
collection,
entry,
field,
+ useMdx,
+ controlProps,
onChange,
onFocus,
onBlur,
@@ -130,15 +141,15 @@ const PlateEditor: FC = ({
const editorContainerRef = useRef(null);
const innerEditorContainerRef = useRef(null);
- const components = useMemo(
- () => ({
+ const components = useMemo(() => {
+ const baseComponents = {
[ELEMENT_H1]: Heading1,
[ELEMENT_H2]: Heading2,
[ELEMENT_H3]: Heading3,
[ELEMENT_H4]: Heading4,
[ELEMENT_H5]: Heading5,
[ELEMENT_H6]: Heading6,
- [ELEMENT_PARAGRAPH]: Paragraph,
+ [ELEMENT_PARAGRAPH]: ParagraphElement,
[ELEMENT_TABLE]: TableElement,
[ELEMENT_TR]: TableRowElement,
[ELEMENT_TH]: TableCellElement,
@@ -161,81 +172,92 @@ const PlateEditor: FC = ({
[ELEMENT_UL]: UnorderedListElement,
[ELEMENT_LI]: ListItemElement,
[ELEMENT_LIC]: ListItemContentElement,
+ [ELEMENT_SHORTCODE]: withShortcodeElement({ controlProps }),
[MARK_BOLD]: withProps(StyledLeaf, { as: 'strong' }),
[MARK_ITALIC]: withProps(StyledLeaf, { as: 'em' }),
[MARK_STRIKETHROUGH]: withProps(StyledLeaf, { as: 's' }),
- [MARK_SUBSCRIPT]: withProps(StyledLeaf, { as: 'sub' }),
- [MARK_SUPERSCRIPT]: withProps(StyledLeaf, { as: 'sup' }),
- [MARK_UNDERLINE]: withProps(StyledLeaf, { as: 'u' }),
- }),
- [collection, entry, field],
- );
+ };
- const [hasEditorFocus, setHasEditorFocus] = useState(false);
+ if (useMdx) {
+ // MDX Widget
+ return {
+ ...baseComponents,
+ [MARK_SUBSCRIPT]: withProps(StyledLeaf, { as: 'sub' }),
+ [MARK_SUPERSCRIPT]: withProps(StyledLeaf, { as: 'sup' }),
+ [MARK_UNDERLINE]: withProps(StyledLeaf, { as: 'u' }),
+ };
+ }
- const handleOnFocus = useCallback(() => {
- setHasEditorFocus(true);
- onFocus();
- }, [onFocus]);
+ // Markdown widget
+ return {
+ ...baseComponents,
+ [ELEMENT_SHORTCODE]: withShortcodeElement({ controlProps }),
+ };
+ }, [collection, controlProps, entry, field, useMdx]);
- const handleOnBlur = useCallback(() => {
- setHasEditorFocus(false);
- onBlur();
- }, [onBlur]);
+ const plugins = useMemo(() => {
+ const basePlugins: PlatePlugin[] = [
+ createParagraphPlugin(),
+ createBlockquotePlugin(),
+ createTodoListPlugin(),
+ createHeadingPlugin(),
+ createImagePlugin(),
+ // createHorizontalRulePlugin(),
+ createLinkPlugin(),
+ createListPlugin(),
+ createTablePlugin(),
+ // createMediaEmbedPlugin(),
+ createCodeBlockPlugin(),
+ createBoldPlugin(),
+ createCodePlugin(),
+ createItalicPlugin(),
+ // createHighlightPlugin(),
+ createStrikethroughPlugin(),
+ // createFontSizePlugin(),
+ // createKbdPlugin(),
+ // createNodeIdPlugin(),
+ // createDndPlugin({ options: { enableScroller: true } }),
+ // dragOverCursorPlugin,
+ // createIndentPlugin(indentPlugin),
+ createAutoformatPlugin, MdValue, MdEditor>(
+ autoformatPlugin,
+ ),
+ createResetNodePlugin(resetBlockTypePlugin),
+ createSoftBreakPlugin(softBreakPlugin),
+ createExitBreakPlugin(exitBreakPlugin),
+ createTrailingBlockPlugin(trailingBlockPlugin),
+ // createSelectOnBackspacePlugin(selectOnBackspacePlugin),
+ // createComboboxPlugin(),
+ // createMentionPlugin(),
+ // createDeserializeMdPlugin(),
+ // createDeserializeCsvPlugin(),
+ // createDeserializeDocxPlugin(),
+ // createJuicePlugin() as MdPlatePlugin,
+ ];
- const plugins = useMemo(
- () =>
- createMdPlugins(
+ if (useMdx) {
+ // MDX Widget
+ return createMdPlugins(
[
- createParagraphPlugin(),
- createBlockquotePlugin(),
- createTodoListPlugin(),
- createHeadingPlugin(),
- createImagePlugin(),
- // createHorizontalRulePlugin(),
- createLinkPlugin(),
- createListPlugin(),
- createTablePlugin(),
- // createMediaEmbedPlugin(),
- createCodeBlockPlugin(),
- createAlignPlugin(alignPlugin),
- createBoldPlugin(),
- createCodePlugin(),
- createItalicPlugin(),
- // createHighlightPlugin(),
- createUnderlinePlugin(),
- createStrikethroughPlugin(),
- createSubscriptPlugin(),
- createSuperscriptPlugin(),
+ ...basePlugins,
createFontColorPlugin(),
createFontBackgroundColorPlugin(),
- // createFontSizePlugin(),
- // createKbdPlugin(),
- // createNodeIdPlugin(),
- // createDndPlugin({ options: { enableScroller: true } }),
- // dragOverCursorPlugin,
- // createIndentPlugin(indentPlugin),
- createAutoformatPlugin, MdValue, MdEditor>(
- autoformatPlugin,
- ),
- createResetNodePlugin(resetBlockTypePlugin),
- createSoftBreakPlugin(softBreakPlugin),
- createExitBreakPlugin(exitBreakPlugin),
- createTrailingBlockPlugin(trailingBlockPlugin),
- // createSelectOnBackspacePlugin(selectOnBackspacePlugin),
- // createComboboxPlugin(),
- // createMentionPlugin(),
- // createDeserializeMdPlugin(),
- // createDeserializeCsvPlugin(),
- // createDeserializeDocxPlugin(),
- // createJuicePlugin() as MdPlatePlugin,
+ createSubscriptPlugin(),
+ createSuperscriptPlugin(),
+ createUnderlinePlugin(),
+ createAlignPlugin(alignPlugin),
],
{
components,
},
- ),
- [components],
- );
+ );
+ }
+
+ // Markdown Widget
+ return createMdPlugins([...basePlugins, createShortcodePlugin()], {
+ components,
+ });
+ }, [components, useMdx]);
const id = useUUID();
@@ -253,6 +275,7 @@ const PlateEditor: FC = ({
= ({
id={id}
editableProps={{
...editableProps,
- onFocus: handleOnFocus,
- onBlur: handleOnBlur,
+ onFocus,
+ onBlur,
}}
>
= ({
>
= ({
),
// eslint-disable-next-line react-hooks/exhaustive-deps
- [collection, field, handleOnBlur, handleOnFocus, initialValue, onChange, plugins],
+ [collection, field, onBlur, onFocus, initialValue, onChange, plugins],
);
};
diff --git a/core/src/widgets/markdown/plate/components/balloon-toolbar/BalloonToolbar.tsx b/core/src/widgets/markdown/plate/components/balloon-toolbar/BalloonToolbar.tsx
index c25368aa..64fe7ee2 100644
--- a/core/src/widgets/markdown/plate/components/balloon-toolbar/BalloonToolbar.tsx
+++ b/core/src/widgets/markdown/plate/components/balloon-toolbar/BalloonToolbar.tsx
@@ -16,6 +16,7 @@ import {
someNode,
usePlateSelection,
} from '@udecode/plate';
+import { useFocused } from 'slate-react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import useDebounce from '@staticcms/core/lib/hooks/useDebounce';
@@ -25,6 +26,7 @@ import BasicElementToolbarButtons from '../buttons/BasicElementToolbarButtons';
import BasicMarkToolbarButtons from '../buttons/BasicMarkToolbarButtons';
import MediaToolbarButtons from '../buttons/MediaToolbarButtons';
import TableToolbarButtons from '../buttons/TableToolbarButtons';
+import ShortcodeToolbarButton from '../buttons/ShortcodeToolbarButton';
import type { Collection, Entry, MarkdownField } from '@staticcms/core/interface';
import type { ClientRectObject } from '@udecode/plate';
@@ -54,20 +56,21 @@ const StyledDivider = styled('div')(
);
export interface BalloonToolbarProps {
+ useMdx: boolean;
containerRef: HTMLElement | null;
- hasEditorFocus: boolean;
collection: Collection;
field: MarkdownField;
entry: Entry;
}
const BalloonToolbar: FC = ({
+ useMdx,
containerRef,
- hasEditorFocus,
collection,
field,
entry,
}) => {
+ const hasEditorFocus = useFocused();
const editor = useMdPlateEditorState();
const selection = usePlateSelection();
const [hasFocus, setHasFocus] = useState(false);
@@ -126,9 +129,10 @@ const BalloonToolbar: FC = ({
return [];
}
+ // Selected text buttons
if (selectionText && selectionExpanded) {
return [
- ,
+ ,
= ({
].filter(Boolean);
}
+ // Empty paragraph, not first line
if (
editor.children.length > 1 &&
node &&
@@ -164,7 +169,7 @@ const BalloonToolbar: FC = ({
parent[0].children.length === 1
) {
return [
- ,
+ ,
= ({
entry={entry}
onMediaToggle={setMediaOpen}
/>,
+ !useMdx ? : null,
];
}
}
@@ -186,18 +192,20 @@ const BalloonToolbar: FC = ({
return [];
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
- collection,
- editor,
- field,
+ mediaOpen,
+ debouncedEditorFocus,
hasFocus,
debouncedHasFocus,
- debouncedEditorFocus,
- isInTableCell,
- mediaOpen,
- node,
selection,
- selectionExpanded,
+ editor,
selectionText,
+ selectionExpanded,
+ node,
+ useMdx,
+ isInTableCell,
+ containerRef,
+ collection,
+ field,
]);
const [prevSelectionBoundingClientRect, setPrevSelectionBoundingClientRect] = useState(
@@ -243,7 +251,7 @@ const BalloonToolbar: FC = ({
>
{(groups.length > 0 ? groups : debouncedGroups).map((group, index) => [
- index !== 0 ? : null,
+ index !== 0 ? : null,
group,
])}
diff --git a/core/src/widgets/markdown/plate/components/buttons/AlignToolbarButtons.tsx b/core/src/widgets/markdown/plate/components/buttons/AlignToolbarButtons.tsx
index 6f355ae5..7494b909 100644
--- a/core/src/widgets/markdown/plate/components/buttons/AlignToolbarButtons.tsx
+++ b/core/src/widgets/markdown/plate/components/buttons/AlignToolbarButtons.tsx
@@ -10,9 +10,24 @@ import type { FC } from 'react';
const AlignToolbarButtons: FC = () => {
return (
<>
- } />
- } />
- } />
+ }
+ />
+ }
+ />
+ }
+ />
>
);
};
diff --git a/core/src/widgets/markdown/plate/components/buttons/BasicMarkToolbarButtons.tsx b/core/src/widgets/markdown/plate/components/buttons/BasicMarkToolbarButtons.tsx
index e38fa663..ded15fbd 100644
--- a/core/src/widgets/markdown/plate/components/buttons/BasicMarkToolbarButtons.tsx
+++ b/core/src/widgets/markdown/plate/components/buttons/BasicMarkToolbarButtons.tsx
@@ -22,33 +22,42 @@ import type { FC } from 'react';
export interface BasicMarkToolbarButtonsProps {
extended?: boolean;
+ useMdx: boolean;
}
-const BasicMarkToolbarButtons: FC = ({ extended = false }) => {
+const BasicMarkToolbarButtons: FC = ({
+ extended = false,
+ useMdx,
+}) => {
return (
<>
} />
} />
- }
- />
+ {useMdx ? (
+ }
+ />
+ ) : null}
}
/>
} />
- {extended ? (
+ {useMdx && extended ? (
<>
}
/>
{
+ const editor = useMdPlateEditorState();
+ const [anchorEl, setAnchorEl] = useState(null);
+ const open = Boolean(anchorEl);
+ const handleClick = useCallback((_editor: MdEditor, event: MouseEvent) => {
+ setAnchorEl(event.currentTarget);
+ }, []);
+
+ const handleClose = useCallback(() => {
+ setAnchorEl(null);
+ }, []);
+
+ const configs = useMemo(() => getShortcodes(), []);
+
+ const handleShortcodeClick = useCallback(
+ (shortcode: string) => () => {
+ insertNodes(editor, {
+ type: ELEMENT_SHORTCODE,
+ shortcode,
+ args: [],
+ children: [{ text: '' }],
+ });
+ focusEditor(editor);
+ handleClose();
+ },
+ [editor, handleClose],
+ );
+
+ return (
+ <>
+ }
+ onClick={handleClick}
+ />
+
+ >
+ );
+};
+
+export default ShortcodeToolbarButton;
diff --git a/core/src/widgets/markdown/plate/components/nodes/common/MediaPopover.tsx b/core/src/widgets/markdown/plate/components/nodes/common/MediaPopover.tsx
index ec278cec..ef0789fb 100644
--- a/core/src/widgets/markdown/plate/components/nodes/common/MediaPopover.tsx
+++ b/core/src/widgets/markdown/plate/components/nodes/common/MediaPopover.tsx
@@ -154,6 +154,7 @@ const MediaPopover = ({
useEffect(() => {
if (
+ anchorEl &&
!debouncedHasEditorFocus &&
!hasEditorFocus &&
!hasFocus &&
@@ -163,6 +164,7 @@ const MediaPopover = ({
handleClose(false);
}
}, [
+ anchorEl,
debouncedHasEditorFocus,
debouncedHasFocus,
handleClose,
diff --git a/core/src/widgets/markdown/plate/components/nodes/image/withImageElement.tsx b/core/src/widgets/markdown/plate/components/nodes/image/withImageElement.tsx
index 64ca38a8..77e9b02d 100644
--- a/core/src/widgets/markdown/plate/components/nodes/image/withImageElement.tsx
+++ b/core/src/widgets/markdown/plate/components/nodes/image/withImageElement.tsx
@@ -117,7 +117,7 @@ const withImageElement = ({ containerRef, collection, entry, field }: WithImageE
}, [handleClose, hasEditorFocus, element, selection, editor, handleOpenPopover]);
return (
-
+
{children}
-
+
);
};
diff --git a/core/src/widgets/markdown/plate/components/nodes/index.ts b/core/src/widgets/markdown/plate/components/nodes/index.ts
index 90d5ccaa..3c882794 100644
--- a/core/src/widgets/markdown/plate/components/nodes/index.ts
+++ b/core/src/widgets/markdown/plate/components/nodes/index.ts
@@ -7,4 +7,5 @@ export * from './image';
export * from './link';
export * from './list';
export * from './paragraph';
+export * from './shortcode';
export * from './table';
diff --git a/core/src/widgets/markdown/plate/components/nodes/paragraph/Paragraph.tsx b/core/src/widgets/markdown/plate/components/nodes/paragraph/ParagraphElement.tsx
similarity index 71%
rename from core/src/widgets/markdown/plate/components/nodes/paragraph/Paragraph.tsx
rename to core/src/widgets/markdown/plate/components/nodes/paragraph/ParagraphElement.tsx
index e91ab020..c38c16cd 100644
--- a/core/src/widgets/markdown/plate/components/nodes/paragraph/Paragraph.tsx
+++ b/core/src/widgets/markdown/plate/components/nodes/paragraph/ParagraphElement.tsx
@@ -4,11 +4,11 @@ import type { MdParagraphElement, MdValue } from '@staticcms/markdown';
import type { PlateRenderElementProps } from '@udecode/plate';
import type { FC } from 'react';
-const Paragraph: FC> = ({
+const ParagraphElement: FC> = ({
children,
element: { align },
}) => {
return {children}
;
};
-export default Paragraph;
+export default ParagraphElement;
diff --git a/core/src/widgets/markdown/plate/components/nodes/paragraph/index.ts b/core/src/widgets/markdown/plate/components/nodes/paragraph/index.ts
index 5aa80d30..90234cf5 100644
--- a/core/src/widgets/markdown/plate/components/nodes/paragraph/index.ts
+++ b/core/src/widgets/markdown/plate/components/nodes/paragraph/index.ts
@@ -1,2 +1,2 @@
/* eslint-disable import/prefer-default-export */
-export { default as Paragraph } from './Paragraph';
+export { default as Paragraph } from './ParagraphElement';
diff --git a/core/src/widgets/markdown/plate/components/nodes/shortcode/index.ts b/core/src/widgets/markdown/plate/components/nodes/shortcode/index.ts
new file mode 100644
index 00000000..78b20381
--- /dev/null
+++ b/core/src/widgets/markdown/plate/components/nodes/shortcode/index.ts
@@ -0,0 +1,2 @@
+export * from './withShortcodeElement';
+export { default as withShortcodeElement } from './withShortcodeElement';
diff --git a/core/src/widgets/markdown/plate/components/nodes/shortcode/withShortcodeElement.tsx b/core/src/widgets/markdown/plate/components/nodes/shortcode/withShortcodeElement.tsx
new file mode 100644
index 00000000..63910107
--- /dev/null
+++ b/core/src/widgets/markdown/plate/components/nodes/shortcode/withShortcodeElement.tsx
@@ -0,0 +1,57 @@
+import { findNodePath, setNodes } from '@udecode/plate';
+import React, { useCallback, useMemo } from 'react';
+
+import { getShortcode } from '../../../../../../lib/registry';
+
+import type { MarkdownField, WidgetControlProps } from '@staticcms/core/interface';
+import type { MdShortcodeElement, MdValue } from '@staticcms/markdown';
+import type { PlateRenderElementProps } from '@udecode/plate';
+import type { FC } from 'react';
+
+export interface WithShortcodeElementProps {
+ controlProps: WidgetControlProps;
+}
+
+const withShortcodeElement = ({ controlProps }: WithShortcodeElementProps) => {
+ const ShortcodeElement: FC> = ({
+ element,
+ editor,
+ children,
+ }) => {
+ const config = useMemo(() => getShortcode(element.shortcode), [element.shortcode]);
+
+ const [ShortcodeControl, props] = useMemo(() => {
+ if (!config) {
+ return [null, {}];
+ }
+
+ const props = config.toProps ? config.toProps(element.args) : {};
+ return [config.control, props];
+ }, [config, element.args]);
+
+ const handleOnChange = useCallback(
+ (props: {}) => {
+ if (!config || !config.toArgs) {
+ return;
+ }
+
+ const path = findNodePath(editor, element);
+ path && setNodes(editor, { args: config.toArgs(props) }, { at: path });
+ },
+ [config, editor, element],
+ );
+
+ return (
+
+ {ShortcodeControl ? (
+
+ ) : null}
+ {children}
+
+ );
+ };
+
+ return ShortcodeElement;
+};
+
+export default withShortcodeElement;
diff --git a/core/src/widgets/markdown/plate/components/toolbar/Toolbar.tsx b/core/src/widgets/markdown/plate/components/toolbar/Toolbar.tsx
index 7059db90..bf48488e 100644
--- a/core/src/widgets/markdown/plate/components/toolbar/Toolbar.tsx
+++ b/core/src/widgets/markdown/plate/components/toolbar/Toolbar.tsx
@@ -7,6 +7,7 @@ import BasicMarkToolbarButtons from '../buttons/BasicMarkToolbarButtons';
import ColorToolbarButtons from '../buttons/ColorToolbarButtons';
import ListToolbarButtons from '../buttons/ListToolbarButtons';
import MediaToolbarButton from '../buttons/MediaToolbarButtons';
+import ShortcodeToolbarButton from '../buttons/ShortcodeToolbarButton';
import type { FC } from 'react';
import type { Collection, Entry, MarkdownField } from '@staticcms/core/interface';
@@ -42,32 +43,36 @@ const StyledDivider = styled('div')(
);
export interface ToolbarProps {
+ useMdx: boolean;
containerRef: HTMLElement | null;
collection: Collection;
field: MarkdownField;
entry: Entry;
}
-const Toolbar: FC = ({ containerRef, collection, field, entry }) => {
+const Toolbar: FC = ({ useMdx, containerRef, collection, field, entry }) => {
+ const groups = [
+ ,
+ ,
+ ,
+ useMdx ? : null,
+ useMdx ? : null,
+ ,
+ !useMdx ? : null,
+ ].filter(Boolean);
+
return (
-
-
-
-
-
-
-
-
-
-
-
+ {groups.map((group, index) => [
+ index !== 0 ? : null,
+ group,
+ ])}
);
};
diff --git a/core/src/widgets/markdown/plate/hooks/__tests__/useMarkdownToSlate.spec.ts b/core/src/widgets/markdown/plate/hooks/__tests__/useMarkdownToSlate.spec.ts
new file mode 100644
index 00000000..8d3c8e0c
--- /dev/null
+++ b/core/src/widgets/markdown/plate/hooks/__tests__/useMarkdownToSlate.spec.ts
@@ -0,0 +1,50 @@
+import {
+ deserializationOnlyTestData,
+ runSerializationTests,
+ testShortcodeConfigs as shortcodeConfigs,
+} from '../../tests-util/serializationTests.util';
+import { markdownToSlate } from '../useMarkdownToSlate';
+
+import type { SerializationTestData } from '../../tests-util/serializationTests.util';
+import type { UseMarkdownToSlateOptions } from '../useMarkdownToSlate';
+
+async function expectNodes(
+ markdown: string,
+ options: UseMarkdownToSlateOptions,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ children: any[],
+) {
+ expect(await markdownToSlate(markdown, options)).toEqual(children);
+}
+
+function sanitizeHtmlInMarkdown(markdown: string) {
+ return markdown
+ .replace('', '<\\/font>')
+ .replace('', '')
+ .replace('', '<\\/u>')
+ .replace('', '')
+ .replace('', '<\\/sub>')
+ .replace('', '')
+ .replace('', '<\\/sup>');
+}
+
+function testRunner(key: string, mode: 'markdown' | 'mdx' | 'both', data: SerializationTestData) {
+ it(`deserializes ${key}`, async () => {
+ if (mode === 'both') {
+ await expectNodes(data.markdown, { shortcodeConfigs, useMdx: false }, data.slate);
+ await expectNodes(data.markdown, { shortcodeConfigs, useMdx: true }, data.slate);
+ return;
+ }
+
+ await expectNodes(
+ mode === 'markdown' ? sanitizeHtmlInMarkdown(data.markdown) : data.markdown,
+ { shortcodeConfigs, useMdx: mode === 'mdx' },
+ data.slate,
+ );
+ });
+}
+
+describe('markdownToSlate', () => {
+ runSerializationTests(testRunner);
+ runSerializationTests(testRunner, deserializationOnlyTestData);
+});
diff --git a/core/src/widgets/markdown/plate/hooks/index.ts b/core/src/widgets/markdown/plate/hooks/index.ts
index 9207feff..fb7f2a33 100644
--- a/core/src/widgets/markdown/plate/hooks/index.ts
+++ b/core/src/widgets/markdown/plate/hooks/index.ts
@@ -1,3 +1,4 @@
+export * from './useMarkdownToSlate';
export { default as useMarkdownToSlate } from './useMarkdownToSlate';
export * from './useMdx';
export { default as useMdx } from './useMdx';
diff --git a/core/src/widgets/markdown/plate/hooks/useMarkdownToSlate.ts b/core/src/widgets/markdown/plate/hooks/useMarkdownToSlate.ts
new file mode 100644
index 00000000..a66952ba
--- /dev/null
+++ b/core/src/widgets/markdown/plate/hooks/useMarkdownToSlate.ts
@@ -0,0 +1,61 @@
+import { ELEMENT_PARAGRAPH } from '@udecode/plate';
+import { useEffect, useState } from 'react';
+import gfm from 'remark-gfm';
+import mdx from 'remark-mdx';
+import markdown from 'remark-parse';
+import { unified } from 'unified';
+
+import { getShortcodes } from '../../../../lib/registry';
+import toSlatePlugin from '../serialization/slate/toSlatePlugin';
+
+import type { ShortcodeConfig } from '../../../../interface';
+import type { MdValue } from '../plateTypes';
+
+export interface UseMarkdownToSlateOptions {
+ shortcodeConfigs?: Record;
+ useMdx: boolean;
+}
+
+export const markdownToSlate = async (
+ markdownValue: string,
+ { useMdx, shortcodeConfigs }: UseMarkdownToSlateOptions,
+) => {
+ return new Promise(resolve => {
+ unified()
+ .use(markdown)
+ .use(gfm)
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
+ .use(useMdx ? mdx : () => {})
+ .use(toSlatePlugin({ shortcodeConfigs: shortcodeConfigs ?? getShortcodes(), useMdx }))
+ .process(markdownValue, (err, file) => {
+ if (err) {
+ console.error(err);
+ return;
+ }
+ resolve(file?.result as MdValue);
+ });
+ });
+};
+
+const useMarkdownToSlate = (
+ markdownValue: string,
+ options: UseMarkdownToSlateOptions,
+): [MdValue, boolean] => {
+ const [loaded, setLoaded] = useState(false);
+ const [slateValue, setSlateValue] = useState([]);
+
+ useEffect(() => {
+ markdownToSlate(markdownValue, options).then(value => {
+ setSlateValue(value);
+ setLoaded(true);
+ });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ return [
+ slateValue.length > 0 ? slateValue : [{ type: ELEMENT_PARAGRAPH, children: [{ text: '' }] }],
+ loaded,
+ ];
+};
+
+export default useMarkdownToSlate;
diff --git a/core/src/widgets/markdown/plate/hooks/useMarkdownToSlate.tsx b/core/src/widgets/markdown/plate/hooks/useMarkdownToSlate.tsx
deleted file mode 100644
index 7727c5f3..00000000
--- a/core/src/widgets/markdown/plate/hooks/useMarkdownToSlate.tsx
+++ /dev/null
@@ -1,40 +0,0 @@
-import { ELEMENT_PARAGRAPH } from '@udecode/plate';
-import { useEffect, useState } from 'react';
-import gfm from 'remark-gfm';
-import mdx from 'remark-mdx';
-import markdown from 'remark-parse';
-import { unified } from 'unified';
-
-import toSlatePlugin from '../serialization/slate/toSlatePlugin';
-
-import type { MdValue } from '../plateTypes';
-
-const useMarkdownToSlate = (markdownValue: string): [MdValue, boolean] => {
- const [loaded, setLoaded] = useState(false);
- const [slateValue, setSlateValue] = useState([]);
-
- useEffect(() => {
- unified()
- .use(markdown)
- .use(gfm)
- .use(mdx)
- .use(toSlatePlugin)
- .process(markdownValue, (err, file) => {
- if (err) {
- console.error(err);
- return;
- }
- setSlateValue(file?.result as MdValue);
- setLoaded(true);
- });
-
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
-
- return [
- slateValue.length > 0 ? slateValue : [{ type: ELEMENT_PARAGRAPH, children: [{ text: '' }] }],
- loaded,
- ];
-};
-
-export default useMarkdownToSlate;
diff --git a/core/src/widgets/markdown/plate/hooks/useMdx.tsx b/core/src/widgets/markdown/plate/hooks/useMdx.tsx
index 9390cb3d..cd187b74 100644
--- a/core/src/widgets/markdown/plate/hooks/useMdx.tsx
+++ b/core/src/widgets/markdown/plate/hooks/useMdx.tsx
@@ -1,4 +1,5 @@
import { evaluate } from '@mdx-js/mdx';
+import * as provider from '@mdx-js/react';
import { useCallback, useEffect, useState } from 'react';
import * as runtime from 'react/jsx-runtime';
import remarkGfm from 'remark-gfm';
@@ -20,6 +21,7 @@ export default function useMdx(input: string): [UseMdxState, (value: string) =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const options: any = {
+ ...provider,
...runtime,
useDynamicImport: true,
remarkPlugins: [remarkGfm, flattenListItemParagraphs],
diff --git a/core/src/widgets/markdown/plate/plateTypes.ts b/core/src/widgets/markdown/plate/plateTypes.ts
index d73b6762..05b2a2e2 100644
--- a/core/src/widgets/markdown/plate/plateTypes.ts
+++ b/core/src/widgets/markdown/plate/plateTypes.ts
@@ -35,6 +35,7 @@ import type {
ELEMENT_HR,
ELEMENT_IMAGE,
ELEMENT_LI,
+ ELEMENT_LIC,
ELEMENT_LINK,
ELEMENT_MEDIA_EMBED,
ELEMENT_MENTION,
@@ -79,6 +80,8 @@ import type {
} from '@udecode/plate';
import type { CSSProperties } from 'styled-components';
+export const ELEMENT_SHORTCODE = 'shortcode' as const;
+
/**
* Text
*/
@@ -125,7 +128,12 @@ export interface MdMentionElement extends TMentionElement {
children: [EmptyText];
}
-export type MdInlineElement = MdLinkElement | MdMentionElement | MdMentionInputElement;
+export type MdInlineElement =
+ | MdImageElement
+ | MdLinkElement
+ | MdMentionElement
+ | MdMentionInputElement
+ | MdShortcodeElement;
export type MdInlineDescendant = MdInlineElement | RichText;
export type MdInlineChildren = MdInlineDescendant[];
@@ -165,6 +173,13 @@ export interface MdParagraphElement extends MdBlockElement {
align?: 'left' | 'center' | 'right';
}
+export interface MdShortcodeElement extends TElement {
+ type: typeof ELEMENT_SHORTCODE;
+ shortcode: string;
+ args: string[];
+ children: [EmptyText];
+}
+
export interface MdH1Element extends MdBlockElement {
type: typeof ELEMENT_H1;
children: MdInlineChildren;
@@ -202,7 +217,7 @@ export interface MdBlockquoteElement extends MdBlockElement {
export interface MdCodeBlockElement extends MdBlockElement {
type: typeof ELEMENT_CODE_BLOCK;
- lang: string | undefined;
+ lang: string | undefined | null;
code: string;
}
@@ -239,6 +254,12 @@ export interface MdNumberedListElement extends TElement, MdBlockElement {
export interface MdListItemElement extends TElement, MdBlockElement {
type: typeof ELEMENT_LI;
checked: boolean | null;
+ children: MdListItemContentElement[];
+}
+
+export interface MdListItemContentElement extends TElement, MdBlockElement {
+ type: typeof ELEMENT_LIC;
+ checked: boolean | null;
children: MdInlineChildren;
}
diff --git a/core/src/widgets/markdown/plate/plugins/index.ts b/core/src/widgets/markdown/plate/plugins/index.ts
index f19bee3e..6831f9ce 100644
--- a/core/src/widgets/markdown/plate/plugins/index.ts
+++ b/core/src/widgets/markdown/plate/plugins/index.ts
@@ -7,6 +7,7 @@ export * from './indent';
export * from './list';
export * from './reset-node';
export * from './select-on-backspace';
+export * from './shortcode';
export * from './soft-break';
export * from './table';
export * from './trailing-block';
diff --git a/core/src/widgets/markdown/plate/plugins/shortcode/createShortcodePlugin.ts b/core/src/widgets/markdown/plate/plugins/shortcode/createShortcodePlugin.ts
new file mode 100644
index 00000000..ea03dd69
--- /dev/null
+++ b/core/src/widgets/markdown/plate/plugins/shortcode/createShortcodePlugin.ts
@@ -0,0 +1,12 @@
+import { createPluginFactory } from '@udecode/plate';
+
+import { ELEMENT_SHORTCODE } from '../../plateTypes';
+
+const createShortcodePlugin = createPluginFactory({
+ key: ELEMENT_SHORTCODE,
+ isElement: true,
+ isInline: true,
+ type: ELEMENT_SHORTCODE,
+});
+
+export default createShortcodePlugin;
diff --git a/core/src/widgets/markdown/plate/plugins/shortcode/index.ts b/core/src/widgets/markdown/plate/plugins/shortcode/index.ts
new file mode 100644
index 00000000..45374cbf
--- /dev/null
+++ b/core/src/widgets/markdown/plate/plugins/shortcode/index.ts
@@ -0,0 +1,2 @@
+// eslint-disable-next-line import/prefer-default-export
+export { default as createShortcodePlugin } from './createShortcodePlugin';
diff --git a/core/src/widgets/markdown/plate/serialization/__tests__/serializeMarkdown.spec.ts b/core/src/widgets/markdown/plate/serialization/__tests__/serializeMarkdown.spec.ts
new file mode 100644
index 00000000..cc727cd2
--- /dev/null
+++ b/core/src/widgets/markdown/plate/serialization/__tests__/serializeMarkdown.spec.ts
@@ -0,0 +1,26 @@
+import {
+ runSerializationTests,
+ testShortcodeConfigs as shortcodeConfigs,
+} from '../../tests-util/serializationTests.util';
+import serializeMarkdown from '../serializeMarkdown';
+
+import type { MdValue } from '../../plateTypes';
+
+function expectMarkdown(nodes: MdValue, options: { useMdx: boolean }, markdown: string) {
+ const result = serializeMarkdown(nodes, { ...options, shortcodeConfigs });
+ expect(result).toBe(`${markdown}\n`);
+}
+
+describe('serializeMarkdown', () => {
+ runSerializationTests((key, mode, data) => {
+ it(`serializes ${key}`, async () => {
+ if (mode === 'both') {
+ await expectMarkdown(data.slate, { useMdx: false }, data.markdown);
+ await expectMarkdown(data.slate, { useMdx: true }, data.markdown);
+ return;
+ }
+
+ await expectMarkdown(data.slate, { useMdx: mode === 'mdx' }, data.markdown);
+ });
+ });
+});
diff --git a/core/src/widgets/markdown/plate/serialization/index.ts b/core/src/widgets/markdown/plate/serialization/index.ts
index 910bce6d..dd1fab34 100644
--- a/core/src/widgets/markdown/plate/serialization/index.ts
+++ b/core/src/widgets/markdown/plate/serialization/index.ts
@@ -1,2 +1,3 @@
+export * from './serializeMarkdown';
+export { default as serializeMarkdown } from './serializeMarkdown';
export * from './slate';
-export { default as serializerMarkdown } from './serializerMarkdown';
diff --git a/core/src/widgets/markdown/plate/serialization/serializerMarkdown.ts b/core/src/widgets/markdown/plate/serialization/serializeMarkdown.ts
similarity index 73%
rename from core/src/widgets/markdown/plate/serialization/serializerMarkdown.ts
rename to core/src/widgets/markdown/plate/serialization/serializeMarkdown.ts
index 61ebea44..1cf4d23d 100644
--- a/core/src/widgets/markdown/plate/serialization/serializerMarkdown.ts
+++ b/core/src/widgets/markdown/plate/serialization/serializeMarkdown.ts
@@ -1,22 +1,24 @@
/* eslint-disable no-case-declarations */
-// import { BlockType, defaultNodeTypes, LeafType, NodeTypes } from './ast-types';
-import escapeHtml from 'escape-html';
-
+import { getShortcodes } from '../../../../lib/registry';
+import { isEmpty } from '../../../../lib/util/string.util';
import { LIST_TYPES, NodeTypes } from './slate/ast-types';
+import type { CSSProperties } from 'react';
+import type { ShortcodeConfig } from '../../../../interface';
import type {
MdCodeBlockElement,
MdImageElement,
MdLinkElement,
MdListItemElement,
MdParagraphElement,
+ MdShortcodeElement,
+ MdValue,
} from '../plateTypes';
-import type { TableNode, BlockType, LeafType } from './slate/ast-types';
-import type { CSSProperties } from 'react';
+import type { BlockType, LeafType, TableNode } from './slate/ast-types';
type FontStyles = Pick;
-interface MdLeafType extends LeafType {
+export interface MdLeafType extends LeafType {
superscript?: boolean;
subscript?: boolean;
underline?: boolean;
@@ -24,39 +26,55 @@ interface MdLeafType extends LeafType {
backgroundColor?: string;
}
-interface MdBlockType extends Omit {
+export interface MdBlockType extends Omit {
children: Array;
}
-interface Options {
+interface SerializeMarkdownNodeOptions {
isInTable?: boolean;
isInCode?: boolean;
listDepth?: number;
blockquoteDepth?: number;
ignoreParagraphNewline?: boolean;
+ useMdx: boolean;
+ index: number;
+ shortcodeConfigs: Record;
}
const isLeafNode = (node: MdBlockType | MdLeafType): node is MdLeafType => {
return typeof (node as MdLeafType).text === 'string';
};
-const VOID_ELEMENTS: Array = ['thematic_break', 'image', 'code_block'];
+const VOID_ELEMENTS: Array = [
+ 'thematic_break',
+ 'image',
+ 'code_block',
+ 'shortcode',
+ 'tableCell',
+ 'tableHeaderCell',
+];
const BREAK_TAG = '
';
const CODE_ELEMENTS = [NodeTypes.code_block];
-export default function serializerMarkdown(chunk: MdBlockType | MdLeafType, opts: Options = {}) {
+function serializeMarkdownNode(
+ chunk: MdBlockType | MdLeafType,
+ opts: SerializeMarkdownNodeOptions,
+) {
const {
ignoreParagraphNewline = false,
listDepth = 0,
isInTable = false,
isInCode = false,
blockquoteDepth = 0,
+ useMdx,
+ shortcodeConfigs,
} = opts;
const text = (chunk as MdLeafType).text || '';
let type = (chunk as MdBlockType).type || '';
+ const selfIsBlockquote = 'type' in chunk && chunk.type === 'blockquote';
let children = text;
@@ -67,12 +85,11 @@ export default function serializerMarkdown(chunk: MdBlockType | MdLeafType, opts
}
children = chunk.children
- .map((c: MdBlockType | MdLeafType) => {
+ .map((c: MdBlockType | MdLeafType, childIndex) => {
const selfIsTable = type === NodeTypes.table;
const isList = !isLeafNode(c) ? (LIST_TYPES as string[]).includes(c.type || '') : false;
const selfIsList = (LIST_TYPES as string[]).includes(chunk.type || '');
const selfIsCode = (CODE_ELEMENTS as string[]).includes(chunk.type || '');
- const selfIsBlockquote = chunk.type === 'blockquote';
// Links can have the following shape
// In which case we don't want to surround
@@ -91,7 +108,7 @@ export default function serializerMarkdown(chunk: MdBlockType | MdLeafType, opts
childrenHasLink = chunk.children.some(f => !isLeafNode(f) && f.type === NodeTypes.link);
}
- return serializerMarkdown(
+ return serializeMarkdownNode(
{ ...c, parentType: type },
{
// WOAH.
@@ -102,20 +119,18 @@ export default function serializerMarkdown(chunk: MdBlockType | MdLeafType, opts
// of whitespace. If we're parallel to a link we also don't want
// to respect neighboring paragraphs
ignoreParagraphNewline:
- (ignoreParagraphNewline || isList || selfIsList || childrenHasLink) &&
+ (ignoreParagraphNewline || isList || selfIsList || childrenHasLink || isInTable) &&
// if we have c.break, never ignore empty paragraph new line
!(c as MdBlockType).break,
// track depth of nested lists so we can add proper spacing
- listDepth: (LIST_TYPES as string[]).includes((c as MdBlockType).type || '')
- ? listDepth + 1
- : listDepth,
-
+ listDepth: selfIsList ? listDepth + 1 : listDepth,
isInTable: selfIsTable || isInTable,
-
isInCode: selfIsCode || isInCode,
-
blockquoteDepth: selfIsBlockquote ? blockquoteDepth + 1 : blockquoteDepth,
+ useMdx,
+ index: childIndex,
+ shortcodeConfigs,
},
);
})
@@ -127,7 +142,10 @@ export default function serializerMarkdown(chunk: MdBlockType | MdLeafType, opts
!ignoreParagraphNewline &&
(text === '' || text === '\n') &&
chunk.parentType === NodeTypes.paragraph &&
- type !== NodeTypes.image
+ type !== NodeTypes.image &&
+ type !== NodeTypes.shortcode &&
+ type !== NodeTypes.tableCell &&
+ type !== NodeTypes.tableHeaderCell
) {
type = NodeTypes.paragraph;
children = '\n';
@@ -145,7 +163,6 @@ export default function serializerMarkdown(chunk: MdBlockType | MdLeafType, opts
// "Text foo bar **baz**" resulting in "**Text foo bar **baz****"
// which is invalid markup and can mess everything up
if (children !== '\n' && isLeafNode(chunk)) {
- children = isInCode || chunk.code ? children : escapeHtml(children);
if (chunk.strikethrough && chunk.bold && chunk.italic) {
children = retainWhitespaceAndFormat(children, '~~***');
} else if (chunk.bold && chunk.italic) {
@@ -220,46 +237,44 @@ export default function serializerMarkdown(chunk: MdBlockType | MdLeafType, opts
return `###### ${children}\n`;
case NodeTypes.block_quote:
- // For some reason, marked is parsing blockquotes w/ one new line
- // as contiued blockquotes, so adding two new lines ensures that doesn't
- // happen
- return `> ${children
+ return `${selfIsBlockquote && blockquoteDepth > 0 ? '\n' : ''}> ${children
.replace(/^[\n]*|[\n]*$/gm, '')
.split('\n')
- .join('\n> ')}\n\n`;
+ .join('\n> ')}\n`;
case NodeTypes.code_block:
const codeBlock = chunk as MdCodeBlockElement;
return `\`\`\`${codeBlock.lang ?? ''}\n${codeBlock.code}\n\`\`\`\n`;
case NodeTypes.link:
- const linkBlock = chunk as unknown as MdLinkElement;
+ const linkBlock = chunk as MdLinkElement;
return `[${children}](${linkBlock.url || ''})`;
case NodeTypes.image:
- const imageBlock = chunk as unknown as MdImageElement;
- const caption = imageBlock.caption ?? [];
- return `![${caption.length > 0 ? caption[0].text ?? '' : ''}](${imageBlock.url || ''})`;
+ const imageBlock = chunk as MdImageElement;
+ const alt = imageBlock.alt ?? '';
+ return `![${alt}](${imageBlock.url || ''})`;
case NodeTypes.ul_list:
case NodeTypes.ol_list:
- return `\n${children}`;
+ return `${listDepth > 0 ? '\n' : ''}${children}`;
case NodeTypes.listItemContent:
return children;
case NodeTypes.listItem:
- const listItemBlock = chunk as unknown as MdListItemElement;
+ const listItemBlock = chunk as MdListItemElement;
const isOL = chunk && chunk.parentType === NodeTypes.ol_list;
const treatAsLeaf =
(chunk as MdBlockType).children.length >= 1 &&
((chunk as MdBlockType).children.reduce((acc, child) => acc && isLeafNode(child), true) ||
- ((chunk as MdBlockType).children[0] as BlockType).type === 'lic');
+ ((chunk as MdBlockType).children.length === 1 &&
+ ((chunk as MdBlockType).children[0] as BlockType).type === 'lic'));
let spacer = '';
- for (let k = 0; listDepth > k; k++) {
+ for (let k = 1; listDepth > k; k++) {
if (isOL) {
// https://github.com/remarkjs/remark-react/issues/65
spacer += ' ';
@@ -270,15 +285,19 @@ export default function serializerMarkdown(chunk: MdBlockType | MdLeafType, opts
let checkbox = '';
if (typeof listItemBlock.checked === 'boolean') {
- checkbox = ` [${listItemBlock.checked ? 'X' : ' '}]`;
+ checkbox = ` [${listItemBlock.checked ? 'x' : ' '}]`;
}
return `${spacer}${isOL ? '1.' : '-'}${checkbox} ${children}${treatAsLeaf ? '\n' : ''}`;
case NodeTypes.paragraph:
- const paragraphNode = chunk as unknown as MdParagraphElement;
- if (paragraphNode.align) {
- return `${children}
`;
+ const paragraphNode = chunk as MdParagraphElement;
+ if (useMdx && paragraphNode.align) {
+ return retainWhitespaceAndFormat(
+ children,
+ ``,
+ '
\n',
+ );
}
return `${children}${!isInTable ? '\n' : ''}`;
@@ -287,15 +306,31 @@ export default function serializerMarkdown(chunk: MdBlockType | MdLeafType, opts
case NodeTypes.table:
const columns = getTableColumnCount(chunk as TableNode);
- return `|${Array(columns).fill(' ').join('|')}|
+ const rows = children.split('\n');
+ const header = rows.length > 0 ? rows[0] : `|${Array(columns).fill(' ').join('|')}|`;
+ const bodyRows = rows.slice(1);
+
+ return `${header}
|${Array(columns).fill('---').join('|')}|
-${children}\n`;
+${bodyRows.join('\n')}`;
case NodeTypes.tableRow:
return `|${children}|\n`;
+ case NodeTypes.tableHeaderCell:
case NodeTypes.tableCell:
- return children.replace(/\|/g, '\\|').replace(/\n/g, BREAK_TAG);
+ return isEmpty(children) ? ' ' : children.replace(/\|/g, '\\|').replace(/\n/g, BREAK_TAG);
+
+ case NodeTypes.shortcode:
+ const shortcodeNode = chunk as MdShortcodeElement;
+ const shortcodeConfig = shortcodeConfigs[shortcodeNode.shortcode];
+ if (!shortcodeConfig) {
+ return children;
+ }
+
+ return `${shortcodeConfig.openTag}${[shortcodeNode.shortcode, ...shortcodeNode.args].join(
+ shortcodeConfig.separator,
+ )}${shortcodeConfig.closeTag}`;
default:
console.warn('Unrecognized slate node, proceeding as text', `"${type}"`, chunk);
@@ -343,3 +378,23 @@ function getTableColumnCount(tableNode: TableNode): number {
return rows[0].children.length;
}
+
+export interface SerializeMarkdownOptions {
+ useMdx: boolean;
+ shortcodeConfigs?: Record>;
+}
+
+export default function serializeMarkdown(
+ slateValue: MdValue,
+ { useMdx, shortcodeConfigs }: SerializeMarkdownOptions,
+) {
+ return slateValue
+ .map((v, index) =>
+ serializeMarkdownNode(v as BlockType | LeafType, {
+ useMdx,
+ index,
+ shortcodeConfigs: shortcodeConfigs ?? getShortcodes(),
+ }),
+ )
+ .join('\n');
+}
diff --git a/core/src/widgets/markdown/plate/serialization/slate/__test__/processShortcodeConfig.spec.ts b/core/src/widgets/markdown/plate/serialization/slate/__test__/processShortcodeConfig.spec.ts
new file mode 100644
index 00000000..15f2e60f
--- /dev/null
+++ b/core/src/widgets/markdown/plate/serialization/slate/__test__/processShortcodeConfig.spec.ts
@@ -0,0 +1,90 @@
+import { processShortcodeConfigToMdx } from '../processShortcodeConfig';
+import { testShortcodeConfigs } from '../../../tests-util/serializationTests.util';
+
+describe('processShortcodeConfig', () => {
+ describe('processShortcodeConfigToMdx', () => {
+ it('converts to mdx', () => {
+ const markdown = '[youtube|p6h-rYSVX90]';
+ const mdx = '';
+
+ expect(processShortcodeConfigToMdx(testShortcodeConfigs, markdown)).toBe(mdx);
+ });
+
+ it('converts shortcode with no args', () => {
+ const markdown = '[youtube]';
+ const mdx = '';
+
+ expect(processShortcodeConfigToMdx(testShortcodeConfigs, markdown)).toBe(mdx);
+ });
+
+ it('converts shortcode with multiple args', () => {
+ const markdown = '[youtube|p6h-rYSVX90|somethingElse|andOneMore]';
+ const mdx =
+ "";
+
+ expect(processShortcodeConfigToMdx(testShortcodeConfigs, markdown)).toBe(mdx);
+ });
+
+ it('shortcode with text before', () => {
+ const markdown = 'Text before [youtube|p6h-rYSVX90]';
+ const mdx = 'Text before ';
+
+ expect(processShortcodeConfigToMdx(testShortcodeConfigs, markdown)).toBe(mdx);
+ });
+
+ it('shortcode with text after', () => {
+ const markdown = '[youtube|p6h-rYSVX90] and text after';
+ const mdx = ' and text after';
+
+ expect(processShortcodeConfigToMdx(testShortcodeConfigs, markdown)).toBe(mdx);
+ });
+
+ it('shortcode with text before and after', () => {
+ const markdown = 'Text before [youtube|p6h-rYSVX90] and text after';
+ const mdx =
+ 'Text before and text after';
+
+ expect(processShortcodeConfigToMdx(testShortcodeConfigs, markdown)).toBe(mdx);
+ });
+
+ it('multiple shortcodes', () => {
+ const markdown = 'Text before [youtube|p6h-rYSVX90] and {{< twitter 917359331535966209 >}}';
+ const mdx =
+ 'Text before and ';
+
+ expect(processShortcodeConfigToMdx(testShortcodeConfigs, markdown)).toBe(mdx);
+ });
+
+ it('multiple of the same shortcodes', () => {
+ const markdown =
+ 'Text before [youtube|p6h-rYSVX90], [youtube|p6h-rYSVX90], {{< twitter 917359331535966209 >}} and [youtube|p6h-rYSVX90]';
+ const mdx =
+ 'Text before , , and ';
+
+ expect(processShortcodeConfigToMdx(testShortcodeConfigs, markdown)).toBe(mdx);
+ });
+
+ it('unrecognized shortcode', () => {
+ const markdown = '[someOtherShortcode|andstuff]';
+ const mdx = '[someOtherShortcode|andstuff]';
+
+ expect(processShortcodeConfigToMdx(testShortcodeConfigs, markdown)).toBe(mdx);
+ });
+
+ it('unrecognized shortcode surrounded by recognized shortcodes', () => {
+ const markdown =
+ 'Text before [youtube|p6h-rYSVX90], [someOtherShortcode|andstuff] and {{< twitter 917359331535966209 >}}';
+ const mdx =
+ 'Text before , [someOtherShortcode|andstuff] and ';
+
+ expect(processShortcodeConfigToMdx(testShortcodeConfigs, markdown)).toBe(mdx);
+ });
+
+ it('plain text', () => {
+ const markdown = 'Some text about something going on somewhere';
+ const mdx = 'Some text about something going on somewhere';
+
+ expect(processShortcodeConfigToMdx(testShortcodeConfigs, markdown)).toBe(mdx);
+ });
+ });
+});
diff --git a/core/src/widgets/markdown/plate/serialization/slate/ast-types.ts b/core/src/widgets/markdown/plate/serialization/slate/ast-types.ts
index f9ecae9c..c6208a94 100644
--- a/core/src/widgets/markdown/plate/serialization/slate/ast-types.ts
+++ b/core/src/widgets/markdown/plate/serialization/slate/ast-types.ts
@@ -15,10 +15,13 @@ import {
ELEMENT_PARAGRAPH,
ELEMENT_TABLE,
ELEMENT_TD,
+ ELEMENT_TH,
ELEMENT_TR,
ELEMENT_UL,
} from '@udecode/plate';
+import { ELEMENT_SHORTCODE } from '../../plateTypes';
+
export const VOID_ELEMENTS = [ELEMENT_CODE_BLOCK, ELEMENT_IMAGE];
export const MarkNodeTypes = {
@@ -39,6 +42,7 @@ export const NodeTypes = {
table: ELEMENT_TABLE,
tableRow: ELEMENT_TR,
tableCell: ELEMENT_TD,
+ tableHeaderCell: ELEMENT_TH,
heading: {
1: ELEMENT_H1,
2: ELEMENT_H2,
@@ -47,6 +51,7 @@ export const NodeTypes = {
5: ELEMENT_H5,
6: ELEMENT_H6,
},
+ shortcode: ELEMENT_SHORTCODE,
emphasis_mark: 'italic',
strong_mark: 'bold',
delete_mark: 'strikethrough',
@@ -87,13 +92,19 @@ export interface BlockType {
type: string;
parentType?: string;
link?: string;
- caption?: string;
+ alt?: string;
language?: string;
break?: boolean;
children: Array;
}
-export type MdastNode = BaseMdastNode | MdxMdastNode;
+export interface ShortcodeNode extends BaseMdastNode {
+ type: 'shortcode';
+ shortcode: string;
+ args: string[];
+}
+
+export type MdastNode = BaseMdastNode | MdxMdastNode | ShortcodeNode;
export interface BaseMdastNode {
type?: Omit;
@@ -114,6 +125,7 @@ export interface BaseMdastNode {
checked?: any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
indent?: any;
+ align?: (string | null)[];
}
export interface MdxMdastNodeAttributeValue {
@@ -150,8 +162,6 @@ export interface MdxMdastNode extends BaseMdastNode {
attributes?: MdxMdastNodeAttribute[];
}
-export const allowedStyles: string[] = ['color', 'backgroundColor'];
-
export interface TextNodeStyles {
color?: string;
backgroundColor?: string;
@@ -208,7 +218,7 @@ export type ImageNode = {
type: typeof NodeTypes['image'];
children: Array;
url: string | undefined;
- caption: TextNode;
+ alt: string | undefined;
};
export type TableNode = {
diff --git a/core/src/widgets/markdown/plate/serialization/slate/deserializeMarkdown.ts b/core/src/widgets/markdown/plate/serialization/slate/deserializeMarkdown.ts
index 22856fa2..91839fe6 100644
--- a/core/src/widgets/markdown/plate/serialization/slate/deserializeMarkdown.ts
+++ b/core/src/widgets/markdown/plate/serialization/slate/deserializeMarkdown.ts
@@ -1,8 +1,10 @@
/* eslint-disable no-case-declarations */
import { ELEMENT_PARAGRAPH } from '@udecode/plate';
-import { allowedStyles, LIST_TYPES, MarkNodeTypes, NodeTypes } from './ast-types';
+import { LIST_TYPES, MarkNodeTypes, NodeTypes } from './ast-types';
+import { processShortcodeConfigToSlate } from './processShortcodeConfig';
+import type { ShortcodeConfig } from '@staticcms/core/interface';
import type { MdBlockElement } from '@staticcms/markdown';
import type {
AlignMdxMdastNodeAttribute,
@@ -18,6 +20,7 @@ import type {
ListNode,
MarkNode,
MdastNode,
+ MdxMdastNode,
ParagraphNode,
StyleMdxMdastNodeAttribute,
TextNode,
@@ -54,21 +57,62 @@ function mdxToMark(mark: keyof typeof MarkNodeTypes, children: DeserializedNode[
} as MarkNode;
}
-export interface Options {
- isInTable?: boolean;
+function parseStyleAttribute(node: MdxMdastNode, allowedStyles: Record) {
+ const styleAttribute = node.attributes?.find(
+ a => a.name === 'style',
+ ) as StyleMdxMdastNodeAttribute;
+ const nodeStyles: TextNodeStyles = {};
+ if (styleAttribute) {
+ let styles: Record = {};
+ try {
+ styles =
+ JSON.parse(
+ styleAttribute.value.value
+ .replace(/(['"])?([a-zA-Z0-9_]+)(['"])?:/g, '"$2": ')
+ .replace(/:[ ]*[']([^']+)[']/g, ': "$1"'),
+ ) ?? {};
+ } catch (e) {
+ console.error(`Error parsing font styles (${styleAttribute.value.value})`, e);
+ }
+
+ Object.keys(styles).map(key => {
+ if (key in allowedStyles) {
+ nodeStyles[allowedStyles[key] as keyof TextNodeStyles] = styles[key];
+ }
+ });
+ }
+
+ return nodeStyles;
}
-export default function deserializeMarkdown(node: MdastNode, options?: Options) {
+export interface Options {
+ isInTable?: boolean;
+ isInTableHeaderRow?: boolean;
+ tableAlign?: (string | null)[];
+ useMdx: boolean;
+ shortcodeConfigs: Record;
+ index: number;
+}
+
+export default function deserializeMarkdown(node: MdastNode, options: Options) {
let children: Array = [{ text: '' }];
- const { isInTable = false } = options ?? {};
+ const {
+ isInTable = false,
+ isInTableHeaderRow = false,
+ tableAlign,
+ useMdx,
+ shortcodeConfigs,
+ index,
+ } = options ?? {};
const selfIsTable = node.type === 'table';
+ const selfIsTableHeaderRow = node.type === 'tableRow' && index === 0;
const nodeChildren = node.children;
if (nodeChildren && Array.isArray(nodeChildren) && nodeChildren.length > 0) {
children = nodeChildren.flatMap(
- (c: MdastNode) =>
+ (c: MdastNode, childIndex) =>
deserializeMarkdown(
{
...c,
@@ -76,6 +120,11 @@ export default function deserializeMarkdown(node: MdastNode, options?: Options)
},
{
isInTable: selfIsTable || isInTable,
+ isInTableHeaderRow: selfIsTableHeaderRow || isInTableHeaderRow,
+ useMdx,
+ shortcodeConfigs,
+ index: childIndex,
+ tableAlign: tableAlign || (selfIsTable ? node.align : undefined),
},
) as DeserializedNode,
);
@@ -152,7 +201,7 @@ export default function deserializeMarkdown(node: MdastNode, options?: Options)
type: NodeTypes.image,
children: [{ text: '' }],
url: node.url,
- caption: [{ text: node.alt ?? '' }],
+ alt: node.alt,
} as ImageNode;
case 'blockquote':
@@ -213,7 +262,23 @@ export default function deserializeMarkdown(node: MdastNode, options?: Options)
return { type: NodeTypes.tableRow, children };
case 'tableCell':
- return { type: NodeTypes.tableCell, children: [{ type: NodeTypes.paragraph, children }] };
+ return {
+ type: isInTableHeaderRow ? NodeTypes.tableHeaderCell : NodeTypes.tableCell,
+ children: [{ type: NodeTypes.paragraph, children }],
+ };
+
+ case 'mdxJsxFlowElement':
+ if ('name' in node) {
+ switch (node.name) {
+ case 'br':
+ return { type: NodeTypes.paragraph, children: [{ text: '' }] };
+ default:
+ console.warn('unrecognized mdx flow element', node);
+ break;
+ }
+ }
+
+ return { text: node.value || '' };
case 'mdxJsxTextElement':
if ('name' in node) {
@@ -227,6 +292,8 @@ export default function deserializeMarkdown(node: MdastNode, options?: Options)
case 'u':
return mdxToMark('underline_mark', children);
case 'p':
+ const paragraphNodeStyles = parseStyleAttribute(node, { textAlign: 'align' });
+
const alignAttribute = node.attributes?.find(
a => a.name === 'align',
) as AlignMdxMdastNodeAttribute;
@@ -237,6 +304,7 @@ export default function deserializeMarkdown(node: MdastNode, options?: Options)
return {
type: NodeTypes.paragraph,
+ ...paragraphNodeStyles,
...pNodeStyles,
children: [
{
@@ -246,44 +314,25 @@ export default function deserializeMarkdown(node: MdastNode, options?: Options)
],
} as ParagraphNode;
case 'font':
- const styleAttribute = node.attributes?.find(
- a => a.name === 'style',
- ) as StyleMdxMdastNodeAttribute;
- const nodeStyles: TextNodeStyles = {};
- if (styleAttribute) {
- let styles: Record = {};
- try {
- styles =
- JSON.parse(
- styleAttribute.value.value
- .replace(/(['"])?([a-zA-Z0-9_]+)(['"])?:/g, '"$2": ')
- .replace(/:[ ]*[']([^']+)[']/g, ': "$1"'),
- ) ?? {};
- } catch (e) {
- console.error(`Error parsing font styles (${styleAttribute.value.value})`, e);
- }
-
- Object.keys(styles).map(key => {
- if (allowedStyles.includes(key)) {
- nodeStyles[key as keyof TextNodeStyles] = styles[key];
- }
- });
- }
+ const fontNodeStyles = parseStyleAttribute(node, {
+ color: 'color',
+ backgroundColor: 'backgroundColor',
+ });
const colorAttribute = node.attributes?.find(
a => a.name === 'color',
) as ColorMdxMdastNodeAttribute;
if (colorAttribute) {
- nodeStyles.color = colorAttribute.value;
+ fontNodeStyles.color = colorAttribute.value;
}
return {
- ...nodeStyles,
+ ...fontNodeStyles,
...forceLeafNode(children as Array),
...persistLeafFormats(children as Array),
} as TextNode;
default:
- console.warn('unrecognized mdx node', node);
+ console.warn('unrecognized mdx text element', node);
break;
}
}
@@ -291,7 +340,22 @@ export default function deserializeMarkdown(node: MdastNode, options?: Options)
return { text: node.value || '' };
case 'text':
- return { text: node.value || '' };
+ if (useMdx) {
+ return { text: node.value || '' };
+ }
+
+ if (!node.value) {
+ return { text: '' };
+ }
+
+ let nodes: MdastNode[] = [node];
+
+ for (const shortcode in shortcodeConfigs) {
+ nodes = processShortcodeConfigToSlate(shortcode, shortcodeConfigs[shortcode], nodes);
+ }
+
+ return nodes.map(node => (node.type === 'text' ? { text: node.value ?? '' } : node));
+
default:
console.warn('Unrecognized mdast node, proceeding as text', node);
return { text: node.value || '' };
diff --git a/core/src/widgets/markdown/plate/serialization/slate/index.ts b/core/src/widgets/markdown/plate/serialization/slate/index.ts
index 91ffa874..49fdf35e 100644
--- a/core/src/widgets/markdown/plate/serialization/slate/index.ts
+++ b/core/src/widgets/markdown/plate/serialization/slate/index.ts
@@ -2,4 +2,5 @@ export * from './ast-types';
export * from './deserializeMarkdown';
export { default as deserializeMarkdown } from './deserializeMarkdown';
export { default as flattenListItemParagraphs } from './flattenListItemParagraphs';
+export * from './toSlatePlugin';
export { default as toSlatePlugin } from './toSlatePlugin';
diff --git a/core/src/widgets/markdown/plate/serialization/slate/processShortcodeConfig.ts b/core/src/widgets/markdown/plate/serialization/slate/processShortcodeConfig.ts
new file mode 100644
index 00000000..686571ee
--- /dev/null
+++ b/core/src/widgets/markdown/plate/serialization/slate/processShortcodeConfig.ts
@@ -0,0 +1,98 @@
+/* eslint-disable no-case-declarations */
+import { isEmpty, isNotEmpty } from '@staticcms/core/lib/util/string.util';
+
+import type { ShortcodeConfig } from '@staticcms/core/interface';
+import type { BaseMdastNode, MdastNode } from './ast-types';
+
+function cleanRegex(str: string) {
+ return str
+ .replace('[', '\\[')
+ .replace(']', '\\]')
+ .replace('(', '\\(')
+ .replace(')', '\\)')
+ .replace('|', '\\|');
+}
+
+function createShortcodeRegex(name: string, config: ShortcodeConfig) {
+ return `${cleanRegex(config.openTag)}(${name})${cleanRegex(
+ config.separator,
+ )}?([\\w\\W]*?)${cleanRegex(config.closeTag)}`;
+}
+
+export function processShortcodeConfigToSlate(
+ name: string,
+ config: ShortcodeConfig,
+ nodes: BaseMdastNode[],
+) {
+ const output: MdastNode[] = [];
+
+ for (const node of nodes) {
+ if (node.value) {
+ const regex = new RegExp(`([\\w\\W]*?)${createShortcodeRegex(name, config)}([\\w\\W]*)`, 'g');
+
+ let matches: RegExpExecArray | null;
+ let rest = node.value;
+ while (isNotEmpty(rest) && (matches = regex.exec(rest)) !== null && matches.length === 5) {
+ const args = matches[3].trim();
+ if (isNotEmpty(matches[1])) {
+ output.push({
+ type: 'text',
+ value: matches[1],
+ });
+ }
+
+ output.push({
+ type: 'shortcode',
+ shortcode: name,
+ args: isEmpty(args) ? [] : args.split(config.separator),
+ children: [{ text: '' }],
+ });
+
+ rest = matches[4];
+ regex.lastIndex = 0;
+ }
+
+ if (isNotEmpty(rest)) {
+ output.push({
+ type: 'text',
+ value: rest,
+ });
+ }
+
+ continue;
+ }
+
+ output.push(node);
+ }
+
+ return output;
+}
+
+export function processShortcodeConfigToMdx(
+ configs: Record,
+ markdown: string,
+) {
+ if (isEmpty(markdown)) {
+ return '';
+ }
+
+ let output = markdown;
+
+ for (const name in configs) {
+ const config = configs[name];
+ const regex = new RegExp(createShortcodeRegex(name, config), 'g');
+
+ let matches: RegExpExecArray | null;
+ while ((matches = regex.exec(markdown)) !== null && matches.length === 3) {
+ const args = isEmpty(matches[2]) ? [] : matches[2]?.split(config.separator);
+ const argsOutput = args?.length > 0 ? `'${args.join("', '")}'` : '';
+
+ output = output.replace(
+ matches[0],
+ ``,
+ );
+ }
+ }
+
+ return output;
+}
diff --git a/core/src/widgets/markdown/plate/serialization/slate/toSlatePlugin.ts b/core/src/widgets/markdown/plate/serialization/slate/toSlatePlugin.ts
index 0a884301..11ce9aed 100644
--- a/core/src/widgets/markdown/plate/serialization/slate/toSlatePlugin.ts
+++ b/core/src/widgets/markdown/plate/serialization/slate/toSlatePlugin.ts
@@ -1,14 +1,21 @@
import transform from './deserializeMarkdown';
+import type { ShortcodeConfig } from '@staticcms/core/interface';
import type { Plugin } from 'unified';
import type { MdastNode } from './ast-types';
-const toSlatePlugin: Plugin = function () {
- const compiler = (node: { children: Array }) => {
- return node.children.map(c => transform(c, {}));
+export interface ToSlatePluginOptions {
+ shortcodeConfigs: Record;
+ useMdx: boolean;
+}
+
+const toSlatePlugin = ({ shortcodeConfigs, useMdx }: ToSlatePluginOptions): Plugin =>
+ function () {
+ const compiler = (node: { children: Array }) => {
+ return node.children.map((c, index) => transform(c, { shortcodeConfigs, useMdx, index }));
+ };
+
+ this.Compiler = compiler;
};
- this.Compiler = compiler;
-};
-
export default toSlatePlugin;
diff --git a/core/src/widgets/markdown/plate/tests-util/serializationTests.util.tsx b/core/src/widgets/markdown/plate/tests-util/serializationTests.util.tsx
new file mode 100644
index 00000000..b8a1a97c
--- /dev/null
+++ b/core/src/widgets/markdown/plate/tests-util/serializationTests.util.tsx
@@ -0,0 +1,2954 @@
+import {
+ ELEMENT_BLOCKQUOTE,
+ ELEMENT_CODE_BLOCK,
+ ELEMENT_H1,
+ ELEMENT_H2,
+ ELEMENT_H3,
+ ELEMENT_H4,
+ ELEMENT_H5,
+ ELEMENT_H6,
+ ELEMENT_IMAGE,
+ ELEMENT_LI,
+ ELEMENT_LIC,
+ ELEMENT_LINK,
+ ELEMENT_OL,
+ ELEMENT_PARAGRAPH,
+ ELEMENT_TABLE,
+ ELEMENT_TD,
+ ELEMENT_TH,
+ ELEMENT_TR,
+ ELEMENT_UL,
+} from '@udecode/plate';
+import React from 'react';
+
+import { ELEMENT_SHORTCODE } from '../plateTypes';
+
+import type { ShortcodeConfig } from '@staticcms/core/interface';
+import type { MdValue } from '../plateTypes';
+
+export const testShortcodeConfigs: Record = {
+ twitter: {
+ openTag: '{{< ',
+ closeTag: ' >}}',
+ separator: ' ',
+ control: () => twitter control
,
+ preview: () => twitter preview
,
+ },
+ youtube: {
+ openTag: '[',
+ closeTag: ']',
+ separator: '|',
+ control: () => youtube control
,
+ preview: () => youtube preview
,
+ },
+};
+
+export interface SerializationTestData {
+ markdown: string;
+ slate: MdValue;
+}
+
+interface SerializationMarkdownMdxSplitTests {
+ markdown?: Record;
+ mdx?: Record;
+}
+
+function isSerializationTest(
+ input:
+ | SerializationMarkdownMdxSplitTests
+ | SerializationTestData
+ | Record,
+): input is SerializationTestData {
+ return 'markdown' in input && 'slate' in input;
+}
+
+function isSerializationMarkdownMdxSplitTests(
+ input:
+ | SerializationMarkdownMdxSplitTests
+ | SerializationTestData
+ | Record,
+): input is SerializationMarkdownMdxSplitTests {
+ return 'mdx' in input || ('markdown' in input && !('slate' in input));
+}
+
+type SerializationTests = Record<
+ string,
+ SerializationMarkdownMdxSplitTests | SerializationTestData | Record
+>;
+
+const serializationTestData: SerializationTests = {
+ 'plain text': {
+ paragraph: {
+ markdown: 'A line of text',
+ slate: [
+ {
+ type: ELEMENT_PARAGRAPH,
+ children: [
+ {
+ text: 'A line of text',
+ },
+ ],
+ },
+ ] as MdValue,
+ },
+
+ 'paragraph with line break': {
+ markdown: `A line of text
+With another in the same paragraph`,
+ slate: [
+ {
+ type: ELEMENT_PARAGRAPH,
+ children: [
+ {
+ text: 'A line of text\nWith another in the same paragraph',
+ },
+ ],
+ },
+ ],
+ },
+
+ 'two paragraphs': {
+ markdown: `A line of text
+
+And a completely new paragraph`,
+ slate: [
+ {
+ type: ELEMENT_PARAGRAPH,
+ children: [
+ {
+ text: 'A line of text',
+ },
+ ],
+ },
+ {
+ type: ELEMENT_PARAGRAPH,
+ children: [
+ {
+ text: 'And a completely new paragraph',
+ },
+ ],
+ },
+ ],
+ },
+ },
+
+ headers: {
+ 'header 1': {
+ markdown: '# Header One',
+ slate: [
+ {
+ type: ELEMENT_H1,
+ children: [
+ {
+ text: 'Header One',
+ },
+ ],
+ },
+ ],
+ },
+
+ 'header 2': {
+ markdown: '## Header Two',
+ slate: [
+ {
+ type: ELEMENT_H2,
+ children: [
+ {
+ text: 'Header Two',
+ },
+ ],
+ },
+ ],
+ },
+
+ 'header 3': {
+ markdown: '### Header Three',
+ slate: [
+ {
+ type: ELEMENT_H3,
+ children: [
+ {
+ text: 'Header Three',
+ },
+ ],
+ },
+ ],
+ },
+
+ 'header 4': {
+ markdown: '#### Header Four',
+ slate: [
+ {
+ type: ELEMENT_H4,
+ children: [
+ {
+ text: 'Header Four',
+ },
+ ],
+ },
+ ],
+ },
+
+ 'header 5': {
+ markdown: '##### Header Five',
+ slate: [
+ {
+ type: ELEMENT_H5,
+ children: [
+ {
+ text: 'Header Five',
+ },
+ ],
+ },
+ ],
+ },
+
+ 'header 6': {
+ markdown: '###### Header Six',
+ slate: [
+ {
+ type: ELEMENT_H6,
+ children: [
+ {
+ text: 'Header Six',
+ },
+ ],
+ },
+ ],
+ },
+ },
+
+ blockquote: {
+ blockquote: {
+ markdown: '> I am a block quote',
+ slate: [
+ {
+ type: ELEMENT_BLOCKQUOTE,
+ children: [
+ {
+ text: 'I am a block quote',
+ },
+ ],
+ },
+ ],
+ },
+
+ 'multiline blockquote': {
+ markdown: '> I am a block quote\n> And another line',
+ slate: [
+ {
+ type: ELEMENT_BLOCKQUOTE,
+ children: [
+ {
+ text: 'I am a block quote\nAnd another line',
+ },
+ ],
+ },
+ ],
+ },
+
+ 'nested blockquote': {
+ markdown: '> I am a block quote\n> > And another line',
+ slate: [
+ {
+ type: ELEMENT_BLOCKQUOTE,
+ children: [
+ {
+ text: 'I am a block quote',
+ },
+ {
+ type: ELEMENT_BLOCKQUOTE,
+ children: [
+ {
+ text: 'And another line',
+ },
+ ],
+ },
+ ],
+ },
+ ] as MdValue,
+ },
+ },
+
+ code: {
+ 'inline code': {
+ markdown: "`Colored Text`",
+ slate: [
+ {
+ type: ELEMENT_PARAGRAPH,
+ children: [
+ {
+ code: true,
+ text: "Colored Text",
+ },
+ ],
+ },
+ ],
+ },
+ },
+
+ 'code block': {
+ 'code block': {
+ markdown:
+ "```\nColored Text\n```",
+ slate: [
+ {
+ type: ELEMENT_CODE_BLOCK,
+ code: "Colored Text",
+ lang: null,
+ children: [
+ {
+ text: '',
+ },
+ ],
+ },
+ ] as MdValue,
+ },
+
+ 'code block with language': {
+ markdown:
+ "```javascript\nColored Text\n```",
+ slate: [
+ {
+ type: ELEMENT_CODE_BLOCK,
+ code: "Colored Text",
+ lang: 'javascript',
+ children: [
+ {
+ text: '',
+ },
+ ],
+ },
+ ],
+ },
+ },
+
+ image: {
+ image: {
+ markdown: '![Alt Text](https://example.com/picture.png)',
+ slate: [
+ {
+ type: ELEMENT_PARAGRAPH,
+ children: [
+ {
+ type: ELEMENT_IMAGE,
+ url: 'https://example.com/picture.png',
+ alt: 'Alt Text',
+ children: [
+ {
+ text: '',
+ },
+ ],
+ },
+ ],
+ },
+ ] as MdValue,
+ },
+
+ 'image without alt text': {
+ markdown: '![](https://example.com/picture.png)',
+ slate: [
+ {
+ type: ELEMENT_PARAGRAPH,
+ children: [
+ {
+ type: ELEMENT_IMAGE,
+ url: 'https://example.com/picture.png',
+ alt: '',
+ children: [
+ {
+ text: '',
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ },
+
+ link: {
+ links: {
+ markdown: '[Link Text](https://example.com/)',
+ slate: [
+ {
+ type: ELEMENT_PARAGRAPH,
+ children: [
+ {
+ type: ELEMENT_LINK,
+ url: 'https://example.com/',
+ children: [
+ {
+ text: 'Link Text',
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ },
+
+ list: {
+ 'unordered list': {
+ markdown: `- List Item 1
+- List Item 2
+- List Item 3`,
+ slate: [
+ {
+ type: ELEMENT_UL,
+ children: [
+ {
+ type: ELEMENT_LI,
+ checked: null,
+ children: [
+ {
+ type: ELEMENT_LIC,
+ children: [
+ {
+ text: 'List Item 1',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: ELEMENT_LI,
+ checked: null,
+ children: [
+ {
+ type: ELEMENT_LIC,
+ children: [
+ {
+ text: 'List Item 2',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: ELEMENT_LI,
+ checked: null,
+ children: [
+ {
+ type: ELEMENT_LIC,
+ children: [
+ {
+ text: 'List Item 3',
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ] as MdValue,
+ },
+
+ 'nested unordered list': {
+ markdown: `- List Item 1
+- List Item 2
+ - List Item 3`,
+ slate: [
+ {
+ type: ELEMENT_UL,
+ children: [
+ {
+ type: ELEMENT_LI,
+ checked: null,
+ children: [
+ {
+ type: ELEMENT_LIC,
+ children: [
+ {
+ text: 'List Item 1',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: ELEMENT_LI,
+ checked: null,
+ children: [
+ {
+ type: ELEMENT_LIC,
+ children: [
+ {
+ text: 'List Item 2',
+ },
+ ],
+ },
+ {
+ type: ELEMENT_UL,
+ children: [
+ {
+ type: ELEMENT_LI,
+ checked: null,
+ children: [
+ {
+ type: ELEMENT_LIC,
+ children: [
+ {
+ text: 'List Item 3',
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ] as MdValue,
+ },
+
+ 'todo unordered list': {
+ markdown: `- [ ] List Item 1
+- [x] List Item 2
+ - [x] List Item 3`,
+ slate: [
+ {
+ type: ELEMENT_UL,
+ children: [
+ {
+ type: ELEMENT_LI,
+ checked: false,
+ children: [
+ {
+ type: ELEMENT_LIC,
+ children: [
+ {
+ text: 'List Item 1',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: ELEMENT_LI,
+ checked: true,
+ children: [
+ {
+ type: ELEMENT_LIC,
+ children: [
+ {
+ text: 'List Item 2',
+ },
+ ],
+ },
+ {
+ type: ELEMENT_UL,
+ children: [
+ {
+ type: ELEMENT_LI,
+ checked: true,
+ children: [
+ {
+ type: ELEMENT_LIC,
+ children: [
+ {
+ text: 'List Item 3',
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ] as MdValue,
+ },
+
+ 'ordered list': {
+ markdown: `1. List Item 1
+1. List Item 2
+1. List Item 3`,
+ slate: [
+ {
+ type: ELEMENT_OL,
+ children: [
+ {
+ type: ELEMENT_LI,
+ checked: null,
+ children: [
+ {
+ type: ELEMENT_LIC,
+ children: [
+ {
+ text: 'List Item 1',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: ELEMENT_LI,
+ checked: null,
+ children: [
+ {
+ type: ELEMENT_LIC,
+ children: [
+ {
+ text: 'List Item 2',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: ELEMENT_LI,
+ checked: null,
+ children: [
+ {
+ type: ELEMENT_LIC,
+ children: [
+ {
+ text: 'List Item 3',
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ] as MdValue,
+ },
+
+ 'nested ordered list': {
+ markdown: `1. List Item 1
+1. List Item 2
+ 1. List Item 3`,
+ slate: [
+ {
+ type: ELEMENT_OL,
+ children: [
+ {
+ type: ELEMENT_LI,
+ checked: null,
+ children: [
+ {
+ type: ELEMENT_LIC,
+ children: [
+ {
+ text: 'List Item 1',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: ELEMENT_LI,
+ checked: null,
+ children: [
+ {
+ type: ELEMENT_LIC,
+ children: [
+ {
+ text: 'List Item 2',
+ },
+ ],
+ },
+ {
+ type: ELEMENT_OL,
+ children: [
+ {
+ type: ELEMENT_LI,
+ checked: null,
+ children: [
+ {
+ type: ELEMENT_LIC,
+ children: [
+ {
+ text: 'List Item 3',
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ] as MdValue,
+ },
+
+ 'nested todo ordered list': {
+ markdown: `1. [x] List Item 1
+1. [ ] List Item 2
+ 1. [ ] List Item 3`,
+ slate: [
+ {
+ type: ELEMENT_OL,
+ children: [
+ {
+ type: ELEMENT_LI,
+ checked: true,
+ children: [
+ {
+ type: ELEMENT_LIC,
+ children: [
+ {
+ text: 'List Item 1',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: ELEMENT_LI,
+ checked: false,
+ children: [
+ {
+ type: ELEMENT_LIC,
+ children: [
+ {
+ text: 'List Item 2',
+ },
+ ],
+ },
+ {
+ type: ELEMENT_OL,
+ children: [
+ {
+ type: ELEMENT_LI,
+ checked: false,
+ children: [
+ {
+ type: ELEMENT_LIC,
+ children: [
+ {
+ text: 'List Item 3',
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ] as MdValue,
+ },
+
+ 'nested mixed list': {
+ markdown: `- List Item 1
+- List Item 2
+ 1. [x] List Item 3
+ 1. [ ] List Item 4`,
+ slate: [
+ {
+ type: ELEMENT_UL,
+ children: [
+ {
+ type: ELEMENT_LI,
+ checked: null,
+ children: [
+ {
+ type: ELEMENT_LIC,
+ children: [
+ {
+ text: 'List Item 1',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: ELEMENT_LI,
+ checked: null,
+ children: [
+ {
+ type: ELEMENT_LIC,
+ children: [
+ {
+ text: 'List Item 2',
+ },
+ ],
+ },
+ {
+ type: ELEMENT_OL,
+ children: [
+ {
+ type: ELEMENT_LI,
+ checked: true,
+ children: [
+ {
+ type: ELEMENT_LIC,
+ children: [
+ {
+ text: 'List Item 3',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: ELEMENT_LI,
+ checked: false,
+ children: [
+ {
+ type: ELEMENT_LIC,
+ children: [
+ {
+ text: 'List Item 4',
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ] as MdValue,
+ },
+ },
+
+ italic: {
+ 'italic (using _)': {
+ markdown: '_Italic_',
+ slate: [
+ {
+ type: ELEMENT_PARAGRAPH,
+ children: [
+ {
+ italic: true,
+ text: 'Italic',
+ },
+ ],
+ },
+ ],
+ },
+ },
+
+ bold: {
+ 'bold (using **)': {
+ markdown: '**Bold**',
+ slate: [
+ {
+ type: ELEMENT_PARAGRAPH,
+ children: [
+ {
+ bold: true,
+ text: 'Bold',
+ },
+ ],
+ },
+ ],
+ },
+ },
+
+ strikethrough: {
+ strikethrough: {
+ markdown: '~~Strikethrough~~',
+ slate: [
+ {
+ type: ELEMENT_PARAGRAPH,
+ children: [
+ {
+ strikethrough: true,
+ text: 'Strikethrough',
+ },
+ ],
+ },
+ ],
+ },
+ },
+
+ align: {
+ markdown: {
+ align: {
+ markdown: "Align Center
",
+ slate: [
+ {
+ type: ELEMENT_PARAGRAPH,
+ children: [
+ {
+ text: "Align Center
",
+ },
+ ],
+ },
+ ] as MdValue,
+ },
+ },
+
+ mdx: {
+ 'align left': {
+ markdown: "Align Left
",
+ slate: [
+ {
+ type: ELEMENT_PARAGRAPH,
+ align: 'left',
+ children: [
+ {
+ text: 'Align Left',
+ },
+ ],
+ },
+ ] as MdValue,
+ },
+
+ 'align center': {
+ markdown: "Align Center
",
+ slate: [
+ {
+ type: ELEMENT_PARAGRAPH,
+ align: 'center',
+ children: [
+ {
+ text: 'Align Center',
+ },
+ ],
+ },
+ ],
+ },
+
+ 'align right': {
+ markdown: "Align Right
",
+ slate: [
+ {
+ type: ELEMENT_PARAGRAPH,
+ align: 'right',
+ children: [
+ {
+ text: 'Align Right',
+ },
+ ],
+ },
+ ],
+ },
+ },
+ },
+
+ subscript: {
+ markdown: {
+ 'subscript tag': {
+ markdown: 'Subscript',
+ slate: [
+ {
+ type: ELEMENT_PARAGRAPH,
+ children: [
+ {
+ text: 'Subscript',
+ },
+ ],
+ },
+ ],
+ },
+ },
+
+ mdx: {
+ 'subscript tag': {
+ markdown: 'Subscript',
+ slate: [
+ {
+ type: ELEMENT_PARAGRAPH,
+ children: [
+ {
+ subscript: true,
+ text: 'Subscript',
+ },
+ ],
+ },
+ ],
+ },
+ },
+ },
+
+ superscript: {
+ markdown: {
+ 'superscript tag': {
+ markdown: 'Superscript',
+ slate: [
+ {
+ type: ELEMENT_PARAGRAPH,
+ children: [
+ {
+ text: 'Superscript',
+ },
+ ],
+ },
+ ],
+ },
+ },
+
+ mdx: {
+ 'superscript tag': {
+ markdown: 'Superscript',
+ slate: [
+ {
+ type: ELEMENT_PARAGRAPH,
+ children: [
+ {
+ superscript: true,
+ text: 'Superscript',
+ },
+ ],
+ },
+ ],
+ },
+ },
+ },
+
+ underline: {
+ markdown: {
+ 'underline tag': {
+ markdown: 'Underlined',
+ slate: [
+ {
+ type: ELEMENT_PARAGRAPH,
+ children: [
+ {
+ text: 'Underlined',
+ },
+ ],
+ },
+ ],
+ },
+ },
+
+ mdx: {
+ 'underline tag': {
+ markdown: 'Underlined',
+ slate: [
+ {
+ type: ELEMENT_PARAGRAPH,
+ children: [
+ {
+ underline: true,
+ text: 'Underlined',
+ },
+ ],
+ },
+ ],
+ },
+ },
+ },
+
+ 'font tags': {
+ markdown: {
+ 'font tag': {
+ markdown: "Colored Text",
+ slate: [
+ {
+ type: ELEMENT_PARAGRAPH,
+ children: [
+ {
+ text: "Colored Text",
+ },
+ ],
+ },
+ ],
+ },
+ },
+
+ mdx: {
+ 'color and background color from style attribute of font tag': {
+ markdown: "Colored Text",
+ slate: [
+ {
+ type: ELEMENT_PARAGRAPH,
+ children: [
+ {
+ color: 'red',
+ backgroundColor: 'black',
+ text: 'Colored Text',
+ },
+ ],
+ },
+ ],
+ },
+ },
+ },
+
+ shortcodes: {
+ markdown: {
+ shortcode: {
+ markdown: '[youtube|p6h-rYSVX90]',
+ slate: [
+ {
+ type: ELEMENT_PARAGRAPH,
+ children: [
+ {
+ type: ELEMENT_SHORTCODE,
+ shortcode: 'youtube',
+ args: ['p6h-rYSVX90'],
+ children: [
+ {
+ text: '',
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+
+ 'shortcode with no args': {
+ markdown: '[youtube]',
+
+ slate: [
+ {
+ type: ELEMENT_PARAGRAPH,
+ children: [
+ {
+ type: ELEMENT_SHORTCODE,
+ shortcode: 'youtube',
+ args: [],
+ children: [
+ {
+ text: '',
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+
+ 'shortcode with multiple args': {
+ markdown: '[youtube|p6h-rYSVX90|somethingElse|andOneMore]',
+
+ slate: [
+ {
+ type: ELEMENT_PARAGRAPH,
+ children: [
+ {
+ type: ELEMENT_SHORTCODE,
+ shortcode: 'youtube',
+ args: ['p6h-rYSVX90', 'somethingElse', 'andOneMore'],
+ children: [
+ {
+ text: '',
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+
+ 'shortcode with text before': {
+ markdown: 'Text before [youtube|p6h-rYSVX90]',
+
+ slate: [
+ {
+ type: ELEMENT_PARAGRAPH,
+ children: [
+ {
+ text: 'Text before ',
+ },
+ {
+ type: ELEMENT_SHORTCODE,
+ shortcode: 'youtube',
+ args: ['p6h-rYSVX90'],
+ children: [
+ {
+ text: '',
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+
+ 'shortcode with text after': {
+ markdown: '[youtube|p6h-rYSVX90] and text after',
+
+ slate: [
+ {
+ type: ELEMENT_PARAGRAPH,
+ children: [
+ {
+ type: ELEMENT_SHORTCODE,
+ shortcode: 'youtube',
+ args: ['p6h-rYSVX90'],
+ children: [
+ {
+ text: '',
+ },
+ ],
+ },
+ {
+ text: ' and text after',
+ },
+ ],
+ },
+ ],
+ },
+
+ 'shortcode with text before and after': {
+ markdown: 'Text before [youtube|p6h-rYSVX90] and text after',
+
+ slate: [
+ {
+ type: ELEMENT_PARAGRAPH,
+ children: [
+ {
+ text: 'Text before ',
+ },
+ {
+ type: ELEMENT_SHORTCODE,
+ shortcode: 'youtube',
+ args: ['p6h-rYSVX90'],
+ children: [
+ {
+ text: '',
+ },
+ ],
+ },
+ {
+ text: ' and text after',
+ },
+ ],
+ },
+ ],
+ },
+
+ 'multiple shortcodes': {
+ markdown: 'Text before [youtube|p6h-rYSVX90] and {{< twitter 917359331535966209 >}}',
+
+ slate: [
+ {
+ type: ELEMENT_PARAGRAPH,
+ children: [
+ {
+ text: 'Text before ',
+ },
+ {
+ type: ELEMENT_SHORTCODE,
+ shortcode: 'youtube',
+ args: ['p6h-rYSVX90'],
+ children: [
+ {
+ text: '',
+ },
+ ],
+ },
+ {
+ text: ' and ',
+ },
+ {
+ type: ELEMENT_SHORTCODE,
+ shortcode: 'twitter',
+ args: ['917359331535966209'],
+ children: [
+ {
+ text: '',
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+
+ 'multiple of the same shortcodes': {
+ markdown:
+ 'Text before [youtube|p6h-rYSVX90], [youtube|p6h-rYSVX90], {{< twitter 917359331535966209 >}} and [youtube|p6h-rYSVX90]',
+
+ slate: [
+ {
+ type: ELEMENT_PARAGRAPH,
+ children: [
+ {
+ text: 'Text before ',
+ },
+ {
+ type: ELEMENT_SHORTCODE,
+ shortcode: 'youtube',
+ args: ['p6h-rYSVX90'],
+ children: [
+ {
+ text: '',
+ },
+ ],
+ },
+ {
+ text: ', ',
+ },
+ {
+ type: ELEMENT_SHORTCODE,
+ shortcode: 'youtube',
+ args: ['p6h-rYSVX90'],
+ children: [
+ {
+ text: '',
+ },
+ ],
+ },
+ {
+ text: ', ',
+ },
+ {
+ type: ELEMENT_SHORTCODE,
+ shortcode: 'twitter',
+ args: ['917359331535966209'],
+ children: [
+ {
+ text: '',
+ },
+ ],
+ },
+ {
+ text: ' and ',
+ },
+ {
+ type: ELEMENT_SHORTCODE,
+ shortcode: 'youtube',
+ args: ['p6h-rYSVX90'],
+ children: [
+ {
+ text: '',
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+
+ 'unrecognized shortcode': {
+ markdown: '[someOtherShortcode|andstuff]',
+ slate: [
+ {
+ type: ELEMENT_PARAGRAPH,
+ children: [
+ {
+ text: '[someOtherShortcode|andstuff]',
+ },
+ ],
+ },
+ ],
+ },
+
+ 'unrecognized shortcode surrounded by recognized shortcodes': {
+ markdown:
+ 'Text before [youtube|p6h-rYSVX90], [someOtherShortcode|andstuff] and {{< twitter 917359331535966209 >}}',
+
+ slate: [
+ {
+ type: ELEMENT_PARAGRAPH,
+ children: [
+ {
+ text: 'Text before ',
+ },
+ {
+ type: ELEMENT_SHORTCODE,
+ shortcode: 'youtube',
+ args: ['p6h-rYSVX90'],
+ children: [
+ {
+ text: '',
+ },
+ ],
+ },
+ {
+ text: ', [someOtherShortcode|andstuff] and ',
+ },
+ {
+ type: ELEMENT_SHORTCODE,
+ shortcode: 'twitter',
+ args: ['917359331535966209'],
+ children: [
+ {
+ text: '',
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+
+ 'plain text': {
+ markdown: 'Some text about something going on somewhere',
+
+ slate: [
+ {
+ type: ELEMENT_PARAGRAPH,
+ children: [
+ {
+ text: 'Some text about something going on somewhere',
+ },
+ ],
+ },
+ ],
+ },
+ },
+
+ mdx: {
+ shortcode: {
+ markdown: '[youtube|p6h-rYSVX90]',
+
+ slate: [
+ {
+ type: ELEMENT_PARAGRAPH,
+ children: [
+ {
+ text: '[youtube|p6h-rYSVX90]',
+ },
+ ],
+ },
+ ],
+ },
+ },
+ },
+
+ table: {
+ table: {
+ markdown: `|Name|Age|
+|---|---|
+|Bob|25|
+|Billy|30|
+|Sam|29|`,
+
+ slate: [
+ {
+ type: ELEMENT_TABLE,
+ children: [
+ {
+ type: ELEMENT_TR,
+ children: [
+ {
+ type: ELEMENT_TH,
+ children: [
+ {
+ type: ELEMENT_PARAGRAPH,
+ children: [
+ {
+ text: 'Name',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: ELEMENT_TH,
+ children: [
+ {
+ type: ELEMENT_PARAGRAPH,
+ children: [
+ {
+ text: 'Age',
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: ELEMENT_TR,
+ children: [
+ {
+ type: ELEMENT_TD,
+ children: [
+ {
+ type: ELEMENT_PARAGRAPH,
+ children: [
+ {
+ text: 'Bob',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: ELEMENT_TD,
+ children: [
+ {
+ type: ELEMENT_PARAGRAPH,
+ children: [
+ {
+ text: '25',
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: ELEMENT_TR,
+ children: [
+ {
+ type: ELEMENT_TD,
+ children: [
+ {
+ type: ELEMENT_PARAGRAPH,
+ children: [
+ {
+ text: 'Billy',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: ELEMENT_TD,
+ children: [
+ {
+ type: ELEMENT_PARAGRAPH,
+ children: [
+ {
+ text: '30',
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: ELEMENT_TR,
+ children: [
+ {
+ type: ELEMENT_TD,
+ children: [
+ {
+ type: ELEMENT_PARAGRAPH,
+ children: [
+ {
+ text: 'Sam',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: ELEMENT_TD,
+ children: [
+ {
+ type: ELEMENT_PARAGRAPH,
+ children: [
+ {
+ text: '29',
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ] as MdValue,
+ },
+ },
+
+ 'kitchen sink': {
+ markdown: {
+ 'kitchen sink': {
+ markdown: `# The post is number 1
+
+![Static CMS](https://raw.githubusercontent.com/StaticJsCMS/static-cms/main/static-cms-logo.png)
+
+# Awesome Editor!
+
+It was _released as open source in 2022_ and is ***continually*** evolving to be the **best editor experience** available for static site generators.
+
+## MDX
+
+The output out this widget is \`mdx\`, a mixture of \`markdown\` and \`javascript components\`. See [MDX documentation](https://mdxjs.com/docs/).
+
+\`\`\`yaml
+name: body
+label: Blog post content
+widget: markdown
+\`\`\`
+
+\`\`\`js
+name: 'body',
+label: 'Blog post content',
+widget: 'markdown',
+\`\`\`
+
+> See the table below for default options
+> More API information can be found in the document
+
+|Name|Type|Default|Description|
+|---|---|---|---|
+|default|string|\`''\`|_Optional_. The default value for the field. Accepts markdown content|
+|media_library|Media Library Options|\`{}\`|_Optional_. Media library settings to apply when a media library is opened by the current widget. See [Media Library Options](#media-library-options)|
+|media_folder|string| |_Optional_. Specifies the folder path where uploaded files should be saved, relative to the base of the repo|
+|public_folder|string| |_Optional_. Specifies the folder path where the files uploaded by the media library will be accessed, relative to the base of the built site|
+
+### Media Library Options
+
+|Name|Type|Default|Description|
+|---|---|---|---|
+|allow_multiple|boolean|\`true\`|_Optional_. When set to \`false\`, prevents multiple selection for any media library extension, but must be supported by the extension in use|
+|config|string|\`{}\`|_Optional_. A configuration object that will be passed directly to the media library being used - available options are determined by the library|
+|choose_url|string
\\| boolean|\`true\`|_Optional_. When set to \`false\`, the "Insert from URL" button will be hidden|
+
+## Features
+
+- CommonMark + GFM Specifications
+ - Live \`Preview\`
+ - Auto Indent
+ - Syntax Highlight
+ 1. Rich Editor
+ 1. Preview
+
+## Formatting
+
+**Bold**, _Italic_, ***both***
+
+~~Strikethrough~~
+
+## Shortcodes
+
+Text ahead [youtube|p6h-rYSVX90] and behind
+
+{{< twitter 917359331535966209 >}} Only behind text
+
+Only text before {{< twitter 917359331535966209 >}}
+
+[youtube|p6h-rYSVX90]
+
+Text ahead [youtube|p6h-rYSVX90] and behind and another {{< twitter 917359331535966209 >}} shortcode
+
+## Support
+
+> - Supports remark plugins
+> - Supports wrappers
+> 1. [x] React
+> 1. [ ] More coming soon`,
+ slate: [
+ {
+ type: 'h1',
+ children: [
+ {
+ text: 'The post is number 1',
+ },
+ ],
+ },
+ {
+ type: 'p',
+ children: [
+ {
+ type: 'img',
+ children: [
+ {
+ text: '',
+ },
+ ],
+ url: 'https://raw.githubusercontent.com/StaticJsCMS/static-cms/main/static-cms-logo.png',
+ alt: 'Static CMS',
+ },
+ ],
+ },
+ {
+ type: 'h1',
+ children: [
+ {
+ text: 'Awesome Editor!',
+ },
+ ],
+ },
+ {
+ type: 'p',
+ children: [
+ {
+ text: 'It was ',
+ },
+ {
+ italic: true,
+ text: 'released as open source in 2022',
+ },
+ {
+ text: ' and is ',
+ },
+ {
+ bold: true,
+ text: 'continually',
+ italic: true,
+ },
+ {
+ text: ' evolving to be the ',
+ },
+ {
+ bold: true,
+ text: 'best editor experience',
+ },
+ {
+ text: ' available for static site generators.',
+ },
+ ],
+ },
+ {
+ type: 'h2',
+ children: [
+ {
+ text: 'MDX',
+ },
+ ],
+ },
+ {
+ type: 'p',
+ children: [
+ {
+ text: 'The output out this widget is ',
+ },
+ {
+ code: true,
+ text: 'mdx',
+ },
+ {
+ text: ', a mixture of ',
+ },
+ {
+ code: true,
+ text: 'markdown',
+ },
+ {
+ text: ' and ',
+ },
+ {
+ code: true,
+ text: 'javascript components',
+ },
+ {
+ text: '. See ',
+ },
+ {
+ type: 'a',
+ url: 'https://mdxjs.com/docs/',
+ children: [
+ {
+ text: 'MDX documentation',
+ },
+ ],
+ },
+ {
+ text: '.',
+ },
+ ],
+ },
+ {
+ type: 'code_block',
+ lang: 'yaml',
+ code: 'name: body\nlabel: Blog post content\nwidget: markdown',
+ children: [
+ {
+ text: '',
+ },
+ ],
+ },
+ {
+ type: 'code_block',
+ lang: 'js',
+ code: "name: 'body',\nlabel: 'Blog post content',\nwidget: 'markdown',",
+ children: [
+ {
+ text: '',
+ },
+ ],
+ },
+ {
+ type: 'blockquote',
+ children: [
+ {
+ text: 'See the table below for default options\nMore API information can be found in the document',
+ },
+ ],
+ },
+ {
+ type: 'table',
+ children: [
+ {
+ type: 'tr',
+ children: [
+ {
+ type: 'th',
+ children: [
+ {
+ type: 'p',
+ children: [
+ {
+ text: 'Name',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'th',
+ children: [
+ {
+ type: 'p',
+ children: [
+ {
+ text: 'Type',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'th',
+ children: [
+ {
+ type: 'p',
+ children: [
+ {
+ text: 'Default',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'th',
+ children: [
+ {
+ type: 'p',
+ children: [
+ {
+ text: 'Description',
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'tr',
+ children: [
+ {
+ type: 'td',
+ children: [
+ {
+ type: 'p',
+ children: [
+ {
+ text: 'default',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'td',
+ children: [
+ {
+ type: 'p',
+ children: [
+ {
+ text: 'string',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'td',
+ children: [
+ {
+ type: 'p',
+ children: [
+ {
+ code: true,
+ text: "''",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'td',
+ children: [
+ {
+ type: 'p',
+ children: [
+ {
+ italic: true,
+ text: 'Optional',
+ },
+ {
+ text: '. The default value for the field. Accepts markdown content',
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'tr',
+ children: [
+ {
+ type: 'td',
+ children: [
+ {
+ type: 'p',
+ children: [
+ {
+ text: 'media_library',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'td',
+ children: [
+ {
+ type: 'p',
+ children: [
+ {
+ text: 'Media Library Options',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'td',
+ children: [
+ {
+ type: 'p',
+ children: [
+ {
+ code: true,
+ text: '{}',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'td',
+ children: [
+ {
+ type: 'p',
+ children: [
+ {
+ italic: true,
+ text: 'Optional',
+ },
+ {
+ text: '. Media library settings to apply when a media library is opened by the current widget. See ',
+ },
+ {
+ type: 'a',
+ url: '#media-library-options',
+ children: [
+ {
+ text: 'Media Library Options',
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'tr',
+ children: [
+ {
+ type: 'td',
+ children: [
+ {
+ type: 'p',
+ children: [
+ {
+ text: 'media_folder',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'td',
+ children: [
+ {
+ type: 'p',
+ children: [
+ {
+ text: 'string',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'td',
+ children: [
+ {
+ type: 'p',
+ children: [
+ {
+ text: '',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'td',
+ children: [
+ {
+ type: 'p',
+ children: [
+ {
+ italic: true,
+ text: 'Optional',
+ },
+ {
+ text: '. Specifies the folder path where uploaded files should be saved, relative to the base of the repo',
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'tr',
+ children: [
+ {
+ type: 'td',
+ children: [
+ {
+ type: 'p',
+ children: [
+ {
+ text: 'public_folder',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'td',
+ children: [
+ {
+ type: 'p',
+ children: [
+ {
+ text: 'string',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'td',
+ children: [
+ {
+ type: 'p',
+ children: [
+ {
+ text: '',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'td',
+ children: [
+ {
+ type: 'p',
+ children: [
+ {
+ italic: true,
+ text: 'Optional',
+ },
+ {
+ text: '. Specifies the folder path where the files uploaded by the media library will be accessed, relative to the base of the built site',
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'h3',
+ children: [
+ {
+ text: 'Media Library Options',
+ },
+ ],
+ },
+ {
+ type: 'table',
+ children: [
+ {
+ type: 'tr',
+ children: [
+ {
+ type: 'th',
+ children: [
+ {
+ type: 'p',
+ children: [
+ {
+ text: 'Name',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'th',
+ children: [
+ {
+ type: 'p',
+ children: [
+ {
+ text: 'Type',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'th',
+ children: [
+ {
+ type: 'p',
+ children: [
+ {
+ text: 'Default',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'th',
+ children: [
+ {
+ type: 'p',
+ children: [
+ {
+ text: 'Description',
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'tr',
+ children: [
+ {
+ type: 'td',
+ children: [
+ {
+ type: 'p',
+ children: [
+ {
+ text: 'allow_multiple',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'td',
+ children: [
+ {
+ type: 'p',
+ children: [
+ {
+ text: 'boolean',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'td',
+ children: [
+ {
+ type: 'p',
+ children: [
+ {
+ code: true,
+ text: 'true',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'td',
+ children: [
+ {
+ type: 'p',
+ children: [
+ {
+ italic: true,
+ text: 'Optional',
+ },
+ {
+ text: '. When set to ',
+ },
+ {
+ code: true,
+ text: 'false',
+ },
+ {
+ text: ', prevents multiple selection for any media library extension, but must be supported by the extension in use',
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'tr',
+ children: [
+ {
+ type: 'td',
+ children: [
+ {
+ type: 'p',
+ children: [
+ {
+ text: 'config',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'td',
+ children: [
+ {
+ type: 'p',
+ children: [
+ {
+ text: 'string',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'td',
+ children: [
+ {
+ type: 'p',
+ children: [
+ {
+ code: true,
+ text: '{}',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'td',
+ children: [
+ {
+ type: 'p',
+ children: [
+ {
+ italic: true,
+ text: 'Optional',
+ },
+ {
+ text: '. A configuration object that will be passed directly to the media library being used - available options are determined by the library',
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'tr',
+ children: [
+ {
+ type: 'td',
+ children: [
+ {
+ type: 'p',
+ children: [
+ {
+ text: 'choose_url',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'td',
+ children: [
+ {
+ type: 'p',
+ children: [
+ {
+ text: 'string',
+ },
+ {
+ type: 'p',
+ children: [
+ {
+ text: '
',
+ },
+ ],
+ },
+ {
+ text: '| boolean',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'td',
+ children: [
+ {
+ type: 'p',
+ children: [
+ {
+ code: true,
+ text: 'true',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'td',
+ children: [
+ {
+ type: 'p',
+ children: [
+ {
+ italic: true,
+ text: 'Optional',
+ },
+ {
+ text: '. When set to ',
+ },
+ {
+ code: true,
+ text: 'false',
+ },
+ {
+ text: ', the "Insert from URL" button will be hidden',
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'h2',
+ children: [
+ {
+ text: 'Features',
+ },
+ ],
+ },
+ {
+ type: 'ul',
+ children: [
+ {
+ type: 'li',
+ checked: null,
+ children: [
+ {
+ type: 'lic',
+ children: [
+ {
+ text: 'CommonMark + GFM Specifications',
+ },
+ ],
+ },
+ {
+ type: 'ul',
+ children: [
+ {
+ type: 'li',
+ checked: null,
+ children: [
+ {
+ type: 'lic',
+ children: [
+ {
+ text: 'Live ',
+ },
+ {
+ code: true,
+ text: 'Preview',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'li',
+ checked: null,
+ children: [
+ {
+ type: 'lic',
+ children: [
+ {
+ text: 'Auto Indent',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'li',
+ checked: null,
+ children: [
+ {
+ type: 'lic',
+ children: [
+ {
+ text: 'Syntax Highlight',
+ },
+ ],
+ },
+ {
+ type: 'ol',
+ children: [
+ {
+ type: 'li',
+ checked: null,
+ children: [
+ {
+ type: 'lic',
+ children: [
+ {
+ text: 'Rich Editor',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'li',
+ checked: null,
+ children: [
+ {
+ type: 'lic',
+ children: [
+ {
+ text: 'Preview',
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'h2',
+ children: [
+ {
+ text: 'Formatting',
+ },
+ ],
+ },
+ {
+ type: 'p',
+ children: [
+ {
+ bold: true,
+ text: 'Bold',
+ },
+ {
+ text: ', ',
+ },
+ {
+ italic: true,
+ text: 'Italic',
+ },
+ {
+ text: ', ',
+ },
+ {
+ italic: true,
+ text: 'both',
+ bold: true,
+ },
+ ],
+ },
+ {
+ type: 'p',
+ children: [
+ {
+ strikethrough: true,
+ text: 'Strikethrough',
+ },
+ ],
+ },
+ {
+ type: 'h2',
+ children: [
+ {
+ text: 'Shortcodes',
+ },
+ ],
+ },
+ {
+ type: 'p',
+ children: [
+ {
+ text: 'Text ahead ',
+ },
+ {
+ type: 'shortcode',
+ shortcode: 'youtube',
+ args: ['p6h-rYSVX90'],
+ children: [
+ {
+ text: '',
+ },
+ ],
+ },
+ {
+ text: ' and behind',
+ },
+ ],
+ },
+ {
+ type: 'p',
+ children: [
+ {
+ type: 'shortcode',
+ shortcode: 'twitter',
+ args: ['917359331535966209'],
+ children: [
+ {
+ text: '',
+ },
+ ],
+ },
+ {
+ text: ' Only behind text',
+ },
+ ],
+ },
+ {
+ type: 'p',
+ children: [
+ {
+ text: 'Only text before ',
+ },
+ {
+ type: 'shortcode',
+ shortcode: 'twitter',
+ args: ['917359331535966209'],
+ children: [
+ {
+ text: '',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'p',
+ children: [
+ {
+ type: 'shortcode',
+ shortcode: 'youtube',
+ args: ['p6h-rYSVX90'],
+ children: [
+ {
+ text: '',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'p',
+ children: [
+ {
+ text: 'Text ahead ',
+ },
+ {
+ type: 'shortcode',
+ shortcode: 'youtube',
+ args: ['p6h-rYSVX90'],
+ children: [
+ {
+ text: '',
+ },
+ ],
+ },
+ {
+ text: ' and behind and another ',
+ },
+ {
+ type: 'shortcode',
+ shortcode: 'twitter',
+ args: ['917359331535966209'],
+ children: [
+ {
+ text: '',
+ },
+ ],
+ },
+ {
+ text: ' shortcode',
+ },
+ ],
+ },
+ {
+ type: 'h2',
+ children: [
+ {
+ text: 'Support',
+ },
+ ],
+ },
+ {
+ type: 'blockquote',
+ children: [
+ {
+ type: 'ul',
+ children: [
+ {
+ type: 'li',
+ checked: null,
+ children: [
+ {
+ type: 'lic',
+ children: [
+ {
+ text: 'Supports remark plugins',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'li',
+ checked: null,
+ children: [
+ {
+ type: 'lic',
+ children: [
+ {
+ text: 'Supports wrappers',
+ },
+ ],
+ },
+ {
+ type: 'ol',
+ children: [
+ {
+ type: 'li',
+ checked: true,
+ children: [
+ {
+ type: 'lic',
+ children: [
+ {
+ text: 'React',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'li',
+ checked: false,
+ children: [
+ {
+ type: 'lic',
+ children: [
+ {
+ text: 'More coming soon',
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ] as MdValue,
+ },
+ },
+ },
+};
+
+export const deserializationOnlyTestData: SerializationTests = {
+ italic: {
+ 'italic (using *)': {
+ markdown: '*Italic*',
+ slate: [
+ {
+ type: ELEMENT_PARAGRAPH,
+ children: [
+ {
+ italic: true,
+ text: 'Italic',
+ },
+ ],
+ },
+ ],
+ },
+ },
+
+ bold: {
+ 'bold (using __)': {
+ markdown: '__Bold__',
+ slate: [
+ {
+ type: ELEMENT_PARAGRAPH,
+ children: [
+ {
+ bold: true,
+ text: 'Bold',
+ },
+ ],
+ },
+ ],
+ },
+ },
+
+ 'bold and italic': {
+ 'bold and italic (using ___)': {
+ markdown: '___Bold and Italic___',
+ slate: [
+ {
+ type: ELEMENT_PARAGRAPH,
+ children: [
+ {
+ bold: true,
+ italic: true,
+ text: 'Bold and Italic',
+ },
+ ],
+ },
+ ],
+ },
+
+ 'bold and italic (using **_)': {
+ markdown: '**_Bold and Italic_**',
+ slate: [
+ {
+ type: ELEMENT_PARAGRAPH,
+ children: [
+ {
+ bold: true,
+ italic: true,
+ text: 'Bold and Italic',
+ },
+ ],
+ },
+ ],
+ },
+
+ 'bold and italic (using __*)': {
+ markdown: '__*Bold and Italic*__',
+ slate: [
+ {
+ type: ELEMENT_PARAGRAPH,
+ children: [
+ {
+ bold: true,
+ italic: true,
+ text: 'Bold and Italic',
+ },
+ ],
+ },
+ ],
+ },
+
+ 'bold and italic (using *__)': {
+ markdown: '*__Bold and Italic__*',
+ slate: [
+ {
+ type: ELEMENT_PARAGRAPH,
+ children: [
+ {
+ bold: true,
+ italic: true,
+ text: 'Bold and Italic',
+ },
+ ],
+ },
+ ],
+ },
+ },
+
+ color: {
+ mdx: {
+ 'color attribute of font tag': {
+ markdown: 'Colored Text',
+ slate: [
+ {
+ type: ELEMENT_PARAGRAPH,
+ children: [
+ {
+ color: 'red',
+ text: 'Colored Text',
+ },
+ ],
+ },
+ ],
+ },
+
+ 'color and style attributes of font tag together (favoring color)': {
+ markdown:
+ "Colored Text",
+ slate: [
+ {
+ type: ELEMENT_PARAGRAPH,
+ children: [
+ {
+ color: 'blue',
+ backgroundColor: 'black',
+ text: 'Colored Text',
+ },
+ ],
+ },
+ ],
+ },
+ },
+ },
+
+ align: {
+ mdx: {
+ 'align attribute of paragraph tag': {
+ markdown: 'Aligned Left
',
+ slate: [
+ {
+ type: ELEMENT_PARAGRAPH,
+ align: 'left',
+ children: [
+ {
+ text: 'Aligned Left',
+ },
+ ],
+ },
+ ],
+ },
+
+ 'align and style attributes of font paragraph together (favoring align)': {
+ markdown: 'Aligned Center
',
+ slate: [
+ {
+ type: ELEMENT_PARAGRAPH,
+ align: 'center',
+ children: [
+ {
+ text: 'Aligned Center',
+ },
+ ],
+ },
+ ],
+ },
+ },
+ },
+
+ break: {
+ mdx: {
+ 'break tag': {
+ markdown: '
',
+ slate: [
+ {
+ type: ELEMENT_PARAGRAPH,
+ children: [
+ {
+ text: '',
+ },
+ ],
+ },
+ ],
+ },
+ },
+ },
+};
+
+function runSectionSerializationTests(
+ sectionKey: string,
+ mode: 'markdown' | 'mdx' | 'both',
+ tests: Record,
+ testCallback: (
+ key: string,
+ mode: 'markdown' | 'mdx' | 'both',
+ data: SerializationTestData,
+ ) => void,
+) {
+ describe(sectionKey, () => {
+ Object.keys(tests).forEach(key => testCallback(key, mode, tests[key]));
+ });
+}
+
+export function runSerializationTests(
+ testCallback: (
+ key: string,
+ mode: 'markdown' | 'mdx' | 'both',
+ data: SerializationTestData,
+ ) => void,
+ testData = serializationTestData,
+) {
+ Object.keys(testData).forEach(key => {
+ const data = testData[key];
+
+ if (isSerializationTest(data)) {
+ testCallback(key, 'both', data);
+ return;
+ }
+
+ if (isSerializationMarkdownMdxSplitTests(data)) {
+ describe(key, () => {
+ if (data.markdown) {
+ runSectionSerializationTests('markdown', 'markdown', data.markdown, testCallback);
+ }
+ if (data.mdx) {
+ runSectionSerializationTests('mdx', 'mdx', data.mdx, testCallback);
+ }
+ });
+ return;
+ }
+
+ runSectionSerializationTests(key, 'both', data, testCallback);
+ });
+}
diff --git a/core/src/widgets/markdown/withMarkdownControl.tsx b/core/src/widgets/markdown/withMarkdownControl.tsx
new file mode 100644
index 00000000..6d2061c7
--- /dev/null
+++ b/core/src/widgets/markdown/withMarkdownControl.tsx
@@ -0,0 +1,127 @@
+import { styled } from '@mui/material/styles';
+import React, { useCallback, useMemo, useState } from 'react';
+
+import FieldLabel from '@staticcms/core/components/UI/FieldLabel';
+import Outline from '@staticcms/core/components/UI/Outline';
+import useDebounce from '../../lib/hooks/useDebounce';
+import useMarkdownToSlate from './plate/hooks/useMarkdownToSlate';
+import PlateEditor from './plate/PlateEditor';
+import serializeMarkdown from './plate/serialization/serializeMarkdown';
+
+import type { MarkdownField, WidgetControlProps } from '@staticcms/core/interface';
+import type { FC } from 'react';
+import type { MdValue } from './plate/plateTypes';
+
+const StyledEditorWrapper = styled('div')`
+ position: relative;
+ width: 100%;
+
+ .toastui-editor-main .toastui-editor-md-vertical-style .toastui-editor {
+ width: 100%;
+ }
+
+ .toastui-editor-main .toastui-editor-md-splitter {
+ display: none;
+ }
+
+ .toastui-editor-md-preview {
+ display: none;
+ }
+
+ .toastui-editor-defaultUI {
+ border: none;
+ }
+`;
+
+export interface WithMarkdownControlProps {
+ useMdx: boolean;
+}
+
+const withMarkdownControl = ({ useMdx }: WithMarkdownControlProps) => {
+ const MarkdownControl: FC> = controlProps => {
+ const { label, value, onChange, hasErrors, collection, entry, field } = controlProps;
+
+ const [internalValue, setInternalValue] = useState(value ?? '');
+ const [hasFocus, setHasFocus] = useState(false);
+ const debouncedFocus = useDebounce(hasFocus, 150);
+
+ const handleOnFocus = useCallback(() => {
+ setHasFocus(true);
+ }, []);
+
+ const handleOnBlur = useCallback(() => {
+ setHasFocus(false);
+ }, []);
+
+ const handleOnChange = useCallback(
+ (slateValue: MdValue) => {
+ const newValue = serializeMarkdown(slateValue, { useMdx });
+ if (newValue !== internalValue) {
+ setInternalValue(newValue);
+ onChange(newValue);
+ }
+ },
+ [internalValue, onChange],
+ );
+
+ const handleLabelClick = useCallback(() => {
+ // editorRef.current?.getInstance().focus();
+ }, []);
+
+ const [slateValue, loaded] = useMarkdownToSlate(internalValue, { useMdx });
+
+ return useMemo(
+ () => (
+
+
+ {label}
+
+ {loaded ? (
+
+ ) : null}
+
+
+ ),
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [
+ collection,
+ controlProps,
+ debouncedFocus,
+ field,
+ handleLabelClick,
+ handleOnBlur,
+ handleOnChange,
+ handleOnFocus,
+ hasErrors,
+ hasFocus,
+ label,
+ loaded,
+ slateValue,
+ ],
+ );
+ };
+
+ return MarkdownControl;
+};
+
+export default withMarkdownControl;
diff --git a/core/src/widgets/mdx/index.ts b/core/src/widgets/mdx/index.ts
new file mode 100644
index 00000000..5f3a573f
--- /dev/null
+++ b/core/src/widgets/mdx/index.ts
@@ -0,0 +1,22 @@
+import withMarkdownControl from '../markdown/withMarkdownControl';
+import previewComponent from '../markdown/MarkdownPreview';
+import schema from '../markdown/schema';
+
+import type { MarkdownField, WidgetParam } from '@staticcms/core/interface';
+
+const controlComponent = withMarkdownControl({ useMdx: true });
+
+const MdxWidget = (): WidgetParam => {
+ return {
+ name: 'mdx',
+ controlComponent,
+ previewComponent,
+ options: {
+ schema,
+ },
+ };
+};
+
+export { controlComponent as MdxControl, previewComponent as MdxPreview, schema as MdxSchema };
+
+export default MdxWidget;
diff --git a/core/test/setupEnv.js b/core/test/setupEnv.js
index 5835880d..b4e5098d 100644
--- a/core/test/setupEnv.js
+++ b/core/test/setupEnv.js
@@ -10,5 +10,8 @@ if (typeof window === 'undefined') {
removeItem: jest.fn(),
getItem: jest.fn(),
},
+ navigator: {
+ platform: 'Win',
+ },
};
}
diff --git a/core/tsconfig.base.json b/core/tsconfig.base.json
index d3528b9c..265ed66d 100644
--- a/core/tsconfig.base.json
+++ b/core/tsconfig.base.json
@@ -4,8 +4,8 @@
"declarationDir": "dist",
"emitDeclarationOnly": true,
"jsx": "react",
- "target": "esnext",
- "module": "esnext",
+ "target": "ES2020",
+ "module": "ES2020",
"moduleResolution": "node",
"esModuleInterop": true,
"preserveSymlinks": true,
@@ -17,7 +17,7 @@
"noUnusedLocals": true,
"noUnusedParameters": true,
"baseUrl": "./",
- "lib": ["DOM", "es6", "ES2015"],
+ "lib": ["DOM", "es6", "ES2015", "ES2020"],
"paths": {
"@staticcms/boolean": ["./src/widgets/boolean"],
"@staticcms/boolean/*": ["./src/widgets/boolean/*"],