This commit is contained in:
Arjan Adriaanse 2021-01-24 16:52:56 +01:00
commit d4176bfada
15 changed files with 777 additions and 0 deletions

3
.babelrc Normal file
View File

@ -0,0 +1,3 @@
{
"presets" : [ "@babel/env" ]
}

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
node_modules/
yarn.lock
*.log
dist/
.env

18
package.json Normal file
View File

@ -0,0 +1,18 @@
{
"name": "mobiledoc-wp-renderer",
"version": "0.0.1",
"description": "Renders Mobiledoc input to WordPress block output",
"keywords": [
"mobiledoc",
"mobiledoc-renderer"
],
"main": "dist",
"scripts": {
"build": "babel src -d dist"
},
"devDependencies": {
"@babel/cli": "^7.12.10",
"@babel/core": "^7.12.10",
"@babel/preset-env": "^7.12.11"
}
}

10
src/index.js Normal file
View File

@ -0,0 +1,10 @@
import RendererFactory from './renderer-factory';
import RENDER_TYPE from './utils/render-type';
export { RENDER_TYPE };
export function registerGlobal(window) {
window.MobiledocWPRenderer = RendererFactory;
}
export default RendererFactory;

105
src/renderer-factory.js Normal file
View File

@ -0,0 +1,105 @@
import { registerStore } from './utils/wp-utils.js';
// import Renderer_0_2, {
// MOBILEDOC_VERSION as MOBILEDOC_VERSION_0_2
// } from './renderers/0-2';
import Renderer_0_3, {
MOBILEDOC_VERSION_0_3_0,
MOBILEDOC_VERSION_0_3_1,
MOBILEDOC_VERSION_0_3_2
} from './renderers/0-3';
import RENDER_TYPE from './utils/render-type';
/**
* runtime WordPress renderer
* renders a mobiledoc to WordPress blocks
*
* input: mobiledoc
* output: text
*/
function validateCards(cards) {
if (!Array.isArray(cards)) {
throw new Error('`cards` must be passed as an array');
}
for (let i=0; i < cards.length; i++) {
let card = cards[i];
if (card.type !== RENDER_TYPE) {
throw new Error(`Card "${card.name}" must be of type "${RENDER_TYPE}", was "${card.type}"`);
}
if (!card.render) {
throw new Error(`Card "${card.name}" must define \`render\``);
}
}
}
function validateAtoms(atoms) {
if (!Array.isArray(atoms)) {
throw new Error('`atoms` must be passed as an array');
}
for (let i=0; i < atoms.length; i++) {
let atom = atoms[i];
if (atom.type !== RENDER_TYPE) {
throw new Error(`Atom "${atom.name}" must be type "${RENDER_TYPE}", was "${atom.type}"`);
}
if (!atom.render) {
throw new Error(`Atom "${atom.name}" must define \`render\``);
}
}
}
export default class RendererFactory {
constructor({
cards=[],
atoms=[],
cardOptions={},
unknownCardHandler,
unknownAtomHandler,
markupElementRenderer={},
sectionElementRenderer={},
dom,
serializer,
createBlock,
markupSanitizer=null
}={}) {
validateCards(cards);
validateAtoms(atoms);
if (!dom) {
if (typeof window === 'undefined') {
throw new Error('A `dom` option must be provided to the renderer when running without window.document');
}
dom = window.document;
}
this.options = {
cards,
atoms,
cardOptions,
unknownCardHandler,
unknownAtomHandler,
markupElementRenderer,
sectionElementRenderer,
dom,
serializer,
createBlock,
markupSanitizer
};
}
render(mobiledoc) {
let { version } = mobiledoc;
switch (version) {
// case MOBILEDOC_VERSION_0_2:
// case undefined:
// case null:
// return new Renderer_0_2(mobiledoc, this.options).render();
case MOBILEDOC_VERSION_0_3_0:
case MOBILEDOC_VERSION_0_3_1:
case MOBILEDOC_VERSION_0_3_2:
return new Renderer_0_3(mobiledoc, this.options).render();
default:
throw new Error(`Unexpected Mobiledoc version "${version}"`);
}
}
}

395
src/renderers/0-3.js Normal file
View File

