feat: add markdown shortcodes (#215)

This commit is contained in:
Daniel Lautzenheiser 2022-12-11 09:03:53 -05:00 committed by GitHub
parent 388de1e0c4
commit bb84382f6e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
51 changed files with 4229 additions and 399 deletions

View File

@ -79,4 +79,4 @@ jobs:
- name: Test
working-directory: ./core
run: |
yarn test
yarn test:ci

View File

@ -174,13 +174,13 @@ widget: 'markdown',
## Formatting
<font style={{ color: 'red', backgroundColor: 'black' }}>Colored Text</font>
**Bold**, *Italic*, ***both***
<p align="center">Centered Text</p>
~~Strikethrough~~
**Bold**, *Italic*, ***both***, <u>Underlined</u>
## Shortcodes
~~Strikethrough~~, <sub>subscript</sub>, <sup>superscript</sup>
[youtube|p6h-rYSVX90]
## Support

View File

@ -118,3 +118,56 @@ CMS.registerAdditionalLink({
icon: 'page',
},
});
CMS.registerShortcode('youtube', {
label: 'YouTube',
openTag: '[',
closeTag: ']',
separator: '|',
toProps: args => {
if (args.length > 0) {
return { src: args[0] };
}
return { src: '' };
},
toArgs: ({ src }) => {
return [src];
},
control: ({ src, onChange }) => {
return h('span', {}, [
h('input', {
key: 'control-input',
value: src,
onChange: event => {
onChange({ src: event.target.value });
},
}),
h(
'iframe',
{
key: 'control-preview',
width: '420',
height: '315',
src: `https://www.youtube.com/embed/${src}`,
},
'',
),
]);
},
preview: ({ src }) => {
return h(
'span',
{},
h(
'iframe',
{
width: '420',
height: '315',
src: `https://www.youtube.com/embed/${src}`,
},
'',
),
);
},
});

View File

@ -12,6 +12,6 @@ module.exports = {
...pathsToModuleNameMapper(compilerOptions.paths, { prefix: '<rootDir>/' }),
'\\.(css|less)$': '<rootDir>/src/__mocks__/styleMock.ts',
},
transformIgnorePatterns: ['node_modules/(?!(url-join|array-move|ol)/)'],
transformIgnorePatterns: [],
setupFiles: ['./test/setupEnv.js'],
};

View File

@ -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",

View File

@ -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 {};

View File

@ -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(),

View File

