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:
parent
d0e0760dae
commit
a0bdba2516
8 changed files with 184 additions and 12 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
15
core/server/web/admin/preview.js
Normal file
15
core/server/web/admin/preview.js
Normal 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
|
||||
];
|
53
core/server/web/admin/views/preview.html
Normal file
53
core/server/web/admin/views/preview.html
Normal 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>
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Add table
Reference in a new issue