0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-03-11 02:12:21 -05:00

Added theme preview mode

- Allow the frontend to accept post messages to generate previews of the frontend
- Created a new endpoint in admin we can use to render these previews, which is possibly not necessary
- Supports a limited group of settings, which can easily be expanded, but care should be taken if expanding to use user-provided strings
This commit is contained in:
Hannah Wolfe 2021-02-12 16:08:55 +00:00
parent d0e0760dae
commit a0bdba2516
8 changed files with 184 additions and 12 deletions

1
.gitignore vendored
View file

@ -123,4 +123,5 @@ test/coverage
# Built asset files
/core/built
/core/server/web/admin/views/*.html
!/core/server/web/admin/views/preview.html
/core/server/public/ghost.min.css

View file

@ -142,6 +142,7 @@ class ParentRouter extends EventEmitter {
mountRoute(path, controller) {
debug(this.name + ': mountRoute for', path, controller.name);
registry.setRoute(this.name, path);
this._router.post(path, controller);
this._router.get(path, controller);
}

View file

@ -31,6 +31,10 @@ module.exports = {
themeLoader.loadAllThemes();
});
events.on('themes.ready', function readAllThemesOnServerStart() {
themeLoader.loadAllThemes();
});
// Just read the active theme for now
return themeLoader
.loadOneTheme(activeThemeName)

View file

@ -84,11 +84,50 @@ function haxGetMembersPriceData() {
}
}
// The preview header contains a query string with the custom preview data
// This is deliberately slightly obscure & means we don't need to add body parsing to the frontend :D
// If we start passing in strings like title or description we will probably need to change this
const PREVIEW_HEADER_NAME = 'x-ghost-preview';
function handlePreview(previewHeader, siteData) {
// Keep the string shorter with short codes for certain parameters
const supportedSettings = {
c: 'accent_color',
icon: 'icon',
logo: 'logo',
cover: 'cover_image'
};
// @TODO: change this to use a proper query string parser - maybe build a fake url and use the url lib
let opts = decodeURIComponent(previewHeader).split('&');
opts.forEach((opt) => {
let [key, value] = opt.split('=');
if (supportedSettings[key]) {
_.set(siteData, supportedSettings[key], value);
}
});
siteData._preview = previewHeader;
return siteData;
}
function getSiteData(req) {
let siteData = settingsCache.getPublic();
if (req.header(PREVIEW_HEADER_NAME)) {
siteData = handlePreview(req.header(PREVIEW_HEADER_NAME), siteData);
}
return siteData;
}
function updateGlobalTemplateOptions(req, res, next) {
// Static information, same for every request unless the settings change
// @TODO: bind this once and then update based on events?
// @TODO: decouple theme layer from settings cache using the Content API
const siteData = settingsCache.getPublic();
const siteData = getSiteData(req);
const labsData = labs.getAll();
const themeData = {
@ -98,16 +137,18 @@ function updateGlobalTemplateOptions(req, res, next) {
const priceData = haxGetMembersPriceData();
// @TODO: only do this if something changed?
// @TODO: remove blog if we drop v2 (Ghost 4.0)
hbs.updateTemplateOptions({
data: {
blog: siteData,
site: siteData,
labs: labsData,
config: themeData,
price: priceData
}
});
// @TODO: remove blog in a major where we are happy to break more themes
{
hbs.updateTemplateOptions({
data: {
blog: siteData,
site: siteData,
labs: labsData,
config: themeData,
price: priceData
}
});
}
next();
}

View file

@ -6,11 +6,14 @@ const constants = require('@tryghost/constants');
const urlUtils = require('../../../shared/url-utils');
const shared = require('../shared');
const adminMiddleware = require('./middleware');
const preview = require('./preview');
module.exports = function setupAdminApp() {
debug('Admin setup start');
const adminApp = express('admin');
adminApp.use('/preview', preview);
// Admin assets
// @TODO ensure this gets a local 404 error handler
const configMaxAge = config.get('caching:admin:maxAge');
@ -40,6 +43,7 @@ module.exports = function setupAdminApp() {
// Cache headers go last before serving the request
// Admin is currently set to not be cached at all
adminApp.use(shared.middlewares.cacheControl('private'));
// Special redirects for the admin (these should have their own cache-control headers)
adminApp.use(adminMiddleware);

View file

@ -0,0 +1,15 @@
const path = require('path');
const config = require('../../../shared/config');
function servePreview(req, res, next) {
if (req.path === '/') {
const templatePath = path.resolve(config.get('paths').adminViews, 'preview.html');
return res.sendFile(templatePath);
}
next();
}
module.exports = [
servePreview
];

View file

@ -0,0 +1,53 @@
<script type="text/javascript" charset="utf-8">
(function(){
function onReceive(message) {
// If we're not using IE, or HTTPS we can use document.write
if ((window.location.protocol === 'http:') || !navigator.userAgent.match(/MSIE|rv:11/i)) {
document.write(message.data);
document.close();
return;
}
// In all other cases document.write() is blocked in callbacks - process the HTML instead
// We also have to individually add <script> tags back in - same as when using unsafeHTML in react
var domParser = new DOMParser();
var html = domParser.parseFromString(message.data, 'text/html');
document.getElementsByTagName('head')[0].innerHTML = html.getElementsByTagName('head')[0].innerHTML;
document.getElementsByTagName('body')[0].innerHTML = html.getElementsByTagName('body')[0].innerHTML;
var allScripts = document.getElementsByTagName('script');
if (allScripts.length > 0) {
var scripts = [];
for (var i = 0; i < allScripts.length; i++) {
scripts.push(allScripts[i]);
}
for (var i = 0; i < scripts.length; i++) {
var s = document.createElement('script');
s.innerHTML = scripts[i].innerHTML;
scripts[i].parentNode.appendChild(s);
scripts[i].parentNode.removeChild(scripts[i]);
}
}
}
if (window.addEventListener){
addEventListener("message", onReceive, true);
} else {
attachEvent("onmessage", onReceive);
}
top.postMessage('loaded', "*");
})();
(function(XMLHttpRequest){
if (!XMLHttpRequest || !XMLHttpRequest.prototype) return;
var noXHR = function() {
if (console) {
console.error('Not Permitted');
}
};
XMLHttpRequest.prototype.open = XMLHttpRequest.prototype.send = noXHR;
})(this.XMLHttpRequest);
</script>

View file

@ -37,7 +37,7 @@ describe('Themes middleware', function () {
let fakeLabsData;
beforeEach(function () {
req = {app: {}};
req = {app: {}, header: () => { }};
res = {locals: {}};
fakeActiveTheme = {
@ -150,4 +150,57 @@ describe('Themes middleware', function () {
done();
});
});
describe('Preview Mode', function () {
it('calls updateTemplateOptions with correct data when one parameter is set', function (done) {
const previewString = 'c=%23000fff';
req.header = () => {
return previewString;
};
executeMiddleware(middleware, req, res, function next(err) {
should.not.exist(err);
hbs.updateTemplateOptions.calledOnce.should.be.true();
const templateOptions = hbs.updateTemplateOptions.firstCall.args[0];
const data = templateOptions.data;
data.should.be.an.Object().with.properties('site', 'labs', 'config');
should.equal(data.site, fakeSiteData);
data.site.should.be.an.Object().with.properties('accent_color', '_preview');
data.site._preview.should.eql(previewString);
data.site.accent_color.should.eql('#000fff');
done();
});
});
it('calls updateTemplateOptions with correct data when two parameters are set', function (done) {
const previewString = 'c=%23000fff&icon=%2Fcontent%2Fimages%2Fmyimg.png';
req.header = () => {
return previewString;
};
executeMiddleware(middleware, req, res, function next(err) {
should.not.exist(err);
hbs.updateTemplateOptions.calledOnce.should.be.true();
const templateOptions = hbs.updateTemplateOptions.firstCall.args[0];
const data = templateOptions.data;
data.should.be.an.Object().with.properties('site', 'labs', 'config');
should.equal(data.site, fakeSiteData);
data.site.should.be.an.Object().with.properties('accent_color', 'icon', '_preview');
data.site._preview.should.eql(previewString);
data.site.accent_color.should.eql('#000fff');
data.site.icon.should.eql('/content/images/myimg.png');
done();
});
});
});
});