@ -889,6 +889,26 @@ export interface MarkdownEditorOptions {
plugins?: MarkdownPluginFactory[];
}
export type ShortcodeControlProps<P = {}> = P & {
onChange: (props: P) => void;
controlProps: WidgetControlProps<string, MarkdownField>;
};
export type ShortcodePreviewProps<P = {}> = P & {
previewProps: WidgetPreviewProps<string, MarkdownField>;
};
export interface ShortcodeConfig<P = {}> {
label?: string;
openTag: string;
closeTag: string;
separator: string;
toProps?: (args: string[]) => P;
toArgs?: (props: P) => string[];
control: ComponentType<ShortcodeControlProps>;
preview: ComponentType<ShortcodePreviewProps>;
}
export enum CollectionType {
FOLDER,
FILES,

View File

@ -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<string, ShortcodeConfig>;
}
/**
@ -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<T = unknown, F extends BaseField = UnknownField>(
options?: WidgetOptions<T, F>,
): void;
export function registerWidget<T = unknown, F extends BaseField = UnknownField>(
name: string | WidgetParam<T, F> | WidgetParam[],
nameOrWidgetOrWidgets: string | WidgetParam<T, F> | WidgetParam[],
control?: string | Widget<T, F>['control'],
preview?: Widget<T, F>['preview'],
{
@ -143,22 +146,22 @@ export function registerWidget<T = unknown, F extends BaseField = UnknownField>(
getDefaultValue,
}: WidgetOptions<T, F> = {},
): 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<T = unknown, F extends BaseField = UnknownField>(
schema,
};
}
} else if (typeof name === 'object') {
} else if (typeof nameOrWidgetOrWidgets === 'object') {
const {
name: widgetName,
controlComponent: control,
@ -178,7 +181,7 @@ export function registerWidget<T = unknown, F extends BaseField = UnknownField>(
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<string, ShortcodeConfig> {
return registry.shortcodes;
}

View File

@ -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';

View File

@ -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<WidgetControlProps<string, MarkdownField>> = ({
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(
() => (
<StyledEditorWrapper key="markdown-control-wrapper">
<FieldLabel
key="markdown-control-label"
isActive={hasFocus}
hasErrors={hasErrors}
onClick={handleLabelClick}
>
{label}
</FieldLabel>
{loaded ? (
<PlateEditor
initialValue={slateValue}
collection={collection}
entry={entry}
field={field}
onChange={handleOnChange}
onFocus={handleOnFocus}
onBlur={handleOnBlur}
/>
) : null}
<Outline
key="markdown-control-outline"
hasLabel
hasError={hasErrors}
active={hasFocus || debouncedFocus}
/>
</StyledEditorWrapper>
),
// eslint-disable-next-line react-hooks/exhaustive-deps
[hasErrors, hasFocus, label, loaded, slateValue],
);
};
export default MarkdownControl;

View File

@ -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<WidgetPreviewProps<string, MarkdownField>> = ({ value }) => {
useEffect(() => {
// viewer.current?.getInstance().setMarkdown(value ?? '');
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]);
const MarkdownPreview: FC<WidgetPreviewProps<string, MarkdownField>> = 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<WidgetPreviewProps<string, MarkdownField>> = ({ value
}
}, [state.file]);
const components = useMemo(() => ({}), []);
return useMemo(() => {
if (!value) {
return null;

View File

@ -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<string, MarkdownField> => {
return {
name: 'markdown',

View File

@ -0,0 +1,2 @@
export * from './withShortcodeMdxComponent';
export { default as withShortcodeElement } from './withShortcodeMdxComponent';

View File

@ -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<string, MarkdownField>;
}
interface ShortcodeMdxComponentProps {
shortcode: string;
args: string[];
}
const withShortcodeMdxComponent = ({ previewProps }: WithShortcodeMdxComponentProps) => {
const ShortcodeMdxComponent: FC<ShortcodeMdxComponentProps> = ({ 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 ? <ShortcodePreview previewProps={previewProps} {...props} /> : null;
};
return ShortcodeMdxComponent;
};
export default withShortcodeMdxComponent;

View File

@ -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<MarkdownField>;
entry: Entry;
field: MarkdownField;
useMdx: boolean;
controlProps: WidgetControlProps<string, MarkdownField>;
onChange: (value: MdValue) => void;
onFocus: () => void;
onBlur: () => void;
@ -122,6 +131,8 @@ const PlateEditor: FC<PlateEditorProps> = ({
collection,
entry,
field,
useMdx,
controlProps,
onChange,
onFocus,
onBlur,
@ -130,15 +141,15 @@ const PlateEditor: FC<PlateEditorProps> = ({
const editorContainerRef = useRef<HTMLDivElement | null>(null);
const innerEditorContainerRef = useRef<HTMLDivElement | null>(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<PlateEditorProps> = ({
[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<AnyObject, MdValue>[] = [
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<AutoformatPlugin<MdValue, MdEditor>, 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<AutoformatPlugin<MdValue, MdEditor>, 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<PlateEditorProps> = ({
<div key="editor-outer_wrapper" ref={outerEditorContainerRef} style={styles.container}>
<Toolbar
key="toolbar"
useMdx={useMdx}
containerRef={outerEditorContainerRef.current}
collection={collection}
field={field}
@ -265,8 +288,8 @@ const PlateEditor: FC<PlateEditorProps> = ({
id={id}
editableProps={{
...editableProps,
onFocus: handleOnFocus,
onBlur: handleOnBlur,
onFocus,
onBlur,
}}
>
<div
@ -276,8 +299,8 @@ const PlateEditor: FC<PlateEditorProps> = ({
>
<BalloonToolbar
key="balloon-toolbar"
useMdx={useMdx}
containerRef={innerEditorContainerRef.current}
hasEditorFocus={hasEditorFocus}
collection={collection}
field={field}
entry={entry}
@ -292,7 +315,7 @@ const PlateEditor: FC<PlateEditorProps> = ({
</StyledPlateEditor>
),
// eslint-disable-next-line react-hooks/exhaustive-deps
[collection, field, handleOnBlur, handleOnFocus, initialValue, onChange, plugins],
[collection, field, onBlur, onFocus, initialValue, onChange, plugins],
);
};

View File

@ -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<MarkdownField>;
field: MarkdownField;
entry: Entry;
}
const BalloonToolbar: FC<BalloonToolbarProps> = ({
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<BalloonToolbarProps> = ({
return [];
}
// Selected text buttons
if (selectionText && selectionExpanded) {
return [
<BasicMarkToolbarButtons key="selection-basic-mark-buttons" />,
<BasicMarkToolbarButtons key="selection-basic-mark-buttons" useMdx={useMdx} />,
<BasicElementToolbarButtons
key="selection-basic-element-buttons"
hideFontTypeSelect={isInTableCell}
@ -147,6 +151,7 @@ const BalloonToolbar: FC<BalloonToolbarProps> = ({
].filter(Boolean);
}
// Empty paragraph, not first line
if (
editor.children.length > 1 &&
node &&
@ -164,7 +169,7 @@ const BalloonToolbar: FC<BalloonToolbarProps> = ({
parent[0].children.length === 1
) {
return [
<BasicMarkToolbarButtons key="empty-basic-mark-buttons" />,
<BasicMarkToolbarButtons key="empty-basic-mark-buttons" useMdx={useMdx} />,
<BasicElementToolbarButtons
key="empty-basic-element-buttons"
hideFontTypeSelect={isInTableCell}
@ -179,6 +184,7 @@ const BalloonToolbar: FC<BalloonToolbarProps> = ({
entry={entry}
onMediaToggle={setMediaOpen}
/>,
!useMdx ? <ShortcodeToolbarButton key="shortcode-button" /> : null,
];
}
}
@ -186,18 +192,20 @@ const BalloonToolbar: FC<BalloonToolbarProps> = ({
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<BalloonToolbarProps> = ({
>
<StyledPopperContent>
{(groups.length > 0 ? groups : debouncedGroups).map((group, index) => [
index !== 0 ? <StyledDivider key={`table-divider-${index}`} /> : null,
index !== 0 ? <StyledDivider key={`balloon-toolbar-divider-${index}`} /> : null,
group,
])}
</StyledPopperContent>

View File

@ -10,9 +10,24 @@ import type { FC } from 'react';
const AlignToolbarButtons: FC = () => {
return (
<>
<AlignToolbarButton tooltip="Align Left" value="left" icon={<FormatAlignLeftIcon />} />
<AlignToolbarButton tooltip="Align Center" value="center" icon={<FormatAlignCenterIcon />} />
<AlignToolbarButton tooltip="Align Right" value="right" icon={<FormatAlignRightIcon />} />
<AlignToolbarButton
key="algin-button-left"
tooltip="Align Left"
value="left"
icon={<FormatAlignLeftIcon />}
/>
<AlignToolbarButton
key="algin-button-center"
tooltip="Align Center"
value="center"
icon={<FormatAlignCenterIcon />}
/>
<AlignToolbarButton
key="algin-button-right"
tooltip="Align Right"
value="right"
icon={<FormatAlignRightIcon />}
/>
</>
);
};

View File

@ -22,33 +22,42 @@ import type { FC } from 'react';
export interface BasicMarkToolbarButtonsProps {
extended?: boolean;
useMdx: boolean;
}
const BasicMarkToolbarButtons: FC<BasicMarkToolbarButtonsProps> = ({ extended = false }) => {
const BasicMarkToolbarButtons: FC<BasicMarkToolbarButtonsProps> = ({
extended = false,
useMdx,
}) => {
return (
<>
<MarkToolbarButton tooltip="Bold" type={MARK_BOLD} icon={<FormatBoldIcon />} />
<MarkToolbarButton tooltip="Italic" type={MARK_ITALIC} icon={<FormatItalicIcon />} />
<MarkToolbarButton
tooltip="Underline"
type={MARK_UNDERLINE}
icon={<FormatUnderlinedIcon />}
/>
{useMdx ? (
<MarkToolbarButton
key="underline-button"
tooltip="Underline"
type={MARK_UNDERLINE}
icon={<FormatUnderlinedIcon />}
/>
) : null}
<MarkToolbarButton
tooltip="Strikethrough"
type={MARK_STRIKETHROUGH}
icon={<FormatStrikethroughIcon />}
/>
<MarkToolbarButton tooltip="Code" type={MARK_CODE} icon={<CodeIcon />} />
{extended ? (
{useMdx && extended ? (
<>
<MarkToolbarButton
key="superscript-button"
tooltip="Superscript"
type={MARK_SUPERSCRIPT}
clear={MARK_SUBSCRIPT}
icon={<SuperscriptIcon />}
/>
<MarkToolbarButton
key="subscript-button"
tooltip="Subscript"
type={MARK_SUBSCRIPT}
clear={MARK_SUPERSCRIPT}

View File

@ -0,0 +1,73 @@
import DataArrayIcon from '@mui/icons-material/DataArray';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import { focusEditor, insertNodes } from '@udecode/plate-core';
import React, { useCallback, useMemo, useState } from 'react';
import { getShortcodes } from '../../../../../lib/registry';
import { toTitleCase } from '../../../../../lib/util/string.util';
import { ELEMENT_SHORTCODE, useMdPlateEditorState } from '../../plateTypes';
import ToolbarButton from './common/ToolbarButton';
import type { FC, MouseEvent } from 'react';
import type { MdEditor } from '../../plateTypes';
const ShortcodeToolbarButton: FC = () => {
const editor = useMdPlateEditorState();
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
const handleClick = useCallback((_editor: MdEditor, event: MouseEvent<HTMLButtonElement>) => {
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 (
<>
<ToolbarButton
key="shortcode-button"
tooltip="Add Shortcode"
icon={<DataArrayIcon />}
onClick={handleClick}
/>
<Menu
id="shortcode-menu"
anchorEl={anchorEl}
open={open}
onClose={handleClose}
MenuListProps={{
'aria-labelledby': 'shortcode-button',
}}
>
{Object.keys(configs).map(name => {
const config = configs[name];
return (
<MenuItem key={`shortcode-${name}`} onClick={handleShortcodeClick(name)}>
{config.label ?? toTitleCase(name)}
</MenuItem>
);
})}
</Menu>
</>
);
};
export default ShortcodeToolbarButton;

View File

@ -154,6 +154,7 @@ const MediaPopover = <T extends FileOrImageField | MarkdownField>({
useEffect(() => {
if (
anchorEl &&
!debouncedHasEditorFocus &&
!hasEditorFocus &&
!hasFocus &&
@ -163,6 +164,7 @@ const MediaPopover = <T extends FileOrImageField | MarkdownField>({
handleClose(false);
}
}, [
anchorEl,
debouncedHasEditorFocus,
debouncedHasFocus,
handleClose,

View File

@ -117,7 +117,7 @@ const withImageElement = ({ containerRef, collection, entry, field }: WithImageE
}, [handleClose, hasEditorFocus, element, selection, editor, handleOpenPopover]);
return (
<div onBlur={handleBlur}>
<span onBlur={handleBlur}>
<img
ref={imageRef}
src={assetSource}
@ -142,7 +142,7 @@ const withImageElement = ({ containerRef, collection, entry, field }: WithImageE
forImage
/>
{children}
</div>
</span>
);
};

View File

@ -7,4 +7,5 @@ export * from './image';
export * from './link';
export * from './list';
export * from './paragraph';
export * from './shortcode';
export * from './table';

View File

@ -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<PlateRenderElementProps<MdValue, MdParagraphElement>> = ({
const ParagraphElement: FC<PlateRenderElementProps<MdValue, MdParagraphElement>> = ({
children,
element: { align },
}) => {
return <p style={{ textAlign: align }}>{children}</p>;
};
export default Paragraph;
export default ParagraphElement;

View File

@ -1,2 +1,2 @@
/* eslint-disable import/prefer-default-export */
export { default as Paragraph } from './Paragraph';
export { default as Paragraph } from './ParagraphElement';

