feat: add markdown shortcodes (#215)
This commit is contained in:
parent
388de1e0c4
commit
bb84382f6e
2
.github/workflows/core.yml
vendored
2
.github/workflows/core.yml
vendored
@ -79,4 +79,4 @@ jobs:
|
||||
- name: Test
|
||||
working-directory: ./core
|
||||
run: |
|
||||
yarn test
|
||||
yarn test:ci
|
||||
|
@ -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
|
||||
|
||||
|
@ -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}`,
|
||||
},
|
||||
'',
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
@ -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'],
|
||||
};
|
||||
|
@ -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",
|
||||
|
45
core/src/__mocks__/@udecode/plate.ts
Normal file
45
core/src/__mocks__/@udecode/plate.ts
Normal 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 {};
|
@ -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(),
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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';
|
||||
|
@ -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;
|
@ -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;
|
||||
|
@ -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',
|
||||
|
2
core/src/widgets/markdown/mdx/index.ts
Normal file
2
core/src/widgets/markdown/mdx/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './withShortcodeMdxComponent';
|
||||
export { default as withShortcodeElement } from './withShortcodeMdxComponent';
|
36
core/src/widgets/markdown/mdx/withShortcodeMdxComponent.tsx
Normal file
36
core/src/widgets/markdown/mdx/withShortcodeMdxComponent.tsx
Normal 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;
|
@ -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],
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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 />}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -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}
|
||||
|
@ -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;
|
@ -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,
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -7,4 +7,5 @@ export * from './image';
|
||||
export * from './link';
|
||||
export * from './list';
|
||||
export * from './paragraph';
|
||||
export * from './shortcode';
|
||||
export * from './table';
|
||||
|
@ -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;
|
@ -1,2 +1,2 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export { default as Paragraph } from './Paragraph';
|
||||
export { default as Paragraph } from './ParagraphElement';
|
||||
|
@ -0,0 +1,2 @@
|
||||
export * from './withShortcodeElement';
|
||||
export { default as withShortcodeElement } from './withShortcodeElement';
|
@ -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;
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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);
|
||||
});
|
@ -1,3 +1,4 @@
|
||||
export * from './useMarkdownToSlate';
|
||||
export { default as useMarkdownToSlate } from './useMarkdownToSlate';
|
||||
export * from './useMdx';
|
||||
export { default as useMdx } from './useMdx';
|
||||
|
61
core/src/widgets/markdown/plate/hooks/useMarkdownToSlate.ts
Normal file
61
core/src/widgets/markdown/plate/hooks/useMarkdownToSlate.ts
Normal 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;
|
@ -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;
|
@ -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],
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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';
|
||||
|
@ -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;
|
@ -0,0 +1,2 @@
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export { default as createShortcodePlugin } from './createShortcodePlugin';
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
@ -1,2 +1,3 @@
|
||||
export * from './serializeMarkdown';
|
||||
export { default as serializeMarkdown } from './serializeMarkdown';
|
||||
export * from './slate';
|
||||
export { default as serializerMarkdown } from './serializerMarkdown';
|
||||
|
@ -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');
|
||||
}
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
@ -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 = {
|
||||
|
@ -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 || '' };
|
||||
|
@ -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';
|
||||
|
@ -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;
|
||||
}
|
@ -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
127
core/src/widgets/markdown/withMarkdownControl.tsx
Normal file
127
core/src/widgets/markdown/withMarkdownControl.tsx
Normal 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;
|
22
core/src/widgets/mdx/index.ts
Normal file
22
core/src/widgets/mdx/index.ts
Normal 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;
|
@ -10,5 +10,8 @@ if (typeof window === 'undefined') {
|
||||
removeItem: jest.fn(),
|
||||
getItem: jest.fn(),
|
||||
},
|
||||
navigator: {
|
||||
platform: 'Win',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
@ -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/*"],
|
||||
|
Loading…
x
Reference in New Issue
Block a user