Skip to content

Commit

Permalink
✨ Added support for multiple built-in themes (#1784)
Browse files Browse the repository at this point in the history
refs TryGhost/Product#3510

- Until now, Ghost has always shipped with a single built-in theme, Casper. This change adds support for Ghost to ship with multiple built-in themes.
- Updated the install and update commands to create symlinks for any theme that is shipped alongside Ghost (e.g. any theme in `ghost/core/content/themes`)
- When rolling back with `ghost update --rollback`, any symlinks that are broken in the process will be removed. A migration in Ghost itself will change the active_theme back to Casper if the currently active_theme is no longer installed in the previous version of Ghost

Co-authored-by: Vikas Potluri <[email protected]>
  • Loading branch information
cmraible and vikaspotluri123 authored Oct 4, 2023
1 parent 5268592 commit 064f6b6
Show file tree
Hide file tree
Showing 5 changed files with 131 additions and 20 deletions.
22 changes: 14 additions & 8 deletions lib/commands/install.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,8 @@ class InstallCommand extends Command {
title: 'Linking latest Ghost and recording versions',
task: this.link.bind(this)
}, {
title: 'Linking latest Casper',
task: this.casper
title: 'Linking built-in themes',
task: this.defaultThemes
}], false)
}], {
argv: {...argv, version},
Expand Down Expand Up @@ -129,12 +129,18 @@ class InstallCommand extends Command {
ctx.installPath = path.join(process.cwd(), 'versions', resolvedVersion); // eslint-disable-line require-atomic-updates
}

casper() {
// Create a symlink to the theme from the current version
return symlinkSync(
path.join(process.cwd(), 'current', 'content', 'themes', 'casper'),
path.join(process.cwd(), 'content', 'themes', 'casper')
);
defaultThemes() {
const currentThemesDir = path.join(process.cwd(), 'current', 'content', 'themes');
const contentThemesDir = path.join(process.cwd(), 'content', 'themes');
const defaultThemes = fs.readdirSync(currentThemesDir);
for (const theme of defaultThemes) {
if (!fs.existsSync(path.join(contentThemesDir, theme))) {
symlinkSync(
path.join(currentThemesDir, theme),
path.join(contentThemesDir, theme)
);
}
}
}

link(ctx) {
Expand Down
35 changes: 33 additions & 2 deletions lib/commands/update.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
const fs = require('fs-extra');
const path = require('path');
const symlinkSync = require('symlink-or-copy').sync;

// Utils
const {GhostError} = require('../errors');
Expand Down Expand Up @@ -92,6 +93,9 @@ class UpdateCommand extends Command {
}, {
title: 'Linking latest Ghost and recording versions',
task: this.link
}, {
title: 'Installing default themes',
task: this.linkDefaultThemes
}, {
title: 'Running database migrations',
skip: ({rollback}) => rollback,
Expand Down Expand Up @@ -218,15 +222,42 @@ class UpdateCommand extends Command {
}

link({instance, installPath, version, rollback}) {
const symlinkSync = require('symlink-or-copy').sync;

fs.removeSync(path.join(process.cwd(), 'current'));
symlinkSync(installPath, path.join(process.cwd(), 'current'));

instance.previousVersion = rollback ? null : instance.version;
instance.version = version;
instance.nodeVersion = process.versions.node;
}

linkDefaultThemes({instance, rollback}) {
const currentThemesDir = path.join(process.cwd(), 'current', 'content', 'themes');
const contentThemesDir = path.join(instance.config.get('paths.contentPath'), 'themes');
// remove any broken symlinks caused by default themes no longer existing in previous version
if (rollback) {
if (fs.existsSync(contentThemesDir)) {
const installedThemes = fs.readdirSync(contentThemesDir);
for (const theme of installedThemes) {
if (!fs.existsSync(path.join(contentThemesDir, theme))) {
fs.rmSync(path.join(contentThemesDir, theme));
}
}
}
}

// ensure all default themes (e.g. themes shipped with Ghost) are symlinked to /content/themes directory
if (fs.existsSync(currentThemesDir)) {
const defaultThemes = fs.readdirSync(currentThemesDir);
for (const theme of defaultThemes) {
if (!fs.existsSync(path.join(contentThemesDir, theme))) {
symlinkSync(
path.join(currentThemesDir, theme),
path.join(contentThemesDir, theme)
);
}
}
}
}
}

UpdateCommand.description = 'Update a Ghost instance';
Expand Down
2 changes: 1 addition & 1 deletion lib/tasks/backup.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ module.exports = async function (ui, instance) {
const zipPath = path.join(process.cwd(), `backup-${backupSuffix}.zip`);

try {
await zip.compress(path.join(instance.dir, 'content/'), zipPath, {glob: `{data/${contentExportFile},data/${membersExportFile},files/**,images/**,media/**,settings/**,themes/**}`, ignore: 'themes/casper'});
await zip.compress(path.join(instance.dir, 'content/'), zipPath, {glob: `{data/${contentExportFile},data/${membersExportFile},files/**,images/**,media/**,settings/**,themes/**}`, ignore: 'themes/casper,themes/source'});
} catch (err) {
throw new ProcessError(err);
}
Expand Down
27 changes: 18 additions & 9 deletions test/unit/commands/install-spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ describe('Unit: Commands > Install', function () {
const runCommandStub = sinon.stub(testInstance, 'runCommand').resolves();
const versionStub = sinon.stub(testInstance, 'version').resolves();
const linkStub = sinon.stub(testInstance, 'link').resolves();
const casperStub = sinon.stub(testInstance, 'casper').resolves();
const defaultThemesStub = sinon.stub(testInstance, 'defaultThemes').resolves();

return testInstance.run({version: '1.0.0', setup: false, 'check-empty': true}).then(() => {
expect(dirEmptyStub.calledOnce).to.be.true;
Expand All @@ -216,7 +216,7 @@ describe('Unit: Commands > Install', function () {
expect(ensureStructureStub.calledOnce).to.be.true;
expect(versionStub.calledOnce).to.be.true;
expect(linkStub.calledOnce).to.be.true;
expect(casperStub.calledOnce).to.be.true;
expect(defaultThemesStub.calledOnce).to.be.true;
expect(runCommandStub.calledOnce).to.be.true;
});
});
Expand Down Expand Up @@ -412,20 +412,29 @@ describe('Unit: Commands > Install', function () {
});
});

describe('tasks > casper', function () {
it('links casper version correctly', function () {
describe('tasks > defaultThemes', function () {
it('creates a symlink to all themes shipped with Ghost', function () {
const symlinkSyncStub = sinon.stub();
const readdirSyncStub = sinon.stub().returns(['casper', 'source']);
const existsSyncStub = sinon.stub();
existsSyncStub.returns(false);
const InstallCommand = proxyquire(modulePath, {
'symlink-or-copy': {sync: symlinkSyncStub}
'symlink-or-copy': {sync: symlinkSyncStub},
'fs-extra': {readdirSync: readdirSyncStub, existsSync: existsSyncStub}
});

const testInstance = new InstallCommand({}, {});

testInstance.casper();
expect(symlinkSyncStub.calledOnce).to.be.true;
const context = {version: '5.67.0'};
testInstance.defaultThemes(context);
expect(symlinkSyncStub.callCount).to.equal(2);
expect(symlinkSyncStub.calledWithExactly(
path.join(process.cwd(), 'current', 'content', 'themes', 'casper'),
path.join(process.cwd(), 'content', 'themes', 'casper')
));
expect(symlinkSyncStub.calledWithExactly(
path.join(process.cwd(), 'current/content/themes/casper'),
path.join(process.cwd(), 'content/themes/casper')
path.join(process.cwd(), 'current', 'content', 'themes', 'source'),
path.join(process.cwd(), 'content', 'themes', 'source')
));
});
});
Expand Down
65 changes: 65 additions & 0 deletions test/unit/commands/update-spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ describe('Unit: Commands > Update', function () {
const downloadStub = sinon.stub(cmdInstance, 'downloadAndUpdate').resolves();
const removeOldVersionsStub = sinon.stub(cmdInstance, 'removeOldVersions').resolves();
const linkStub = sinon.stub(cmdInstance, 'link').resolves();
const linkDefaultThemesStub = sinon.stub(cmdInstance, 'linkDefaultThemes').resolves();

await cmdInstance.run({version: '2.0.1', force: false, zip: '', v1: false, restart: false});
expect(runCommandStub.calledTwice).to.be.true;
Expand All @@ -246,6 +247,7 @@ describe('Unit: Commands > Update', function () {
expect(ui.listr.calledOnce).to.be.true;
expect(removeOldVersionsStub.calledOnce).to.be.true;
expect(linkStub.calledOnce).to.be.true;
expect(linkDefaultThemesStub.calledOnce).to.be.true;
expect(downloadStub.calledOnce).to.be.true;
expect(fakeInstance.isRunning.calledOnce).to.be.true;
expect(fakeInstance.stop.calledOnce).to.be.true;
Expand Down Expand Up @@ -277,6 +279,7 @@ describe('Unit: Commands > Update', function () {
ghostConfig.get.withArgs('database').returns({
client: 'sqlite3'
});
ghostConfig.get.withArgs('paths.contentPath').returns('/content/themes');

const ui = {log: sinon.stub(), listr: sinon.stub(), run: sinon.stub()};
const system = {getInstance: sinon.stub()};
Expand Down Expand Up @@ -468,6 +471,7 @@ describe('Unit: Commands > Update', function () {
const cmdInstance = new UpdateCommand(ui, system);
const versionStub = sinon.stub(cmdInstance, 'version').resolves(true);
const linkStub = sinon.stub(cmdInstance, 'link').resolves();
sinon.stub(cmdInstance, 'linkDefaultThemes').resolves();
sinon.stub(process, 'cwd').returns(fakeInstance.dir);
const downloadStub = sinon.stub(cmdInstance, 'downloadAndUpdate');
const removeOldVersionsStub = sinon.stub(cmdInstance, 'removeOldVersions');
Expand Down Expand Up @@ -533,6 +537,7 @@ describe('Unit: Commands > Update', function () {
const downloadStub = sinon.stub(cmdInstance, 'downloadAndUpdate');
const removeOldVersionsStub = sinon.stub(cmdInstance, 'removeOldVersions');
const runCommandStub = sinon.stub(cmdInstance, 'runCommand').resolves();
sinon.stub(cmdInstance, 'linkDefaultThemes').resolves();

await cmdInstance.run({rollback: true, force: false, zip: '', restart: true, v1: true});
const expectedCtx = {
Expand Down Expand Up @@ -593,7 +598,9 @@ describe('Unit: Commands > Update', function () {
fakeInstance.start.resolves();
const cmdInstance = new UpdateCommand(ui, system);
const versionStub = sinon.stub(cmdInstance, 'version').resolves(true);

sinon.stub(cmdInstance, 'link').resolves();
sinon.stub(cmdInstance, 'linkDefaultThemes').resolves();
sinon.stub(process, 'cwd').returns(fakeInstance.dir);
sinon.stub(cmdInstance, 'downloadAndUpdate');
sinon.stub(cmdInstance, 'removeOldVersions');
Expand Down Expand Up @@ -1069,4 +1076,62 @@ describe('Unit: Commands > Update', function () {
expect(instance.nodeVersion).to.equal(process.versions.node);
});
});

describe('linkDefaultThemes', function () {
const UpdateCommand = require(modulePath);

it('links all default themes bundled with Ghost', function () {
const command = new UpdateCommand({}, {});
const envCfg = {
dirs: ['versions/5.62.0', 'versions/5.67.0', 'versions/5.67.0/content/themes/source', 'versions/5.67.0/content/themes/casper', 'content/themes'],
links: [['versions/5.62.0', 'current']]
};
const env = setupTestFolder(envCfg);
sinon.stub(process, 'cwd').returns(env.dir);
const instance = {
version: '5.62.0',
config: {
get: sinon.stub().withArgs('paths.contentPath').returns(path.join(env.dir, 'content'))
}
};
const context = {
installPath: path.join(env.dir, 'versions/5.67.0'),
version: '5.67.0',
rollback: false,
instance
};

command.link(context);
command.linkDefaultThemes(context);
expect(fs.readlinkSync(path.join(env.dir, 'content', 'themes', 'source'))).to.equal(path.join(env.dir, 'current', 'content', 'themes', 'source'));
expect(fs.readlinkSync(path.join(env.dir, 'content', 'themes', 'casper'))).to.equal(path.join(env.dir, 'current', 'content', 'themes', 'casper'));
});

it('removes invalid symlinks when rolling back', function () {
const command = new UpdateCommand({}, {});
const envCfg = {
dirs: ['versions/5.62.0', 'versions/5.67.0', 'versions/5.62.0/content/themes/casper', 'versions/5.67.0/content/themes/source', 'versions/5.67.0/content/themes/casper', 'content/themes'],
links: [['versions/5.67.0', 'current']]
};
const env = setupTestFolder(envCfg);
sinon.stub(process, 'cwd').returns(env.dir);
const instance = {
version: '5.67.0',
config: {
get: sinon.stub().withArgs('paths.contentPath').returns(path.join(env.dir, 'content'))
}
};
const context = {
installPath: path.join(env.dir, 'versions/5.62.0'),
version: '5.62.0',
rollback: true,
instance
};

command.link(context);
command.linkDefaultThemes(context);
expect(fs.existsSync(path.join(env.dir, 'content', 'themes', 'source'))).to.equal(false);
expect(fs.readlinkSync(path.join(env.dir, 'content', 'themes', 'casper'))).to.equal(path.join(env.dir, 'current', 'content', 'themes', 'casper'));
});
});
});

0 comments on commit 064f6b6

Please sign in to comment.