View File

@ -0,0 +1,2 @@
export * from './withShortcodeElement';
export { default as withShortcodeElement } from './withShortcodeElement';

View File

@ -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<string, MarkdownField>;
}
const withShortcodeElement = ({ controlProps }: WithShortcodeElementProps) => {
const ShortcodeElement: FC<PlateRenderElementProps<MdValue, MdShortcodeElement>> = ({
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 (
<span contentEditable={false}>
{ShortcodeControl ? (
<ShortcodeControl controlProps={controlProps} onChange={handleOnChange} {...props} />
) : null}
{children}
</span>
);
};
return ShortcodeElement;
};
export default withShortcodeElement;

View File

@ -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<MarkdownField>;
field: MarkdownField;
entry: Entry;
}
const Toolbar: FC<ToolbarProps> = ({ containerRef, collection, field, entry }) => {
const Toolbar: FC<ToolbarProps> = ({ useMdx, containerRef, collection, field, entry }) => {
const groups = [
<BasicMarkToolbarButtons key="basic-mark-buttons" useMdx={useMdx} extended />,
<BasicElementToolbarButtons key="basic-element-buttons" />,
<ListToolbarButtons key="list-buttons" />,
useMdx ? <ColorToolbarButtons key="color-buttons" /> : null,
useMdx ? <AlignToolbarButtons key="align-mark-buttons" /> : null,
<MediaToolbarButton
key="media-buttons"
containerRef={containerRef}
collection={collection}
field={field}
entry={entry}
/>,
!useMdx ? <ShortcodeToolbarButton key="shortcode-button" /> : null,
].filter(Boolean);
return (
<StyledToolbar>
<BasicMarkToolbarButtons key="basic-mark-buttons" extended />
<StyledDivider />
<BasicElementToolbarButtons key="basic-element-buttons" />
<StyledDivider />
<ListToolbarButtons key="list-buttons" />
<StyledDivider />
<ColorToolbarButtons key="color-buttons" />
<StyledDivider />
<AlignToolbarButtons key="align-mark-buttons" />
<StyledDivider />
<MediaToolbarButton
key="media-buttons"
containerRef={containerRef}
collection={collection}
field={field}
entry={entry}
/>
{groups.map((group, index) => [
index !== 0 ? <StyledDivider key={`toolbar-divider-${index}`} /> : null,
group,
])}
</StyledToolbar>
);
};

View File

@ -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>', '<\\/font>')
.replace('<u>', '<u\\>')
.replace('</u>', '<\\/u>')
.replace('<sub>', '<sub\\>')
.replace('</sub>', '<\\/sub>')
.replace('<sup>', '<sup\\>')
.replace('</sup>', '<\\/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);
});

