0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-04-15 03:01:37 -05:00

Koenig - Versioned renderer (#9606)

refs https://github.com/TryGhost/Ghost/issues/9505
- updates mobiledoc converter's `render` method to accept a `version` argument
    - `1` === Ghost 1.0's markdown-only renderer output
    - `2` === Koenig's full mobiledoc renderer output
- switch between mobiledoc renderer versions in Post model's `onSaving` hook
    - version 1 by default
    - version 2 if Koenig is enabled (currently behind dev experiments config + labs flag)
    - version 2 if the post's mobiledoc is not compatible with the markdown-only renderer
- "version 2" full-Koenig mobiledoc renderer output
    - wraps content in a `.kg-post` div
    - removes wrapper around markdown and html card output
    - adds classes to image card output including selected image size/style
- standardises es6 usage across mobiledoc related files
This commit is contained in:
Kevin Ansfield 2018-05-04 14:59:39 +01:00 committed by GitHub
parent 58aa531813
commit e953a1c3a8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 424 additions and 146 deletions

View file

@ -1,3 +1,5 @@
var softReturn = require('./soft-return');
'use strict';
const softReturn = require('./soft-return');
module.exports = [softReturn];

View file

@ -1,3 +1,5 @@
'use strict';
module.exports = {
name: 'soft-return',
type: 'dom',

View file

@ -1,3 +1,5 @@
'use strict';
module.exports = {
name: 'hr',
type: 'dom',

View file

@ -2,20 +2,8 @@ module.exports = {
name: 'html',
type: 'dom',
render(opts) {
let payload = opts.payload;
let dom = opts.env.dom;
let caption = '';
if (payload.caption) {
caption = `<p>${payload.caption}</p>`;
}
let html = `<div class="kg-card-html">${payload.html}${caption}</div>`;
// use the SimpleDOM document to create a raw HTML section.
// avoids parsing/rendering of potentially broken or unsupported HTML
let element = dom.createRawHTMLSection(html);
return element;
return opts.env.dom.createRawHTMLSection(opts.payload.html);
}
};

View file

@ -3,12 +3,20 @@ module.exports = {
type: 'dom',
render(opts) {
let payload = opts.payload;
// let version = opts.options.version;
let dom = opts.env.dom;
let figure = dom.createElement('figure');
figure.setAttribute('class', 'kg-image-card');
let img = dom.createElement('img');
img.className = 'kg-card-image';
let imgClass = 'kg-image';
if (payload.imageStyle) {
imgClass = `${imgClass} kg-image--${payload.imageStyle}`;
}
img.setAttribute('src', payload.src);
img.setAttribute('class', imgClass);
figure.appendChild(img);
if (payload.caption) {

View file

@ -1,7 +1,9 @@
var hr = require('./hr'),
html = require('./html'),
image = require('./image'),
markdown = require('./markdown'),
cardMarkdown = require('./card-markdown');
'use strict';
const hr = require('./hr');
const html = require('./html');
const image = require('./image');
const markdown = require('./markdown');
const cardMarkdown = require('./card-markdown');
module.exports = [hr, html, image, markdown, cardMarkdown];

View file

@ -1,19 +1,22 @@
'use strict';
module.exports = {
name: 'markdown',
type: 'dom',
render: function (opts) {
var converters = require('../converters'),
html, element;
let converters = require('../converters');
let payload = opts.payload;
let version = opts.options.version;
// convert markdown to HTML ready for insertion into dom
html = '<div class="kg-card-markdown">'
+ converters.markdownConverter.render(opts.payload.markdown || '')
+ '</div>';
let html = converters.markdownConverter.render(payload.markdown || '');
// Ghost 1.0's markdown-only renderer wrapped cards
if (version === 1) {
html = `<div class="kg-card-markdown">${html}</div>`;
}
// use the SimpleDOM document to create a raw HTML section.
// avoids parsing/rendering of potentially broken or unsupported HTML
element = opts.env.dom.createRawHTMLSection(html);
return element;
return opts.env.dom.createRawHTMLSection(html);
}
};

View file

@ -36,11 +36,38 @@ var SimpleDom = require('simple-dom'),
// }
module.exports = {
render: function (mobiledoc) {
var renderer = new Renderer(options),
rendered = renderer.render(mobiledoc),
serializer = new SimpleDom.HTMLSerializer(SimpleDom.voidMap),
html = serializer.serializeChildren(rendered.result);
// version 1 === Ghost 1.0 markdown-only mobiledoc
// version 2 === Ghost 2.0 full mobiledoc
render: function (mobiledoc, version) {
version = version || 1;
// pass the version through to the card renderers.
// create a new object here to avoid modifying the default options
// object because the version can change per-render until 2.0 is released
let versionedOptions = Object.assign({}, options, {
cardOptions: {version}
});
let renderer = new Renderer(versionedOptions);
let rendered = renderer.render(mobiledoc);
let serializer = new SimpleDom.HTMLSerializer(SimpleDom.voidMap);
// Koenig keeps a blank paragraph at the end of a doc but we want to
// make sure it doesn't get rendered
let lastChild = rendered.result.lastChild;
if (lastChild && lastChild.tagName === 'P' && !lastChild.firstChild) {
rendered.result.removeChild(lastChild);
}
let html = serializer.serializeChildren(rendered.result);
// full version of Koenig wraps the content with a specific class to
// be targetted with our default stylesheet for vertical rhythm and
// card-specific styles
if (version === 2) {
html = `<div class="kg-post">\n${html}\n</div>`;
}
return html;
}
};

View file

@ -9,6 +9,7 @@ var _ = require('lodash'),
htmlToText = require('html-to-text'),
ghostBookshelf = require('./base'),
config = require('../config'),
labs = require('../services/labs'),
converters = require('../lib/mobiledoc/converters'),
urlService = require('../services/url'),
relations = require('./relations'),
@ -185,7 +186,7 @@ Post = ghostBookshelf.Model.extend({
prevSlug = this.previous('slug'),
publishedAt = this.get('published_at'),
publishedAtHasChanged = this.hasDateChanged('published_at', {beforeWrite: true}),
mobiledoc = this.get('mobiledoc'),
mobiledoc = JSON.parse(this.get('mobiledoc') || null),
generatedFields = ['html', 'plaintext'],
tagsToSave,
ops = [];
@ -249,8 +250,37 @@ Post = ghostBookshelf.Model.extend({
}
});
// render mobiledoc to HTML. Switch render version if Koenig is enabled
// or has been edited with Koenig and is no longer compatible with the
// Ghost 1.0 markdown-only renderer
// TODO: re-render all content and remove the version toggle for Ghost 2.0
if (mobiledoc) {
this.set('html', converters.mobiledocConverter.render(JSON.parse(mobiledoc)));
let version = 1;
let devExperimentsEnabled = config.get('enableDeveloperExperiments');
let koenigEnabled = labs.isSet('koenigEditor') === true;
let mobiledocIsCompatibleWithV1 = function mobiledocIsCompatibleWithV1(doc) {
if (doc
&& doc.markups.length === 0
&& doc.cards.length === 1
&& doc.cards[0][0].match(/(?:card-)?markdown/)
&& doc.sections.length === 1
&& doc.sections[0].length === 2
&& doc.sections[0][0] === 10
&& doc.sections[0][1] === 0
) {
return true;
}
return false;
};
if ((devExperimentsEnabled && koenigEnabled) || !mobiledocIsCompatibleWithV1(mobiledoc)) {
version = 2;
}
let html = converters.mobiledocConverter.render(mobiledoc, version);
this.set('html', html);
}
if (this.hasChanged('html') || !this.get('plaintext')) {

View file

@ -1,17 +1,18 @@
var should = require('should'), // jshint ignore:line
card = require('../../../../../server/lib/mobiledoc/atoms/soft-return'),
SimpleDom = require('simple-dom'),
opts;
'use strict';
describe('Soft return card', function () {
const should = require('should'); // jshint ignore:line
const atom = require('../../../../../server/lib/mobiledoc/atoms/soft-return');
const SimpleDom = require('simple-dom');
const serializer = new SimpleDom.HTMLSerializer(SimpleDom.voidMap);
describe('Soft return atom', function () {
it('generates a `br` tag', function () {
opts = {
let opts = {
env: {
dom: new SimpleDom.Document()
}
};
var serializer = new SimpleDom.HTMLSerializer([]);
serializer.serialize(card.render(opts)).should.match('<br></br>');
serializer.serialize(atom.render(opts)).should.match('<br>');
});
});

View file

@ -1,17 +1,18 @@
var should = require('should'), // jshint ignore:line
card = require('../../../../../server/lib/mobiledoc/cards/hr'),
SimpleDom = require('simple-dom'),
opts;
'use strict';
const should = require('should'); // jshint ignore:line
const card = require('../../../../../server/lib/mobiledoc/cards/hr');
const SimpleDom = require('simple-dom');
const serializer = new SimpleDom.HTMLSerializer(SimpleDom.voidMap);
describe('HR card', function () {
it('generates a horizontal rule', function () {
opts = {
let opts = {
env: {
dom: new SimpleDom.Document()
}
};
var serializer = new SimpleDom.HTMLSerializer([]);
serializer.serialize(card.render(opts)).should.match('<hr></hr>');
serializer.serialize(card.render(opts)).should.match('<hr>');
});
});

View file

@ -1,11 +1,13 @@
var should = require('should'), // jshint ignore:line
card = require('../../../../../server/lib/mobiledoc/cards/html'),
SimpleDom = require('simple-dom'),
opts;
'use strict';
const should = require('should'); // jshint ignore:line
const card = require('../../../../../server/lib/mobiledoc/cards/html');
const SimpleDom = require('simple-dom');
const serializer = new SimpleDom.HTMLSerializer(SimpleDom.voidMap);
describe('HTML card', function () {
it('HTML Card renders', function () {
opts = {
let opts = {
env: {
dom: new SimpleDom.Document()
},
@ -14,12 +16,11 @@ describe('HTML card', function () {
}
};
var serializer = new SimpleDom.HTMLSerializer([]);
serializer.serialize(card.render(opts)).should.match('<div class="kg-card-html"><h1>HEADING</h1><p>PARAGRAPH</p></div>');
serializer.serialize(card.render(opts)).should.match('<h1>HEADING</h1><p>PARAGRAPH</p>');
});
it('Plain content renders', function () {
opts = {
let opts = {
env: {
dom: new SimpleDom.Document()
},
@ -28,12 +29,11 @@ describe('HTML card', function () {
}
};
var serializer = new SimpleDom.HTMLSerializer([]);
serializer.serialize(card.render(opts)).should.match('<div class="kg-card-html">CONTENT</div>');
serializer.serialize(card.render(opts)).should.match('CONTENT');
});
it('Invalid HTML returns', function () {
opts = {
let opts = {
env: {
dom: new SimpleDom.Document()
},
@ -42,22 +42,6 @@ describe('HTML card', function () {
}
};
var serializer = new SimpleDom.HTMLSerializer([]);
serializer.serialize(card.render(opts)).should.match('<div class="kg-card-html"><h1>HEADING<</div>');
});
it('Caption renders', function () {
opts = {
env: {
dom: new SimpleDom.Document()
},
payload: {
html: '<iframe src="http://vimeo.com"></iframe>',
caption: 'Embed caption test'
}
};
var serializer = new SimpleDom.HTMLSerializer([]);
serializer.serialize(card.render(opts)).should.match('<div class="kg-card-html"><iframe src="http://vimeo.com"></iframe><p>Embed caption test</p></div>');
serializer.serialize(card.render(opts)).should.match('<h1>HEADING<');
});
});

View file

@ -1,11 +1,13 @@
var should = require('should'), // jshint ignore:line
card = require('../../../../../server/lib/mobiledoc/cards/image'),
SimpleDom = require('simple-dom'),
opts;
'use strict';
const should = require('should'); // jshint ignore:line
const card = require('../../../../../server/lib/mobiledoc/cards/image');
const SimpleDom = require('simple-dom');
const serializer = new SimpleDom.HTMLSerializer(SimpleDom.voidMap);
describe('Image card', function () {
it('generates an image', function () {
opts = {
let opts = {
env: {
dom: new SimpleDom.Document()
},
@ -14,12 +16,11 @@ describe('Image card', function () {
}
};
var serializer = new SimpleDom.HTMLSerializer([]);
serializer.serialize(card.render(opts)).should.match('<figure><img src="https://www.ghost.org/image.png"></img></figure>');
serializer.serialize(card.render(opts)).should.eql('<figure class="kg-image-card"><img src="https://www.ghost.org/image.png" class="kg-image"></figure>');
});
it('generates an image with caption', function () {
opts = {
let opts = {
env: {
dom: new SimpleDom.Document()
},
@ -29,7 +30,50 @@ describe('Image card', function () {
}
};
var serializer = new SimpleDom.HTMLSerializer([]);
serializer.serialize(card.render(opts)).should.match('<figure><img src="https://www.ghost.org/image.png"></img><figcaption>Test caption</figcaption></figure>');
serializer.serialize(card.render(opts)).should.eql('<figure class="kg-image-card"><img src="https://www.ghost.org/image.png" class="kg-image"><figcaption>Test caption</figcaption></figure>');
});
describe('sizes', function () {
it('standard', function () {
let opts = {
env: {
dom: new SimpleDom.Document()
},
payload: {
src: 'https://www.ghost.org/image.png',
imageStyle: ''
}
};
serializer.serialize(card.render(opts)).should.eql('<figure class="kg-image-card"><img src="https://www.ghost.org/image.png" class="kg-image"></figure>');
});
it('wide', function () {
let opts = {
env: {
dom: new SimpleDom.Document()
},
payload: {
src: 'https://www.ghost.org/image.png',
imageStyle: 'wide'
}
};
serializer.serialize(card.render(opts)).should.eql('<figure class="kg-image-card"><img src="https://www.ghost.org/image.png" class="kg-image kg-image--wide"></figure>');
});
it('full', function () {
let opts = {
env: {
dom: new SimpleDom.Document()
},
payload: {
src: 'https://www.ghost.org/image.png',
imageStyle: 'full'
}
};
serializer.serialize(card.render(opts)).should.eql('<figure class="kg-image-card"><img src="https://www.ghost.org/image.png" class="kg-image kg-image--full"></figure>');
});
});
});

View file

@ -1,34 +1,76 @@
var should = require('should'), // jshint ignore:line
card = require('../../../../../server/lib/mobiledoc/cards/markdown'),
SimpleDom = require('simple-dom'),
opts;
'use strict';
const should = require('should'); // jshint ignore:line
const card = require('../../../../../server/lib/mobiledoc/cards/markdown');
const SimpleDom = require('simple-dom');
const serializer = new SimpleDom.HTMLSerializer(SimpleDom.voidMap);
describe('Markdown card', function () {
it('Markdown Card renders', function () {
opts = {
env: {
dom: new SimpleDom.Document()
},
payload: {
markdown: '#HEADING\r\n- list\r\n- items'
}
};
describe('version 1', function () {
it('Markdown Card renders', function () {
let opts = {
env: {
dom: new SimpleDom.Document()
},
payload: {
markdown: '#HEADING\r\n- list\r\n- items'
},
options: {
version: 1
}
};
var serializer = new SimpleDom.HTMLSerializer([]);
serializer.serialize(card.render(opts)).should.match('<div class="kg-card-markdown"><h1 id="heading">HEADING</h1>\n<ul>\n<li>list</li>\n<li>items</li>\n</ul>\n</div>');
serializer.serialize(card.render(opts)).should.match('<div class="kg-card-markdown"><h1 id="heading">HEADING</h1>\n<ul>\n<li>list</li>\n<li>items</li>\n</ul>\n</div>');
});
it('Accepts invalid HTML in markdown', function () {
let opts = {
env: {
dom: new SimpleDom.Document()
},
payload: {
markdown: '#HEADING\r\n<h2>Heading 2>'
},
options: {
version: 1
}
};
serializer.serialize(card.render(opts)).should.match('<div class="kg-card-markdown"><h1 id="heading">HEADING</h1>\n<h2>Heading 2></div>');
});
});
it('Accepts invalid HTML in markdown', function () {
opts = {
env: {
dom: new SimpleDom.Document()
},
payload: {
markdown: '#HEADING\r\n<h2>Heading 2>'
}
};
describe('version 2', function () {
it('Markdown Card renders', function () {
let opts = {
env: {
dom: new SimpleDom.Document()
},
payload: {
markdown: '#HEADING\r\n- list\r\n- items'
},
options: {
version: 2
}
};
var serializer = new SimpleDom.HTMLSerializer([]);
serializer.serialize(card.render(opts)).should.match('<div class="kg-card-markdown"><h1 id="heading">HEADING</h1>\n<h2>Heading 2></div>');
serializer.serialize(card.render(opts)).should.match('<h1 id="heading">HEADING</h1>\n<ul>\n<li>list</li>\n<li>items</li>\n</ul>\n');
});
it('Accepts invalid HTML in markdown', function () {
let opts = {
env: {
dom: new SimpleDom.Document()
},
payload: {
markdown: '#HEADING\r\n<h2>Heading 2>'
},
options: {
version: 2
}
};
serializer.serialize(card.render(opts)).should.match('<h1 id="heading">HEADING</h1>\n<h2>Heading 2>');
});
});
});

View file

@ -1,34 +1,111 @@
var should = require('should'), // jshint ignore:line
converter = require('../../../../../server/lib/mobiledoc/converters/mobiledoc-converter');
'use strict';
describe('Convert mobiledoc to HTML ', function () {
var mobiledoc = {
version: '0.3.1',
atoms: [],
cards: [
['markdown',
{
pos: 'top',
card_name: 'markdown',
markdown: '#heading\n\n- list one\n- list two\n- list three'
}
],
['html', {
pos: 'top',
card_name: 'html',
html: '<p>HTML CARD</p>'
}]
],
markups: [],
sections: [
[1, 'p', [
[0, [], 0, 'test']
]],
[10, 0],
[10, 1]
]
};
it('Converts a mobiledoc to HTML', function () {
converter.render(mobiledoc).should.match('<p>test</p><div class="kg-card-markdown"><h1 id="heading">heading</h1>\n<ul>\n<li>list one</li>\n<li>list two</li>\n<li>list three</li>\n</ul>\n</div><div class="kg-card-html"><p>HTML CARD</p></div>');
const should = require('should'); // jshint ignore:line
const converter = require('../../../../../server/lib/mobiledoc/converters/mobiledoc-converter');
describe('Mobiledoc converter', function () {
// version 1 === Ghost 1.0 markdown-only renderer
describe('version 1', function () {
it('renders correctly', function () {
let mobiledoc = {
version: '0.3.1',
atoms: [],
cards: [
['markdown',
{
pos: 'top',
card_name: 'markdown',
markdown: '#heading\n\n- list one\n- list two\n- list three'
}
]
],
markups: [],
sections: [
[10, 0]
]
};
converter.render(mobiledoc).should.eql('<div class="kg-card-markdown"><h1 id="heading">heading</h1>\n<ul>\n<li>list one</li>\n<li>list two</li>\n<li>list three</li>\n</ul>\n</div>');
});
});
// version 2 === Ghost 2.0 full Koenig renderer
describe('version 2', function () {
it('renders all default cards and atoms', function () {
let mobiledoc = {
version: '0.3.1',
atoms: [
['soft-return', '', {}]
],
cards: [
['markdown', {
markdown: '# Markdown card\nSome markdown'
}],
['hr', {}],
['image', {
imageStyle: 'wide',
src: '/content/images/2018/04/NatGeo06.jpg',
caption: 'Birdies'
}],
['html', {
html: '<h2>HTML card</h2>\n<div><p>Some HTML</p></div>'
}]
],
markups: [],
sections: [
[1, 'p', [
[0, [], 0, 'One'],
[1, [], 0, 0],
[0, [], 0, 'Two']
]],
[10, 0],
[1, 'p', [
[0, [], 0, 'Three']
]],
[10, 1],
[10, 2],
[1, 'p', [
[0, [], 0, 'Four']
]],
[10, 3],
[1, 'p', []]
]
};
converter.render(mobiledoc, 2).should.eql('<div class="kg-post">\n<p>One<br>Two</p><h1 id="markdowncard">Markdown card</h1>\n<p>Some markdown</p>\n<p>Three</p><hr><figure class="kg-image-card"><img src="/content/images/2018/04/NatGeo06.jpg" class="kg-image kg-image--wide"><figcaption>Birdies</figcaption></figure><p>Four</p><h2>HTML card</h2>\n<div><p>Some HTML</p></div>\n</div>');
});
it('wraps output with a .kg-post div', function () {
let mobiledoc = {
version: '0.3.1',
atoms: [],
cards: [],
markups: [],
sections: [
[1, 'p', [
[0, [], 0, 'Test']
]]
]
};
converter.render(mobiledoc, 2).should.eql('<div class="kg-post">\n<p>Test</p>\n</div>');
});
it('removes final blank paragraph', function () {
let mobiledoc = {
version: '0.3.1',
atoms: [],
cards: [],
markups: [],
sections: [
[1, 'p', [
[0, [], 0, 'Test']
]],
[1, 'p', []]
]
};
converter.render(mobiledoc, 2).should.eql('<div class="kg-post">\n<p>Test</p>\n</div>');
});
});
});

View file

@ -1889,4 +1889,69 @@ describe('Unit: models/post', function () {
});
});
});
describe('Mobiledoc conversion', function () {
let configUtils = require('../../utils/configUtils');
let labs = require('../../../server/services/labs');
let origLabs = _.cloneDeep(labs);
let events;
beforeEach(function () {
events = {
post: []
};
sandbox.stub(models.Post.prototype, 'emitChange').callsFake(function (event) {
events.post.push({event: event, data: this.toJSON()});
});
});
afterEach(configUtils.restore);
it('uses v2 if Koenig is enabled', function () {
configUtils.set('enableDeveloperExperiments', true);
sandbox.stub(labs, 'isSet').callsFake(function (key) {
if (key === 'koenigEditor') {
return true;
}
return origLabs.get(key);
});
let newPost = testUtils.DataGenerator.forModel.posts[2];
return models.Post.add(
newPost,
testUtils.context.editor
).then((post) => {
should.exist(post);
post.has('html').should.equal(true);
post.get('html').should.equal('<div class="kg-post">\n<h2 id="testing">testing</h2>\n<p>mctesters</p>\n<ul>\n<li>test</li>\n<li>line</li>\n<li>items</li>\n</ul>\n\n</div>');
});
});
it('uses v2 if Koenig is disabled but post is not v1 compatible', function () {
let newPost = testUtils.DataGenerator.forModel.posts[2];
newPost.mobiledoc = JSON.stringify({
version: '0.3.1',
atoms: [],
cards: [],
markups: [],
sections: [
[1, 'p', [
[0, [], 0, 'Test']
]]
]
});
return models.Post.add(
newPost,
testUtils.context.editor
).then((post) => {
should.exist(post);
post.has('html').should.equal(true);
post.get('html').should.equal('<div class="kg-post">\n<p>Test</p>\n</div>');
});
});
});
});