@ -0,0 +1,395 @@
import { createTextNode } from '../utils/dom';
import RENDER_TYPE from '../utils/render-type';
import {
MARKUP_SECTION_TYPE,
IMAGE_SECTION_TYPE,
LIST_SECTION_TYPE,
CARD_SECTION_TYPE
} from '../utils/section-types';
import {
isValidSectionTagName,
isValidMarkerType
} from '../utils/tag-names';
import {
reduceAttributes
} from '../utils/sanitization-utils';
import {
defaultSectionElementRenderer,
defaultMarkupElementRenderer
} from '../utils/render-utils';
import {
MARKUP_MARKER_TYPE,
ATOM_MARKER_TYPE
} from '../utils/marker-types';
import {
createImage,
createList,
blockRenderer
} from '../utils/wp-utils';
export const MOBILEDOC_VERSION_0_3_0 = '0.3.0';
export const MOBILEDOC_VERSION_0_3_1 = '0.3.1';
export const MOBILEDOC_VERSION_0_3_2 = '0.3.2';
function validateVersion(version) {
switch (version) {
case MOBILEDOC_VERSION_0_3_0:
case MOBILEDOC_VERSION_0_3_1:
case MOBILEDOC_VERSION_0_3_2:
return;
default:
throw new Error(`Unexpected Mobiledoc version "${version}"`);
}
}
export default class Renderer {
constructor(mobiledoc, state) {
let {
cards,
cardOptions,
atoms,
unknownCardHandler,
unknownAtomHandler,
markupElementRenderer,
sectionElementRenderer,
dom,
serializer,
createBlock
} = state;
let {
version,
sections,
atoms: atomTypes,
cards: cardTypes,
markups: markerTypes
} = mobiledoc;
validateVersion(version);
this.dom = dom;
this.serializer = serializer;
this.createBlock = createBlock;
this.root = [];
this.sections = sections;
this.atomTypes = atomTypes;
this.cardTypes = cardTypes;
this.markerTypes = markerTypes;
this.cards = cards;
this.atoms = atoms;
this.cardOptions = cardOptions;
this.unknownCardHandler = unknownCardHandler || this._defaultUnknownCardHandler;
this.unknownAtomHandler = unknownAtomHandler || this._defaultUnknownAtomHandler;
this.sectionElementRenderer = {
'__default__': defaultSectionElementRenderer
};
Object.keys(sectionElementRenderer).forEach(key => {
this.sectionElementRenderer[key.toLowerCase()] = sectionElementRenderer[key];
});
this.markupElementRenderer = {
'__default__': defaultMarkupElementRenderer
};
Object.keys(markupElementRenderer).forEach(key => {
this.markupElementRenderer[key.toLowerCase()] = markupElementRenderer[key];
});
this._renderCallbacks = [];
this._teardownCallbacks = [];
}
get _defaultUnknownCardHandler() {
return ({env: {name}}) => {
throw new Error(`Card "${name}" not found but no unknownCardHandler was registered`);
};
}
get _defaultUnknownAtomHandler() {
return ({env: {name}}) => {
throw new Error(`Atom "${name}" not found but no unknownAtomHandler was registered`);
};
}
render() {
this.sections.forEach(section => {
let rendered = this.renderSection(section);
if (rendered) {
this.root.push(rendered);
}
});
for (let i=0; i < this._renderCallbacks.length; i++) {
this._renderCallbacks[i]();
}
return { result: this.root, teardown: () => this.teardown() };
}
teardown() {
for (let i=0; i < this._teardownCallbacks.length; i++) {
this._teardownCallbacks[i]();
}
}
renderSection(section) {
const [type] = section;
switch (type) {
case MARKUP_SECTION_TYPE:
return this.renderMarkupSection(section);
case IMAGE_SECTION_TYPE:
return this.renderImageSection(section);
case LIST_SECTION_TYPE:
return this.renderListSection(section);
case CARD_SECTION_TYPE:
return this.renderCardSection(section);
default:
throw new Error(`Cannot render mobiledoc section of type "${type}"`);
}
}
renderMarkersOnElement(element, markers) {
let elements = [element];
let currentElement = element;
let pushElement = (openedElement) => {
currentElement.appendChild(openedElement);
elements.push(openedElement);
currentElement = openedElement;
};
for (let i=0, l=markers.length; i<l; i++) {
let marker = markers[i];
let [type, openTypes, closeCount, value] = marker;
for (let j=0, m=openTypes.length; j<m; j++) {
let markerType = this.markerTypes[openTypes[j]];
let [tagName, attrs=[]] = markerType;
if (isValidMarkerType(tagName)) {
pushElement(this.renderMarkupElement(tagName, attrs));
} else {
closeCount--;
}
}
switch (type) {
case MARKUP_MARKER_TYPE:
currentElement.appendChild(createTextNode(this.dom, value));
break;
case ATOM_MARKER_TYPE:
currentElement.appendChild(this._renderAtom(value));
break;
default:
throw new Error(`Unknown markup type (${type})`);
}
for (let j=0, m=closeCount; j<m; j++) {
elements.pop();
currentElement = elements[elements.length - 1];
}
}
}
/**
* @param attrs Array
*/
renderMarkupElement(tagName, attrs) {
tagName = tagName.toLowerCase();
attrs = reduceAttributes(attrs);
let renderer = this.markupElementRendererFor(tagName);
return renderer(tagName, this.dom, attrs);
}
markupElementRendererFor(tagName) {
return this.markupElementRenderer[tagName] ||
this.markupElementRenderer.__default__;
}
renderListItem(markers) {
const element = this.dom.createElement('li');
this.renderMarkersOnElement(element, markers);
return element;
}
renderListSection([type, tagName, listItems]) {
if (!isValidSectionTagName(tagName, LIST_SECTION_TYPE)) {
return;
}
const element = this.dom.createElement(tagName);
listItems.forEach(li => {
element.appendChild(this.renderListItem(li));
});
const items = this.serializer.serializeChildren(element);
return this.createBlock(... createList(tagName === 'ol', items));
}
renderImageSection([type, src]) {
return this.createBlock(... createImage(src));
}
findCard(name) {
for (let i=0; i < this.cards.length; i++) {
if (this.cards[i].name === name) {
return this.cards[i];
}
}
return this._createUnknownCard(name);
}
_findCardByIndex(index) {
let cardType = this.cardTypes[index];
if (!cardType) {
throw new Error(`No card definition found at index ${index}`);
}
let [ name, payload ] = cardType;
let card = this.findCard(name);
return {
card,
payload
};
}
_createUnknownCard(name) {
return {
name,
type: RENDER_TYPE,
render: this.unknownCardHandler
};
}
_createCardArgument(card, payload={}) {
let env = {
name: card.name,
isInEditor: false,
dom: this.dom,
serializer: this.serializer,
didRender: (callback) => this._registerRenderCallback(callback),
onTeardown: (callback) => this._registerTeardownCallback(callback)
};
let options = this.cardOptions;
return { env, options, payload };
}
_registerTeardownCallback(callback) {
this._teardownCallbacks.push(callback);
}
_registerRenderCallback(callback) {
this._renderCallbacks.push(callback);
}
renderCardSection([type, index]) {
let { card, payload } = this._findCardByIndex(index);
let cardArg = this._createCardArgument(card, payload);
let rendered = card.render(cardArg);
this._validateCardRender(rendered, card.name);
return rendered;
}
_validateCardRender(rendered, cardName) {
if (!rendered) {
return;
}
if (typeof rendered !== 'object') {
throw new Error(`Card "${cardName}" must render ${RENDER_TYPE}, but result was "${rendered}"`);
}
}
findAtom(name) {
for (let i=0; i < this.atoms.length; i++) {
if (this.atoms[i].name === name) {
return this.atoms[i];
}
}
return this._createUnknownAtom(name);
}
_createUnknownAtom(name) {
return {
name,
type: RENDER_TYPE,
render: this.unknownAtomHandler
};
}
_createAtomArgument(atom, value, payload) {
let env = {
name: atom.name,
isInEditor: false,
dom: this.dom,
onTeardown: (callback) => this._registerTeardownCallback(callback)
};
let options = this.cardOptions;
return { env, options, value, payload };
}
_validateAtomRender(rendered, atomName) {
if (!rendered) {
return;
}
if (typeof rendered !== 'object') {
throw new Error(`Atom "${atomName}" must render ${RENDER_TYPE}, but result was "${rendered}"`);
}
}
_findAtomByIndex(index) {
let atomType = this.atomTypes[index];
if (!atomType) {
throw new Error(`No atom definition found at index ${index}`);
}
let [ name, value, payload ] = atomType;
let atom = this.findAtom(name);
return {
atom,
value,
payload
};
}
_renderAtom(index) {
let { atom, value, payload } = this._findAtomByIndex(index);
let atomArg = this._createAtomArgument(atom, value, payload);
let rendered = atom.render(atomArg);
this._validateAtomRender(rendered, atom.name);
return rendered || createTextNode(this.dom, '');
}
renderMarkupSection([type, tagName, markers, attributes = []]) {
tagName = tagName.toLowerCase();
if (!isValidSectionTagName(tagName, MARKUP_SECTION_TYPE)) {
return;
}
let attrsObj = reduceAttributes(attributes);
let renderer = this.sectionElementRendererFor(tagName);
let element = renderer(tagName, this.dom, attrsObj);
this.renderMarkersOnElement(element, markers);
var html = this.serializer.serializeChildren(element);
return this.createBlock(... blockRenderer[tagName](html));
}
sectionElementRendererFor(tagName) {
return this.sectionElementRenderer[tagName] ||
this.sectionElementRenderer.__default__;
}
}