View File

@ -1,3 +1,4 @@
export * from './useMarkdownToSlate';
export { default as useMarkdownToSlate } from './useMarkdownToSlate';
export * from './useMdx';
export { default as useMdx } from './useMdx';

View File

@ -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<string, ShortcodeConfig>;
useMdx: boolean;
}
export const markdownToSlate = async (
markdownValue: string,
{ useMdx, shortcodeConfigs }: UseMarkdownToSlateOptions,
) => {
return new Promise<MdValue>(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<MdValue>([]);
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;

View File

@ -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<MdValue>([]);
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;

View File

@ -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],

View File

@ -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;
}

View File

@ -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';

View File

@ -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;

View File

@ -0,0 +1,2 @@
// eslint-disable-next-line import/prefer-default-export
export { default as createShortcodePlugin } from './createShortcodePlugin';

View File

@ -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);
});
});
});

View File

@ -1,2 +1,3 @@
export * from './serializeMarkdown';
export { default as serializeMarkdown } from './serializeMarkdown';
export * from './slate';
export { default as serializerMarkdown } from './serializerMarkdown';

View File

@ -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<CSSProperties, 'color' | 'backgroundColor' | 'textAlign'>;
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<BlockType, 'children'> {
export interface MdBlockType extends Omit<BlockType, 'children'> {
children: Array<MdBlockType | MdLeafType>;
}
interface Options {
interface SerializeMarkdownNodeOptions {
isInTable?: boolean;
isInCode?: boolean;
listDepth?: number;
blockquoteDepth?: number;
ignoreParagraphNewline?: boolean;
useMdx: boolean;
index: number;
shortcodeConfigs: Record<string, ShortcodeConfig>;
}
const isLeafNode = (node: MdBlockType | MdLeafType): node is MdLeafType => {
return typeof (node as MdLeafType).text === 'string';
};
const VOID_ELEMENTS: Array<keyof typeof NodeTypes> = ['thematic_break', 'image', 'code_block'];
const VOID_ELEMENTS: Array<keyof typeof NodeTypes> = [
'thematic_break',
'image',
'code_block',
'shortcode',
'tableCell',
'tableHeaderCell',
];
const BREAK_TAG = '<br />';
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 `<p style={{ textAlign: '${paragraphNode.align}' }}>${children}</p>`;
const paragraphNode = chunk as MdParagraphElement;
if (useMdx && paragraphNode.align) {
return retainWhitespaceAndFormat(
children,
`<p style={{ textAlign: '${paragraphNode.align}' }}>`,
'</p>\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<string, ShortcodeConfig<{}>>;
}
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');
}

View File

@ -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 = '<Shortcode shortcode="youtube" args={[\'p6h-rYSVX90\']} />';
expect(processShortcodeConfigToMdx(testShortcodeConfigs, markdown)).toBe(mdx);
});
it('converts shortcode with no args', () => {
const markdown = '[youtube]';
const mdx = '<Shortcode shortcode="youtube" args={[]} />';
expect(processShortcodeConfigToMdx(testShortcodeConfigs, markdown)).toBe(mdx);
});
it('converts shortcode with multiple args', () => {
const markdown = '[youtube|p6h-rYSVX90|somethingElse|andOneMore]';
const mdx =
"<Shortcode shortcode=\"youtube\" args={['p6h-rYSVX90', 'somethingElse', 'andOneMore']} />";
expect(processShortcodeConfigToMdx(testShortcodeConfigs, markdown)).toBe(mdx);
});
it('shortcode with text before', () => {
const markdown = 'Text before [youtube|p6h-rYSVX90]';
const mdx = 'Text before <Shortcode shortcode="youtube" args={[\'p6h-rYSVX90\']} />';
expect(processShortcodeConfigToMdx(testShortcodeConfigs, markdown)).toBe(mdx);
});
it('shortcode with text after', () => {
const markdown = '[youtube|p6h-rYSVX90] and text after';
const mdx = '<Shortcode shortcode="youtube" args={[\'p6h-rYSVX90\']} /> 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 <Shortcode shortcode="youtube" args={[\'p6h-rYSVX90\']} /> 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 <Shortcode shortcode="youtube" args={[\'p6h-rYSVX90\']} /> and <Shortcode shortcode="twitter" args={[\'917359331535966209\']} />';
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 <Shortcode shortcode="youtube" args={[\'p6h-rYSVX90\']} />, <Shortcode shortcode="youtube" args={[\'p6h-rYSVX90\']} />, <Shortcode shortcode="twitter" args={[\'917359331535966209\']} /> and <Shortcode shortcode="youtube" args={[\'p6h-rYSVX90\']} />';
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 <Shortcode shortcode="youtube" args={[\'p6h-rYSVX90\']} />, [someOtherShortcode|andstuff] and <Shortcode shortcode="twitter" args={[\'917359331535966209\']} />';
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);
});
});
});

