From a9c2d80e4e2725ff9d90fd33ff5f1c903d6e8332 Mon Sep 17 00:00:00 2001 From: Richard Livsey Date: Sun, 29 Nov 2015 22:35:17 +0000 Subject: [PATCH] Implement Mobiledoc renderer v0.3 with atom support --- src/js/renderers/mobiledoc/0-3.js | 187 +++++++++++++ src/js/renderers/mobiledoc/index.js | 9 +- tests/unit/renderers/mobiledoc/0-3-test.js | 309 +++++++++++++++++++++ 3 files changed, 502 insertions(+), 3 deletions(-) create mode 100644 src/js/renderers/mobiledoc/0-3.js create mode 100644 tests/unit/renderers/mobiledoc/0-3-test.js diff --git a/src/js/renderers/mobiledoc/0-3.js b/src/js/renderers/mobiledoc/0-3.js new file mode 100644 index 000000000..e5cba6b23 --- /dev/null +++ b/src/js/renderers/mobiledoc/0-3.js @@ -0,0 +1,187 @@ +import {visit, visitArray, compile} from '../../utils/compiler'; +import { objectToSortedKVArray } from '../../utils/array-utils'; +import { + POST_TYPE, + MARKUP_SECTION_TYPE, + LIST_SECTION_TYPE, + LIST_ITEM_TYPE, + MARKER_TYPE, + MARKUP_TYPE, + IMAGE_SECTION_TYPE, + CARD_TYPE, + ATOM_TYPE +} from '../../models/types'; + +export const MOBILEDOC_VERSION = '0.2.0'; +export const MOBILEDOC_MARKUP_SECTION_TYPE = 1; +export const MOBILEDOC_IMAGE_SECTION_TYPE = 2; +export const MOBILEDOC_LIST_SECTION_TYPE = 3; +export const MOBILEDOC_CARD_SECTION_TYPE = 10; + +export const MOBILEDOC_MARKUP_CONTENT_TYPE = 0; +export const MOBILEDOC_ATOM_CONTENT_TYPE = 1; + +const visitor = { + [POST_TYPE](node, opcodes) { + opcodes.push(['openPost']); + visitArray(visitor, node.sections, opcodes); + }, + [MARKUP_SECTION_TYPE](node, opcodes) { + opcodes.push(['openMarkupSection', node.tagName]); + visitArray(visitor, node.markers, opcodes); + }, + [LIST_SECTION_TYPE](node, opcodes) { + opcodes.push(['openListSection', node.tagName]); + visitArray(visitor, node.items, opcodes); + }, + [LIST_ITEM_TYPE](node, opcodes) { + opcodes.push(['openListItem']); + visitArray(visitor, node.markers, opcodes); + }, + [IMAGE_SECTION_TYPE](node, opcodes) { + opcodes.push(['openImageSection', node.src]); + }, + [CARD_TYPE](node, opcodes) { + opcodes.push(['openCardSection', node.name, node.payload]); + }, + [MARKER_TYPE](node, opcodes) { + opcodes.push(['openMarker', node.closedMarkups.length, node.value]); + visitArray(visitor, node.openedMarkups, opcodes); + }, + [MARKUP_TYPE](node, opcodes) { + opcodes.push(['openMarkup', node.tagName, objectToSortedKVArray(node.attributes)]); + }, + [ATOM_TYPE](node, opcodes) { + opcodes.push(['openAtom', node.closedMarkups.length, node.name, node.value, node.payload]); + } +}; + +// NOTE - naive implementation just for discussion +function objectToCacheKey(obj) { + if (!obj) { + return 'none'; + } + + let attributesArray = objectToSortedKVArray(obj); + return attributesArray.join('-'); +} + +const postOpcodeCompiler = { + openMarker(closeCount, value) { + this.markupMarkerIds = []; + this.markers.push([ + MOBILEDOC_MARKUP_CONTENT_TYPE, + this.markupMarkerIds, + closeCount, + value || '' + ]); + }, + openMarkupSection(tagName) { + this.markers = []; + this.sections.push([MOBILEDOC_MARKUP_SECTION_TYPE, tagName, this.markers]); + }, + openListSection(tagName) { + this.items = []; + this.sections.push([MOBILEDOC_LIST_SECTION_TYPE, tagName, this.items]); + }, + openListItem() { + this.markers = []; + this.items.push(this.markers); + }, + openImageSection(url) { + this.sections.push([MOBILEDOC_IMAGE_SECTION_TYPE, url]); + }, + openCardSection(name, payload) { + const index = this._findOrAddCardTypeIndex(name, payload); + this.sections.push([MOBILEDOC_CARD_SECTION_TYPE, index]); + }, + openAtom(closeCount, name, value, payload) { + const index = this._findOrAddAtomTypeIndex(name, value, payload); + this.markupMarkerIds = []; + this.markers.push([ + MOBILEDOC_ATOM_CONTENT_TYPE, + this.markupMarkerIds, + closeCount, + index + ]); + }, + openPost() { + this.atomTypes = []; + this.cardTypes = []; + this.markerTypes = []; + this.sections = []; + this.result = { + version: MOBILEDOC_VERSION, + atoms: this.atomTypes, + cards: this.cardTypes, + markups: this.markerTypes, + sections: this.sections + }; + }, + openMarkup(tagName, attributes) { + const index = this._findOrAddMarkerTypeIndex(tagName, attributes); + this.markupMarkerIds.push(index); + }, + _findOrAddCardTypeIndex(cardName, payload) { + if (!this._cardTypeCache) { this._cardTypeCache = {}; } + const key = `${cardName}-${objectToCacheKey(payload)}`; + + let index = this._cardTypeCache[key]; + if (index === undefined) { + let cardType = [cardName, payload]; + this.cardTypes.push(cardType); + + index = this.cardTypes.length - 1; + this._cardTypeCache[key] = index; + } + return index; + }, + _findOrAddAtomTypeIndex(atomName, atomValue, payload) { + if (!this._atomTypeCache) { this._atomTypeCache = {}; } + const key = `${atomName}-${atomValue}-${objectToCacheKey(payload)}`; + + let index = this._atomTypeCache[key]; + if (index === undefined) { + let atomType = [atomName, atomValue, payload]; + this.atomTypes.push(atomType); + + index = this.atomTypes.length - 1; + this._atomTypeCache[key] = index; + } + return index; + }, + _findOrAddMarkerTypeIndex(tagName, attributesArray) { + if (!this._markerTypeCache) { this._markerTypeCache = {}; } + const key = `${tagName}-${attributesArray.join('-')}`; + + let index = this._markerTypeCache[key]; + if (index === undefined) { + let markerType = [tagName]; + if (attributesArray.length) { markerType.push(attributesArray); } + this.markerTypes.push(markerType); + + index = this.markerTypes.length - 1; + this._markerTypeCache[key] = index; + } + + return index; + } +}; + +/** + * Render from post -> mobiledoc + */ +export default { + /** + * @method render + * @param {Post} + * @return {Mobiledoc} + */ + render(post) { + let opcodes = []; + visit(visitor, post, opcodes); + let compiler = Object.create(postOpcodeCompiler); + compile(compiler, opcodes); + return compiler.result; + } +}; diff --git a/src/js/renderers/mobiledoc/index.js b/src/js/renderers/mobiledoc/index.js index a3fb00c39..a4435f780 100644 --- a/src/js/renderers/mobiledoc/index.js +++ b/src/js/renderers/mobiledoc/index.js @@ -1,15 +1,18 @@ -import MobiledocRenderer_0_2, { MOBILEDOC_VERSION } from './0-2'; +import MobiledocRenderer_0_2, { MOBILEDOC_VERSION as MOBILEDOC_VERSION_0_2 } from './0-2'; +import MobiledocRenderer_0_3, { MOBILEDOC_VERSION as MOBILEDOC_VERSION_0_3 } from './0-3'; import assert from 'mobiledoc-kit/utils/assert'; -export { MOBILEDOC_VERSION }; +export const MOBILEDOC_VERSION = MOBILEDOC_VERSION_0_2; export default { render(post, version) { switch (version) { - case MOBILEDOC_VERSION: + case MOBILEDOC_VERSION_0_2: case undefined: case null: return MobiledocRenderer_0_2.render(post); + case MOBILEDOC_VERSION_0_3: + return MobiledocRenderer_0_3.render(post); default: assert(`Unknown version of mobiledoc renderer requested: ${version}`, false); } diff --git a/tests/unit/renderers/mobiledoc/0-3-test.js b/tests/unit/renderers/mobiledoc/0-3-test.js new file mode 100644 index 000000000..c573a9f41 --- /dev/null +++ b/tests/unit/renderers/mobiledoc/0-3-test.js @@ -0,0 +1,309 @@ +import MobiledocRenderer, { MOBILEDOC_VERSION } from 'mobiledoc-kit/renderers/mobiledoc/0-3'; +import PostNodeBuilder from 'mobiledoc-kit/models/post-node-builder'; +import { normalizeTagName } from 'mobiledoc-kit/utils/dom-utils'; +import Helpers from '../../../test-helpers'; + +const { module, test } = Helpers; +function render(post) { + return MobiledocRenderer.render(post); +} +let builder; + +module('Unit: Mobiledoc Renderer 0.3', { + beforeEach() { + builder = new PostNodeBuilder(); + } +}); + +test('renders a blank post', (assert) => { + let post = builder.createPost(); + let mobiledoc = render(post); + assert.deepEqual(mobiledoc, { + version: MOBILEDOC_VERSION, + atoms: [], + cards: [], + markups: [], + sections: [] + }); +}); + +test('renders a post with marker', (assert) => { + const post = Helpers.postAbstract.build(({post, markupSection, marker, markup}) => { + return post([ + markupSection('p', [marker('Hi', [markup('strong')])]) + ]); + }); + const mobiledoc = render(post); + assert.deepEqual(mobiledoc, { + version: MOBILEDOC_VERSION, + atoms: [], + cards: [], + markups: [['strong']], + sections: [ + [1, normalizeTagName('P'), [[0, [0], 1, 'Hi']]] + ] + }); +}); + +test('renders a post section with markers sharing a markup', (assert) => { + const post = Helpers.postAbstract.build(({post, markupSection, marker, markup}) => { + const strong = markup('strong'); + return post([ + markupSection('p', [marker('Hi', [strong]), marker(' Guy', [strong])]) + ]); + }); + let mobiledoc = render(post); + assert.deepEqual(mobiledoc, { + version: MOBILEDOC_VERSION, + atoms: [], + cards: [], + markups: [['strong']], + sections: [ + [1, normalizeTagName('P'), [ + [0, [0], 0, 'Hi'], + [0, [], 1, ' Guy'] + ]] + ] + }); +}); + +test('renders a post with markers with markers with complex attributes', (assert) => { + let link1,link2; + const post = Helpers.postAbstract.build(({post, markupSection, marker, markup}) => { + link1 = markup('a', {href:'bustle.com'}); + link2 = markup('a', {href:'other.com'}); + return post([ + markupSection('p', [ + marker('Hi', [link1]), + marker(' Guy', [link2]), + marker(' other guy', [link1]) + ]) + ]); + }); + let mobiledoc = render(post); + assert.deepEqual(mobiledoc, { + version: MOBILEDOC_VERSION, + atoms: [], + cards: [], + markups: [ + ['a', ['href', 'bustle.com']], + ['a', ['href', 'other.com']] + ], + sections: [ + [1, normalizeTagName('P'), [ + [0, [0], 1, 'Hi'], + [0, [1], 1, ' Guy'], + [0, [0], 1, ' other guy'] + ]] + ] + }); + +}); + + +test('renders a post with image', (assert) => { + let url = "data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACwAAAAAAQABAAACAkQBADs="; + let post = builder.createPost(); + let section = builder.createImageSection(url); + post.sections.append(section); + + let mobiledoc = render(post); + assert.deepEqual(mobiledoc, { + version: MOBILEDOC_VERSION, + atoms: [], + cards: [], + markups: [], + sections: [ + [2, url] + ] + }); +}); + +test('renders a post with image and null src', (assert) => { + let post = builder.createPost(); + let section = builder.createImageSection(); + post.sections.append(section); + + let mobiledoc = render(post); + assert.deepEqual(mobiledoc, { + version: MOBILEDOC_VERSION, + atoms: [], + cards: [], + markups: [], + sections: [ + [2, null] + ] + }); +}); + +test('renders a post with atom', (assert) => { + const post = Helpers.postAbstract.build(({post, markupSection, marker, atom}) => { + return post([ + markupSection('p', [ + marker('Hi'), + atom('mention', '@bob', { id: 42 }) + ]) + ]); + }); + + let mobiledoc = render(post); + assert.deepEqual(mobiledoc, { + version: MOBILEDOC_VERSION, + atoms: [ + ['mention', '@bob', { id: 42 }] + ], + cards: [], + markups: [], + sections: [ + [1, normalizeTagName('P'), [ + [0, [], 0, 'Hi'], + [1, [], 0, 0] + ]] + ] + }); +}); + +test('renders a post with atom inside markup', (assert) => { + const post = Helpers.postAbstract.build(({post, markupSection, marker, markup, atom}) => { + const strong = markup('strong'); + return post([ + markupSection('p', [ + marker('Hi ', [strong]), + atom('mention', '@bob', { id: 42 }, [strong]), + marker(' Bye', [strong]) + ]) + ]); + }); + + let mobiledoc = render(post); + assert.deepEqual(mobiledoc, { + version: MOBILEDOC_VERSION, + atoms: [ + ['mention', '@bob', { id: 42 }] + ], + cards: [], + markups: [['strong']], + sections: [ + [1, normalizeTagName('P'), [ + [0, [0], 0, 'Hi '], + [1, [], 0, 0], + [0, [], 1, ' Bye'] + ]] + ] + }); +}); + +test('renders a post with card', (assert) => { + let cardName = 'super-card'; + let payload = { bar: 'baz' }; + let post = builder.createPost(); + let section = builder.createCardSection(cardName, payload); + post.sections.append(section); + + let mobiledoc = render(post); + assert.deepEqual(mobiledoc, { + version: MOBILEDOC_VERSION, + atoms: [], + cards: [ + [cardName, payload] + ], + markups: [], + sections: [ + [10, 0] + ] + }); +}); + +test('renders a post with multiple cards with identical payloads', (assert) => { + let cardName = 'super-card'; + let payload1 = { bar: 'baz' }; + let payload2 = { bar: 'baz' }; + let post = builder.createPost(); + + let section1 = builder.createCardSection(cardName, payload1); + post.sections.append(section1); + + let section2 = builder.createCardSection(cardName, payload2); + post.sections.append(section2); + + let mobiledoc = render(post); + assert.deepEqual(mobiledoc, { + version: MOBILEDOC_VERSION, + atoms: [], + cards: [ + [cardName, payload1] + ], + markups: [], + sections: [ + [10, 0], + [10, 0] + ] + }); +}); + +test('renders a post with cards with differing payloads', (assert) => { + let cardName = 'super-card'; + let payload1 = { bar: 'baz1' }; + let payload2 = { bar: 'baz2' }; + let post = builder.createPost(); + + let section1 = builder.createCardSection(cardName, payload1); + post.sections.append(section1); + + let section2 = builder.createCardSection(cardName, payload2); + post.sections.append(section2); + + let mobiledoc = render(post); + assert.deepEqual(mobiledoc, { + version: MOBILEDOC_VERSION, + atoms: [], + cards: [ + [cardName, payload1], + [cardName, payload2] + ], + markups: [], + sections: [ + [10, 0], + [10, 1] + ] + }); +}); + +test('renders a post with a list', (assert) => { + const items = [ + builder.createListItem([builder.createMarker('first item')]), + builder.createListItem([builder.createMarker('second item')]) + ]; + const section = builder.createListSection('ul', items); + const post = builder.createPost([section]); + + const mobiledoc = render(post); + assert.deepEqual(mobiledoc, { + version: MOBILEDOC_VERSION, + atoms: [], + cards: [], + markups: [], + sections: [ + [3, 'ul', [ + [[0, [], 0, 'first item']], + [[0, [], 0, 'second item']] + ]] + ] + }); +}); + +test('renders a pull-quote as markup section', (assert) => { + const post = Helpers.postAbstract.build(({post, markupSection, marker}) => { + return post([markupSection('pull-quote', [marker('abc')])]); + }); + const mobiledoc = render(post); + assert.deepEqual(mobiledoc, { + version: MOBILEDOC_VERSION, + atoms: [], + cards: [], + markups: [], + sections: [ + [1, 'pull-quote', [[0, [], 0, 'abc']]] + ] + }); +});