41
src/utils/array-utils.js Normal file
View File

@ -0,0 +1,41 @@
export function includes(array, detectValue) {
for (let i=0;i < array.length;i++) {
let value = array[i];
if (value === detectValue) {
return true;
}
}
return false;
}
/**
* @param {Array} array of key1,value1,key2,value2,...
* @return {Object} {key1:value1, key2:value2, ...}
* @private
*/
export function kvArrayToObject(array) {
if (!Array.isArray(array)) { return {}; }
const obj = {};
for (let i = 0; i < array.length; i+=2) {
let [key, value] = [array[i], array[i+1]];
obj[key] = value;
}
return obj;
}
/**
* @param {Object} {key1:value1, key2:value2, ...}
* @return {Array} array of key1,value1,key2,value2,...
* @private
*/
export function objectToSortedKVArray(obj) {
const keys = Object.keys(obj).sort();
const result = [];
keys.forEach(k => {
result.push(k);
result.push(obj[k]);
});
return result;
}

12
src/utils/dom.js Normal file
View File

@ -0,0 +1,12 @@
function addHTMLSpaces(text) {
let nbsp = '\u00A0';
return text.replace(/ /g, ' ' + nbsp);
}
export function createTextNode(dom, text) {
return dom.createTextNode(addHTMLSpaces(text));
}
export function normalizeTagName(tagName) {
return tagName.toLowerCase();
}