View File

@ -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<BlockType | LeafType>;
}
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<MdastNodeType, 'mdxJsxTextElement'>;
@ -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<DeserializedNode>;
url: string | undefined;
caption: TextNode;
alt: string | undefined;
};
export type TableNode = {

View File

@ -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<string, string>) {
const styleAttribute = node.attributes?.find(
a => a.name === 'style',
) as StyleMdxMdastNodeAttribute;
const nodeStyles: TextNodeStyles = {};
if (styleAttribute) {
let styles: Record<string, string> = {};
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<string, ShortcodeConfig>;
index: number;
}
export default function deserializeMarkdown(node: MdastNode, options: Options) {
let children: Array<DeserializedNode> = [{ 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<string, string> = {};
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<TextNode>),
...persistLeafFormats(children as Array<MdastNode>),
} 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 || '' };

View File

@ -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';

View File

@ -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<string, ShortcodeConfig>,
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],
`<Shortcode shortcode="${matches[1]}" args={[${argsOutput}]} />`,
);
}
}
return output;
}

View File

@ -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<MdastNode> }) => {
return node.children.map(c => transform(c, {}));
export interface ToSlatePluginOptions {
shortcodeConfigs: Record<string, ShortcodeConfig>;
useMdx: boolean;
}
const toSlatePlugin = ({ shortcodeConfigs, useMdx }: ToSlatePluginOptions): Plugin =>
function () {
const compiler = (node: { children: Array<MdastNode> }) => {
return node.children.map((c, index) => transform(c, { shortcodeConfigs, useMdx, index }));
};
this.Compiler = compiler;
};
this.Compiler = compiler;
};
export default toSlatePlugin;

