migrate markdown widget

This commit is contained in:
Shawn Erquhart
2018-07-24 21:46:04 -04:00
parent 3f47fe6dbf
commit f1a2eb33b4
62 changed files with 411 additions and 538 deletions

View File

@ -27,15 +27,12 @@
"gray-matter": "^3.0.6",
"history": "^4.7.2",
"immutable": "^3.7.6",
"is-hotkey": "^0.1.1",
"js-base64": "^2.1.9",
"js-yaml": "^3.10.0",
"jwt-decode": "^2.1.0",
"lib": "^3.0.2",
"localforage": "^1.4.2",
"lodash": "^4.17.10",
"mdast-util-definitions": "^1.2.2",
"mdast-util-to-string": "^1.0.4",
"moment": "^2.11.2",
"netlify-cms-editor-component-image": "2.0.0-alpha.0",
"netlify-cms-lib-auth": "2.0.0-alpha.0",
@ -65,30 +62,14 @@
"react-topbar-progress-indicator": "^2.0.0",
"react-transition-group": "^2.2.1",
"react-waypoint": "^7.1.0",
"recompose": "^0.27.1",
"redux": "^3.3.1",
"redux-notifications": "^4.0.1",
"redux-optimist": "^0.0.2",
"redux-thunk": "^1.0.3",
"rehype-parse": "^3.1.0",
"rehype-remark": "^2.0.0",
"rehype-stringify": "^3.0.0",
"remark-parse": "^3.0.1",
"remark-rehype": "^2.0.0",
"remark-stringify": "^3.0.1",
"sanitize-filename": "^1.6.1",
"semaphore": "^1.0.5",
"slate": "^0.30.0",
"slate-edit-list": "^0.10.1",
"slate-edit-table": "^0.12.0",
"slate-plain-serializer": "^0.4.0",
"slate-react": "0.10.11",
"slate-soft-break": "^0.6.0",
"toml-j0.4": "^1.1.1",
"tomlify-j0.4": "^3.0.0-alpha.0",
"unified": "^6.1.4",
"unist-builder": "^1.0.2",
"unist-util-visit-parents": "^1.1.1",
"url": "^0.11.0",
"uuid": "^3.1.0",
"what-input": "^5.0.3"

View File

@ -10,11 +10,11 @@ import { FileControl, FilePreview } from 'netlify-cms-widget-file';
import { ImageControl, ImagePreview } from 'netlify-cms-widget-image';
import { ListControl, ListPreview } from 'netlify-cms-widget-list';
import { ObjectControl, ObjectPreview } from 'netlify-cms-widget-object';
import { MarkdownControl, MarkdownPreview } from 'netlify-cms-widget-markdown';
import { StringControl, StringPreview } from 'netlify-cms-widget-string';
// import { NumberControl, NumberPreview } from 'netlify-cms-widget-number';
// import { TextControl, TextPreview } from 'netlify-cms-widget-text';
// import { SelectControl, SelectPreview } from 'netlify-cms-widget-select';
// import { MarkdownControl, MarkdownPreview } from 'netlify-cms-widget-markdown';
// import { RelationControl, RelationPreview } from 'netlify-cms-widget-relation';
import image from 'netlify-cms-editor-component-image';
@ -28,11 +28,11 @@ registerWidget('datetime', DateTimeControl, DateTimePreview);
registerWidget('file', FileControl, FilePreview);
registerWidget('image', ImageControl, ImagePreview);
registerWidget('list', ListControl, ListPreview);
registerWidget('markdown', MarkdownControl, MarkdownPreview);
registerWidget('object', ObjectControl, ObjectPreview);
registerWidget('string', StringControl, StringPreview);
// registerWidget('text', TextControl, TextPreview);
// registerWidget('number', NumberControl, NumberPreview);
// registerWidget('markdown', MarkdownControl, MarkdownPreview);
// registerWidget('select', SelectControl, SelectPreview);
// registerWidget('relation', RelationControl, RelationPreview);
registerEditorComponent(image);

View File