View File

@ -0,0 +1,2 @@
export const MARKUP_MARKER_TYPE = 0;
export const ATOM_MARKER_TYPE = 1;

1
src/utils/render-type.js Normal file
View File

@ -0,0 +1 @@
export default 'wp';

57
src/utils/render-utils.js Normal file
View File

@ -0,0 +1,57 @@
import {
isMarkupSectionElementName
} from '../utils/tag-names';
import {
sanitizeHref
} from './sanitization-utils';
export const VALID_ATTRIBUTES = [
'data-md-text-align'
];
function _isValidAttribute(attr) {
return VALID_ATTRIBUTES.indexOf(attr) !== -1;
}
function handleMarkupSectionAttribute(element, attributeKey, attributeValue) {
if (!_isValidAttribute(attributeKey)) {
throw new Error(`Cannot use attribute: ${attributeKey}`);
}
element.setAttribute(attributeKey, attributeValue);
}
export function defaultSectionElementRenderer(tagName, dom, attrsObj = {}) {
let element;
if (isMarkupSectionElementName(tagName)) {
element = dom.createElement(tagName);
Object.keys(attrsObj).forEach(k => {
handleMarkupSectionAttribute(element, k, attrsObj[k]);
});
} else {
element = dom.createElement('div');
element.setAttribute('class', tagName);
}
return element;
}
function sanitizeAttribute(tagName, attrName, attrValue) {
if (tagName === 'a' && attrName === 'href') {
return sanitizeHref(attrValue);
} else {
return attrValue;
}
}
export function defaultMarkupElementRenderer(tagName, dom, attrsObj) {
let element = dom.createElement(tagName);
Object.keys(attrsObj).forEach(attrName => {
let attrValue = attrsObj[attrName];
attrValue = sanitizeAttribute(tagName, attrName, attrValue);
element.setAttribute(attrName, attrValue);
});
return element;
}

