From bb84382f6e2a9acf6d7e431d9762b85418154b68 Mon Sep 17 00:00:00 2001 From: Daniel Lautzenheiser Date: Sun, 11 Dec 2022 09:03:53 -0500 Subject: [PATCH] feat: add markdown shortcodes (#215) --- .github/workflows/core.yml | 2 +- core/dev-test/index.html | 8 +- core/dev-test/index.js | 53 + core/jest.config.js | 2 +- core/package.json | 1 + core/src/__mocks__/@udecode/plate.ts | 45 + core/src/extensions.ts | 2 + core/src/interface.ts | 20 + core/src/lib/registry.ts | 41 +- core/src/widgets/index.tsx | 2 + core/src/widgets/markdown/MarkdownControl.tsx | 113 - core/src/widgets/markdown/MarkdownPreview.tsx | 28 +- core/src/widgets/markdown/index.ts | 4 +- core/src/widgets/markdown/mdx/index.ts | 2 + .../mdx/withShortcodeMdxComponent.tsx | 36 + .../widgets/markdown/plate/PlateEditor.tsx | 169 +- .../balloon-toolbar/BalloonToolbar.tsx | 34 +- .../buttons/AlignToolbarButtons.tsx | 21 +- .../buttons/BasicMarkToolbarButtons.tsx | 23 +- .../buttons/ShortcodeToolbarButton.tsx | 73 + .../components/nodes/common/MediaPopover.tsx | 2 + .../nodes/image/withImageElement.tsx | 4 +- .../markdown/plate/components/nodes/index.ts | 1 + .../{Paragraph.tsx => ParagraphElement.tsx} | 4 +- .../plate/components/nodes/paragraph/index.ts | 2 +- .../plate/components/nodes/shortcode/index.ts | 2 + .../nodes/shortcode/withShortcodeElement.tsx | 57 + .../plate/components/toolbar/Toolbar.tsx | 41 +- .../__tests__/useMarkdownToSlate.spec.ts | 50 + .../src/widgets/markdown/plate/hooks/index.ts | 1 + .../plate/hooks/useMarkdownToSlate.ts | 61 + .../plate/hooks/useMarkdownToSlate.tsx | 40 - .../widgets/markdown/plate/hooks/useMdx.tsx | 2 + core/src/widgets/markdown/plate/plateTypes.ts | 25 +- .../widgets/markdown/plate/plugins/index.ts | 1 + .../shortcode/createShortcodePlugin.ts | 12 + .../markdown/plate/plugins/shortcode/index.ts | 2 + .../__tests__/serializeMarkdown.spec.ts | 26 + .../markdown/plate/serialization/index.ts | 3 +- ...alizerMarkdown.ts => serializeMarkdown.ts} | 139 +- .../__test__/processShortcodeConfig.spec.ts | 90 + .../plate/serialization/slate/ast-types.ts | 20 +- .../slate/deserializeMarkdown.ts | 134 +- .../plate/serialization/slate/index.ts | 1 + .../slate/processShortcodeConfig.ts | 98 + .../serialization/slate/toSlatePlugin.ts | 19 +- .../tests-util/serializationTests.util.tsx | 2954 +++++++++++++++++ .../widgets/markdown/withMarkdownControl.tsx | 127 + core/src/widgets/mdx/index.ts | 22 + core/test/setupEnv.js | 3 + core/tsconfig.base.json | 6 +- 51 files changed, 4229 insertions(+), 399 deletions(-) create mode 100644 core/src/__mocks__/@udecode/plate.ts delete mode 100644 core/src/widgets/markdown/MarkdownControl.tsx create mode 100644 core/src/widgets/markdown/mdx/index.ts create mode 100644 core/src/widgets/markdown/mdx/withShortcodeMdxComponent.tsx create mode 100644 core/src/widgets/markdown/plate/components/buttons/ShortcodeToolbarButton.tsx rename core/src/widgets/markdown/plate/components/nodes/paragraph/{Paragraph.tsx => ParagraphElement.tsx} (71%) create mode 100644 core/src/widgets/markdown/plate/components/nodes/shortcode/index.ts create mode 100644 core/src/widgets/markdown/plate/components/nodes/shortcode/withShortcodeElement.tsx create mode 100644 core/src/widgets/markdown/plate/hooks/__tests__/useMarkdownToSlate.spec.ts create mode 100644 core/src/widgets/markdown/plate/hooks/useMarkdownToSlate.ts delete mode 100644 core/src/widgets/markdown/plate/hooks/useMarkdownToSlate.tsx create mode 100644 core/src/widgets/markdown/plate/plugins/shortcode/createShortcodePlugin.ts create mode 100644 core/src/widgets/markdown/plate/plugins/shortcode/index.ts create mode 100644 core/src/widgets/markdown/plate/serialization/__tests__/serializeMarkdown.spec.ts rename core/src/widgets/markdown/plate/serialization/{serializerMarkdown.ts => serializeMarkdown.ts} (73%) create mode 100644 core/src/widgets/markdown/plate/serialization/slate/__test__/processShortcodeConfig.spec.ts create mode 100644 core/src/widgets/markdown/plate/serialization/slate/processShortcodeConfig.ts create mode 100644 core/src/widgets/markdown/plate/tests-util/serializationTests.util.tsx create mode 100644 core/src/widgets/markdown/withMarkdownControl.tsx create mode 100644 core/src/widgets/mdx/index.ts diff --git a/.github/workflows/core.yml b/.github/workflows/core.yml index e5a35028..d23d2f72 100644 --- a/.github/workflows/core.yml +++ b/.github/workflows/core.yml @@ -79,4 +79,4 @@ jobs: - name: Test working-directory: ./core run: | - yarn test + yarn test:ci diff --git a/core/dev-test/index.html b/core/dev-test/index.html index ab756e5e..098cf840 100644 --- a/core/dev-test/index.html +++ b/core/dev-test/index.html @@ -174,13 +174,13 @@ widget: 'markdown', ## Formatting -Colored Text +**Bold**, *Italic*, ***both*** -

Centered Text

+~~Strikethrough~~ -**Bold**, *Italic*, ***both***, Underlined +## Shortcodes -~~Strikethrough~~, subscript, superscript +[youtube|p6h-rYSVX90] ## Support diff --git a/core/dev-test/index.js b/core/dev-test/index.js index 37e24961..0428dfd3 100644 --- a/core/dev-test/index.js +++ b/core/dev-test/index.js @@ -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}`, + }, + '', + ), + ); + }, +}); diff --git a/core/jest.config.js b/core/jest.config.js index 4b02fbf4..b59413c2 100644 --- a/core/jest.config.js +++ b/core/jest.config.js @@ -12,6 +12,6 @@ module.exports = { ...pathsToModuleNameMapper(compilerOptions.paths, { prefix: '/' }), '\\.(css|less)$': '/src/__mocks__/styleMock.ts', }, - transformIgnorePatterns: ['node_modules/(?!(url-join|array-move|ol)/)'], + transformIgnorePatterns: [], setupFiles: ['./test/setupEnv.js'], }; diff --git a/core/package.json b/core/package.json index 9968187c..82caaa39 100644 --- a/core/package.json +++ b/core/package.json @@ -25,6 +25,7 @@ "prepublishOnly": "yarn build", "start": "run-s clean develop", "test": "cross-env NODE_ENV=test jest", + "test:ci": "cross-env NODE_ENV=test jest --maxWorkers=2", "type-check": "tsc --watch" }, "main": "dist/static-cms-core.js", diff --git a/core/src/__mocks__/@udecode/plate.ts b/core/src/__mocks__/@udecode/plate.ts new file mode 100644 index 00000000..b5c9da95 --- /dev/null +++ b/core/src/__mocks__/@udecode/plate.ts @@ -0,0 +1,45 @@ +import { + ELEMENT_BLOCKQUOTE, + ELEMENT_CODE_BLOCK, + ELEMENT_H1, + ELEMENT_H2, + ELEMENT_H3, + ELEMENT_H4, + ELEMENT_H5, + ELEMENT_H6, + ELEMENT_IMAGE, + ELEMENT_LI, + ELEMENT_LIC, + ELEMENT_LINK, + ELEMENT_OL, + ELEMENT_PARAGRAPH, + ELEMENT_TABLE, + ELEMENT_TD, + ELEMENT_TH, + ELEMENT_TR, + ELEMENT_UL, +} from '@udecode/plate'; + +export { + ELEMENT_BLOCKQUOTE, + ELEMENT_CODE_BLOCK, + ELEMENT_H1, + ELEMENT_H2, + ELEMENT_H3, + ELEMENT_H4, + ELEMENT_H5, + ELEMENT_H6, + ELEMENT_IMAGE, + ELEMENT_LI, + ELEMENT_LIC, + ELEMENT_LINK, + ELEMENT_OL, + ELEMENT_PARAGRAPH, + ELEMENT_TABLE, + ELEMENT_TD, + ELEMENT_TH, + ELEMENT_TR, + ELEMENT_UL, +}; + +export default {}; diff --git a/core/src/extensions.ts b/core/src/extensions.ts index 10887345..7498f813 100644 --- a/core/src/extensions.ts +++ b/core/src/extensions.ts @@ -18,6 +18,7 @@ import { ListWidget, MapWidget, MarkdownWidget, + MdxWidget, NumberWidget, ObjectWidget, RelationWidget, @@ -44,6 +45,7 @@ export default function addExtensions() { ListWidget(), MapWidget(), MarkdownWidget(), + MdxWidget(), NumberWidget(), ObjectWidget(), RelationWidget(), diff --git a/core/src/interface.ts b/core/src/interface.ts index f0d17b07..dfee55d8 100644 --- a/core/src/interface.ts +++ b/core/src/interface.ts @@ -889,6 +889,26 @@ export interface MarkdownEditorOptions { plugins?: MarkdownPluginFactory[]; } +export type ShortcodeControlProps

= P & { + onChange: (props: P) => void; + controlProps: WidgetControlProps; +}; + +export type ShortcodePreviewProps

= P & { + previewProps: WidgetPreviewProps; +}; + +export interface ShortcodeConfig

{ + label?: string; + openTag: string; + closeTag: string; + separator: string; + toProps?: (args: string[]) => P; + toArgs?: (props: P) => string[]; + control: ComponentType; + preview: ComponentType; +} + export enum CollectionType { FOLDER, FILES, diff --git a/core/src/lib/registry.ts b/core/src/lib/registry.ts index 6678704e..1eb975e0 100644 --- a/core/src/lib/registry.ts +++ b/core/src/lib/registry.ts @@ -14,11 +14,11 @@ import type { EventListener, Field, LocalePhrasesRoot, - MarkdownEditorOptions, MediaLibraryExternalLibrary, MediaLibraryOptions, PreviewStyle, PreviewStyleOptions, + ShortcodeConfig, TemplatePreviewComponent, UnknownField, Widget, @@ -48,7 +48,7 @@ interface Registry { previewStyles: PreviewStyle[]; /** Markdown editor */ - markdownEditorConfig: MarkdownEditorOptions; + shortcodes: Record; } /** @@ -65,7 +65,7 @@ const registry: Registry = { locales: {}, eventHandlers, previewStyles: [], - markdownEditorConfig: {}, + shortcodes: {}, }; export default { @@ -93,6 +93,9 @@ export default { getAdditionalLinks, registerPreviewStyle, getPreviewStyles, + registerShortcode, + getShortcode, + getShortcodes, }; /** @@ -133,7 +136,7 @@ export function registerWidget( options?: WidgetOptions, ): void; export function registerWidget( - name: string | WidgetParam | WidgetParam[], + nameOrWidgetOrWidgets: string | WidgetParam | WidgetParam[], control?: string | Widget['control'], preview?: Widget['preview'], { @@ -143,22 +146,22 @@ export function registerWidget( getDefaultValue, }: WidgetOptions = {}, ): void { - if (Array.isArray(name)) { - name.forEach(widget => { + if (Array.isArray(nameOrWidgetOrWidgets)) { + nameOrWidgetOrWidgets.forEach(widget => { if (typeof widget !== 'object') { console.error(`Cannot register widget: ${widget}`); } else { registerWidget(widget); } }); - } else if (typeof name === 'string') { + } else if (typeof nameOrWidgetOrWidgets === 'string') { // A registered widget control can be reused by a new widget, allowing // multiple copies with different previews. const newControl = ( typeof control === 'string' ? registry.widgets[control]?.control : control ) as Widget['control']; if (newControl) { - registry.widgets[name] = { + registry.widgets[nameOrWidgetOrWidgets] = { control: newControl, preview: preview as Widget['preview'], validator: validator as Widget['validator'], @@ -167,7 +170,7 @@ export function registerWidget( schema, }; } - } else if (typeof name === 'object') { + } else if (typeof nameOrWidgetOrWidgets === 'object') { const { name: widgetName, controlComponent: control, @@ -178,7 +181,7 @@ export function registerWidget( getDefaultValue, schema, } = {}, - } = name; + } = nameOrWidgetOrWidgets; if (registry.widgets[widgetName]) { console.warn(oneLine` Multiple widgets registered with name "${widgetName}". Only the last widget registered with @@ -369,12 +372,20 @@ export function getAdditionalLink(id: string): AdditionalLink | undefined { } /** - * Markdown editor options + * Markdown editor shortcodes */ -export function setMarkdownEditorOptions(options: MarkdownEditorOptions) { - registry.markdownEditorConfig = options; +export function registerShortcode(name: string, config: ShortcodeConfig) { + if (registry.backends[name]) { + console.error(`Shortcode [${name}] already registered. Please choose a different name.`); + return; + } + registry.shortcodes[name] = config; } -export function getMarkdownEditorOptions(): MarkdownEditorOptions { - return registry.markdownEditorConfig; +export function getShortcode(name: string): ShortcodeConfig { + return registry.shortcodes[name]; +} + +export function getShortcodes(): Record { + return registry.shortcodes; } diff --git a/core/src/widgets/index.tsx b/core/src/widgets/index.tsx index 43b11808..53b2a843 100644 --- a/core/src/widgets/index.tsx +++ b/core/src/widgets/index.tsx @@ -16,6 +16,8 @@ export * from './map'; export { default as MapWidget } from './map'; export * from './markdown'; export { default as MarkdownWidget } from './markdown'; +export * from './mdx'; +export { default as MdxWidget } from './mdx'; export * from './number'; export { default as NumberWidget } from './number'; export * from './object'; diff --git a/core/src/widgets/markdown/MarkdownControl.tsx b/core/src/widgets/markdown/MarkdownControl.tsx deleted file mode 100644 index 894c36d2..00000000 --- a/core/src/widgets/markdown/MarkdownControl.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { styled } from '@mui/material/styles'; -import React, { useCallback, useMemo, useState } from 'react'; - -import FieldLabel from '@staticcms/core/components/UI/FieldLabel'; -import Outline from '@staticcms/core/components/UI/Outline'; -import useDebounce from '../../lib/hooks/useDebounce'; -import useMarkdownToSlate from './plate/hooks/useMarkdownToSlate'; -import PlateEditor from './plate/PlateEditor'; -import serialize from './plate/serialization/serializerMarkdown'; - -import type { MarkdownField, WidgetControlProps } from '@staticcms/core/interface'; -import type { FC } from 'react'; -import type { MdValue } from './plate/plateTypes'; -import type { BlockType, LeafType } from './plate/serialization/slate/ast-types'; - -const StyledEditorWrapper = styled('div')` - position: relative; - width: 100%; - - .toastui-editor-main .toastui-editor-md-vertical-style .toastui-editor { - width: 100%; - } - - .toastui-editor-main .toastui-editor-md-splitter { - display: none; - } - - .toastui-editor-md-preview { - display: none; - } - - .toastui-editor-defaultUI { - border: none; - } -`; - -const MarkdownControl: FC> = ({ - label, - value, - onChange, - hasErrors, - collection, - entry, - field, -}) => { - const [internalValue, setInternalValue] = useState(value ?? ''); - const [hasFocus, setHasFocus] = useState(false); - const debouncedFocus = useDebounce(hasFocus, 150); - - const handleOnFocus = useCallback(() => { - setHasFocus(true); - }, []); - - const handleOnBlur = useCallback(() => { - setHasFocus(false); - }, []); - - const handleOnChange = useCallback( - (slateValue: MdValue) => { - const newValue = slateValue.map(v => serialize(v as BlockType | LeafType)).join('\n'); - // console.log('[Plate] slateValue', slateValue, 'newMarkdownValue', newValue); - if (newValue !== internalValue) { - setInternalValue(newValue); - onChange(newValue); - } - }, - [internalValue, onChange], - ); - - const handleLabelClick = useCallback(() => { - // editorRef.current?.getInstance().focus(); - }, []); - - const [slateValue, loaded] = useMarkdownToSlate(internalValue); - - // console.log('[Plate] slateValue', slateValue); - - return useMemo( - () => ( - - - {label} - - {loaded ? ( - - ) : null} - - - ), - // eslint-disable-next-line react-hooks/exhaustive-deps - [hasErrors, hasFocus, label, loaded, slateValue], - ); -}; - -export default MarkdownControl; diff --git a/core/src/widgets/markdown/MarkdownPreview.tsx b/core/src/widgets/markdown/MarkdownPreview.tsx index b82fe833..08de9525 100644 --- a/core/src/widgets/markdown/MarkdownPreview.tsx +++ b/core/src/widgets/markdown/MarkdownPreview.tsx @@ -3,10 +3,13 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { VFileMessage } from 'vfile-message'; import WidgetPreviewContainer from '@staticcms/core/components/UI/WidgetPreviewContainer'; +import { getShortcodes } from '../../lib/registry'; +import withShortcodeMdxComponent from './mdx/withShortcodeMdxComponent'; import useMdx from './plate/hooks/useMdx'; +import { processShortcodeConfigToMdx } from './plate/serialization/slate/processShortcodeConfig'; -import type { FC } from 'react'; import type { MarkdownField, WidgetPreviewProps } from '@staticcms/core/interface'; +import type { FC } from 'react'; interface FallbackComponentProps { error: string; @@ -22,18 +25,23 @@ function FallbackComponent({ error }: FallbackComponentProps) { ); } -const MarkdownPreview: FC> = ({ value }) => { - useEffect(() => { - // viewer.current?.getInstance().setMarkdown(value ?? ''); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [value]); +const MarkdownPreview: FC> = previewProps => { + const { value } = previewProps; + + const components = useMemo( + () => ({ + Shortcode: withShortcodeMdxComponent({ previewProps }), + }), + [previewProps], + ); const [state, setValue] = useMdx(value ?? ''); - const [prevValue, setPrevValue] = useState(value); + const [prevValue, setPrevValue] = useState(''); useEffect(() => { if (prevValue !== value) { - setPrevValue(value ?? ''); - setValue(value ?? ''); + const parsedValue = processShortcodeConfigToMdx(getShortcodes(), value ?? ''); + setPrevValue(parsedValue); + setValue(parsedValue); } }, [prevValue, setValue, value]); @@ -50,8 +58,6 @@ const MarkdownPreview: FC> = ({ value } }, [state.file]); - const components = useMemo(() => ({}), []); - return useMemo(() => { if (!value) { return null; diff --git a/core/src/widgets/markdown/index.ts b/core/src/widgets/markdown/index.ts index e05fd927..26a565ca 100644 --- a/core/src/widgets/markdown/index.ts +++ b/core/src/widgets/markdown/index.ts @@ -1,9 +1,11 @@ -import controlComponent from './MarkdownControl'; +import withMarkdownControl from './withMarkdownControl'; import previewComponent from './MarkdownPreview'; import schema from './schema'; import type { MarkdownField, WidgetParam } from '@staticcms/core/interface'; +const controlComponent = withMarkdownControl({ useMdx: false }); + const MarkdownWidget = (): WidgetParam => { return { name: 'markdown', diff --git a/core/src/widgets/markdown/mdx/index.ts b/core/src/widgets/markdown/mdx/index.ts new file mode 100644 index 00000000..644b01f6 --- /dev/null +++ b/core/src/widgets/markdown/mdx/index.ts @@ -0,0 +1,2 @@ +export * from './withShortcodeMdxComponent'; +export { default as withShortcodeElement } from './withShortcodeMdxComponent'; diff --git a/core/src/widgets/markdown/mdx/withShortcodeMdxComponent.tsx b/core/src/widgets/markdown/mdx/withShortcodeMdxComponent.tsx new file mode 100644 index 00000000..5c134567 --- /dev/null +++ b/core/src/widgets/markdown/mdx/withShortcodeMdxComponent.tsx @@ -0,0 +1,36 @@ +import React, { useMemo } from 'react'; + +import { getShortcode } from '../../../lib/registry'; + +import type { MarkdownField, WidgetPreviewProps } from '@staticcms/core/interface'; +import type { FC } from 'react'; + +export interface WithShortcodeMdxComponentProps { + previewProps: WidgetPreviewProps; +} + +interface ShortcodeMdxComponentProps { + shortcode: string; + args: string[]; +} + +const withShortcodeMdxComponent = ({ previewProps }: WithShortcodeMdxComponentProps) => { + const ShortcodeMdxComponent: FC = ({ shortcode, args }) => { + const config = useMemo(() => getShortcode(shortcode), [shortcode]); + + const [ShortcodePreview, props] = useMemo(() => { + if (!config) { + return [null, {}]; + } + + const props = config.toProps ? config.toProps(args) : {}; + return [config.preview, props]; + }, [config, args]); + + return ShortcodePreview ? : null; + }; + + return ShortcodeMdxComponent; +}; + +export default withShortcodeMdxComponent; diff --git a/core/src/widgets/markdown/plate/PlateEditor.tsx b/core/src/widgets/markdown/plate/PlateEditor.tsx index 9981a43c..34bd689e 100644 --- a/core/src/widgets/markdown/plate/PlateEditor.tsx +++ b/core/src/widgets/markdown/plate/PlateEditor.tsx @@ -51,11 +51,12 @@ import { withProps, } from '@udecode/plate'; import { StyledLeaf } from '@udecode/plate-styled-components'; -import React, { useCallback, useMemo, useRef, useState } from 'react'; +import React, { useMemo, useRef } from 'react'; import { DndProvider } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; import useUUID from '@staticcms/core/lib/hooks/useUUID'; +import { withShortcodeElement } from './components'; import { BalloonToolbar } from './components/balloon-toolbar'; import { BlockquoteElement } from './components/nodes/blockquote'; import { CodeBlockElement } from './components/nodes/code-block'; @@ -75,11 +76,11 @@ import { OrderedListElement, UnorderedListElement, } from './components/nodes/list'; -import Paragraph from './components/nodes/paragraph/Paragraph'; +import ParagraphElement from './components/nodes/paragraph/ParagraphElement'; import { TableCellElement, TableElement, TableRowElement } from './components/nodes/table'; import { Toolbar } from './components/toolbar'; import editableProps from './editableProps'; -import { createMdPlugins } from './plateTypes'; +import { createMdPlugins, ELEMENT_SHORTCODE } from './plateTypes'; import { alignPlugin } from './plugins/align'; import { autoformatPlugin } from './plugins/autoformat'; import { createCodeBlockPlugin } from './plugins/code-block'; @@ -87,12 +88,18 @@ import { CursorOverlayContainer } from './plugins/cursor-overlay'; import { exitBreakPlugin } from './plugins/exit-break'; import { createListPlugin } from './plugins/list'; import { resetBlockTypePlugin } from './plugins/reset-node'; +import { createShortcodePlugin } from './plugins/shortcode'; import { softBreakPlugin } from './plugins/soft-break'; import { createTablePlugin } from './plugins/table'; import { trailingBlockPlugin } from './plugins/trailing-block'; -import type { Collection, Entry, MarkdownField } from '@staticcms/core/interface'; -import type { AutoformatPlugin } from '@udecode/plate'; +import type { + Collection, + Entry, + MarkdownField, + WidgetControlProps, +} from '@staticcms/core/interface'; +import type { AnyObject, AutoformatPlugin, PlatePlugin } from '@udecode/plate'; import type { CSSProperties, FC } from 'react'; import type { MdEditor, MdValue } from './plateTypes'; @@ -112,6 +119,8 @@ export interface PlateEditorProps { collection: Collection; entry: Entry; field: MarkdownField; + useMdx: boolean; + controlProps: WidgetControlProps; onChange: (value: MdValue) => void; onFocus: () => void; onBlur: () => void; @@ -122,6 +131,8 @@ const PlateEditor: FC = ({ collection, entry, field, + useMdx, + controlProps, onChange, onFocus, onBlur, @@ -130,15 +141,15 @@ const PlateEditor: FC = ({ const editorContainerRef = useRef(null); const innerEditorContainerRef = useRef(null); - const components = useMemo( - () => ({ + const components = useMemo(() => { + const baseComponents = { [ELEMENT_H1]: Heading1, [ELEMENT_H2]: Heading2, [ELEMENT_H3]: Heading3, [ELEMENT_H4]: Heading4, [ELEMENT_H5]: Heading5, [ELEMENT_H6]: Heading6, - [ELEMENT_PARAGRAPH]: Paragraph, + [ELEMENT_PARAGRAPH]: ParagraphElement, [ELEMENT_TABLE]: TableElement, [ELEMENT_TR]: TableRowElement, [ELEMENT_TH]: TableCellElement, @@ -161,81 +172,92 @@ const PlateEditor: FC = ({ [ELEMENT_UL]: UnorderedListElement, [ELEMENT_LI]: ListItemElement, [ELEMENT_LIC]: ListItemContentElement, + [ELEMENT_SHORTCODE]: withShortcodeElement({ controlProps }), [MARK_BOLD]: withProps(StyledLeaf, { as: 'strong' }), [MARK_ITALIC]: withProps(StyledLeaf, { as: 'em' }), [MARK_STRIKETHROUGH]: withProps(StyledLeaf, { as: 's' }), - [MARK_SUBSCRIPT]: withProps(StyledLeaf, { as: 'sub' }), - [MARK_SUPERSCRIPT]: withProps(StyledLeaf, { as: 'sup' }), - [MARK_UNDERLINE]: withProps(StyledLeaf, { as: 'u' }), - }), - [collection, entry, field], - ); + }; - const [hasEditorFocus, setHasEditorFocus] = useState(false); + if (useMdx) { + // MDX Widget + return { + ...baseComponents, + [MARK_SUBSCRIPT]: withProps(StyledLeaf, { as: 'sub' }), + [MARK_SUPERSCRIPT]: withProps(StyledLeaf, { as: 'sup' }), + [MARK_UNDERLINE]: withProps(StyledLeaf, { as: 'u' }), + }; + } - const handleOnFocus = useCallback(() => { - setHasEditorFocus(true); - onFocus(); - }, [onFocus]); + // Markdown widget + return { + ...baseComponents, + [ELEMENT_SHORTCODE]: withShortcodeElement({ controlProps }), + }; + }, [collection, controlProps, entry, field, useMdx]); - const handleOnBlur = useCallback(() => { - setHasEditorFocus(false); - onBlur(); - }, [onBlur]); + const plugins = useMemo(() => { + const basePlugins: PlatePlugin[] = [ + createParagraphPlugin(), + createBlockquotePlugin(), + createTodoListPlugin(), + createHeadingPlugin(), + createImagePlugin(), + // createHorizontalRulePlugin(), + createLinkPlugin(), + createListPlugin(), + createTablePlugin(), + // createMediaEmbedPlugin(), + createCodeBlockPlugin(), + createBoldPlugin(), + createCodePlugin(), + createItalicPlugin(), + // createHighlightPlugin(), + createStrikethroughPlugin(), + // createFontSizePlugin(), + // createKbdPlugin(), + // createNodeIdPlugin(), + // createDndPlugin({ options: { enableScroller: true } }), + // dragOverCursorPlugin, + // createIndentPlugin(indentPlugin), + createAutoformatPlugin, MdValue, MdEditor>( + autoformatPlugin, + ), + createResetNodePlugin(resetBlockTypePlugin), + createSoftBreakPlugin(softBreakPlugin), + createExitBreakPlugin(exitBreakPlugin), + createTrailingBlockPlugin(trailingBlockPlugin), + // createSelectOnBackspacePlugin(selectOnBackspacePlugin), + // createComboboxPlugin(), + // createMentionPlugin(), + // createDeserializeMdPlugin(), + // createDeserializeCsvPlugin(), + // createDeserializeDocxPlugin(), + // createJuicePlugin() as MdPlatePlugin, + ]; - const plugins = useMemo( - () => - createMdPlugins( + if (useMdx) { + // MDX Widget + return createMdPlugins( [ - createParagraphPlugin(), - createBlockquotePlugin(), - createTodoListPlugin(), - createHeadingPlugin(), - createImagePlugin(), - // createHorizontalRulePlugin(), - createLinkPlugin(), - createListPlugin(), - createTablePlugin(), - // createMediaEmbedPlugin(), - createCodeBlockPlugin(), - createAlignPlugin(alignPlugin), - createBoldPlugin(), - createCodePlugin(), - createItalicPlugin(), - // createHighlightPlugin(), - createUnderlinePlugin(), - createStrikethroughPlugin(), - createSubscriptPlugin(), - createSuperscriptPlugin(), + ...basePlugins, createFontColorPlugin(), createFontBackgroundColorPlugin(), - // createFontSizePlugin(), - // createKbdPlugin(), - // createNodeIdPlugin(), - // createDndPlugin({ options: { enableScroller: true } }), - // dragOverCursorPlugin, - // createIndentPlugin(indentPlugin), - createAutoformatPlugin, MdValue, MdEditor>( - autoformatPlugin, - ), - createResetNodePlugin(resetBlockTypePlugin), - createSoftBreakPlugin(softBreakPlugin), - createExitBreakPlugin(exitBreakPlugin), - createTrailingBlockPlugin(trailingBlockPlugin), - // createSelectOnBackspacePlugin(selectOnBackspacePlugin), - // createComboboxPlugin(), - // createMentionPlugin(), - // createDeserializeMdPlugin(), - // createDeserializeCsvPlugin(), - // createDeserializeDocxPlugin(), - // createJuicePlugin() as MdPlatePlugin, + createSubscriptPlugin(), + createSuperscriptPlugin(), + createUnderlinePlugin(), + createAlignPlugin(alignPlugin), ], { components, }, - ), - [components], - ); + ); + } + + // Markdown Widget + return createMdPlugins([...basePlugins, createShortcodePlugin()], { + components, + }); + }, [components, useMdx]); const id = useUUID(); @@ -253,6 +275,7 @@ const PlateEditor: FC = ({

= ({ id={id} editableProps={{ ...editableProps, - onFocus: handleOnFocus, - onBlur: handleOnBlur, + onFocus, + onBlur, }} >
= ({ > = ({ ), // eslint-disable-next-line react-hooks/exhaustive-deps - [collection, field, handleOnBlur, handleOnFocus, initialValue, onChange, plugins], + [collection, field, onBlur, onFocus, initialValue, onChange, plugins], ); }; diff --git a/core/src/widgets/markdown/plate/components/balloon-toolbar/BalloonToolbar.tsx b/core/src/widgets/markdown/plate/components/balloon-toolbar/BalloonToolbar.tsx index c25368aa..64fe7ee2 100644 --- a/core/src/widgets/markdown/plate/components/balloon-toolbar/BalloonToolbar.tsx +++ b/core/src/widgets/markdown/plate/components/balloon-toolbar/BalloonToolbar.tsx @@ -16,6 +16,7 @@ import { someNode, usePlateSelection, } from '@udecode/plate'; +import { useFocused } from 'slate-react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import useDebounce from '@staticcms/core/lib/hooks/useDebounce'; @@ -25,6 +26,7 @@ import BasicElementToolbarButtons from '../buttons/BasicElementToolbarButtons'; import BasicMarkToolbarButtons from '../buttons/BasicMarkToolbarButtons'; import MediaToolbarButtons from '../buttons/MediaToolbarButtons'; import TableToolbarButtons from '../buttons/TableToolbarButtons'; +import ShortcodeToolbarButton from '../buttons/ShortcodeToolbarButton'; import type { Collection, Entry, MarkdownField } from '@staticcms/core/interface'; import type { ClientRectObject } from '@udecode/plate'; @@ -54,20 +56,21 @@ const StyledDivider = styled('div')( ); export interface BalloonToolbarProps { + useMdx: boolean; containerRef: HTMLElement | null; - hasEditorFocus: boolean; collection: Collection; field: MarkdownField; entry: Entry; } const BalloonToolbar: FC = ({ + useMdx, containerRef, - hasEditorFocus, collection, field, entry, }) => { + const hasEditorFocus = useFocused(); const editor = useMdPlateEditorState(); const selection = usePlateSelection(); const [hasFocus, setHasFocus] = useState(false); @@ -126,9 +129,10 @@ const BalloonToolbar: FC = ({ return []; } + // Selected text buttons if (selectionText && selectionExpanded) { return [ - , + , = ({ ].filter(Boolean); } + // Empty paragraph, not first line if ( editor.children.length > 1 && node && @@ -164,7 +169,7 @@ const BalloonToolbar: FC = ({ parent[0].children.length === 1 ) { return [ - , + , = ({ entry={entry} onMediaToggle={setMediaOpen} />, + !useMdx ? : null, ]; } } @@ -186,18 +192,20 @@ const BalloonToolbar: FC = ({ return []; // eslint-disable-next-line react-hooks/exhaustive-deps }, [ - collection, - editor, - field, + mediaOpen, + debouncedEditorFocus, hasFocus, debouncedHasFocus, - debouncedEditorFocus, - isInTableCell, - mediaOpen, - node, selection, - selectionExpanded, + editor, selectionText, + selectionExpanded, + node, + useMdx, + isInTableCell, + containerRef, + collection, + field, ]); const [prevSelectionBoundingClientRect, setPrevSelectionBoundingClientRect] = useState( @@ -243,7 +251,7 @@ const BalloonToolbar: FC = ({ > {(groups.length > 0 ? groups : debouncedGroups).map((group, index) => [ - index !== 0 ? : null, + index !== 0 ? : null, group, ])} diff --git a/core/src/widgets/markdown/plate/components/buttons/AlignToolbarButtons.tsx b/core/src/widgets/markdown/plate/components/buttons/AlignToolbarButtons.tsx index 6f355ae5..7494b909 100644 --- a/core/src/widgets/markdown/plate/components/buttons/AlignToolbarButtons.tsx +++ b/core/src/widgets/markdown/plate/components/buttons/AlignToolbarButtons.tsx @@ -10,9 +10,24 @@ import type { FC } from 'react'; const AlignToolbarButtons: FC = () => { return ( <> - } /> - } /> - } /> + } + /> + } + /> + } + /> ); }; diff --git a/core/src/widgets/markdown/plate/components/buttons/BasicMarkToolbarButtons.tsx b/core/src/widgets/markdown/plate/components/buttons/BasicMarkToolbarButtons.tsx index e38fa663..ded15fbd 100644 --- a/core/src/widgets/markdown/plate/components/buttons/BasicMarkToolbarButtons.tsx +++ b/core/src/widgets/markdown/plate/components/buttons/BasicMarkToolbarButtons.tsx @@ -22,33 +22,42 @@ import type { FC } from 'react'; export interface BasicMarkToolbarButtonsProps { extended?: boolean; + useMdx: boolean; } -const BasicMarkToolbarButtons: FC = ({ extended = false }) => { +const BasicMarkToolbarButtons: FC = ({ + extended = false, + useMdx, +}) => { return ( <> } /> } /> - } - /> + {useMdx ? ( + } + /> + ) : null} } /> } /> - {extended ? ( + {useMdx && extended ? ( <> } /> { + const editor = useMdPlateEditorState(); + const [anchorEl, setAnchorEl] = useState(null); + const open = Boolean(anchorEl); + const handleClick = useCallback((_editor: MdEditor, event: MouseEvent) => { + setAnchorEl(event.currentTarget); + }, []); + + const handleClose = useCallback(() => { + setAnchorEl(null); + }, []); + + const configs = useMemo(() => getShortcodes(), []); + + const handleShortcodeClick = useCallback( + (shortcode: string) => () => { + insertNodes(editor, { + type: ELEMENT_SHORTCODE, + shortcode, + args: [], + children: [{ text: '' }], + }); + focusEditor(editor); + handleClose(); + }, + [editor, handleClose], + ); + + return ( + <> + } + onClick={handleClick} + /> + + {Object.keys(configs).map(name => { + const config = configs[name]; + return ( + + {config.label ?? toTitleCase(name)} + + ); + })} + + + ); +}; + +export default ShortcodeToolbarButton; diff --git a/core/src/widgets/markdown/plate/components/nodes/common/MediaPopover.tsx b/core/src/widgets/markdown/plate/components/nodes/common/MediaPopover.tsx index ec278cec..ef0789fb 100644 --- a/core/src/widgets/markdown/plate/components/nodes/common/MediaPopover.tsx +++ b/core/src/widgets/markdown/plate/components/nodes/common/MediaPopover.tsx @@ -154,6 +154,7 @@ const MediaPopover = ({ useEffect(() => { if ( + anchorEl && !debouncedHasEditorFocus && !hasEditorFocus && !hasFocus && @@ -163,6 +164,7 @@ const MediaPopover = ({ handleClose(false); } }, [ + anchorEl, debouncedHasEditorFocus, debouncedHasFocus, handleClose, diff --git a/core/src/widgets/markdown/plate/components/nodes/image/withImageElement.tsx b/core/src/widgets/markdown/plate/components/nodes/image/withImageElement.tsx index 64ca38a8..77e9b02d 100644 --- a/core/src/widgets/markdown/plate/components/nodes/image/withImageElement.tsx +++ b/core/src/widgets/markdown/plate/components/nodes/image/withImageElement.tsx @@ -117,7 +117,7 @@ const withImageElement = ({ containerRef, collection, entry, field }: WithImageE }, [handleClose, hasEditorFocus, element, selection, editor, handleOpenPopover]); return ( -
+ {children} -
+ ); }; diff --git a/core/src/widgets/markdown/plate/components/nodes/index.ts b/core/src/widgets/markdown/plate/components/nodes/index.ts index 90d5ccaa..3c882794 100644 --- a/core/src/widgets/markdown/plate/components/nodes/index.ts +++ b/core/src/widgets/markdown/plate/components/nodes/index.ts @@ -7,4 +7,5 @@ export * from './image'; export * from './link'; export * from './list'; export * from './paragraph'; +export * from './shortcode'; export * from './table'; diff --git a/core/src/widgets/markdown/plate/components/nodes/paragraph/Paragraph.tsx b/core/src/widgets/markdown/plate/components/nodes/paragraph/ParagraphElement.tsx similarity index 71% rename from core/src/widgets/markdown/plate/components/nodes/paragraph/Paragraph.tsx rename to core/src/widgets/markdown/plate/components/nodes/paragraph/ParagraphElement.tsx index e91ab020..c38c16cd 100644 --- a/core/src/widgets/markdown/plate/components/nodes/paragraph/Paragraph.tsx +++ b/core/src/widgets/markdown/plate/components/nodes/paragraph/ParagraphElement.tsx @@ -4,11 +4,11 @@ import type { MdParagraphElement, MdValue } from '@staticcms/markdown'; import type { PlateRenderElementProps } from '@udecode/plate'; import type { FC } from 'react'; -const Paragraph: FC> = ({ +const ParagraphElement: FC> = ({ children, element: { align }, }) => { return

{children}

; }; -export default Paragraph; +export default ParagraphElement; diff --git a/core/src/widgets/markdown/plate/components/nodes/paragraph/index.ts b/core/src/widgets/markdown/plate/components/nodes/paragraph/index.ts index 5aa80d30..90234cf5 100644 --- a/core/src/widgets/markdown/plate/components/nodes/paragraph/index.ts +++ b/core/src/widgets/markdown/plate/components/nodes/paragraph/index.ts @@ -1,2 +1,2 @@ /* eslint-disable import/prefer-default-export */ -export { default as Paragraph } from './Paragraph'; +export { default as Paragraph } from './ParagraphElement'; diff --git a/core/src/widgets/markdown/plate/components/nodes/shortcode/index.ts b/core/src/widgets/markdown/plate/components/nodes/shortcode/index.ts new file mode 100644 index 00000000..78b20381 --- /dev/null +++ b/core/src/widgets/markdown/plate/components/nodes/shortcode/index.ts @@ -0,0 +1,2 @@ +export * from './withShortcodeElement'; +export { default as withShortcodeElement } from './withShortcodeElement'; diff --git a/core/src/widgets/markdown/plate/components/nodes/shortcode/withShortcodeElement.tsx b/core/src/widgets/markdown/plate/components/nodes/shortcode/withShortcodeElement.tsx new file mode 100644 index 00000000..63910107 --- /dev/null +++ b/core/src/widgets/markdown/plate/components/nodes/shortcode/withShortcodeElement.tsx @@ -0,0 +1,57 @@ +import { findNodePath, setNodes } from '@udecode/plate'; +import React, { useCallback, useMemo } from 'react'; + +import { getShortcode } from '../../../../../../lib/registry'; + +import type { MarkdownField, WidgetControlProps } from '@staticcms/core/interface'; +import type { MdShortcodeElement, MdValue } from '@staticcms/markdown'; +import type { PlateRenderElementProps } from '@udecode/plate'; +import type { FC } from 'react'; + +export interface WithShortcodeElementProps { + controlProps: WidgetControlProps; +} + +const withShortcodeElement = ({ controlProps }: WithShortcodeElementProps) => { + const ShortcodeElement: FC> = ({ + element, + editor, + children, + }) => { + const config = useMemo(() => getShortcode(element.shortcode), [element.shortcode]); + + const [ShortcodeControl, props] = useMemo(() => { + if (!config) { + return [null, {}]; + } + + const props = config.toProps ? config.toProps(element.args) : {}; + return [config.control, props]; + }, [config, element.args]); + + const handleOnChange = useCallback( + (props: {}) => { + if (!config || !config.toArgs) { + return; + } + + const path = findNodePath(editor, element); + path && setNodes(editor, { args: config.toArgs(props) }, { at: path }); + }, + [config, editor, element], + ); + + return ( + + {ShortcodeControl ? ( + + ) : null} + {children} + + ); + }; + + return ShortcodeElement; +}; + +export default withShortcodeElement; diff --git a/core/src/widgets/markdown/plate/components/toolbar/Toolbar.tsx b/core/src/widgets/markdown/plate/components/toolbar/Toolbar.tsx index 7059db90..bf48488e 100644 --- a/core/src/widgets/markdown/plate/components/toolbar/Toolbar.tsx +++ b/core/src/widgets/markdown/plate/components/toolbar/Toolbar.tsx @@ -7,6 +7,7 @@ import BasicMarkToolbarButtons from '../buttons/BasicMarkToolbarButtons'; import ColorToolbarButtons from '../buttons/ColorToolbarButtons'; import ListToolbarButtons from '../buttons/ListToolbarButtons'; import MediaToolbarButton from '../buttons/MediaToolbarButtons'; +import ShortcodeToolbarButton from '../buttons/ShortcodeToolbarButton'; import type { FC } from 'react'; import type { Collection, Entry, MarkdownField } from '@staticcms/core/interface'; @@ -42,32 +43,36 @@ const StyledDivider = styled('div')( ); export interface ToolbarProps { + useMdx: boolean; containerRef: HTMLElement | null; collection: Collection; field: MarkdownField; entry: Entry; } -const Toolbar: FC = ({ containerRef, collection, field, entry }) => { +const Toolbar: FC = ({ useMdx, containerRef, collection, field, entry }) => { + const groups = [ + , + , + , + useMdx ? : null, + useMdx ? : null, + , + !useMdx ? : null, + ].filter(Boolean); + return ( - - - - - - - - - - - + {groups.map((group, index) => [ + index !== 0 ? : null, + group, + ])} ); }; diff --git a/core/src/widgets/markdown/plate/hooks/__tests__/useMarkdownToSlate.spec.ts b/core/src/widgets/markdown/plate/hooks/__tests__/useMarkdownToSlate.spec.ts new file mode 100644 index 00000000..8d3c8e0c --- /dev/null +++ b/core/src/widgets/markdown/plate/hooks/__tests__/useMarkdownToSlate.spec.ts @@ -0,0 +1,50 @@ +import { + deserializationOnlyTestData, + runSerializationTests, + testShortcodeConfigs as shortcodeConfigs, +} from '../../tests-util/serializationTests.util'; +import { markdownToSlate } from '../useMarkdownToSlate'; + +import type { SerializationTestData } from '../../tests-util/serializationTests.util'; +import type { UseMarkdownToSlateOptions } from '../useMarkdownToSlate'; + +async function expectNodes( + markdown: string, + options: UseMarkdownToSlateOptions, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + children: any[], +) { + expect(await markdownToSlate(markdown, options)).toEqual(children); +} + +function sanitizeHtmlInMarkdown(markdown: string) { + return markdown + .replace('', '<\\/font>') + .replace('', '') + .replace('', '<\\/u>') + .replace('', '') + .replace('', '<\\/sub>') + .replace('', '') + .replace('', '<\\/sup>'); +} + +function testRunner(key: string, mode: 'markdown' | 'mdx' | 'both', data: SerializationTestData) { + it(`deserializes ${key}`, async () => { + if (mode === 'both') { + await expectNodes(data.markdown, { shortcodeConfigs, useMdx: false }, data.slate); + await expectNodes(data.markdown, { shortcodeConfigs, useMdx: true }, data.slate); + return; + } + + await expectNodes( + mode === 'markdown' ? sanitizeHtmlInMarkdown(data.markdown) : data.markdown, + { shortcodeConfigs, useMdx: mode === 'mdx' }, + data.slate, + ); + }); +} + +describe('markdownToSlate', () => { + runSerializationTests(testRunner); + runSerializationTests(testRunner, deserializationOnlyTestData); +}); diff --git a/core/src/widgets/markdown/plate/hooks/index.ts b/core/src/widgets/markdown/plate/hooks/index.ts index 9207feff..fb7f2a33 100644 --- a/core/src/widgets/markdown/plate/hooks/index.ts +++ b/core/src/widgets/markdown/plate/hooks/index.ts @@ -1,3 +1,4 @@ +export * from './useMarkdownToSlate'; export { default as useMarkdownToSlate } from './useMarkdownToSlate'; export * from './useMdx'; export { default as useMdx } from './useMdx'; diff --git a/core/src/widgets/markdown/plate/hooks/useMarkdownToSlate.ts b/core/src/widgets/markdown/plate/hooks/useMarkdownToSlate.ts new file mode 100644 index 00000000..a66952ba --- /dev/null +++ b/core/src/widgets/markdown/plate/hooks/useMarkdownToSlate.ts @@ -0,0 +1,61 @@ +import { ELEMENT_PARAGRAPH } from '@udecode/plate'; +import { useEffect, useState } from 'react'; +import gfm from 'remark-gfm'; +import mdx from 'remark-mdx'; +import markdown from 'remark-parse'; +import { unified } from 'unified'; + +import { getShortcodes } from '../../../../lib/registry'; +import toSlatePlugin from '../serialization/slate/toSlatePlugin'; + +import type { ShortcodeConfig } from '../../../../interface'; +import type { MdValue } from '../plateTypes'; + +export interface UseMarkdownToSlateOptions { + shortcodeConfigs?: Record; + useMdx: boolean; +} + +export const markdownToSlate = async ( + markdownValue: string, + { useMdx, shortcodeConfigs }: UseMarkdownToSlateOptions, +) => { + return new Promise(resolve => { + unified() + .use(markdown) + .use(gfm) + // eslint-disable-next-line @typescript-eslint/no-empty-function + .use(useMdx ? mdx : () => {}) + .use(toSlatePlugin({ shortcodeConfigs: shortcodeConfigs ?? getShortcodes(), useMdx })) + .process(markdownValue, (err, file) => { + if (err) { + console.error(err); + return; + } + resolve(file?.result as MdValue); + }); + }); +}; + +const useMarkdownToSlate = ( + markdownValue: string, + options: UseMarkdownToSlateOptions, +): [MdValue, boolean] => { + const [loaded, setLoaded] = useState(false); + const [slateValue, setSlateValue] = useState([]); + + useEffect(() => { + markdownToSlate(markdownValue, options).then(value => { + setSlateValue(value); + setLoaded(true); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return [ + slateValue.length > 0 ? slateValue : [{ type: ELEMENT_PARAGRAPH, children: [{ text: '' }] }], + loaded, + ]; +}; + +export default useMarkdownToSlate; diff --git a/core/src/widgets/markdown/plate/hooks/useMarkdownToSlate.tsx b/core/src/widgets/markdown/plate/hooks/useMarkdownToSlate.tsx deleted file mode 100644 index 7727c5f3..00000000 --- a/core/src/widgets/markdown/plate/hooks/useMarkdownToSlate.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { ELEMENT_PARAGRAPH } from '@udecode/plate'; -import { useEffect, useState } from 'react'; -import gfm from 'remark-gfm'; -import mdx from 'remark-mdx'; -import markdown from 'remark-parse'; -import { unified } from 'unified'; - -import toSlatePlugin from '../serialization/slate/toSlatePlugin'; - -import type { MdValue } from '../plateTypes'; - -const useMarkdownToSlate = (markdownValue: string): [MdValue, boolean] => { - const [loaded, setLoaded] = useState(false); - const [slateValue, setSlateValue] = useState([]); - - useEffect(() => { - unified() - .use(markdown) - .use(gfm) - .use(mdx) - .use(toSlatePlugin) - .process(markdownValue, (err, file) => { - if (err) { - console.error(err); - return; - } - setSlateValue(file?.result as MdValue); - setLoaded(true); - }); - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - return [ - slateValue.length > 0 ? slateValue : [{ type: ELEMENT_PARAGRAPH, children: [{ text: '' }] }], - loaded, - ]; -}; - -export default useMarkdownToSlate; diff --git a/core/src/widgets/markdown/plate/hooks/useMdx.tsx b/core/src/widgets/markdown/plate/hooks/useMdx.tsx index 9390cb3d..cd187b74 100644 --- a/core/src/widgets/markdown/plate/hooks/useMdx.tsx +++ b/core/src/widgets/markdown/plate/hooks/useMdx.tsx @@ -1,4 +1,5 @@ import { evaluate } from '@mdx-js/mdx'; +import * as provider from '@mdx-js/react'; import { useCallback, useEffect, useState } from 'react'; import * as runtime from 'react/jsx-runtime'; import remarkGfm from 'remark-gfm'; @@ -20,6 +21,7 @@ export default function useMdx(input: string): [UseMdxState, (value: string) => // eslint-disable-next-line @typescript-eslint/no-explicit-any const options: any = { + ...provider, ...runtime, useDynamicImport: true, remarkPlugins: [remarkGfm, flattenListItemParagraphs], diff --git a/core/src/widgets/markdown/plate/plateTypes.ts b/core/src/widgets/markdown/plate/plateTypes.ts index d73b6762..05b2a2e2 100644 --- a/core/src/widgets/markdown/plate/plateTypes.ts +++ b/core/src/widgets/markdown/plate/plateTypes.ts @@ -35,6 +35,7 @@ import type { ELEMENT_HR, ELEMENT_IMAGE, ELEMENT_LI, + ELEMENT_LIC, ELEMENT_LINK, ELEMENT_MEDIA_EMBED, ELEMENT_MENTION, @@ -79,6 +80,8 @@ import type { } from '@udecode/plate'; import type { CSSProperties } from 'styled-components'; +export const ELEMENT_SHORTCODE = 'shortcode' as const; + /** * Text */ @@ -125,7 +128,12 @@ export interface MdMentionElement extends TMentionElement { children: [EmptyText]; } -export type MdInlineElement = MdLinkElement | MdMentionElement | MdMentionInputElement; +export type MdInlineElement = + | MdImageElement + | MdLinkElement + | MdMentionElement + | MdMentionInputElement + | MdShortcodeElement; export type MdInlineDescendant = MdInlineElement | RichText; export type MdInlineChildren = MdInlineDescendant[]; @@ -165,6 +173,13 @@ export interface MdParagraphElement extends MdBlockElement { align?: 'left' | 'center' | 'right'; } +export interface MdShortcodeElement extends TElement { + type: typeof ELEMENT_SHORTCODE; + shortcode: string; + args: string[]; + children: [EmptyText]; +} + export interface MdH1Element extends MdBlockElement { type: typeof ELEMENT_H1; children: MdInlineChildren; @@ -202,7 +217,7 @@ export interface MdBlockquoteElement extends MdBlockElement { export interface MdCodeBlockElement extends MdBlockElement { type: typeof ELEMENT_CODE_BLOCK; - lang: string | undefined; + lang: string | undefined | null; code: string; } @@ -239,6 +254,12 @@ export interface MdNumberedListElement extends TElement, MdBlockElement { export interface MdListItemElement extends TElement, MdBlockElement { type: typeof ELEMENT_LI; checked: boolean | null; + children: MdListItemContentElement[]; +} + +export interface MdListItemContentElement extends TElement, MdBlockElement { + type: typeof ELEMENT_LIC; + checked: boolean | null; children: MdInlineChildren; } diff --git a/core/src/widgets/markdown/plate/plugins/index.ts b/core/src/widgets/markdown/plate/plugins/index.ts index f19bee3e..6831f9ce 100644 --- a/core/src/widgets/markdown/plate/plugins/index.ts +++ b/core/src/widgets/markdown/plate/plugins/index.ts @@ -7,6 +7,7 @@ export * from './indent'; export * from './list'; export * from './reset-node'; export * from './select-on-backspace'; +export * from './shortcode'; export * from './soft-break'; export * from './table'; export * from './trailing-block'; diff --git a/core/src/widgets/markdown/plate/plugins/shortcode/createShortcodePlugin.ts b/core/src/widgets/markdown/plate/plugins/shortcode/createShortcodePlugin.ts new file mode 100644 index 00000000..ea03dd69 --- /dev/null +++ b/core/src/widgets/markdown/plate/plugins/shortcode/createShortcodePlugin.ts @@ -0,0 +1,12 @@ +import { createPluginFactory } from '@udecode/plate'; + +import { ELEMENT_SHORTCODE } from '../../plateTypes'; + +const createShortcodePlugin = createPluginFactory({ + key: ELEMENT_SHORTCODE, + isElement: true, + isInline: true, + type: ELEMENT_SHORTCODE, +}); + +export default createShortcodePlugin; diff --git a/core/src/widgets/markdown/plate/plugins/shortcode/index.ts b/core/src/widgets/markdown/plate/plugins/shortcode/index.ts new file mode 100644 index 00000000..45374cbf --- /dev/null +++ b/core/src/widgets/markdown/plate/plugins/shortcode/index.ts @@ -0,0 +1,2 @@ +// eslint-disable-next-line import/prefer-default-export +export { default as createShortcodePlugin } from './createShortcodePlugin'; diff --git a/core/src/widgets/markdown/plate/serialization/__tests__/serializeMarkdown.spec.ts b/core/src/widgets/markdown/plate/serialization/__tests__/serializeMarkdown.spec.ts new file mode 100644 index 00000000..cc727cd2 --- /dev/null +++ b/core/src/widgets/markdown/plate/serialization/__tests__/serializeMarkdown.spec.ts @@ -0,0 +1,26 @@ +import { + runSerializationTests, + testShortcodeConfigs as shortcodeConfigs, +} from '../../tests-util/serializationTests.util'; +import serializeMarkdown from '../serializeMarkdown'; + +import type { MdValue } from '../../plateTypes'; + +function expectMarkdown(nodes: MdValue, options: { useMdx: boolean }, markdown: string) { + const result = serializeMarkdown(nodes, { ...options, shortcodeConfigs }); + expect(result).toBe(`${markdown}\n`); +} + +describe('serializeMarkdown', () => { + runSerializationTests((key, mode, data) => { + it(`serializes ${key}`, async () => { + if (mode === 'both') { + await expectMarkdown(data.slate, { useMdx: false }, data.markdown); + await expectMarkdown(data.slate, { useMdx: true }, data.markdown); + return; + } + + await expectMarkdown(data.slate, { useMdx: mode === 'mdx' }, data.markdown); + }); + }); +}); diff --git a/core/src/widgets/markdown/plate/serialization/index.ts b/core/src/widgets/markdown/plate/serialization/index.ts index 910bce6d..dd1fab34 100644 --- a/core/src/widgets/markdown/plate/serialization/index.ts +++ b/core/src/widgets/markdown/plate/serialization/index.ts @@ -1,2 +1,3 @@ +export * from './serializeMarkdown'; +export { default as serializeMarkdown } from './serializeMarkdown'; export * from './slate'; -export { default as serializerMarkdown } from './serializerMarkdown'; diff --git a/core/src/widgets/markdown/plate/serialization/serializerMarkdown.ts b/core/src/widgets/markdown/plate/serialization/serializeMarkdown.ts similarity index 73% rename from core/src/widgets/markdown/plate/serialization/serializerMarkdown.ts rename to core/src/widgets/markdown/plate/serialization/serializeMarkdown.ts index 61ebea44..1cf4d23d 100644 --- a/core/src/widgets/markdown/plate/serialization/serializerMarkdown.ts +++ b/core/src/widgets/markdown/plate/serialization/serializeMarkdown.ts @@ -1,22 +1,24 @@ /* eslint-disable no-case-declarations */ -// import { BlockType, defaultNodeTypes, LeafType, NodeTypes } from './ast-types'; -import escapeHtml from 'escape-html'; - +import { getShortcodes } from '../../../../lib/registry'; +import { isEmpty } from '../../../../lib/util/string.util'; import { LIST_TYPES, NodeTypes } from './slate/ast-types'; +import type { CSSProperties } from 'react'; +import type { ShortcodeConfig } from '../../../../interface'; import type { MdCodeBlockElement, MdImageElement, MdLinkElement, MdListItemElement, MdParagraphElement, + MdShortcodeElement, + MdValue, } from '../plateTypes'; -import type { TableNode, BlockType, LeafType } from './slate/ast-types'; -import type { CSSProperties } from 'react'; +import type { BlockType, LeafType, TableNode } from './slate/ast-types'; type FontStyles = Pick; -interface MdLeafType extends LeafType { +export interface MdLeafType extends LeafType { superscript?: boolean; subscript?: boolean; underline?: boolean; @@ -24,39 +26,55 @@ interface MdLeafType extends LeafType { backgroundColor?: string; } -interface MdBlockType extends Omit { +export interface MdBlockType extends Omit { children: Array; } -interface Options { +interface SerializeMarkdownNodeOptions { isInTable?: boolean; isInCode?: boolean; listDepth?: number; blockquoteDepth?: number; ignoreParagraphNewline?: boolean; + useMdx: boolean; + index: number; + shortcodeConfigs: Record; } const isLeafNode = (node: MdBlockType | MdLeafType): node is MdLeafType => { return typeof (node as MdLeafType).text === 'string'; }; -const VOID_ELEMENTS: Array = ['thematic_break', 'image', 'code_block']; +const VOID_ELEMENTS: Array = [ + 'thematic_break', + 'image', + 'code_block', + 'shortcode', + 'tableCell', + 'tableHeaderCell', +]; const BREAK_TAG = '
'; const CODE_ELEMENTS = [NodeTypes.code_block]; -export default function serializerMarkdown(chunk: MdBlockType | MdLeafType, opts: Options = {}) { +function serializeMarkdownNode( + chunk: MdBlockType | MdLeafType, + opts: SerializeMarkdownNodeOptions, +) { const { ignoreParagraphNewline = false, listDepth = 0, isInTable = false, isInCode = false, blockquoteDepth = 0, + useMdx, + shortcodeConfigs, } = opts; const text = (chunk as MdLeafType).text || ''; let type = (chunk as MdBlockType).type || ''; + const selfIsBlockquote = 'type' in chunk && chunk.type === 'blockquote'; let children = text; @@ -67,12 +85,11 @@ export default function serializerMarkdown(chunk: MdBlockType | MdLeafType, opts } children = chunk.children - .map((c: MdBlockType | MdLeafType) => { + .map((c: MdBlockType | MdLeafType, childIndex) => { const selfIsTable = type === NodeTypes.table; const isList = !isLeafNode(c) ? (LIST_TYPES as string[]).includes(c.type || '') : false; const selfIsList = (LIST_TYPES as string[]).includes(chunk.type || ''); const selfIsCode = (CODE_ELEMENTS as string[]).includes(chunk.type || ''); - const selfIsBlockquote = chunk.type === 'blockquote'; // Links can have the following shape // In which case we don't want to surround @@ -91,7 +108,7 @@ export default function serializerMarkdown(chunk: MdBlockType | MdLeafType, opts childrenHasLink = chunk.children.some(f => !isLeafNode(f) && f.type === NodeTypes.link); } - return serializerMarkdown( + return serializeMarkdownNode( { ...c, parentType: type }, { // WOAH. @@ -102,20 +119,18 @@ export default function serializerMarkdown(chunk: MdBlockType | MdLeafType, opts // of whitespace. If we're parallel to a link we also don't want // to respect neighboring paragraphs ignoreParagraphNewline: - (ignoreParagraphNewline || isList || selfIsList || childrenHasLink) && + (ignoreParagraphNewline || isList || selfIsList || childrenHasLink || isInTable) && // if we have c.break, never ignore empty paragraph new line !(c as MdBlockType).break, // track depth of nested lists so we can add proper spacing - listDepth: (LIST_TYPES as string[]).includes((c as MdBlockType).type || '') - ? listDepth + 1 - : listDepth, - + listDepth: selfIsList ? listDepth + 1 : listDepth, isInTable: selfIsTable || isInTable, - isInCode: selfIsCode || isInCode, - blockquoteDepth: selfIsBlockquote ? blockquoteDepth + 1 : blockquoteDepth, + useMdx, + index: childIndex, + shortcodeConfigs, }, ); }) @@ -127,7 +142,10 @@ export default function serializerMarkdown(chunk: MdBlockType | MdLeafType, opts !ignoreParagraphNewline && (text === '' || text === '\n') && chunk.parentType === NodeTypes.paragraph && - type !== NodeTypes.image + type !== NodeTypes.image && + type !== NodeTypes.shortcode && + type !== NodeTypes.tableCell && + type !== NodeTypes.tableHeaderCell ) { type = NodeTypes.paragraph; children = '\n'; @@ -145,7 +163,6 @@ export default function serializerMarkdown(chunk: MdBlockType | MdLeafType, opts // "Text foo bar **baz**" resulting in "**Text foo bar **baz****" // which is invalid markup and can mess everything up if (children !== '\n' && isLeafNode(chunk)) { - children = isInCode || chunk.code ? children : escapeHtml(children); if (chunk.strikethrough && chunk.bold && chunk.italic) { children = retainWhitespaceAndFormat(children, '~~***'); } else if (chunk.bold && chunk.italic) { @@ -220,46 +237,44 @@ export default function serializerMarkdown(chunk: MdBlockType | MdLeafType, opts return `###### ${children}\n`; case NodeTypes.block_quote: - // For some reason, marked is parsing blockquotes w/ one new line - // as contiued blockquotes, so adding two new lines ensures that doesn't - // happen - return `> ${children + return `${selfIsBlockquote && blockquoteDepth > 0 ? '\n' : ''}> ${children .replace(/^[\n]*|[\n]*$/gm, '') .split('\n') - .join('\n> ')}\n\n`; + .join('\n> ')}\n`; case NodeTypes.code_block: const codeBlock = chunk as MdCodeBlockElement; return `\`\`\`${codeBlock.lang ?? ''}\n${codeBlock.code}\n\`\`\`\n`; case NodeTypes.link: - const linkBlock = chunk as unknown as MdLinkElement; + const linkBlock = chunk as MdLinkElement; return `[${children}](${linkBlock.url || ''})`; case NodeTypes.image: - const imageBlock = chunk as unknown as MdImageElement; - const caption = imageBlock.caption ?? []; - return `![${caption.length > 0 ? caption[0].text ?? '' : ''}](${imageBlock.url || ''})`; + const imageBlock = chunk as MdImageElement; + const alt = imageBlock.alt ?? ''; + return `![${alt}](${imageBlock.url || ''})`; case NodeTypes.ul_list: case NodeTypes.ol_list: - return `\n${children}`; + return `${listDepth > 0 ? '\n' : ''}${children}`; case NodeTypes.listItemContent: return children; case NodeTypes.listItem: - const listItemBlock = chunk as unknown as MdListItemElement; + const listItemBlock = chunk as MdListItemElement; const isOL = chunk && chunk.parentType === NodeTypes.ol_list; const treatAsLeaf = (chunk as MdBlockType).children.length >= 1 && ((chunk as MdBlockType).children.reduce((acc, child) => acc && isLeafNode(child), true) || - ((chunk as MdBlockType).children[0] as BlockType).type === 'lic'); + ((chunk as MdBlockType).children.length === 1 && + ((chunk as MdBlockType).children[0] as BlockType).type === 'lic')); let spacer = ''; - for (let k = 0; listDepth > k; k++) { + for (let k = 1; listDepth > k; k++) { if (isOL) { // https://github.com/remarkjs/remark-react/issues/65 spacer += ' '; @@ -270,15 +285,19 @@ export default function serializerMarkdown(chunk: MdBlockType | MdLeafType, opts let checkbox = ''; if (typeof listItemBlock.checked === 'boolean') { - checkbox = ` [${listItemBlock.checked ? 'X' : ' '}]`; + checkbox = ` [${listItemBlock.checked ? 'x' : ' '}]`; } return `${spacer}${isOL ? '1.' : '-'}${checkbox} ${children}${treatAsLeaf ? '\n' : ''}`; case NodeTypes.paragraph: - const paragraphNode = chunk as unknown as MdParagraphElement; - if (paragraphNode.align) { - return `

${children}

`; + const paragraphNode = chunk as MdParagraphElement; + if (useMdx && paragraphNode.align) { + return retainWhitespaceAndFormat( + children, + `

`, + '

\n', + ); } return `${children}${!isInTable ? '\n' : ''}`; @@ -287,15 +306,31 @@ export default function serializerMarkdown(chunk: MdBlockType | MdLeafType, opts case NodeTypes.table: const columns = getTableColumnCount(chunk as TableNode); - return `|${Array(columns).fill(' ').join('|')}| + const rows = children.split('\n'); + const header = rows.length > 0 ? rows[0] : `|${Array(columns).fill(' ').join('|')}|`; + const bodyRows = rows.slice(1); + + return `${header} |${Array(columns).fill('---').join('|')}| -${children}\n`; +${bodyRows.join('\n')}`; case NodeTypes.tableRow: return `|${children}|\n`; + case NodeTypes.tableHeaderCell: case NodeTypes.tableCell: - return children.replace(/\|/g, '\\|').replace(/\n/g, BREAK_TAG); + return isEmpty(children) ? ' ' : children.replace(/\|/g, '\\|').replace(/\n/g, BREAK_TAG); + + case NodeTypes.shortcode: + const shortcodeNode = chunk as MdShortcodeElement; + const shortcodeConfig = shortcodeConfigs[shortcodeNode.shortcode]; + if (!shortcodeConfig) { + return children; + } + + return `${shortcodeConfig.openTag}${[shortcodeNode.shortcode, ...shortcodeNode.args].join( + shortcodeConfig.separator, + )}${shortcodeConfig.closeTag}`; default: console.warn('Unrecognized slate node, proceeding as text', `"${type}"`, chunk); @@ -343,3 +378,23 @@ function getTableColumnCount(tableNode: TableNode): number { return rows[0].children.length; } + +export interface SerializeMarkdownOptions { + useMdx: boolean; + shortcodeConfigs?: Record>; +} + +export default function serializeMarkdown( + slateValue: MdValue, + { useMdx, shortcodeConfigs }: SerializeMarkdownOptions, +) { + return slateValue + .map((v, index) => + serializeMarkdownNode(v as BlockType | LeafType, { + useMdx, + index, + shortcodeConfigs: shortcodeConfigs ?? getShortcodes(), + }), + ) + .join('\n'); +} diff --git a/core/src/widgets/markdown/plate/serialization/slate/__test__/processShortcodeConfig.spec.ts b/core/src/widgets/markdown/plate/serialization/slate/__test__/processShortcodeConfig.spec.ts new file mode 100644 index 00000000..15f2e60f --- /dev/null +++ b/core/src/widgets/markdown/plate/serialization/slate/__test__/processShortcodeConfig.spec.ts @@ -0,0 +1,90 @@ +import { processShortcodeConfigToMdx } from '../processShortcodeConfig'; +import { testShortcodeConfigs } from '../../../tests-util/serializationTests.util'; + +describe('processShortcodeConfig', () => { + describe('processShortcodeConfigToMdx', () => { + it('converts to mdx', () => { + const markdown = '[youtube|p6h-rYSVX90]'; + const mdx = ''; + + expect(processShortcodeConfigToMdx(testShortcodeConfigs, markdown)).toBe(mdx); + }); + + it('converts shortcode with no args', () => { + const markdown = '[youtube]'; + const mdx = ''; + + expect(processShortcodeConfigToMdx(testShortcodeConfigs, markdown)).toBe(mdx); + }); + + it('converts shortcode with multiple args', () => { + const markdown = '[youtube|p6h-rYSVX90|somethingElse|andOneMore]'; + const mdx = + ""; + + expect(processShortcodeConfigToMdx(testShortcodeConfigs, markdown)).toBe(mdx); + }); + + it('shortcode with text before', () => { + const markdown = 'Text before [youtube|p6h-rYSVX90]'; + const mdx = 'Text before '; + + expect(processShortcodeConfigToMdx(testShortcodeConfigs, markdown)).toBe(mdx); + }); + + it('shortcode with text after', () => { + const markdown = '[youtube|p6h-rYSVX90] and text after'; + const mdx = ' and text after'; + + expect(processShortcodeConfigToMdx(testShortcodeConfigs, markdown)).toBe(mdx); + }); + + it('shortcode with text before and after', () => { + const markdown = 'Text before [youtube|p6h-rYSVX90] and text after'; + const mdx = + 'Text before and text after'; + + expect(processShortcodeConfigToMdx(testShortcodeConfigs, markdown)).toBe(mdx); + }); + + it('multiple shortcodes', () => { + const markdown = 'Text before [youtube|p6h-rYSVX90] and {{< twitter 917359331535966209 >}}'; + const mdx = + 'Text before and '; + + expect(processShortcodeConfigToMdx(testShortcodeConfigs, markdown)).toBe(mdx); + }); + + it('multiple of the same shortcodes', () => { + const markdown = + 'Text before [youtube|p6h-rYSVX90], [youtube|p6h-rYSVX90], {{< twitter 917359331535966209 >}} and [youtube|p6h-rYSVX90]'; + const mdx = + 'Text before , , and '; + + expect(processShortcodeConfigToMdx(testShortcodeConfigs, markdown)).toBe(mdx); + }); + + it('unrecognized shortcode', () => { + const markdown = '[someOtherShortcode|andstuff]'; + const mdx = '[someOtherShortcode|andstuff]'; + + expect(processShortcodeConfigToMdx(testShortcodeConfigs, markdown)).toBe(mdx); + }); + + it('unrecognized shortcode surrounded by recognized shortcodes', () => { + const markdown = + 'Text before [youtube|p6h-rYSVX90], [someOtherShortcode|andstuff] and {{< twitter 917359331535966209 >}}'; + const mdx = + 'Text before , [someOtherShortcode|andstuff] and '; + + expect(processShortcodeConfigToMdx(testShortcodeConfigs, markdown)).toBe(mdx); + }); + + it('plain text', () => { + const markdown = 'Some text about something going on somewhere'; + const mdx = 'Some text about something going on somewhere'; + + expect(processShortcodeConfigToMdx(testShortcodeConfigs, markdown)).toBe(mdx); + }); + }); +}); diff --git a/core/src/widgets/markdown/plate/serialization/slate/ast-types.ts b/core/src/widgets/markdown/plate/serialization/slate/ast-types.ts index f9ecae9c..c6208a94 100644 --- a/core/src/widgets/markdown/plate/serialization/slate/ast-types.ts +++ b/core/src/widgets/markdown/plate/serialization/slate/ast-types.ts @@ -15,10 +15,13 @@ import { ELEMENT_PARAGRAPH, ELEMENT_TABLE, ELEMENT_TD, + ELEMENT_TH, ELEMENT_TR, ELEMENT_UL, } from '@udecode/plate'; +import { ELEMENT_SHORTCODE } from '../../plateTypes'; + export const VOID_ELEMENTS = [ELEMENT_CODE_BLOCK, ELEMENT_IMAGE]; export const MarkNodeTypes = { @@ -39,6 +42,7 @@ export const NodeTypes = { table: ELEMENT_TABLE, tableRow: ELEMENT_TR, tableCell: ELEMENT_TD, + tableHeaderCell: ELEMENT_TH, heading: { 1: ELEMENT_H1, 2: ELEMENT_H2, @@ -47,6 +51,7 @@ export const NodeTypes = { 5: ELEMENT_H5, 6: ELEMENT_H6, }, + shortcode: ELEMENT_SHORTCODE, emphasis_mark: 'italic', strong_mark: 'bold', delete_mark: 'strikethrough', @@ -87,13 +92,19 @@ export interface BlockType { type: string; parentType?: string; link?: string; - caption?: string; + alt?: string; language?: string; break?: boolean; children: Array; } -export type MdastNode = BaseMdastNode | MdxMdastNode; +export interface ShortcodeNode extends BaseMdastNode { + type: 'shortcode'; + shortcode: string; + args: string[]; +} + +export type MdastNode = BaseMdastNode | MdxMdastNode | ShortcodeNode; export interface BaseMdastNode { type?: Omit; @@ -114,6 +125,7 @@ export interface BaseMdastNode { checked?: any; // eslint-disable-next-line @typescript-eslint/no-explicit-any indent?: any; + align?: (string | null)[]; } export interface MdxMdastNodeAttributeValue { @@ -150,8 +162,6 @@ export interface MdxMdastNode extends BaseMdastNode { attributes?: MdxMdastNodeAttribute[]; } -export const allowedStyles: string[] = ['color', 'backgroundColor']; - export interface TextNodeStyles { color?: string; backgroundColor?: string; @@ -208,7 +218,7 @@ export type ImageNode = { type: typeof NodeTypes['image']; children: Array; url: string | undefined; - caption: TextNode; + alt: string | undefined; }; export type TableNode = { diff --git a/core/src/widgets/markdown/plate/serialization/slate/deserializeMarkdown.ts b/core/src/widgets/markdown/plate/serialization/slate/deserializeMarkdown.ts index 22856fa2..91839fe6 100644 --- a/core/src/widgets/markdown/plate/serialization/slate/deserializeMarkdown.ts +++ b/core/src/widgets/markdown/plate/serialization/slate/deserializeMarkdown.ts @@ -1,8 +1,10 @@ /* eslint-disable no-case-declarations */ import { ELEMENT_PARAGRAPH } from '@udecode/plate'; -import { allowedStyles, LIST_TYPES, MarkNodeTypes, NodeTypes } from './ast-types'; +import { LIST_TYPES, MarkNodeTypes, NodeTypes } from './ast-types'; +import { processShortcodeConfigToSlate } from './processShortcodeConfig'; +import type { ShortcodeConfig } from '@staticcms/core/interface'; import type { MdBlockElement } from '@staticcms/markdown'; import type { AlignMdxMdastNodeAttribute, @@ -18,6 +20,7 @@ import type { ListNode, MarkNode, MdastNode, + MdxMdastNode, ParagraphNode, StyleMdxMdastNodeAttribute, TextNode, @@ -54,21 +57,62 @@ function mdxToMark(mark: keyof typeof MarkNodeTypes, children: DeserializedNode[ } as MarkNode; } -export interface Options { - isInTable?: boolean; +function parseStyleAttribute(node: MdxMdastNode, allowedStyles: Record) { + const styleAttribute = node.attributes?.find( + a => a.name === 'style', + ) as StyleMdxMdastNodeAttribute; + const nodeStyles: TextNodeStyles = {}; + if (styleAttribute) { + let styles: Record = {}; + try { + styles = + JSON.parse( + styleAttribute.value.value + .replace(/(['"])?([a-zA-Z0-9_]+)(['"])?:/g, '"$2": ') + .replace(/:[ ]*[']([^']+)[']/g, ': "$1"'), + ) ?? {}; + } catch (e) { + console.error(`Error parsing font styles (${styleAttribute.value.value})`, e); + } + + Object.keys(styles).map(key => { + if (key in allowedStyles) { + nodeStyles[allowedStyles[key] as keyof TextNodeStyles] = styles[key]; + } + }); + } + + return nodeStyles; } -export default function deserializeMarkdown(node: MdastNode, options?: Options) { +export interface Options { + isInTable?: boolean; + isInTableHeaderRow?: boolean; + tableAlign?: (string | null)[]; + useMdx: boolean; + shortcodeConfigs: Record; + index: number; +} + +export default function deserializeMarkdown(node: MdastNode, options: Options) { let children: Array = [{ text: '' }]; - const { isInTable = false } = options ?? {}; + const { + isInTable = false, + isInTableHeaderRow = false, + tableAlign, + useMdx, + shortcodeConfigs, + index, + } = options ?? {}; const selfIsTable = node.type === 'table'; + const selfIsTableHeaderRow = node.type === 'tableRow' && index === 0; const nodeChildren = node.children; if (nodeChildren && Array.isArray(nodeChildren) && nodeChildren.length > 0) { children = nodeChildren.flatMap( - (c: MdastNode) => + (c: MdastNode, childIndex) => deserializeMarkdown( { ...c, @@ -76,6 +120,11 @@ export default function deserializeMarkdown(node: MdastNode, options?: Options) }, { isInTable: selfIsTable || isInTable, + isInTableHeaderRow: selfIsTableHeaderRow || isInTableHeaderRow, + useMdx, + shortcodeConfigs, + index: childIndex, + tableAlign: tableAlign || (selfIsTable ? node.align : undefined), }, ) as DeserializedNode, ); @@ -152,7 +201,7 @@ export default function deserializeMarkdown(node: MdastNode, options?: Options) type: NodeTypes.image, children: [{ text: '' }], url: node.url, - caption: [{ text: node.alt ?? '' }], + alt: node.alt, } as ImageNode; case 'blockquote': @@ -213,7 +262,23 @@ export default function deserializeMarkdown(node: MdastNode, options?: Options) return { type: NodeTypes.tableRow, children }; case 'tableCell': - return { type: NodeTypes.tableCell, children: [{ type: NodeTypes.paragraph, children }] }; + return { + type: isInTableHeaderRow ? NodeTypes.tableHeaderCell : NodeTypes.tableCell, + children: [{ type: NodeTypes.paragraph, children }], + }; + + case 'mdxJsxFlowElement': + if ('name' in node) { + switch (node.name) { + case 'br': + return { type: NodeTypes.paragraph, children: [{ text: '' }] }; + default: + console.warn('unrecognized mdx flow element', node); + break; + } + } + + return { text: node.value || '' }; case 'mdxJsxTextElement': if ('name' in node) { @@ -227,6 +292,8 @@ export default function deserializeMarkdown(node: MdastNode, options?: Options) case 'u': return mdxToMark('underline_mark', children); case 'p': + const paragraphNodeStyles = parseStyleAttribute(node, { textAlign: 'align' }); + const alignAttribute = node.attributes?.find( a => a.name === 'align', ) as AlignMdxMdastNodeAttribute; @@ -237,6 +304,7 @@ export default function deserializeMarkdown(node: MdastNode, options?: Options) return { type: NodeTypes.paragraph, + ...paragraphNodeStyles, ...pNodeStyles, children: [ { @@ -246,44 +314,25 @@ export default function deserializeMarkdown(node: MdastNode, options?: Options) ], } as ParagraphNode; case 'font': - const styleAttribute = node.attributes?.find( - a => a.name === 'style', - ) as StyleMdxMdastNodeAttribute; - const nodeStyles: TextNodeStyles = {}; - if (styleAttribute) { - let styles: Record = {}; - try { - styles = - JSON.parse( - styleAttribute.value.value - .replace(/(['"])?([a-zA-Z0-9_]+)(['"])?:/g, '"$2": ') - .replace(/:[ ]*[']([^']+)[']/g, ': "$1"'), - ) ?? {}; - } catch (e) { - console.error(`Error parsing font styles (${styleAttribute.value.value})`, e); - } - - Object.keys(styles).map(key => { - if (allowedStyles.includes(key)) { - nodeStyles[key as keyof TextNodeStyles] = styles[key]; - } - }); - } + const fontNodeStyles = parseStyleAttribute(node, { + color: 'color', + backgroundColor: 'backgroundColor', + }); const colorAttribute = node.attributes?.find( a => a.name === 'color', ) as ColorMdxMdastNodeAttribute; if (colorAttribute) { - nodeStyles.color = colorAttribute.value; + fontNodeStyles.color = colorAttribute.value; } return { - ...nodeStyles, + ...fontNodeStyles, ...forceLeafNode(children as Array), ...persistLeafFormats(children as Array), } as TextNode; default: - console.warn('unrecognized mdx node', node); + console.warn('unrecognized mdx text element', node); break; } } @@ -291,7 +340,22 @@ export default function deserializeMarkdown(node: MdastNode, options?: Options) return { text: node.value || '' }; case 'text': - return { text: node.value || '' }; + if (useMdx) { + return { text: node.value || '' }; + } + + if (!node.value) { + return { text: '' }; + } + + let nodes: MdastNode[] = [node]; + + for (const shortcode in shortcodeConfigs) { + nodes = processShortcodeConfigToSlate(shortcode, shortcodeConfigs[shortcode], nodes); + } + + return nodes.map(node => (node.type === 'text' ? { text: node.value ?? '' } : node)); + default: console.warn('Unrecognized mdast node, proceeding as text', node); return { text: node.value || '' }; diff --git a/core/src/widgets/markdown/plate/serialization/slate/index.ts b/core/src/widgets/markdown/plate/serialization/slate/index.ts index 91ffa874..49fdf35e 100644 --- a/core/src/widgets/markdown/plate/serialization/slate/index.ts +++ b/core/src/widgets/markdown/plate/serialization/slate/index.ts @@ -2,4 +2,5 @@ export * from './ast-types'; export * from './deserializeMarkdown'; export { default as deserializeMarkdown } from './deserializeMarkdown'; export { default as flattenListItemParagraphs } from './flattenListItemParagraphs'; +export * from './toSlatePlugin'; export { default as toSlatePlugin } from './toSlatePlugin'; diff --git a/core/src/widgets/markdown/plate/serialization/slate/processShortcodeConfig.ts b/core/src/widgets/markdown/plate/serialization/slate/processShortcodeConfig.ts new file mode 100644 index 00000000..686571ee --- /dev/null +++ b/core/src/widgets/markdown/plate/serialization/slate/processShortcodeConfig.ts @@ -0,0 +1,98 @@ +/* eslint-disable no-case-declarations */ +import { isEmpty, isNotEmpty } from '@staticcms/core/lib/util/string.util'; + +import type { ShortcodeConfig } from '@staticcms/core/interface'; +import type { BaseMdastNode, MdastNode } from './ast-types'; + +function cleanRegex(str: string) { + return str + .replace('[', '\\[') + .replace(']', '\\]') + .replace('(', '\\(') + .replace(')', '\\)') + .replace('|', '\\|'); +} + +function createShortcodeRegex(name: string, config: ShortcodeConfig) { + return `${cleanRegex(config.openTag)}(${name})${cleanRegex( + config.separator, + )}?([\\w\\W]*?)${cleanRegex(config.closeTag)}`; +} + +export function processShortcodeConfigToSlate( + name: string, + config: ShortcodeConfig, + nodes: BaseMdastNode[], +) { + const output: MdastNode[] = []; + + for (const node of nodes) { + if (node.value) { + const regex = new RegExp(`([\\w\\W]*?)${createShortcodeRegex(name, config)}([\\w\\W]*)`, 'g'); + + let matches: RegExpExecArray | null; + let rest = node.value; + while (isNotEmpty(rest) && (matches = regex.exec(rest)) !== null && matches.length === 5) { + const args = matches[3].trim(); + if (isNotEmpty(matches[1])) { + output.push({ + type: 'text', + value: matches[1], + }); + } + + output.push({ + type: 'shortcode', + shortcode: name, + args: isEmpty(args) ? [] : args.split(config.separator), + children: [{ text: '' }], + }); + + rest = matches[4]; + regex.lastIndex = 0; + } + + if (isNotEmpty(rest)) { + output.push({ + type: 'text', + value: rest, + }); + } + + continue; + } + + output.push(node); + } + + return output; +} + +export function processShortcodeConfigToMdx( + configs: Record, + markdown: string, +) { + if (isEmpty(markdown)) { + return ''; + } + + let output = markdown; + + for (const name in configs) { + const config = configs[name]; + const regex = new RegExp(createShortcodeRegex(name, config), 'g'); + + let matches: RegExpExecArray | null; + while ((matches = regex.exec(markdown)) !== null && matches.length === 3) { + const args = isEmpty(matches[2]) ? [] : matches[2]?.split(config.separator); + const argsOutput = args?.length > 0 ? `'${args.join("', '")}'` : ''; + + output = output.replace( + matches[0], + ``, + ); + } + } + + return output; +} diff --git a/core/src/widgets/markdown/plate/serialization/slate/toSlatePlugin.ts b/core/src/widgets/markdown/plate/serialization/slate/toSlatePlugin.ts index 0a884301..11ce9aed 100644 --- a/core/src/widgets/markdown/plate/serialization/slate/toSlatePlugin.ts +++ b/core/src/widgets/markdown/plate/serialization/slate/toSlatePlugin.ts @@ -1,14 +1,21 @@ import transform from './deserializeMarkdown'; +import type { ShortcodeConfig } from '@staticcms/core/interface'; import type { Plugin } from 'unified'; import type { MdastNode } from './ast-types'; -const toSlatePlugin: Plugin = function () { - const compiler = (node: { children: Array }) => { - return node.children.map(c => transform(c, {})); +export interface ToSlatePluginOptions { + shortcodeConfigs: Record; + useMdx: boolean; +} + +const toSlatePlugin = ({ shortcodeConfigs, useMdx }: ToSlatePluginOptions): Plugin => + function () { + const compiler = (node: { children: Array }) => { + return node.children.map((c, index) => transform(c, { shortcodeConfigs, useMdx, index })); + }; + + this.Compiler = compiler; }; - this.Compiler = compiler; -}; - export default toSlatePlugin; diff --git a/core/src/widgets/markdown/plate/tests-util/serializationTests.util.tsx b/core/src/widgets/markdown/plate/tests-util/serializationTests.util.tsx new file mode 100644 index 00000000..b8a1a97c --- /dev/null +++ b/core/src/widgets/markdown/plate/tests-util/serializationTests.util.tsx @@ -0,0 +1,2954 @@ +import { + ELEMENT_BLOCKQUOTE, + ELEMENT_CODE_BLOCK, + ELEMENT_H1, + ELEMENT_H2, + ELEMENT_H3, + ELEMENT_H4, + ELEMENT_H5, + ELEMENT_H6, + ELEMENT_IMAGE, + ELEMENT_LI, + ELEMENT_LIC, + ELEMENT_LINK, + ELEMENT_OL, + ELEMENT_PARAGRAPH, + ELEMENT_TABLE, + ELEMENT_TD, + ELEMENT_TH, + ELEMENT_TR, + ELEMENT_UL, +} from '@udecode/plate'; +import React from 'react'; + +import { ELEMENT_SHORTCODE } from '../plateTypes'; + +import type { ShortcodeConfig } from '@staticcms/core/interface'; +import type { MdValue } from '../plateTypes'; + +export const testShortcodeConfigs: Record = { + twitter: { + openTag: '{{< ', + closeTag: ' >}}', + separator: ' ', + control: () =>
twitter control
, + preview: () =>
twitter preview
, + }, + youtube: { + openTag: '[', + closeTag: ']', + separator: '|', + control: () =>
youtube control
, + preview: () =>
youtube preview
, + }, +}; + +export interface SerializationTestData { + markdown: string; + slate: MdValue; +} + +interface SerializationMarkdownMdxSplitTests { + markdown?: Record; + mdx?: Record; +} + +function isSerializationTest( + input: + | SerializationMarkdownMdxSplitTests + | SerializationTestData + | Record, +): input is SerializationTestData { + return 'markdown' in input && 'slate' in input; +} + +function isSerializationMarkdownMdxSplitTests( + input: + | SerializationMarkdownMdxSplitTests + | SerializationTestData + | Record, +): input is SerializationMarkdownMdxSplitTests { + return 'mdx' in input || ('markdown' in input && !('slate' in input)); +} + +type SerializationTests = Record< + string, + SerializationMarkdownMdxSplitTests | SerializationTestData | Record +>; + +const serializationTestData: SerializationTests = { + 'plain text': { + paragraph: { + markdown: 'A line of text', + slate: [ + { + type: ELEMENT_PARAGRAPH, + children: [ + { + text: 'A line of text', + }, + ], + }, + ] as MdValue, + }, + + 'paragraph with line break': { + markdown: `A line of text +With another in the same paragraph`, + slate: [ + { + type: ELEMENT_PARAGRAPH, + children: [ + { + text: 'A line of text\nWith another in the same paragraph', + }, + ], + }, + ], + }, + + 'two paragraphs': { + markdown: `A line of text + +And a completely new paragraph`, + slate: [ + { + type: ELEMENT_PARAGRAPH, + children: [ + { + text: 'A line of text', + }, + ], + }, + { + type: ELEMENT_PARAGRAPH, + children: [ + { + text: 'And a completely new paragraph', + }, + ], + }, + ], + }, + }, + + headers: { + 'header 1': { + markdown: '# Header One', + slate: [ + { + type: ELEMENT_H1, + children: [ + { + text: 'Header One', + }, + ], + }, + ], + }, + + 'header 2': { + markdown: '## Header Two', + slate: [ + { + type: ELEMENT_H2, + children: [ + { + text: 'Header Two', + }, + ], + }, + ], + }, + + 'header 3': { + markdown: '### Header Three', + slate: [ + { + type: ELEMENT_H3, + children: [ + { + text: 'Header Three', + }, + ], + }, + ], + }, + + 'header 4': { + markdown: '#### Header Four', + slate: [ + { + type: ELEMENT_H4, + children: [ + { + text: 'Header Four', + }, + ], + }, + ], + }, + + 'header 5': { + markdown: '##### Header Five', + slate: [ + { + type: ELEMENT_H5, + children: [ + { + text: 'Header Five', + }, + ], + }, + ], + }, + + 'header 6': { + markdown: '###### Header Six', + slate: [ + { + type: ELEMENT_H6, + children: [ + { + text: 'Header Six', + }, + ], + }, + ], + }, + }, + + blockquote: { + blockquote: { + markdown: '> I am a block quote', + slate: [ + { + type: ELEMENT_BLOCKQUOTE, + children: [ + { + text: 'I am a block quote', + }, + ], + }, + ], + }, + + 'multiline blockquote': { + markdown: '> I am a block quote\n> And another line', + slate: [ + { + type: ELEMENT_BLOCKQUOTE, + children: [ + { + text: 'I am a block quote\nAnd another line', + }, + ], + }, + ], + }, + + 'nested blockquote': { + markdown: '> I am a block quote\n> > And another line', + slate: [ + { + type: ELEMENT_BLOCKQUOTE, + children: [ + { + text: 'I am a block quote', + }, + { + type: ELEMENT_BLOCKQUOTE, + children: [ + { + text: 'And another line', + }, + ], + }, + ], + }, + ] as MdValue, + }, + }, + + code: { + 'inline code': { + markdown: "`Colored Text`", + slate: [ + { + type: ELEMENT_PARAGRAPH, + children: [ + { + code: true, + text: "Colored Text", + }, + ], + }, + ], + }, + }, + + 'code block': { + 'code block': { + markdown: + "```\nColored Text\n```", + slate: [ + { + type: ELEMENT_CODE_BLOCK, + code: "Colored Text", + lang: null, + children: [ + { + text: '', + }, + ], + }, + ] as MdValue, + }, + + 'code block with language': { + markdown: + "```javascript\nColored Text\n```", + slate: [ + { + type: ELEMENT_CODE_BLOCK, + code: "Colored Text", + lang: 'javascript', + children: [ + { + text: '', + }, + ], + }, + ], + }, + }, + + image: { + image: { + markdown: '![Alt Text](https://example.com/picture.png)', + slate: [ + { + type: ELEMENT_PARAGRAPH, + children: [ + { + type: ELEMENT_IMAGE, + url: 'https://example.com/picture.png', + alt: 'Alt Text', + children: [ + { + text: '', + }, + ], + }, + ], + }, + ] as MdValue, + }, + + 'image without alt text': { + markdown: '![](https://example.com/picture.png)', + slate: [ + { + type: ELEMENT_PARAGRAPH, + children: [ + { + type: ELEMENT_IMAGE, + url: 'https://example.com/picture.png', + alt: '', + children: [ + { + text: '', + }, + ], + }, + ], + }, + ], + }, + }, + + link: { + links: { + markdown: '[Link Text](https://example.com/)', + slate: [ + { + type: ELEMENT_PARAGRAPH, + children: [ + { + type: ELEMENT_LINK, + url: 'https://example.com/', + children: [ + { + text: 'Link Text', + }, + ], + }, + ], + }, + ], + }, + }, + + list: { + 'unordered list': { + markdown: `- List Item 1 +- List Item 2 +- List Item 3`, + slate: [ + { + type: ELEMENT_UL, + children: [ + { + type: ELEMENT_LI, + checked: null, + children: [ + { + type: ELEMENT_LIC, + children: [ + { + text: 'List Item 1', + }, + ], + }, + ], + }, + { + type: ELEMENT_LI, + checked: null, + children: [ + { + type: ELEMENT_LIC, + children: [ + { + text: 'List Item 2', + }, + ], + }, + ], + }, + { + type: ELEMENT_LI, + checked: null, + children: [ + { + type: ELEMENT_LIC, + children: [ + { + text: 'List Item 3', + }, + ], + }, + ], + }, + ], + }, + ] as MdValue, + }, + + 'nested unordered list': { + markdown: `- List Item 1 +- List Item 2 + - List Item 3`, + slate: [ + { + type: ELEMENT_UL, + children: [ + { + type: ELEMENT_LI, + checked: null, + children: [ + { + type: ELEMENT_LIC, + children: [ + { + text: 'List Item 1', + }, + ], + }, + ], + }, + { + type: ELEMENT_LI, + checked: null, + children: [ + { + type: ELEMENT_LIC, + children: [ + { + text: 'List Item 2', + }, + ], + }, + { + type: ELEMENT_UL, + children: [ + { + type: ELEMENT_LI, + checked: null, + children: [ + { + type: ELEMENT_LIC, + children: [ + { + text: 'List Item 3', + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ] as MdValue, + }, + + 'todo unordered list': { + markdown: `- [ ] List Item 1 +- [x] List Item 2 + - [x] List Item 3`, + slate: [ + { + type: ELEMENT_UL, + children: [ + { + type: ELEMENT_LI, + checked: false, + children: [ + { + type: ELEMENT_LIC, + children: [ + { + text: 'List Item 1', + }, + ], + }, + ], + }, + { + type: ELEMENT_LI, + checked: true, + children: [ + { + type: ELEMENT_LIC, + children: [ + { + text: 'List Item 2', + }, + ], + }, + { + type: ELEMENT_UL, + children: [ + { + type: ELEMENT_LI, + checked: true, + children: [ + { + type: ELEMENT_LIC, + children: [ + { + text: 'List Item 3', + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ] as MdValue, + }, + + 'ordered list': { + markdown: `1. List Item 1 +1. List Item 2 +1. List Item 3`, + slate: [ + { + type: ELEMENT_OL, + children: [ + { + type: ELEMENT_LI, + checked: null, + children: [ + { + type: ELEMENT_LIC, + children: [ + { + text: 'List Item 1', + }, + ], + }, + ], + }, + { + type: ELEMENT_LI, + checked: null, + children: [ + { + type: ELEMENT_LIC, + children: [ + { + text: 'List Item 2', + }, + ], + }, + ], + }, + { + type: ELEMENT_LI, + checked: null, + children: [ + { + type: ELEMENT_LIC, + children: [ + { + text: 'List Item 3', + }, + ], + }, + ], + }, + ], + }, + ] as MdValue, + }, + + 'nested ordered list': { + markdown: `1. List Item 1 +1. List Item 2 + 1. List Item 3`, + slate: [ + { + type: ELEMENT_OL, + children: [ + { + type: ELEMENT_LI, + checked: null, + children: [ + { + type: ELEMENT_LIC, + children: [ + { + text: 'List Item 1', + }, + ], + }, + ], + }, + { + type: ELEMENT_LI, + checked: null, + children: [ + { + type: ELEMENT_LIC, + children: [ + { + text: 'List Item 2', + }, + ], + }, + { + type: ELEMENT_OL, + children: [ + { + type: ELEMENT_LI, + checked: null, + children: [ + { + type: ELEMENT_LIC, + children: [ + { + text: 'List Item 3', + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ] as MdValue, + }, + + 'nested todo ordered list': { + markdown: `1. [x] List Item 1 +1. [ ] List Item 2 + 1. [ ] List Item 3`, + slate: [ + { + type: ELEMENT_OL, + children: [ + { + type: ELEMENT_LI, + checked: true, + children: [ + { + type: ELEMENT_LIC, + children: [ + { + text: 'List Item 1', + }, + ], + }, + ], + }, + { + type: ELEMENT_LI, + checked: false, + children: [ + { + type: ELEMENT_LIC, + children: [ + { + text: 'List Item 2', + }, + ], + }, + { + type: ELEMENT_OL, + children: [ + { + type: ELEMENT_LI, + checked: false, + children: [ + { + type: ELEMENT_LIC, + children: [ + { + text: 'List Item 3', + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ] as MdValue, + }, + + 'nested mixed list': { + markdown: `- List Item 1 +- List Item 2 + 1. [x] List Item 3 + 1. [ ] List Item 4`, + slate: [ + { + type: ELEMENT_UL, + children: [ + { + type: ELEMENT_LI, + checked: null, + children: [ + { + type: ELEMENT_LIC, + children: [ + { + text: 'List Item 1', + }, + ], + }, + ], + }, + { + type: ELEMENT_LI, + checked: null, + children: [ + { + type: ELEMENT_LIC, + children: [ + { + text: 'List Item 2', + }, + ], + }, + { + type: ELEMENT_OL, + children: [ + { + type: ELEMENT_LI, + checked: true, + children: [ + { + type: ELEMENT_LIC, + children: [ + { + text: 'List Item 3', + }, + ], + }, + ], + }, + { + type: ELEMENT_LI, + checked: false, + children: [ + { + type: ELEMENT_LIC, + children: [ + { + text: 'List Item 4', + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ] as MdValue, + }, + }, + + italic: { + 'italic (using _)': { + markdown: '_Italic_', + slate: [ + { + type: ELEMENT_PARAGRAPH, + children: [ + { + italic: true, + text: 'Italic', + }, + ], + }, + ], + }, + }, + + bold: { + 'bold (using **)': { + markdown: '**Bold**', + slate: [ + { + type: ELEMENT_PARAGRAPH, + children: [ + { + bold: true, + text: 'Bold', + }, + ], + }, + ], + }, + }, + + strikethrough: { + strikethrough: { + markdown: '~~Strikethrough~~', + slate: [ + { + type: ELEMENT_PARAGRAPH, + children: [ + { + strikethrough: true, + text: 'Strikethrough', + }, + ], + }, + ], + }, + }, + + align: { + markdown: { + align: { + markdown: "

Align Center

", + slate: [ + { + type: ELEMENT_PARAGRAPH, + children: [ + { + text: "

Align Center

", + }, + ], + }, + ] as MdValue, + }, + }, + + mdx: { + 'align left': { + markdown: "

Align Left

", + slate: [ + { + type: ELEMENT_PARAGRAPH, + align: 'left', + children: [ + { + text: 'Align Left', + }, + ], + }, + ] as MdValue, + }, + + 'align center': { + markdown: "

Align Center

", + slate: [ + { + type: ELEMENT_PARAGRAPH, + align: 'center', + children: [ + { + text: 'Align Center', + }, + ], + }, + ], + }, + + 'align right': { + markdown: "

Align Right

", + slate: [ + { + type: ELEMENT_PARAGRAPH, + align: 'right', + children: [ + { + text: 'Align Right', + }, + ], + }, + ], + }, + }, + }, + + subscript: { + markdown: { + 'subscript tag': { + markdown: 'Subscript', + slate: [ + { + type: ELEMENT_PARAGRAPH, + children: [ + { + text: 'Subscript', + }, + ], + }, + ], + }, + }, + + mdx: { + 'subscript tag': { + markdown: 'Subscript', + slate: [ + { + type: ELEMENT_PARAGRAPH, + children: [ + { + subscript: true, + text: 'Subscript', + }, + ], + }, + ], + }, + }, + }, + + superscript: { + markdown: { + 'superscript tag': { + markdown: 'Superscript', + slate: [ + { + type: ELEMENT_PARAGRAPH, + children: [ + { + text: 'Superscript', + }, + ], + }, + ], + }, + }, + + mdx: { + 'superscript tag': { + markdown: 'Superscript', + slate: [ + { + type: ELEMENT_PARAGRAPH, + children: [ + { + superscript: true, + text: 'Superscript', + }, + ], + }, + ], + }, + }, + }, + + underline: { + markdown: { + 'underline tag': { + markdown: 'Underlined', + slate: [ + { + type: ELEMENT_PARAGRAPH, + children: [ + { + text: 'Underlined', + }, + ], + }, + ], + }, + }, + + mdx: { + 'underline tag': { + markdown: 'Underlined', + slate: [ + { + type: ELEMENT_PARAGRAPH, + children: [ + { + underline: true, + text: 'Underlined', + }, + ], + }, + ], + }, + }, + }, + + 'font tags': { + markdown: { + 'font tag': { + markdown: "Colored Text", + slate: [ + { + type: ELEMENT_PARAGRAPH, + children: [ + { + text: "Colored Text", + }, + ], + }, + ], + }, + }, + + mdx: { + 'color and background color from style attribute of font tag': { + markdown: "Colored Text", + slate: [ + { + type: ELEMENT_PARAGRAPH, + children: [ + { + color: 'red', + backgroundColor: 'black', + text: 'Colored Text', + }, + ], + }, + ], + }, + }, + }, + + shortcodes: { + markdown: { + shortcode: { + markdown: '[youtube|p6h-rYSVX90]', + slate: [ + { + type: ELEMENT_PARAGRAPH, + children: [ + { + type: ELEMENT_SHORTCODE, + shortcode: 'youtube', + args: ['p6h-rYSVX90'], + children: [ + { + text: '', + }, + ], + }, + ], + }, + ], + }, + + 'shortcode with no args': { + markdown: '[youtube]', + + slate: [ + { + type: ELEMENT_PARAGRAPH, + children: [ + { + type: ELEMENT_SHORTCODE, + shortcode: 'youtube', + args: [], + children: [ + { + text: '', + }, + ], + }, + ], + }, + ], + }, + + 'shortcode with multiple args': { + markdown: '[youtube|p6h-rYSVX90|somethingElse|andOneMore]', + + slate: [ + { + type: ELEMENT_PARAGRAPH, + children: [ + { + type: ELEMENT_SHORTCODE, + shortcode: 'youtube', + args: ['p6h-rYSVX90', 'somethingElse', 'andOneMore'], + children: [ + { + text: '', + }, + ], + }, + ], + }, + ], + }, + + 'shortcode with text before': { + markdown: 'Text before [youtube|p6h-rYSVX90]', + + slate: [ + { + type: ELEMENT_PARAGRAPH, + children: [ + { + text: 'Text before ', + }, + { + type: ELEMENT_SHORTCODE, + shortcode: 'youtube', + args: ['p6h-rYSVX90'], + children: [ + { + text: '', + }, + ], + }, + ], + }, + ], + }, + + 'shortcode with text after': { + markdown: '[youtube|p6h-rYSVX90] and text after', + + slate: [ + { + type: ELEMENT_PARAGRAPH, + children: [ + { + type: ELEMENT_SHORTCODE, + shortcode: 'youtube', + args: ['p6h-rYSVX90'], + children: [ + { + text: '', + }, + ], + }, + { + text: ' and text after', + }, + ], + }, + ], + }, + + 'shortcode with text before and after': { + markdown: 'Text before [youtube|p6h-rYSVX90] and text after', + + slate: [ + { + type: ELEMENT_PARAGRAPH, + children: [ + { + text: 'Text before ', + }, + { + type: ELEMENT_SHORTCODE, + shortcode: 'youtube', + args: ['p6h-rYSVX90'], + children: [ + { + text: '', + }, + ], + }, + { + text: ' and text after', + }, + ], + }, + ], + }, + + 'multiple shortcodes': { + markdown: 'Text before [youtube|p6h-rYSVX90] and {{< twitter 917359331535966209 >}}', + + slate: [ + { + type: ELEMENT_PARAGRAPH, + children: [ + { + text: 'Text before ', + }, + { + type: ELEMENT_SHORTCODE, + shortcode: 'youtube', + args: ['p6h-rYSVX90'], + children: [ + { + text: '', + }, + ], + }, + { + text: ' and ', + }, + { + type: ELEMENT_SHORTCODE, + shortcode: 'twitter', + args: ['917359331535966209'], + children: [ + { + text: '', + }, + ], + }, + ], + }, + ], + }, + + 'multiple of the same shortcodes': { + markdown: + 'Text before [youtube|p6h-rYSVX90], [youtube|p6h-rYSVX90], {{< twitter 917359331535966209 >}} and [youtube|p6h-rYSVX90]', + + slate: [ + { + type: ELEMENT_PARAGRAPH, + children: [ + { + text: 'Text before ', + }, + { + type: ELEMENT_SHORTCODE, + shortcode: 'youtube', + args: ['p6h-rYSVX90'], + children: [ + { + text: '', + }, + ], + }, + { + text: ', ', + }, + { + type: ELEMENT_SHORTCODE, + shortcode: 'youtube', + args: ['p6h-rYSVX90'], + children: [ + { + text: '', + }, + ], + }, + { + text: ', ', + }, + { + type: ELEMENT_SHORTCODE, + shortcode: 'twitter', + args: ['917359331535966209'], + children: [ + { + text: '', + }, + ], + }, + { + text: ' and ', + }, + { + type: ELEMENT_SHORTCODE, + shortcode: 'youtube', + args: ['p6h-rYSVX90'], + children: [ + { + text: '', + }, + ], + }, + ], + }, + ], + }, + + 'unrecognized shortcode': { + markdown: '[someOtherShortcode|andstuff]', + slate: [ + { + type: ELEMENT_PARAGRAPH, + children: [ + { + text: '[someOtherShortcode|andstuff]', + }, + ], + }, + ], + }, + + 'unrecognized shortcode surrounded by recognized shortcodes': { + markdown: + 'Text before [youtube|p6h-rYSVX90], [someOtherShortcode|andstuff] and {{< twitter 917359331535966209 >}}', + + slate: [ + { + type: ELEMENT_PARAGRAPH, + children: [ + { + text: 'Text before ', + }, + { + type: ELEMENT_SHORTCODE, + shortcode: 'youtube', + args: ['p6h-rYSVX90'], + children: [ + { + text: '', + }, + ], + }, + { + text: ', [someOtherShortcode|andstuff] and ', + }, + { + type: ELEMENT_SHORTCODE, + shortcode: 'twitter', + args: ['917359331535966209'], + children: [ + { + text: '', + }, + ], + }, + ], + }, + ], + }, + + 'plain text': { + markdown: 'Some text about something going on somewhere', + + slate: [ + { + type: ELEMENT_PARAGRAPH, + children: [ + { + text: 'Some text about something going on somewhere', + }, + ], + }, + ], + }, + }, + + mdx: { + shortcode: { + markdown: '[youtube|p6h-rYSVX90]', + + slate: [ + { + type: ELEMENT_PARAGRAPH, + children: [ + { + text: '[youtube|p6h-rYSVX90]', + }, + ], + }, + ], + }, + }, + }, + + table: { + table: { + markdown: `|Name|Age| +|---|---| +|Bob|25| +|Billy|30| +|Sam|29|`, + + slate: [ + { + type: ELEMENT_TABLE, + children: [ + { + type: ELEMENT_TR, + children: [ + { + type: ELEMENT_TH, + children: [ + { + type: ELEMENT_PARAGRAPH, + children: [ + { + text: 'Name', + }, + ], + }, + ], + }, + { + type: ELEMENT_TH, + children: [ + { + type: ELEMENT_PARAGRAPH, + children: [ + { + text: 'Age', + }, + ], + }, + ], + }, + ], + }, + { + type: ELEMENT_TR, + children: [ + { + type: ELEMENT_TD, + children: [ + { + type: ELEMENT_PARAGRAPH, + children: [ + { + text: 'Bob', + }, + ], + }, + ], + }, + { + type: ELEMENT_TD, + children: [ + { + type: ELEMENT_PARAGRAPH, + children: [ + { + text: '25', + }, + ], + }, + ], + }, + ], + }, + { + type: ELEMENT_TR, + children: [ + { + type: ELEMENT_TD, + children: [ + { + type: ELEMENT_PARAGRAPH, + children: [ + { + text: 'Billy', + }, + ], + }, + ], + }, + { + type: ELEMENT_TD, + children: [ + { + type: ELEMENT_PARAGRAPH, + children: [ + { + text: '30', + }, + ], + }, + ], + }, + ], + }, + { + type: ELEMENT_TR, + children: [ + { + type: ELEMENT_TD, + children: [ + { + type: ELEMENT_PARAGRAPH, + children: [ + { + text: 'Sam', + }, + ], + }, + ], + }, + { + type: ELEMENT_TD, + children: [ + { + type: ELEMENT_PARAGRAPH, + children: [ + { + text: '29', + }, + ], + }, + ], + }, + ], + }, + ], + }, + ] as MdValue, + }, + }, + + 'kitchen sink': { + markdown: { + 'kitchen sink': { + markdown: `# The post is number 1 + +![Static CMS](https://raw.githubusercontent.com/StaticJsCMS/static-cms/main/static-cms-logo.png) + +# Awesome Editor! + +It was _released as open source in 2022_ and is ***continually*** evolving to be the **best editor experience** available for static site generators. + +## MDX + +The output out this widget is \`mdx\`, a mixture of \`markdown\` and \`javascript components\`. See [MDX documentation](https://mdxjs.com/docs/). + +\`\`\`yaml +name: body +label: Blog post content +widget: markdown +\`\`\` + +\`\`\`js +name: 'body', +label: 'Blog post content', +widget: 'markdown', +\`\`\` + +> See the table below for default options +> More API information can be found in the document + +|Name|Type|Default|Description| +|---|---|---|---| +|default|string|\`''\`|_Optional_. The default value for the field. Accepts markdown content| +|media_library|Media Library Options|\`{}\`|_Optional_. Media library settings to apply when a media library is opened by the current widget. See [Media Library Options](#media-library-options)| +|media_folder|string| |_Optional_. Specifies the folder path where uploaded files should be saved, relative to the base of the repo| +|public_folder|string| |_Optional_. Specifies the folder path where the files uploaded by the media library will be accessed, relative to the base of the built site| + +### Media Library Options + +|Name|Type|Default|Description| +|---|---|---|---| +|allow_multiple|boolean|\`true\`|_Optional_. When set to \`false\`, prevents multiple selection for any media library extension, but must be supported by the extension in use| +|config|string|\`{}\`|_Optional_. A configuration object that will be passed directly to the media library being used - available options are determined by the library| +|choose_url|string
\\| boolean|\`true\`|_Optional_. When set to \`false\`, the "Insert from URL" button will be hidden| + +## Features + +- CommonMark + GFM Specifications + - Live \`Preview\` + - Auto Indent + - Syntax Highlight + 1. Rich Editor + 1. Preview + +## Formatting + +**Bold**, _Italic_, ***both*** + +~~Strikethrough~~ + +## Shortcodes + +Text ahead [youtube|p6h-rYSVX90] and behind + +{{< twitter 917359331535966209 >}} Only behind text + +Only text before {{< twitter 917359331535966209 >}} + +[youtube|p6h-rYSVX90] + +Text ahead [youtube|p6h-rYSVX90] and behind and another {{< twitter 917359331535966209 >}} shortcode + +## Support + +> - Supports remark plugins +> - Supports wrappers +> 1. [x] React +> 1. [ ] More coming soon`, + slate: [ + { + type: 'h1', + children: [ + { + text: 'The post is number 1', + }, + ], + }, + { + type: 'p', + children: [ + { + type: 'img', + children: [ + { + text: '', + }, + ], + url: 'https://raw.githubusercontent.com/StaticJsCMS/static-cms/main/static-cms-logo.png', + alt: 'Static CMS', + }, + ], + }, + { + type: 'h1', + children: [ + { + text: 'Awesome Editor!', + }, + ], + }, + { + type: 'p', + children: [ + { + text: 'It was ', + }, + { + italic: true, + text: 'released as open source in 2022', + }, + { + text: ' and is ', + }, + { + bold: true, + text: 'continually', + italic: true, + }, + { + text: ' evolving to be the ', + }, + { + bold: true, + text: 'best editor experience', + }, + { + text: ' available for static site generators.', + }, + ], + }, + { + type: 'h2', + children: [ + { + text: 'MDX', + }, + ], + }, + { + type: 'p', + children: [ + { + text: 'The output out this widget is ', + }, + { + code: true, + text: 'mdx', + }, + { + text: ', a mixture of ', + }, + { + code: true, + text: 'markdown', + }, + { + text: ' and ', + }, + { + code: true, + text: 'javascript components', + }, + { + text: '. See ', + }, + { + type: 'a', + url: 'https://mdxjs.com/docs/', + children: [ + { + text: 'MDX documentation', + }, + ], + }, + { + text: '.', + }, + ], + }, + { + type: 'code_block', + lang: 'yaml', + code: 'name: body\nlabel: Blog post content\nwidget: markdown', + children: [ + { + text: '', + }, + ], + }, + { + type: 'code_block', + lang: 'js', + code: "name: 'body',\nlabel: 'Blog post content',\nwidget: 'markdown',", + children: [ + { + text: '', + }, + ], + }, + { + type: 'blockquote', + children: [ + { + text: 'See the table below for default options\nMore API information can be found in the document', + }, + ], + }, + { + type: 'table', + children: [ + { + type: 'tr', + children: [ + { + type: 'th', + children: [ + { + type: 'p', + children: [ + { + text: 'Name', + }, + ], + }, + ], + }, + { + type: 'th', + children: [ + { + type: 'p', + children: [ + { + text: 'Type', + }, + ], + }, + ], + }, + { + type: 'th', + children: [ + { + type: 'p', + children: [ + { + text: 'Default', + }, + ], + }, + ], + }, + { + type: 'th', + children: [ + { + type: 'p', + children: [ + { + text: 'Description', + }, + ], + }, + ], + }, + ], + }, + { + type: 'tr', + children: [ + { + type: 'td', + children: [ + { + type: 'p', + children: [ + { + text: 'default', + }, + ], + }, + ], + }, + { + type: 'td', + children: [ + { + type: 'p', + children: [ + { + text: 'string', + }, + ], + }, + ], + }, + { + type: 'td', + children: [ + { + type: 'p', + children: [ + { + code: true, + text: "''", + }, + ], + }, + ], + }, + { + type: 'td', + children: [ + { + type: 'p', + children: [ + { + italic: true, + text: 'Optional', + }, + { + text: '. The default value for the field. Accepts markdown content', + }, + ], + }, + ], + }, + ], + }, + { + type: 'tr', + children: [ + { + type: 'td', + children: [ + { + type: 'p', + children: [ + { + text: 'media_library', + }, + ], + }, + ], + }, + { + type: 'td', + children: [ + { + type: 'p', + children: [ + { + text: 'Media Library Options', + }, + ], + }, + ], + }, + { + type: 'td', + children: [ + { + type: 'p', + children: [ + { + code: true, + text: '{}', + }, + ], + }, + ], + }, + { + type: 'td', + children: [ + { + type: 'p', + children: [ + { + italic: true, + text: 'Optional', + }, + { + text: '. Media library settings to apply when a media library is opened by the current widget. See ', + }, + { + type: 'a', + url: '#media-library-options', + children: [ + { + text: 'Media Library Options', + }, + ], + }, + ], + }, + ], + }, + ], + }, + { + type: 'tr', + children: [ + { + type: 'td', + children: [ + { + type: 'p', + children: [ + { + text: 'media_folder', + }, + ], + }, + ], + }, + { + type: 'td', + children: [ + { + type: 'p', + children: [ + { + text: 'string', + }, + ], + }, + ], + }, + { + type: 'td', + children: [ + { + type: 'p', + children: [ + { + text: '', + }, + ], + }, + ], + }, + { + type: 'td', + children: [ + { + type: 'p', + children: [ + { + italic: true, + text: 'Optional', + }, + { + text: '. Specifies the folder path where uploaded files should be saved, relative to the base of the repo', + }, + ], + }, + ], + }, + ], + }, + { + type: 'tr', + children: [ + { + type: 'td', + children: [ + { + type: 'p', + children: [ + { + text: 'public_folder', + }, + ], + }, + ], + }, + { + type: 'td', + children: [ + { + type: 'p', + children: [ + { + text: 'string', + }, + ], + }, + ], + }, + { + type: 'td', + children: [ + { + type: 'p', + children: [ + { + text: '', + }, + ], + }, + ], + }, + { + type: 'td', + children: [ + { + type: 'p', + children: [ + { + italic: true, + text: 'Optional', + }, + { + text: '. Specifies the folder path where the files uploaded by the media library will be accessed, relative to the base of the built site', + }, + ], + }, + ], + }, + ], + }, + ], + }, + { + type: 'h3', + children: [ + { + text: 'Media Library Options', + }, + ], + }, + { + type: 'table', + children: [ + { + type: 'tr', + children: [ + { + type: 'th', + children: [ + { + type: 'p', + children: [ + { + text: 'Name', + }, + ], + }, + ], + }, + { + type: 'th', + children: [ + { + type: 'p', + children: [ + { + text: 'Type', + }, + ], + }, + ], + }, + { + type: 'th', + children: [ + { + type: 'p', + children: [ + { + text: 'Default', + }, + ], + }, + ], + }, + { + type: 'th', + children: [ + { + type: 'p', + children: [ + { + text: 'Description', + }, + ], + }, + ], + }, + ], + }, + { + type: 'tr', + children: [ + { + type: 'td', + children: [ + { + type: 'p', + children: [ + { + text: 'allow_multiple', + }, + ], + }, + ], + }, + { + type: 'td', + children: [ + { + type: 'p', + children: [ + { + text: 'boolean', + }, + ], + }, + ], + }, + { + type: 'td', + children: [ + { + type: 'p', + children: [ + { + code: true, + text: 'true', + }, + ], + }, + ], + }, + { + type: 'td', + children: [ + { + type: 'p', + children: [ + { + italic: true, + text: 'Optional', + }, + { + text: '. When set to ', + }, + { + code: true, + text: 'false', + }, + { + text: ', prevents multiple selection for any media library extension, but must be supported by the extension in use', + }, + ], + }, + ], + }, + ], + }, + { + type: 'tr', + children: [ + { + type: 'td', + children: [ + { + type: 'p', + children: [ + { + text: 'config', + }, + ], + }, + ], + }, + { + type: 'td', + children: [ + { + type: 'p', + children: [ + { + text: 'string', + }, + ], + }, + ], + }, + { + type: 'td', + children: [ + { + type: 'p', + children: [ + { + code: true, + text: '{}', + }, + ], + }, + ], + }, + { + type: 'td', + children: [ + { + type: 'p', + children: [ + { + italic: true, + text: 'Optional', + }, + { + text: '. A configuration object that will be passed directly to the media library being used - available options are determined by the library', + }, + ], + }, + ], + }, + ], + }, + { + type: 'tr', + children: [ + { + type: 'td', + children: [ + { + type: 'p', + children: [ + { + text: 'choose_url', + }, + ], + }, + ], + }, + { + type: 'td', + children: [ + { + type: 'p', + children: [ + { + text: 'string', + }, + { + type: 'p', + children: [ + { + text: '
', + }, + ], + }, + { + text: '| boolean', + }, + ], + }, + ], + }, + { + type: 'td', + children: [ + { + type: 'p', + children: [ + { + code: true, + text: 'true', + }, + ], + }, + ], + }, + { + type: 'td', + children: [ + { + type: 'p', + children: [ + { + italic: true, + text: 'Optional', + }, + { + text: '. When set to ', + }, + { + code: true, + text: 'false', + }, + { + text: ', the "Insert from URL" button will be hidden', + }, + ], + }, + ], + }, + ], + }, + ], + }, + { + type: 'h2', + children: [ + { + text: 'Features', + }, + ], + }, + { + type: 'ul', + children: [ + { + type: 'li', + checked: null, + children: [ + { + type: 'lic', + children: [ + { + text: 'CommonMark + GFM Specifications', + }, + ], + }, + { + type: 'ul', + children: [ + { + type: 'li', + checked: null, + children: [ + { + type: 'lic', + children: [ + { + text: 'Live ', + }, + { + code: true, + text: 'Preview', + }, + ], + }, + ], + }, + { + type: 'li', + checked: null, + children: [ + { + type: 'lic', + children: [ + { + text: 'Auto Indent', + }, + ], + }, + ], + }, + { + type: 'li', + checked: null, + children: [ + { + type: 'lic', + children: [ + { + text: 'Syntax Highlight', + }, + ], + }, + { + type: 'ol', + children: [ + { + type: 'li', + checked: null, + children: [ + { + type: 'lic', + children: [ + { + text: 'Rich Editor', + }, + ], + }, + ], + }, + { + type: 'li', + checked: null, + children: [ + { + type: 'lic', + children: [ + { + text: 'Preview', + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + { + type: 'h2', + children: [ + { + text: 'Formatting', + }, + ], + }, + { + type: 'p', + children: [ + { + bold: true, + text: 'Bold', + }, + { + text: ', ', + }, + { + italic: true, + text: 'Italic', + }, + { + text: ', ', + }, + { + italic: true, + text: 'both', + bold: true, + }, + ], + }, + { + type: 'p', + children: [ + { + strikethrough: true, + text: 'Strikethrough', + }, + ], + }, + { + type: 'h2', + children: [ + { + text: 'Shortcodes', + }, + ], + }, + { + type: 'p', + children: [ + { + text: 'Text ahead ', + }, + { + type: 'shortcode', + shortcode: 'youtube', + args: ['p6h-rYSVX90'], + children: [ + { + text: '', + }, + ], + }, + { + text: ' and behind', + }, + ], + }, + { + type: 'p', + children: [ + { + type: 'shortcode', + shortcode: 'twitter', + args: ['917359331535966209'], + children: [ + { + text: '', + }, + ], + }, + { + text: ' Only behind text', + }, + ], + }, + { + type: 'p', + children: [ + { + text: 'Only text before ', + }, + { + type: 'shortcode', + shortcode: 'twitter', + args: ['917359331535966209'], + children: [ + { + text: '', + }, + ], + }, + ], + }, + { + type: 'p', + children: [ + { + type: 'shortcode', + shortcode: 'youtube', + args: ['p6h-rYSVX90'], + children: [ + { + text: '', + }, + ], + }, + ], + }, + { + type: 'p', + children: [ + { + text: 'Text ahead ', + }, + { + type: 'shortcode', + shortcode: 'youtube', + args: ['p6h-rYSVX90'], + children: [ + { + text: '', + }, + ], + }, + { + text: ' and behind and another ', + }, + { + type: 'shortcode', + shortcode: 'twitter', + args: ['917359331535966209'], + children: [ + { + text: '', + }, + ], + }, + { + text: ' shortcode', + }, + ], + }, + { + type: 'h2', + children: [ + { + text: 'Support', + }, + ], + }, + { + type: 'blockquote', + children: [ + { + type: 'ul', + children: [ + { + type: 'li', + checked: null, + children: [ + { + type: 'lic', + children: [ + { + text: 'Supports remark plugins', + }, + ], + }, + ], + }, + { + type: 'li', + checked: null, + children: [ + { + type: 'lic', + children: [ + { + text: 'Supports wrappers', + }, + ], + }, + { + type: 'ol', + children: [ + { + type: 'li', + checked: true, + children: [ + { + type: 'lic', + children: [ + { + text: 'React', + }, + ], + }, + ], + }, + { + type: 'li', + checked: false, + children: [ + { + type: 'lic', + children: [ + { + text: 'More coming soon', + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ] as MdValue, + }, + }, + }, +}; + +export const deserializationOnlyTestData: SerializationTests = { + italic: { + 'italic (using *)': { + markdown: '*Italic*', + slate: [ + { + type: ELEMENT_PARAGRAPH, + children: [ + { + italic: true, + text: 'Italic', + }, + ], + }, + ], + }, + }, + + bold: { + 'bold (using __)': { + markdown: '__Bold__', + slate: [ + { + type: ELEMENT_PARAGRAPH, + children: [ + { + bold: true, + text: 'Bold', + }, + ], + }, + ], + }, + }, + + 'bold and italic': { + 'bold and italic (using ___)': { + markdown: '___Bold and Italic___', + slate: [ + { + type: ELEMENT_PARAGRAPH, + children: [ + { + bold: true, + italic: true, + text: 'Bold and Italic', + }, + ], + }, + ], + }, + + 'bold and italic (using **_)': { + markdown: '**_Bold and Italic_**', + slate: [ + { + type: ELEMENT_PARAGRAPH, + children: [ + { + bold: true, + italic: true, + text: 'Bold and Italic', + }, + ], + }, + ], + }, + + 'bold and italic (using __*)': { + markdown: '__*Bold and Italic*__', + slate: [ + { + type: ELEMENT_PARAGRAPH, + children: [ + { + bold: true, + italic: true, + text: 'Bold and Italic', + }, + ], + }, + ], + }, + + 'bold and italic (using *__)': { + markdown: '*__Bold and Italic__*', + slate: [ + { + type: ELEMENT_PARAGRAPH, + children: [ + { + bold: true, + italic: true, + text: 'Bold and Italic', + }, + ], + }, + ], + }, + }, + + color: { + mdx: { + 'color attribute of font tag': { + markdown: 'Colored Text', + slate: [ + { + type: ELEMENT_PARAGRAPH, + children: [ + { + color: 'red', + text: 'Colored Text', + }, + ], + }, + ], + }, + + 'color and style attributes of font tag together (favoring color)': { + markdown: + "Colored Text", + slate: [ + { + type: ELEMENT_PARAGRAPH, + children: [ + { + color: 'blue', + backgroundColor: 'black', + text: 'Colored Text', + }, + ], + }, + ], + }, + }, + }, + + align: { + mdx: { + 'align attribute of paragraph tag': { + markdown: '

Aligned Left

', + slate: [ + { + type: ELEMENT_PARAGRAPH, + align: 'left', + children: [ + { + text: 'Aligned Left', + }, + ], + }, + ], + }, + + 'align and style attributes of font paragraph together (favoring align)': { + markdown: '

Aligned Center

', + slate: [ + { + type: ELEMENT_PARAGRAPH, + align: 'center', + children: [ + { + text: 'Aligned Center', + }, + ], + }, + ], + }, + }, + }, + + break: { + mdx: { + 'break tag': { + markdown: '
', + slate: [ + { + type: ELEMENT_PARAGRAPH, + children: [ + { + text: '', + }, + ], + }, + ], + }, + }, + }, +}; + +function runSectionSerializationTests( + sectionKey: string, + mode: 'markdown' | 'mdx' | 'both', + tests: Record, + testCallback: ( + key: string, + mode: 'markdown' | 'mdx' | 'both', + data: SerializationTestData, + ) => void, +) { + describe(sectionKey, () => { + Object.keys(tests).forEach(key => testCallback(key, mode, tests[key])); + }); +} + +export function runSerializationTests( + testCallback: ( + key: string, + mode: 'markdown' | 'mdx' | 'both', + data: SerializationTestData, + ) => void, + testData = serializationTestData, +) { + Object.keys(testData).forEach(key => { + const data = testData[key]; + + if (isSerializationTest(data)) { + testCallback(key, 'both', data); + return; + } + + if (isSerializationMarkdownMdxSplitTests(data)) { + describe(key, () => { + if (data.markdown) { + runSectionSerializationTests('markdown', 'markdown', data.markdown, testCallback); + } + if (data.mdx) { + runSectionSerializationTests('mdx', 'mdx', data.mdx, testCallback); + } + }); + return; + } + + runSectionSerializationTests(key, 'both', data, testCallback); + }); +} diff --git a/core/src/widgets/markdown/withMarkdownControl.tsx b/core/src/widgets/markdown/withMarkdownControl.tsx new file mode 100644 index 00000000..6d2061c7 --- /dev/null +++ b/core/src/widgets/markdown/withMarkdownControl.tsx @@ -0,0 +1,127 @@ +import { styled } from '@mui/material/styles'; +import React, { useCallback, useMemo, useState } from 'react'; + +import FieldLabel from '@staticcms/core/components/UI/FieldLabel'; +import Outline from '@staticcms/core/components/UI/Outline'; +import useDebounce from '../../lib/hooks/useDebounce'; +import useMarkdownToSlate from './plate/hooks/useMarkdownToSlate'; +import PlateEditor from './plate/PlateEditor'; +import serializeMarkdown from './plate/serialization/serializeMarkdown'; + +import type { MarkdownField, WidgetControlProps } from '@staticcms/core/interface'; +import type { FC } from 'react'; +import type { MdValue } from './plate/plateTypes'; + +const StyledEditorWrapper = styled('div')` + position: relative; + width: 100%; + + .toastui-editor-main .toastui-editor-md-vertical-style .toastui-editor { + width: 100%; + } + + .toastui-editor-main .toastui-editor-md-splitter { + display: none; + } + + .toastui-editor-md-preview { + display: none; + } + + .toastui-editor-defaultUI { + border: none; + } +`; + +export interface WithMarkdownControlProps { + useMdx: boolean; +} + +const withMarkdownControl = ({ useMdx }: WithMarkdownControlProps) => { + const MarkdownControl: FC> = controlProps => { + const { label, value, onChange, hasErrors, collection, entry, field } = controlProps; + + const [internalValue, setInternalValue] = useState(value ?? ''); + const [hasFocus, setHasFocus] = useState(false); + const debouncedFocus = useDebounce(hasFocus, 150); + + const handleOnFocus = useCallback(() => { + setHasFocus(true); + }, []); + + const handleOnBlur = useCallback(() => { + setHasFocus(false); + }, []); + + const handleOnChange = useCallback( + (slateValue: MdValue) => { + const newValue = serializeMarkdown(slateValue, { useMdx }); + if (newValue !== internalValue) { + setInternalValue(newValue); + onChange(newValue); + } + }, + [internalValue, onChange], + ); + + const handleLabelClick = useCallback(() => { + // editorRef.current?.getInstance().focus(); + }, []); + + const [slateValue, loaded] = useMarkdownToSlate(internalValue, { useMdx }); + + return useMemo( + () => ( + + + {label} + + {loaded ? ( + + ) : null} + + + ), + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + collection, + controlProps, + debouncedFocus, + field, + handleLabelClick, + handleOnBlur, + handleOnChange, + handleOnFocus, + hasErrors, + hasFocus, + label, + loaded, + slateValue, + ], + ); + }; + + return MarkdownControl; +}; + +export default withMarkdownControl; diff --git a/core/src/widgets/mdx/index.ts b/core/src/widgets/mdx/index.ts new file mode 100644 index 00000000..5f3a573f --- /dev/null +++ b/core/src/widgets/mdx/index.ts @@ -0,0 +1,22 @@ +import withMarkdownControl from '../markdown/withMarkdownControl'; +import previewComponent from '../markdown/MarkdownPreview'; +import schema from '../markdown/schema'; + +import type { MarkdownField, WidgetParam } from '@staticcms/core/interface'; + +const controlComponent = withMarkdownControl({ useMdx: true }); + +const MdxWidget = (): WidgetParam => { + return { + name: 'mdx', + controlComponent, + previewComponent, + options: { + schema, + }, + }; +}; + +export { controlComponent as MdxControl, previewComponent as MdxPreview, schema as MdxSchema }; + +export default MdxWidget; diff --git a/core/test/setupEnv.js b/core/test/setupEnv.js index 5835880d..b4e5098d 100644 --- a/core/test/setupEnv.js +++ b/core/test/setupEnv.js @@ -10,5 +10,8 @@ if (typeof window === 'undefined') { removeItem: jest.fn(), getItem: jest.fn(), }, + navigator: { + platform: 'Win', + }, }; } diff --git a/core/tsconfig.base.json b/core/tsconfig.base.json index d3528b9c..265ed66d 100644 --- a/core/tsconfig.base.json +++ b/core/tsconfig.base.json @@ -4,8 +4,8 @@ "declarationDir": "dist", "emitDeclarationOnly": true, "jsx": "react", - "target": "esnext", - "module": "esnext", + "target": "ES2020", + "module": "ES2020", "moduleResolution": "node", "esModuleInterop": true, "preserveSymlinks": true, @@ -17,7 +17,7 @@ "noUnusedLocals": true, "noUnusedParameters": true, "baseUrl": "./", - "lib": ["DOM", "es6", "ES2015"], + "lib": ["DOM", "es6", "ES2015", "ES2020"], "paths": { "@staticcms/boolean": ["./src/widgets/boolean"], "@staticcms/boolean/*": ["./src/widgets/boolean/*"],