migrate markdown widget
This commit is contained in:
@ -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"
|
||||
|
@ -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);
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
}
|
@ -1,4 +0,0 @@
|
||||
@import "./MarkdownControl/RawEditor/index.css";
|
||||
@import "./MarkdownControl/Toolbar/Toolbar.css";
|
||||
@import "./MarkdownControl/Toolbar/ToolbarButton.css";
|
||||
@import "./MarkdownControl/VisualEditor/index.css";
|
@ -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));
|
||||
}
|
@ -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
|
||||
};
|
@ -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;
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
@ -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;
|
@ -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);
|
||||
}
|
@ -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);
|
File diff suppressed because it is too large
Load Diff
@ -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 => ``,
|
||||
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 = `
|
||||

|
||||
`;
|
||||
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 >}}
|
||||
`;
|
||||
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();
|
||||
});
|
||||
});
|
@ -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;
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
};
|
@ -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;
|
@ -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}/>;
|
||||
}
|
||||
};
|
@ -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)
|
||||
))
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
@ -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;
|
||||
}
|
||||
}
|
@ -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> & <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></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>"
|
||||
`;
|
@ -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
|
||||
|
||||

|
||||
|
||||
###### 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();
|
||||
});
|
||||
});
|
||||
});
|
@ -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;
|
@ -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('<div>')).toEqual('<div>');
|
||||
});
|
||||
});
|
@ -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);
|
||||
});
|
||||
});
|
@ -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('')).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]');
|
||||
});
|
||||
});
|
@ -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) '));
|
||||
});
|
||||
});
|
@ -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')]);
|
||||
});
|
||||
});
|
@ -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><div></code>')).toEqual('<code><div></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('****')).toEqual('****');
|
||||
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 ')).toEqual('a ');
|
||||
});
|
||||
|
||||
it('should not escape markdown entities in html', () => {
|
||||
expect(process('<span>*</span>')).toEqual('<span>*</span>');
|
||||
});
|
||||
});
|
@ -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;
|
||||
};
|
@ -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;
|
||||
}
|
@ -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,
|
||||
});
|
||||
}
|
||||
};
|
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
@ -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 = ``;
|
||||
child.children = [{ type: 'text', value }];
|
||||
}
|
||||
return child;
|
||||
});
|
||||
return { ...node, children };
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
@ -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 };
|
||||
}
|
||||
}
|
@ -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]);
|
||||
}
|
||||
}
|
@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
@ -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:
|
||||
*
|
||||
* ```
|
||||
* 
|
||||
* ```
|
||||
*
|
||||
*/
|
||||
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) };
|
||||
}
|
||||
}
|
@ -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;
|
||||
};
|
@ -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;
|
||||
}
|
@ -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).
|
||||
*/
|
||||
}
|
||||
}
|
@ -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);
|
||||
|
@ -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('');
|
||||
}
|
@ -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() {
|
||||
|
@ -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,
|
Reference in New Issue
Block a user