View File

@ -0,0 +1,36 @@
import { includes } from './array-utils';
const PROTOCOL_REGEXP = /^([a-z0-9.+-]+:)/i;
const badProtocols = [
'javascript:', // jshint ignore:line
'vbscript:' // jshint ignore:line
];
function getProtocol(url) {
let matches = url && url.match(PROTOCOL_REGEXP);
let protocol = (matches && matches[0]) || ':';
return protocol;
}
export function sanitizeHref(url) {
let protocol = getProtocol(url).toLowerCase();
if (includes(badProtocols, protocol)) {
return `unsafe:${url}`;
}
return url;
}
/**
* @param attributes array
* @return obj with normalized attribute names (lowercased)
*/
export function reduceAttributes(attributes) {
let obj = {};
for (let i = 0; i < attributes.length; i += 2) {
let key = attributes[i];
let val = attributes[i+1];
obj[key.toLowerCase()] = val;
}
return obj;
}

View File

@ -0,0 +1,4 @@
export const MARKUP_SECTION_TYPE = 1;
export const IMAGE_SECTION_TYPE = 2;
export const LIST_SECTION_TYPE = 3;
export const CARD_SECTION_TYPE = 10;

48
src/utils/tag-names.js Normal file
View File

@ -0,0 +1,48 @@
import {
MARKUP_SECTION_TYPE,
LIST_SECTION_TYPE
} from './section-types';
import { normalizeTagName } from './dom';
const MARKUP_SECTION_TAG_NAMES = [
'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'pull-quote', 'aside'
].map(normalizeTagName);
const MARKUP_SECTION_ELEMENT_NAMES = [
'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'aside'
].map(normalizeTagName);
const LIST_SECTION_TAG_NAMES = [
'ul', 'ol'
].map(normalizeTagName);
const MARKUP_TYPES = [
'b', 'i', 'strong', 'em', 'a', 'u', 'sub', 'sup', 's', 'code'
].map(normalizeTagName);
function contains(array, item) {
return array.indexOf(item) !== -1;
}
export function isValidSectionTagName(tagName, sectionType) {
tagName = normalizeTagName(tagName);
switch (sectionType) {
case MARKUP_SECTION_TYPE:
return contains(MARKUP_SECTION_TAG_NAMES, tagName);
case LIST_SECTION_TYPE:
return contains(LIST_SECTION_TAG_NAMES, tagName);
default:
throw new Error(`Cannot validate tagName for unknown section type "${sectionType}"`);
}
}
export function isMarkupSectionElementName(tagName) {
tagName = normalizeTagName(tagName);
return contains(MARKUP_SECTION_ELEMENT_NAMES, tagName);
}
export function isValidMarkerType(type) {
type = normalizeTagName(type);
return contains(MARKUP_TYPES, type);
}

40
src/utils/wp-utils.js Normal file
View File

@ -0,0 +1,40 @@
export const createImage = url =>
["core/image", {
url: url
}]
export const createList = (ordered, values) =>
["core/list", {
ordered: ordered,
values: values
}]
export const createQuote = content =>
["core/quote", {
content: `<p>${content}</p>`
}]
export const createHeading = level => content =>
["core/heading", {
content: content,
level: level
}]
export const createParagraph = content =>
["core/paragraph", {
content: content
}]
export const blockRenderer = {
aside: () => { throw new Error(`not implemented`) },
blockquote: createQuote,
h1: createHeading(1),
h2: createHeading(2),
h3: createHeading(3),
h4: createHeading(4),
h5: createHeading(5),
h6: createHeading(6),
p: createParagraph
}