File diff suppressed because it is too large Load Diff

View File

@ -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<WidgetControlProps<string, MarkdownField>> = 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(
() => (
<StyledEditorWrapper key="markdown-control-wrapper">
<FieldLabel
key="markdown-control-label"
isActive={hasFocus}
hasErrors={hasErrors}
onClick={handleLabelClick}
>
{label}
</FieldLabel>
{loaded ? (
<PlateEditor
initialValue={slateValue}
collection={collection}
entry={entry}
field={field}
useMdx={useMdx}
controlProps={controlProps}
onChange={handleOnChange}
onFocus={handleOnFocus}
onBlur={handleOnBlur}
/>
) : null}
<Outline
key="markdown-control-outline"
hasLabel
hasError={hasErrors}
active={hasFocus || debouncedFocus}
/>
</StyledEditorWrapper>
),
// 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;

View File

@ -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<string, MarkdownField> => {
return {
name: 'mdx',
controlComponent,
previewComponent,
options: {
schema,
},
};
};
export { controlComponent as MdxControl, previewComponent as MdxPreview, schema as MdxSchema };
export default MdxWidget;

View File

@ -10,5 +10,8 @@ if (typeof window === 'undefined') {
removeItem: jest.fn(),
getItem: jest.fn(),
},
navigator: {
platform: 'Win',
},
};
}

View File

@ -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/*"],