@ -24,8 +24,6 @@ import {
deleteUnpublishedEntry
} from 'Actions/editorialWorkflow';
import { deserializeValues } from 'Lib/serializeEntryValues';
import { addAsset } from 'Actions/media';
import { openMediaLibrary, removeInsertedMedia } from 'Actions/mediaLibrary';
import { selectEntry, selectUnpublishedEntry, getAsset } from 'Reducers';
import { selectFields } from 'Reducers/collections';
import { status } from 'Constants/publishModes';
@ -40,7 +38,6 @@ const navigateToEntry = (collectionName, slug) => navigateCollection(`${collecti
class Editor extends React.Component {
static propTypes = {
addAsset: PropTypes.func.isRequired,
boundGetAsset: PropTypes.func.isRequired,
changeDraftField: PropTypes.func.isRequired,
changeDraftFieldValidation: PropTypes.func.isRequired,
@ -49,14 +46,11 @@ class Editor extends React.Component {
createEmptyDraft: PropTypes.func.isRequired,
discardDraft: PropTypes.func.isRequired,
entry: ImmutablePropTypes.map,
mediaPaths: ImmutablePropTypes.map.isRequired,
entryDraft: ImmutablePropTypes.map.isRequired,
loadEntry: PropTypes.func.isRequired,
persistEntry: PropTypes.func.isRequired,
deleteEntry: PropTypes.func.isRequired,
showDelete: PropTypes.bool.isRequired,
openMediaLibrary: PropTypes.func.isRequired,
removeInsertedMedia: PropTypes.func.isRequired,
fields: ImmutablePropTypes.list.isRequired,
slug: PropTypes.string,
newEntry: PropTypes.bool.isRequired,
@ -268,14 +262,10 @@ class Editor extends React.Component {
entry,
entryDraft,
fields,
mediaPaths,
boundGetAsset,
collection,
changeDraftField,
changeDraftFieldValidation,
openMediaLibrary,
addAsset,
removeInsertedMedia,
user,
hasChanged,
displayUrl,
@ -303,12 +293,8 @@ class Editor extends React.Component {
fields={fields}
fieldsMetaData={entryDraft.get('fieldsMetaData')}
fieldsErrors={entryDraft.get('fieldsErrors')}
mediaPaths={mediaPaths}
onChange={changeDraftField}
onValidate={changeDraftFieldValidation}
onOpenMediaLibrary={openMediaLibrary}
onAddAsset={addAsset}
onRemoveInsertedMedia={removeInsertedMedia}
onPersist={this.handlePersistEntry}
onDelete={this.handleDeleteEntry}
onDeleteUnpublishedChanges={this.handleDeleteUnpublishedChanges}
@ -339,7 +325,6 @@ function mapStateToProps(state, ownProps) {
const fields = selectFields(collection, slug);
const entry = newEntry ? null : selectEntry(state, collectionName, slug);
const boundGetAsset = getAsset.bind(null, state);
const mediaPaths = mediaLibrary.get('controlMedia');
const user = auth && auth.get('user');
const hasChanged = entryDraft.get('hasChanged');
const displayUrl = config.get('display_url');
@ -353,7 +338,6 @@ function mapStateToProps(state, ownProps) {
collections,
newEntry,
entryDraft,
mediaPaths,
boundGetAsset,
fields,
slug,
@ -373,9 +357,6 @@ export default connect(
{
changeDraftField,
changeDraftFieldValidation,
openMediaLibrary,
removeInsertedMedia,
addAsset,
loadEntry,
loadEntries,
createDraftFromEntry,

View File

@ -1,8 +1,12 @@
import React from 'react';
import styled, { css, cx } from 'react-emotion';
import { partial, uniqueId } from 'lodash';
import { connect } from 'react-redux';
import { colors, colorsRaw, transitions, lengths, borders } from 'netlify-cms-ui-default';
import { resolveWidget } from 'Lib/registry';
import { resolveWidget, getEditorComponents } from 'Lib/registry';
import { addAsset } from 'Actions/media';
import { openMediaLibrary, removeInsertedMedia } from 'Actions/mediaLibrary';
import { getAsset } from 'Reducers';
import Widget from './Widget';
const styles = {
@ -100,7 +104,7 @@ const ControlErrorsList = styled.ul`
export default class EditorControl extends React.Component {
class EditorControl extends React.Component {
state = {
activeLabel: false,
};
@ -112,11 +116,11 @@ export default class EditorControl extends React.Component {
fieldsMetaData,
fieldsErrors,
mediaPaths,
getAsset,
boundGetAsset,
onChange,
onOpenMediaLibrary,
onAddAsset,
onRemoveInsertedMedia,
openMediaLibrary,
addAsset,
removeInsertedMedia,
onValidate,
processControlRef,
} = this.props;
@ -165,18 +169,34 @@ export default class EditorControl extends React.Component {
metadata={metadata}
onChange={(newValue, newMetadata) => onChange(fieldName, newValue, newMetadata)}
onValidate={onValidate && partial(onValidate, fieldName)}
onOpenMediaLibrary={onOpenMediaLibrary}
onRemoveInsertedMedia={onRemoveInsertedMedia}
onAddAsset={onAddAsset}
getAsset={getAsset}
onOpenMediaLibrary={openMediaLibrary}
onRemoveInsertedMedia={removeInsertedMedia}
onAddAsset={addAsset}
getAsset={boundGetAsset}
hasActiveStyle={this.state.styleActive}
setActiveStyle={() => this.setState({ styleActive: true })}
setInactiveStyle={() => this.setState({ styleActive: false })}
resolveWidget={resolveWidget}
getEditorComponents={getEditorComponents}
ref={processControlRef && partial(processControlRef, fieldName)}
editorControl={EditorControl}
editorControl={ConnectedEditorControl}
/>
</ControlContainer>
);
}
}
const mapStateToProps = (state, ownProps) => ({
mediaPaths: state.mediaLibrary.get('controlMedia'),
boundGetAsset: getAsset.bind(null, state),
});
const mapDispatchToProps = {
openMediaLibrary,
removeInsertedMedia,
addAsset,
};
const ConnectedEditorControl = connect(mapStateToProps, mapDispatchToProps)(EditorControl);
export default ConnectedEditorControl;

View File

@ -36,12 +36,7 @@ export default class ControlPane extends React.Component {
entry,
fieldsMetaData,
fieldsErrors,
mediaPaths,
getAsset,
onChange,
onOpenMediaLibrary,
onAddAsset,
onRemoveInsertedMedia,
onValidate,
} = this.props;
@ -62,12 +57,7 @@ export default class ControlPane extends React.Component {
value={entry.getIn(['data', field.get('name')])}
fieldsMetaData={fieldsMetaData}
fieldsErrors={fieldsErrors}
mediaPaths={mediaPaths}
getAsset={getAsset}
onChange={onChange}
onOpenMediaLibrary={onOpenMediaLibrary}
onAddAsset={onAddAsset}
onRemoveInsertedMedia={onRemoveInsertedMedia}
onValidate={onValidate}
processControlRef={this.processControlRef}
/>
@ -83,11 +73,6 @@ ControlPane.propTypes = {
fields: ImmutablePropTypes.list.isRequired,
fieldsMetaData: ImmutablePropTypes.map.isRequired,
fieldsErrors: ImmutablePropTypes.map.isRequired,
mediaPaths: ImmutablePropTypes.map.isRequired,
getAsset: PropTypes.func.isRequired,
onOpenMediaLibrary: PropTypes.func.isRequired,
onAddAsset: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
onValidate: PropTypes.func.isRequired,
onRemoveInsertedMedia: PropTypes.func.isRequired,
};

View File

@ -40,6 +40,7 @@ export default class Widget extends Component {
onRemoveInsertedMedia: PropTypes.func.isRequired,
getAsset: PropTypes.func.isRequired,
resolveWidget: PropTypes.func.isRequired,
getEditorComponents: PropTypes.func.isRequired,
};
shouldComponentUpdate(nextProps) {
@ -192,6 +193,7 @@ export default class Widget extends Component {
editorControl,
uniqueFieldId,
resolveWidget,
getEditorComponents,
} = this.props;
return React.createElement(controlComponent, {
field,
@ -216,6 +218,7 @@ export default class Widget extends Component {
hasActiveStyle,
editorControl,
resolveWidget,
getEditorComponents,
});
}
}

View File

@ -152,7 +152,6 @@ class EditorInterface extends Component {
fields,
fieldsMetaData,
fieldsErrors,
mediaPaths,
getAsset,
onChange,
enableSave,
@ -162,9 +161,6 @@ class EditorInterface extends Component {
onChangeStatus,
onPublish,
onValidate,
onOpenMediaLibrary,
onAddAsset,
onRemoveInsertedMedia,
user,
hasChanged,
displayUrl,
@ -188,13 +184,8 @@ class EditorInterface extends Component {
fields={fields}
fieldsMetaData={fieldsMetaData}
fieldsErrors={fieldsErrors}
mediaPaths={mediaPaths}
getAsset={getAsset}
onChange={onChange}
onValidate={onValidate}
onOpenMediaLibrary={onOpenMediaLibrary}
onAddAsset={onAddAsset}
onRemoveInsertedMedia={onRemoveInsertedMedia}
ref={c => this.controlPaneRef = c} // eslint-disable-line
/>
</ControlPaneContainer>
@ -283,10 +274,7 @@ EditorInterface.propTypes = {
fields: ImmutablePropTypes.list.isRequired,
fieldsMetaData: ImmutablePropTypes.map.isRequired,
fieldsErrors: ImmutablePropTypes.map.isRequired,
mediaPaths: ImmutablePropTypes.map.isRequired,
getAsset: PropTypes.func.isRequired,
onOpenMediaLibrary: PropTypes.func.isRequired,
onAddAsset: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
onValidate: PropTypes.func.isRequired,
onPersist: PropTypes.func.isRequired,
@ -296,7 +284,6 @@ EditorInterface.propTypes = {
onDeleteUnpublishedChanges: PropTypes.func.isRequired,
onPublish: PropTypes.func.isRequired,
onChangeStatus: PropTypes.func.isRequired,
onRemoveInsertedMedia: PropTypes.func.isRequired,
user: ImmutablePropTypes.map,
hasChanged: PropTypes.bool,
displayUrl: PropTypes.string,

View File

@ -1,13 +0,0 @@
@import "./Object/Object.css";
@import "./List/List.css";
@import "./withMedia/withMedia.css";
@import "./Image/Image.css";
@import "./File/FileControl.css";
@import "./Markdown/Markdown.css";
@import "./Boolean/Boolean.css";
@import "./Relation/Relation.css";
@import "./DateTime/DateTime.css";
:root {
--widgetNestDistance: 14px;
}

View File

@ -1,4 +0,0 @@
@import "./MarkdownControl/RawEditor/index.css";
@import "./MarkdownControl/Toolbar/Toolbar.css";
@import "./MarkdownControl/Toolbar/ToolbarButton.css";
@import "./MarkdownControl/VisualEditor/index.css";

View File

@ -1,15 +0,0 @@
.nc-rawEditor-rawWrapper {
position: relative;
}
.nc-rawEditor-rawEditor {
position: relative;
overflow: hidden;
overflow-x: auto;
min-height: var(--richTextEditorMinHeight);
font-family: var(--fontFamilyMono);
border-top-left-radius: 0;
border-top-right-radius: 0;
border-top: 0;
margin-top: calc(-1 * var(--stickyDistanceBottom));
}

View File

@ -1,83 +0,0 @@
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import React from 'react';
import { Editor as Slate } from 'slate-react';
import Plain from 'slate-plain-serializer';
import { debounce } from 'lodash';
import Toolbar from 'EditorWidgets/Markdown/MarkdownControl/Toolbar/Toolbar';
export default class RawEditor extends React.Component {
constructor(props) {
super(props);
this.state = {
value: Plain.deserialize(this.props.value || ''),
};
}
shouldComponentUpdate(nextProps, nextState) {
return !this.state.value.equals(nextState.value);
}
handleChange = change => {
if (!this.state.value.document.equals(change.value.document)) {
this.handleDocumentChange(change);
}
this.setState({ value: change.value });
};
/**
* When the document value changes, serialize from Slate's AST back to plain
* text (which is Markdown) and pass that up as the new value.
*/
handleDocumentChange = debounce(change => {
const value = Plain.serialize(change.value);
this.props.onChange(value);
}, 150);
/**
* If a paste contains plain text, deserialize it to Slate's AST and insert
* to the document. Selection logic (where to insert, whether to replace) is
* handled by Slate.
*/
handlePaste = (e, data, change) => {
if (data.text) {
const fragment = Plain.deserialize(data.text).document;
return change.insertFragment(fragment);
}
};
handleToggleMode = () => {
this.props.onMode('visual');
};
render() {
const { className, field } = this.props;
return (
<div className="nc-rawEditor-rawWrapper">
<div className="nc-visualEditor-editorControlBar">
<Toolbar
onToggleMode={this.handleToggleMode}
buttons={field.get('buttons')}
className="nc-markdownWidget-toolbarRaw"
disabled
rawMode
/>
</div>
<Slate
className={`${className} nc-rawEditor-rawEditor`}
value={this.state.value}
onChange={this.handleChange}
onPaste={this.handlePaste}
/>
</div>
);
}
}
RawEditor.propTypes = {
onChange: PropTypes.func.isRequired,
onMode: PropTypes.func.isRequired,
className: PropTypes.string.isRequired,
value: PropTypes.string,
field: ImmutablePropTypes.map
};

View File

@ -1,48 +0,0 @@
.nc-toolbar-Toolbar {
background-color: var(--textFieldBorderColor);
position: relative;
display: flex;
justify-content: space-between;
align-items: center;
padding: 11px 14px;
min-height: 58px;
transition: background-color var(--transition), color var(--transition);
}
.nc-markdownWidget-toolbar-toggle {
flex-shrink: 0;
display: flex;
align-items: center;
font-size: 14px;
margin: 0 10px;
}
.nc-markdownWidget-toolbar-toggle-label {
display: inline-block;
text-align: center;
white-space: nowrap;
line-height: 20px;
}
.nc-markdownWidget-toolbar-toggle-label-active {
font-weight: 600;
color: #3a69c7;
}
.nc-toolbar-ToolbarActive {
background-color: var(--colorActive);
color: var(--colorTextLight);
& .nc-markdownWidget-toolbar-toggle-label {
color: var(--colorTextLight);
}
& .nc-markdownWidget-toolbar-toggle-background {
background-color: var(--textFieldBorderColor);
}
}
.nc-toolbar-dropdown {
display: inline-block;
position: relative;
}

View File

@ -1,207 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import { List } from 'immutable';
import ImmutablePropTypes from 'react-immutable-proptypes';
import c from 'classnames';
import { Icon, Toggle, Dropdown, DropdownItem, DropdownButton } from 'netlify-cms-ui-default';
import ToolbarButton from './ToolbarButton';
export default class Toolbar extends React.Component {
static propTypes = {
buttons: PropTypes.object,
onToggleMode: PropTypes.func.isRequired,
rawMode: PropTypes.bool,
plugins: ImmutablePropTypes.map,
onSubmit: PropTypes.func,
onAddAsset: PropTypes.func,
getAsset: PropTypes.func,
disabled: PropTypes.bool,
className: PropTypes.string,
buttons: ImmutablePropTypes.list
};
constructor(props) {
super(props);
this.state = {
activePlugin: null,
};
}
isHidden = button => {
const { buttons } = this.props;
return List.isList(buttons) ? !buttons.includes(button) : false;
}
render() {
const {
onMarkClick,
onBlockClick,
onLinkClick,
selectionHasMark,
selectionHasBlock,
selectionHasLink,
onToggleMode,
rawMode,
plugins,
onAddAsset,
getAsset,
disabled,
onSubmit,
className,
} = this.props;
const { activePlugin } = this.state;
/**
* Because the toggle labels change font weight for active/inactive state,
* we need to set estimated widths for them to maintain position without
* moving other inline items on font weight change.
*/
const toggleOffLabel = 'Rich text';
const toggleOffLabelWidth = '62px';
const toggleOnLabel = 'Markdown';
const toggleOnLabelWidth = '70px';
return (
<div className={c(className, 'nc-toolbar-Toolbar')}>
<div>
<ToolbarButton
type="bold"
label="Bold"
icon="bold"
onClick={onMarkClick}
isActive={selectionHasMark}
isHidden={this.isHidden('bold')}
disabled={disabled}
/>
<ToolbarButton
type="italic"
label="Italic"
icon="italic"
onClick={onMarkClick}
isActive={selectionHasMark}
isHidden={this.isHidden('italic')}
disabled={disabled}
/>
<ToolbarButton
type="code"
label="Code"
icon="code"
onClick={onMarkClick}
isActive={selectionHasMark}
isHidden={this.isHidden('code')}
disabled={disabled}
/>
<ToolbarButton
type="link"
label="Link"
icon="link"
onClick={onLinkClick}
isActive={selectionHasLink}
isHidden={this.isHidden('link')}
disabled={disabled}
/>
<ToolbarButton
type="heading-one"
label="Header 1"
icon="h1"
onClick={onBlockClick}
isActive={selectionHasBlock}
isHidden={this.isHidden('heading-one')}
disabled={disabled}
/>
<ToolbarButton
type="heading-two"
label="Header 2"
icon="h2"
onClick={onBlockClick}
isActive={selectionHasBlock}
isHidden={this.isHidden('heading-two')}
disabled={disabled}
/>
<ToolbarButton
type="quote"
label="Quote"
icon="quote"
onClick={onBlockClick}
isActive={selectionHasBlock}
isHidden={this.isHidden('quote')}
disabled={disabled}
/>
<ToolbarButton
type="code"
label="Code Block"
icon="code-block"
onClick={onBlockClick}
isActive={selectionHasBlock}
isHidden={this.isHidden('code-block')}
disabled={disabled}
/>
<ToolbarButton
type="bulleted-list"
label="Bulleted List"
icon="list-bulleted"
onClick={onBlockClick}
isActive={selectionHasBlock}
isHidden={this.isHidden('bulleted-list')}
disabled={disabled}
/>
<ToolbarButton
type="numbered-list"
label="Numbered List"
icon="list-numbered"
onClick={onBlockClick}
isActive={selectionHasBlock}
isHidden={this.isHidden('numbered-list')}
disabled={disabled}
/>
<div className="nc-toolbar-dropdown">
<Dropdown
dropdownTopOverlap="36px"
renderButton={() => (
<DropdownButton>
<ToolbarButton
label="Add Component"
icon="add-with"
onClick={this.handleComponentsMenuToggle}
disabled={disabled}
/>
</DropdownButton>
)}
>
{plugins && plugins.toList().map((plugin, idx) => (
<DropdownItem key={idx} label={plugin.get('label')} onClick={() => onSubmit(plugin.get('id'))} />
))}
</Dropdown>
</div>
</div>
<div className="nc-markdownWidget-toolbar-toggle">
<span
style={{ width: toggleOffLabelWidth }}
className={c(
'nc-markdownWidget-toolbar-toggle-label',
{ 'nc-markdownWidget-toolbar-toggle-label-active': !rawMode },
)}
>
{toggleOffLabel}
</span>
<Toggle
active={rawMode}
onChange={onToggleMode}
className="nc-markdownWidget-toolbar-toggle"
classNameBackground="nc-markdownWidget-toolbar-toggle-background"
/>
<span
style={{ width: toggleOnLabelWidth }}
className={c(
'nc-markdownWidget-toolbar-toggle-label',
{ 'nc-markdownWidget-toolbar-toggle-label-active': rawMode },
)}
>
{toggleOnLabel}
</span>
</div>
</div>
);
}
}

View File

@ -1,22 +0,0 @@
.nc-toolbarButton-button {
display: inline-block;
padding: 6px;
border: none;
background-color: transparent;
font-size: 16px;
color: inherit;
cursor: pointer;
&:disabled {
cursor: auto;
opacity: 0.5;
}
& .nc-icon {
display: block;
}
}
.nc-toolbarButton-active {
color: #1e2532;
}

View File

@ -1,34 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import c from 'classnames';
import { Icon } from 'netlify-cms-ui-default';
const ToolbarButton = ({ type, label, icon, onClick, isActive, isHidden, disabled }) => {
const active = isActive && type && isActive(type);
if (isHidden) {
return null;
}
return (
<button
className={c('nc-toolbarButton-button', { ['nc-toolbarButton-active']: active })}
onClick={e => onClick && onClick(e, type)}
title={label}
disabled={disabled}
>
{ icon ? <Icon type={icon}/> : label }
</button>
);
};
ToolbarButton.propTypes = {
type: PropTypes.string,
label: PropTypes.string.isRequired,
icon: PropTypes.string,
onClick: PropTypes.func,
isActive: PropTypes.func,
disabled: PropTypes.bool,
};
export default ToolbarButton;

View File

@ -1,22 +0,0 @@
.nc-visualEditor-shortcode {
border-radius: var(--borderRadius);
border: 2px solid var(--textFieldBorderColor);
margin: 12px 0;
padding: 14px;
}
.nc-visualEditor-shortcode-topBar {
background-color: var(--textFieldBorderColor);
margin: calc(-1 * var(--widgetNestDistance)) calc(-1 * var(--widgetNestDistance)) 0;
border-radius: 0;
}
.nc-visualEditor-shortcode-collapsed {
background-color: var(--textFieldBorderColor);
cursor: pointer;
}
.nc-visualEditor-shortcode-collapsedTitle {
padding: 8px;
color: var(--controlLabelColor);
}

View File

@ -1,137 +0,0 @@
import React from 'react';
import c from 'classnames';
import { Map } from 'immutable';
import { connect } from 'react-redux';
import { partial, capitalize } from 'lodash';
import { resolveWidget, getEditorComponents } from 'Lib/registry';
import { openMediaLibrary, removeInsertedMedia } from 'Actions/mediaLibrary';
import { addAsset } from 'Actions/media';
import { getAsset } from 'Reducers';
import { ListItemTopBar } from 'netlify-cms-ui-default';
import { getEditorControl } from '../index';
class Shortcode extends React.Component {
constructor(props) {
super(props);
this.state = {
/**
* The `shortcodeNew` prop is set to `true` when creating a new Shortcode,
* so that the form is immediately open for editing. Otherwise all
* shortcodes are collapsed by default.
*/
collapsed: !props.node.data.get('shortcodeNew'),
};
}
handleChange = (fieldName, value) => {
const { editor, node } = this.props;
const shortcodeData = Map(node.data.get('shortcodeData')).set(fieldName, value);
const data = node.data.set('shortcodeData', shortcodeData);
editor.change(c => c.setNodeByKey(node.key, { data }));
};
handleCollapseToggle = () => {
this.setState({ collapsed: !this.state.collapsed });
}
handleRemove = () => {
const { editor, node } = this.props;
editor.change(change => {
change
.removeNodeByKey(node.key)
.focus();
});
}
handleClick = event => {
/**
* Stop click from propagating to editor, otherwise focus will be passed
* to the editor.
*/
event.stopPropagation();
/**
* If collapsed, any click should open the form.
*/
if (this.state.collapsed) {
this.handleCollapseToggle();
}
}
renderControl = (shortcodeData, field, index) => {
const {
onAddAsset,
boundGetAsset,
mediaPaths,
onOpenMediaLibrary,
onRemoveInsertedMedia,
} = this.props;
if (field.get('widget') === 'hidden') return null;
const value = shortcodeData.get(field.get('name'));
const key = `field-${ field.get('name') }`;
const Control = getEditorControl();
const controlProps = {
field,
value,
onAddAsset,
getAsset: boundGetAsset,
onChange: this.handleChange,
mediaPaths,
onOpenMediaLibrary,
onRemoveInsertedMedia,
};
return (
<div key={key}>
<Control {...controlProps}/>
</div>
);
};
render() {
const { attributes, node, editor } = this.props;
const { collapsed } = this.state;
const pluginId = node.data.get('shortcode');
const shortcodeData = Map(this.props.node.data.get('shortcodeData'));
const plugin = getEditorComponents().get(pluginId);
const className = c(
'nc-objectControl-root',
'nc-visualEditor-shortcode',
{ 'nc-visualEditor-shortcode-collapsed': collapsed },
);
return (
<div {...attributes} className={className} onClick={this.handleClick}>
<ListItemTopBar
className="nc-visualEditor-shortcode-topBar"
collapsed={collapsed}
onCollapseToggle={this.handleCollapseToggle}
onRemove={this.handleRemove}
/>
{
collapsed
? <div className="nc-visualEditor-shortcode-collapsedTitle">{capitalize(pluginId)}</div>
: plugin.get('fields').map(partial(this.renderControl, shortcodeData))
}
</div>
);
}
}
const mapStateToProps = (state, ownProps) => {
const { attributes, node, editor } = ownProps;
return {
mediaPaths: state.mediaLibrary.get('controlMedia'),
boundGetAsset: getAsset.bind(null, state),
attributes,
node,
editor,
};
};
const mapDispatchToProps = {
onAddAsset: addAsset,
onOpenMediaLibrary: openMediaLibrary,
onRemoveInsertedMedia: removeInsertedMedia,
};
export default connect(mapStateToProps, mapDispatchToProps)(Shortcode);

View File

@ -1,269 +0,0 @@
import React from 'react';
import { fromJS } from 'immutable';
import { markdownToSlate } from 'EditorWidgets/Markdown/serializers';
const parser = markdownToSlate;
// Temporary plugins test
const testPlugins = fromJS([
{
label: 'Image',
id: 'image',
fromBlock: match => match && {
image: match[2],
alt: match[1],
},
toBlock: data => `![${ data.alt }](${ data.image })`,
toPreview: data => <img src={data.image} alt={data.alt} />,
pattern: /^!\[([^\]]+)]\(([^)]+)\)$/,
fields: [{
label: 'Image',
name: 'image',
widget: 'image',
}, {
label: 'Alt Text',
name: 'alt',
}],
},
{
id: "youtube",
label: "Youtube",
fields: [{name: 'id', label: 'Youtube Video ID'}],
pattern: /^{{<\s?youtube (\S+)\s?>}}/,
fromBlock: function(match) {
return {
id: match[1]
};
},
toBlock: function(obj) {
return '{{< youtube ' + obj.id + ' >}}';
},
toPreview: function(obj) {
return (
'<img src="http://img.youtube.com/vi/' + obj.id + '/maxresdefault.jpg" alt="Youtube Video"/>'
);
}
},
]);
describe("Compile markdown to Slate Raw AST", () => {
it("should compile simple markdown", () => {
const value = `
# H1
sweet body
`;
expect(parser(value)).toMatchSnapshot();
});
it("should compile a markdown ordered list", () => {
const value = `
# H1
1. yo
2. bro
3. fro
`;
expect(parser(value)).toMatchSnapshot();
});
it("should compile bulleted lists", () => {
const value = `
# H1
* yo
* bro
* fro
`;
expect(parser(value)).toMatchSnapshot();
});
it("should compile multiple header levels", () => {
const value = `
# H1
## H2
### H3
`;
expect(parser(value)).toMatchSnapshot();
});
it("should compile horizontal rules", () => {
const value = `
# H1
---
blue moon
`;
expect(parser(value)).toMatchSnapshot();
});
it("should compile horizontal rules", () => {
const value = `
# H1
---
blue moon
`;
expect(parser(value)).toMatchSnapshot();
});
it("should compile soft breaks (double space)", () => {
const value = `
blue moon
footballs
`;
expect(parser(value)).toMatchSnapshot();
});
it("should compile images", () => {
const value = `
![super](duper.jpg)
`;
expect(parser(value)).toMatchSnapshot();
});
it("should compile code blocks", () => {
const value = `
\`\`\`javascript
var a = 1;
\`\`\`
`;
expect(parser(value)).toMatchSnapshot();
});
it("should compile nested inline markup", () => {
const value = `
# Word
This is **some *hot* content**
perhaps **scalding** even
`;
expect(parser(value)).toMatchSnapshot();
});
it("should compile inline code", () => {
const value = `
# Word
This is some sweet \`inline code\` yo!
`;
expect(parser(value)).toMatchSnapshot();
});
it("should compile links", () => {
const value = `
# Word
How far is it to [Google](https://google.com) land?
`;
expect(parser(value)).toMatchSnapshot();
});
it("should compile plugins", () => {
const value = `
![test](test.png)
{{< test >}}
`;
expect(parser(value)).toMatchSnapshot();
});
it("should compile kitchen sink example", () => {
const value = `
# An exhibit of Markdown
This note demonstrates some of what Markdown is capable of doing.
*Note: Feel free to play with this page. Unlike regular notes, this doesn't
automatically save itself.*
## Basic formatting
Paragraphs can be written like so. A paragraph is the basic block of Markdown.
A paragraph is what text will turn into when there is no reason it should
become anything else.
Paragraphs must be separated by a blank line. Basic formatting of *italics* and
**bold** is supported. This *can be **nested** like* so.
## Lists
### Ordered list
1. Item 1 2. A second item 3. Number 3 4. Ⅳ
*Note: the fourth item uses the Unicode character for Roman numeral four.*
### Unordered list
* An item Another item Yet another item And there's more...
## Paragraph modifiers
### Code block
Code blocks are very useful for developers and other people who look at
code or other things that are written in plain text. As you can see, it
uses a fixed-width font.
You can also make \`inline code\` to add code into other things.
### Quote
> Here is a quote. What this is should be self explanatory. Quotes are
automatically indented when they are used.
## Headings
There are six levels of headings. They correspond with the six levels of HTML
headings. You've probably noticed them already in the page. Each level down
uses one more hash character.
### Headings *can* also contain **formatting**
### They can even contain \`inline code\`
Of course, demonstrating what headings look like messes up the structure of the
page.
I don't recommend using more than three or four levels of headings here,
because, when you're smallest heading isn't too small, and you're largest
heading isn't too big, and you want each size up to look noticeably larger and
more important, there there are only so many sizes that you can use.
## URLs
URLs can be made in a handful of ways:
* A named link to MarkItDown. The easiest way to do these is to select what you
* want to make a link and hit \`Ctrl+L\`. Another named link to
* [MarkItDown](http://www.markitdown.net/) Sometimes you just want a URL like
* <http://www.markitdown.net/>.
## Horizontal rule
A horizontal rule is a line that goes across the middle of the page.
---
It's sometimes handy for breaking things up.
## Images
Markdown can also contain images. I'll need to add something here sometime.
## Finally
There's actually a lot more to Markdown than this. See the official
introduction and syntax for more information. However, be aware that this is
not using the official implementation, and this might work subtly differently
in some of the little things.
`;
expect(parser(value)).toMatchSnapshot();
});
});

View File

@ -1,130 +0,0 @@
@import './Shortcode.css';
:root {
--stickyDistanceBottom: 100px;
}
.nc-visualEditor-editorControlBar {
z-index: 1;
position: sticky;
top: 0;
margin-bottom: var(--stickyDistanceBottom);
}
.nc-visualEditor-wrapper {
position: relative;
}
.nc-visualEditor-editor {
position: relative;
overflow: hidden;
overflow-x: auto;
min-height: var(--richTextEditorMinHeight);
font-family: var(--fontFamilyPrimary);
border-top-left-radius: 0;
border-top-right-radius: 0;
border-top: 0;
margin-top: calc(-1 * var(--stickyDistanceBottom));
}
.nc-visualEditor-editor h1 {
font-size: 32px;
margin-top: 16px;
}
.nc-visualEditor-editor h2 {
font-size: 24px;
margin-top: 12px;
}
.nc-visualEditor-editor h3 {
font-size: 20px;
margin-top: 8px;
}
.nc-visualEditor-editor h4 {
font-size: 18px;
margin-top: 8px;
}
.nc-visualEditor-editor h5,
.nc-visualEditor-editor h6 {
font-size: 16px;
margin-top: 8px;
}
.nc-visualEditor-editor h1,
.nc-visualEditor-editor h2,
.nc-visualEditor-editor h3,
.nc-visualEditor-editor h4,
.nc-visualEditor-editor h5,
.nc-visualEditor-editor h6 {
font-weight: 700;
line-height: 1;
}
.nc-visualEditor-editor p,
.nc-visualEditor-editor pre,
.nc-visualEditor-editor blockquote,
.nc-visualEditor-editor ul,
.nc-visualEditor-editor ol {
margin-top: 16px;
margin-bottom: 16px;
}
.nc-visualEditor-editor a {
text-decoration: underline;
}
.nc-visualEditor-editor hr {
border: 1px solid;
margin-bottom: 16px;
}
.nc-visualEditor-editor li > p {
margin: 0;
}
.nc-visualEditor-editor ul,
.nc-visualEditor-editor ol {
padding-left: 30px;
}
.nc-visualEditor-editor pre {
white-space: pre-wrap;
}
.nc-visualEditor-editor pre > code {
display: block;
width: 100%;
overflow-y: auto;
background-color: #000;
color: #ccc;
border-radius: var(--borderRadius);
padding: 10px;
}
.nc-visualEditor-editor code {
background-color: var(--colorBackground);
border-radius: var(--borderRadius);
padding: 0 2px;
font-size: 85%;
}
.nc-visualEditor-editor blockquote {
padding-left: 16px;
border-left: 3px solid var(--colorBackground);
margin-left: 0;
margin-right: 0;
}
.nc-visualEditor-editor table {
border-collapse: collapse;
}
.nc-visualEditor-editor td,
.nc-visualEditor-editor th {
border: 2px solid black;
padding: 8px;
text-align: left;
}

View File

@ -1,230 +0,0 @@
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import React, { Component } from 'react';
import { get, isEmpty, debounce } from 'lodash';
import { Map } from 'immutable';
import { Value, Document, Block, Text } from 'slate';
import { Editor as Slate } from 'slate-react';
import { slateToMarkdown, markdownToSlate, htmlToSlate } from 'EditorWidgets/Markdown/serializers';
import { getEditorComponents } from 'Lib/registry';
import Toolbar from 'EditorWidgets/Markdown/MarkdownControl/Toolbar/Toolbar';
import { renderNode, renderMark } from './renderers';
import { validateNode } from './validators';
import plugins, { EditListConfigured } from './plugins';
import onKeyDown from './keys';
const createEmptyRawDoc = () => {
const emptyText = Text.create('');
const emptyBlock = Block.create({ kind: 'block', type: 'paragraph', nodes: [ emptyText ] });
return { nodes: [emptyBlock] };
};
const createSlateValue = (rawValue) => {
const rawDoc = rawValue && markdownToSlate(rawValue);
const rawDocHasNodes = !isEmpty(get(rawDoc, 'nodes'))
const document = Document.fromJSON(rawDocHasNodes ? rawDoc : createEmptyRawDoc());
return Value.create({ document });
}
export default class Editor extends Component {
static propTypes = {
onAddAsset: PropTypes.func.isRequired,
getAsset: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
onMode: PropTypes.func.isRequired,
className: PropTypes.string.isRequired,
value: PropTypes.string,
field: ImmutablePropTypes.map
};
constructor(props) {
super(props);
this.state = {
value: createSlateValue(props.value),
shortcodePlugins: getEditorComponents(),
};
}
shouldComponentUpdate(nextProps, nextState) {
return !this.state.value.equals(nextState.value);
}
handlePaste = (e, data, change) => {
if (data.type !== 'html' || data.isShift) {
return;
}
const ast = htmlToSlate(data.html);
const doc = Document.fromJSON(ast);
return change.insertFragment(doc);
}
selectionHasMark = type => this.state.value.activeMarks.some(mark => mark.type === type);
selectionHasBlock = type => this.state.value.blocks.some(node => node.type === type);
handleMarkClick = (event, type) => {
event.preventDefault();
const resolvedChange = this.state.value.change().focus().toggleMark(type);
this.ref.onChange(resolvedChange);
this.setState({ value: resolvedChange.value });
};
handleBlockClick = (event, type) => {
event.preventDefault();
let { value } = this.state;
const { document: doc, selection } = value;
const { unwrapList, wrapInList } = EditListConfigured.changes;
let change = value.change();
// Handle everything except list buttons.
if (!['bulleted-list', 'numbered-list'].includes(type)) {
const isActive = this.selectionHasBlock(type);
change = change.setBlock(isActive ? 'paragraph' : type);
}
// Handle the extra wrapping required for list buttons.
else {
const isSameListType = value.blocks.some(block => {
return !!doc.getClosest(block.key, parent => parent.type === type);
});
const isInList = EditListConfigured.utils.isSelectionInList(value);
if (isInList && isSameListType) {
change = change.call(unwrapList, type);
} else if (isInList) {
const currentListType = type === 'bulleted-list' ? 'numbered-list' : 'bulleted-list';
change = change.call(unwrapList, currentListType).call(wrapInList, type);
} else {
change = change.call(wrapInList, type);
}
}
const resolvedChange = change.focus();
this.ref.onChange(resolvedChange);
this.setState({ value: resolvedChange.value });
};
hasLinks = () => {
return this.state.value.inlines.some(inline => inline.type === 'link');
};
handleLink = () => {
let change = this.state.value.change();
// If the current selection contains links, clicking the "link" button
// should simply unlink them.
if (this.hasLinks()) {
change = change.unwrapInline('link');
}
else {
const url = window.prompt('Enter the URL of the link');
// If nothing is entered in the URL prompt, do nothing.
if (!url) return;
// If no text is selected, use the entered URL as text.
if (change.value.isCollapsed) {
change = change
.insertText(url)
.extend(0 - url.length);
}
change = change
.wrapInline({ type: 'link', data: { url } })
.collapseToEnd();
}
this.ref.onChange(change);
this.setState({ value: change.value });
};
handlePluginAdd = pluginId => {
const { value } = this.state;
const nodes = [Text.create('')];
const block = {
kind: 'block',
type: 'shortcode',
data: {
shortcode: pluginId,
shortcodeNew: true,
shortcodeData: Map(),
},
isVoid: true,
nodes
};
let change = value.change();
const { focusBlock } = change.value;
if (focusBlock.text === '') {
change = change.setNodeByKey(focusBlock.key, block);
} else {
change = change.insertBlock(block);
}
change = change.focus();
this.ref.onChange(change);
this.setState({ value: change.value });
};
handleToggle = () => {
this.props.onMode('raw');
};
handleDocumentChange = debounce(change => {
const raw = change.value.document.toJSON();
const plugins = this.state.shortcodePlugins;
const markdown = slateToMarkdown(raw, plugins);
this.props.onChange(markdown);
}, 150);
handleChange = change => {
if (!this.state.value.document.equals(change.value.document)) {
this.handleDocumentChange(change);
}
this.setState({ value: change.value });
};
processRef = ref => {
this.ref = ref;
}
render() {
const { onAddAsset, getAsset, className, field } = this.props;
return (
<div className="nc-visualEditor-wrapper">
<div className="nc-visualEditor-editorControlBar">
<Toolbar
onMarkClick={this.handleMarkClick}
onBlockClick={this.handleBlockClick}
onLinkClick={this.handleLink}
selectionHasMark={this.selectionHasMark}
selectionHasBlock={this.selectionHasBlock}
selectionHasLink={this.hasLinks}
onToggleMode={this.handleToggle}
plugins={this.state.shortcodePlugins}
onSubmit={this.handlePluginAdd}
onAddAsset={onAddAsset}
getAsset={getAsset}
buttons={field.get('buttons')}
/>
</div>
<Slate
className={`${className} nc-visualEditor-editor`}
value={this.state.value}
renderNode={renderNode}
renderMark={renderMark}
validateNode={validateNode}
plugins={plugins}
onChange={this.handleChange}
onKeyDown={onKeyDown}
onPaste={this.handlePaste}
ref={this.processRef}
spellCheck
/>
</div>
);
}
}

View File

@ -1,54 +0,0 @@
import { Block, Text } from 'slate';
import isHotkey from 'is-hotkey';
export default onKeyDown;
function onKeyDown(event, change) {
const createDefaultBlock = () => {
return Block.create({
type: 'paragraph',
nodes: [Text.create('')],
});
};
if (isHotkey('Enter', event)) {
/**
* If "Enter" is pressed while a single void block is selected, a new
* paragraph should be added above or below it, and the current selection
* (range) should be collapsed to the start of the new paragraph.
*
* If the selected block is the first block in the document, create the
* new block above it. If not, create the new block below it.
*/
const { document: doc, range, anchorBlock, focusBlock } = change.value;
const singleBlockSelected = anchorBlock === focusBlock;
if (!singleBlockSelected || !focusBlock.isVoid) return;
event.preventDefault();
const focusBlockParent = doc.getParent(focusBlock.key);
const focusBlockIndex = focusBlockParent.nodes.indexOf(focusBlock);
const focusBlockIsFirstChild = focusBlockIndex === 0;
const newBlock = createDefaultBlock();
const newBlockIndex = focusBlockIsFirstChild ? 0 : focusBlockIndex + 1;
return change
.insertNodeByKey(focusBlockParent.key, newBlockIndex, newBlock)
.collapseToStartOf(newBlock);
}
const marks = [
[ 'b', 'bold' ],
[ 'i', 'italic' ],
[ 's', 'strikethrough' ],
[ '`', 'code' ],
];
const [ markKey, markName ] = marks.find(([ key ]) => isHotkey(`mod+${key}`, event)) || [];
if (markName) {
event.preventDefault();
return change.toggleMark(markName);
}
};

View File

@ -1,111 +0,0 @@
import { Text, Inline } from 'slate';
import isHotkey from 'is-hotkey';
import SlateSoftBreak from 'slate-soft-break';
import EditList from 'slate-edit-list';
import EditTable from 'slate-edit-table';
const SoftBreak = (options = {}) => ({
onKeyDown(event, change) {
if (options.shift && !isHotkey('shift+enter', event)) return;
if (!options.shift && !isHotkey('enter', event)) return;
const { onlyIn, ignoreIn, defaultBlock = 'paragraph' } = options;
const { type, text } = change.value.startBlock;
if (onlyIn && !onlyIn.includes(type)) return;
if (ignoreIn && ignoreIn.includes(type)) return;
const shouldClose = text.endsWith('\n');
if (shouldClose) {
return change
.deleteBackward(1)
.insertBlock(defaultBlock);
}
const textNode = Text.create('\n');
const breakNode = Inline.create({ type: 'break', nodes: [ textNode ] });
return change
.insertInline(breakNode)
.insertText('')
.collapseToStartOfNextText();
}
});
const SoftBreakOpts = {
onlyIn: ['quote', 'code'],
};
export const SoftBreakConfigured = SoftBreak(SoftBreakOpts);
export const ParagraphSoftBreakConfigured = SoftBreak({ onlyIn: ['paragraph'], shift: true });
const BreakToDefaultBlock = ({ onlyIn = [], defaultBlock = 'paragraph' }) => ({
onKeyDown(event, change) {
const { value } = change;
if (!isHotkey('enter', event) || value.isExpanded) return;
if (onlyIn.includes(value.startBlock.type)) {
return change.insertBlock(defaultBlock);
}
}
});
const BreakToDefaultBlockOpts = {
onlyIn: ['heading-one', 'heading-two', 'heading-three', 'heading-four', 'heading-five', 'heading-six'],
};
export const BreakToDefaultBlockConfigured = BreakToDefaultBlock(BreakToDefaultBlockOpts);
const BackspaceCloseBlock = (options = {}) => ({
onKeyDown(event, change) {
if (event.key !== 'Backspace') return;
const { defaultBlock = 'paragraph', ignoreIn, onlyIn } = options;
const { startBlock } = change.value;
const { type } = startBlock;
if (onlyIn && !onlyIn.includes(type)) return;
if (ignoreIn && ignoreIn.includes(type)) return;
if (startBlock.text === '') {
return change.setBlock(defaultBlock).focus();
}
}
});
const BackspaceCloseBlockOpts = {
ignoreIn: [
'paragraph',
'list-item',
'bulleted-list',
'numbered-list',
'table',
'table-row',
'table-cell',
],
};
export const BackspaceCloseBlockConfigured = BackspaceCloseBlock(BackspaceCloseBlockOpts);
const EditListOpts = {
types: ['bulleted-list', 'numbered-list'],
typeItem: 'list-item',
};
export const EditListConfigured = EditList(EditListOpts);
const EditTableOpts = {
typeTable: 'table',
typeRow: 'table-row',
typeCell: 'table-cell',
};
export const EditTableConfigured = EditTable(EditTableOpts);
const plugins = [
SoftBreakConfigured,
ParagraphSoftBreakConfigured,
BackspaceCloseBlockConfigured,
BreakToDefaultBlockConfigured,
EditListConfigured,
];
export default plugins;

View File

@ -1,96 +0,0 @@
import React from 'react';
import { List } from 'immutable';
import cn from 'classnames';
import Shortcode from './Shortcode';
/**
* Slate uses React components to render each type of node that it receives.
* This is the closest thing Slate has to a schema definition. The types are set
* by us when we manually deserialize from Remark's MDAST to Slate's AST.
*/
/**
* Mark Components
*/
const Bold = props => <strong>{props.children}</strong>;
const Italic = props => <em>{props.children}</em>;
const Strikethrough = props => <s>{props.children}</s>;
const Code = props => <code>{props.children}</code>;
/**
* Node Components
*/
const Paragraph = props => <p {...props.attributes}>{props.children}</p>;
const ListItem = props => <li {...props.attributes}>{props.children}</li>;
const Quote = props => <blockquote {...props.attributes}>{props.children}</blockquote>;
const CodeBlock = props => <pre><code {...props.attributes}>{props.children}</code></pre>;
const HeadingOne = props => <h1 {...props.attributes}>{props.children}</h1>;
const HeadingTwo = props => <h2 {...props.attributes}>{props.children}</h2>;
const HeadingThree = props => <h3 {...props.attributes}>{props.children}</h3>;
const HeadingFour = props => <h4 {...props.attributes}>{props.children}</h4>;
const HeadingFive = props => <h5 {...props.attributes}>{props.children}</h5>;
const HeadingSix = props => <h6 {...props.attributes}>{props.children}</h6>;
const Table = props => <table><tbody {...props.attributes}>{props.children}</tbody></table>;
const TableRow = props => <tr {...props.attributes}>{props.children}</tr>;
const TableCell = props => <td {...props.attributes}>{props.children}</td>;
const ThematicBreak = props => <hr {...props.attributes}/>;
const BulletedList = props => <ul {...props.attributes}>{props.children}</ul>;
const NumberedList = props => (
<ol {...props.attributes} start={props.node.data.get('start') || 1}>{props.children}</ol>
);
const Link = props => {
const data = props.node.get('data');
const marks = data.get('marks');
const url = data.get('url');
const title = data.get('title');
const link = <a href={url} title={title} {...props.attributes}>{props.children}</a>;
const result = !marks ? link : marks.reduce((acc, mark) => {
return renderMark({ mark, children: acc });
}, link);
return result;
};
const Image = props => {
const data = props.node.get('data');
const marks = data.get('marks');
const url = data.get('url');
const title = data.get('title');
const alt = data.get('alt');
const image = <img src={url} title={title} alt={alt} {...props.attributes}/>;
const result = !marks ? image : marks.reduce((acc, mark) => {
return renderMark({ mark, children: acc });
}, image);
return result;
};
export const renderMark = props => {
switch (props.mark.type) {
case 'bold': return <Bold {...props}/>;
case 'italic': return <Italic {...props}/>;
case 'strikethrough': return <Strikethrough {...props}/>;
case 'code': return <Code {...props}/>;
}
};
export const renderNode = props => {
switch (props.node.type) {
case 'paragraph': return <Paragraph {...props}/>;
case 'list-item': return <ListItem {...props}/>;
case 'quote': return <Quote {...props}/>;
case 'code': return <CodeBlock {...props}/>;
case 'heading-one': return <HeadingOne {...props}/>;
case 'heading-two': return <HeadingTwo {...props}/>;
case 'heading-three': return <HeadingThree {...props}/>;
case 'heading-four': return <HeadingFour {...props}/>;
case 'heading-five': return <HeadingFive {...props}/>;
case 'heading-six': return <HeadingSix {...props}/>;
case 'table': return <Table {...props}/>;
case 'table-row': return <TableRow {...props}/>;
case 'table-cell': return <TableCell {...props}/>;
case 'thematic-break': return <ThematicBreak {...props}/>;
case 'bulleted-list': return <BulletedList {...props}/>;
case 'numbered-list': return <NumberedList {...props}/>;
case 'link': return <Link {...props}/>;
case 'image': return <Image {...props}/>;
case 'shortcode': return <Shortcode {...props}/>;
}
};

View File

@ -1,89 +0,0 @@
import { Block, Text } from 'slate';
/**
* Validation functions are used to validate the editor state each time it
* changes, to ensure it is never rendered in an undesirable state.
*/
export function validateNode(node) {
/**
* Validation of the document itself.
*/
if (node.kind === 'document') {
const doc = node;
/**
* If the editor is ever in an empty state, insert an empty
* paragraph block.
*/
const hasBlocks = !doc.getBlocks().isEmpty();
if (!hasBlocks) {
return change => {
const block = Block.create({
type: 'paragraph',
nodes: [Text.create('')],
});
const { key } = change.value.document;
return change.insertNodeByKey(key, 0, block).focus();
};
}
/**
* Ensure that shortcodes are children of the root node.
*/
const nestedShortcode = doc.findDescendant(descendant => {
const { type, key } = descendant;
return type === 'shortcode' && doc.getParent(key).key !== doc.key;
});
if (nestedShortcode) {
const unwrapShortcode = change => {
const key = nestedShortcode.key;
const newDoc = change.value.document;
const newParent = newDoc.getParent(key);
const docIsParent = newParent.key === newDoc.key;
const newParentParent = newDoc.getParent(newParent.key);
const docIsParentParent = newParentParent && newParentParent.key === newDoc.key;
if (docIsParent) {
return change;
}
/**
* Normalization happens by default, and causes all validation to
* restart with the result of a change upon execution. This unwrap loop
* could temporarily place a shortcode node in conflict with an outside
* plugin's schema, resulting in an infinite loop. To ensure against
* this, we turn off normalization until the last change.
*/
change.unwrapNodeByKey(nestedShortcode.key, { normalize: docIsParentParent });
};
return unwrapShortcode;
}
/**
* Ensure that trailing shortcodes are followed by an empty paragraph.
*/
const trailingShortcode = doc.findDescendant(descendant => {
const { type, key } = descendant;
return type === 'shortcode' && doc.getBlocks().last().key === key;
});
if (trailingShortcode) {
return change => {
const text = Text.create('');
const block = Block.create({ type: 'paragraph', nodes: [ text ] });
return change.insertNodeByKey(doc.key, doc.get('nodes').size, block);
};
}
}
/**
* Ensure that code blocks contain no marks.
*/
if (node.type === 'code') {
const invalidChild = node.getTexts().find(text => !text.getMarks().isEmpty());
if (invalidChild) {
return change => (
invalidChild.getMarks().forEach(mark => (
change.removeMarkByKey(invalidChild.key, 0, invalidChild.get('characters').size, mark)
))
);
}
}
};

View File

@ -1,80 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import c from 'classnames';
import { markdownToRemark, remarkToMarkdown } from 'EditorWidgets/Markdown/serializers'
import RawEditor from './RawEditor';
import VisualEditor from './VisualEditor';
const MODE_STORAGE_KEY = 'cms.md-mode';
let editorControl;
export const getEditorControl = () => editorControl;
export default class MarkdownControl extends React.Component {
static propTypes = {
onChange: PropTypes.func.isRequired,
onAddAsset: PropTypes.func.isRequired,
getAsset: PropTypes.func.isRequired,
classNameWrapper: PropTypes.string.isRequired,
editorControl: PropTypes.func.isRequired,
value: PropTypes.string,
};
static defaultProps = {
value: '',
};
constructor(props) {
super(props);
editorControl = props.editorControl;
this.state = { mode: localStorage.getItem(MODE_STORAGE_KEY) || 'visual' };
}
handleMode = (mode) => {
this.setState({ mode });
localStorage.setItem(MODE_STORAGE_KEY, mode);
};
processRef = ref => this.ref = ref;
render() {
const {
onChange,
onAddAsset,
getAsset,
value,
classNameWrapper,
field
} = this.props;
const { mode } = this.state;
const visualEditor = (
<div className="cms-editor-visual" ref={this.processRef}>
<VisualEditor
onChange={onChange}
onAddAsset={onAddAsset}
onMode={this.handleMode}
getAsset={getAsset}
className={classNameWrapper}
value={value}
field={field}
/>
</div>
);
const rawEditor = (
<div className="cms-editor-raw" ref={this.processRef}>
<RawEditor
onChange={onChange}
onAddAsset={onAddAsset}
onMode={this.handleMode}
getAsset={getAsset}
className={classNameWrapper}
value={value}
field={field}
/>
</div>
);
return mode === 'visual' ? visualEditor : rawEditor;
}
}

View File

@ -1,78 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Markdown Preview renderer HTML rendering should render HTML 1`] = `"<div class=\\"nc-widgetPreview\\"><p>Paragraph with <em>inline</em> element</p></div>"`;
exports[`Markdown Preview renderer Markdown rendering Code should render code 1`] = `"<div class=\\"nc-widgetPreview\\"><p>Use the <code>printf()</code> function.</p></div>"`;
exports[`Markdown Preview renderer Markdown rendering Code should render code 2 1`] = `"<div class=\\"nc-widgetPreview\\"><p><code>There is a literal backtick (\`) here.</code></p></div>"`;
exports[`Markdown Preview renderer Markdown rendering General should render markdown 1`] = `
"<div class=\\"nc-widgetPreview\\"><h1>H1</h1>
<p>Text with <strong>bold</strong> &#x26; <em>em</em> elements</p>
<h2>H2</h2>
<ul>
<li>ul item 1</li>
<li>ul item 2</li>
</ul>
<h3>H3</h3>
<ol>
<li>ol item 1</li>
<li>ol item 2</li>
<li>ol item 3</li>
</ol>
<h4>H4</h4>
<p><a href=\\"http://google.com\\">link title</a></p>
<h5>H5</h5>
<p>![alt text](https://pbs.twimg.com/profile_images/678903331176214528/TQTdqGwD.jpg)</p>
<h6>H6</h6></div>"
`;
exports[`Markdown Preview renderer Markdown rendering HTML should render HTML as is when using Markdown 1`] = `
"<div class=\\"nc-widgetPreview\\"><h1>Title</h1>
<form action=\\"test\\">
<label for=\\"input\\">
<input type=\\"checkbox\\" checked=\\"checked\\" id=\\"input\\"/> My label
</label>
<dl class=\\"test-class another-class\\" style=\\"width: 100%\\">
<dt data-attr=\\"test\\">Test HTML content</dt>
<dt>Testing HTML in Markdown</dt>
</dl>
</form>
<h1 style=\\"display: block; border: 10px solid #f00; width: 100%\\">Test</h1></div>"
`;
exports[`Markdown Preview renderer Markdown rendering Headings should render Heading 1 1`] = `"<div class=\\"nc-widgetPreview\\"><h1>Title</h1></div>"`;
exports[`Markdown Preview renderer Markdown rendering Headings should render Heading 2 1`] = `"<div class=\\"nc-widgetPreview\\"><h2>Title</h2></div>"`;
exports[`Markdown Preview renderer Markdown rendering Headings should render Heading 3 1`] = `"<div class=\\"nc-widgetPreview\\"><h3>Title</h3></div>"`;
exports[`Markdown Preview renderer Markdown rendering Headings should render Heading 4 1`] = `"<div class=\\"nc-widgetPreview\\"><h4>Title</h4></div>"`;
exports[`Markdown Preview renderer Markdown rendering Headings should render Heading 5 1`] = `"<div class=\\"nc-widgetPreview\\"><h5>Title</h5></div>"`;
exports[`Markdown Preview renderer Markdown rendering Headings should render Heading 6 1`] = `"<div class=\\"nc-widgetPreview\\"><h6>Title</h6></div>"`;
exports[`Markdown Preview renderer Markdown rendering Links should render links 1`] = `"<div class=\\"nc-widgetPreview\\"><p>I get 10 times more traffic from <a href=\\"http://google.com/\\" title=\\"Google\\">Google</a> than from <a href=\\"http://search.yahoo.com/\\" title=\\"Yahoo Search\\">Yahoo</a> or <a href=\\"http://search.msn.com/\\" title=\\"MSN Search\\">MSN</a>.</p></div>"`;
exports[`Markdown Preview renderer Markdown rendering Lists should render lists 1`] = `
"<div class=\\"nc-widgetPreview\\"><ol>
<li>ol item 1</li>
<li>
<p>ol item 2</p>
<ul>
<li>Sublist 1</li>
<li>Sublist 2</li>
<li>
<p>Sublist 3</p>
<ol>
<li>Sub-Sublist 1</li>
<li>Sub-Sublist 2</li>
<li>Sub-Sublist 3</li>
</ol>
</li>
</ul>
</li>
<li>ol item 3</li>
</ol></div>"
`;

View File

@ -1,132 +0,0 @@
/* eslint max-len:0 */
import React from 'react';
import { shallow } from 'enzyme';
import { padStart } from 'lodash';
import MarkdownPreview from '../index';
import { markdownToHtml } from 'EditorWidgets/Markdown/serializers';
const parser = markdownToHtml;
describe('Markdown Preview renderer', () => {
describe('Markdown rendering', () => {
describe('General', () => {
it('should render markdown', () => {
const value = `
# H1
Text with **bold** & _em_ elements
## H2
* ul item 1
* ul item 2
### H3
1. ol item 1
1. ol item 2
1. ol item 3
#### H4
[link title](http://google.com)
##### H5
![alt text](https://pbs.twimg.com/profile_images/678903331176214528/TQTdqGwD.jpg)
###### H6
`;
const component = shallow(<MarkdownPreview value={markdownToHtml(value)} />);
expect(component.html()).toMatchSnapshot();
});
});
describe('Headings', () => {
for (const heading of [...Array(6).keys()]) {
it(`should render Heading ${ heading + 1 }`, () => {
const value = padStart(' Title', heading + 7, '#');
const component = shallow(<MarkdownPreview value={markdownToHtml(value)} />);
expect(component.html()).toMatchSnapshot();
});
}
});
describe('Lists', () => {
it('should render lists', () => {
const value = `
1. ol item 1
1. ol item 2
* Sublist 1
* Sublist 2
* Sublist 3
1. Sub-Sublist 1
1. Sub-Sublist 2
1. Sub-Sublist 3
1. ol item 3
`;
const component = shallow(<MarkdownPreview value={markdownToHtml(value)} />);
expect(component.html()).toMatchSnapshot();
});
});
describe('Links', () => {
it('should render links', () => {
const value = `
I get 10 times more traffic from [Google] [1] than from [Yahoo] [2] or [MSN] [3].
[1]: http://google.com/ "Google"
[2]: http://search.yahoo.com/ "Yahoo Search"
[3]: http://search.msn.com/ "MSN Search"
`;
const component = shallow(<MarkdownPreview value={markdownToHtml(value)} />);
expect(component.html()).toMatchSnapshot();
});
});
describe('Code', () => {
it('should render code', () => {
const value = 'Use the `printf()` function.';
const component = shallow(<MarkdownPreview value={markdownToHtml(value)} />);
expect(component.html()).toMatchSnapshot();
});
it('should render code 2', () => {
const value = '``There is a literal backtick (`) here.``';
const component = shallow(<MarkdownPreview value={markdownToHtml(value)} />);
expect(component.html()).toMatchSnapshot();
});
});
describe('HTML', () => {
it('should render HTML as is when using Markdown', () => {
const value = `
# Title
<form action="test">
<label for="input">
<input type="checkbox" checked="checked" id="input"/> My label
</label>
<dl class="test-class another-class" style="width: 100%">
<dt data-attr="test">Test HTML content</dt>
<dt>Testing HTML in Markdown</dt>
</dl>
</form>
<h1 style="display: block; border: 10px solid #f00; width: 100%">Test</h1>
`;
const component = shallow(<MarkdownPreview value={markdownToHtml(value)} />);
expect(component.html()).toMatchSnapshot();
});
});
});
describe('HTML rendering', () => {
it('should render HTML', () => {
const value = '<p>Paragraph with <em>inline</em> element</p>';
const component = shallow(<MarkdownPreview value={markdownToHtml(value)} />);
expect(component.html()).toMatchSnapshot();
});
});
});

View File

@ -1,18 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import { markdownToHtml } from 'EditorWidgets/Markdown/serializers';
const MarkdownPreview = ({ value, getAsset }) => {
if (value === null) {
return null;
}
const html = markdownToHtml(value, getAsset);
return <div className="nc-widgetPreview" dangerouslySetInnerHTML={{__html: html}}></div>;
};
MarkdownPreview.propTypes = {
getAsset: PropTypes.func.isRequired,
value: PropTypes.string,
};
export default MarkdownPreview;

View File

@ -1,24 +0,0 @@
import unified from 'unified';
import markdownToRemark from 'remark-parse';
import remarkAllowHtmlEntities from '../remarkAllowHtmlEntities';
const process = markdown => {
const mdast = unified().use(markdownToRemark).use(remarkAllowHtmlEntities).parse(markdown);
/**
* The MDAST will look like:
*
* { type: 'root', children: [
* { type: 'paragraph', children: [
* // results here
* ]}
* ]}
*/
return mdast.children[0].children[0].value;
};
describe('remarkAllowHtmlEntities', () => {
it('should not decode HTML entities', () => {
expect(process('&lt;div&gt;')).toEqual('&lt;div&gt;');
});
});

View File

@ -1,204 +0,0 @@
import u from 'unist-builder';
import remarkAssertParents from '../remarkAssertParents';
const transform = remarkAssertParents();
describe('remarkAssertParents', () => {
it('should unnest invalidly nested blocks', () => {
const input = u('root', [
u('paragraph', [
u('paragraph', [ u('text', 'Paragraph text.') ]),
u('heading', { depth: 1 }, [ u('text', 'Heading text.') ]),
u('code', 'someCode()'),
u('blockquote', [ u('text', 'Quote text.') ]),
u('list', [ u('listItem', [ u('text', 'A list item.') ]) ]),
u('table', [ u('tableRow', [ u('tableCell', [ u('text', 'Text in a table cell.') ]) ]) ]),
u('thematicBreak'),
]),
]);
const output = u('root', [
u('paragraph', [ u('text', 'Paragraph text.') ]),
u('heading', { depth: 1 }, [ u('text', 'Heading text.') ]),
u('code', 'someCode()'),
u('blockquote', [ u('text', 'Quote text.') ]),
u('list', [ u('listItem', [ u('text', 'A list item.') ]) ]),
u('table', [ u('tableRow', [ u('tableCell', [ u('text', 'Text in a table cell.') ]) ]) ]),
u('thematicBreak'),
]);
expect(transform(input)).toEqual(output);
});
it('should unnest deeply nested blocks', () => {
const input = u('root', [
u('paragraph', [
u('paragraph', [
u('paragraph', [
u('paragraph', [ u('text', 'Paragraph text.') ]),
u('heading', { depth: 1 }, [ u('text', 'Heading text.') ]),
u('code', 'someCode()'),
u('blockquote', [
u('paragraph', [
u('strong', [
u('heading', [
u('text', 'Quote text.'),
]),
]),
]),
]),
u('list', [ u('listItem', [ u('text', 'A list item.') ]) ]),
u('table', [ u('tableRow', [ u('tableCell', [ u('text', 'Text in a table cell.') ]) ]) ]),
u('thematicBreak'),
]),
]),
]),
]);
const output = u('root', [
u('paragraph', [ u('text', 'Paragraph text.') ]),
u('heading', { depth: 1 }, [ u('text', 'Heading text.') ]),
u('code', 'someCode()'),
u('blockquote', [
u('heading', [
u('text', 'Quote text.'),
]),
]),
u('list', [ u('listItem', [ u('text', 'A list item.') ]) ]),
u('table', [ u('tableRow', [ u('tableCell', [ u('text', 'Text in a table cell.') ]) ]) ]),
u('thematicBreak'),
]);
expect(transform(input)).toEqual(output);
});
it('should remove blocks that are emptied as a result of denesting', () => {
const input = u('root', [
u('paragraph', [
u('heading', { depth: 1 }, [ u('text', 'Heading text.') ]),
]),
]);
const output = u('root', [
u('heading', { depth: 1 }, [ u('text', 'Heading text.') ]),
]);
expect(transform(input)).toEqual(output);
});
it('should remove blocks that are emptied as a result of denesting', () => {
const input = u('root', [
u('paragraph', [
u('heading', { depth: 1 }, [ u('text', 'Heading text.') ]),
]),
]);
const output = u('root', [
u('heading', { depth: 1 }, [ u('text', 'Heading text.') ]),
]);
expect(transform(input)).toEqual(output);
});
it('should handle assymetrical splits', () => {
const input = u('root', [
u('paragraph', [
u('heading', { depth: 1 }, [ u('text', 'Heading text.') ]),
]),
]);
const output = u('root', [
u('heading', { depth: 1 }, [ u('text', 'Heading text.') ]),
]);
expect(transform(input)).toEqual(output);
});
it('should nest invalidly nested blocks in the nearest valid ancestor', () => {
const input = u('root', [
u('paragraph', [
u('blockquote', [
u('strong', [
u('heading', { depth: 1 }, [ u('text', 'Heading text.') ]),
]),
]),
]),
]);
const output = u('root', [
u('blockquote', [
u('heading', { depth: 1 }, [ u('text', 'Heading text.') ]),
]),
]);
expect(transform(input)).toEqual(output);
});
it('should preserve validly nested siblings of invalidly nested blocks', () => {
const input = u('root', [
u('paragraph', [
u('blockquote', [
u('strong', [
u('text', 'Deep validly nested text a.'),
u('heading', { depth: 1 }, [ u('text', 'Heading text.') ]),
u('text', 'Deep validly nested text b.'),
]),
]),
u('text', 'Validly nested text.'),
]),
]);
const output = u('root', [
u('blockquote', [
u('strong', [
u('text', 'Deep validly nested text a.'),
]),
u('heading', { depth: 1 }, [ u('text', 'Heading text.') ]),
u('strong', [
u('text', 'Deep validly nested text b.'),
]),
]),
u('paragraph', [
u('text', 'Validly nested text.'),
]),
]);
expect(transform(input)).toEqual(output);
});
it('should allow intermediate parents like list and table to contain required block children', () => {
const input = u('root', [
u('blockquote', [
u('list', [
u('listItem', [
u('table', [
u('tableRow', [
u('tableCell', [
u('heading', { depth: 1 }, [ u('text', 'Validly nested heading text.') ]),
]),
]),
]),
]),
]),
]),
]);
const output = u('root', [
u('blockquote', [
u('list', [
u('listItem', [
u('table', [
u('tableRow', [
u('tableCell', [
u('heading', { depth: 1 }, [ u('text', 'Validly nested heading text.') ]),
]),
]),
]),
]),
]),
]),
]);
expect(transform(input)).toEqual(output);
});
});

View File

@ -1,78 +0,0 @@
import unified from 'unified';
import u from 'unist-builder';
import remarkEscapeMarkdownEntities from '../remarkEscapeMarkdownEntities';
const process = text => {
const tree = u('root', [ u('text', text) ]);
const escapedMdast = unified()
.use(remarkEscapeMarkdownEntities)
.runSync(tree);
return escapedMdast.children[0].value;
};
describe('remarkEscapeMarkdownEntities', () => {
it('should escape common markdown entities', () => {
expect(process('*a*')).toEqual('\\*a\\*');
expect(process('**a**')).toEqual('\\*\\*a\\*\\*');
expect(process('***a***')).toEqual('\\*\\*\\*a\\*\\*\\*');
expect(process('_a_')).toEqual('\\_a\\_');
expect(process('__a__')).toEqual('\\_\\_a\\_\\_');
expect(process('~~a~~')).toEqual('\\~\\~a\\~\\~');
expect(process('[]')).toEqual('\\[]');
expect(process('[]()')).toEqual('\\[]()');
expect(process('[a](b)')).toEqual('\\[a](b)');
expect(process('[Test sentence.](https://www.example.com)'))
.toEqual('\\[Test sentence.](https://www.example.com)');
expect(process('![a](b)')).toEqual('!\\[a](b)');
});
it('should not escape inactive, single markdown entities', () => {
expect(process('a*b')).toEqual('a*b');
expect(process('_')).toEqual('_');
expect(process('~')).toEqual('~');
expect(process('[')).toEqual('[');
});
it('should escape leading markdown entities', () => {
expect(process('#')).toEqual('\\#');
expect(process('-')).toEqual('\\-');
expect(process('*')).toEqual('\\*');
expect(process('>')).toEqual('\\>');
expect(process('=')).toEqual('\\=');
expect(process('|')).toEqual('\\|');
expect(process('```')).toEqual('\\`\\``');
expect(process(' ')).toEqual('\\ ');
});
it('should escape leading markdown entities preceded by whitespace', () => {
expect(process('\n #')).toEqual('\\#');
expect(process(' \n-')).toEqual('\\-');
});
it('should not escape leading markdown entities preceded by non-whitespace characters', () => {
expect(process('a# # b #')).toEqual('a# # b #');
expect(process('a- - b -')).toEqual('a- - b -');
});
it('should not escape html tags', () => {
expect(process('<a attr="**a**">')).toEqual('<a attr="**a**">');
expect(process('a b <c attr="**d**"> e')).toEqual('a b <c attr="**d**"> e');
});
it('should escape the contents of html blocks', () => {
expect(process('<div>*a*</div>')).toEqual('<div>\\*a\\*</div>');
});
it('should not escape the contents of preformatted html blocks', () => {
expect(process('<pre>*a*</pre>')).toEqual('<pre>*a*</pre>');
expect(process('<script>*a*</script>')).toEqual('<script>*a*</script>');
expect(process('<style>*a*</style>')).toEqual('<style>*a*</style>');
expect(process('<pre>\n*a*\n</pre>')).toEqual('<pre>\n*a*\n</pre>');
expect(process('a b <pre>*c*</pre> d e')).toEqual('a b <pre>*c*</pre> d e');
});
it('should not parse footnotes', () => {
expect(process('[^a]')).toEqual('\\[^a]');
});
});

View File

@ -1,45 +0,0 @@
import unified from 'unified';
import markdownToRemark from 'remark-parse';
import remarkToMarkdown from 'remark-stringify';
import remarkPaddedLinks from '../remarkPaddedLinks';
const input = markdown =>
unified()
.use(markdownToRemark)
.use(remarkPaddedLinks)
.use(remarkToMarkdown)
.processSync(markdown)
.contents;
const output = markdown =>
unified()
.use(markdownToRemark)
.use(remarkToMarkdown)
.processSync(markdown)
.contents;
describe('remarkPaddedLinks', () => {
it('should move leading and trailing spaces outside of a link', () => {
expect(input('[ a ](b)')).toEqual(output(' [a](b) '));
});
it('should convert multiple leading or trailing spaces to a single space', () => {
expect(input('[ a ](b)')).toEqual(output(' [a](b) '));
});
it('should work with only a leading space or only a trailing space', () => {
expect(input('[ a](b)[c ](d)')).toEqual(output(' [a](b)[c](d) '));
});
it('should work for nested links', () => {
expect(input('* # a[ b ](c)d')).toEqual(output('* # a [b](c) d'));
});
it('should work for parents with multiple links that are not siblings', () => {
expect(input('# a[ b ](c)d **[ e ](f)**')).toEqual(output('# a [b](c) d ** [e](f) **'));
});
it('should work for links with arbitrarily nested children', () => {
expect(input('[ a __*b*__ _c_ ](d)')).toEqual(output(' [a __*b*__ _c_](d) '));
});
});

View File

@ -1,24 +0,0 @@
import unified from 'unified';
import u from 'unist-builder';
import remarkStripTrailingBreaks from '../remarkStripTrailingBreaks';
const process = children => {
const tree = u('root', children);
const strippedMdast = unified()
.use(remarkStripTrailingBreaks)
.runSync(tree);
return strippedMdast.children;
};
describe('remarkStripTrailingBreaks', () => {
it('should remove trailing breaks at the end of a block', () => {
expect(process([u('break')])).toEqual([]);
expect(process([u('break'), u('text', '\n \n')])).toEqual([u('text', '\n \n')]);
expect(process([u('text', 'a'), u('break')])).toEqual([u('text', 'a')]);
});
it('should not remove trailing breaks that are not at the end of a block', () => {
expect(process([u('break'), u('text', 'a')])).toEqual([u('break'), u('text', 'a')]);
});
});

View File

@ -1,36 +0,0 @@
import { flow } from 'lodash';
import { markdownToSlate, slateToMarkdown } from '../index';
const process = flow([markdownToSlate, slateToMarkdown]);
describe('slate', () => {
it('should not decode encoded html entities in inline code', () => {
expect(process('<code>&lt;div&gt;</code>')).toEqual('<code>&lt;div&gt;</code>');
});
it('should parse non-text children of mark nodes', () => {
expect(process('**a[b](c)d**')).toEqual('**a[b](c)d**');
expect(process('**[a](b)**')).toEqual('**[a](b)**');
expect(process('**![a](b)**')).toEqual('**![a](b)**');
expect(process('_`a`_')).toEqual('_`a`_');
expect(process('_`a`b_')).toEqual('_`a`b_');
});
it('should condense adjacent, identically styled text and inline nodes', () => {
expect(process('**a ~~b~~~~c~~**')).toEqual('**a ~~bc~~**');
expect(process('**a ~~b~~~~[c](d)~~**')).toEqual('**a ~~b[c](d)~~**');
});
it('should handle nested markdown entities', () => {
expect(process('**a**b**c**')).toEqual('**a**b**c**');
expect(process('**a _b_ c**')).toEqual('**a _b_ c**');
});
it('should parse inline images as images', () => {
expect(process('a ![b](c)')).toEqual('a ![b](c)');
});
it('should not escape markdown entities in html', () => {
expect(process('<span>*</span>')).toEqual('<span>*</span>');
});
});

View File

@ -1,224 +0,0 @@
import { get, isEmpty, reduce, pull, trimEnd } from 'lodash';
import unified from 'unified';
import u from 'unist-builder';
import markdownToRemarkPlugin from 'remark-parse';
import remarkToMarkdownPlugin from 'remark-stringify';
import remarkToRehype from 'remark-rehype';
import rehypeToHtml from 'rehype-stringify';
import htmlToRehype from 'rehype-parse';
import rehypeToRemark from 'rehype-remark';
import { getEditorComponents } from 'Lib/registry';
import remarkToRehypeShortcodes from './remarkRehypeShortcodes';
import rehypePaperEmoji from './rehypePaperEmoji';
import remarkAssertParents from './remarkAssertParents';
import remarkPaddedLinks from './remarkPaddedLinks';
import remarkWrapHtml from './remarkWrapHtml';
import remarkToSlate from './remarkSlate';
import remarkSquashReferences from './remarkSquashReferences';
import remarkImagesToText from './remarkImagesToText';
import remarkShortcodes from './remarkShortcodes';
import remarkEscapeMarkdownEntities from './remarkEscapeMarkdownEntities';
import remarkStripTrailingBreaks from './remarkStripTrailingBreaks';
import remarkAllowHtmlEntities from './remarkAllowHtmlEntities';
import slateToRemark from './slateRemark';
/**
* This module contains all serializers for the Markdown widget.
*
* The value of a Markdown widget is transformed to various formats during
* editing, and these formats are referenced throughout serializer source
* documentation. Below is brief glossary of the formats used.
*
* - Markdown {string}
* The stringified Markdown value. The value of the field is persisted
* (stored) in this format, and the stringified value is also used when the
* editor is in "raw" Markdown mode.
*
* - MDAST {object}
* Also loosely referred to as "Remark". MDAST stands for MarkDown AST
* (Abstract Syntax Tree), and is an object representation of a Markdown
* document. Underneath, it's a Unist tree with a Markdown-specific schema.
* MDAST syntax is a part of the Unified ecosystem, and powers the Remark
* processor, so Remark plugins may be used.
*
* - HAST {object}
* Also loosely referred to as "Rehype". HAST, similar to MDAST, is an object
* representation of an HTML document. The field value takes this format
* temporarily before the document is stringified to HTML.
*
* - HTML {string}
* The field value is stringifed to HTML for preview purposes - the HTML value
* is never parsed, it is output only.
*
* - Slate Raw AST {object}
* Slate's Raw AST is a very simple and unopinionated object representation of
* a document in a Slate editor. We define our own Markdown-specific schema
* for serialization to/from Slate's Raw AST and MDAST.
*/
/**
* Deserialize a Markdown string to an MDAST.
*/
export const markdownToRemark = markdown => {
/**
* Parse the Markdown string input to an MDAST.
*/
const parsed = unified()
.use(markdownToRemarkPlugin, { fences: true, commonmark: true })
.use(markdownToRemarkRemoveTokenizers, { inlineTokenizers: ['url'] })
.use(remarkAllowHtmlEntities)
.parse(markdown);
/**
* Further transform the MDAST with plugins.
*/
const result = unified()
.use(remarkSquashReferences)
.use(remarkImagesToText)
.use(remarkShortcodes, { plugins: getEditorComponents() })
.runSync(parsed);
return result;
};
/**
* Remove named tokenizers from the parser, effectively deactivating them.
*/
function markdownToRemarkRemoveTokenizers({ inlineTokenizers }) {
inlineTokenizers && inlineTokenizers.forEach(tokenizer => {
delete this.Parser.prototype.inlineTokenizers[tokenizer];
});
}
/**
* Serialize an MDAST to a Markdown string.
*/
export const remarkToMarkdown = obj => {
/**
* Rewrite the remark-stringify text visitor to simply return the text value,
* without encoding or escaping any characters. This means we're completely
* trusting the markdown that we receive.
*/
function remarkAllowAllText() {
const Compiler = this.Compiler;
const visitors = Compiler.prototype.visitors;
visitors.text = node => node.value;
};
/**
* Provide an empty MDAST if no value is provided.
*/
const mdast = obj || u('root', [u('paragraph', [u('text', '')])]);
const remarkToMarkdownPluginOpts = {
commonmark: true,
fences: true,
listItemIndent: '1',
/**
* Settings to emulate the defaults from the Prosemirror editor, not
* necessarily optimal. Should eventually be configurable.
*/
bullet: '*',
strong: '*',
rule: '-',
};
/**
* Transform the MDAST with plugins.
*/
const processedMdast = unified()
.use(remarkEscapeMarkdownEntities)
.use(remarkStripTrailingBreaks)
.runSync(mdast);
const markdown = unified()
.use(remarkToMarkdownPlugin, remarkToMarkdownPluginOpts)
.use(remarkAllowAllText)
.stringify(processedMdast);
/**
* Return markdown with trailing whitespace removed.
*/
return trimEnd(markdown);
};
/**
* Convert Markdown to HTML.
*/
export const markdownToHtml = (markdown, getAsset) => {
const mdast = markdownToRemark(markdown);
const hast = unified()
.use(remarkToRehypeShortcodes, { plugins: getEditorComponents(), getAsset })
.use(remarkToRehype, { allowDangerousHTML: true })
.runSync(mdast);
const html = unified()
.use(rehypeToHtml, { allowDangerousHTML: true, allowDangerousCharacters: true })
.stringify(hast);
return html;
}
/**
* Deserialize an HTML string to Slate's Raw AST. Currently used for HTML
* pastes.
*/
export const htmlToSlate = html => {
const hast = unified()
.use(htmlToRehype, { fragment: true })
.parse(html);
const mdast = unified()
.use(rehypePaperEmoji)
.use(rehypeToRemark, { minify: false })
.runSync(hast);
const slateRaw = unified()
.use(remarkAssertParents)
.use(remarkPaddedLinks)
.use(remarkImagesToText)
.use(remarkShortcodes, { plugins: getEditorComponents() })
.use(remarkWrapHtml)
.use(remarkToSlate)
.runSync(mdast);
return slateRaw;
};
/**
* Convert Markdown to Slate's Raw AST.
*/
export const markdownToSlate = markdown => {
const mdast = markdownToRemark(markdown);
const slateRaw = unified()
.use(remarkWrapHtml)
.use(remarkToSlate)
.runSync(mdast);
return slateRaw;
};
/**
* Convert a Slate Raw AST to Markdown.
*
* Requires shortcode plugins to parse shortcode nodes back to text.
*
* Note that Unified is not utilized for the conversion from Slate's Raw AST to
* MDAST. The conversion is manual because Unified can only operate on Unist
* trees.
*/
export const slateToMarkdown = raw => {
const mdast = slateToRemark(raw, { shortcodePlugins: getEditorComponents() });
const markdown = remarkToMarkdown(mdast);
return markdown;
};

View File

@ -1,15 +0,0 @@
/**
* Dropbox Paper outputs emoji characters as images, and stores the actual
* emoji character in a `data-emoji-ch` attribute on the image. This plugin
* replaces the images with the emoji characters.
*/
export default function rehypePaperEmoji() {
const transform = node => {
if (node.tagName === 'img' && node.properties.dataEmojiCh) {
return { type: 'text', value: node.properties.dataEmojiCh };
}
node.children = node.children ? node.children.map(transform) : node.children;
return node;
};
return transform;
}

View File

@ -1,59 +0,0 @@
export default function remarkAllowHtmlEntities() {
this.Parser.prototype.inlineTokenizers.text = text;
/**
* This is a port of the `remark-parse` text tokenizer, adapted to exclude
* HTML entity decoding.
*/
function text(eat, value, silent) {
var self = this;
var methods;
var tokenizers;
var index;
var length;
var subvalue;
var position;
var tokenizer;
var name;
var min;
var now;
/* istanbul ignore if - never used (yet) */
if (silent) {
return true;
}
methods = self.inlineMethods;
length = methods.length;
tokenizers = self.inlineTokenizers;
index = -1;
min = value.length;
while (++index < length) {
name = methods[index];
if (name === 'text' || !tokenizers[name]) {
continue;
}
tokenizer = tokenizers[name].locator;
if (!tokenizer) {
eat.file.fail('Missing locator: `' + name + '`');
}
position = tokenizer.call(self, value, 1);
if (position !== -1 && position < min) {
min = position;
}
}
subvalue = value.slice(0, min);
eat(subvalue)({
type: 'text',
value: subvalue,
});
}
};

View File

@ -1,83 +0,0 @@
import { concat, last, nth, isEmpty, set } from 'lodash';
import visitParents from 'unist-util-visit-parents';
/**
* remarkUnwrapInvalidNest
*
* Some MDAST node types can only be nested within specific node types - for
* example, a paragraph can't be nested within another paragraph, and a heading
* can't be nested in a "strong" type node. This kind of invalid MDAST can be
* generated by rehype-remark from invalid HTML.
*
* This plugin finds instances of invalid nesting, and unwraps the invalidly
* nested nodes as far up the parental line as necessary, splitting parent nodes
* along the way. The resulting node has no invalidly nested nodes, and all
* validly nested nodes retain their ancestry. Nodes that are emptied as a
* result of unnesting nodes are removed from the tree.
*/
export default function remarkUnwrapInvalidNest() {
return transform;
function transform(tree) {
const invalidNest = findInvalidNest(tree);
if (!invalidNest) return tree;
splitTreeAtNest(tree, invalidNest);
return transform(tree);
}
/**
* visitParents uses unist-util-visit-parent to check every node in the
* tree while having access to every ancestor of the node. This is ideal
* for determining whether a block node has an ancestor that should not
* contain a block node. Note that it operates in a mutable fashion.
*/
function findInvalidNest(tree) {
/**
* Node types that are considered "blocks".
*/
const blocks = ['paragraph', 'heading', 'code', 'blockquote', 'list', 'table', 'thematicBreak'];
/**
* Node types that can contain "block" nodes as direct children. We check
*/
const canContainBlocks = ['root', 'blockquote', 'listItem', 'tableCell'];
let invalidNest;
visitParents(tree, (node, parents) => {
const parentType = !isEmpty(parents) && last(parents).type;
const isInvalidNest = blocks.includes(node.type) && !canContainBlocks.includes(parentType);
if (isInvalidNest) {
invalidNest = concat(parents, node);
return false;
}
});
return invalidNest;
}
function splitTreeAtNest(tree, nest) {
const grandparent = nth(nest, -3) || tree;
const parent = nth(nest, -2);
const node = last(nest);
const splitIndex = grandparent.children.indexOf(parent);
const splitChildren = grandparent.children;
const splitChildIndex = parent.children.indexOf(node);
const childrenBefore = parent.children.slice(0, splitChildIndex);
const childrenAfter = parent.children.slice(splitChildIndex + 1);
const nodeBefore = !isEmpty(childrenBefore) && { ...parent, children: childrenBefore };
const nodeAfter = !isEmpty(childrenAfter) && { ...parent, children: childrenAfter };
const childrenToInsert = [nodeBefore, node, nodeAfter].filter(val => !isEmpty(val));
const beforeChildren = splitChildren.slice(0, splitIndex);
const afterChildren = splitChildren.slice(splitIndex + 1);
const newChildren = concat(beforeChildren, childrenToInsert, afterChildren);
grandparent.children = newChildren;
}
}

View File

@ -1,280 +0,0 @@
import { has, flow, partial, flatMap, flatten, map } from 'lodash';
import { joinPatternSegments, combinePatterns, replaceWhen } from 'Lib/regexHelper';
/**
* Reusable regular expressions segments.
*/
const patternSegments = {
/**
* Matches zero or more HTML attributes followed by the tag close bracket,
* which may be prepended by zero or more spaces. The attributes can use
* single or double quotes and may be prepended by zero or more spaces.
*/
htmlOpeningTagEnd: /(?: *\w+=(?:(?:"[^"]*")|(?:'[^']*')))* *>/,
};
/**
* Patterns matching substrings that should not be escaped. Array values must be
* joined before use.
*/
const nonEscapePatterns = {
/**
* HTML Tags
*
* Matches HTML opening tags and any attributes. Does not check for contents
* between tags or closing tags.
*/
htmlTags: [
/**
* Matches the beginning of an HTML tag, excluding preformatted tag types.
*/
/<(?!pre|style|script)[\w]+/,
/**
* Matches attributes.
*/
patternSegments.htmlOpeningTagEnd,
],
/**
* Preformatted HTML Blocks
*
* Matches HTML blocks with preformatted content. The content of these blocks,
* including the tags and attributes, should not be escaped at all.
*/
preformattedHtmlBlocks: [
/**
* Matches the names of tags known to have preformatted content. The capture
* group is reused when matching the closing tag.
*
* NOTE: this pattern reuses a capture group, and could break if combined with
* other expressions using capture groups.
*/
/<(pre|style|script)/,
/**
* Matches attributes.
*/
patternSegments.htmlOpeningTagEnd,
/**
* Allow zero or more of any character (including line breaks) between the
* tags. Match lazily in case of subsequent blocks.
*/
/(.|[\n\r])*?/,
/**
* Match closing tag via first capture group.
*/
/<\/\1>/,
],
};
/**
* Escape patterns
*
* Each escape pattern matches a markdown entity and captures up to two
* groups. These patterns must use one of the following formulas:
*
* - Single capture group followed by match content - /(...).../
* The captured characters should be escaped and the remaining match should
* remain unchanged.
*
* - Two capture groups surrounding matched content - /(...)...(...)/
* The captured characters in both groups should be escaped and the matched
* characters in between should remain unchanged.
*/
const escapePatterns = [
/**
* Emphasis/Bold - Asterisk
*
* Match strings surrounded by one or more asterisks on both sides.
*/
/(\*+)[^\*]*(\1)/g,
/**
* Emphasis - Underscore
*
* Match strings surrounded by a single underscore on both sides followed by
* a word boundary. Remark disregards whether a word boundary exists at the
* beginning of an emphasis node.
*/
/(_)[^_]+(_)\b/g,
/**
* Bold - Underscore
*
* Match strings surrounded by multiple underscores on both sides. Remark
* disregards the absence of word boundaries on either side of a bold node.
*/
/(_{2,})[^_]*(\1)/g,
/**
* Strikethrough
*
* Match strings surrounded by multiple tildes on both sides.
*/
/(~+)[^~]*(\1)/g,
/**
* Inline Code
*
* Match strings surrounded by backticks.
*/
/(`+)[^`]*(\1)/g,
/**
* Links, Images, References, and Footnotes
*
* Match strings surrounded by brackets. This could be improved to
* specifically match only the exact syntax of each covered entity, but
* doing so through current approach would incur a considerable performance
* penalty.
*/
/(\[)[^\]]*]/g,
];
/**
* Generate new non-escape expression. The non-escape expression matches
* substrings whose contents should not be processed for escaping.
*/
const joinedNonEscapePatterns = map(nonEscapePatterns, pattern => {
return new RegExp(joinPatternSegments(pattern));
});
const nonEscapePattern = combinePatterns(joinedNonEscapePatterns);
/**
* Create chain of successive escape functions for various markdown entities.
*/
const escapeFunctions = escapePatterns.map(pattern => partial(escapeDelimiters, pattern));
const escapeAll = flow(escapeFunctions);
/**
* Executes both the `escapeCommonChars` and `escapeLeadingChars` functions.
*/
function escapeAllChars(text) {
const partiallyEscapedMarkdown = escapeCommonChars(text);
return escapeLeadingChars(partiallyEscapedMarkdown);
}
/**
* escapeLeadingChars
*
* Handles escaping for characters that must be positioned at the beginning of
* the string, such as headers and list items.
*
* Escapes '#', '*', '-', '>', '=', '|', and sequences of 3+ backticks or 4+
* spaces when found at the beginning of a string, preceded by zero or more
* whitespace characters.
*/
function escapeLeadingChars(text) {
return text.replace(/^\s*([-#*>=|]| {4,}|`{3,})/, '$`\\$1');
}
/**
* escapeCommonChars
*
* Escapes active markdown entities. See escape pattern groups for details on
* which entities are replaced.
*/
function escapeCommonChars(text) {
/**
* Generate new non-escape expression (must happen at execution time because
* we use `RegExp.exec`, which tracks it's own state internally).
*/
const nonEscapeExpression = new RegExp(nonEscapePattern, 'gm');
/**
* Use `replaceWhen` to escape markdown entities only within substrings that
* are eligible for escaping.
*/
return replaceWhen(nonEscapeExpression, escapeAll, text, true);
}
/**
* escapeDelimiters
*
* Executes `String.replace` for a given pattern, but only on the first two
* capture groups. Specifically intended for escaping opening (and optionally
* closing) markdown entities without escaping the content in between.
*/
function escapeDelimiters(pattern, text) {
return text.replace(pattern, (match, start, end) => {
const hasEnd = typeof end === 'string';
const matchSliceEnd = hasEnd ? match.length - end.length : match.length;
const content = match.slice(start.length, matchSliceEnd);
return `${escape(start)}${content}${hasEnd ? escape(end) : ''}`;
});
}
/**
* escape
*
* Simple replacement function for escaping markdown entities. Prepends every
* character in the received string with a backslash.
*/
function escape(delim) {
let result = '';
for (const char of delim) {
result += `\\${char}`;
}
return result;
}
/**
* A Remark plugin for escaping markdown entities.
*
* When markdown entities are entered in raw markdown, they don't appear as
* characters in the resulting AST; for example, dashes surrounding a piece of
* text cause the text to be inserted in a special node type, but the asterisks
* themselves aren't present as text. Therefore, we generally don't expect to
* encounter markdown characters in text nodes.
*
* However, the CMS visual editor does not interpret markdown characters, and
* users will expect these characters to be represented literally. In that case,
* we need to escape them, otherwise they'll be interpreted during
* stringification.
*/
export default function remarkEscapeMarkdownEntities() {
const transform = (node, index) => {
/**
* Shortcode nodes will intentionally inject markdown entities in text node
* children not be escaped.
*/
if (has(node.data, 'shortcode')) return node;
const children = node.children && node.children.map(transform);
/**
* Escape characters in text and html nodes only. We store a lot of normal
* text in html nodes to keep Remark from escaping html entities.
*/
if (['text', 'html'].includes(node.type)) {
/**
* Escape all characters if this is the first child node, otherwise only
* common characters.
*/
const value = index === 0 ? escapeAllChars(node.value) : escapeCommonChars(node.value);
return { ...node, value, children };
}
/**
* Always return nodes with recursively mapped children.
*/
return {...node, children };
};
return transform;
}

View File

@ -1,26 +0,0 @@
/**
* Images must be parsed as shortcodes for asset proxying. This plugin converts
* MDAST image nodes back to text to allow shortcode pattern matching. Note that
* this transformation only occurs for images that are the sole child of a top
* level paragraph - any other image is left alone and treated as an inline
* image.
*/
export default function remarkImagesToText() {
return transform;
function transform(node) {
const children = node.children.map(child => {
if (
child.type === 'paragraph'
&& child.children.length === 1
&& child.children[0].type === 'image'
) {
const { alt = '', url = '', title = '' } = child.children[0];
const value = `![${alt}](${url}${title ? ' title' : ''})`;
child.children = [{ type: 'text', value }];
}
return child;
});
return { ...node, children };
}
}

View File

@ -1,128 +0,0 @@
import {
get,
set,
find,
findLast,
startsWith,
endsWith,
trimStart,
trimEnd,
concat,
flatMap
} from 'lodash';
import u from 'unist-builder';
import toString from 'mdast-util-to-string';
/**
* Convert leading and trailing spaces in a link to single spaces outside of the
* link. MDASTs derived from pasted Google Docs HTML require this treatment.
*
* Note that, because we're potentially replacing characters in a link node's
* children with character's in a link node's siblings, we have to operate on a
* parent (link) node and its children at once, rather than just processing
* children one at a time.
*/
export default function remarkPaddedLinks() {
function transform(node) {
/**
* Because we're operating on link nodes and their children at once, we can
* exit if the current node has no children.
*/
if (!node.children) return node;
/**
* Process a node's children if any of them are links. If a node is a link
* with leading or trailing spaces, we'll get back an array of nodes instead
* of a single node, so we use `flatMap` to keep those nodes as siblings
* with the other children.
*
* If performance improvements are found desirable, we could change this to
* only pass in the link nodes instead of the entire array of children, but
* this seems unlikely to produce a noticeable perf gain.
*/
const hasLinkChild = node.children.some(child => child.type === 'link');
const processedChildren = hasLinkChild ? flatMap(node.children, transformChildren) : node.children;
/**
* Run all children through the transform recursively.
*/
const children = processedChildren.map(transform);
return { ...node, children };
};
function transformChildren(node) {
if (node.type !== 'link') return node;
/**
* Get the node's complete string value, check for leading and trailing
* whitespace, and get nodes from each edge where whitespace is found.
*/
const text = toString(node);
const leadingWhitespaceNode = startsWith(text, ' ') && getEdgeTextChild(node);
const trailingWhitespaceNode = endsWith(text, ' ') && getEdgeTextChild(node, true);
if (!leadingWhitespaceNode && !trailingWhitespaceNode) return node;
/**
* Trim the edge nodes in place. Unified handles everything in a mutable
* fashion, so it's often simpler to do the same when working with Unified
* ASTs.
*/
if (leadingWhitespaceNode) {
leadingWhitespaceNode.value = trimStart(leadingWhitespaceNode.value);
}
if (trailingWhitespaceNode) {
trailingWhitespaceNode.value = trimEnd(trailingWhitespaceNode.value);
}
/**
* Create an array of nodes. The first and last child will either be `false`
* or a text node. We filter out the false values before returning.
*/
const nodes = [
leadingWhitespaceNode && u('text', ' '),
node,
trailingWhitespaceNode && u('text', ' ')
];
return nodes.filter(val => val);
}
/**
* Get the first or last non-blank text child of a node, regardless of
* nesting. If `end` is truthy, get the last node, otherwise first.
*/
function getEdgeTextChild(node, end) {
/**
* This was changed from a ternary to a long form if due to issues with istanbul's instrumentation and babel's code
* generation.
* TODO: watch https://github.com/istanbuljs/babel-plugin-istanbul/issues/95
* when it is resolved then revert to ```const findFn = end ? findLast : find;```
*/
let findFn;
if (end) { findFn = findLast }
else { findFn = find };
let edgeChildWithValue;
setEdgeChildWithValue(node);
return edgeChildWithValue;
/**
* searchChildren checks a node and all of it's children deeply to find a
* non-blank text value. When the text node is found, we set it in an outside
* variable, as it may be deep in the tree and therefore wouldn't be returned
* by `find`/`findLast`.
*/
function setEdgeChildWithValue(child) {
if (!edgeChildWithValue && child.value) {
edgeChildWithValue = child;
}
findFn(child.children, setEdgeChildWithValue);
}
}
return transform;
}

View File

@ -1,50 +0,0 @@
import { map, has } from 'lodash';
import { renderToString } from 'react-dom/server';
import u from 'unist-builder';
/**
* This plugin doesn't actually transform Remark (MDAST) nodes to Rehype
* (HAST) nodes, but rather, it prepares an MDAST shortcode node for HAST
* conversion by replacing the shortcode text with stringified HTML for
* previewing the shortcode output.
*/
export default function remarkToRehypeShortcodes({ plugins, getAsset }) {
return transform;
function transform(root) {
const transformedChildren = map(root.children, processShortcodes);
return { ...root, children: transformedChildren };
}
/**
* Mapping function to transform nodes that contain shortcodes.
*/
function processShortcodes(node) {
/**
* If the node doesn't contain shortcode data, return the original node.
*/
if (!has(node, ['data', 'shortcode'])) return node;
/**
* Get shortcode data from the node, and retrieve the matching plugin by
* key.
*/
const { shortcode, shortcodeData } = node.data;
const plugin = plugins.get(shortcode);
/**
* Run the shortcode plugin's `toPreview` method, which will return either
* an HTML string or a React component. If a React component is returned,
* render it to an HTML string.
*/
const value = plugin.toPreview(shortcodeData, getAsset);
const valueHtml = typeof value === 'string' ? value : renderToString(value);
/**
* Return a new 'html' type node containing the shortcode preview markup.
*/
const textNode = u('html', valueHtml);
const children = [ textNode ];
return { ...node, children };
}
}

View File

@ -1,99 +0,0 @@
import { map, every } from 'lodash';
import u from 'unist-builder';
import mdastToString from 'mdast-util-to-string';
/**
* Parse shortcodes from an MDAST.
*
* Shortcodes are plain text, and must be the lone content of a paragraph. The
* paragraph must also be a direct child of the root node. When a shortcode is
* found, we just need to add data to the node so the shortcode can be
* identified and processed when serializing to a new format. The paragraph
* containing the node is also recreated to ensure normalization.
*/
export default function remarkShortcodes({ plugins }) {
return transform;
/**
* Map over children of the root node and convert any found shortcode nodes.
*/
function transform(root) {
const transformedChildren = map(root.children, processShortcodes);
return { ...root, children: transformedChildren };
}
/**
* Mapping function to transform nodes that contain shortcodes.
*/
function processShortcodes(node) {
/**
* If the node is not eligible to contain a shortcode, return the original
* node unchanged.
*/
if (!nodeMayContainShortcode(node)) return node;
/**
* Combine the text values of all children to a single string, check the
* string for a shortcode pattern match, and validate the match.
*/
const text = mdastToString(node).trim();
const { plugin, match } = matchTextToPlugin(text);
const matchIsValid = validateMatch(text, match);
/**
* If a valid match is found, return a new node with shortcode data
* included. Otherwise, return the original node.
*/
return matchIsValid ? createShortcodeNode(text, plugin, match) : node;
};
/**
* Ensure that the node and it's children are acceptable types to contain
* shortcodes. Currently, only a paragraph containing text and/or html nodes
* may contain shortcodes.
*/
function nodeMayContainShortcode(node) {
const validNodeTypes = ['paragraph'];
const validChildTypes = ['text', 'html'];
if (validNodeTypes.includes(node.type)) {
return every(node.children, child => {
return validChildTypes.includes(child.type);
});
}
}
/**
* Return the plugin and RegExp.match result from the first plugin with a
* pattern that matches the given text.
*/
function matchTextToPlugin(text) {
let match;
const plugin = plugins.find(p => {
match = text.match(p.pattern);
return !!match;
});
return { plugin, match };
}
/**
* A match is only valid if it takes up the entire paragraph.
*/
function validateMatch(text, match) {
return match && match[0].length === text.length;
}
/**
* Create a new node with shortcode data included. Use an 'html' node instead
* of a 'text' node as the child to ensure the node content is not parsed by
* Remark or Rehype. Include the child as an array because an MDAST paragraph
* node must have it's children in an array.
*/
function createShortcodeNode(text, plugin, match) {
const shortcode = plugin.id;
const shortcodeData = plugin.fromBlock(match);
const data = { shortcode, shortcodeData };
const textNode = u('html', text);
return u('paragraph', { data }, [textNode]);
}
}

View File

@ -1,361 +0,0 @@
import { get, isEmpty, isArray, last, flatMap } from 'lodash';
import u from 'unist-builder';
/**
* A Remark plugin for converting an MDAST to Slate Raw AST. Remark plugins
* return a `transform` function that receives the MDAST as it's first argument.
*/
export default function remarkToSlate() {
return transform;
}
function transform(node) {
/**
* Call `transform` recursively on child nodes.
*
* If a node returns a falsey value, filter it out. Some nodes do not
* translate from MDAST to Slate, such as definitions for link/image
* references or footnotes.
*/
const children = !['strong', 'emphasis', 'delete'].includes(node.type)
&& !isEmpty(node.children)
&& flatMap(node.children, transform).filter(val => val);
/**
* Run individual nodes through the conversion factory.
*/
return convertNode(node, children);
}
/**
* Map of MDAST node types to Slate node types.
*/
const typeMap = {
root: 'root',
paragraph: 'paragraph',
blockquote: 'quote',
code: 'code',
listItem: 'list-item',
table: 'table',
tableRow: 'table-row',
tableCell: 'table-cell',
thematicBreak: 'thematic-break',
link: 'link',
image: 'image',
shortcode: 'shortcode',
};
/**
* Map of MDAST node types to Slate mark types.
*/
const markMap = {
strong: 'bold',
emphasis: 'italic',
delete: 'strikethrough',
inlineCode: 'code',
};
/**
* Add nodes to a parent node only if `nodes` is truthy.
*/
function addNodes(parent, nodes) {
return nodes ? { ...parent, nodes } : parent;
}
/**
* Create a Slate Inline node.
*/
function createBlock(type, nodes, props = {}) {
if (!isArray(nodes)) {
props = nodes;
nodes = undefined;
}
const node = { kind: 'block', type, ...props };
return addNodes(node, nodes);
}
/**
* Create a Slate Block node.
*/
function createInline(type, props = {}, nodes) {
const node = { kind: 'inline', type, ...props };
return addNodes(node, nodes);
}
/**
* Create a Slate Raw text node.
*/
function createText(value, data) {
const node = { kind: 'text', data };
const leaves = isArray(value) ? value : [{ text: value }];
return { ...node, leaves };
}
function processMarkNode(node, parentMarks = []) {
/**
* Add the current node's mark type to the marks collected from parent
* mark nodes, if any.
*/
const markType = markMap[node.type];
const marks = markType ? [...parentMarks, { type: markMap[node.type] }] : parentMarks;
const children = flatMap(node.children, childNode => {
switch (childNode.type) {
/**
* If a text node is a direct child of the current node, it should be
* set aside as a leaf, and all marks that have been collected in the
* `marks` array should apply to that specific leaf.
*/
case 'html':
case 'text':
return { text: childNode.value, marks };
/**
* MDAST inline code nodes don't have children, just a text value, similar
* to a text node, so it receives the same treatment as a text node, but we
* first add the inline code mark to the marks array.
*/
case 'inlineCode': {
const childMarks = [ ...marks, { type: markMap['inlineCode'] } ];
return { text: childNode.value, marks: childMarks };
}
/**
* Process nested style nodes. The recursive results should be pushed into
* the leaves array. This way, every MDAST nested text structure becomes a
* flat array of leaves that can serve as the value of a single Slate Raw
* text node.
*/
case 'strong':
case 'emphasis':
case 'delete':
return processMarkNode(childNode, marks);
/**
* Remaining nodes simply need mark data added to them, and to then be
* added into the cumulative children array.
*/
default:
return { ...childNode, data: { marks } };
}
});
return children;
}
function convertMarkNode(node) {
const slateNodes = processMarkNode(node);
const convertedSlateNodes = slateNodes.reduce((acc, node) => {
const lastConvertedNode = last(acc);
if (node.text && lastConvertedNode && lastConvertedNode.leaves) {
lastConvertedNode.leaves.push(node);
}
else if (node.text) {
acc.push(createText([node]));
}
else {
acc.push(transform(node));
}
return acc;
}, []);
return convertedSlateNodes;
}
/**
* Convert a single MDAST node to a Slate Raw node. Uses local node factories
* that mimic the unist-builder function utilized in the slateRemark
* transformer.
*/
function convertNode(node, nodes) {
/**
* Unified/Remark processors use mutable operations, so we don't want to
* change a node's type directly for conversion purposes, as that tends to
* unexpected errors.
*/
const type = get(node, ['data', 'shortcode']) ? 'shortcode' : node.type;
switch (type) {
/**
* General
*
* Convert simple cases that only require a type and children, with no
* additional properties.
*/
case 'root':
case 'paragraph':
case 'listItem':
case 'blockquote':
case 'tableRow':
case 'tableCell': {
return createBlock(typeMap[type], nodes);
}
/**
* Shortcodes
*
* Shortcode nodes are represented as "void" blocks in the Slate AST. They
* maintain the same data as MDAST shortcode nodes. Slate void blocks must
* contain a blank text node.
*/
case 'shortcode': {
const { data } = node;
const nodes = [ createText('') ];
return createBlock(typeMap[type], nodes, { data, isVoid: true });
}
/**
* Text
*
* Text and HTML nodes are both used to render text, and should be treated
* the same. HTML is treated as text because we never want to escape or
* encode it.
*/
case 'text':
case 'html': {
return createText(node.value, node.data);
}
/**
* Inline Code
*
* Inline code nodes from an MDAST are represented in our Slate schema as
* text nodes with a "code" mark. We manually create the "leaf" containing
* the inline code value and a "code" mark, and place it in an array for use
* as a Slate text node's children array.
*/
case 'inlineCode': {
const leaf = {
text: node.value,
marks: [{ type: 'code' }],
};
return createText([ leaf ]);
}
/**
* Marks
*
* Marks are typically decorative sub-types that apply to text nodes. In an
* MDAST, marks are nodes that can contain other nodes. This nested
* hierarchy has to be flattened and split into distinct text nodes with
* their own set of marks.
*/
case 'strong':
case 'emphasis':
case 'delete': {
return convertMarkNode(node);
}
/**
* Headings
*
* MDAST headings use a single type with a separate "depth" property to
* indicate the heading level, while the Slate schema uses a separate node
* type for each heading level. Here we get the proper Slate node name based
* on the MDAST node depth.
*/
case 'heading': {
const depthMap = { 1: 'one', 2: 'two', 3: 'three', 4: 'four', 5: 'five', 6: 'six' };
const slateType = `heading-${depthMap[node.depth]}`;
return createBlock(slateType, nodes);
}
/**
* Code Blocks
*
* MDAST code blocks are a distinct node type with a simple text value. We
* convert that value into a nested child text node for Slate. We also carry
* over the "lang" data property if it's defined.
*/
case 'code': {
const data = { lang: node.lang };
const text = createText(node.value);
const nodes = [text];
return createBlock(typeMap[type], nodes, { data });
}
/**
* Lists
*
* MDAST has a single list type and an "ordered" property. We derive that
* information into the Slate schema's distinct list node types. We also
* include the "start" property, which indicates the number an ordered list
* starts at, if defined.
*/
case 'list': {
const slateType = node.ordered ? 'numbered-list' : 'bulleted-list';
const data = { start: node.start };
return createBlock(slateType, nodes, { data });
}
/**
* Breaks
*
* MDAST soft break nodes represent a trailing double space or trailing
* slash from a Markdown document. In Slate, these are simply transformed to
* line breaks within a text node.
*/
case 'break': {
const textNode = createText('\n');
return createInline('break', {}, [ textNode ]);
}
/**
* Thematic Breaks
*
* Thematic breaks are void nodes in the Slate schema.
*/
case 'thematicBreak': {
return createBlock(typeMap[type], { isVoid: true });
}
/**
* Links
*
* MDAST stores the link attributes directly on the node, while our Slate
* schema references them in the data object.
*/
case 'link': {
const { title, url, data } = node;
const newData = { ...data, title, url };
return createInline(typeMap[type], { data: newData }, nodes);
}
/**
* Images
*
* Identical to link nodes except for the lack of child nodes and addition
* of alt attribute data MDAST stores the link attributes directly on the
* node, while our Slate schema references them in the data object.
*/
case 'image': {
const { title, url, alt, data } = node;
const newData = { ...data, title, alt, url };
return createInline(typeMap[type], { isVoid: true, data: newData });
}
/**
* Tables
*
* Tables are parsed separately because they may include an "align"
* property, which should be passed to the Slate node.
*/
case 'table': {
const data = { align: node.align };
return createBlock(typeMap[type], nodes, { data });
}
}
}

View File

@ -1,74 +0,0 @@
import { without, flatten } from 'lodash';
import u from 'unist-builder';
import mdastDefinitions from 'mdast-util-definitions';
/**
* Raw markdown may contain image references or link references. Because there
* is no way to maintain these references within the Slate AST, we convert image
* and link references to standard images and links by putting their url's
* inline. The definitions are then removed from the document.
*
* For example, the following markdown:
*
* ```
* ![alpha][bravo]
*
* [bravo]: http://example.com/example.jpg
* ```
*
* Yields:
*
* ```
* ![alpha](http://example.com/example.jpg)
* ```
*
*/
export default function remarkSquashReferences() {
return getTransform;
function getTransform(node) {
const getDefinition = mdastDefinitions(node);
return transform.call(null, getDefinition, node);
}
function transform(getDefinition, node) {
/**
* Bind the `getDefinition` function to `transform` and recursively map all
* nodes.
*/
const boundTransform = transform.bind(null, getDefinition);
const children = node.children ? node.children.map(boundTransform) : node.children;
/**
* Combine reference and definition nodes into standard image and link
* nodes.
*/
if (['imageReference', 'linkReference'].includes(node.type)) {
const type = node.type === 'imageReference' ? 'image' : 'link';
const definition = getDefinition(node.identifier);
if (definition) {
const { title, url } = definition;
return u(type, { title, url, alt: node.alt }, children);
}
const pre = u('text', node.type === 'imageReference' ? '![' : '[');
const post = u('text', ']');
const nodes = children || [ u('text', node.alt) ];
return [ pre, ...nodes, post];
}
/**
* Remove definition nodes and filter the resulting null values from the
* filtered children array.
*/
if(node.type === 'definition') {
return null;
}
const filteredChildren = without(children, null);
return { ...node, children: flatten(filteredChildren) };
}
}

View File

@ -1,56 +0,0 @@
import mdastToString from 'mdast-util-to-string';
/**
* Removes break nodes that are at the end of a block.
*
* When a trailing double space or backslash is encountered at the end of a
* markdown block, Remark will interpret the character(s) literally, as only
* break entities followed by text qualify as breaks. A manually created MDAST,
* however, may have such entities, and users of visual editors shouldn't see
* these artifacts in resulting markdown.
*/
export default function remarkStripTrailingBreaks() {
const transform = node => {
if (node.children) {
node.children = node.children
.map((child, idx, children) => {
/**
* Only touch break nodes. Convert all subsequent nodes to their text
* value and exclude the break node if no non-whitespace characters
* are found.
*/
if (child.type === 'break') {
const subsequentNodes = children.slice(idx + 1);
/**
* Create a small MDAST so that mdastToString can process all
* siblings as children of one node rather than making multiple
* calls.
*/
const fragment = { type: 'root', children: subsequentNodes };
const subsequentText = mdastToString(fragment);
return subsequentText.trim() ? child : null;
}
/**
* Always return the child if not a break.
*/
return child;
})
/**
* Because some break nodes may be excluded, we filter out the resulting
* null values.
*/
.filter(child => child)
/**
* Recurse through the MDAST by transforming each individual child node.
*/
.map(transform);
}
return node;
};
return transform;
};

View File

@ -1,21 +0,0 @@
import u from 'unist-builder';
/**
* Ensure that top level 'html' type nodes are wrapped in paragraphs. Html nodes
* are used for text nodes that we don't want Remark or Rehype to parse.
*/
export default function remarkWrapHtml() {
function transform(tree) {
tree.children = tree.children.map(node => {
if (node.type === 'html') {
return u('paragraph', [node]);
}
return node;
});
return tree;
}
return transform;
}

View File

@ -1,502 +0,0 @@
import { get, isEmpty, without, flatMap, last, sortBy } from 'lodash';
import u from 'unist-builder';
/**
* Map of Slate node types to MDAST/Remark node types.
*/
const typeMap = {
'root': 'root',
'paragraph': 'paragraph',
'heading-one': 'heading',
'heading-two': 'heading',
'heading-three': 'heading',
'heading-four': 'heading',
'heading-five': 'heading',
'heading-six': 'heading',
'quote': 'blockquote',
'code': 'code',
'numbered-list': 'list',
'bulleted-list': 'list',
'list-item': 'listItem',
'table': 'table',
'table-row': 'tableRow',
'table-cell': 'tableCell',
'break': 'break',
'thematic-break': 'thematicBreak',
'link': 'link',
'image': 'image',
};
/**
* Map of Slate mark types to MDAST/Remark node types.
*/
const markMap = {
bold: 'strong',
italic: 'emphasis',
strikethrough: 'delete',
code: 'inlineCode',
};
let shortcodePlugins;
export default function slateToRemark(raw, opts) {
/**
* Set shortcode plugins in outer scope.
*/
({ shortcodePlugins } = opts);
/**
* The Slate Raw AST generally won't have a top level type, so we set it to
* "root" for clarity.
*/
raw.type = 'root';
return transform(raw);
}
/**
* The transform function mimics the approach of a Remark plugin for
* conformity with the other serialization functions. This function converts
* Slate nodes to MDAST nodes, and recursively calls itself to process child
* nodes to arbitrary depth.
*/
function transform(node) {
/**
* Combine adjacent text and inline nodes before processing so they can
* share marks.
*/
const combinedChildren = node.nodes && combineTextAndInline(node.nodes);
/**
* Call `transform` recursively on child nodes, and flatten the resulting
* array.
*/
const children = !isEmpty(combinedChildren) && flatMap(combinedChildren, transform);
/**
* Run individual nodes through conversion factories.
*/
return ['text'].includes(node.kind)
? convertTextNode(node)
: convertNode(node, children, shortcodePlugins);
}
/**
* Includes inline nodes as leaves in adjacent text nodes where appropriate, so
* that mark node combining logic can apply to both text and inline nodes. This
* is necessary because Slate doesn't allow inline nodes to have marks while
* inline nodes in MDAST may be nested within mark nodes. Treating them as if
* they were text is a bit of a necessary hack.
*/
function combineTextAndInline(nodes) {
return nodes.reduce((acc, node, idx, nodes) => {
const prevNode = last(acc);
const prevNodeLeaves = get(prevNode, 'leaves');
const data = node.data || {};
/**
* If the previous node has leaves and the current node has marks in data
* (only happens when we place them on inline nodes here in the parser), or
* the current node also has leaves (because the previous node was
* originally an inline node that we've already squashed into a leaf)
* combine the current node into the previous.
*/
if (!isEmpty(prevNodeLeaves) && !isEmpty(data.marks)) {
prevNodeLeaves.push({ node, marks: data.marks });
return acc;
}
if (!isEmpty(prevNodeLeaves) && !isEmpty(node.leaves)) {
prevNode.leaves = prevNodeLeaves.concat(node.leaves);
return acc;
}
/**
* Break nodes contain a single child text node with a newline character
* for visual purposes in the editor, but Remark break nodes have no
* children, so we remove the child node here.
*/
if (node.type === 'break') {
acc.push({ kind: 'inline', type: 'break' });
return acc;
}
/**
* Convert remaining inline nodes to standalone text nodes with leaves.
*/
if (node.kind === 'inline') {
acc.push({ kind: 'text', leaves: [{ node, marks: data.marks }] });
return acc;
}
/**
* Only remaining case is an actual text node, can be pushed as is.
*/
acc.push(node);
return acc;
}, []);
}
/**
* Slate treats inline code decoration as a standard mark, but MDAST does
* not allow inline code nodes to contain children, only a single text
* value. An MDAST inline code node can be nested within mark nodes such
* as "emphasis" and "strong", but it cannot contain them.
*
* Because of this, if a "code" mark (translated to MDAST "inlineCode") is
* in the markTypes array, we make the base text node an "inlineCode" type
* instead of a standard text node.
*/
function processCodeMark(markTypes) {
const isInlineCode = markTypes.includes('inlineCode');
const filteredMarkTypes = isInlineCode ? without(markTypes, 'inlineCode') : markTypes;
const textNodeType = isInlineCode ? 'inlineCode' : 'html';
return { filteredMarkTypes, textNodeType };
}
/**
* Wraps a text node in one or more mark nodes by placing the text node in an
* array and using that as the `children` value of a mark node. The resulting
* mark node is then placed in an array and used as the child of a mark node for
* the next mark type in `markTypes`. This continues for each member of
* `markTypes`. If `markTypes` is empty, the original text node is returned.
*/
function wrapTextWithMarks(textNode, markTypes) {
const wrapTextWithMark = (childNode, markType) => u(markType, [childNode]);
return markTypes.reduce(wrapTextWithMark, textNode);
}
/**
* Converts a Slate Raw text node to an MDAST text node.
*
* Slate text nodes without marks often simply have a "text" property with
* the value. In this case the conversion to MDAST is simple. If a Slate
* text node does not have a "text" property, it will instead have a
* "leaves" property containing an array of objects, each with an array of
* marks, such as "bold" or "italic", along with a "text" property.
*
* MDAST instead expresses such marks in a nested structure, with individual
* nodes for each mark type nested until the deepest mark node, which will
* contain the text node.
*
* To convert a Slate text node's marks to MDAST, we treat each "leaf" as a
* separate text node, convert the text node itself to an MDAST text node,
* and then recursively wrap the text node for each mark, collecting the results
* of each leaf in a single array of child nodes.
*
* For example, this Slate text node:
*
* {
* kind: 'text',
* leaves: [
* {
* text: 'test',
* marks: ['bold', 'italic']
* },
* {
* text: 'test two'
* }
* ]
* }
*
* ...would be converted to this MDAST nested structure:
*
* [
* {
* type: 'strong',
* children: [{
* type: 'emphasis',
* children: [{
* type: 'text',
* value: 'test'
* }]
* }]
* },
* {
* type: 'text',
* value: 'test two'
* }
* ]
*
* This example also demonstrates how a single Slate node may need to be
* replaced with multiple MDAST nodes, so the resulting array must be flattened.
*/
function convertTextNode(node) {
/**
* If the Slate text node has a "leaves" property, translate the Slate AST to
* a nested MDAST structure. Otherwise, just return an equivalent MDAST text
* node.
*/
if (node.leaves) {
const processedLeaves = node.leaves.map(processLeaves);
const condensedNodes = processedLeaves.reduce(condenseNodesReducer, { nodes: [] });
return condensedNodes.nodes;
}
if (node.kind === 'inline') {
return transform(node);
}
return u('html', node.text);
}
/**
* Process Slate node leaves in preparation for MDAST transformation.
*/
function processLeaves(leaf) {
/**
* Get an array of the mark types, converted to their MDAST equivalent
* types.
*/
const { marks = [], text } = leaf;
const markTypes = marks.map(mark => markMap[mark.type]);
if (typeof leaf.text === 'string') {
/**
* Code marks must be removed from the marks array, and the presence of a
* code mark changes the text node type that should be used.
*/
const { filteredMarkTypes, textNodeType } = processCodeMark(markTypes);
return { text, marks: filteredMarkTypes, textNodeType };
}
return { node: leaf.node, marks: markTypes };
}
/**
* Slate's AST doesn't group adjacent text nodes with the same marks - a
* change in marks from letter to letter, even if some are in common, results
* in a separate leaf. For example, given "**a_b_**", transformation to and
* from Slate's AST will result in "**a****_b_**".
*
* MDAST treats styling entities as distinct nodes that contain children, so a
* "strong" node can contain a plain text node with a sibling "emphasis" node,
* which contains more text. This reducer serves to create an optimized nested
* MDAST without the typical redundancies that Slate's AST would produce if
* transformed as-is. The reducer can be called recursively to produce nested
* structures.
*/
function condenseNodesReducer(acc, node, idx, nodes) {
/**
* Skip any nodes that are being processed as children of an MDAST node
* through recursive calls.
*/
if (typeof acc.nextIndex === 'number' && acc.nextIndex > idx) {
return acc;
}
/**
* Processing for nodes with marks.
*/
if (node.marks && node.marks.length > 0) {
/**
* For each mark on the current node, get the number of consecutive nodes
* (starting with this one) that have the mark. Whichever mark covers the
* most nodes is used as the parent node, and the nodes with that mark are
* processed as children. If the greatest number of consecutive nodes is
* tied between multiple marks, there is no priority as to which goes
* first.
*/
const markLengths = node.marks.map(mark => getMarkLength(mark, nodes.slice(idx)));
const parentMarkLength = last(sortBy(markLengths, 'length'));
const { markType: parentType, length: parentLength } = parentMarkLength;
/**
* Since this and any consecutive nodes with the parent mark are going to
* be processed as children of the parent mark, this reducer should simply
* return the accumulator until after the last node to be covered by the
* new parent node. Here we set the next index that should be processed,
* if any.
*/
const newNextIndex = idx + parentLength;
/**
* Get the set of nodes that should be processed as children of the new
* parent mark node, run each through the reducer as children of the
* parent node, and create the parent MDAST node with the resulting
* children.
*/
const children = nodes.slice(idx, newNextIndex);
const denestedChildren = children.map(child => ({ ...child, marks: without(child.marks, parentType) }));
const mdastChildren = denestedChildren.reduce(condenseNodesReducer, { nodes: [], parentType }).nodes;
const mdastNode = u(parentType, mdastChildren);
return { ...acc, nodes: [ ...acc.nodes, mdastNode ], nextIndex: newNextIndex };
}
/**
* Create the base text node, and pass in the array of mark types as data
* (helpful when optimizing/condensing the final structure).
*/
const baseNode = typeof node.text === 'string'
? u(node.textNodeType, { marks: node.marks }, node.text)
: transform(node.node);
/**
* Recursively wrap the base text node in the individual mark nodes, if
* any exist.
*/
return { ...acc, nodes: [ ...acc.nodes, baseNode ] };
}
/**
* Get the number of consecutive Slate nodes containing a given mark beginning
* from the first received node.
*/
function getMarkLength(markType, nodes) {
let length = 0;
while(nodes[length] && nodes[length].marks.includes(markType)) { ++length; }
return { markType, length };
}
/**
* Convert a single Slate Raw node to an MDAST node. Uses the unist-builder `u`
* function to create MDAST nodes and parses shortcodes.
*/
function convertNode(node, children, shortcodePlugins) {
switch (node.type) {
/**
* General
*
* Convert simple cases that only require a type and children, with no
* additional properties.
*/
case 'root':
case 'paragraph':
case 'quote':
case 'list-item':
case 'table':
case 'table-row':
case 'table-cell': {
return u(typeMap[node.type], children);
}
/**
* Shortcodes
*
* Shortcode nodes only exist in Slate's Raw AST if they were inserted
* via the plugin toolbar in memory, so they should always have
* shortcode data attached. The "shortcode" data property contains the
* name of the registered shortcode plugin, and the "shortcodeData" data
* property contains the data received from the shortcode plugin's
* `fromBlock` method when the shortcode node was created.
*
* Here we get the shortcode plugin from the registry and use it's
* `toBlock` method to recreate the original markdown shortcode. We then
* insert that text into a new "html" type node (a "text" type node
* might get encoded or escaped by remark-stringify). Finally, we wrap
* the "html" node in a "paragraph" type node, as shortcode nodes must
* be alone in their own paragraph.
*/
case 'shortcode': {
const { data } = node;
const plugin = shortcodePlugins.get(data.shortcode);
const text = plugin.toBlock(data.shortcodeData);
const textNode = u('html', text);
return u('paragraph', { data }, [ textNode ]);
}
/**
* Headings
*
* Slate schemas don't usually infer basic type info from data, so each
* level of heading is a separately named type. The MDAST schema just
* has a single "heading" type with the depth stored in a "depth"
* property on the node. Here we derive the depth from the Slate node
* type - e.g., for "heading-two", we need a depth value of "2".
*/
case 'heading-one':
case 'heading-two':
case 'heading-three':
case 'heading-four':
case 'heading-five':
case 'heading-six': {
const depthMap = { one: 1, two: 2, three: 3, four: 4, five: 5, six: 6 };
const depthText = node.type.split('-')[1];
const depth = depthMap[depthText];
return u(typeMap[node.type], { depth }, children);
}
/**
* Code Blocks
*
* Code block nodes have a single text child, and may have a code language
* stored in the "lang" data property. Here we transfer both the node
* value and the "lang" data property to the new MDAST node.
*/
case 'code': {
const value = flatMap(node.nodes, child => {
return flatMap(child.leaves, 'text');
}).join('');
const { lang, ...data } = get(node, 'data', {});
return u(typeMap[node.type], { lang, data }, value);
}
/**
* Lists
*
* Our Slate schema has separate node types for ordered and unordered
* lists, but the MDAST spec uses a single type with a boolean "ordered"
* property to indicate whether the list is numbered. The MDAST spec also
* allows for a "start" property to indicate the first number used for an
* ordered list. Here we translate both values to our Slate schema.
*/
case 'numbered-list':
case 'bulleted-list': {
const ordered = node.type === 'numbered-list';
const props = { ordered, start: get(node.data, 'start') || 1 };
return u(typeMap[node.type], props, children);
}
/**
* Breaks
*
* Breaks don't have children. We parse them separately for clarity.
*/
case 'break':
case 'thematic-break': {
return u(typeMap[node.type]);
}
/**
* Links
*
* The url and title attributes of link nodes are stored in properties on
* the node for both Slate and Remark schemas.
*/
case 'link': {
const { url, title, ...data } = get(node, 'data', {});
return u(typeMap[node.type], { url, title, data }, children);
}
/**
* Images
*
* This transformation is almost identical to that of links, except for the
* lack of child nodes and addition of `alt` attribute data. Currently the
* CMS handles block images by shortcode, so this case will only apply to
* inline images, which currently can only occur through raw markdown
* insertion.
*/
case 'image': {
const { url, title, alt, ...data } = get(node, 'data', {});
return u(typeMap[node.type], { url, title, alt, data });
}
/**
* No default case is supplied because an unhandled case should never
* occur. In the event that it does, let the error throw (for now).
*/
}
}

View File

@ -7,14 +7,11 @@ import TextControl from './Text/TextControl';
import TextPreview from './Text/TextPreview';
import SelectControl from './Select/SelectControl';
import SelectPreview from './Select/SelectPreview';
import MarkdownControl from './Markdown/MarkdownControl';
import MarkdownPreview from './Markdown/MarkdownPreview';
import RelationControl from './Relation/RelationControl';
import RelationPreview from './Relation/RelationPreview';
registerWidget('text', TextControl, TextPreview);
registerWidget('number', NumberControl, NumberPreview);
registerWidget('markdown', MarkdownControl, MarkdownPreview);
registerWidget('select', SelectControl, SelectPreview);
registerWidget('relation', RelationControl, RelationPreview);
registerWidget('unknown', UnknownControl, UnknownPreview);

View File

@ -1,145 +0,0 @@
import { last } from 'lodash';
/**
* Joins an array of regular expressions into a single expression, without
* altering the received expressions.
*/
export function joinPatternSegments(patterns) {
return patterns.map(p => p.source).join('');
}
/**
* Combines an array of regular expressions into a single expression, wrapping
* each in a non-capturing group and interposing alternation characters (|) so
* that each expression is executed separately.
*/
export function combinePatterns(patterns, flags = '') {
return patterns.map(p => `(?:${p.source})`).join('|');
}
/**
* Modify substrings within a string if they match a (global) pattern. Can be
* inverted to only modify non-matches.
*
* params:
* matchPattern - regexp - a regular expression to check for matches
* replaceFn - function - a replacement function that receives a matched
* substring and returns a replacement substring
* text - string - the string to process
* invertMatchPattern - boolean - if true, non-matching substrings are modified
* instead of matching substrings
*/
export function replaceWhen(matchPattern, replaceFn, text, invertMatchPattern) {
/**
* Splits the string into an array of objects with the following shape:
*
* {
* index: number - the index of the substring within the string
* text: string - the substring
* match: boolean - true if the substring matched `matchPattern`
* }
*
* Loops through matches via recursion (`RegExp.exec` tracks the loop
* internally).
*/
function split(exp, text, acc) {
/**
* Get the next match starting from the end of the last match or start of
* string.
*/
const match = exp.exec(text);
const lastEntry = last(acc);
/**
* `match` will be null if there are no matches.
*/
if (!match) return acc;
/**
* If the match is at the beginning of the input string, normalize to a data
* object with the `match` flag set to `true`, and add to the accumulator.
*/
if (match.index === 0) {
addSubstring(acc, 0, match[0], true);
}
/**
* If there are no entries in the accumulator, convert the substring before
* the match to a data object (without the `match` flag set to true) and
* push to the accumulator, followed by a data object for the matching
* substring.
*/
else if (!lastEntry) {
addSubstring(acc, 0, match.input.slice(0, match.index));
addSubstring(acc, match.index, match[0], true);
}
/**
* If the last entry in the accumulator immediately preceded the current
* matched substring in the original string, just add the data object for
* the matching substring to the accumulator.
*/
else if (match.index === lastEntry.index + lastEntry.text.length) {
addSubstring(acc, match.index, match[0], true);
}
/**
* Convert the substring before the match to a data object (without the
* `match` flag set to true), followed by a data object for the matching
* substring.
*/
else {
const nextIndex = lastEntry.index + lastEntry.text.length;
const nextText = match.input.slice(nextIndex, match.index);
addSubstring(acc, nextIndex, nextText);
addSubstring(acc, match.index, match[0], true);
}
/**
* Continue executing the expression.
*/
return split(exp, text, acc);
}
/**
* Factory for converting substrings to data objects and adding to an output
* array.
*/
function addSubstring(arr, index, text, match = false) {
arr.push({ index, text, match });
}
/**
* Split the input string to an array of data objects, each representing a
* matching or non-matching string.
*/
const acc = split(matchPattern, text, []);
/**
* Process the trailing substring after the final match, if one exists.
*/
const lastEntry = last(acc);
if (!lastEntry) return replaceFn(text);
const nextIndex = lastEntry.index + lastEntry.text.length;
if (text.length > nextIndex) {
acc.push({ index: nextIndex, text: text.slice(nextIndex) });
}
/**
* Map the data objects in the accumulator to their string values, modifying
* matched strings with the replacement function. Modifies non-matches if
* `invertMatchPattern` is truthy.
*/
const replacedText = acc.map(entry => {
const isMatch = invertMatchPattern ? !entry.match : entry.match;
return isMatch ? replaceFn(entry.text) : entry.text;
});
/**
* Return the joined string.
*/
return replacedText.join('');
}

View File

@ -1,5 +1,5 @@
import { Map } from 'immutable';
import { newEditorPlugin } from 'EditorWidgets/Markdown/MarkdownControl/plugins';
import EditorComponent from 'ValueObjects/EditorComponent'
/**
* Global Registry Object
@ -76,7 +76,7 @@ export function resolveWidget(name) {
* Markdown Editor Custom Components
*/
export function registerEditorComponent(component) {
const plugin = newEditorPlugin(component);
const plugin = EditorComponent(component);
registry.editorComponents = registry.editorComponents.set(plugin.get('id'), plugin);
};
export function getEditorComponents() {

View File

@ -36,7 +36,7 @@ class Plugin extends Component { // eslint-disable-line
}
}
export function newEditorPlugin(config) {
export default function createEditorComponent(config) {
const configObj = new EditorComponent({
id: config.id || config.label.replace(/[^A-Z0-9]+/ig, '_'),
label: config.label,